Epic 6: Stories 6-2 (Markdown roundtrip) + 6-3 (Brainstorm PPTX + Canvas)
Story 6-2 — Markdown roundtrip export/import: - lib/editor/markdown-export.ts: tiptapHTMLToMarkdown, markdownToHTML, looksLikeMarkdown - lib/editor/markdown-paste-extension.ts: TipTap extension paste Markdown → blocs - note-editor-toolbar.tsx: export .md + import .md (file picker) - rich-text-editor.tsx: intégration MarkdownPasteExtension - 40 tests unitaires markdown-export.test.ts Story 6-3 — Brainstorm PPTX + Canvas: - lib/brainstorm/export-pptx.ts: génération PPTX 5 slides (pptxgenjs) - app/api/brainstorm/[sessionId]/export-pptx/route.ts: route POST protégée - brainstorm-page.tsx: bouton PPTX, auto-select session, fix emoji, fix router.replace - wave-canvas.tsx: fitTrigger recentrage, légende bas-droite Onboarding activation wizard (Story 6-1): - components/onboarding/: wizard multi-étapes, hints éditeur - app/api/onboarding/: route PATCH onboarding - prisma/migrations: champs onboarding user Locales: 15 langues mises à jour (brainstorm, markdown, onboarding keys) Sprint: 6-1 done, 6-2 review, 6-3 review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
182
docs/6-3-brainstorm-canvas-finalize.md
Normal file
182
docs/6-3-brainstorm-canvas-finalize.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Story: Brainstorm Canvas — Finalisation (PPTX export + UX Canvas)
|
||||
|
||||
> **Epic:** Epic 6 — Croissance & Activation (PLG)
|
||||
> **ID:** 6-3-brainstorm-canvas-finalize
|
||||
> **Priority:** High
|
||||
> **Status:** review
|
||||
> **Depends on:** `pptxgenjs@^4.0.1` (already in `package.json` ✅), `lib/ai/tools/pptx.tool.ts` (patterns)
|
||||
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
Le brainstorm canvas est quasi-complet (WaveCanvas D3, collaboration temps réel, export en note Markdown, finalize session). Il manque deux choses pour un produit fini :
|
||||
|
||||
1. **Export PPTX** (FR12) : génère une présentation branded depuis la session brainstorm — route API serveur `/api/brainstorm/[sessionId]/export-pptx` + bouton dans le modal "Export/Résumé" existant.
|
||||
2. **UX Canvas** : légende des vagues (couleurs Wave 1/2/3) + bouton "Fit to screen" (re-center).
|
||||
|
||||
`pptxgenjs@^4.0.1` est déjà installé. `lib/ai/tools/pptx.tool.ts` fournit les patterns d'utilisation (lazy import, helpers, thèmes).
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
### US-BRAINSTORM-PPTX : Export PPTX
|
||||
|
||||
**En tant qu'** utilisateur,
|
||||
**Je veux** télécharger ma session brainstorm en fichier `.pptx`,
|
||||
**Afin de** la présenter ou la partager en dehors de l'application.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
- [x] **AC-1** : Un bouton "Télécharger en PPTX" est visible dans le modal "Résumé/Export" du brainstorm
|
||||
- [x] **AC-2** : Au clic, un fichier `brainstorm-{seedIdea-slug}.pptx` est téléchargé via le navigateur
|
||||
- [x] **AC-3** : Le PPTX contient : slide couverture (titre, seedIdea, date, stats), une slide par vague active (Wave 1/2/3 avec idées), slide "Top idées" (starred + converted), slide bilan
|
||||
- [x] **AC-4** : Les idées dismissées ne sont pas incluses
|
||||
- [x] **AC-5** : Le thème utilise les couleurs de l'app (brand-accent `#A47148`, fond clair)
|
||||
- [x] **AC-6** : La route est protégée (auth + participant check)
|
||||
|
||||
### US-BRAINSTORM-CANVAS-UX : UX Canvas
|
||||
|
||||
**En tant qu'** utilisateur,
|
||||
**Je veux** comprendre les codes couleurs du canvas et recentrer la vue,
|
||||
**Afin de** naviguer efficacement dans la session.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
- [x] **AC-7** : Une légende compacte est visible en bas-gauche du canvas (Wave 1 🟠, Wave 2 🔵, Wave 3 🟣, ✓ Converti, ✦ IA, initiale Humain)
|
||||
- [x] **AC-8** : Un bouton "Recentrer" (⊙) est visible sur le canvas et recentre la vue sur le nœud racine
|
||||
|
||||
---
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
### T1 — Route API export PPTX
|
||||
|
||||
- [x] T1.1 — Créer `app/api/brainstorm/[sessionId]/export-pptx/route.ts`
|
||||
- Auth + participant check (réutiliser `verifyParticipant`)
|
||||
- Charger la session avec les idées (non-dismissed)
|
||||
- Générer le PPTX via `pptxgenjs` (lazy import pattern de `pptx.tool.ts`)
|
||||
- Retourner le buffer en `application/vnd.openxmlformats-officedocument.presentationml.presentation`
|
||||
- Headers: `Content-Disposition: attachment; filename="brainstorm-{slug}.pptx"`
|
||||
|
||||
### T2 — Lib helper `lib/brainstorm/export-pptx.ts`
|
||||
|
||||
- [x] T2.1 — Créer `lib/brainstorm/export-pptx.ts` avec `generateBrainstormPptx(session): Promise<Buffer>`
|
||||
- Thème "architectural_mono" (`bg: F2F0E9, primary: 1C1C1C, accent: A47148`) — cohérent avec l'app
|
||||
- Slide 0 : Cover — titre "Brainstorm", seedIdea en sous-titre, date, stats (N idées, M converties)
|
||||
- Slide 1-3 : Une slide par vague active — titre "Wave N — {label}", liste des idées (titre + description courte)
|
||||
- Slide finale : "Top idées" — starred ⭐ et converties ✓ — max 6 items
|
||||
- Idées dismissed : exclues
|
||||
|
||||
### T3 — Bouton PPTX dans le modal export
|
||||
|
||||
- [x] T3.1 — Dans `brainstorm-page.tsx`, ajouter un bouton "Télécharger PPTX" dans le modal de résumé (`summaryOpen`)
|
||||
- Fetch `POST /api/brainstorm/{sessionId}/export-pptx` → blob download
|
||||
- Loading state + toast succès/erreur
|
||||
- i18n key `brainstorm.downloadPptx`
|
||||
|
||||
### T4 — UX Canvas : légende + recentrer
|
||||
|
||||
- [x] T4.1 — Dans `wave-canvas.tsx`, ajouter une légende compacte (overlay bas-droit, au-dessus du hint double-click)
|
||||
- 4 entrées : Wave 1 🟠, Wave 2 🔵, Wave 3 🟣 + ✓ Converti
|
||||
- Style minimaliste, fond semi-transparent
|
||||
- [x] T4.2 — Exposer une ref/méthode `fitToScreen()` ou callback `onFitToScreen` depuis `WaveCanvas`
|
||||
- Re-applique `zoom.transform` vers `d3.zoomIdentity.translate(centerX, centerY).scale(0.8)`
|
||||
- [x] T4.3 — Dans `brainstorm-page.tsx`, ajouter un bouton ⊙ "Recentrer" dans les contrôles canvas
|
||||
- Appelle `fitToScreen()`
|
||||
- i18n key `brainstorm.fitToScreen`
|
||||
|
||||
### T5 — i18n (15 locales)
|
||||
|
||||
- [x] T5.1 — Ajouter dans `locales/en.json` et `locales/fr.json` :
|
||||
- `brainstorm.downloadPptx`, `brainstorm.downloadPptxDesc`, `brainstorm.pptxSuccess`, `brainstorm.pptxError`, `brainstorm.fitToScreen`
|
||||
- [x] T5.2 — Propager dans les 13 autres locales (valeur EN par défaut)
|
||||
|
||||
---
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Architecture
|
||||
|
||||
**Route API (`'use server'` implicite via Next.js route handler) :**
|
||||
```typescript
|
||||
// app/api/brainstorm/[sessionId]/export-pptx/route.ts
|
||||
export async function POST(req, { params }) {
|
||||
// auth + verifyParticipant
|
||||
// load session + ideas (status !== 'dismissed')
|
||||
// generateBrainstormPptx(session) → Buffer
|
||||
// return new Response(buffer, { headers: { 'Content-Type': 'application/vnd.openxmlformats...', 'Content-Disposition': 'attachment; filename=...' } })
|
||||
}
|
||||
```
|
||||
|
||||
**Lazy import pptxgenjs (pattern depuis `pptx.tool.ts`) :**
|
||||
```typescript
|
||||
let _PptxGenJS: (new () => PptxGenJSModule) | null = null
|
||||
async function getPptxGenClass() {
|
||||
if (!_PptxGenJS) {
|
||||
const mod = await import('pptxgenjs')
|
||||
_PptxGenJS = (mod.default ?? mod) as unknown as new () => PptxGenJSModule
|
||||
}
|
||||
return _PptxGenJS
|
||||
}
|
||||
```
|
||||
|
||||
**Client download depuis brainstorm-page.tsx :**
|
||||
```typescript
|
||||
const res = await fetch(`/api/brainstorm/${sessionId}/export-pptx`, { method: 'POST' })
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `brainstorm-${slug}.pptx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
```
|
||||
|
||||
**WaveCanvas fit-to-screen :**
|
||||
- Exposer via `useImperativeHandle` + `forwardRef` un objet `{ fitToScreen: () => void }`
|
||||
- Ou plus simple : passer une prop `fitTrigger: number` (increment → re-zoom)
|
||||
|
||||
### Thème PPTX
|
||||
|
||||
Cohérent avec l'identité visuelle Momento :
|
||||
- `bg: F2F0E9` — fond papier
|
||||
- `primary: 1C1C1C` — noir ardoise
|
||||
- `accent: A47148` — brand-accent Momento
|
||||
- `secondary: D4A373` — ocre clair
|
||||
|
||||
### Fichiers clés existants
|
||||
- `memento-note/lib/ai/tools/pptx.tool.ts` — référence pour patterns pptxgenjs
|
||||
- `memento-note/components/brainstorm/wave-canvas.tsx` — canvas D3
|
||||
- `memento-note/components/brainstorm/brainstorm-page.tsx` — page principale
|
||||
- `memento-note/app/api/brainstorm/[sessionId]/export/route.ts` — export Markdown (référence)
|
||||
- `memento-note/lib/brainstorm-collab.ts` — `verifyParticipant`
|
||||
|
||||
---
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
_À compléter par l'agent dev_
|
||||
|
||||
### Debug Log
|
||||
|
||||
_À compléter_
|
||||
|
||||
### Completion Notes
|
||||
|
||||
_À compléter_
|
||||
|
||||
---
|
||||
|
||||
## File List
|
||||
|
||||
_À compléter_
|
||||
|
||||
---
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Description |
|
||||
|------|-------------|
|
||||
| 2026-05-29 | Story créée — 6-3 brainstorm canvas finalize |
|
||||
223
docs/brief-markdown-roundtrip.md
Normal file
223
docs/brief-markdown-roundtrip.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Brief Technique : US-EDITOR-MARKDOWN — Round-Trip TipTap ↔ Markdown
|
||||
|
||||
> **ID:** US-EDITOR-MARKDOWN
|
||||
> **Priorité:** Beta blocker (confirmé)
|
||||
> **Dépendances:** Rich-text editor TipTap existant (`rich-text-editor.tsx`)
|
||||
> **Complexité estimée:** Moyenne (2–5 jours selon approche choisie)
|
||||
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
TipTap/ProseMirror stocke le contenu en **JSON natif** (format ProseMirror Doc). Actuellement :
|
||||
- L'éditeur **lit et écrit du JSON** en base de données
|
||||
- `marked`, `react-markdown`, `remark-gfm` sont installés mais utilisés uniquement pour **l'affichage** de contenu Markdown externe (Web Clipper, import)
|
||||
- Il **n'existe aucune sérialisation TipTap → Markdown** ni **Markdown → TipTap** dans le code actuel
|
||||
|
||||
L'objectif est un **round-trip fidèle** :
|
||||
```
|
||||
Note TipTap (JSON) → Export Markdown → Re-import → JSON identique (byte-for-byte sur les éléments supportés)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Analyse des Options
|
||||
|
||||
### Option A — `@tiptap/extension-markdown` (recommandé ✅)
|
||||
|
||||
**Package :** `@tiptap/extension-markdown` (officiel TipTap, payant pour certaines extensions avancées — vérifier licence)
|
||||
|
||||
**Principe :**
|
||||
- Extension TipTap officielle qui ajoute une méthode `.storage.markdown.getMarkdown()` sur l'éditeur
|
||||
- Import Markdown via `editor.commands.setContent(markdownString, { parseOptions: { markdown: true } })`
|
||||
- Supporte GFM (GitHub Flavored Markdown) : tableaux, listes de tâches, code fences
|
||||
|
||||
**Avantages :**
|
||||
- Intégration native TipTap — zéro friction
|
||||
- Maintenu par l'équipe TipTap
|
||||
- Support des nœuds custom via `markdownSerializer` sur chaque extension
|
||||
|
||||
**Inconvénients :**
|
||||
- Certaines extensions sont sous licence TipTap Pro ($149/mois)
|
||||
- Les nœuds custom de Momento (`liveBlock`, `structuredViewBlock`) nécessitent des serializers manuels
|
||||
- Round-trip parfait impossible pour ces nœuds (dégradation gracieuse : placeholder HTML comment)
|
||||
|
||||
**Implémentation :**
|
||||
```typescript
|
||||
// Installation
|
||||
npm install @tiptap/extension-markdown
|
||||
|
||||
// Dans rich-text-editor.tsx
|
||||
import { Markdown } from '@tiptap/extension-markdown'
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
// ... extensions existantes ...
|
||||
Markdown.configure({
|
||||
html: false, // Désactiver HTML brut dans le MD
|
||||
tightLists: true, // Listes compactes
|
||||
tightListClass: 'tight',
|
||||
bulletListMarker: '-',
|
||||
linkify: false,
|
||||
breaks: false,
|
||||
transformPastedText: true, // Coller du Markdown → conversion auto
|
||||
transformCopiedText: false,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
// Export
|
||||
const markdown = editor.storage.markdown.getMarkdown()
|
||||
|
||||
// Import
|
||||
editor.commands.setContent(markdownString)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Option B — `prosemirror-markdown` (alternative robuste ✅)
|
||||
|
||||
**Package :** `prosemirror-markdown` (officiel ProseMirror)
|
||||
|
||||
**Principe :**
|
||||
- Bibliothèque bas niveau qui fournit un `MarkdownSerializer` et un `MarkdownParser`
|
||||
- S'intègre à TipTap via le schéma ProseMirror sous-jacent
|
||||
- Utilise `remark` (déjà installé) pour le parsing
|
||||
|
||||
**Avantages :**
|
||||
- Open source, pas de licence Pro
|
||||
- Contrôle total du serializer (chaque nœud est défini explicitement)
|
||||
- Utilisé en production par de nombreux éditeurs (GitLab, Linear)
|
||||
|
||||
**Inconvénients :**
|
||||
- Plus verbeux — chaque extension TipTap nécessite son `toMarkdown` et `fromMarkdown`
|
||||
- Effort initial plus élevé (~2 jours de mapping)
|
||||
- À maintenir à chaque nouvelle extension ajoutée
|
||||
|
||||
**Implémentation :**
|
||||
```typescript
|
||||
import { MarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown'
|
||||
import { MarkdownParser } from 'prosemirror-markdown'
|
||||
import markdownit from 'markdown-it'
|
||||
|
||||
// Serializer — mapper chaque nœud TipTap
|
||||
const momentoSerializer = new MarkdownSerializer(
|
||||
{
|
||||
...defaultMarkdownSerializer.nodes,
|
||||
// Nœuds Momento custom
|
||||
liveBlock: (state, node) => {
|
||||
state.write(`<!-- live-block: ${node.attrs.sourceNoteId}#${node.attrs.blockId} -->`)
|
||||
state.closeBlock(node)
|
||||
},
|
||||
structuredViewBlock: (state, node) => {
|
||||
state.write(`<!-- structured-view: ${JSON.stringify(node.attrs)} -->`)
|
||||
state.closeBlock(node)
|
||||
},
|
||||
},
|
||||
defaultMarkdownSerializer.marks
|
||||
)
|
||||
|
||||
// Export
|
||||
const markdown = momentoSerializer.serialize(editor.state.doc)
|
||||
|
||||
// Parser
|
||||
const md = markdownit('commonmark', { html: true })
|
||||
const parser = new MarkdownParser(editor.schema, md, { /* token map */ })
|
||||
const doc = parser.parse(markdownString)
|
||||
editor.commands.setContent(doc.toJSON())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Option C — `Milkdown` (remplacement complet ❌ déconseillé)
|
||||
|
||||
**Milkdown** est un éditeur Markdown-first qui remplace TipTap/ProseMirror. Le migrer vers Milkdown signifie :
|
||||
- Réécriture complète de `rich-text-editor.tsx` (~800 lignes)
|
||||
- Perte de toutes les extensions custom (Living Blocks, Structured Views, Smart Paste, etc.)
|
||||
- Délai estimé : 2-4 semaines
|
||||
|
||||
**Verdict : ❌ Trop risqué, trop long pour la beta.**
|
||||
|
||||
---
|
||||
|
||||
## Recommandation
|
||||
|
||||
### Court terme (beta) : **Option A** (`@tiptap/extension-markdown`)
|
||||
|
||||
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)
|
||||
|
||||
### Long terme : **Option B** en complément
|
||||
|
||||
Pour les cas avancés (export propre des nœuds custom, CI de round-trip byte-for-byte), implémenter Option B en remplacement d'Option A une fois la beta stabilisée.
|
||||
|
||||
---
|
||||
|
||||
## Scope de l'US-EDITOR-MARKDOWN (Beta)
|
||||
|
||||
### Ce qui est inclus
|
||||
|
||||
| Feature | Priorité |
|
||||
|---------|----------|
|
||||
| Export note → fichier `.md` téléchargeable | 🔴 P0 |
|
||||
| Coller du Markdown → conversion auto en blocs TipTap | 🔴 P0 |
|
||||
| Import note depuis fichier `.md` | 🟡 P1 |
|
||||
| Copy note as Markdown (dans le presse-papier) | 🟡 P1 |
|
||||
| Round-trip fidèle pour : headings, bold, italic, lists, tasks, code, blockquote, links, tables | 🔴 P0 |
|
||||
| Dégradation gracieuse pour `liveBlock` et `structuredViewBlock` (commentaire HTML) | 🟡 P1 |
|
||||
|
||||
### Ce qui est exclu (post-beta)
|
||||
|
||||
- Round-trip byte-for-byte des nœuds Momento custom
|
||||
- Édition native en mode source Markdown (raw text editor)
|
||||
- Sync bidirectionnelle temps réel Markdown ↔ TipTap
|
||||
|
||||
---
|
||||
|
||||
## Fichiers à créer / modifier
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `memento-note/package.json` | Ajouter `@tiptap/extension-markdown` |
|
||||
| `components/rich-text-editor.tsx` | Ajouter extension `Markdown` à la config TipTap |
|
||||
| `lib/editor/markdown-export.ts` | Créer — helper `tiptapDocToMarkdown(doc)` et `markdownToTiptapDoc(md)` |
|
||||
| `components/note-actions.tsx` | Ajouter action "Exporter en Markdown" |
|
||||
| `app/api/notes/[id]/export/route.ts` | Créer — `GET /api/notes/:id/export?format=markdown` |
|
||||
| `locales/en.json` + `fr.json` | Ajouter clés `editor.exportMarkdown`, `editor.importMarkdown`, `editor.pasteMarkdown` |
|
||||
|
||||
---
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
**Given** j'ai une note avec du contenu riche (headings, listes, code, tasks)
|
||||
**When** je clique "Exporter en Markdown"
|
||||
**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
|
||||
**Then** le Markdown est automatiquement converti en blocs TipTap correspondants (pas du texte brut)
|
||||
|
||||
**Given** j'importe un fichier `.md`
|
||||
**When** le parsing est terminé
|
||||
**Then** les headings/listes/code/tables sont correctement représentés dans l'éditeur
|
||||
|
||||
**Given** ma note contient un `liveBlock` (bloc vivant)
|
||||
**When** j'exporte en Markdown
|
||||
**Then** le bloc vivant est exporté en commentaire HTML `<!-- live-block: ... -->` sans erreur
|
||||
|
||||
---
|
||||
|
||||
## Estimation
|
||||
|
||||
| Tâche | Durée estimée |
|
||||
|-------|--------------|
|
||||
| Installation + config `@tiptap/extension-markdown` | 2h |
|
||||
| Helper `lib/editor/markdown-export.ts` | 2h |
|
||||
| Route API export + action UI | 2h |
|
||||
| Import fichier `.md` via modal | 3h |
|
||||
| Tests manuels round-trip + fix edge cases | 4h |
|
||||
| i18n (EN + FR) | 1h |
|
||||
| **Total** | **~2 jours** |
|
||||
@@ -61,4 +61,13 @@ development_status:
|
||||
epic-5: in-progress
|
||||
5-1-nextgen-editor: done
|
||||
|
||||
# Epic 6 — Croissance & Activation (PLG) — ajouté 2026-05-29
|
||||
epic-6: in-progress
|
||||
6-1-onboarding-activation: done # story-onboarding-activation.md
|
||||
6-2-markdown-roundtrip: review # brief-markdown-roundtrip.md
|
||||
6-3-brainstorm-canvas-finalize: review # story: 6-3-brainstorm-canvas-finalize.md
|
||||
6-4-chat-with-pdf: backlog
|
||||
6-5-pptx-export-watermark: backlog
|
||||
epic-6-retrospective: optional
|
||||
|
||||
|
||||
|
||||
239
docs/story-markdown-roundtrip.md
Normal file
239
docs/story-markdown-roundtrip.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Story: Markdown Round-Trip — Export & Import TipTap ↔ Markdown
|
||||
|
||||
> **Epic:** Epic 6 — Croissance & Activation (PLG)
|
||||
> **ID:** 6-2-markdown-roundtrip
|
||||
> **Priority:** Beta Blocker
|
||||
> **Status:** review
|
||||
> **Depends on:** Rich-text editor TipTap (`rich-text-editor.tsx` ✅)
|
||||
> **Ref brief:** `docs/brief-markdown-roundtrip.md`
|
||||
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
TipTap/ProseMirror stocke le contenu en JSON natif. Il n'existe **aucune sérialisation TipTap → Markdown** ni **Markdown → TipTap** dans le code actuel.
|
||||
|
||||
`@tiptap/extension-markdown` n'est pas disponible sur npm public (extension TipTap Pro). L'approche choisie utilise :
|
||||
- **`turndown`** : HTML → Markdown (export)
|
||||
- **`marked`** : Markdown → HTML → TipTap parse (import + paste)
|
||||
|
||||
Ces deux librairies sont déjà dans le registre npm. `react-markdown` est déjà installé mais sert uniquement au rendu. `marked` est disponible (`18.x`).
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
### US-MARKDOWN-1 : Export Markdown
|
||||
|
||||
**En tant qu'** utilisateur,
|
||||
**Je veux** exporter ma note en fichier `.md`,
|
||||
**Afin de** la réutiliser dans d'autres outils (Obsidian, VS Code, GitHub, etc.).
|
||||
|
||||
#### Critères d'acceptation :
|
||||
- [x] **AC-1** : Un item "Exporter en Markdown" est visible dans le menu `…` de la note
|
||||
- [x] **AC-2** : Au clic, un fichier `<titre-note>.md` est téléchargé dans le navigateur
|
||||
- [x] **AC-3** : Le fichier contient : titre en `# H1`, headings, bold, italic, listes, tâches (`- [x]`), code inline/fenced, blockquotes, liens, tables
|
||||
- [x] **AC-4** : Un `liveBlock` est exporté en commentaire HTML `<!-- live-block: sourceNoteId#blockId -->`
|
||||
- [x] **AC-5** : Un `structuredViewBlock` est exporté en commentaire HTML `<!-- structured-view: {...attrs} -->`
|
||||
|
||||
---
|
||||
|
||||
### US-MARKDOWN-2 : Paste Markdown → blocs TipTap
|
||||
|
||||
**En tant qu'** utilisateur,
|
||||
**Je veux** coller du Markdown dans l'éditeur et obtenir des blocs TipTap,
|
||||
**Afin de** ne pas avoir à reformater manuellement.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
- [x] **AC-6** : Coller un texte qui commence par `#`, `##`, `- `, `* `, `1.`, `` ` ``, `>`, `**`, ou `|` déclenche la conversion Markdown → HTML → TipTap
|
||||
- [x] **AC-7** : Un texte normal (sans marqueurs Markdown) est collé tel quel (pas de conversion parasite)
|
||||
- [x] **AC-8** : Les tables Markdown sont converties en tables TipTap
|
||||
- [x] **AC-9** : Les tâches `- [x]` sont converties en `taskItem` cochés
|
||||
|
||||
---
|
||||
|
||||
### US-MARKDOWN-3 : Import fichier `.md`
|
||||
|
||||
**En tant qu'** utilisateur,
|
||||
**Je veux** importer un fichier `.md` dans une nouvelle note (ou écraser le contenu),
|
||||
**Afin de** migrer du contenu depuis d'autres outils.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
- [x] **AC-10** : Un item "Importer Markdown" est visible dans le menu `…` de la note (ou via un bouton dédié)
|
||||
- [x] **AC-11** : Au clic, un file picker s'ouvre filtré sur `.md`
|
||||
- [x] **AC-12** : Après sélection, le contenu de la note est **remplacé** par le contenu du fichier parsé en TipTap
|
||||
- [x] **AC-13** : Le titre de la note est mis à jour avec le premier `# H1` du fichier importé (si présent)
|
||||
|
||||
---
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
### T1 — Installation des dépendances
|
||||
|
||||
- [x] T1.1 — Installer `turndown` + `@types/turndown` (HTML → Markdown)
|
||||
- [x] T1.2 — Vérifier que `marked` est déjà disponible ou installer si absent
|
||||
- [x] T1.3 — Vérifier que `@types/marked` est disponible
|
||||
|
||||
### T2 — Créer `lib/editor/markdown-export.ts`
|
||||
|
||||
- [x] T2.1 — Implémenter `tiptapHTMLToMarkdown(html: string): string` via `turndown`
|
||||
- Règle custom `liveBlock` : div avec `data-live-block` → commentaire HTML via sentinel + post-process
|
||||
- Règle custom `structuredViewBlock` : div avec `data-structured-view-block` → commentaire HTML via sentinel
|
||||
- GFM tables activé (`turndown-plugin-gfm`)
|
||||
- GFM task lists activé
|
||||
- [x] T2.2 — Implémenter `markdownToHTML(md: string): string` via `marked`
|
||||
- GFM activé, tables activées
|
||||
- Retourner HTML propre pour injection TipTap via `editor.commands.setContent(html)`
|
||||
- [x] T2.3 — Implémenter `looksLikeMarkdown(text: string): boolean`
|
||||
- Détecte si un texte collé contient des marqueurs Markdown (heuristique)
|
||||
- Regex : `#`, `- `, `* `, `1. `, `>`, ` ``` `, `**`, `_`, `|`, `[...](...)`
|
||||
|
||||
### T3 — Route API export Markdown
|
||||
|
||||
- [x] T3.1 — Implémenté côté client dans `note-editor-toolbar.tsx` (pas de route serveur — choix UX : utilise l'état live de l'éditeur)
|
||||
- [x] T3.2 — Titre en `# <note.title>` préfixé dans le fichier `.md` exporté
|
||||
|
||||
### T4 — Action UI Export dans `note-editor-toolbar.tsx`
|
||||
|
||||
- [x] T4.1 — Ajouter `DropdownMenuItem` "Exporter en Markdown" dans le menu `…` (avec icône `FileDown`)
|
||||
- [x] T4.2 — Au clic : `editor.getHTML()` → `tiptapHTMLToMarkdown()` → `Blob` → download via URL.createObjectURL
|
||||
- [x] T4.3 — Toast de succès/erreur
|
||||
|
||||
### T5 — Paste Markdown dans l'éditeur
|
||||
|
||||
- [x] T5.1 — Créer extension TipTap `MarkdownPasteExtension` dans `lib/editor/markdown-paste-extension.ts`
|
||||
- Hook sur `handlePaste` : si `looksLikeMarkdown(text)` → `markdownToHTML(text)` → `editor.commands.insertContent`
|
||||
- `setTimeout(0)` pour éviter conflits de transactions
|
||||
- [x] T5.2 — Intégrer `MarkdownPasteExtension` dans `extensions[]` de `rich-text-editor.tsx`
|
||||
|
||||
### T6 — Import fichier `.md` dans `note-editor-toolbar.tsx`
|
||||
|
||||
- [x] T6.1 — Ajouter `DropdownMenuItem` "Importer Markdown" dans le menu `…` (icône `FileUp`)
|
||||
- [x] T6.2 — Input `<input type="file" accept=".md">` caché avec `mdImportInputRef`
|
||||
- [x] T6.3 — Handler `handleImportMarkdownFile` : lire fichier → `markdownToHTML()` → `actions.setContent(html)`
|
||||
- [x] T6.4 — Titre mis à jour via `extractMarkdownTitle()` + `actions.setTitle()`
|
||||
|
||||
### T7 — i18n (15 locales)
|
||||
|
||||
- [x] T7.1 — Ajout clés dans `locales/en.json` et `locales/fr.json` (namespace `richTextEditor`)
|
||||
- `exportMarkdown`, `importMarkdown`, `markdownExportSuccess`, `markdownExportError`, `markdownImportSuccess`
|
||||
- [x] T7.2 — Clés ajoutées dans les 13 autres locales (de, es, it, pt, nl, pl, ru, zh, ja, ko, ar, fa, hi)
|
||||
|
||||
### T8 — Tests unitaires
|
||||
|
||||
- [x] T8.1 — Tests `lib/editor/markdown-export.ts` : 40 tests — heading, bold, italic, list, code, blockquote, link, table, liveBlock, svBlock
|
||||
- [x] T8.2 — Tests `looksLikeMarkdown` : 12 cas positifs + négatifs
|
||||
|
||||
---
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Approche technique
|
||||
|
||||
**Pourquoi pas `@tiptap/extension-markdown` ?**
|
||||
Non disponible sur npm public (`404 Not Found`). C'est une extension TipTap Pro.
|
||||
|
||||
**Approche choisie : `turndown` + `marked`**
|
||||
- `turndown` (7.x) : battle-tested, supporte les plugins GFM (tables, task lists via `turndown-plugin-gfm`)
|
||||
- `marked` (18.x) : fast, supporte GFM tables/tasks nativement
|
||||
|
||||
**Export côté serveur vs client :**
|
||||
L'export via route API `/api/notes/[id]/export?format=markdown` permet de ne pas dépendre de l'état de l'éditeur côté client — fonctionne même si l'éditeur est fermé.
|
||||
|
||||
**TipTap `generateHTML` côté serveur :**
|
||||
```typescript
|
||||
import { generateHTML } from '@tiptap/html'
|
||||
```
|
||||
Nécessite d'importer toutes les extensions utilisées. Une liste partagée dans `lib/editor/tiptap-extensions-server.ts` simplifie la maintenance.
|
||||
|
||||
**MarkdownPasteExtension :**
|
||||
Extension légère qui s'insère dans la chaîne de `handlePaste`. Si le texte collé contient des marqueurs Markdown, on le convertit avant insertion. Sinon, on laisse TipTap gérer normalement.
|
||||
|
||||
**Custom nodes HTML output :**
|
||||
- `liveBlock` génère `<div data-type="live-block" data-source-note-id="..." data-block-id="..."></div>` dans `getHTML()`
|
||||
- `structuredViewBlock` génère `<div data-type="structured-view-block" ...></div>`
|
||||
- `turndown` peut détecter ces divs par `data-type` et les convertir en commentaires HTML
|
||||
|
||||
**Import titre :**
|
||||
Regex sur la première ligne du fichier `.md` : `^#\s+(.+)` → titre. Passé en callback au parent.
|
||||
|
||||
### Fichiers clés existants
|
||||
- `memento-note/components/rich-text-editor.tsx` — éditeur principal
|
||||
- `memento-note/components/note-actions.tsx` — menu actions note (dropdown `…`)
|
||||
- `memento-note/lib/editor/` — extensions TipTap existantes
|
||||
- `memento-note/locales/` — 15 fichiers JSON i18n
|
||||
|
||||
### Patterns importants
|
||||
- Actions dans le menu `…` : `DropdownMenuItem` avec icône Lucide + `t('key')` i18n
|
||||
- Download client-side : `URL.createObjectURL(blob)` + clic programmatique + `URL.revokeObjectURL`
|
||||
- Tests unitaires : `tests/unit/` avec Jest, importé via alias `@/`
|
||||
|
||||
---
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
**Approche finale :** `turndown` + `marked` (l'extension officielle `@tiptap/extension-markdown` est indisponible sur npm public — TipTap Pro uniquement).
|
||||
|
||||
- Export : client-side via `editor.getHTML()` → `turndown` → Blob download (pas de route serveur — utilise l'état live)
|
||||
- Import : `FileReader` + `marked` → `editor.commands.setContent(html)`
|
||||
- Paste : extension ProseMirror `handlePaste` → `looksLikeMarkdown` heuristique → `marked` → `insertContent`
|
||||
- Custom nodes (liveBlock, structuredViewBlock) : pre-processing avec sentinels alphanumériques, post-processing avec commentaires HTML
|
||||
|
||||
### Debug Log
|
||||
|
||||
- **turndown + divs vides** : `turndown` ignore les nœuds "blank" (pas de texte). Solution : pre-process HTML → remplacer divs custom par `<p>MOMENTOBLOCKSNTINELXXX</p>`, post-process le MD résultant pour remplacer par `<!-- comment -->`.
|
||||
- **turndown escape underscores** : le sentinel `__MOMENTO_BLOCK__` est échappé en `\_\_MOMENTO\_BLOCK\_\_`. Solution : utiliser uniquement des caractères alphanumériques (`MOMENTOBLOCKSENTINELLIVEBLOCK0`).
|
||||
- **TS2322 dans toolbar** : `editor.commands.setContent(html, true)` — le 2e argument est `SetContentOptions` pas `boolean` dans TipTap v3. Corrigé.
|
||||
- **Fragment JSX** : `<input type="file">` caché hors du div principal — wrappé dans `<>` fragment.
|
||||
- **handleConvertToRichtext** : le remplacement d'import avait supprimé l'ouverture `const handleConvertToRichtext = async () => {`. Restauré.
|
||||
|
||||
### Completion Notes
|
||||
|
||||
- **T1** ✅ : `turndown@7.2.4` + `turndown-plugin-gfm` + `@types/turndown` installés. `marked@18.0.3` déjà présent.
|
||||
- **T2** ✅ : `lib/editor/markdown-export.ts` — `tiptapHTMLToMarkdown`, `markdownToHTML`, `looksLikeMarkdown`, `extractMarkdownTitle`. Gestion des nœuds custom via sentinels.
|
||||
- **T3** ✅ : Export client-side (pas de route serveur). `richTextEditorRef.current?.getEditor().getHTML()` → markdown → Blob download.
|
||||
- **T4** ✅ : `DropdownMenuItem` "Exporter en Markdown" + "Importer Markdown" dans `note-editor-toolbar.tsx` (menu `…`).
|
||||
- **T5** ✅ : `lib/editor/markdown-paste-extension.ts` créé + intégré dans `rich-text-editor.tsx`.
|
||||
- **T6** ✅ : Import fichier `.md` avec `mdImportInputRef` + `handleImportMarkdownFile` dans toolbar. Titre auto-extrait.
|
||||
- **T7** ✅ : 5 clés dans namespace `richTextEditor` ajoutées à toutes les 15 locales.
|
||||
- **T8** ✅ : 40 tests unit passent (39 sur fonctions, 1 fix pour spacing turndown). Suite complète : 174/174.
|
||||
|
||||
---
|
||||
|
||||
## File List
|
||||
|
||||
- `memento-note/package.json` — ajout `turndown@7.2.4`, `turndown-plugin-gfm`, `@types/turndown`
|
||||
- `memento-note/lib/editor/markdown-export.ts` — **créé** — helpers markdown roundtrip
|
||||
- `memento-note/lib/editor/markdown-paste-extension.ts` — **créé** — extension TipTap paste Markdown
|
||||
- `memento-note/components/note-editor/note-editor-toolbar.tsx` — modifié — items Export/Import Markdown + handlers + import ref
|
||||
- `memento-note/components/rich-text-editor.tsx` — modifié — import + intégration `MarkdownPasteExtension`
|
||||
- `memento-note/types/global.d.ts` — modifié — déclaration de types pour `turndown-plugin-gfm`
|
||||
- `memento-note/locales/en.json` — modifié — 5 clés `richTextEditor.export/importMarkdown*`
|
||||
- `memento-note/locales/fr.json` — modifié — idem
|
||||
- `memento-note/locales/de.json` — modifié — idem
|
||||
- `memento-note/locales/es.json` — modifié — idem
|
||||
- `memento-note/locales/it.json` — modifié — idem
|
||||
- `memento-note/locales/pt.json` — modifié — idem
|
||||
- `memento-note/locales/nl.json` — modifié — idem
|
||||
- `memento-note/locales/pl.json` — modifié — idem
|
||||
- `memento-note/locales/ru.json` — modifié — idem
|
||||
- `memento-note/locales/zh.json` — modifié — idem
|
||||
- `memento-note/locales/ja.json` — modifié — idem
|
||||
- `memento-note/locales/ko.json` — modifié — idem
|
||||
- `memento-note/locales/ar.json` — modifié — idem
|
||||
- `memento-note/locales/fa.json` — modifié — idem
|
||||
- `memento-note/locales/hi.json` — modifié — idem
|
||||
- `memento-note/tests/unit/markdown-export.test.ts` — **créé** — 40 tests unitaires
|
||||
- `docs/story-markdown-roundtrip.md` — **créé** — story complète
|
||||
- `docs/sprint-status.yaml` — modifié — `6-2-markdown-roundtrip: in-progress → review`
|
||||
|
||||
---
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Description |
|
||||
|------|-------------|
|
||||
| 2026-05-30 | Story créée à partir du brief `docs/brief-markdown-roundtrip.md` |
|
||||
| 2026-05-30 | Implémentation complète — T1 à T8 (voir Dev Agent Record) |
|
||||
315
docs/story-onboarding-activation.md
Normal file
315
docs/story-onboarding-activation.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# Story: Onboarding & Activation — Wizard "Aha! Moment"
|
||||
|
||||
> **Epic:** Epic 6 — Croissance & Activation (PLG)
|
||||
> **ID:** US-ONBOARDING
|
||||
> **Priority:** Critical — Beta Blocker
|
||||
> **Status:** done
|
||||
> **Depends on:** Stripe (3.6 ✅), Redis Quotas (3.1 ✅), Semantic Search (existant ✅)
|
||||
> **Blocks:** Toutes les métriques d'activation
|
||||
|
||||
---
|
||||
|
||||
## 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 :
|
||||
|
||||
> *"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 :
|
||||
|
||||
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!")
|
||||
3. Afficher la **progression du Starter Pack** pour créer l'urgence de conversion
|
||||
4. **Ne jamais bloquer** l'utilisateur — skip à tout moment
|
||||
|
||||
**Modèle Prisma actuel :** Le champ `onboardingCompleted` n'existe pas sur `User`. Il faut une migration.
|
||||
|
||||
---
|
||||
|
||||
## Migration Prisma requise
|
||||
|
||||
```prisma
|
||||
model User {
|
||||
// ... champs existants ...
|
||||
onboardingCompleted Boolean @default(false)
|
||||
onboardingStep Int @default(0)
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ Migration **additive uniquement** — safe, pas de perte de données.
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
### US-ONBOARDING-1 : Détection du premier usage
|
||||
|
||||
**En tant que** nouvel utilisateur,
|
||||
**Je veux** être reconnu comme nouveau dès ma première connexion,
|
||||
**Afin de** bénéficier d'une expérience guidée adaptée.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
- **Étant donné** que je viens de créer mon compte (Google OAuth ou email)
|
||||
- **Quand** je me connecte pour la première fois
|
||||
- **Alors** `user.onboardingCompleted === false` est détecté côté serveur
|
||||
- **Et** l'app me redirige vers `/home?onboarding=1` (ou affiche le wizard en overlay)
|
||||
- **Et** si je rafraîchis la page, le wizard réapparaît (tant que `onboardingCompleted === false`)
|
||||
|
||||
---
|
||||
|
||||
### 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,
|
||||
**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."*
|
||||
- CTA : `"Commencer →"` + lien `"Passer l'intro"`
|
||||
|
||||
#### Étape 2 — "Vos notes" (30 secondes)
|
||||
- **Si** l'utilisateur a 0 notes :
|
||||
- Proposer : `"Importer mes notes"` (Markdown/CSV) **ou** `"Créer 5 notes d'exemple"`
|
||||
- Si "notes d'exemple" → insérer 5 notes dans sa langue (voir contenu ci-dessous)
|
||||
- CTA : `"Mes notes sont prêtes →"`
|
||||
- **Si** l'utilisateur a ≥ 1 note :
|
||||
- Afficher : `"Parfait, vous avez déjà X notes ! Découvrons la magie."`
|
||||
- CTA : `"Continuer →"`
|
||||
|
||||
#### Étape 3 — "L'effet Aha!" (60 secondes — le plus important)
|
||||
- Titre : *"Retrouvez ce que vous avez oublié"*
|
||||
- Afficher la barre de recherche sémantique **mise en avant** (highlight animé)
|
||||
- Placer une requête exemple pré-remplie dans la langue détectée :
|
||||
- FR : *"notes sur ma productivité"* | EN : *"notes about productivity"*
|
||||
- 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"`
|
||||
|
||||
#### Critères d'acceptation généraux :
|
||||
- Wizard rendu en overlay (`position: fixed`, z-index élevé) avec fond semi-transparent
|
||||
- Barre de progression `1/3 → 2/3 → 3/3` en haut du wizard
|
||||
- Bouton `"Passer"` (skip) visible à chaque étape → marque `onboardingCompleted = true` immédiatement
|
||||
- Responsive mobile (bottom sheet sur < 768px)
|
||||
- i18n : clés sous `onboarding.*` dans les 15 locales (EN + FR comme référence)
|
||||
- RTL correct pour `fa` et `ar`
|
||||
|
||||
---
|
||||
|
||||
### US-ONBOARDING-3 : Notes d'exemple multilingues
|
||||
|
||||
**En tant que** système,
|
||||
**Je veux** insérer 5 notes d'exemple pertinentes dans la langue de l'utilisateur,
|
||||
**Afin de** permettre immédiatement la démonstration de la recherche sémantique.
|
||||
|
||||
#### Contenu des 5 notes d'exemple (FR) :
|
||||
1. **"Réunion Q3 — Stratégie produit"** — texte sur roadmap, priorités, KPIs
|
||||
2. **"Idées de projets secondaires"** — liste d'idées créatives (app, podcast, etc.)
|
||||
3. **"Livres à lire — Recommandations"** — liste de titres avec résumés courts
|
||||
4. **"Notes de formation React"** — concepts techniques, hooks, bonnes pratiques
|
||||
5. **"Objectifs personnels 2025"** — texte de réflexion sur goals, habitudes
|
||||
|
||||
> Ces notes doivent être **vectorisées automatiquement** à l'insertion (même pipeline que les vraies notes) pour que la recherche sémantique fonctionne immédiatement.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
- Route API : `POST /api/onboarding/seed-demo-notes`
|
||||
- Auth requise (`session.user.id`)
|
||||
- Idempotente : si des notes de démo existent déjà, ne pas re-créer (tag interne `isDemoNote: true` ou champ `isDemo Boolean @default(false)` sur `Note`)
|
||||
- Vectorisation déclenchée immédiatement (pas en background différé)
|
||||
- Les notes d'exemple sont supprimables normalement par l'utilisateur
|
||||
|
||||
---
|
||||
|
||||
### US-ONBOARDING-4 : Indicateur Starter Pack permanent
|
||||
|
||||
**En tant qu'** utilisateur free,
|
||||
**Je veux** voir en permanence combien de crédits IA il me reste,
|
||||
**Afin de** comprendre l'urgence de conversion au bon moment.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
- Composant `<StarterPackBadge />` dans la sidebar (icône ⚡ + `"X crédits restants"`)
|
||||
- Visible uniquement pour les utilisateurs `plan === 'FREE'`
|
||||
- Mis à jour en temps réel après chaque action IA (via mutation React Query + invalidation)
|
||||
- Au passage sous 5 crédits : couleur orange + animation pulse
|
||||
- À 0 crédit : couleur rouge + CTA `"Passer Pro →"` (link vers `/settings/billing`)
|
||||
- Disparaît pour les utilisateurs Pro/Business/Enterprise
|
||||
|
||||
---
|
||||
|
||||
### US-ONBOARDING-5 : Fin de l'onboarding et état persistant
|
||||
|
||||
**En tant que** utilisateur,
|
||||
**Je veux** que le wizard ne réapparaisse jamais après que je l'ai complété ou sauté,
|
||||
**Afin de** ne pas être perturbé lors de mes usages suivants.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
- À la fin de l'étape 3 (ou au clic "Passer") : appel `PATCH /api/users/me` avec `{ onboardingCompleted: true }`
|
||||
- `user.onboardingCompleted` est stocké en DB et inclus dans la session NextAuth
|
||||
- Le wizard ne s'affiche plus jamais après ce flag
|
||||
- Si l'utilisateur recrée un compte avec le même email, le flag est reset
|
||||
|
||||
---
|
||||
|
||||
## Fichiers à créer / modifier
|
||||
|
||||
| Fichier | Action | Notes |
|
||||
|---------|--------|-------|
|
||||
| `prisma/schema.prisma` | Modifier | Ajouter `onboardingCompleted` + `onboardingStep` sur `User` |
|
||||
| `prisma/migrations/...` | Créer | Migration additive (safe) |
|
||||
| `components/onboarding/onboarding-wizard.tsx` | Créer | Composant wizard 3 étapes |
|
||||
| `components/onboarding/onboarding-step-welcome.tsx` | Créer | Étape 1 |
|
||||
| `components/onboarding/onboarding-step-notes.tsx` | Créer | Étape 2 |
|
||||
| `components/onboarding/onboarding-step-aha.tsx` | Créer | Étape 3 (recherche sémantique) |
|
||||
| `components/onboarding/starter-pack-badge.tsx` | Créer | Indicateur crédits sidebar |
|
||||
| `app/api/onboarding/seed-demo-notes/route.ts` | Créer | Insertion notes d'exemple |
|
||||
| `app/api/users/me/route.ts` | Modifier | Ajouter support PATCH `onboardingCompleted` |
|
||||
| `components/providers-wrapper.tsx` | Modifier | Ajouter `<OnboardingWizard />` conditionnel |
|
||||
| `components/sidebar.tsx` | Modifier | Ajouter `<StarterPackBadge />` |
|
||||
| `locales/en.json` + `locales/fr.json` | Modifier | Clés `onboarding.*` + `starterPack.*` |
|
||||
| (autres 13 locales) | Modifier | Traductions onboarding |
|
||||
|
||||
---
|
||||
|
||||
## Clés i18n à créer (EN référence)
|
||||
|
||||
```json
|
||||
{
|
||||
"onboarding": {
|
||||
"welcome_title": "Your AI-augmented memory",
|
||||
"welcome_subtitle": "Momento remembers what you forget.",
|
||||
"welcome_cta": "Get started",
|
||||
"skip": "Skip intro",
|
||||
"step_notes_title": "Your notes",
|
||||
"step_notes_empty": "You have no notes yet. Import yours or start with examples.",
|
||||
"step_notes_import": "Import my notes",
|
||||
"step_notes_demo": "Create 5 example notes",
|
||||
"step_notes_has_notes": "You already have {count} notes. Let's discover the magic.",
|
||||
"step_notes_cta": "My notes are ready",
|
||||
"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",
|
||||
"progress": "{current} of {total}"
|
||||
},
|
||||
"starterPack": {
|
||||
"credits_remaining": "{count} credits left",
|
||||
"almost_empty": "Almost out of credits",
|
||||
"empty": "No credits left",
|
||||
"upgrade_cta": "Go Pro →"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Métriques à tracker (analytics events)
|
||||
|
||||
| Événement | Déclencheur | Propriétés |
|
||||
|-----------|------------|------------|
|
||||
| `onboarding_started` | Wizard affiché | `user_id`, `has_notes` |
|
||||
| `onboarding_step_completed` | Étape validée | `step` (1/2/3), `duration_ms` |
|
||||
| `onboarding_demo_notes_created` | 5 notes insérées | `user_id` |
|
||||
| `onboarding_search_performed` | Recherche étape 3 | `result_count` |
|
||||
| `onboarding_completed` | Wizard terminé | `skipped: false`, `total_duration_ms` |
|
||||
| `onboarding_skipped` | Bouton "Passer" | `at_step` |
|
||||
| `starter_pack_warning_shown` | < 5 crédits restants | `credits_left` |
|
||||
| `starter_pack_empty_shown` | 0 crédits | `user_id` |
|
||||
|
||||
---
|
||||
|
||||
## Notes d'implémentation
|
||||
|
||||
- Les **5 notes d'exemple** doivent être vectorisées **synchroniquement** (pas en cron job) pour que la démonstration fonctionne immédiatement
|
||||
- La **recherche sémantique étape 3** doit utiliser le vrai pipeline pgvector (pas un mock) — si la vectorisation est async, afficher un spinner et attendre
|
||||
- Le wizard est un **overlay** (pas une page dédiée) pour ne pas briser la navigation back/forward
|
||||
- Sur mobile : utiliser un **bottom sheet** animé au lieu d'un modal centré
|
||||
- Le flag `onboardingCompleted` doit être présent dans le token JWT NextAuth (via `callbacks.jwt` et `callbacks.session`) pour éviter un appel DB à chaque render
|
||||
|
||||
---
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
Implémentation complète réalisée en session. Toutes les US-ONBOARDING 1-5 sont satisfaites :
|
||||
|
||||
- **US-ONBOARDING-1** : `onboardingCompleted` et `onboardingStep` ajoutés au schéma Prisma (migration additive), exposés via JWT/session NextAuth.
|
||||
- **US-ONBOARDING-2** : Wizard 3 étapes (`OnboardingWizard`) — overlay fixe z-200, backdrop blur, bottom sheet mobile, AnimatePresence, progress dots.
|
||||
- **US-ONBOARDING-3** : Route `POST /api/onboarding/seed-demo-notes` — 5 notes fr/en/fa, embeddings synchrones, idempotent.
|
||||
- **US-ONBOARDING-4** : `StarterPackBadge` intégré dans la sidebar, visible uniquement pour les plans FREE, pulse orange < 5 crédits, rouge à 0.
|
||||
- **US-ONBOARDING-5** : `PATCH /api/user/me` + `useSession().update()` — flag persisté en DB et JWT, wizard disparu au refresh.
|
||||
|
||||
### Files Created/Modified
|
||||
|
||||
**Created:**
|
||||
- `memento-note/prisma/migrations/20260529060000_add_onboarding_fields/migration.sql`
|
||||
- `memento-note/app/api/user/me/route.ts`
|
||||
- `memento-note/app/api/onboarding/seed-demo-notes/route.ts`
|
||||
- `memento-note/components/onboarding/onboarding-step-welcome.tsx`
|
||||
- `memento-note/components/onboarding/onboarding-step-notes.tsx`
|
||||
- `memento-note/components/onboarding/onboarding-step-aha.tsx`
|
||||
- `memento-note/components/onboarding/onboarding-wizard.tsx`
|
||||
- `memento-note/components/onboarding/starter-pack-badge.tsx`
|
||||
|
||||
**Modified:**
|
||||
- `memento-note/prisma/schema.prisma`
|
||||
- `memento-note/auth.ts`
|
||||
- `memento-note/auth.config.ts`
|
||||
- `memento-note/locales/*.json` (15 fichiers, clés `onboarding.*`)
|
||||
- `memento-note/components/providers-wrapper.tsx`
|
||||
- `memento-note/components/sidebar.tsx`
|
||||
- `docs/sprint-status.yaml`
|
||||
- `docs/user-stories.md`
|
||||
|
||||
### Change Log
|
||||
|
||||
- 2026-05-29: Implémentation complète story 6-1-onboarding-activation — DB migration, auth JWT, APIs, i18n 15 locales, wizard 3 étapes, StarterPackBadge, intégration providers + sidebar. 134 tests unitaires passés, 0 régression.
|
||||
|
||||
---
|
||||
|
||||
## Senior Developer Review (AI)
|
||||
|
||||
**Date:** 2026-05-29
|
||||
**Outcome:** Approved — all issues resolved
|
||||
**Layers:** Blind Hunter ✅ | Edge Case Hunter ✅ | Acceptance Auditor ✅
|
||||
|
||||
### Action Items
|
||||
|
||||
**Decision-Needed (4)**
|
||||
- [x] [Review][Decision] D1 — dismissed: dots animated are acceptable UX — Progress indicator: dots actuels vs texte "1/3 → 2/3 → 3/3" exigé par la spec — les dots sont UX-valides mais la spec est explicite
|
||||
- [x] [Review][Decision] D2 — dismissed: import stub acceptable, future story — Bouton "Importer mes notes" avance à l'étape 3 (onNext) au lieu d'ouvrir un vrai flux d'import — import peut être hors scope de cette story
|
||||
- [x] [Review][Decision] D3 — dismissed: client locale equiv to server-detected — Locale seed-demo-notes vient du body client vs `initialLanguage` serveur — client envoie `language` depuis LanguageProvider qui a été initialisé côté serveur (peut être équivalent)
|
||||
- [x] [Review][Decision] D4 — resolved: added withTimeout(6s) per embedding call — 5 embeddings synchrones dans un seul handler HTTP — intentionnel (notes cherchables immédiatement) mais peut dépasser le timeout serveur (10s Vercel)
|
||||
|
||||
**Patches (17)**
|
||||
|
||||
*HIGH*
|
||||
- [x] [Review][Patch] H1 — `countOnly` param non implémenté dans `/api/notes` → `getNoteCount()` retourne toujours 0 → step 2 toujours "pas de notes" [onboarding-wizard.tsx:22 + app/api/notes/route.ts]
|
||||
- [x] [Review][Patch] H2 — `tier` est `'BASIC'` jamais `'FREE'` → `StarterPackBadge` retourne `null` pour tous les utilisateurs [starter-pack-badge.tsx:28]
|
||||
- [x] [Review][Patch] H3 — `QuotaExceededError` silencieusement avalé → user voit "No results" sans feedback de quota dépassé [onboarding-step-aha.tsx:55]
|
||||
|
||||
*MED*
|
||||
- [x] [Review][Patch] M1 — Race condition: deux POST simultanés passent tous deux le check `existing.length >= 5` → création de 10 notes [seed-demo-notes/route.ts:~252]
|
||||
- [x] [Review][Patch] M2 — `setVisible(false)` avant `markOnboardingComplete()` complète → si PATCH échoue et user refresh, wizard réapparaît [onboarding-wizard.tsx:50]
|
||||
- [x] [Review][Patch] M3 — `markOnboardingComplete()` ne throw pas sur non-2xx → `updateSession()` s'exécute quand même → wizard revient après rotation du token [onboarding-wizard.tsx:14]
|
||||
- [x] [Review][Patch] M4 — Empty input déclenche une vraie recherche sémantique (crédits consommés) via le placeholder [onboarding-step-aha.tsx:42]
|
||||
- [x] [Review][Patch] M5 — `useSession().update({ onboardingCompleted, aiSessionConsent })` en un seul appel : les deux branches `trigger=update` sont des early-returns mutuellement exclusifs → seule la première clé est traitée [auth.ts JWT callback]
|
||||
- [x] [Review][Patch] M6 — `PATCH /api/user/me` accepte `onboardingStep` sans validation du type (peut recevoir une string, un float, ou négatif) [user/me/route.ts:~42]
|
||||
- [x] [Review][Patch] M7 — Idempotency partielle: si un appel précédent a créé 3 notes puis échoué, le suivant crée 2 nouvelles sans déduplication par titre [seed-demo-notes/route.ts]
|
||||
- [x] [Review][Patch] M8 — Animate-out cassé: `if (!visible) return null` est évalué avant `AnimatePresence` → le composant disparaît immédiatement sans animation de sortie [onboarding-wizard.tsx:68]
|
||||
|
||||
*Spec/i18n*
|
||||
- [x] [Review][Patch] S1 — Badge "✨ 1 recherche utilisée" absent après la recherche (spec US-ONBOARDING-2 Étape 3) [onboarding-step-aha.tsx]
|
||||
- [x] [Review][Patch] S2 — Champ de recherche commence vide au lieu d'être pré-rempli (spec: "champ pré-rempli") [onboarding-step-aha.tsx:40]
|
||||
- [x] [Review][Patch] S3 — Bouton recherche icône seule sans libellé "Chercher" ni aria-label [onboarding-step-aha.tsx:101]
|
||||
- [x] [Review][Patch] S4 — Seuil d'avertissement `<= 5` devrait être `< 5` (≤ 4) selon spec [starter-pack-badge.tsx:33]
|
||||
- [x] [Review][Patch] S5 — "No results — try another query." hardcodé en anglais, non passé par `t()` [onboarding-step-aha.tsx:123]
|
||||
- [x] [Review][Patch] S6 — `.replace('{count}', ...)` au lieu de `t(key, { count })` — bypass API i18n du projet [onboarding-step-notes.tsx:61]
|
||||
|
||||
**Deferred (2)**
|
||||
- [x] [Review][Defer] W1 — Session version check bypassed by trigger=update — préexistant, pas introduit par cette story [auth.ts] — deferred, pre-existing
|
||||
- [x] [Review][Defer] W2 — `isMarkdown: true` avec contenu HTML — format préexistant utilisé par l'app pour d'autres notes [seed-demo-notes/route.ts] — deferred, pre-existing
|
||||
|
||||
**Dismissed (1)**
|
||||
- StarterPackBadge sans error handling fetch — React Query gère les erreurs via son state interne, composant retourne null si !data
|
||||
@@ -1,7 +1,7 @@
|
||||
# User Stories — Momento Next Phase
|
||||
|
||||
> Basé sur l'analyse du prototype `architectural-grid/` et du code production `memento-note/`.
|
||||
> Dernière mise à jour : 2026-05-25 (US-NEXTGEN-EDITOR réorganisé, 4 nouvelles stories éditeur ajoutées)
|
||||
> Dernière mise à jour : 2026-05-29 (Epic 6 Croissance & Activation ajouté — analyse stratégique Mary/BMad)
|
||||
|
||||
---
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
| **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** | — |
|
||||
| **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** | — |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { generateBrainstormPptx } from '@/lib/brainstorm/export-pptx'
|
||||
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
OR: [
|
||||
{ userId: session.user.id },
|
||||
{ participants: { some: { userId: session.user.id } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
ideas: {
|
||||
orderBy: [{ waveNumber: 'asc' }, { createdAt: 'asc' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const sessionData = {
|
||||
seedIdea: brainstormSession.seedIdea,
|
||||
createdAt: brainstormSession.createdAt,
|
||||
ideas: brainstormSession.ideas.map(idea => ({
|
||||
id: idea.id,
|
||||
title: idea.title,
|
||||
description: idea.description,
|
||||
waveNumber: idea.waveNumber,
|
||||
status: idea.status,
|
||||
isStarred: idea.isStarred,
|
||||
convertedToNoteId: idea.convertedToNoteId,
|
||||
})),
|
||||
}
|
||||
|
||||
const { buffer, filename } = await generateBrainstormPptx(sessionData)
|
||||
|
||||
return new NextResponse(new Uint8Array(buffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': String(buffer.length),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[export-pptx]', err)
|
||||
return NextResponse.json({ error: 'Export failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
300
memento-note/app/api/onboarding/seed-demo-notes/route.ts
Normal file
300
memento-note/app/api/onboarding/seed-demo-notes/route.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
||||
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
||||
|
||||
type DemoNote = { title: string; content: string }
|
||||
|
||||
const DEMO_NOTES: Record<string, DemoNote[]> = {
|
||||
fr: [
|
||||
{
|
||||
title: 'Réunion Q3 — Stratégie produit',
|
||||
content: `<h2>Réunion Q3 — Stratégie produit</h2>
|
||||
<p>Points clés abordés lors de la réunion trimestrielle :</p>
|
||||
<ul>
|
||||
<li><strong>Roadmap</strong> : priorité aux features de monétisation (Stripe, BYOK) avant les agents autonomes</li>
|
||||
<li><strong>KPI Activation</strong> : objectif 40% d'utilisateurs actifs dans les 7 jours suivant l'inscription</li>
|
||||
<li><strong>Acquisition PLG</strong> : brainstorm partageable = viral loop principale</li>
|
||||
<li><strong>Pricing</strong> : Pro à 9,90 €/mois validé avec 3 personas cibles</li>
|
||||
</ul>
|
||||
<p>Prochaine étape : finaliser l'onboarding wizard pour atteindre le taux d'activation cible.</p>`,
|
||||
},
|
||||
{
|
||||
title: 'Idées de projets secondaires',
|
||||
content: `<h2>Idées de projets secondaires</h2>
|
||||
<p>Liste de projets à explorer dans les prochains mois :</p>
|
||||
<ul>
|
||||
<li>📱 <strong>App de suivi d'habitudes</strong> — gamification, streaks, rappels intelligents</li>
|
||||
<li>🎙️ <strong>Podcast "Tech Indépendant"</strong> — interviews de freelances tech, conseils pratiques</li>
|
||||
<li>🤖 <strong>Bot Telegram de veille</strong> — scraping RSS + résumé IA chaque matin</li>
|
||||
<li>📚 <strong>Newsletter hebdomadaire</strong> — curation de ressources pour développeurs</li>
|
||||
<li>🎮 <strong>Jeu de stratégie minimaliste</strong> — WebGL, logique pure, pas de monétisation</li>
|
||||
</ul>
|
||||
<p>Critères de sélection : impact fort, temps minimal, apprentissage technique.</p>`,
|
||||
},
|
||||
{
|
||||
title: 'Livres à lire — Recommandations',
|
||||
content: `<h2>Livres à lire — Recommandations</h2>
|
||||
<h3>Productivité & Pensée</h3>
|
||||
<ul>
|
||||
<li><strong>Building a Second Brain</strong> — Tiago Forte · Système de notes PKM, méthode CODE</li>
|
||||
<li><strong>Deep Work</strong> — Cal Newport · Concentration profonde dans un monde de distractions</li>
|
||||
<li><strong>Atomic Habits</strong> — James Clear · Les habitudes comme système de progression</li>
|
||||
</ul>
|
||||
<h3>Technique</h3>
|
||||
<ul>
|
||||
<li><strong>Designing Data-Intensive Applications</strong> — Martin Kleppmann · Architecture systèmes</li>
|
||||
<li><strong>The Pragmatic Programmer</strong> — Hunt & Thomas · Bonnes pratiques développement</li>
|
||||
</ul>
|
||||
<p>Prochaine lecture : Building a Second Brain (lien avec Momento évident).</p>`,
|
||||
},
|
||||
{
|
||||
title: 'Notes de formation React — Hooks avancés',
|
||||
content: `<h2>Notes de formation React — Hooks avancés</h2>
|
||||
<h3>useCallback vs useMemo</h3>
|
||||
<ul>
|
||||
<li><strong>useCallback</strong> : mémoïse une <em>fonction</em>, utile pour éviter re-renders des enfants</li>
|
||||
<li><strong>useMemo</strong> : mémoïse une <em>valeur calculée</em>, utile pour calculs coûteux</li>
|
||||
<li>⚠️ Ne pas abuser — le coût de la mémoïsation peut dépasser le gain</li>
|
||||
</ul>
|
||||
<h3>useReducer</h3>
|
||||
<p>Préférer <code>useReducer</code> à <code>useState</code> quand :</p>
|
||||
<ul>
|
||||
<li>L'état a plusieurs sous-valeurs liées</li>
|
||||
<li>La prochaine valeur dépend de la précédente</li>
|
||||
<li>La logique de transition est complexe</li>
|
||||
</ul>
|
||||
<h3>Pattern : Context + useReducer</h3>
|
||||
<p>Combine Context API avec useReducer pour un state management léger sans Redux.</p>`,
|
||||
},
|
||||
{
|
||||
title: 'Objectifs personnels 2025',
|
||||
content: `<h2>Objectifs personnels 2025</h2>
|
||||
<h3>🎯 Professionnel</h3>
|
||||
<ul>
|
||||
<li>Lancer un produit SaaS avec les premiers utilisateurs payants</li>
|
||||
<li>Contribuer à un projet open source significatif</li>
|
||||
<li>Maîtriser l'architecture de systèmes distribués</li>
|
||||
</ul>
|
||||
<h3>📚 Apprentissage</h3>
|
||||
<ul>
|
||||
<li>Lire 12 livres techniques (1 par mois)</li>
|
||||
<li>Compléter une formation en machine learning appliqué</li>
|
||||
<li>Améliorer le niveau en algorithmique (LeetCode medium)</li>
|
||||
</ul>
|
||||
<h3>🌱 Personnel</h3>
|
||||
<ul>
|
||||
<li>Sport : 3x par semaine minimum</li>
|
||||
<li>Méditation : 10 minutes par jour (habitude matinale)</li>
|
||||
<li>Voyager dans 2 nouveaux pays</li>
|
||||
</ul>
|
||||
<p>Revue mensuelle le 1er de chaque mois pour ajuster les priorités.</p>`,
|
||||
},
|
||||
],
|
||||
en: [
|
||||
{
|
||||
title: 'Q3 Meeting — Product Strategy',
|
||||
content: `<h2>Q3 Meeting — Product Strategy</h2>
|
||||
<p>Key points from the quarterly review:</p>
|
||||
<ul>
|
||||
<li><strong>Roadmap</strong>: monetization features first (Stripe, BYOK) before autonomous agents</li>
|
||||
<li><strong>Activation KPI</strong>: target 40% active users within 7 days of signup</li>
|
||||
<li><strong>PLG Acquisition</strong>: shareable brainstorm canvas = main viral loop</li>
|
||||
<li><strong>Pricing</strong>: Pro at $9.90/month validated with 3 target personas</li>
|
||||
</ul>
|
||||
<p>Next step: finalize onboarding wizard to reach activation target.</p>`,
|
||||
},
|
||||
{
|
||||
title: 'Side Project Ideas',
|
||||
content: `<h2>Side Project Ideas</h2>
|
||||
<p>Projects to explore in the coming months:</p>
|
||||
<ul>
|
||||
<li>📱 <strong>Habit tracking app</strong> — gamification, streaks, smart reminders</li>
|
||||
<li>🎙️ <strong>Tech podcast</strong> — interviews with indie developers, practical advice</li>
|
||||
<li>🤖 <strong>Telegram news bot</strong> — RSS scraping + AI summary every morning</li>
|
||||
<li>📚 <strong>Weekly newsletter</strong> — curated resources for developers</li>
|
||||
<li>🎮 <strong>Minimalist strategy game</strong> — WebGL, pure logic, no monetization</li>
|
||||
</ul>
|
||||
<p>Selection criteria: high impact, minimal time, technical learning.</p>`,
|
||||
},
|
||||
{
|
||||
title: 'Books to Read — Recommendations',
|
||||
content: `<h2>Books to Read — Recommendations</h2>
|
||||
<h3>Productivity & Thinking</h3>
|
||||
<ul>
|
||||
<li><strong>Building a Second Brain</strong> — Tiago Forte · PKM note-taking system, CODE method</li>
|
||||
<li><strong>Deep Work</strong> — Cal Newport · Deep focus in a distracted world</li>
|
||||
<li><strong>Atomic Habits</strong> — James Clear · Habits as a system for progress</li>
|
||||
</ul>
|
||||
<h3>Technical</h3>
|
||||
<ul>
|
||||
<li><strong>Designing Data-Intensive Applications</strong> — Martin Kleppmann · Systems architecture</li>
|
||||
<li><strong>The Pragmatic Programmer</strong> — Hunt & Thomas · Development best practices</li>
|
||||
</ul>
|
||||
<p>Next read: Building a Second Brain (obvious connection to Momento).</p>`,
|
||||
},
|
||||
{
|
||||
title: 'React Training Notes — Advanced Hooks',
|
||||
content: `<h2>React Training Notes — Advanced Hooks</h2>
|
||||
<h3>useCallback vs useMemo</h3>
|
||||
<ul>
|
||||
<li><strong>useCallback</strong>: memoizes a <em>function</em>, useful to prevent child re-renders</li>
|
||||
<li><strong>useMemo</strong>: memoizes a <em>computed value</em>, useful for expensive calculations</li>
|
||||
<li>⚠️ Don't overuse — memoization cost can exceed the gain</li>
|
||||
</ul>
|
||||
<h3>useReducer</h3>
|
||||
<p>Prefer <code>useReducer</code> over <code>useState</code> when:</p>
|
||||
<ul>
|
||||
<li>State has multiple related sub-values</li>
|
||||
<li>Next state depends on previous state</li>
|
||||
<li>Transition logic is complex</li>
|
||||
</ul>`,
|
||||
},
|
||||
{
|
||||
title: 'Personal Goals 2025',
|
||||
content: `<h2>Personal Goals 2025</h2>
|
||||
<h3>🎯 Professional</h3>
|
||||
<ul>
|
||||
<li>Launch a SaaS product with first paying users</li>
|
||||
<li>Contribute to a significant open source project</li>
|
||||
<li>Master distributed systems architecture</li>
|
||||
</ul>
|
||||
<h3>📚 Learning</h3>
|
||||
<ul>
|
||||
<li>Read 12 technical books (1 per month)</li>
|
||||
<li>Complete an applied machine learning course</li>
|
||||
<li>Improve algorithmic skills (LeetCode medium)</li>
|
||||
</ul>
|
||||
<h3>🌱 Personal</h3>
|
||||
<ul>
|
||||
<li>Exercise: 3x per week minimum</li>
|
||||
<li>Meditation: 10 minutes per day (morning habit)</li>
|
||||
<li>Travel to 2 new countries</li>
|
||||
</ul>`,
|
||||
},
|
||||
],
|
||||
fa: [
|
||||
{
|
||||
title: 'جلسه Q3 — استراتژی محصول',
|
||||
content: `<h2>جلسه Q3 — استراتژی محصول</h2>
|
||||
<p>نکات کلیدی جلسه فصلی:</p>
|
||||
<ul>
|
||||
<li><strong>نقشه راه</strong>: اول ویژگیهای پولیسازی (Stripe، BYOK) و سپس عاملهای خودمختار</li>
|
||||
<li><strong>KPI فعالسازی</strong>: هدف ۴۰٪ کاربران فعال در ۷ روز اول</li>
|
||||
<li><strong>رشد محصولمحور</strong>: اشتراکگذاری بوم طوفان فکری = حلقه ویروسی اصلی</li>
|
||||
</ul>
|
||||
<p>قدم بعدی: نهایی کردن ویزارد آنبوردینگ برای رسیدن به هدف فعالسازی.</p>`,
|
||||
},
|
||||
{
|
||||
title: 'ایدههای پروژههای جانبی',
|
||||
content: `<h2>ایدههای پروژههای جانبی</h2>
|
||||
<ul>
|
||||
<li>📱 <strong>اپلیکیشن پیگیری عادت</strong> — گیمیفیکیشن، استریک، یادآوری هوشمند</li>
|
||||
<li>🎙️ <strong>پادکست فناوری</strong> — مصاحبه با توسعهدهندگان مستقل</li>
|
||||
<li>🤖 <strong>ربات تلگرام اخبار</strong> — خلاصهسازی روزانه با هوش مصنوعی</li>
|
||||
</ul>`,
|
||||
},
|
||||
{
|
||||
title: 'کتابهای پیشنهادی',
|
||||
content: `<h2>کتابهای پیشنهادی</h2>
|
||||
<ul>
|
||||
<li><strong>ساختن مغز دوم</strong> — تیاگو فورتی · سیستم یادداشتبرداری PKM</li>
|
||||
<li><strong>کار عمیق</strong> — کال نیوپورت · تمرکز عمیق در دنیای پر از حواسپرتی</li>
|
||||
<li><strong>عادتهای اتمی</strong> — جیمز کلیر · عادتها به عنوان سیستم پیشرفت</li>
|
||||
</ul>`,
|
||||
},
|
||||
{
|
||||
title: 'یادداشتهای آموزش React',
|
||||
content: `<h2>یادداشتهای آموزش React</h2>
|
||||
<h3>useCallback و useMemo</h3>
|
||||
<ul>
|
||||
<li><strong>useCallback</strong>: یک تابع را حفظ میکند</li>
|
||||
<li><strong>useMemo</strong>: یک مقدار محاسبهشده را حفظ میکند</li>
|
||||
</ul>`,
|
||||
},
|
||||
{
|
||||
title: 'اهداف شخصی ۲۰۲۵',
|
||||
content: `<h2>اهداف شخصی ۲۰۲۵</h2>
|
||||
<h3>🎯 حرفهای</h3>
|
||||
<ul>
|
||||
<li>راهاندازی یک محصول SaaS با اولین کاربران پرداختکننده</li>
|
||||
<li>مشارکت در یک پروژه متنباز مهم</li>
|
||||
</ul>
|
||||
<h3>📚 یادگیری</h3>
|
||||
<ul>
|
||||
<li>خواندن ۱۲ کتاب فناوری (یکی در ماه)</li>
|
||||
<li>تکمیل یک دوره یادگیری ماشین کاربردی</li>
|
||||
</ul>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function getNotesForLocale(locale: string): DemoNote[] {
|
||||
const lang = locale.split('-')[0].toLowerCase()
|
||||
return DEMO_NOTES[lang] ?? DEMO_NOTES.en
|
||||
}
|
||||
|
||||
/** Wraps a promise with a timeout — rejects after `ms` milliseconds. */
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms)
|
||||
)
|
||||
return Promise.race([promise, timeout])
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
let locale = 'en'
|
||||
try {
|
||||
const body = await req.json().catch(() => ({}))
|
||||
if (body?.locale) locale = body.locale
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Idempotency check — if any demo notes already exist, return them (handles partial creation too)
|
||||
const existing = await prisma.note.findMany({
|
||||
where: { userId, isDemo: true, trashedAt: null },
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
if (existing.length > 0) {
|
||||
return NextResponse.json({ created: false, notes: existing, message: 'Demo notes already exist' })
|
||||
}
|
||||
|
||||
const demoNotes = getNotesForLocale(locale)
|
||||
const created: { id: string; title: string | null }[] = []
|
||||
|
||||
for (const demo of demoNotes) {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId,
|
||||
title: demo.title,
|
||||
content: demo.content,
|
||||
isMarkdown: true,
|
||||
isDemo: true,
|
||||
type: 'richtext',
|
||||
color: 'default',
|
||||
},
|
||||
})
|
||||
created.push({ id: note.id, title: note.title })
|
||||
|
||||
// Synchronous embedding generation so semantic search works immediately (6s timeout per note)
|
||||
try {
|
||||
const { embedding } = await withTimeout(
|
||||
embeddingService.generateNoteEmbedding(demo.title, demo.content),
|
||||
6000
|
||||
)
|
||||
if (embedding) {
|
||||
await withTimeout(upsertNoteEmbedding(note.id, embedding), 3000)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ONBOARDING] Embedding failed for demo note:', note.id, e)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ created: true, notes: created })
|
||||
}
|
||||
61
memento-note/app/api/user/me/route.ts
Normal file
61
memento-note/app/api/user/me/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
* GET /api/user/me
|
||||
* Returns lightweight user profile including onboardingCompleted flag.
|
||||
*/
|
||||
export async function GET() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { id: true, name: true, email: true, onboardingCompleted: true, onboardingStep: true },
|
||||
})
|
||||
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
return NextResponse.json(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/user/me
|
||||
* Partial update of user profile. Supported fields: onboardingCompleted, onboardingStep.
|
||||
*/
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const allowedFields = ['onboardingCompleted', 'onboardingStep'] as const
|
||||
type AllowedField = typeof allowedFields[number]
|
||||
const data: Partial<Record<AllowedField, unknown>> = {}
|
||||
if ('onboardingCompleted' in body) {
|
||||
data.onboardingCompleted = Boolean(body.onboardingCompleted)
|
||||
}
|
||||
if ('onboardingStep' in body) {
|
||||
const val = parseInt(String(body.onboardingStep), 10)
|
||||
if (Number.isInteger(val) && val >= 0 && val <= 10) {
|
||||
data.onboardingStep = val
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: data as any,
|
||||
select: { id: true, onboardingCompleted: true, onboardingStep: true },
|
||||
})
|
||||
return NextResponse.json(updated)
|
||||
}
|
||||
@@ -67,6 +67,7 @@ export const authConfig = {
|
||||
(session.user as any).id = token.id;
|
||||
(session.user as any).role = token.role;
|
||||
session.aiSessionConsent = token.aiSessionConsent === true;
|
||||
(session.user as any).onboardingCompleted = token.onboardingCompleted === true;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
@@ -55,8 +55,9 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
|
||||
return true;
|
||||
},
|
||||
async jwt({ token, user, trigger, session }) {
|
||||
if (trigger === 'update' && session && 'aiSessionConsent' in session) {
|
||||
token.aiSessionConsent = session.aiSessionConsent === true;
|
||||
if (trigger === 'update' && session) {
|
||||
if ('aiSessionConsent' in session) token.aiSessionConsent = session.aiSessionConsent === true;
|
||||
if ('onboardingCompleted' in session) token.onboardingCompleted = session.onboardingCompleted === true;
|
||||
return token;
|
||||
}
|
||||
if (user?.id) {
|
||||
@@ -64,15 +65,16 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
|
||||
token.aiSessionConsent = false;
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { role: true, sessionVersion: true },
|
||||
select: { role: true, sessionVersion: true, onboardingCompleted: true },
|
||||
});
|
||||
if (!dbUser) return null;
|
||||
token.role = dbUser.role;
|
||||
token.sessionVersion = dbUser.sessionVersion;
|
||||
token.onboardingCompleted = dbUser.onboardingCompleted;
|
||||
} else if (token.sub) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: token.sub },
|
||||
select: { role: true, sessionVersion: true },
|
||||
select: { role: true, sessionVersion: true, onboardingCompleted: true },
|
||||
});
|
||||
if (!dbUser) return null;
|
||||
if (
|
||||
@@ -84,6 +86,7 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
|
||||
token.id = token.sub;
|
||||
token.role = dbUser.role;
|
||||
token.sessionVersion = dbUser.sessionVersion;
|
||||
token.onboardingCompleted = dbUser.onboardingCompleted;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
@@ -94,12 +94,23 @@ export function BrainstormPage() {
|
||||
setActiveSessionId(urlSessionId)
|
||||
}
|
||||
}, [urlSessionId])
|
||||
|
||||
const [showActivityFeed, setShowActivityFeed] = useState(false)
|
||||
const [showShareDialog, setShowShareDialog] = useState(false)
|
||||
const [manualEditCount, setManualEditCount] = useState(0)
|
||||
const [shareStatus, setShareStatus] = useState<'idle' | 'copying' | 'copied'>('idle')
|
||||
|
||||
const { data: sessions, isLoading: sessionsLoading } = useBrainstormSessions()
|
||||
|
||||
// Auto-sélectionner la dernière session si aucune session active
|
||||
useEffect(() => {
|
||||
if (!activeSessionId && !urlSessionId && !sessionsLoading && sessions && sessions.length > 0) {
|
||||
const last = sessions[0]
|
||||
setActiveSessionId(last.id)
|
||||
router.replace('/brainstorm?session=' + last.id)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessions, sessionsLoading])
|
||||
const { data: sessionResult, isLoading: sessionLoading } = useBrainstormSession(activeSessionId)
|
||||
const session = sessionResult?.session || null
|
||||
const sessionMeta = sessionResult?.meta
|
||||
@@ -128,6 +139,9 @@ export function BrainstormPage() {
|
||||
const [viewMode, setViewMode] = useState<'canvas' | 'list'>('canvas')
|
||||
const [summaryOpen, setSummaryOpen] = useState(false)
|
||||
const [summaryText, setSummaryText] = useState<string | null>(null)
|
||||
const [pptxLoading, setPptxLoading] = useState(false)
|
||||
const [pptxError, setPptxError] = useState<string | null>(null)
|
||||
const [fitTrigger, setFitTrigger] = useState(0)
|
||||
const [renamingSession, setRenamingSession] = useState<string | null>(null)
|
||||
const [renameInput, setRenameInput] = useState('')
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -237,6 +251,36 @@ export function BrainstormPage() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleExportPptx = async () => {
|
||||
if (!activeSessionId) return
|
||||
setPptxLoading(true)
|
||||
setPptxError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/brainstorm/${activeSessionId}/export-pptx`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error(t('brainstorm.pptxError') || 'Export PPTX failed')
|
||||
const blob = await res.blob()
|
||||
const disposition = res.headers.get('Content-Disposition') || ''
|
||||
const match = disposition.match(/filename="([^"]+)"/)
|
||||
const filename = match?.[1] || 'brainstorm.pptx'
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err: any) {
|
||||
setPptxError(err?.message || t('brainstorm.pptxError') || 'Export PPTX failed')
|
||||
setTimeout(() => setPptxError(null), 4000)
|
||||
} finally {
|
||||
setPptxLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
setExportError(null)
|
||||
try {
|
||||
@@ -577,6 +621,7 @@ export function BrainstormPage() {
|
||||
remoteMove={remoteMove}
|
||||
manualEditTrigger={manualEditCount}
|
||||
playbackIdeas={playbackIdeas}
|
||||
fitTrigger={fitTrigger}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-20 flex-col gap-6">
|
||||
@@ -594,7 +639,7 @@ export function BrainstormPage() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="absolute bottom-6 left-6 flex gap-2"
|
||||
>
|
||||
<div className="px-4 py-2 bg-[#F4F1EA] dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-6">
|
||||
<div className="px-4 py-2 bg-[#F4F1EA] dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-5">
|
||||
{[1, 2, 3].map((w) => (
|
||||
<div key={w} className="flex items-center gap-2">
|
||||
<div
|
||||
@@ -609,6 +654,20 @@ export function BrainstormPage() {
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{viewMode === 'canvas' && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-border/60" />
|
||||
<button
|
||||
onClick={() => setFitTrigger((c) => c + 1)}
|
||||
title={t('brainstorm.fitToScreen') || 'Recentrer'}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
@@ -854,7 +913,10 @@ export function BrainstormPage() {
|
||||
{sessions?.map((s) => (
|
||||
<div key={s.id} className="relative group/session">
|
||||
<button
|
||||
onClick={() => setActiveSessionId(s.id)}
|
||||
onClick={() => {
|
||||
setActiveSessionId(s.id)
|
||||
router.replace('/brainstorm?session=' + s.id)
|
||||
}}
|
||||
className={`w-10 h-10 min-h-[40px] rounded-xl flex items-center justify-center text-xs font-bold transition-all shrink-0 ${
|
||||
activeSessionId === s.id
|
||||
? 'bg-foreground text-background scale-110 shadow-lg'
|
||||
@@ -864,7 +926,7 @@ export function BrainstormPage() {
|
||||
}`}
|
||||
title={s.seedIdea}
|
||||
>
|
||||
{s.seedIdea.charAt(0).toUpperCase()}
|
||||
{(s.seedIdea.replace(/\p{Emoji}/gu, '').trim().charAt(0) || '?').toUpperCase()}
|
||||
</button>
|
||||
{activeSessionId === s.id && (
|
||||
<button
|
||||
@@ -943,14 +1005,29 @@ export function BrainstormPage() {
|
||||
{t('brainstorm.regenerateSummary') || 'Regenerate'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setSummaryOpen(false); handleExport() }}
|
||||
disabled={exportBrainstorm.isPending || summarize.isPending}
|
||||
className="ms-auto flex items-center gap-2 px-5 py-2.5 bg-foreground text-background rounded-xl text-xs font-bold uppercase tracking-widest disabled:opacity-50 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Download size={13} />
|
||||
{t('brainstorm.exportAsNote') || 'Export as note'}
|
||||
</button>
|
||||
<div className="ms-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleExportPptx}
|
||||
disabled={pptxLoading || summarize.isPending}
|
||||
title={t('brainstorm.downloadPptxDesc') || 'Download as PowerPoint'}
|
||||
className="flex items-center gap-2 px-4 py-2.5 border border-foreground/20 text-foreground rounded-xl text-xs font-bold uppercase tracking-widest disabled:opacity-50 hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
{pptxLoading ? (
|
||||
<motion.div animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: 'linear' }} className="w-3 h-3 border-2 border-current border-t-transparent rounded-full" />
|
||||
) : (
|
||||
<Download size={13} />
|
||||
)}
|
||||
{t('brainstorm.downloadPptx') || 'PPTX'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSummaryOpen(false); handleExport() }}
|
||||
disabled={exportBrainstorm.isPending || summarize.isPending}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-foreground text-background rounded-xl text-xs font-bold uppercase tracking-widest disabled:opacity-50 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Download size={13} />
|
||||
{t('brainstorm.exportAsNote') || 'Export as note'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
@@ -1033,6 +1110,20 @@ export function BrainstormPage() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{pptxError && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-rose-500 text-white rounded-2xl shadow-2xl flex items-center gap-4 text-sm font-medium"
|
||||
>
|
||||
<span>{pptxError}</span>
|
||||
<button onClick={() => setPptxError(null)} className="text-white/60 hover:text-white">✕</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{exportToast && (
|
||||
<motion.div
|
||||
|
||||
@@ -14,6 +14,7 @@ interface WaveCanvasProps {
|
||||
remoteMove?: { ideaId: string; x: number; y: number; _seq: number } | null
|
||||
manualEditTrigger?: number
|
||||
playbackIdeas?: any[] | null
|
||||
fitTrigger?: number
|
||||
}
|
||||
|
||||
const WAVE_COLORS: Record<number, string> = {
|
||||
@@ -31,6 +32,7 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
remoteMove,
|
||||
manualEditTrigger,
|
||||
playbackIdeas,
|
||||
fitTrigger,
|
||||
}) => {
|
||||
const { t, language } = useLanguage()
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
@@ -39,6 +41,8 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
const linkRef = useRef<d3.Selection<SVGLineElement, any, SVGGElement, unknown> | null>(null)
|
||||
const simulationRef = useRef<d3.Simulation<any, any> | null>(null)
|
||||
const transformRef = useRef<d3.ZoomTransform>(d3.zoomIdentity)
|
||||
const zoomRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null)
|
||||
const centerRef = useRef<{ x: number; y: number; scale: number }>({ x: 0, y: 0, scale: 0.8 })
|
||||
|
||||
const onNodeSelectRef = useRef(onNodeSelect)
|
||||
onNodeSelectRef.current = onNodeSelect
|
||||
@@ -167,6 +171,9 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
transformRef.current = event.transform
|
||||
})
|
||||
|
||||
zoomRef.current = zoom
|
||||
centerRef.current = { x: centerX, y: centerY, scale: 0.8 }
|
||||
|
||||
svg.call(zoom)
|
||||
svg.call(zoom.transform, d3.zoomIdentity.translate(centerX, centerY).scale(0.8))
|
||||
|
||||
@@ -517,6 +524,16 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
node.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
||||
}, [remoteMove])
|
||||
|
||||
// Fit-to-screen: re-center when fitTrigger increments
|
||||
useEffect(() => {
|
||||
if (!fitTrigger || !svgRef.current || !zoomRef.current) return
|
||||
const { x, y, scale } = centerRef.current
|
||||
d3.select(svgRef.current)
|
||||
.transition()
|
||||
.duration(500)
|
||||
.call(zoomRef.current.transform, d3.zoomIdentity.translate(x, y).scale(scale))
|
||||
}, [fitTrigger])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -524,6 +541,21 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
>
|
||||
<svg ref={svgRef} className="w-full h-full" />
|
||||
|
||||
{/* Legend overlay — bottom right */}
|
||||
<div className="absolute bottom-6 right-6 pointer-events-none flex flex-col gap-1 bg-white/80 dark:bg-[#1A1A1A]/80 backdrop-blur-sm rounded-xl px-3 py-2.5 shadow-sm border border-black/[0.06] dark:border-white/[0.06]">
|
||||
{[
|
||||
{ color: '#fb923c', label: t('brainstorm.legendWave1') || 'Variations' },
|
||||
{ color: '#60a5fa', label: t('brainstorm.legendWave2') || 'Analogies' },
|
||||
{ color: '#a78bfa', label: t('brainstorm.legendWave3') || 'Disruptions' },
|
||||
{ color: '#10b981', label: t('brainstorm.legendConverted') || 'Convertie' },
|
||||
].map(({ color, label }) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
|
||||
<span className="text-[10px] text-foreground/50 font-medium">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editingNode && (
|
||||
<div
|
||||
className="absolute z-50 pointer-events-auto"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -18,7 +19,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
|
||||
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp
|
||||
} from 'lucide-react'
|
||||
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
|
||||
import { NoteShareDialog } from './note-share-dialog'
|
||||
@@ -29,6 +30,7 @@ import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { format } from 'date-fns'
|
||||
import { tiptapHTMLToMarkdown, markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export'
|
||||
|
||||
interface NoteEditorToolbarProps {
|
||||
mode: 'fullPage' | 'dialog'
|
||||
@@ -38,16 +40,70 @@ interface NoteEditorToolbarProps {
|
||||
}
|
||||
|
||||
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
|
||||
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
|
||||
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
|
||||
const { t } = useLanguage()
|
||||
const [isConverting, setIsConverting] = useState(false)
|
||||
const [shareOpen, setShareOpen] = useState(false)
|
||||
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
|
||||
const mdImportInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||
|
||||
const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null)
|
||||
|
||||
// ── Markdown export ───────────────────────────────────────────────────────
|
||||
const handleExportMarkdown = () => {
|
||||
try {
|
||||
const editor = richTextEditorRef?.current?.getEditor()
|
||||
if (!editor) {
|
||||
toast.error(t('richTextEditor.markdownExportError'))
|
||||
return
|
||||
}
|
||||
const html = editor.getHTML()
|
||||
const title = state.title || note.title || 'note'
|
||||
const titleLine = title ? `# ${title}\n\n` : ''
|
||||
const markdown = titleLine + tiptapHTMLToMarkdown(html)
|
||||
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${title.replace(/[^a-z0-9\-_\s]/gi, '').trim().replace(/\s+/g, '-') || 'note'}.md`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(t('richTextEditor.markdownExportSuccess'))
|
||||
} catch {
|
||||
toast.error(t('richTextEditor.markdownExportError'))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Markdown import ───────────────────────────────────────────────────────
|
||||
const handleImportMarkdownFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
try {
|
||||
const md = ev.target?.result as string
|
||||
const html = markdownToHTML(md)
|
||||
const extractedTitle = extractMarkdownTitle(md)
|
||||
const editor = richTextEditorRef?.current?.getEditor()
|
||||
if (editor) {
|
||||
editor.commands.setContent(html)
|
||||
}
|
||||
actions.setContent(html)
|
||||
if (extractedTitle) actions.setTitle(extractedTitle)
|
||||
toast.success(t('richTextEditor.markdownImportSuccess'))
|
||||
} catch {
|
||||
toast.error(t('richTextEditor.markdownExportError'))
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
// Reset input so same file can be imported again
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleConvertToRichtext = async () => {
|
||||
if (isConverting || !state.content.trim()) return
|
||||
setIsConverting(true)
|
||||
@@ -240,7 +296,16 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem onClick={handleExportMarkdown}>
|
||||
<FileDown className="h-4 w-4 me-2" />
|
||||
{t('richTextEditor.exportMarkdown')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => mdImportInputRef.current?.click()}>
|
||||
<FileUp className="h-4 w-4 me-2" />
|
||||
{t('richTextEditor.importMarkdown')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
@@ -304,6 +369,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{!readOnly && (
|
||||
@@ -432,5 +498,14 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Hidden file input for Markdown import */}
|
||||
<input
|
||||
ref={mdImportInputRef}
|
||||
type="file"
|
||||
accept=".md,text/markdown"
|
||||
className="hidden"
|
||||
onChange={handleImportMarkdownFile}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
232
memento-note/components/onboarding/onboarding-editor-hints.tsx
Normal file
232
memento-note/components/onboarding/onboarding-editor-hints.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
Slash, Sparkles, History, GraduationCap, Link2,
|
||||
PenLine, FlipVertical, Keyboard, Lightbulb, ArrowRight,
|
||||
FileDown, Network, Zap, RefreshCw,
|
||||
X, ChevronLeft, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface HintDef {
|
||||
icon: LucideIcon
|
||||
color: string
|
||||
bg: string
|
||||
key: string
|
||||
}
|
||||
|
||||
interface RouteHintSet {
|
||||
hints: HintDef[]
|
||||
storageKey: string
|
||||
}
|
||||
|
||||
// ── Hint definitions per route ─────────────────────────────────────────────
|
||||
// Every item here maps to a real, verified UI element or gesture in the codebase.
|
||||
|
||||
const ROUTE_HINTS: Record<string, RouteHintSet> = {
|
||||
'/home': {
|
||||
storageKey: 'momento_hints_home',
|
||||
hints: [
|
||||
{ icon: PenLine, color: 'text-violet-500', bg: 'bg-violet-500/10', key: 'create_note' },
|
||||
{ icon: Slash, color: 'text-indigo-500', bg: 'bg-indigo-500/10', key: 'slash' },
|
||||
{ icon: Sparkles, color: 'text-amber-500', bg: 'bg-amber-500/10', key: 'ai' },
|
||||
{ icon: History, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'version' },
|
||||
{ icon: Link2, color: 'text-rose-500', bg: 'bg-rose-500/10', key: 'links' },
|
||||
{ icon: GraduationCap, color: 'text-emerald-500',bg: 'bg-emerald-500/10',key: 'flashcards' },
|
||||
],
|
||||
},
|
||||
'/revision': {
|
||||
storageKey: 'momento_hints_revision',
|
||||
hints: [
|
||||
{ icon: FlipVertical, color: 'text-violet-500', bg: 'bg-violet-500/10', key: 'flip' },
|
||||
{ icon: Keyboard, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'rate_keys' },
|
||||
{ icon: GraduationCap,color: 'text-emerald-500',bg: 'bg-emerald-500/10',key: 'generate_from_note' },
|
||||
],
|
||||
},
|
||||
'/brainstorm': {
|
||||
storageKey: 'momento_hints_brainstorm',
|
||||
hints: [
|
||||
{ icon: Lightbulb, color: 'text-amber-500', bg: 'bg-amber-500/10', key: 'brainstorm_start' },
|
||||
{ icon: ArrowRight, color: 'text-violet-500',bg: 'bg-violet-500/10', key: 'brainstorm_deepen' },
|
||||
{ icon: FileDown, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'brainstorm_export' },
|
||||
],
|
||||
},
|
||||
'/insights': {
|
||||
storageKey: 'momento_hints_insights',
|
||||
hints: [
|
||||
{ icon: Network, color: 'text-violet-500', bg: 'bg-violet-500/10', key: 'insights_clusters' },
|
||||
{ icon: Zap, color: 'text-amber-500', bg: 'bg-amber-500/10', key: 'insights_bridge' },
|
||||
{ icon: RefreshCw, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'insights_refresh' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
function getRouteSet(pathname: string): RouteHintSet | null {
|
||||
// Exact match first
|
||||
if (ROUTE_HINTS[pathname]) return ROUTE_HINTS[pathname]
|
||||
// Prefix match (e.g. /brainstorm?session=xxx)
|
||||
for (const route of Object.keys(ROUTE_HINTS)) {
|
||||
if (pathname.startsWith(route)) return ROUTE_HINTS[route]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function OnboardingEditorHints() {
|
||||
const { data: session } = useSession()
|
||||
const pathname = usePathname()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [index, setIndex] = useState(0)
|
||||
const [routeKey, setRouteKey] = useState<string | null>(null)
|
||||
|
||||
const user = session?.user as any
|
||||
|
||||
// When route changes, check if we should show hints for the new page
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
if (user?.onboardingCompleted !== false) return
|
||||
|
||||
const routeSet = getRouteSet(pathname)
|
||||
if (!routeSet) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && localStorage.getItem(routeSet.storageKey)) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Reset to first hint when page changes
|
||||
setIndex(0)
|
||||
setRouteKey(pathname)
|
||||
|
||||
const timer = setTimeout(() => setVisible(true), 900)
|
||||
return () => clearTimeout(timer)
|
||||
}, [pathname, session, user?.onboardingCompleted])
|
||||
|
||||
// Hide when hints change (route switch)
|
||||
useEffect(() => {
|
||||
if (routeKey !== null && routeKey !== pathname) {
|
||||
setVisible(false)
|
||||
}
|
||||
}, [pathname, routeKey])
|
||||
|
||||
function dismiss() {
|
||||
const routeSet = getRouteSet(pathname)
|
||||
if (routeSet && typeof window !== 'undefined') {
|
||||
localStorage.setItem(routeSet.storageKey, '1')
|
||||
}
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
const routeSet = getRouteSet(pathname)
|
||||
if (!routeSet) return null
|
||||
|
||||
const hints = routeSet.hints
|
||||
const hint = hints[Math.min(index, hints.length - 1)]
|
||||
const Icon = hint.icon
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
key={`hints-${pathname}`}
|
||||
initial={{ opacity: 0, x: 60 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 60 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
className="fixed bottom-24 right-4 z-[150] w-72 rounded-2xl border border-border bg-background shadow-xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 pt-3 pb-2 border-b border-border/50">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{t('onboarding.editor_hints_title')}
|
||||
</p>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="rounded p-0.5 text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hint content */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={hint.key}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="px-4 py-3 flex items-start gap-3"
|
||||
>
|
||||
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${hint.bg} ${hint.color}`}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{t(`onboarding.hint_${hint.key}_title`)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">
|
||||
{t(`onboarding.hint_${hint.key}_desc`)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between px-4 pb-3">
|
||||
{/* Dots */}
|
||||
<div className="flex gap-1">
|
||||
{hints.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
i === index ? 'w-4 bg-violet-500' : 'w-1.5 bg-border'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Arrows + dismiss */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setIndex(i => Math.max(0, i - 1))}
|
||||
disabled={index === 0}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{index < hints.length - 1 ? (
|
||||
<button
|
||||
onClick={() => setIndex(i => i + 1)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="flex items-center gap-1 text-xs font-medium px-2.5 h-6 rounded-lg bg-violet-500/10 text-violet-500 hover:bg-violet-500/20 transition-colors"
|
||||
>
|
||||
{t('onboarding.editor_hints_got_it')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
202
memento-note/components/onboarding/onboarding-step-aha.tsx
Normal file
202
memento-note/components/onboarding/onboarding-step-aha.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { Search, Sparkles, ArrowRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { semanticSearch } from '@/app/actions/semantic-search'
|
||||
import confetti from 'canvas-confetti'
|
||||
|
||||
interface SearchResult { id: string; title: string | null; snippet?: string }
|
||||
|
||||
interface Props {
|
||||
onDone: () => void
|
||||
locale: string
|
||||
autoSearch?: boolean
|
||||
}
|
||||
|
||||
const SEARCH_PREFILL: Record<string, string> = {
|
||||
fr: 'notes sur ma productivité',
|
||||
en: 'notes about productivity',
|
||||
fa: 'یادداشتهای بهرهوری',
|
||||
ar: 'ملاحظات حول الإنتاجية',
|
||||
de: 'Notizen zur Produktivität',
|
||||
es: 'notas sobre productividad',
|
||||
it: 'note sulla produttività',
|
||||
pt: 'notas sobre produtividade',
|
||||
ru: 'заметки о продуктивности',
|
||||
zh: '关于生产力的笔记',
|
||||
ja: '生産性に関するメモ',
|
||||
ko: '생산성에 관한 노트',
|
||||
nl: 'notities over productiviteit',
|
||||
pl: 'notatki o produktywności',
|
||||
hi: 'उत्पादकता पर नोट्स',
|
||||
}
|
||||
|
||||
function fireConfetti() {
|
||||
const end = Date.now() + 800
|
||||
const colors = ['#7c3aed', '#a78bfa', '#fbbf24', '#34d399']
|
||||
const frame = () => {
|
||||
confetti({ particleCount: 3, angle: 60, spread: 55, origin: { x: 0 }, colors })
|
||||
confetti({ particleCount: 3, angle: 120, spread: 55, origin: { x: 1 }, colors })
|
||||
if (Date.now() < end) requestAnimationFrame(frame)
|
||||
}
|
||||
frame()
|
||||
}
|
||||
|
||||
export function OnboardingStepAha({ onDone, locale, autoSearch = false }: Props) {
|
||||
const { t } = useLanguage()
|
||||
const lang = locale.split('-')[0].toLowerCase()
|
||||
const prefill = SEARCH_PREFILL[lang] ?? SEARCH_PREFILL.en
|
||||
|
||||
const [query, setQuery] = useState(prefill)
|
||||
const [results, setResults] = useState<SearchResult[] | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [quotaExceeded, setQuotaExceeded] = useState(false)
|
||||
const autoSearched = useRef(false)
|
||||
|
||||
async function handleSearch() {
|
||||
const q = query.trim()
|
||||
if (!q) return
|
||||
setLoading(true)
|
||||
setQuotaExceeded(false)
|
||||
try {
|
||||
const res = await semanticSearch(q, { limit: 5 })
|
||||
const hits = res.results.map(r => ({ id: r.noteId, title: r.title, snippet: r.content?.slice(0, 80) }))
|
||||
setResults(hits)
|
||||
setSearched(true)
|
||||
if (hits.length > 0) {
|
||||
setTimeout(fireConfetti, 200)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message.toLowerCase() : ''
|
||||
if (msg.includes('quota') || msg.includes('limit') || msg.includes('exceeded') || msg.includes('upgrade')) {
|
||||
setQuotaExceeded(true)
|
||||
}
|
||||
setResults([])
|
||||
setSearched(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSearch || autoSearched.current) return
|
||||
autoSearched.current = true
|
||||
const timer = setTimeout(() => { void handleSearch() }, 800)
|
||||
return () => clearTimeout(timer)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoSearch])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16 }}
|
||||
className="flex flex-col items-center gap-5 w-full"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.7, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.1, type: 'spring', stiffness: 200 }}
|
||||
className="flex h-20 w-20 items-center justify-center rounded-2xl bg-violet-500/10 text-violet-500"
|
||||
>
|
||||
<Sparkles className="h-10 w-10" />
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{t('onboarding.step_aha_title')}
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground max-w-xs mx-auto">
|
||||
{t('onboarding.step_aha_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
animate={searched ? {} : { boxShadow: ['0 0 0 0 rgba(139,92,246,0)', '0 0 0 6px rgba(139,92,246,0.15)', '0 0 0 0 rgba(139,92,246,0)'] }}
|
||||
transition={{ duration: 2, repeat: searched ? 0 : Infinity }}
|
||||
className="flex w-full max-w-sm rounded-xl border border-border bg-background overflow-hidden shadow-sm"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
className="flex-1 px-4 py-3 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
dir={['fa', 'ar'].includes(lang) ? 'rtl' : 'ltr'}
|
||||
aria-label={t('onboarding.step_aha_search_aria')}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={loading || !query.trim()}
|
||||
aria-label={t('onboarding.step_aha_search_button')}
|
||||
className="flex items-center gap-1.5 px-4 text-sm font-medium text-muted-foreground hover:text-violet-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <span className="h-4 w-4 animate-spin border-2 border-violet-500/30 border-t-violet-500 rounded-full block" />
|
||||
: <Search className="h-4 w-4" />
|
||||
}
|
||||
<span className="hidden sm:inline">{t('onboarding.step_aha_search_button')}</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{searched && results !== null && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="w-full max-w-sm space-y-2"
|
||||
>
|
||||
{quotaExceeded ? (
|
||||
<p className="text-sm text-center text-destructive py-2">
|
||||
{t('onboarding.quota_exceeded')}
|
||||
</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-sm text-center text-muted-foreground py-2">
|
||||
{t('onboarding.no_results')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{results.slice(0, 3).map((r, i) => (
|
||||
<motion.div
|
||||
key={r.id}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.07 }}
|
||||
className="rounded-lg border border-border bg-muted/30 px-3 py-2"
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground truncate">{r.title ?? 'Untitled'}</p>
|
||||
{r.snippet && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{r.snippet}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="flex items-center justify-center gap-1 text-xs text-violet-500 pt-1"
|
||||
>
|
||||
<span>✨</span>
|
||||
<span>{t('onboarding.search_credit_used')}</span>
|
||||
</motion.p>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Button
|
||||
onClick={onDone}
|
||||
size="lg"
|
||||
className="w-full max-w-sm gap-2"
|
||||
>
|
||||
{t('onboarding.step_aha_cta')}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
116
memento-note/components/onboarding/onboarding-step-features.tsx
Normal file
116
memento-note/components/onboarding/onboarding-step-features.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { Pencil, CreditCard, Lightbulb, Check, ArrowRight, Sparkles } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface Props {
|
||||
onDone: () => void
|
||||
onTry: (href: string) => void
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{ icon: Pencil, color: 'text-violet-500', bg: 'bg-violet-500/10', key: 'write', href: '/home' },
|
||||
{ icon: CreditCard, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'flashcards', href: '/revision' },
|
||||
{ icon: Lightbulb, color: 'text-amber-500', bg: 'bg-amber-500/10', key: 'brainstorm', href: '/brainstorm' },
|
||||
]
|
||||
|
||||
export function OnboardingStepFeatures({ onDone, onTry }: Props) {
|
||||
const { t } = useLanguage()
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||
|
||||
function handleTry(key: string, href: string) {
|
||||
setChecked(prev => new Set([...prev, key]))
|
||||
onTry(href)
|
||||
}
|
||||
|
||||
const allChecked = checked.size === ACTIONS.length
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16 }}
|
||||
className="flex flex-col items-center gap-5 w-full"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.7, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.1, type: 'spring', stiffness: 200 }}
|
||||
className="flex h-20 w-20 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500"
|
||||
>
|
||||
<Sparkles className="h-10 w-10" />
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-1 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{t('onboarding.step_features_title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||
{t('onboarding.step_features_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5 w-full">
|
||||
{ACTIONS.map(({ icon: Icon, color, bg, key, href }, i) => {
|
||||
const done = checked.has(key)
|
||||
return (
|
||||
<motion.div
|
||||
key={key}
|
||||
initial={{ opacity: 0, x: -12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.08 + i * 0.08 }}
|
||||
className={`flex items-center gap-3 rounded-xl border p-3 transition-all ${
|
||||
done ? 'border-emerald-500/30 bg-emerald-500/5' : 'border-border bg-muted/10 hover:border-border/80'
|
||||
}`}
|
||||
>
|
||||
{/* Checkmark */}
|
||||
<span className={`shrink-0 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors ${
|
||||
done ? 'border-emerald-500 bg-emerald-500' : 'border-border'
|
||||
}`}>
|
||||
<AnimatePresence>
|
||||
{done && (
|
||||
<motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
||||
<Check className="h-3 w-3 text-white" />
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
|
||||
{/* Icon */}
|
||||
<span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${bg} ${color}`}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium leading-tight ${done ? 'text-muted-foreground' : 'text-foreground'}`}>
|
||||
{t(`onboarding.action_${key}_title`)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-tight mt-0.5">
|
||||
{t(`onboarding.action_${key}_desc`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Essayer — minimise wizard + navigue */}
|
||||
<button
|
||||
onClick={() => handleTry(key, href)}
|
||||
className={`shrink-0 flex items-center gap-1 text-xs font-medium px-2.5 py-1.5 rounded-lg transition-opacity ${color} ${bg} ${done ? 'opacity-40' : 'hover:opacity-80'}`}
|
||||
>
|
||||
{done ? t('onboarding.action_done') : t('onboarding.action_try')}
|
||||
{!done && <ArrowRight className="h-3 w-3" />}
|
||||
</button>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button onClick={onDone} size="lg" className="w-full gap-2 mt-1">
|
||||
{allChecked ? t('onboarding.step_features_cta_all') : t('onboarding.step_features_cta')}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
203
memento-note/components/onboarding/onboarding-step-notes.tsx
Normal file
203
memento-note/components/onboarding/onboarding-step-notes.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { motion } from 'motion/react'
|
||||
import { FileText, Upload, Sparkles, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export'
|
||||
|
||||
interface Props {
|
||||
noteCount: number
|
||||
onNext: (justCreated: boolean) => void
|
||||
onSkip: () => void
|
||||
locale: string
|
||||
}
|
||||
|
||||
async function createNote(title: string | null, content: string) {
|
||||
const res = await fetch('/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: title ?? 'Untitled', content }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to create note')
|
||||
}
|
||||
|
||||
export function OnboardingStepNotes({ noteCount, onNext, onSkip, locale }: Props) {
|
||||
const { t } = useLanguage()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [created, setCreated] = useState(false)
|
||||
const [importError, setImportError] = useState<string | null>(null)
|
||||
const [importedCount, setImportedCount] = useState(0)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function handleCreateDemo() {
|
||||
setLoading(true)
|
||||
try {
|
||||
await fetch('/api/onboarding/seed-demo-notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locale }),
|
||||
})
|
||||
setCreated(true)
|
||||
setTimeout(() => onNext(true), 1200)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportFiles(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
if (!files.length) return
|
||||
setLoading(true)
|
||||
setImportError(null)
|
||||
let count = 0
|
||||
try {
|
||||
for (const file of files) {
|
||||
const text = await file.text()
|
||||
const isMd = file.name.endsWith('.md')
|
||||
const title = extractMarkdownTitle(text) ?? file.name.replace(/\.(md|txt)$/, '')
|
||||
const content = isMd ? markdownToHTML(text) : `<p>${text.replace(/\n\n+/g, '</p><p>').replace(/\n/g, '<br/>')}</p>`
|
||||
await createNote(title, content)
|
||||
count++
|
||||
}
|
||||
setImportedCount(count)
|
||||
setCreated(true)
|
||||
setTimeout(() => onNext(false), 1200)
|
||||
} catch {
|
||||
setImportError(t('onboarding.import_error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const hasNotes = noteCount > 0
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16 }}
|
||||
className="flex flex-col items-center gap-6 text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.7, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.1, type: 'spring', stiffness: 200 }}
|
||||
className="flex h-20 w-20 items-center justify-center rounded-2xl bg-blue-500/10 text-blue-500"
|
||||
>
|
||||
<FileText className="h-10 w-10" />
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{t('onboarding.step_notes_title')}
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground max-w-xs">
|
||||
{hasNotes
|
||||
? t('onboarding.step_notes_has_notes', { count: noteCount })
|
||||
: t('onboarding.step_notes_empty')}
|
||||
</p>
|
||||
{!hasNotes && (
|
||||
<p className="text-xs text-violet-500/80 max-w-xs">
|
||||
{t('onboarding.step_notes_hint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasNotes ? (
|
||||
<div className="flex flex-col gap-2 w-full max-w-xs">
|
||||
<Button onClick={() => onNext(false)} size="lg" className="w-full">
|
||||
{t('onboarding.step_notes_cta')} →
|
||||
</Button>
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors py-1"
|
||||
>
|
||||
{t('onboarding.skip')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 w-full max-w-xs">
|
||||
{created ? (
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-green-500/10 py-3 text-green-600"
|
||||
>
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">
|
||||
{importedCount > 0
|
||||
? t('onboarding.import_notes_ready', { count: importedCount })
|
||||
: t('onboarding.demo_notes_ready')}
|
||||
</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleCreateDemo}
|
||||
size="lg"
|
||||
className="w-full gap-2"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="animate-spin h-4 w-4 border-2 border-white/30 border-t-white rounded-full" />
|
||||
{t('onboarding.creating_demo_notes')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t('onboarding.step_notes_demo')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Hidden file input — accepts .md and .txt */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".md,.txt"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleImportFiles}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
disabled={loading}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{t('onboarding.step_notes_import')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground/50">
|
||||
{t('onboarding.import_formats')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{importError && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex items-center gap-2 rounded-lg bg-destructive/10 py-2 px-3 text-destructive text-sm"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{importError}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors py-1"
|
||||
>
|
||||
{t('onboarding.skip')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { Brain } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface Props {
|
||||
onNext: () => void
|
||||
onSkip: () => void
|
||||
userName?: string | null
|
||||
}
|
||||
|
||||
export function OnboardingStepWelcome({ onNext, onSkip, userName }: Props) {
|
||||
const { t } = useLanguage()
|
||||
const firstName = userName?.split(' ')[0] ?? null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16 }}
|
||||
className="flex flex-col items-center gap-6 text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.7, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.1, type: 'spring', stiffness: 200 }}
|
||||
className="flex h-20 w-20 items-center justify-center rounded-2xl bg-brand-accent/10 text-brand-accent"
|
||||
>
|
||||
<Brain className="h-10 w-10" />
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{firstName
|
||||
? t('onboarding.welcome_title_name', { name: firstName })
|
||||
: t('onboarding.welcome_title')}
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground max-w-xs">
|
||||
{t('onboarding.welcome_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full max-w-xs">
|
||||
<Button onClick={onNext} size="lg" className="w-full">
|
||||
{t('onboarding.welcome_cta')} →
|
||||
</Button>
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors py-1"
|
||||
>
|
||||
{t('onboarding.skip')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
219
memento-note/components/onboarding/onboarding-wizard.tsx
Normal file
219
memento-note/components/onboarding/onboarding-wizard.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Sparkles, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { OnboardingStepWelcome } from './onboarding-step-welcome'
|
||||
import { OnboardingStepNotes } from './onboarding-step-notes'
|
||||
import { OnboardingStepAha } from './onboarding-step-aha'
|
||||
import { OnboardingStepFeatures } from './onboarding-step-features'
|
||||
|
||||
const TOTAL_STEPS = 4
|
||||
|
||||
async function markOnboardingComplete() {
|
||||
const res = await fetch('/api/user/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ onboardingCompleted: true }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to save onboarding state')
|
||||
}
|
||||
|
||||
async function getNoteCount(): Promise<number> {
|
||||
try {
|
||||
const res = await fetch('/api/notes?limit=1')
|
||||
if (!res.ok) return 0
|
||||
const data = await res.json()
|
||||
if (data?.data && Array.isArray(data.data)) return data.data.length
|
||||
if (Array.isArray(data)) return data.length
|
||||
return 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function OnboardingWizard() {
|
||||
const { data: session, update: updateSession } = useSession()
|
||||
const { language, t } = useLanguage()
|
||||
const router = useRouter()
|
||||
|
||||
const [step, setStep] = useState(1)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
const [noteCount, setNoteCount] = useState(0)
|
||||
const [demoNotesJustCreated, setDemoNotesJustCreated] = useState(false)
|
||||
const [triedCount, setTriedCount] = useState(0)
|
||||
|
||||
const user = session?.user as any
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
if (user?.onboardingCompleted === false) {
|
||||
setVisible(true)
|
||||
getNoteCount().then(setNoteCount)
|
||||
}
|
||||
}, [session, user?.onboardingCompleted])
|
||||
|
||||
const handleSkip = useCallback(async () => {
|
||||
setVisible(false)
|
||||
setMinimized(false)
|
||||
try {
|
||||
await markOnboardingComplete()
|
||||
await updateSession({ onboardingCompleted: true })
|
||||
} catch (e) {
|
||||
console.error('[Onboarding] skip failed:', e)
|
||||
}
|
||||
}, [updateSession])
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
setStep(s => Math.min(s + 1, TOTAL_STEPS))
|
||||
}, [])
|
||||
|
||||
const handleNotesNext = useCallback((justCreated: boolean) => {
|
||||
setDemoNotesJustCreated(justCreated)
|
||||
setStep(s => Math.min(s + 1, TOTAL_STEPS))
|
||||
}, [])
|
||||
|
||||
const handleDone = useCallback(async () => {
|
||||
setVisible(false)
|
||||
setMinimized(false)
|
||||
try {
|
||||
await markOnboardingComplete()
|
||||
await updateSession({ onboardingCompleted: true })
|
||||
} catch (e) {
|
||||
console.error('[Onboarding] done failed:', e)
|
||||
}
|
||||
}, [updateSession])
|
||||
|
||||
// Called from step 4 when user clicks "Essayer" on a feature
|
||||
const handleTry = useCallback((href: string) => {
|
||||
setMinimized(true)
|
||||
setTriedCount(c => c + 1)
|
||||
router.push(href)
|
||||
}, [router])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Minimized pill — always rendered when minimized so it shows on any page */}
|
||||
<AnimatePresence>
|
||||
{minimized && (
|
||||
<motion.button
|
||||
key="onboarding-pill"
|
||||
initial={{ opacity: 0, y: 40, scale: 0.8 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 40, scale: 0.8 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 28 }}
|
||||
onClick={() => setMinimized(false)}
|
||||
className="fixed bottom-6 right-6 z-[300] flex items-center gap-2.5 rounded-full bg-violet-600 text-white shadow-lg shadow-violet-500/30 px-4 py-3 text-sm font-medium hover:bg-violet-700 transition-colors"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{t('onboarding.pill_resume')}</span>
|
||||
{triedCount > 0 && (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-xs font-bold">
|
||||
{triedCount}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Full wizard modal */}
|
||||
<AnimatePresence>
|
||||
{!minimized && (
|
||||
<motion.div
|
||||
key="onboarding-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-[200] flex items-end justify-center sm:items-center bg-black/50 backdrop-blur-sm p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0, scale: 0.97 }}
|
||||
animate={{ y: 0, opacity: 1, scale: 1 }}
|
||||
exit={{ y: 60, opacity: 0, scale: 0.95 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
className={cn(
|
||||
'relative w-full max-w-md rounded-2xl bg-background border border-border shadow-2xl p-8',
|
||||
'sm:rounded-2xl rounded-t-2xl rounded-b-none sm:rounded-b-2xl'
|
||||
)}
|
||||
>
|
||||
{/* Progress */}
|
||||
<div className="flex flex-col items-center gap-1.5 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{Array.from({ length: TOTAL_STEPS }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
animate={{
|
||||
width: i + 1 === step ? 24 : 8,
|
||||
backgroundColor: i + 1 <= step ? 'var(--brand-accent, #7c3aed)' : 'var(--border)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-2 rounded-full bg-border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
{t('onboarding.progress', { current: step, total: TOTAL_STEPS })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Skip / close button (steps 1-3 only, step 4 has its own CTA) */}
|
||||
{step < 4 && (
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="absolute top-4 right-4 rounded-lg p-1.5 text-muted-foreground/40 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Step content */}
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 1 && (
|
||||
<OnboardingStepWelcome
|
||||
key="step-1"
|
||||
onNext={handleNext}
|
||||
onSkip={handleSkip}
|
||||
userName={user?.name}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<OnboardingStepNotes
|
||||
key="step-2"
|
||||
noteCount={noteCount}
|
||||
onNext={handleNotesNext}
|
||||
onSkip={handleSkip}
|
||||
locale={language}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<OnboardingStepAha
|
||||
key="step-3"
|
||||
onDone={handleNext}
|
||||
locale={language}
|
||||
autoSearch={demoNotesJustCreated}
|
||||
/>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<OnboardingStepFeatures
|
||||
key="step-4"
|
||||
onDone={handleDone}
|
||||
onTry={handleTry}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
59
memento-note/components/onboarding/starter-pack-badge.tsx
Normal file
59
memento-note/components/onboarding/starter-pack-badge.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Zap, ArrowUpRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface QuotaData { remaining: number; limit: number; used: number }
|
||||
interface UsageData { quotas: Record<string, QuotaData>; tier: string }
|
||||
|
||||
export function StarterPackBadge() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
const { data } = useQuery<UsageData>({
|
||||
queryKey: ['usage', 'current'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/usage/current')
|
||||
if (!res.ok) throw new Error('Failed')
|
||||
return res.json()
|
||||
},
|
||||
staleTime: 5000,
|
||||
})
|
||||
|
||||
if (!data) return null
|
||||
// Paid tiers do not need the badge; only BASIC (free) users see it
|
||||
const PAID_TIERS = ['PRO', 'BUSINESS', 'ENTERPRISE']
|
||||
if (PAID_TIERS.includes(data.tier)) return null
|
||||
|
||||
const semanticQuota = data.quotas?.['semantic_search']
|
||||
if (!semanticQuota) return null
|
||||
|
||||
const remaining = semanticQuota.remaining ?? 0
|
||||
const isCritical = remaining === 0
|
||||
const isWarning = remaining > 0 && remaining < 5
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/pricing"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium transition-colors',
|
||||
isCritical
|
||||
? 'bg-destructive/10 text-destructive hover:bg-destructive/20'
|
||||
: isWarning
|
||||
? 'bg-orange-500/10 text-orange-500 hover:bg-orange-500/20'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80'
|
||||
)}
|
||||
>
|
||||
<Zap className={cn('h-3.5 w-3.5', isWarning || isCritical ? 'animate-pulse' : '')} />
|
||||
<span>
|
||||
{isCritical
|
||||
? t('onboarding.badge_upgrade')
|
||||
: t('onboarding.badge_credits', { count: remaining })
|
||||
}
|
||||
</span>
|
||||
{isCritical && <ArrowUpRight className="h-3 w-3 ml-auto" />}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import type { ReactNode } from 'react'
|
||||
import type { Translations } from '@/lib/i18n/load-translations'
|
||||
import { AiConsentProvider } from '@/components/legal/ai-consent-provider'
|
||||
import { SearchModalProvider } from '@/context/search-modal-context'
|
||||
import { OnboardingWizard } from '@/components/onboarding/onboarding-wizard'
|
||||
import { OnboardingEditorHints } from '@/components/onboarding/onboarding-editor-hints'
|
||||
|
||||
const RTL_LANGUAGES = ['ar', 'fa']
|
||||
|
||||
@@ -42,6 +44,8 @@ export function ProvidersWrapper({
|
||||
<DirWrapper>
|
||||
<SearchModalProvider>
|
||||
{children}
|
||||
<OnboardingWizard />
|
||||
<OnboardingEditorHints />
|
||||
</SearchModalProvider>
|
||||
</DirWrapper>
|
||||
</AiConsentProvider>
|
||||
|
||||
@@ -41,6 +41,7 @@ import { parseBlockReferenceFromText, recallLastBlockReference, type ParsedBlock
|
||||
import { getEmptyParagraphAtSelection } from '@/lib/editor/empty-paragraph-at-selection'
|
||||
import { SmartPasteExtension } from '@/lib/editor/smart-paste-extension'
|
||||
import { BlockSelectionExtension } from '@/lib/editor/block-selection-extension'
|
||||
import { MarkdownPasteExtension } from '@/lib/editor/markdown-paste-extension'
|
||||
import { TurnIntoShortcutExtension } from '@/lib/editor/turn-into-shortcut-extension'
|
||||
import { UndoRedoFeedbackExtension } from '@/lib/editor/undo-redo-feedback-extension'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
@@ -417,6 +418,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
...globalDragHandleExtensions,
|
||||
SmartPasteExtension,
|
||||
BlockSelectionExtension,
|
||||
MarkdownPasteExtension,
|
||||
TurnIntoShortcutExtension,
|
||||
UndoRedoFeedbackExtension,
|
||||
LiveBlockExtension,
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
import { performSignOut } from '@/lib/auth-client'
|
||||
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
||||
import { UsageMeter } from './usage-meter'
|
||||
import { StarterPackBadge } from './onboarding/starter-pack-badge'
|
||||
|
||||
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||||
@@ -1535,8 +1536,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* ── Usage meter en bas du panneau ── */}
|
||||
<div className="border-t border-border/20 px-3 py-3 mt-auto">
|
||||
{/* ── Usage meter + starter badge en bas du panneau ── */}
|
||||
<div className="border-t border-border/20 px-3 py-3 mt-auto space-y-2">
|
||||
<StarterPackBadge />
|
||||
<UsageMeter />
|
||||
</div>
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
322
memento-note/lib/brainstorm/export-pptx.ts
Normal file
322
memento-note/lib/brainstorm/export-pptx.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import type PptxGenJSModule from 'pptxgenjs'
|
||||
|
||||
let _PptxGenJS: (new () => PptxGenJSModule) | null = null
|
||||
async function getPptxGenClass(): Promise<new () => PptxGenJSModule> {
|
||||
if (!_PptxGenJS) {
|
||||
const mod = await import('pptxgenjs')
|
||||
_PptxGenJS = (mod.default ?? mod) as unknown as new () => PptxGenJSModule
|
||||
}
|
||||
return _PptxGenJS
|
||||
}
|
||||
|
||||
// ── Theme — cohérent avec l'identité visuelle Momento ───────────────────────
|
||||
const T = {
|
||||
bg: 'F2F0E9',
|
||||
primary: '1C1C1C',
|
||||
accent: 'A47148',
|
||||
secondary: 'D4A373',
|
||||
muted: '9A8C87',
|
||||
wave1: 'fb923c',
|
||||
wave2: '60a5fa',
|
||||
wave3: 'a78bfa',
|
||||
green: '10b981',
|
||||
}
|
||||
|
||||
const WAVE_LABELS: Record<number, string> = {
|
||||
1: '🔄 Variations',
|
||||
2: '🔗 Analogies',
|
||||
3: '💥 Disruptions',
|
||||
}
|
||||
|
||||
const WAVE_COLORS: Record<number, string> = {
|
||||
1: T.wave1,
|
||||
2: T.wave2,
|
||||
3: T.wave3,
|
||||
}
|
||||
|
||||
// ── Types (minimal subset) ──────────────────────────────────────────────────
|
||||
|
||||
interface IdeaLike {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
waveNumber: number
|
||||
status: string
|
||||
isStarred: boolean
|
||||
convertedToNoteId: string | null
|
||||
}
|
||||
|
||||
interface SessionLike {
|
||||
seedIdea: string
|
||||
createdAt: Date
|
||||
ideas: IdeaLike[]
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.slice(0, 40)
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? text.slice(0, max - 1) + '…' : text
|
||||
}
|
||||
|
||||
// Add a consistent slide background
|
||||
function addBg(slide: any) {
|
||||
slide.background = { color: T.bg }
|
||||
}
|
||||
|
||||
// Accent bar at the top of a slide
|
||||
function addTopBar(slide: any, color: string = T.accent) {
|
||||
slide.addShape('rect', { x: 0, y: 0, w: '100%', h: 0.12, fill: { color } })
|
||||
}
|
||||
|
||||
// ── Slide builders ───────────────────────────────────────────────────────────
|
||||
|
||||
function buildCoverSlide(pres: PptxGenJSModule, session: SessionLike, stats: { total: number; converted: number; starred: number }) {
|
||||
const slide = pres.addSlide()
|
||||
addBg(slide)
|
||||
|
||||
// Left accent panel
|
||||
slide.addShape('rect', { x: 0, y: 0, w: 3.6, h: '100%', fill: { color: T.primary } })
|
||||
|
||||
// Brand label on accent panel
|
||||
slide.addText('MOMENTO', {
|
||||
x: 0.3, y: 0.4, w: 3.0, h: 0.4,
|
||||
fontSize: 9, fontFace: 'Arial', color: T.accent, bold: true,
|
||||
charSpacing: 4, align: 'left',
|
||||
})
|
||||
|
||||
// Seed idea (big) on right
|
||||
slide.addText(truncate(session.seedIdea, 80), {
|
||||
x: 4.0, y: 1.2, w: 5.6, h: 2.4,
|
||||
fontSize: 26, fontFace: 'Georgia', color: T.primary,
|
||||
bold: false, align: 'left', valign: 'middle', wrap: true,
|
||||
})
|
||||
|
||||
// Date
|
||||
slide.addText(session.createdAt.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }), {
|
||||
x: 4.0, y: 4.0, w: 5.6, h: 0.4,
|
||||
fontSize: 10, fontFace: 'Arial', color: T.muted, align: 'left',
|
||||
})
|
||||
|
||||
// Stats row
|
||||
const statItems = [
|
||||
{ label: 'idées', value: String(stats.total) },
|
||||
{ label: 'converties', value: String(stats.converted) },
|
||||
{ label: 'favorites', value: String(stats.starred) },
|
||||
]
|
||||
statItems.forEach((s, i) => {
|
||||
const x = 4.0 + i * 2.0
|
||||
slide.addText(s.value, { x, y: 4.8, w: 1.8, h: 0.55, fontSize: 22, fontFace: 'Georgia', color: T.accent, bold: true, align: 'left' })
|
||||
slide.addText(s.label, { x, y: 5.35, w: 1.8, h: 0.3, fontSize: 9, fontFace: 'Arial', color: T.muted, align: 'left', charSpacing: 1 })
|
||||
})
|
||||
|
||||
// Vertical label "BRAINSTORM" on left panel
|
||||
slide.addText('BRAINSTORM', {
|
||||
x: 0.1, y: 2.0, w: 3.2, h: 0.5,
|
||||
fontSize: 11, fontFace: 'Arial', color: 'FFFFFF', bold: true,
|
||||
charSpacing: 6, align: 'center',
|
||||
rotate: 270,
|
||||
})
|
||||
}
|
||||
|
||||
function buildWaveSlide(pres: PptxGenJSModule, wave: number, ideas: IdeaLike[]) {
|
||||
const slide = pres.addSlide()
|
||||
addBg(slide)
|
||||
addTopBar(slide, WAVE_COLORS[wave] || T.accent)
|
||||
|
||||
// Wave label
|
||||
slide.addText(WAVE_LABELS[wave] || `Wave ${wave}`, {
|
||||
x: 0.5, y: 0.25, w: 9.0, h: 0.5,
|
||||
fontSize: 14, fontFace: 'Arial', color: WAVE_COLORS[wave] || T.accent, bold: true, charSpacing: 2,
|
||||
})
|
||||
|
||||
// Count badge
|
||||
slide.addShape('roundRect', { x: 9.0, y: 0.28, w: 0.6, h: 0.4, fill: { color: WAVE_COLORS[wave] || T.accent }, rectRadius: 0.1 })
|
||||
slide.addText(String(ideas.length), { x: 9.0, y: 0.28, w: 0.6, h: 0.4, fontSize: 11, fontFace: 'Arial', color: 'FFFFFF', bold: true, align: 'center', valign: 'middle' })
|
||||
|
||||
const maxPerSlide = 6
|
||||
const shown = ideas.slice(0, maxPerSlide)
|
||||
|
||||
const colW = 4.5
|
||||
const rowH = 1.3
|
||||
const startY = 1.0
|
||||
|
||||
shown.forEach((idea, i) => {
|
||||
const col = i % 2
|
||||
const row = Math.floor(i / 2)
|
||||
const x = 0.4 + col * (colW + 0.3)
|
||||
const y = startY + row * (rowH + 0.15)
|
||||
|
||||
// Card background
|
||||
slide.addShape('roundRect', { x, y, w: colW, h: rowH, fill: { color: 'FFFFFF' }, line: { color: 'E8E6E0', width: 0.75 }, rectRadius: 0.08 })
|
||||
|
||||
// Star / converted badge
|
||||
if (idea.isStarred || idea.convertedToNoteId) {
|
||||
const badge = idea.convertedToNoteId ? '✓' : '⭐'
|
||||
const badgeColor = idea.convertedToNoteId ? T.green : T.wave1
|
||||
slide.addText(badge, { x: x + colW - 0.45, y: y + 0.08, w: 0.35, h: 0.35, fontSize: 10, fontFace: 'Arial', color: badgeColor, align: 'center' })
|
||||
}
|
||||
|
||||
// Title
|
||||
slide.addText(truncate(idea.title, 55), {
|
||||
x: x + 0.18, y: y + 0.12, w: colW - 0.55, h: 0.4,
|
||||
fontSize: 11, fontFace: 'Arial', color: T.primary, bold: true, wrap: true,
|
||||
})
|
||||
|
||||
// Description
|
||||
slide.addText(truncate(idea.description, 120), {
|
||||
x: x + 0.18, y: y + 0.52, w: colW - 0.36, h: 0.65,
|
||||
fontSize: 9, fontFace: 'Arial', color: T.muted, wrap: true, valign: 'top',
|
||||
})
|
||||
})
|
||||
|
||||
if (ideas.length > maxPerSlide) {
|
||||
slide.addText(`+ ${ideas.length - maxPerSlide} autres idées`, {
|
||||
x: 0.4, y: 5.5, w: 9.2, h: 0.3,
|
||||
fontSize: 9, fontFace: 'Arial', color: T.muted, align: 'center', italic: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function buildTopIdeasSlide(pres: PptxGenJSModule, starred: IdeaLike[], converted: IdeaLike[]) {
|
||||
const slide = pres.addSlide()
|
||||
addBg(slide)
|
||||
addTopBar(slide, T.accent)
|
||||
|
||||
slide.addText('Top Idées', {
|
||||
x: 0.5, y: 0.25, w: 9.0, h: 0.5,
|
||||
fontSize: 14, fontFace: 'Arial', color: T.accent, bold: true, charSpacing: 2,
|
||||
})
|
||||
|
||||
const all = [
|
||||
...starred.map(i => ({ ...i, badge: '⭐', badgeColor: T.wave1 })),
|
||||
...converted.filter(i => !i.isStarred).map(i => ({ ...i, badge: '✓', badgeColor: T.green })),
|
||||
].slice(0, 6)
|
||||
|
||||
if (all.length === 0) {
|
||||
slide.addText('Aucune idée favorite ou convertie.', {
|
||||
x: 0.5, y: 3.0, w: 9.0, h: 0.5,
|
||||
fontSize: 12, fontFace: 'Georgia', color: T.muted, align: 'center', italic: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const colW = 4.5
|
||||
const rowH = 1.25
|
||||
|
||||
all.forEach((idea, i) => {
|
||||
const col = i % 2
|
||||
const row = Math.floor(i / 2)
|
||||
const x = 0.4 + col * (colW + 0.3)
|
||||
const y = 1.1 + row * (rowH + 0.15)
|
||||
|
||||
const waveColor = WAVE_COLORS[idea.waveNumber] || T.muted
|
||||
|
||||
slide.addShape('roundRect', { x, y, w: colW, h: rowH, fill: { color: 'FFFFFF' }, line: { color: waveColor, width: 1.5 }, rectRadius: 0.08 })
|
||||
|
||||
slide.addText(idea.badge, { x: x + 0.1, y: y + 0.1, w: 0.4, h: 0.4, fontSize: 14, fontFace: 'Arial', color: idea.badgeColor, align: 'center' })
|
||||
|
||||
slide.addText(truncate(idea.title, 55), {
|
||||
x: x + 0.55, y: y + 0.1, w: colW - 0.7, h: 0.4,
|
||||
fontSize: 11, fontFace: 'Arial', color: T.primary, bold: true, wrap: true,
|
||||
})
|
||||
|
||||
slide.addText(truncate(idea.description, 110), {
|
||||
x: x + 0.18, y: y + 0.55, w: colW - 0.36, h: 0.6,
|
||||
fontSize: 9, fontFace: 'Arial', color: T.muted, wrap: true, valign: 'top',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildSummarySlide(pres: PptxGenJSModule, session: SessionLike, stats: { total: number; converted: number; starred: number; dismissed: number }) {
|
||||
const slide = pres.addSlide()
|
||||
addBg(slide)
|
||||
|
||||
// Dark left panel
|
||||
slide.addShape('rect', { x: 0, y: 0, w: '100%', h: '100%', fill: { color: T.primary } })
|
||||
|
||||
slide.addText('BILAN DE SESSION', {
|
||||
x: 0.8, y: 0.8, w: 8.4, h: 0.6,
|
||||
fontSize: 10, fontFace: 'Arial', color: T.accent, bold: true, charSpacing: 5, align: 'left',
|
||||
})
|
||||
|
||||
slide.addText(truncate(session.seedIdea, 70), {
|
||||
x: 0.8, y: 1.55, w: 8.4, h: 1.0,
|
||||
fontSize: 20, fontFace: 'Georgia', color: 'FFFFFF', align: 'left', wrap: true,
|
||||
})
|
||||
|
||||
// Divider
|
||||
slide.addShape('rect', { x: 0.8, y: 2.8, w: 8.4, h: 0.02, fill: { color: T.accent } })
|
||||
|
||||
// Stats grid
|
||||
const statCols = [
|
||||
{ label: 'IDÉES GÉNÉRÉES', value: String(stats.total), color: T.wave2 },
|
||||
{ label: 'CONVERTIES EN NOTES', value: String(stats.converted), color: T.green },
|
||||
{ label: 'FAVORITES', value: String(stats.starred), color: T.wave1 },
|
||||
{ label: 'REJETÉES', value: String(stats.dismissed), color: T.muted },
|
||||
]
|
||||
|
||||
statCols.forEach((s, i) => {
|
||||
const x = 0.8 + i * 2.3
|
||||
slide.addText(s.value, { x, y: 3.2, w: 2.2, h: 0.9, fontSize: 36, fontFace: 'Georgia', color: s.color, bold: true, align: 'left' })
|
||||
slide.addText(s.label, { x, y: 4.1, w: 2.2, h: 0.5, fontSize: 8, fontFace: 'Arial', color: T.muted, align: 'left', charSpacing: 1.5, wrap: true })
|
||||
})
|
||||
|
||||
slide.addText('Généré par Momento', {
|
||||
x: 0.8, y: 5.5, w: 8.4, h: 0.3,
|
||||
fontSize: 8, fontFace: 'Arial', color: T.muted, align: 'right', italic: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Main export function ─────────────────────────────────────────────────────
|
||||
|
||||
export async function generateBrainstormPptx(session: SessionLike): Promise<{ buffer: Buffer; filename: string }> {
|
||||
const PptxGenJS = await getPptxGenClass()
|
||||
const pres = new PptxGenJS()
|
||||
|
||||
pres.layout = 'LAYOUT_WIDE'
|
||||
pres.author = 'Momento'
|
||||
pres.subject = `Brainstorm: ${session.seedIdea}`
|
||||
|
||||
const activeIdeas = session.ideas.filter(i => i.status !== 'dismissed')
|
||||
const dismissedCount = session.ideas.filter(i => i.status === 'dismissed').length
|
||||
const converted = activeIdeas.filter(i => i.convertedToNoteId !== null)
|
||||
const starred = activeIdeas.filter(i => i.isStarred)
|
||||
|
||||
const stats = {
|
||||
total: activeIdeas.length,
|
||||
converted: converted.length,
|
||||
starred: starred.length,
|
||||
dismissed: dismissedCount,
|
||||
}
|
||||
|
||||
// Slide 1 — Cover
|
||||
buildCoverSlide(pres, session, stats)
|
||||
|
||||
// Slides 2-4 — One per active wave
|
||||
for (const wave of [1, 2, 3]) {
|
||||
const waveIdeas = activeIdeas.filter(i => i.waveNumber === wave)
|
||||
if (waveIdeas.length === 0) continue
|
||||
buildWaveSlide(pres, wave, waveIdeas)
|
||||
}
|
||||
|
||||
// Slide N — Top ideas (starred + converted)
|
||||
if (starred.length > 0 || converted.length > 0) {
|
||||
buildTopIdeasSlide(pres, starred, converted)
|
||||
}
|
||||
|
||||
// Last slide — Summary
|
||||
buildSummarySlide(pres, session, stats)
|
||||
|
||||
const buffer = (await pres.write({ outputType: 'nodebuffer' })) as unknown as Buffer
|
||||
const filename = `brainstorm-${slugify(session.seedIdea)}.pptx`
|
||||
|
||||
return { buffer, filename }
|
||||
}
|
||||
209
memento-note/lib/editor/markdown-export.ts
Normal file
209
memento-note/lib/editor/markdown-export.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* markdown-export.ts
|
||||
* Utilities for TipTap HTML ↔ Markdown conversion.
|
||||
*
|
||||
* Uses:
|
||||
* - turndown (+ turndown-plugin-gfm) : HTML → Markdown
|
||||
* - marked : Markdown → HTML
|
||||
*/
|
||||
|
||||
import TurndownService from 'turndown'
|
||||
import { tables, taskListItems, strikethrough } from 'turndown-plugin-gfm'
|
||||
import { marked } from 'marked'
|
||||
|
||||
// ── Markdown heuristic detection ────────────────────────────────────────────
|
||||
|
||||
const MARKDOWN_PATTERNS = [
|
||||
/^#{1,6}\s/m, // headings
|
||||
/^\s*[-*+]\s/m, // unordered list
|
||||
/^\s*\d+\.\s/m, // ordered list
|
||||
/^\s*>\s/m, // blockquote
|
||||
/^```/m, // code fence
|
||||
/`[^`]+`/, // inline code
|
||||
/\*\*[^*]+\*\*/, // bold
|
||||
/\*[^*]+\*/, // italic
|
||||
/^[|].+[|]/m, // table
|
||||
/\[.+\]\(.+\)/, // link
|
||||
/!\[.+\]\(.+\)/, // image
|
||||
/~~[^~]+~~/, // strikethrough
|
||||
]
|
||||
|
||||
/**
|
||||
* Returns true if the given plain text looks like it contains Markdown syntax.
|
||||
* Used by the paste handler to decide whether to convert before inserting.
|
||||
*/
|
||||
export function looksLikeMarkdown(text: string): boolean {
|
||||
if (!text || text.trim().length < 3) return false
|
||||
return MARKDOWN_PATTERNS.some((re) => re.test(text))
|
||||
}
|
||||
|
||||
// ── Turndown service factory ─────────────────────────────────────────────────
|
||||
|
||||
function createTurndownService(): TurndownService {
|
||||
const td = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
hr: '---',
|
||||
bulletListMarker: '-',
|
||||
codeBlockStyle: 'fenced',
|
||||
fence: '```',
|
||||
emDelimiter: '_',
|
||||
strongDelimiter: '**',
|
||||
linkStyle: 'inlined',
|
||||
linkReferenceStyle: 'full',
|
||||
})
|
||||
|
||||
// GFM plugins: tables + task lists + strikethrough
|
||||
td.use([tables, taskListItems, strikethrough])
|
||||
|
||||
// Custom rule: liveBlock → HTML comment
|
||||
td.addRule('liveBlock', {
|
||||
filter(node) {
|
||||
return (
|
||||
node.nodeName === 'DIV' &&
|
||||
(node as HTMLElement).hasAttribute('data-live-block')
|
||||
)
|
||||
},
|
||||
replacement(_content, node) {
|
||||
const el = node as HTMLElement
|
||||
const sourceNoteId = el.getAttribute('sourcenoteId') || el.getAttribute('sourcenoteId') || el.getAttribute('sourcenoteid') || ''
|
||||
const blockId = el.getAttribute('blockId') || el.getAttribute('blockid') || ''
|
||||
return `\n\n<!-- live-block: ${sourceNoteId}#${blockId} -->\n\n`
|
||||
},
|
||||
})
|
||||
|
||||
// Custom rule: structuredViewBlock → HTML comment
|
||||
td.addRule('structuredViewBlock', {
|
||||
filter(node) {
|
||||
return (
|
||||
node.nodeName === 'DIV' &&
|
||||
(node as HTMLElement).hasAttribute('data-structured-view-block')
|
||||
)
|
||||
},
|
||||
replacement(_content, node) {
|
||||
const el = node as HTMLElement
|
||||
const attrs: Record<string, string> = {}
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (attr.name !== 'data-structured-view-block') {
|
||||
attrs[attr.name] = attr.value
|
||||
}
|
||||
}
|
||||
return `\n\n<!-- structured-view: ${JSON.stringify(attrs)} -->\n\n`
|
||||
},
|
||||
})
|
||||
|
||||
return td
|
||||
}
|
||||
|
||||
// Singleton (lazy-init) — safe for server + client usage
|
||||
let _tdService: TurndownService | null = null
|
||||
function getTurndownService(): TurndownService {
|
||||
if (!_tdService) _tdService = createTurndownService()
|
||||
return _tdService
|
||||
}
|
||||
|
||||
// ── Custom node pre-processor ─────────────────────────────────────────────
|
||||
|
||||
// Sentinel prefix — alphanumeric only to avoid Markdown escaping by turndown
|
||||
const SENTINEL_PREFIX = 'MOMENTOBLOCKSENTINEL'
|
||||
|
||||
interface BlockPlaceholder {
|
||||
key: string
|
||||
comment: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-process HTML before passing to turndown:
|
||||
* - Replace empty custom node divs (liveBlock, structuredViewBlock) with text
|
||||
* placeholders so they survive turndown processing (turndown drops blank nodes
|
||||
* and strips HTML comments).
|
||||
* - Return the modified HTML and a map of placeholder → HTML comment.
|
||||
*/
|
||||
function preprocessCustomNodes(html: string): { html: string; placeholders: BlockPlaceholder[] } {
|
||||
const placeholders: BlockPlaceholder[] = []
|
||||
|
||||
// liveBlock: <div data-live-block="true" sourceNoteId="..." blockId="..."></div>
|
||||
let result = html.replace(
|
||||
/<div([^>]*?data-live-block[^>]*?)>\s*<\/div>/gi,
|
||||
(_match, attrs) => {
|
||||
const snId = (attrs.match(/sourcenoteid="([^"]*)"/i) || attrs.match(/sourcenoteid='([^']*)'/i) || [])[1] || ''
|
||||
const bId = (attrs.match(/blockid="([^"]*)"/i) || attrs.match(/blockid='([^']*)'/i) || [])[1] || ''
|
||||
const key = `${SENTINEL_PREFIX}LIVEBLOCK${placeholders.length}`
|
||||
placeholders.push({ key, comment: `<!-- live-block: ${snId}#${bId} -->` })
|
||||
return `<p>${key}</p>`
|
||||
}
|
||||
)
|
||||
|
||||
// structuredViewBlock: <div data-structured-view-block="true" ...></div>
|
||||
result = result.replace(
|
||||
/<div([^>]*?data-structured-view-block[^>]*?)>\s*<\/div>/gi,
|
||||
(_match, attrs) => {
|
||||
const attrMap: Record<string, string> = {}
|
||||
const attrRe = /(data-[a-z-]+)="([^"]*)"/gi
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = attrRe.exec(attrs)) !== null) {
|
||||
if (m[1] !== 'data-structured-view-block') attrMap[m[1]] = m[2]
|
||||
}
|
||||
const key = `${SENTINEL_PREFIX}SVBLOCK${placeholders.length}`
|
||||
placeholders.push({ key, comment: `<!-- structured-view: ${JSON.stringify(attrMap)} -->` })
|
||||
return `<p>${key}</p>`
|
||||
}
|
||||
)
|
||||
|
||||
return { html: result, placeholders }
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process the markdown output: replace sentinel placeholders with HTML comments.
|
||||
*/
|
||||
function postprocessPlaceholders(md: string, placeholders: BlockPlaceholder[]): string {
|
||||
let result = md
|
||||
for (const { key, comment } of placeholders) {
|
||||
result = result.replace(key, `\n\n${comment}\n\n`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── HTML → Markdown ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a TipTap-generated HTML string to GitHub-Flavored Markdown.
|
||||
* Custom nodes (liveBlock, structuredViewBlock) are serialised as HTML comments.
|
||||
*/
|
||||
export function tiptapHTMLToMarkdown(html: string): string {
|
||||
if (!html || html.trim() === '') return ''
|
||||
const { html: preprocessed, placeholders } = preprocessCustomNodes(html)
|
||||
const td = getTurndownService()
|
||||
const md = td.turndown(preprocessed).trim()
|
||||
return postprocessPlaceholders(md, placeholders).trim()
|
||||
}
|
||||
|
||||
// ── Markdown → HTML ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a Markdown string to HTML suitable for injection into TipTap via
|
||||
* `editor.commands.setContent(html)`.
|
||||
*
|
||||
* Uses marked with GFM enabled (tables, task lists, line breaks).
|
||||
*/
|
||||
export function markdownToHTML(markdown: string): string {
|
||||
if (!markdown || markdown.trim() === '') return ''
|
||||
|
||||
// marked v18+ uses synchronous parse by default when no async tokens
|
||||
const html = marked.parse(markdown, {
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
}) as string
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// ── Title extraction from Markdown ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the first H1 title from a Markdown string.
|
||||
* Returns null if no H1 is found.
|
||||
*/
|
||||
export function extractMarkdownTitle(markdown: string): string | null {
|
||||
const match = markdown.match(/^#\s+(.+)/m)
|
||||
return match ? match[1].trim() : null
|
||||
}
|
||||
50
memento-note/lib/editor/markdown-paste-extension.ts
Normal file
50
memento-note/lib/editor/markdown-paste-extension.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* markdown-paste-extension.ts
|
||||
*
|
||||
* TipTap extension that intercepts paste events. If the pasted plain text
|
||||
* looks like Markdown, it converts it to HTML and inserts it into the editor
|
||||
* as structured TipTap nodes — instead of inserting it as raw text.
|
||||
*/
|
||||
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { looksLikeMarkdown, markdownToHTML } from './markdown-export'
|
||||
|
||||
const MARKDOWN_PASTE_KEY = new PluginKey('markdownPaste')
|
||||
|
||||
export const MarkdownPasteExtension = Extension.create({
|
||||
name: 'markdownPaste',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: MARKDOWN_PASTE_KEY,
|
||||
props: {
|
||||
handlePaste(_view, event) {
|
||||
const text = event.clipboardData?.getData('text/plain')
|
||||
if (!text || !looksLikeMarkdown(text)) return false
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
try {
|
||||
const html = markdownToHTML(text)
|
||||
// Schedule after current event loop to avoid transaction conflicts
|
||||
setTimeout(() => {
|
||||
editor.commands.insertContent(html, {
|
||||
parseOptions: { preserveWhitespace: 'full' },
|
||||
})
|
||||
}, 0)
|
||||
} catch {
|
||||
// Fallback: let TipTap handle the paste normally
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -2176,7 +2176,12 @@
|
||||
"Italiano": "الإيطالية",
|
||||
"Chinois": "الصينية",
|
||||
"Japonais": "اليابانية"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "تصدير كـ Markdown",
|
||||
"importMarkdown": "استيراد Markdown",
|
||||
"markdownExportSuccess": "تم تصدير الملاحظة كـ Markdown",
|
||||
"markdownExportError": "فشل تصدير الملاحظة",
|
||||
"markdownImportSuccess": "تم استيراد Markdown بنجاح"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2306,7 +2311,16 @@
|
||||
"ownerBadge": "Owner",
|
||||
"waveBadge": "Wave {wave}",
|
||||
"quotaGuest": "استنفد مضيف الجلسة حدّ الذكاء الاصطناعي. اطلب منه ترقية خطته.",
|
||||
"quotaHost": "لقد وصلت إلى حدّ الذكاء الاصطناعي لهذه الجلسة. رقِّ خطتك للمتابعة."
|
||||
"quotaHost": "لقد وصلت إلى حدّ الذكاء الاصطناعي لهذه الجلسة. رقِّ خطتك للمتابعة.",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "تنزيل كـ PowerPoint",
|
||||
"pptxSuccess": "تم تنزيل PPTX",
|
||||
"pptxError": "فشل تصدير PPTX",
|
||||
"fitToScreen": "إعادة التمركز",
|
||||
"legendWave1": "التنويعات",
|
||||
"legendWave2": "التشابهات",
|
||||
"legendWave3": "الاضطرابات",
|
||||
"legendConverted": "محوّل"
|
||||
},
|
||||
"usageMeter": {
|
||||
"packName": "AI Discovery Pack",
|
||||
@@ -2762,5 +2776,96 @@
|
||||
"uploadError": "خطأ في الرفع",
|
||||
"uploadFailed": "فشل الرفع",
|
||||
"uploading": "جاري الرفع..."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "ذاكرتك المعززة بالذكاء الاصطناعي",
|
||||
"welcome_subtitle": "Momento يتذكر ما تنساه.",
|
||||
"welcome_cta": "ابدأ",
|
||||
"skip": "تخطي",
|
||||
"step_notes_title": "ملاحظاتك",
|
||||
"step_notes_empty": "ليس لديك ملاحظات بعد. استورد ملاحظاتك أو ابدأ بأمثلة.",
|
||||
"step_notes_import": "استيراد ملاحظاتي",
|
||||
"step_notes_demo": "إنشاء 5 ملاحظات تجريبية",
|
||||
"step_notes_has_notes": "لديك بالفعل {count} ملاحظة. دعنا نكتشف السحر.",
|
||||
"step_notes_cta": "ملاحظاتي جاهزة",
|
||||
"step_aha_title": "اعثر على ما نسيته",
|
||||
"step_aha_subtitle": "اطرح سؤالاً. اعثر على ملاحظة نسيتها.",
|
||||
"step_aha_placeholder": "ملاحظات حول الإنتاجية...",
|
||||
"step_aha_cta": "استكشف Momento",
|
||||
"progress": "{current} من {total}",
|
||||
"creating_demo_notes": "جارٍ إنشاء الملاحظات التجريبية...",
|
||||
"demo_notes_ready": "تم إنشاء 5 ملاحظات تجريبية!",
|
||||
"badge_credits": "⚡ {count} رصيد متبقٍ",
|
||||
"badge_upgrade": "الترقية إلى Pro →",
|
||||
"no_results": "لا نتائج — جرّب استعلامًا آخر.",
|
||||
"search_credit_used": "تم استخدام بحث واحد",
|
||||
"quota_exceeded": "تم استنفاد حصة البحث — انتقل إلى Pro.",
|
||||
"step_aha_search_button": "بحث",
|
||||
"step_aha_search_aria": "ابحث في ملاحظاتك",
|
||||
"step_notes_hint": "💡 ستُغذِّي هذه الملاحظات عرض البحث بالذكاء الاصطناعي في الخطوة التالية.",
|
||||
"step_features_title": "قدراتك الخارقة بالذكاء الاصطناعي",
|
||||
"step_features_subtitle": "اختر من أين تبدأ.",
|
||||
"step_features_cta": "لنبدأ!",
|
||||
"feature_search_title": "البحث الدلالي",
|
||||
"feature_search_desc": "ابحث عن أي ملاحظة بالمعنى، ليس فقط بالكلمات المفتاحية.",
|
||||
"feature_flashcards_title": "بطاقات الذكاء الاصطناعي",
|
||||
"feature_flashcards_desc": "أنشئ بطاقات مراجعة SRS من ملاحظاتك بنقرة واحدة.",
|
||||
"feature_brainstorm_title": "العصف الذهني بالذكاء الاصطناعي",
|
||||
"feature_brainstorm_desc": "جلسات عصف ذهني تعاوني مدعومة بالذكاء الاصطناعي.",
|
||||
"feature_chat_title": "الدردشة مع ملاحظاتك",
|
||||
"feature_chat_desc": "اطرح أسئلة على قاعدة معرفتك الشخصية.",
|
||||
"feature_insights_title": "رؤى دلالية",
|
||||
"feature_insights_desc": "اكتشف الروابط الخفية بين أفكارك.",
|
||||
"feature_export_title": "تصدير Markdown",
|
||||
"feature_export_desc": "استورد وصدِّر ملاحظاتك بتنسيق Markdown القياسي.",
|
||||
"welcome_title_name": "مرحباً {name} 👋",
|
||||
"import_formats": "الصيغ المقبولة: .md, .txt",
|
||||
"import_error": "تعذر استيراد بعض الملفات. يرجى المحاولة مرة أخرى.",
|
||||
"import_notes_ready": "تم استيراد {count} ملاحظة!",
|
||||
"action_write_title": "اكتب ملاحظتك الأولى الحقيقية",
|
||||
"action_write_desc": "أنشئ ملاحظة وابدأ في تدوين أفكارك.",
|
||||
"action_flashcards_title": "أنشئ أول بطاقاتك التعليمية",
|
||||
"action_flashcards_desc": "افتح ملاحظة وانقر على زر البطاقات.",
|
||||
"action_brainstorm_title": "ابدأ عصفاً ذهنياً بالذكاء الاصطناعي",
|
||||
"action_brainstorm_desc": "استكشف أفكارك مع وكيل الذكاء الاصطناعي.",
|
||||
"action_try": "جرّب",
|
||||
"step_features_cta_all": "كل شيء جاهز — لنبدأ!",
|
||||
"action_write_where": "أغلق → انقر على \"+ ملاحظة جديدة\" في الشريط الجانبي",
|
||||
"action_flashcards_where": "أغلق → افتح ملاحظة → زر 🃏 في شريط الأدوات",
|
||||
"action_brainstorm_where": "أغلق → قسم \"Canvas\" في الشريط الجانبي",
|
||||
"pill_resume": "✨ استئناف الجولة",
|
||||
"action_done": "تم!",
|
||||
"editor_hints_title": "Editor tips",
|
||||
"editor_hints_got_it": "Got it!",
|
||||
"hint_slash_title": "\"/\" command — insert blocks",
|
||||
"hint_slash_desc": "In the editor, type \"/\" to open the block menu: heading, list, code block, table, to-do list, and AI commands (Clarify, Shorten, Improve, Expand).",
|
||||
"hint_ai_title": "Built-in AI assistant",
|
||||
"hint_ai_desc": "Click the ✨ button in the toolbar to open the AI panel — ask questions, summarize, rewrite, or brainstorm directly in your note.",
|
||||
"hint_version_title": "Version history",
|
||||
"hint_version_desc": "Click the ⓘ button in the toolbar → \"Versions\" tab. Enable versioning, then save and restore snapshots of your note at any time.",
|
||||
"hint_flashcards_title": "Generate flashcards",
|
||||
"hint_flashcards_desc": "Click the 🎓 button in the toolbar to auto-generate flashcards from your note for spaced repetition review.",
|
||||
"hint_links_title": "Links between notes",
|
||||
"hint_links_desc": "Type \"[[\" in the editor to search and link to another note. Linked notes appear as backlinks at the bottom of the note.",
|
||||
"hint_create_note_title": "Create a note",
|
||||
"hint_create_note_desc": "Click the \"+\" button in the sidebar or press Ctrl+N to create a new note. Then start writing.",
|
||||
"hint_flip_title": "Flip the card",
|
||||
"hint_flip_desc": "Press Space (or click the card) to flip it and reveal the answer.",
|
||||
"hint_rate_keys_title": "Rate with keyboard",
|
||||
"hint_rate_keys_desc": "After flipping, press 1 (Hard), 2 (Difficult), 3 (Good) or 4 (Easy) to rate the card. The SM-2 algorithm schedules your next review automatically.",
|
||||
"hint_generate_from_note_title": "Generate from a note",
|
||||
"hint_generate_from_note_desc": "Open any note and click the 🎓 button in the toolbar to automatically generate flashcards from its content.",
|
||||
"hint_brainstorm_start_title": "Start with an idea",
|
||||
"hint_brainstorm_start_desc": "Type any concept or question in the input field and press Enter. The AI will generate a set of ideas around it.",
|
||||
"hint_brainstorm_deepen_title": "Deepen an idea",
|
||||
"hint_brainstorm_deepen_desc": "Click on any idea card to expand it with sub-ideas and explore it further.",
|
||||
"hint_brainstorm_export_title": "Export your session",
|
||||
"hint_brainstorm_export_desc": "When done, export the entire brainstorm session as a structured note saved to your notebook.",
|
||||
"hint_insights_clusters_title": "Note clusters",
|
||||
"hint_insights_clusters_desc": "Your notes are automatically grouped into thematic clusters. Click a cluster to explore the notes it contains.",
|
||||
"hint_insights_bridge_title": "Bridge notes",
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2176,7 +2176,12 @@
|
||||
"Italiano": "Italienisch",
|
||||
"Chinois": "Chinesisch",
|
||||
"Japonais": "Japanisch"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "Als Markdown exportieren",
|
||||
"importMarkdown": "Markdown importieren",
|
||||
"markdownExportSuccess": "Notiz als Markdown exportiert",
|
||||
"markdownExportError": "Export der Notiz fehlgeschlagen",
|
||||
"markdownImportSuccess": "Markdown erfolgreich importiert"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2306,7 +2311,16 @@
|
||||
"ownerBadge": "Owner",
|
||||
"waveBadge": "Wave {wave}",
|
||||
"quotaGuest": "Der Gastgeber der Sitzung hat sein KI-Kontingent aufgebraucht. Bitte ihn, seinen Tarif zu erweitern.",
|
||||
"quotaHost": "Sie haben Ihr KI-Kontingent für dieses Brainstorming erreicht. Wechseln Sie den Tarif, um fortzufahren."
|
||||
"quotaHost": "Sie haben Ihr KI-Kontingent für dieses Brainstorming erreicht. Wechseln Sie den Tarif, um fortzufahren.",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "Als PowerPoint herunterladen",
|
||||
"pptxSuccess": "PPTX heruntergeladen",
|
||||
"pptxError": "PPTX-Export fehlgeschlagen",
|
||||
"fitToScreen": "Zentrieren",
|
||||
"legendWave1": "Variationen",
|
||||
"legendWave2": "Analogien",
|
||||
"legendWave3": "Unterbrechungen",
|
||||
"legendConverted": "Konvertiert"
|
||||
},
|
||||
"usageMeter": {
|
||||
"packName": "AI Discovery Pack",
|
||||
@@ -2762,5 +2776,96 @@
|
||||
"uploadError": "Upload-Fehler",
|
||||
"uploadFailed": "Upload fehlgeschlagen",
|
||||
"uploading": "Wird hochgeladen..."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "Ihr KI-erweitertes Gedächtnis",
|
||||
"welcome_subtitle": "Momento erinnert sich an das, was Sie vergessen.",
|
||||
"welcome_cta": "Loslegen",
|
||||
"skip": "Überspringen",
|
||||
"step_notes_title": "Ihre Notizen",
|
||||
"step_notes_empty": "Sie haben noch keine Notizen. Importieren Sie Ihre oder beginnen Sie mit Beispielen.",
|
||||
"step_notes_import": "Notizen importieren",
|
||||
"step_notes_demo": "5 Beispielnotizen erstellen",
|
||||
"step_notes_has_notes": "Sie haben bereits {count} Notizen. Entdecken wir die Magie.",
|
||||
"step_notes_cta": "Meine Notizen sind bereit",
|
||||
"step_aha_title": "Finden Sie, was Sie vergessen haben",
|
||||
"step_aha_subtitle": "Stellen Sie eine Frage. Finden Sie eine vergessene Notiz.",
|
||||
"step_aha_placeholder": "Notizen zur Produktivität...",
|
||||
"step_aha_cta": "Momento erkunden",
|
||||
"progress": "{current} von {total}",
|
||||
"creating_demo_notes": "Beispielnotizen werden erstellt...",
|
||||
"demo_notes_ready": "5 Beispielnotizen erstellt!",
|
||||
"badge_credits": "⚡ Noch {count} Credits",
|
||||
"badge_upgrade": "Auf Pro upgraden →",
|
||||
"no_results": "Keine Ergebnisse — andere Anfrage versuchen.",
|
||||
"search_credit_used": "1 Suche verwendet",
|
||||
"quota_exceeded": "Suchlimit erreicht — auf Pro upgraden für unbegrenzte Suchen.",
|
||||
"step_aha_search_button": "Suchen",
|
||||
"step_aha_search_aria": "Notizen durchsuchen",
|
||||
"step_notes_hint": "💡 Diese Notizen ermöglichen die KI-Suchdemo im nächsten Schritt.",
|
||||
"step_features_title": "Ihre KI-Superkräfte",
|
||||
"step_features_subtitle": "Wählen Sie Ihren Einstieg.",
|
||||
"step_features_cta": "Los geht's!",
|
||||
"feature_search_title": "Semantische Suche",
|
||||
"feature_search_desc": "Finden Sie jede Notiz nach Bedeutung, nicht nur nach Schlüsselwörtern.",
|
||||
"feature_flashcards_title": "KI-Karteikarten",
|
||||
"feature_flashcards_desc": "SRS-Lernkarten aus Ihren Notizen in einem Klick erstellen.",
|
||||
"feature_brainstorm_title": "KI-Brainstorming",
|
||||
"feature_brainstorm_desc": "KI-gestützte kollaborative Brainstorming-Sitzungen.",
|
||||
"feature_chat_title": "Mit Ihren Notizen chatten",
|
||||
"feature_chat_desc": "Stellen Sie Ihrer persönlichen Wissensdatenbank Fragen.",
|
||||
"feature_insights_title": "Semantische Einblicke",
|
||||
"feature_insights_desc": "Entdecken Sie versteckte Verbindungen zwischen Ihren Ideen.",
|
||||
"feature_export_title": "Markdown-Export",
|
||||
"feature_export_desc": "Importieren und exportieren Sie Ihre Notizen im Markdown-Format.",
|
||||
"welcome_title_name": "Hallo {name} 👋",
|
||||
"import_formats": "Akzeptierte Formate: .md, .txt",
|
||||
"import_error": "Einige Dateien konnten nicht importiert werden. Bitte erneut versuchen.",
|
||||
"import_notes_ready": "{count} Notiz(en) importiert!",
|
||||
"action_write_title": "Ihre erste echte Notiz schreiben",
|
||||
"action_write_desc": "Erstellen Sie eine Notiz und beginnen Sie Ideen festzuhalten.",
|
||||
"action_flashcards_title": "Erste Karteikarten erstellen",
|
||||
"action_flashcards_desc": "Öffnen Sie eine Notiz und klicken Sie auf Karteikarten.",
|
||||
"action_brainstorm_title": "KI-Brainstorming starten",
|
||||
"action_brainstorm_desc": "Erkunden Sie Ihre Ideen mit einem KI-Agenten.",
|
||||
"action_try": "Ausprobieren",
|
||||
"step_features_cta_all": "Alles bereit — los geht's!",
|
||||
"action_write_where": "Schließen → \"+ Neue Notiz\" in der Seitenleiste klicken",
|
||||
"action_flashcards_where": "Schließen → Notiz öffnen → 🃏-Button in der Toolbar",
|
||||
"action_brainstorm_where": "Schließen → \"Canvas\"-Bereich in der Seitenleiste",
|
||||
"pill_resume": "✨ Tour fortsetzen",
|
||||
"action_done": "Getestet!",
|
||||
"editor_hints_title": "Editor tips",
|
||||
"editor_hints_got_it": "Got it!",
|
||||
"hint_slash_title": "\"/\" command — insert blocks",
|
||||
"hint_slash_desc": "In the editor, type \"/\" to open the block menu: heading, list, code block, table, to-do list, and AI commands (Clarify, Shorten, Improve, Expand).",
|
||||
"hint_ai_title": "Built-in AI assistant",
|
||||
"hint_ai_desc": "Click the ✨ button in the toolbar to open the AI panel — ask questions, summarize, rewrite, or brainstorm directly in your note.",
|
||||
"hint_version_title": "Version history",
|
||||
"hint_version_desc": "Click the ⓘ button in the toolbar → \"Versions\" tab. Enable versioning, then save and restore snapshots of your note at any time.",
|
||||
"hint_flashcards_title": "Generate flashcards",
|
||||
"hint_flashcards_desc": "Click the 🎓 button in the toolbar to auto-generate flashcards from your note for spaced repetition review.",
|
||||
"hint_links_title": "Links between notes",
|
||||
"hint_links_desc": "Type \"[[\" in the editor to search and link to another note. Linked notes appear as backlinks at the bottom of the note.",
|
||||
"hint_create_note_title": "Create a note",
|
||||
"hint_create_note_desc": "Click the \"+\" button in the sidebar or press Ctrl+N to create a new note. Then start writing.",
|
||||
"hint_flip_title": "Flip the card",
|
||||
"hint_flip_desc": "Press Space (or click the card) to flip it and reveal the answer.",
|
||||
"hint_rate_keys_title": "Rate with keyboard",
|
||||
"hint_rate_keys_desc": "After flipping, press 1 (Hard), 2 (Difficult), 3 (Good) or 4 (Easy) to rate the card. The SM-2 algorithm schedules your next review automatically.",
|
||||
"hint_generate_from_note_title": "Generate from a note",
|
||||
"hint_generate_from_note_desc": "Open any note and click the 🎓 button in the toolbar to automatically generate flashcards from its content.",
|
||||
"hint_brainstorm_start_title": "Start with an idea",
|
||||
"hint_brainstorm_start_desc": "Type any concept or question in the input field and press Enter. The AI will generate a set of ideas around it.",
|
||||
"hint_brainstorm_deepen_title": "Deepen an idea",
|
||||
"hint_brainstorm_deepen_desc": "Click on any idea card to expand it with sub-ideas and explore it further.",
|
||||
"hint_brainstorm_export_title": "Export your session",
|
||||
"hint_brainstorm_export_desc": "When done, export the entire brainstorm session as a structured note saved to your notebook.",
|
||||
"hint_insights_clusters_title": "Note clusters",
|
||||
"hint_insights_clusters_desc": "Your notes are automatically grouped into thematic clusters. Click a cluster to explore the notes it contains.",
|
||||
"hint_insights_bridge_title": "Bridge notes",
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2429,7 +2429,12 @@
|
||||
"Italiano": "Italian",
|
||||
"Chinois": "Chinese",
|
||||
"Japonais": "Japanese"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "Export as Markdown",
|
||||
"importMarkdown": "Import Markdown",
|
||||
"markdownExportSuccess": "Note exported as Markdown",
|
||||
"markdownExportError": "Failed to export note",
|
||||
"markdownImportSuccess": "Markdown imported successfully"
|
||||
},
|
||||
"flashcards": {
|
||||
"generateTitle": "Generate flashcards",
|
||||
@@ -2495,7 +2500,7 @@
|
||||
"retentionRate": "Retention rate",
|
||||
"masteredLabel": "{count}/{total} mastered",
|
||||
"retentionCurve": "Weekly success rate",
|
||||
"retentionCurveHint": "Based on reviews with grade \u2265 Good (3 or 4)",
|
||||
"retentionCurveHint": "Based on reviews with grade ≥ Good (3 or 4)",
|
||||
"retentionNoDataYet": "Review more cards across multiple weeks to see your retention curve.",
|
||||
"streak": "Current streak",
|
||||
"streakDays": "days",
|
||||
@@ -2792,7 +2797,16 @@
|
||||
"ideasCount": "ideas",
|
||||
"star": "Star idea",
|
||||
"unstar": "Unstar idea",
|
||||
"renameSession": "Rename session"
|
||||
"renameSession": "Rename session",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "Download as PowerPoint",
|
||||
"pptxSuccess": "PPTX downloaded",
|
||||
"pptxError": "PPTX export failed",
|
||||
"fitToScreen": "Re-center",
|
||||
"legendWave1": "Variations",
|
||||
"legendWave2": "Analogies",
|
||||
"legendWave3": "Disruptions",
|
||||
"legendConverted": "Converted"
|
||||
},
|
||||
"byokSettings": {
|
||||
"title": "Your API keys (BYOK)",
|
||||
@@ -3461,5 +3475,96 @@
|
||||
"notesLoadError": "Error loading notes",
|
||||
"defaultOption1": "Option 1",
|
||||
"defaultOption2": "Option 2"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "Your AI-augmented memory",
|
||||
"welcome_subtitle": "Momento remembers what you forget.",
|
||||
"welcome_cta": "Get started",
|
||||
"skip": "Skip",
|
||||
"step_notes_title": "Your notes",
|
||||
"step_notes_empty": "You have no notes yet. Import yours or start with examples.",
|
||||
"step_notes_import": "Import my notes",
|
||||
"step_notes_demo": "Create 5 example notes",
|
||||
"step_notes_has_notes": "You already have {count} notes. Let's discover the magic.",
|
||||
"step_notes_cta": "My notes are ready",
|
||||
"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",
|
||||
"progress": "{current} of {total}",
|
||||
"creating_demo_notes": "Creating example notes...",
|
||||
"demo_notes_ready": "5 example notes created!",
|
||||
"badge_credits": "⚡ {count} credits left",
|
||||
"badge_upgrade": "Upgrade to Pro →",
|
||||
"no_results": "No results — try another query.",
|
||||
"search_credit_used": "1 search used",
|
||||
"quota_exceeded": "Search quota reached — upgrade to Pro for unlimited.",
|
||||
"step_aha_search_button": "Search",
|
||||
"step_aha_search_aria": "Search your notes",
|
||||
"step_notes_hint": "💡 These notes will power the AI search demo in the next step.",
|
||||
"step_features_title": "Your AI superpowers",
|
||||
"step_features_subtitle": "Choose where to start.",
|
||||
"step_features_cta": "Let's go!",
|
||||
"feature_search_title": "Semantic search",
|
||||
"feature_search_desc": "Find any note by meaning, not just keywords.",
|
||||
"feature_flashcards_title": "AI Flashcards",
|
||||
"feature_flashcards_desc": "Generate SRS review cards from your notes in one click.",
|
||||
"feature_brainstorm_title": "AI Brainstorm",
|
||||
"feature_brainstorm_desc": "AI-powered collaborative brainstorming sessions.",
|
||||
"feature_chat_title": "Chat with your notes",
|
||||
"feature_chat_desc": "Ask questions to your personal knowledge base.",
|
||||
"feature_insights_title": "Semantic insights",
|
||||
"feature_insights_desc": "Discover hidden connections between your ideas.",
|
||||
"feature_export_title": "Markdown export",
|
||||
"feature_export_desc": "Import and export your notes in standard Markdown format.",
|
||||
"welcome_title_name": "Hello {name} 👋",
|
||||
"import_formats": "Accepted formats: .md, .txt",
|
||||
"import_error": "Could not import some files. Please try again.",
|
||||
"import_notes_ready": "{count} note(s) imported!",
|
||||
"action_write_title": "Write your first real note",
|
||||
"action_write_desc": "Create a note and start capturing your ideas.",
|
||||
"action_flashcards_title": "Generate your first flashcards",
|
||||
"action_flashcards_desc": "Open a note and click the flashcards button.",
|
||||
"action_brainstorm_title": "Start an AI brainstorm",
|
||||
"action_brainstorm_desc": "Explore your ideas with a dedicated AI agent.",
|
||||
"action_try": "Try",
|
||||
"step_features_cta_all": "All done — let's dive in!",
|
||||
"action_write_where": "Close this → click \"+ New note\" in the sidebar",
|
||||
"action_flashcards_where": "Close this → open a note → 🃏 button in the toolbar",
|
||||
"action_brainstorm_where": "Close this → \"Canvas\" section in the sidebar",
|
||||
"pill_resume": "✨ Resume tour",
|
||||
"action_done": "Tried!",
|
||||
"editor_hints_title": "Editor tips",
|
||||
"editor_hints_got_it": "Got it!",
|
||||
"hint_slash_title": "\"/\" command — insert blocks",
|
||||
"hint_slash_desc": "In the editor, type \"/\" to open the block menu: heading, list, code block, table, to-do list, and AI commands (Clarify, Shorten, Improve, Expand).",
|
||||
"hint_ai_title": "Built-in AI assistant",
|
||||
"hint_ai_desc": "Click the ✨ button in the toolbar to open the AI panel — ask questions, summarize, rewrite, or brainstorm directly in your note.",
|
||||
"hint_version_title": "Version history",
|
||||
"hint_version_desc": "Click the ⓘ button in the toolbar → \"Versions\" tab. Enable versioning, then save and restore snapshots of your note at any time.",
|
||||
"hint_flashcards_title": "Generate flashcards",
|
||||
"hint_flashcards_desc": "Click the 🎓 button in the toolbar to auto-generate flashcards from your note for spaced repetition review.",
|
||||
"hint_links_title": "Links between notes",
|
||||
"hint_links_desc": "Type \"[[\" in the editor to search and link to another note. Linked notes appear as backlinks at the bottom of the note.",
|
||||
"hint_create_note_title": "Create a note",
|
||||
"hint_create_note_desc": "Click the \"+\" button in the sidebar or press Ctrl+N to create a new note. Then start writing.",
|
||||
"hint_flip_title": "Flip the card",
|
||||
"hint_flip_desc": "Press Space (or click the card) to flip it and reveal the answer.",
|
||||
"hint_rate_keys_title": "Rate with keyboard",
|
||||
"hint_rate_keys_desc": "After flipping, press 1 (Hard), 2 (Difficult), 3 (Good) or 4 (Easy) to rate the card. The SM-2 algorithm schedules your next review automatically.",
|
||||
"hint_generate_from_note_title": "Generate from a note",
|
||||
"hint_generate_from_note_desc": "Open any note and click the 🎓 button in the toolbar to automatically generate flashcards from its content.",
|
||||
"hint_brainstorm_start_title": "Start with an idea",
|
||||
"hint_brainstorm_start_desc": "Type any concept or question in the input field and press Enter. The AI will generate a set of ideas around it.",
|
||||
"hint_brainstorm_deepen_title": "Deepen an idea",
|
||||
"hint_brainstorm_deepen_desc": "Click on any idea card to expand it with sub-ideas and explore it further.",
|
||||
"hint_brainstorm_export_title": "Export your session",
|
||||
"hint_brainstorm_export_desc": "When done, export the entire brainstorm session as a structured note saved to your notebook.",
|
||||
"hint_insights_clusters_title": "Note clusters",
|
||||
"hint_insights_clusters_desc": "Your notes are automatically grouped into thematic clusters. Click a cluster to explore the notes it contains.",
|
||||
"hint_insights_bridge_title": "Bridge notes",
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
}
|
||||
}
|
||||
@@ -2176,7 +2176,12 @@
|
||||
"Italiano": "Italiano",
|
||||
"Chinois": "Chino",
|
||||
"Japonais": "Japonés"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "Exportar como Markdown",
|
||||
"importMarkdown": "Importar Markdown",
|
||||
"markdownExportSuccess": "Nota exportada como Markdown",
|
||||
"markdownExportError": "Error al exportar la nota",
|
||||
"markdownImportSuccess": "Markdown importado con éxito"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2306,7 +2311,16 @@
|
||||
"ownerBadge": "Owner",
|
||||
"waveBadge": "Wave {wave}",
|
||||
"quotaGuest": "El anfitrión de la sesión ha alcanzado su límite de IA. Pídele que mejore su plan.",
|
||||
"quotaHost": "Has alcanzado tu límite de IA para este brainstorm. Mejora tu plan para continuar."
|
||||
"quotaHost": "Has alcanzado tu límite de IA para este brainstorm. Mejora tu plan para continuar.",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "Descargar como PowerPoint",
|
||||
"pptxSuccess": "PPTX descargado",
|
||||
"pptxError": "Error al exportar PPTX",
|
||||
"fitToScreen": "Recentrar",
|
||||
"legendWave1": "Variaciones",
|
||||
"legendWave2": "Analogías",
|
||||
"legendWave3": "Disruptivas",
|
||||
"legendConverted": "Convertida"
|
||||
},
|
||||
"usageMeter": {
|
||||
"packName": "AI Discovery Pack",
|
||||
@@ -2762,5 +2776,96 @@
|
||||
"uploadError": "Error al subir",
|
||||
"uploadFailed": "Error al subir",
|
||||
"uploading": "Subiendo..."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "Tu memoria aumentada por IA",
|
||||
"welcome_subtitle": "Momento recuerda lo que olvidas.",
|
||||
"welcome_cta": "Empezar",
|
||||
"skip": "Omitir",
|
||||
"step_notes_title": "Tus notas",
|
||||
"step_notes_empty": "Aún no tienes notas. Importa las tuyas o empieza con ejemplos.",
|
||||
"step_notes_import": "Importar mis notas",
|
||||
"step_notes_demo": "Crear 5 notas de ejemplo",
|
||||
"step_notes_has_notes": "Ya tienes {count} notas. ¡Descubramos la magia!",
|
||||
"step_notes_cta": "Mis notas están listas",
|
||||
"step_aha_title": "Encuentra lo que olvidaste",
|
||||
"step_aha_subtitle": "Haz una pregunta. Encuentra una nota olvidada.",
|
||||
"step_aha_placeholder": "notas sobre productividad...",
|
||||
"step_aha_cta": "Explorar Momento",
|
||||
"progress": "{current} de {total}",
|
||||
"creating_demo_notes": "Creando notas de ejemplo...",
|
||||
"demo_notes_ready": "¡5 notas de ejemplo creadas!",
|
||||
"badge_credits": "⚡ {count} créditos restantes",
|
||||
"badge_upgrade": "Mejorar a Pro →",
|
||||
"no_results": "Sin resultados — intenta otra búsqueda.",
|
||||
"search_credit_used": "1 búsqueda utilizada",
|
||||
"quota_exceeded": "Cuota de búsqueda alcanzada — actualiza a Pro.",
|
||||
"step_aha_search_button": "Buscar",
|
||||
"step_aha_search_aria": "Buscar en tus notas",
|
||||
"step_notes_hint": "💡 Estas notas alimentarán la demo de búsqueda IA en el siguiente paso.",
|
||||
"step_features_title": "Tus superpoderes de IA",
|
||||
"step_features_subtitle": "Elige por dónde empezar.",
|
||||
"step_features_cta": "¡Vamos!",
|
||||
"feature_search_title": "Búsqueda semántica",
|
||||
"feature_search_desc": "Encuentra cualquier nota por significado, no solo por palabras clave.",
|
||||
"feature_flashcards_title": "Tarjetas IA",
|
||||
"feature_flashcards_desc": "Genera tarjetas de repaso SRS desde tus notas con un clic.",
|
||||
"feature_brainstorm_title": "Brainstorming IA",
|
||||
"feature_brainstorm_desc": "Sesiones de lluvia de ideas colaborativas con IA.",
|
||||
"feature_chat_title": "Chatea con tus notas",
|
||||
"feature_chat_desc": "Haz preguntas a tu base de conocimiento personal.",
|
||||
"feature_insights_title": "Perspectivas semánticas",
|
||||
"feature_insights_desc": "Descubre conexiones ocultas entre tus ideas.",
|
||||
"feature_export_title": "Exportación Markdown",
|
||||
"feature_export_desc": "Importa y exporta tus notas en formato Markdown estándar.",
|
||||
"welcome_title_name": "¡Hola {name} 👋",
|
||||
"import_formats": "Formatos aceptados: .md, .txt",
|
||||
"import_error": "No se pudieron importar algunos archivos. Inténtalo de nuevo.",
|
||||
"import_notes_ready": "¡{count} nota(s) importada(s)!",
|
||||
"action_write_title": "Escribe tu primera nota real",
|
||||
"action_write_desc": "Crea una nota y empieza a capturar tus ideas.",
|
||||
"action_flashcards_title": "Genera tus primeras tarjetas",
|
||||
"action_flashcards_desc": "Abre una nota y haz clic en el botón de tarjetas.",
|
||||
"action_brainstorm_title": "Inicia un brainstorming IA",
|
||||
"action_brainstorm_desc": "Explora tus ideas con un agente IA.",
|
||||
"action_try": "Probar",
|
||||
"step_features_cta_all": "¡Todo listo — ¡a sumergirnos!",
|
||||
"action_write_where": "Cierra esto → haz clic en \"+ Nueva nota\" en la barra lateral",
|
||||
"action_flashcards_where": "Cierra esto → abre una nota → botón 🃏 en la barra",
|
||||
"action_brainstorm_where": "Cierra esto → sección \"Canvas\" en la barra lateral",
|
||||
"pill_resume": "✨ Retomar visita",
|
||||
"action_done": "¡Probado!",
|
||||
"editor_hints_title": "Editor tips",
|
||||
"editor_hints_got_it": "Got it!",
|
||||
"hint_slash_title": "\"/\" command — insert blocks",
|
||||
"hint_slash_desc": "In the editor, type \"/\" to open the block menu: heading, list, code block, table, to-do list, and AI commands (Clarify, Shorten, Improve, Expand).",
|
||||
"hint_ai_title": "Built-in AI assistant",
|
||||
"hint_ai_desc": "Click the ✨ button in the toolbar to open the AI panel — ask questions, summarize, rewrite, or brainstorm directly in your note.",
|
||||
"hint_version_title": "Version history",
|
||||
"hint_version_desc": "Click the ⓘ button in the toolbar → \"Versions\" tab. Enable versioning, then save and restore snapshots of your note at any time.",
|
||||
"hint_flashcards_title": "Generate flashcards",
|
||||
"hint_flashcards_desc": "Click the 🎓 button in the toolbar to auto-generate flashcards from your note for spaced repetition review.",
|
||||
"hint_links_title": "Links between notes",
|
||||
"hint_links_desc": "Type \"[[\" in the editor to search and link to another note. Linked notes appear as backlinks at the bottom of the note.",
|
||||
"hint_create_note_title": "Create a note",
|
||||
"hint_create_note_desc": "Click the \"+\" button in the sidebar or press Ctrl+N to create a new note. Then start writing.",
|
||||
"hint_flip_title": "Flip the card",
|
||||
"hint_flip_desc": "Press Space (or click the card) to flip it and reveal the answer.",
|
||||
"hint_rate_keys_title": "Rate with keyboard",
|
||||
"hint_rate_keys_desc": "After flipping, press 1 (Hard), 2 (Difficult), 3 (Good) or 4 (Easy) to rate the card. The SM-2 algorithm schedules your next review automatically.",
|
||||
"hint_generate_from_note_title": "Generate from a note",
|
||||
"hint_generate_from_note_desc": "Open any note and click the 🎓 button in the toolbar to automatically generate flashcards from its content.",
|
||||
"hint_brainstorm_start_title": "Start with an idea",
|
||||
"hint_brainstorm_start_desc": "Type any concept or question in the input field and press Enter. The AI will generate a set of ideas around it.",
|
||||
"hint_brainstorm_deepen_title": "Deepen an idea",
|
||||
"hint_brainstorm_deepen_desc": "Click on any idea card to expand it with sub-ideas and explore it further.",
|
||||
"hint_brainstorm_export_title": "Export your session",
|
||||
"hint_brainstorm_export_desc": "When done, export the entire brainstorm session as a structured note saved to your notebook.",
|
||||
"hint_insights_clusters_title": "Note clusters",
|
||||
"hint_insights_clusters_desc": "Your notes are automatically grouped into thematic clusters. Click a cluster to explore the notes it contains.",
|
||||
"hint_insights_bridge_title": "Bridge notes",
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2211,7 +2211,12 @@
|
||||
"Italiano": "ایتالیایی",
|
||||
"Chinois": "چینی",
|
||||
"Japonais": "ژاپنی"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "صدور به فرمت Markdown",
|
||||
"importMarkdown": "وارد کردن Markdown",
|
||||
"markdownExportSuccess": "یادداشت به فرمت Markdown صادر شد",
|
||||
"markdownExportError": "خطا در صدور یادداشت",
|
||||
"markdownImportSuccess": "Markdown با موفقیت وارد شد"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2341,7 +2346,16 @@
|
||||
"ownerBadge": "Owner",
|
||||
"waveBadge": "Wave {wave}",
|
||||
"quotaGuest": "میزبان جلسه به سقف هوش مصنوعی رسیده. از او بخواهید طرحش را ارتقا دهد.",
|
||||
"quotaHost": "به سقف هوش مصنوعی این طوفان فکری رسیدید. برای ادامه، طرح خود را ارتقا دهید."
|
||||
"quotaHost": "به سقف هوش مصنوعی این طوفان فکری رسیدید. برای ادامه، طرح خود را ارتقا دهید.",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "دانلود بهصورت PowerPoint",
|
||||
"pptxSuccess": "PPTX دانلود شد",
|
||||
"pptxError": "خطا در خروجی PPTX",
|
||||
"fitToScreen": "بازمرکزیابی",
|
||||
"legendWave1": "تغییرات",
|
||||
"legendWave2": "قیاسها",
|
||||
"legendWave3": "اختلالات",
|
||||
"legendConverted": "تبدیلشده"
|
||||
},
|
||||
"usageMeter": {
|
||||
"packName": "AI Discovery Pack",
|
||||
@@ -2801,5 +2815,96 @@
|
||||
"uploadError": "خطای آپلود",
|
||||
"uploadFailed": "آپلود ناموفق",
|
||||
"uploading": "در حال آپلود..."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "حافظه شما با هوش مصنوعی",
|
||||
"welcome_subtitle": "Momento آنچه فراموش میکنید را به یاد میآورد.",
|
||||
"welcome_cta": "شروع",
|
||||
"skip": "رد کردن",
|
||||
"step_notes_title": "یادداشتهای شما",
|
||||
"step_notes_empty": "هنوز یادداشتی ندارید. یادداشتهایتان را وارد کنید یا با مثالها شروع کنید.",
|
||||
"step_notes_import": "وارد کردن یادداشتهایم",
|
||||
"step_notes_demo": "ایجاد ۵ یادداشت نمونه",
|
||||
"step_notes_has_notes": "شما از قبل {count} یادداشت دارید. بیایید جادو را کشف کنیم.",
|
||||
"step_notes_cta": "یادداشتهایم آماده است",
|
||||
"step_aha_title": "چیزی که فراموش کردهاید را پیدا کنید",
|
||||
"step_aha_subtitle": "سوال بپرسید. یادداشت فراموش شده را پیدا کنید.",
|
||||
"step_aha_placeholder": "یادداشتهای بهرهوری...",
|
||||
"step_aha_cta": "کشف Momento",
|
||||
"progress": "{current} از {total}",
|
||||
"creating_demo_notes": "در حال ایجاد یادداشتهای نمونه...",
|
||||
"demo_notes_ready": "۵ یادداشت نمونه ایجاد شد!",
|
||||
"badge_credits": "⚡ {count} اعتبار باقی",
|
||||
"badge_upgrade": "ارتقاء به Pro →",
|
||||
"no_results": "نتیجهای یافت نشد — پرسوجوی دیگری امتحان کنید.",
|
||||
"search_credit_used": "۱ جستجو استفاده شد",
|
||||
"quota_exceeded": "سهمیه جستجو تمام شد — به Pro ارتقا دهید.",
|
||||
"step_aha_search_button": "جستجو",
|
||||
"step_aha_search_aria": "جستجو در یادداشتهای شما",
|
||||
"step_notes_hint": "💡 این یادداشتها نمایش جستجوی هوش مصنوعی در مرحله بعد را تأمین میکنند.",
|
||||
"step_features_title": "قدرتهای فوقالعاده هوش مصنوعی شما",
|
||||
"step_features_subtitle": "انتخاب کنید از کجا شروع کنید.",
|
||||
"step_features_cta": "بزن بریم!",
|
||||
"feature_search_title": "جستجوی معنایی",
|
||||
"feature_search_desc": "هر یادداشتی را بر اساس معنا پیدا کنید، نه فقط کلمات کلیدی.",
|
||||
"feature_flashcards_title": "فلشکارتهای هوش مصنوعی",
|
||||
"feature_flashcards_desc": "کارتهای مرور SRS را با یک کلیک از یادداشتهایتان بسازید.",
|
||||
"feature_brainstorm_title": "طوفان فکری هوش مصنوعی",
|
||||
"feature_brainstorm_desc": "جلسات طوفان فکری مشارکتی با پشتیبانی هوش مصنوعی.",
|
||||
"feature_chat_title": "گفتگو با یادداشتها",
|
||||
"feature_chat_desc": "از پایگاه دانش شخصی خود سوال بپرسید.",
|
||||
"feature_insights_title": "بینشهای معنایی",
|
||||
"feature_insights_desc": "ارتباطهای پنهان بین ایدههایتان را کشف کنید.",
|
||||
"feature_export_title": "خروجی Markdown",
|
||||
"feature_export_desc": "یادداشتهایتان را با فرمت Markdown استاندارد وارد و صادر کنید.",
|
||||
"welcome_title_name": "سلام {name} 👋",
|
||||
"import_formats": "فرمتهای پذیرفتهشده: .md, .txt",
|
||||
"import_error": "برخی فایلها وارد نشدند. دوباره امتحان کنید.",
|
||||
"import_notes_ready": "{count} یادداشت وارد شد!",
|
||||
"action_write_title": "اولین یادداشت واقعی خود را بنویسید",
|
||||
"action_write_desc": "یادداشتی بسازید و ایدههایتان را ثبت کنید.",
|
||||
"action_flashcards_title": "اولین فلشکارتهایتان را بسازید",
|
||||
"action_flashcards_desc": "یک یادداشت باز کنید و روی دکمه فلشکارت کلیک کنید.",
|
||||
"action_brainstorm_title": "طوفان فکری هوش مصنوعی را شروع کنید",
|
||||
"action_brainstorm_desc": "ایدههایتان را با یک عامل هوش مصنوعی کشف کنید.",
|
||||
"action_try": "امتحان",
|
||||
"step_features_cta_all": "همه چیز آماده است — بریم!",
|
||||
"action_write_where": "ببند → روی \"+ یادداشت جدید\" در نوار کناری کلیک کنید",
|
||||
"action_flashcards_where": "ببند → یادداشتی باز کنید → دکمه 🃏 در نوار ابزار",
|
||||
"action_brainstorm_where": "ببند → بخش \"Canvas\" در نوار کناری",
|
||||
"pill_resume": "✨ ادامه راهنما",
|
||||
"action_done": "امتحان شد!",
|
||||
"editor_hints_title": "Editor tips",
|
||||
"editor_hints_got_it": "Got it!",
|
||||
"hint_slash_title": "\"/\" command — insert blocks",
|
||||
"hint_slash_desc": "In the editor, type \"/\" to open the block menu: heading, list, code block, table, to-do list, and AI commands (Clarify, Shorten, Improve, Expand).",
|
||||
"hint_ai_title": "Built-in AI assistant",
|
||||
"hint_ai_desc": "Click the ✨ button in the toolbar to open the AI panel — ask questions, summarize, rewrite, or brainstorm directly in your note.",
|
||||
"hint_version_title": "Version history",
|
||||
"hint_version_desc": "Click the ⓘ button in the toolbar → \"Versions\" tab. Enable versioning, then save and restore snapshots of your note at any time.",
|
||||
"hint_flashcards_title": "Generate flashcards",
|
||||
"hint_flashcards_desc": "Click the 🎓 button in the toolbar to auto-generate flashcards from your note for spaced repetition review.",
|
||||
"hint_links_title": "Links between notes",
|
||||
"hint_links_desc": "Type \"[[\" in the editor to search and link to another note. Linked notes appear as backlinks at the bottom of the note.",
|
||||
"hint_create_note_title": "Create a note",
|
||||
"hint_create_note_desc": "Click the \"+\" button in the sidebar or press Ctrl+N to create a new note. Then start writing.",
|
||||
"hint_flip_title": "Flip the card",
|
||||
"hint_flip_desc": "Press Space (or click the card) to flip it and reveal the answer.",
|
||||
"hint_rate_keys_title": "Rate with keyboard",
|
||||
"hint_rate_keys_desc": "After flipping, press 1 (Hard), 2 (Difficult), 3 (Good) or 4 (Easy) to rate the card. The SM-2 algorithm schedules your next review automatically.",
|
||||
"hint_generate_from_note_title": "Generate from a note",
|
||||
"hint_generate_from_note_desc": "Open any note and click the 🎓 button in the toolbar to automatically generate flashcards from its content.",
|
||||
"hint_brainstorm_start_title": "Start with an idea",
|
||||
"hint_brainstorm_start_desc": "Type any concept or question in the input field and press Enter. The AI will generate a set of ideas around it.",
|
||||
"hint_brainstorm_deepen_title": "Deepen an idea",
|
||||
"hint_brainstorm_deepen_desc": "Click on any idea card to expand it with sub-ideas and explore it further.",
|
||||
"hint_brainstorm_export_title": "Export your session",
|
||||
"hint_brainstorm_export_desc": "When done, export the entire brainstorm session as a structured note saved to your notebook.",
|
||||
"hint_insights_clusters_title": "Note clusters",
|
||||
"hint_insights_clusters_desc": "Your notes are automatically grouped into thematic clusters. Click a cluster to explore the notes it contains.",
|
||||
"hint_insights_bridge_title": "Bridge notes",
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2433,7 +2433,12 @@
|
||||
"Italiano": "Italien",
|
||||
"Chinois": "Chinois",
|
||||
"Japonais": "Japonais"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "Exporter en Markdown",
|
||||
"importMarkdown": "Importer un Markdown",
|
||||
"markdownExportSuccess": "Note exportée en Markdown",
|
||||
"markdownExportError": "Échec de l'export de la note",
|
||||
"markdownImportSuccess": "Markdown importé avec succès"
|
||||
},
|
||||
"flashcards": {
|
||||
"generateTitle": "Générer des flashcards",
|
||||
@@ -2796,7 +2801,16 @@
|
||||
"ideasCount": "idées",
|
||||
"star": "Mettre en favori",
|
||||
"unstar": "Retirer des favoris",
|
||||
"renameSession": "Renommer la session"
|
||||
"renameSession": "Renommer la session",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "Télécharger en PowerPoint",
|
||||
"pptxSuccess": "PPTX téléchargé",
|
||||
"pptxError": "Échec de l'export PPTX",
|
||||
"fitToScreen": "Recentrer",
|
||||
"legendWave1": "Variations",
|
||||
"legendWave2": "Analogies",
|
||||
"legendWave3": "Disruptions",
|
||||
"legendConverted": "Convertie"
|
||||
},
|
||||
"byokSettings": {
|
||||
"title": "Vos clés API (BYOK)",
|
||||
@@ -3465,5 +3479,96 @@
|
||||
"notesLoadError": "Erreur de chargement des notes",
|
||||
"defaultOption1": "Option 1",
|
||||
"defaultOption2": "Option 2"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "Votre mémoire augmentée par l'IA",
|
||||
"welcome_subtitle": "Momento se souvient de ce que vous oubliez.",
|
||||
"welcome_cta": "Commencer",
|
||||
"skip": "Passer",
|
||||
"step_notes_title": "Vos notes",
|
||||
"step_notes_empty": "Vous n'avez pas encore de notes. Importez les vôtres ou commencez avec des exemples.",
|
||||
"step_notes_import": "Importer mes notes",
|
||||
"step_notes_demo": "Créer 5 notes d'exemple",
|
||||
"step_notes_has_notes": "Vous avez déjà {count} notes ! Découvrons la magie.",
|
||||
"step_notes_cta": "Mes notes sont prêtes",
|
||||
"step_aha_title": "Retrouvez ce que vous avez oublié",
|
||||
"step_aha_subtitle": "Tapez une question. Retrouvez une note oubliée.",
|
||||
"step_aha_placeholder": "notes sur ma productivité...",
|
||||
"step_aha_cta": "Explorer Momento",
|
||||
"progress": "{current} sur {total}",
|
||||
"creating_demo_notes": "Création des notes d'exemple...",
|
||||
"demo_notes_ready": "5 notes d'exemple créées !",
|
||||
"badge_credits": "⚡ {count} crédits restants",
|
||||
"badge_upgrade": "Passer en Pro →",
|
||||
"no_results": "Aucun résultat — essayez une autre requête.",
|
||||
"search_credit_used": "1 recherche utilisée",
|
||||
"quota_exceeded": "Quota de recherche atteint — passez en Pro pour illimité.",
|
||||
"step_aha_search_button": "Chercher",
|
||||
"step_aha_search_aria": "Rechercher dans vos notes",
|
||||
"step_notes_hint": "💡 Ces notes alimenteront la démo de recherche IA à l'étape suivante.",
|
||||
"step_features_title": "Vos super-pouvoirs IA",
|
||||
"step_features_subtitle": "Choisissez par où commencer.",
|
||||
"step_features_cta": "C'est parti !",
|
||||
"feature_search_title": "Recherche sémantique",
|
||||
"feature_search_desc": "Retrouvez n'importe quelle note par sens, pas seulement par mot-clé.",
|
||||
"feature_flashcards_title": "Flashcards IA",
|
||||
"feature_flashcards_desc": "Générez des cartes de révision SRS depuis vos notes en un clic.",
|
||||
"feature_brainstorm_title": "Brainstorm IA",
|
||||
"feature_brainstorm_desc": "Séances de brainstorming collaboratif alimentées par l'IA.",
|
||||
"feature_chat_title": "Chat avec vos notes",
|
||||
"feature_chat_desc": "Posez des questions à votre base de connaissances personnelle.",
|
||||
"feature_insights_title": "Insights sémantiques",
|
||||
"feature_insights_desc": "Découvrez les connexions cachées entre vos idées.",
|
||||
"feature_export_title": "Export Markdown",
|
||||
"feature_export_desc": "Importez et exportez vos notes au format Markdown standard.",
|
||||
"welcome_title_name": "Bonjour {name} 👋",
|
||||
"import_formats": "Formats acceptés : .md, .txt",
|
||||
"import_error": "Impossible d'importer certains fichiers. Réessayez.",
|
||||
"import_notes_ready": "{count} note(s) importée(s) !",
|
||||
"action_write_title": "Écrire votre première vraie note",
|
||||
"action_write_desc": "Créez une note et commencez à capturer vos idées.",
|
||||
"action_flashcards_title": "Générer vos premières flashcards",
|
||||
"action_flashcards_desc": "Ouvrez une note et cliquez sur le bouton flashcards.",
|
||||
"action_brainstorm_title": "Lancer un brainstorm IA",
|
||||
"action_brainstorm_desc": "Explorez vos idées avec un agent IA dédié.",
|
||||
"action_try": "Essayer",
|
||||
"step_features_cta_all": "Tout est prêt — on plonge !",
|
||||
"action_write_where": "Fermez ce menu → cliquez sur \"+ Nouvelle note\" dans la barre latérale",
|
||||
"action_flashcards_where": "Fermez ce menu → ouvrez une note → bouton 🃏 dans la toolbar",
|
||||
"action_brainstorm_where": "Fermez ce menu → section \"Canvas\" dans la barre latérale",
|
||||
"pill_resume": "✨ Reprendre la visite",
|
||||
"action_done": "Testé !",
|
||||
"editor_hints_title": "Astuces éditeur",
|
||||
"editor_hints_got_it": "C'est compris !",
|
||||
"hint_slash_title": "Commande \"/\" — insérer des blocs",
|
||||
"hint_slash_desc": "Dans l'éditeur, tapez \"/\" pour ouvrir le menu de blocs : titre, liste, code, tableau, liste de tâches, et les commandes IA (Clarifier, Raccourcir, Améliorer, Développer).",
|
||||
"hint_ai_title": "Assistant IA intégré",
|
||||
"hint_ai_desc": "Cliquez sur le bouton ✨ dans la barre d'outils pour ouvrir le panneau IA — posez des questions, résumez, réécrivez ou brainstormez directement dans votre note.",
|
||||
"hint_version_title": "Historique des versions",
|
||||
"hint_version_desc": "Cliquez sur le bouton ⓘ dans la barre d'outils → onglet \"Versions\". Activez le versionnage, puis sauvegardez et restaurez des captures de votre note à tout moment.",
|
||||
"hint_flashcards_title": "Générer des flashcards",
|
||||
"hint_flashcards_desc": "Cliquez sur le bouton 🎓 dans la barre d'outils pour générer automatiquement des flashcards depuis votre note, pour une révision en répétition espacée.",
|
||||
"hint_links_title": "Liens entre notes",
|
||||
"hint_links_desc": "Tapez \"[[\" dans l'éditeur pour rechercher et lier une autre note. Les notes liées apparaissent comme rétroliens en bas de la note.",
|
||||
"hint_create_note_title": "Créer une note",
|
||||
"hint_create_note_desc": "Cliquez sur le bouton \"+\" dans la barre latérale pour créer une nouvelle note, puis commencez à écrire.",
|
||||
"hint_flip_title": "Retourner la carte",
|
||||
"hint_flip_desc": "Appuyez sur Espace (ou cliquez sur la carte) pour la retourner et révéler la réponse.",
|
||||
"hint_rate_keys_title": "Noter au clavier",
|
||||
"hint_rate_keys_desc": "Après avoir retourné la carte, appuyez sur 1 (Difficile), 2 (Laborieux), 3 (Bien) ou 4 (Facile) pour noter. L'algorithme SM-2 planifie automatiquement votre prochaine révision.",
|
||||
"hint_generate_from_note_title": "Générer depuis une note",
|
||||
"hint_generate_from_note_desc": "Ouvrez n'importe quelle note et cliquez sur le bouton 🎓 dans la barre d'outils pour générer automatiquement des flashcards depuis son contenu.",
|
||||
"hint_brainstorm_start_title": "Démarrer avec une idée",
|
||||
"hint_brainstorm_start_desc": "Tapez un concept ou une question dans le champ de saisie et appuyez sur Entrée. L'IA génère un ensemble d'idées autour de ce thème.",
|
||||
"hint_brainstorm_deepen_title": "Approfondir une idée",
|
||||
"hint_brainstorm_deepen_desc": "Cliquez sur n'importe quelle carte d'idée pour l'approfondir avec des sous-idées et l'explorer davantage.",
|
||||
"hint_brainstorm_export_title": "Exporter la session",
|
||||
"hint_brainstorm_export_desc": "Une fois terminé, exportez toute la session de brainstorm sous forme de note structurée sauvegardée dans votre carnet.",
|
||||
"hint_insights_clusters_title": "Clusters de notes",
|
||||
"hint_insights_clusters_desc": "Vos notes sont automatiquement regroupées en clusters thématiques. Cliquez sur un cluster pour explorer les notes qu'il contient.",
|
||||
"hint_insights_bridge_title": "Notes ponts",
|
||||
"hint_insights_bridge_desc": "Les notes ponts relient plusieurs clusters. Elles sont mises en avant car elles constituent les connexions clés de votre graphe de connaissances.",
|
||||
"hint_insights_refresh_title": "Rafraîchir les clusters",
|
||||
"hint_insights_refresh_desc": "Si vous avez ajouté de nouvelles notes, cliquez sur le bouton de rafraîchissement pour recalculer les clusters avec le contenu le plus récent."
|
||||
}
|
||||
}
|
||||
@@ -2176,7 +2176,12 @@
|
||||
"Italiano": "इतालवी",
|
||||
"Chinois": "चीनी",
|
||||
"Japonais": "जापानी"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "Markdown के रूप में निर्यात करें",
|
||||
"importMarkdown": "Markdown आयात करें",
|
||||
"markdownExportSuccess": "नोट Markdown के रूप में निर्यात किया गया",
|
||||
"markdownExportError": "नोट निर्यात करने में विफल",
|
||||
"markdownImportSuccess": "Markdown सफलतापूर्वक आयात किया गया"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2306,7 +2311,16 @@
|
||||
"ownerBadge": "Owner",
|
||||
"waveBadge": "Wave {wave}",
|
||||
"quotaGuest": "सत्र के होस्ट की AI सीमा समाप्त हो गई है। उनसे अपना प्लान अपग्रेड करने को कहें।",
|
||||
"quotaHost": "इस ब्रेनस्टॉर्म के लिए आपकी AI सीमा समाप्त हो गई है। जारी रखने के लिए प्लान अपग्रेड करें।"
|
||||
"quotaHost": "इस ब्रेनस्टॉर्म के लिए आपकी AI सीमा समाप्त हो गई है। जारी रखने के लिए प्लान अपग्रेड करें।",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "PowerPoint के रूप में डाउनलोड करें",
|
||||
"pptxSuccess": "PPTX डाउनलोड हो गया",
|
||||
"pptxError": "PPTX निर्यात विफल",
|
||||
"fitToScreen": "पुनः केन्द्रित करें",
|
||||
"legendWave1": "विविधताएँ",
|
||||
"legendWave2": "समानताएँ",
|
||||
"legendWave3": "व्यवधान",
|
||||
"legendConverted": "रूपांतरित"
|
||||
},
|
||||
"usageMeter": {
|
||||
"packName": "AI Discovery Pack",
|
||||
@@ -2762,5 +2776,96 @@
|
||||
"uploadError": "अपलोड त्रुटि",
|
||||
"uploadFailed": "अपलोड विफल",
|
||||
"uploading": "अपलोड हो रहा है..."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "AI-संवर्धित आपकी स्मृति",
|
||||
"welcome_subtitle": "Momento वह याद रखता है जो आप भूल जाते हैं।",
|
||||
"welcome_cta": "शुरू करें",
|
||||
"skip": "छोड़ें",
|
||||
"step_notes_title": "आपके नोट्स",
|
||||
"step_notes_empty": "आपके पास अभी कोई नोट नहीं है। अपने नोट्स आयात करें या उदाहरणों से शुरू करें।",
|
||||
"step_notes_import": "मेरे नोट्स आयात करें",
|
||||
"step_notes_demo": "5 उदाहरण नोट्स बनाएं",
|
||||
"step_notes_has_notes": "आपके पास पहले से {count} नोट्स हैं। आइए जादू खोजें।",
|
||||
"step_notes_cta": "मेरे नोट्स तैयार हैं",
|
||||
"step_aha_title": "वह खोजें जो आप भूल गए",
|
||||
"step_aha_subtitle": "एक प्रश्न लिखें। भूला हुआ नोट खोजें।",
|
||||
"step_aha_placeholder": "उत्पादकता पर नोट्स...",
|
||||
"step_aha_cta": "Momento एक्सप्लोर करें",
|
||||
"progress": "{total} में से {current}",
|
||||
"creating_demo_notes": "उदाहरण नोट्स बना रहे हैं...",
|
||||
"demo_notes_ready": "5 उदाहरण नोट्स बनाए गए!",
|
||||
"badge_credits": "⚡ {count} क्रेडिट शेष",
|
||||
"badge_upgrade": "Pro में अपग्रेड करें →",
|
||||
"no_results": "कोई परिणाम नहीं — दूसरी क्वेरी आज़माएं।",
|
||||
"search_credit_used": "1 खोज का उपयोग हुआ",
|
||||
"quota_exceeded": "खोज कोटा समाप्त — Pro में अपग्रेड करें।",
|
||||
"step_aha_search_button": "खोजें",
|
||||
"step_aha_search_aria": "अपने नोट्स खोजें",
|
||||
"step_notes_hint": "💡 ये नोट्स अगले चरण में AI खोज डेमो को शक्ति देंगे।",
|
||||
"step_features_title": "आपकी AI महाशक्तियाँ",
|
||||
"step_features_subtitle": "चुनें कहाँ से शुरू करना है।",
|
||||
"step_features_cta": "चलिए शुरू करते हैं!",
|
||||
"feature_search_title": "सिमेंटिक खोज",
|
||||
"feature_search_desc": "केवल कीवर्ड से नहीं, अर्थ से कोई भी नोट खोजें।",
|
||||
"feature_flashcards_title": "AI फ्लैशकार्ड",
|
||||
"feature_flashcards_desc": "एक क्लिक में अपने नोट्स से SRS समीक्षा कार्ड बनाएं।",
|
||||
"feature_brainstorm_title": "AI ब्रेनस्टॉर्म",
|
||||
"feature_brainstorm_desc": "AI-संचालित सहयोगी ब्रेनस्टॉर्मिंग सत्र।",
|
||||
"feature_chat_title": "नोट्स से चैट करें",
|
||||
"feature_chat_desc": "अपने व्यक्तिगत ज्ञान आधार से प्रश्न पूछें।",
|
||||
"feature_insights_title": "सिमेंटिक इनसाइट्स",
|
||||
"feature_insights_desc": "अपने विचारों के बीच छुपे संबंध खोजें।",
|
||||
"feature_export_title": "Markdown निर्यात",
|
||||
"feature_export_desc": "अपने नोट्स को मानक Markdown प्रारूप में आयात और निर्यात करें।",
|
||||
"welcome_title_name": "नमस्ते {name} 👋",
|
||||
"import_formats": "स्वीकृत प्रारूप: .md, .txt",
|
||||
"import_error": "कुछ फ़ाइलें आयात नहीं हो सकीं। पुनः प्रयास करें।",
|
||||
"import_notes_ready": "{count} नोट(स) आयात किए!",
|
||||
"action_write_title": "अपना पहला असली नोट लिखें",
|
||||
"action_write_desc": "एक नोट बनाएं और अपने विचार लिखना शुरू करें।",
|
||||
"action_flashcards_title": "अपने पहले फ्लैशकार्ड बनाएं",
|
||||
"action_flashcards_desc": "एक नोट खोलें और फ्लैशकार्ड बटन पर क्लिक करें।",
|
||||
"action_brainstorm_title": "AI ब्रेनस्टॉर्म शुरू करें",
|
||||
"action_brainstorm_desc": "AI एजेंट के साथ अपने विचारों का अन्वेषण करें।",
|
||||
"action_try": "कोशिश",
|
||||
"step_features_cta_all": "सब तैयार — चलिए शुरू करते हैं!",
|
||||
"action_write_where": "बंद करें → साइडबार में \"+ नया नोट\" क्लिक करें",
|
||||
"action_flashcards_where": "बंद करें → नोट खोलें → टूलबार में 🃏 बटन",
|
||||
"action_brainstorm_where": "बंद करें → साइडबार में \"Canvas\" अनुभाग",
|
||||
"pill_resume": "✨ टूर जारी रखें",
|
||||
"action_done": "आज़माया!",
|
||||
"editor_hints_title": "Editor tips",
|
||||
"editor_hints_got_it": "Got it!",
|
||||
"hint_slash_title": "\"/\" command — insert blocks",
|
||||
"hint_slash_desc": "In the editor, type \"/\" to open the block menu: heading, list, code block, table, to-do list, and AI commands (Clarify, Shorten, Improve, Expand).",
|
||||
"hint_ai_title": "Built-in AI assistant",
|
||||
"hint_ai_desc": "Click the ✨ button in the toolbar to open the AI panel — ask questions, summarize, rewrite, or brainstorm directly in your note.",
|
||||
"hint_version_title": "Version history",
|
||||
"hint_version_desc": "Click the ⓘ button in the toolbar → \"Versions\" tab. Enable versioning, then save and restore snapshots of your note at any time.",
|
||||
"hint_flashcards_title": "Generate flashcards",
|
||||
"hint_flashcards_desc": "Click the 🎓 button in the toolbar to auto-generate flashcards from your note for spaced repetition review.",
|
||||
"hint_links_title": "Links between notes",
|
||||
"hint_links_desc": "Type \"[[\" in the editor to search and link to another note. Linked notes appear as backlinks at the bottom of the note.",
|
||||
"hint_create_note_title": "Create a note",
|
||||
"hint_create_note_desc": "Click the \"+\" button in the sidebar or press Ctrl+N to create a new note. Then start writing.",
|
||||
"hint_flip_title": "Flip the card",
|
||||
"hint_flip_desc": "Press Space (or click the card) to flip it and reveal the answer.",
|
||||
"hint_rate_keys_title": "Rate with keyboard",
|
||||
"hint_rate_keys_desc": "After flipping, press 1 (Hard), 2 (Difficult), 3 (Good) or 4 (Easy) to rate the card. The SM-2 algorithm schedules your next review automatically.",
|
||||
"hint_generate_from_note_title": "Generate from a note",
|
||||
"hint_generate_from_note_desc": "Open any note and click the 🎓 button in the toolbar to automatically generate flashcards from its content.",
|
||||
"hint_brainstorm_start_title": "Start with an idea",
|
||||
"hint_brainstorm_start_desc": "Type any concept or question in the input field and press Enter. The AI will generate a set of ideas around it.",
|
||||
"hint_brainstorm_deepen_title": "Deepen an idea",
|
||||
"hint_brainstorm_deepen_desc": "Click on any idea card to expand it with sub-ideas and explore it further.",
|
||||
"hint_brainstorm_export_title": "Export your session",
|
||||
"hint_brainstorm_export_desc": "When done, export the entire brainstorm session as a structured note saved to your notebook.",
|
||||
"hint_insights_clusters_title": "Note clusters",
|
||||
"hint_insights_clusters_desc": "Your notes are automatically grouped into thematic clusters. Click a cluster to explore the notes it contains.",
|
||||
"hint_insights_bridge_title": "Bridge notes",
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2176,7 +2176,12 @@
|
||||
"Italiano": "Italiano",
|
||||
"Chinois": "Cinese",
|
||||
"Japonais": "Giapponese"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "Esporta come Markdown",
|
||||
"importMarkdown": "Importa Markdown",
|
||||
"markdownExportSuccess": "Nota esportata come Markdown",
|
||||
"markdownExportError": "Esportazione nota fallita",
|
||||
"markdownImportSuccess": "Markdown importato con successo"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2306,7 +2311,16 @@
|
||||
"ownerBadge": "Owner",
|
||||
"waveBadge": "Wave {wave}",
|
||||
"quotaGuest": "L'host della sessione ha raggiunto il limite IA. Chiedigli di aggiornare il piano.",
|
||||
"quotaHost": "Hai raggiunto il limite IA per questo brainstorm. Passa a un piano superiore per continuare."
|
||||
"quotaHost": "Hai raggiunto il limite IA per questo brainstorm. Passa a un piano superiore per continuare.",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "Scarica come PowerPoint",
|
||||
"pptxSuccess": "PPTX scaricato",
|
||||
"pptxError": "Esportazione PPTX fallita",
|
||||
"fitToScreen": "Ri-centrare",
|
||||
"legendWave1": "Variazioni",
|
||||
"legendWave2": "Analogie",
|
||||
"legendWave3": "Disruzioni",
|
||||
"legendConverted": "Convertita"
|
||||
},
|
||||
"usageMeter": {
|
||||
"packName": "AI Discovery Pack",
|
||||
@@ -2762,5 +2776,96 @@
|
||||
"uploadError": "Errore di caricamento",
|
||||
"uploadFailed": "Caricamento non riuscito",
|
||||
"uploading": "Caricamento..."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "La tua memoria potenziata dall'IA",
|
||||
"welcome_subtitle": "Momento ricorda ciò che dimentichi.",
|
||||
"welcome_cta": "Inizia",
|
||||
"skip": "Salta",
|
||||
"step_notes_title": "Le tue note",
|
||||
"step_notes_empty": "Non hai ancora note. Importa le tue o inizia con esempi.",
|
||||
"step_notes_import": "Importa le mie note",
|
||||
"step_notes_demo": "Crea 5 note di esempio",
|
||||
"step_notes_has_notes": "Hai già {count} note. Scopriamo la magia.",
|
||||
"step_notes_cta": "Le mie note sono pronte",
|
||||
"step_aha_title": "Trova ciò che hai dimenticato",
|
||||
"step_aha_subtitle": "Fai una domanda. Trova una nota dimenticata.",
|
||||
"step_aha_placeholder": "note sulla produttività...",
|
||||
"step_aha_cta": "Esplora Momento",
|
||||
"progress": "{current} di {total}",
|
||||
"creating_demo_notes": "Creazione note di esempio...",
|
||||
"demo_notes_ready": "5 note di esempio create!",
|
||||
"badge_credits": "⚡ {count} crediti rimasti",
|
||||
"badge_upgrade": "Passa a Pro →",
|
||||
"no_results": "Nessun risultato — prova un'altra query.",
|
||||
"search_credit_used": "1 ricerca usata",
|
||||
"quota_exceeded": "Quota di ricerca raggiunta — passa a Pro.",
|
||||
"step_aha_search_button": "Cerca",
|
||||
"step_aha_search_aria": "Cerca nelle tue note",
|
||||
"step_notes_hint": "💡 Queste note alimenteranno la demo di ricerca IA nel passo successivo.",
|
||||
"step_features_title": "I tuoi superpoteri IA",
|
||||
"step_features_subtitle": "Scegli da dove iniziare.",
|
||||
"step_features_cta": "Andiamo!",
|
||||
"feature_search_title": "Ricerca semantica",
|
||||
"feature_search_desc": "Trova qualsiasi nota per significato, non solo per parole chiave.",
|
||||
"feature_flashcards_title": "Flashcard IA",
|
||||
"feature_flashcards_desc": "Genera schede di ripasso SRS dalle tue note in un clic.",
|
||||
"feature_brainstorm_title": "Brainstorming IA",
|
||||
"feature_brainstorm_desc": "Sessioni di brainstorming collaborativo con IA.",
|
||||
"feature_chat_title": "Chatta con le tue note",
|
||||
"feature_chat_desc": "Fai domande alla tua base di conoscenza personale.",
|
||||
"feature_insights_title": "Approfondimenti semantici",
|
||||
"feature_insights_desc": "Scopri connessioni nascoste tra le tue idee.",
|
||||
"feature_export_title": "Esportazione Markdown",
|
||||
"feature_export_desc": "Importa ed esporta le tue note in formato Markdown.",
|
||||
"welcome_title_name": "Ciao {name} 👋",
|
||||
"import_formats": "Formati accettati: .md, .txt",
|
||||
"import_error": "Impossibile importare alcuni file. Riprova.",
|
||||
"import_notes_ready": "{count} nota/e importata/e!",
|
||||
"action_write_title": "Scrivi la tua prima vera nota",
|
||||
"action_write_desc": "Crea una nota e inizia a catturare le tue idee.",
|
||||
"action_flashcards_title": "Genera le prime flashcard",
|
||||
"action_flashcards_desc": "Apri una nota e clicca sul pulsante flashcard.",
|
||||
"action_brainstorm_title": "Avvia un brainstorming IA",
|
||||
"action_brainstorm_desc": "Esplora le tue idee con un agente IA.",
|
||||
"action_try": "Prova",
|
||||
"step_features_cta_all": "Tutto pronto — immergiamoci!",
|
||||
"action_write_where": "Chiudi → clicca \"+ Nuova nota\" nella barra laterale",
|
||||
"action_flashcards_where": "Chiudi → apri una nota → pulsante 🃏 nella toolbar",
|
||||
"action_brainstorm_where": "Chiudi → sezione \"Canvas\" nella barra laterale",
|
||||
"pill_resume": "✨ Riprendi tour",
|
||||
"action_done": "Provato!",
|
||||
"editor_hints_title": "Editor tips",
|
||||
"editor_hints_got_it": "Got it!",
|
||||
"hint_slash_title": "\"/\" command — insert blocks",
|
||||
"hint_slash_desc": "In the editor, type \"/\" to open the block menu: heading, list, code block, table, to-do list, and AI commands (Clarify, Shorten, Improve, Expand).",
|
||||
"hint_ai_title": "Built-in AI assistant",
|
||||
"hint_ai_desc": "Click the ✨ button in the toolbar to open the AI panel — ask questions, summarize, rewrite, or brainstorm directly in your note.",
|
||||
"hint_version_title": "Version history",
|
||||
"hint_version_desc": "Click the ⓘ button in the toolbar → \"Versions\" tab. Enable versioning, then save and restore snapshots of your note at any time.",
|
||||
"hint_flashcards_title": "Generate flashcards",
|
||||
"hint_flashcards_desc": "Click the 🎓 button in the toolbar to auto-generate flashcards from your note for spaced repetition review.",
|
||||
"hint_links_title": "Links between notes",
|
||||
"hint_links_desc": "Type \"[[\" in the editor to search and link to another note. Linked notes appear as backlinks at the bottom of the note.",
|
||||
"hint_create_note_title": "Create a note",
|
||||
"hint_create_note_desc": "Click the \"+\" button in the sidebar or press Ctrl+N to create a new note. Then start writing.",
|
||||
"hint_flip_title": "Flip the card",
|
||||
"hint_flip_desc": "Press Space (or click the card) to flip it and reveal the answer.",
|
||||
"hint_rate_keys_title": "Rate with keyboard",
|
||||
"hint_rate_keys_desc": "After flipping, press 1 (Hard), 2 (Difficult), 3 (Good) or 4 (Easy) to rate the card. The SM-2 algorithm schedules your next review automatically.",
|
||||
"hint_generate_from_note_title": "Generate from a note",
|
||||
"hint_generate_from_note_desc": "Open any note and click the 🎓 button in the toolbar to automatically generate flashcards from its content.",
|
||||
"hint_brainstorm_start_title": "Start with an idea",
|
||||
"hint_brainstorm_start_desc": "Type any concept or question in the input field and press Enter. The AI will generate a set of ideas around it.",
|
||||
"hint_brainstorm_deepen_title": "Deepen an idea",
|
||||
"hint_brainstorm_deepen_desc": "Click on any idea card to expand it with sub-ideas and explore it further.",
|
||||
"hint_brainstorm_export_title": "Export your session",
|
||||
"hint_brainstorm_export_desc": "When done, export the entire brainstorm session as a structured note saved to your notebook.",
|
||||
"hint_insights_clusters_title": "Note clusters",
|
||||
"hint_insights_clusters_desc": "Your notes are automatically grouped into thematic clusters. Click a cluster to explore the notes it contains.",
|
||||
"hint_insights_bridge_title": "Bridge notes",
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2176,7 +2176,12 @@
|
||||
"Italiano": "イタリア語",
|
||||
"Chinois": "中国語",
|
||||
"Japonais": "日本語"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "Markdownとしてエクスポート",
|
||||
"importMarkdown": "Markdownをインポート",
|
||||
"markdownExportSuccess": "ノートをMarkdownとしてエクスポートしました",
|
||||
"markdownExportError": "ノートのエクスポートに失敗しました",
|
||||
"markdownImportSuccess": "Markdownのインポートに成功しました"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2306,7 +2311,16 @@
|
||||
"ownerBadge": "Owner",
|
||||
"waveBadge": "Wave {wave}",
|
||||
"quotaGuest": "セッションのホストがAI利用上限に達しました。プランのアップグレードを依頼してください。",
|
||||
"quotaHost": "このブレインストームのAI上限に達しました。続けるにはプランをアップグレードしてください。"
|
||||
"quotaHost": "このブレインストームのAI上限に達しました。続けるにはプランをアップグレードしてください。",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "PowerPointとしてダウンロード",
|
||||
"pptxSuccess": "PPTXをダウンロードしました",
|
||||
"pptxError": "PPTXのエクスポートに失敗しました",
|
||||
"fitToScreen": "中央に戻す",
|
||||
"legendWave1": "バリエーション",
|
||||
"legendWave2": "類推",
|
||||
"legendWave3": "破壊的変革",
|
||||
"legendConverted": "変換済み"
|
||||
},
|
||||
"usageMeter": {
|
||||
"packName": "AI Discovery Pack",
|
||||
@@ -2762,5 +2776,96 @@
|
||||
"uploadError": "アップロードエラー",
|
||||
"uploadFailed": "アップロードに失敗しました",
|
||||
"uploading": "アップロード中..."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "AIで強化されたあなたの記憶",
|
||||
"welcome_subtitle": "Momentoはあなたが忘れたことを覚えています。",
|
||||
"welcome_cta": "始める",
|
||||
"skip": "スキップ",
|
||||
"step_notes_title": "あなたのノート",
|
||||
"step_notes_empty": "まだノートがありません。インポートするか、例から始めましょう。",
|
||||
"step_notes_import": "ノートをインポート",
|
||||
"step_notes_demo": "5つの例ノートを作成",
|
||||
"step_notes_has_notes": "すでに{count}件のノートがあります。魔法を発見しましょう。",
|
||||
"step_notes_cta": "ノートの準備ができました",
|
||||
"step_aha_title": "忘れたことを見つける",
|
||||
"step_aha_subtitle": "質問を入力してください。忘れたノートを見つけます。",
|
||||
"step_aha_placeholder": "生産性に関するメモ...",
|
||||
"step_aha_cta": "Momentoを探索",
|
||||
"progress": "{total}中{current}",
|
||||
"creating_demo_notes": "サンプルノートを作成中...",
|
||||
"demo_notes_ready": "5つのサンプルノートを作成しました!",
|
||||
"badge_credits": "⚡ 残り {count} クレジット",
|
||||
"badge_upgrade": "Proにアップグレード →",
|
||||
"no_results": "結果なし — 別のクエリを試してください。",
|
||||
"search_credit_used": "検索 1 回使用",
|
||||
"quota_exceeded": "検索クォータに達しました — Proにアップグレード。",
|
||||
"step_aha_search_button": "検索",
|
||||
"step_aha_search_aria": "メモを検索",
|
||||
"step_notes_hint": "💡 これらのノートは次のステップのAI検索デモに使用されます。",
|
||||
"step_features_title": "あなたのAIスーパーパワー",
|
||||
"step_features_subtitle": "どこから始めるか選んでください。",
|
||||
"step_features_cta": "始めましょう!",
|
||||
"feature_search_title": "セマンティック検索",
|
||||
"feature_search_desc": "キーワードだけでなく意味でノートを検索。",
|
||||
"feature_flashcards_title": "AIフラッシュカード",
|
||||
"feature_flashcards_desc": "ノートからSRS復習カードをワンクリックで生成。",
|
||||
"feature_brainstorm_title": "AIブレインストーミング",
|
||||
"feature_brainstorm_desc": "AI搭載の共同ブレインストーミングセッション。",
|
||||
"feature_chat_title": "ノートとチャット",
|
||||
"feature_chat_desc": "個人ナレッジベースに質問する。",
|
||||
"feature_insights_title": "セマンティックインサイト",
|
||||
"feature_insights_desc": "アイデア間の隠れた関係を発見。",
|
||||
"feature_export_title": "Markdownエクスポート",
|
||||
"feature_export_desc": "標準Markdown形式でノートをインポート/エクスポート。",
|
||||
"welcome_title_name": "こんにちは {name} 👋",
|
||||
"import_formats": "対応形式:.md, .txt",
|
||||
"import_error": "一部のファイルをインポートできませんでした。再試行してください。",
|
||||
"import_notes_ready": "{count}件のノートをインポートしました!",
|
||||
"action_write_title": "最初の本物のノートを書く",
|
||||
"action_write_desc": "ノートを作成してアイデアを記録しましょう。",
|
||||
"action_flashcards_title": "最初のフラッシュカードを生成",
|
||||
"action_flashcards_desc": "ノートを開いてフラッシュカードボタンをクリック。",
|
||||
"action_brainstorm_title": "AIブレインストーミングを開始",
|
||||
"action_brainstorm_desc": "AIエージェントとアイデアを探索しましょう。",
|
||||
"action_try": "試す",
|
||||
"step_features_cta_all": "準備完了——さあ始めましょう!",
|
||||
"action_write_where": "閉じる → サイドバーの「+ 新しいノート」をクリック",
|
||||
"action_flashcards_where": "閉じる → ノートを開く → ツールバーの 🃏 ボタン",
|
||||
"action_brainstorm_where": "閉じる → サイドバーの「Canvas」セクション",
|
||||
"pill_resume": "✨ ツアーを再開",
|
||||
"action_done": "試した!",
|
||||
"editor_hints_title": "Editor tips",
|
||||
"editor_hints_got_it": "Got it!",
|
||||
"hint_slash_title": "\"/\" command — insert blocks",
|
||||
"hint_slash_desc": "In the editor, type \"/\" to open the block menu: heading, list, code block, table, to-do list, and AI commands (Clarify, Shorten, Improve, Expand).",
|
||||
"hint_ai_title": "Built-in AI assistant",
|
||||
"hint_ai_desc": "Click the ✨ button in the toolbar to open the AI panel — ask questions, summarize, rewrite, or brainstorm directly in your note.",
|
||||
"hint_version_title": "Version history",
|
||||
"hint_version_desc": "Click the ⓘ button in the toolbar → \"Versions\" tab. Enable versioning, then save and restore snapshots of your note at any time.",
|
||||
"hint_flashcards_title": "Generate flashcards",
|
||||
"hint_flashcards_desc": "Click the 🎓 button in the toolbar to auto-generate flashcards from your note for spaced repetition review.",
|
||||
"hint_links_title": "Links between notes",
|
||||
"hint_links_desc": "Type \"[[\" in the editor to search and link to another note. Linked notes appear as backlinks at the bottom of the note.",
|
||||
"hint_create_note_title": "Create a note",
|
||||
"hint_create_note_desc": "Click the \"+\" button in the sidebar or press Ctrl+N to create a new note. Then start writing.",
|
||||
"hint_flip_title": "Flip the card",
|
||||
"hint_flip_desc": "Press Space (or click the card) to flip it and reveal the answer.",
|
||||
"hint_rate_keys_title": "Rate with keyboard",
|
||||
"hint_rate_keys_desc": "After flipping, press 1 (Hard), 2 (Difficult), 3 (Good) or 4 (Easy) to rate the card. The SM-2 algorithm schedules your next review automatically.",
|
||||
"hint_generate_from_note_title": "Generate from a note",
|
||||
"hint_generate_from_note_desc": "Open any note and click the 🎓 button in the toolbar to automatically generate flashcards from its content.",
|
||||
"hint_brainstorm_start_title": "Start with an idea",
|
||||
"hint_brainstorm_start_desc": "Type any concept or question in the input field and press Enter. The AI will generate a set of ideas around it.",
|
||||
"hint_brainstorm_deepen_title": "Deepen an idea",
|
||||
"hint_brainstorm_deepen_desc": "Click on any idea card to expand it with sub-ideas and explore it further.",
|
||||
"hint_brainstorm_export_title": "Export your session",
|
||||
"hint_brainstorm_export_desc": "When done, export the entire brainstorm session as a structured note saved to your notebook.",
|
||||
"hint_insights_clusters_title": "Note clusters",
|
||||
"hint_insights_clusters_desc": "Your notes are automatically grouped into thematic clusters. Click a cluster to explore the notes it contains.",
|
||||
"hint_insights_bridge_title": "Bridge notes",
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2176,7 +2176,12 @@
|
||||
"Italiano": "이탈리아어",
|
||||
"Chinois": "중국어",
|
||||
"Japonais": "일본어"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "Markdown으로 내보내기",
|
||||
"importMarkdown": "Markdown 가져오기",
|
||||
"markdownExportSuccess": "노트가 Markdown으로 내보내졌습니다",
|
||||
"markdownExportError": "노트 내보내기 실패",
|
||||
"markdownImportSuccess": "Markdown 가져오기 성공"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2306,7 +2311,16 @@
|
||||
"ownerBadge": "Owner",
|
||||
"waveBadge": "Wave {wave}",
|
||||
"quotaGuest": "세션 호스트의 AI 한도에 도달했습니다. 플랜 업그레이드를 요청하세요.",
|
||||
"quotaHost": "이 브레인스토밍의 AI 한도에 도달했습니다. 계속하려면 플랜을 업그레이드하세요."
|
||||
"quotaHost": "이 브레인스토밍의 AI 한도에 도달했습니다. 계속하려면 플랜을 업그레이드하세요.",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "PowerPoint로 다운로드",
|
||||
"pptxSuccess": "PPTX 다운로드됨",
|
||||
"pptxError": "PPTX 내보내기 실패",
|
||||
"fitToScreen": "중앙으로",
|
||||
"legendWave1": "변형",
|
||||
"legendWave2": "유추",
|
||||
"legendWave3": "파괴",
|
||||
"legendConverted": "변환됨"
|
||||
},
|
||||
"usageMeter": {
|
||||
"packName": "AI Discovery Pack",
|
||||
@@ -2762,5 +2776,96 @@
|
||||
"uploadError": "업로드 오류",
|
||||
"uploadFailed": "업로드 실패",
|
||||
"uploading": "업로드 중..."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "AI로 강화된 당신의 기억",
|
||||
"welcome_subtitle": "Momento는 당신이 잊은 것을 기억합니다.",
|
||||
"welcome_cta": "시작하기",
|
||||
"skip": "건너뛰기",
|
||||
"step_notes_title": "당신의 노트",
|
||||
"step_notes_empty": "아직 노트가 없습니다. 가져오거나 예제로 시작하세요.",
|
||||
"step_notes_import": "내 노트 가져오기",
|
||||
"step_notes_demo": "예제 노트 5개 만들기",
|
||||
"step_notes_has_notes": "이미 {count}개의 노트가 있습니다. 마법을 발견해 봅시다.",
|
||||
"step_notes_cta": "내 노트 준비 완료",
|
||||
"step_aha_title": "잊은 것을 찾아보세요",
|
||||
"step_aha_subtitle": "질문을 입력하세요. 잊었던 노트를 찾아보세요.",
|
||||
"step_aha_placeholder": "생산성에 관한 노트...",
|
||||
"step_aha_cta": "Momento 탐색",
|
||||
"progress": "{total} 중 {current}",
|
||||
"creating_demo_notes": "예제 노트 생성 중...",
|
||||
"demo_notes_ready": "예제 노트 5개 생성 완료!",
|
||||
"badge_credits": "⚡ {count}크레딧 남음",
|
||||
"badge_upgrade": "Pro로 업그레이드 →",
|
||||
"no_results": "결과 없음 — 다른 검색어를 시도하세요.",
|
||||
"search_credit_used": "검색 1회 사용됨",
|
||||
"quota_exceeded": "검색 할당량 초과 — Pro로 업그레이드하세요.",
|
||||
"step_aha_search_button": "검색",
|
||||
"step_aha_search_aria": "노트 검색",
|
||||
"step_notes_hint": "💡 이 노트들은 다음 단계의 AI 검색 데모에 활용됩니다.",
|
||||
"step_features_title": "당신의 AI 슈퍼파워",
|
||||
"step_features_subtitle": "어디서 시작할지 선택하세요.",
|
||||
"step_features_cta": "시작합시다!",
|
||||
"feature_search_title": "시맨틱 검색",
|
||||
"feature_search_desc": "키워드뿐만 아니라 의미로 노트를 찾아보세요.",
|
||||
"feature_flashcards_title": "AI 플래시카드",
|
||||
"feature_flashcards_desc": "노트에서 SRS 복습 카드를 한 번의 클릭으로 생성하세요.",
|
||||
"feature_brainstorm_title": "AI 브레인스토밍",
|
||||
"feature_brainstorm_desc": "AI 기반 협업 브레인스토밍 세션.",
|
||||
"feature_chat_title": "노트와 채팅",
|
||||
"feature_chat_desc": "개인 지식 베이스에 질문하세요.",
|
||||
"feature_insights_title": "시맨틱 인사이트",
|
||||
"feature_insights_desc": "아이디어 간의 숨겨진 연결을 발견하세요.",
|
||||
"feature_export_title": "Markdown 내보내기",
|
||||
"feature_export_desc": "표준 Markdown 형식으로 노트를 가져오거나 내보내세요.",
|
||||
"welcome_title_name": "안녕하세요 {name} 👋",
|
||||
"import_formats": "지원 형식: .md, .txt",
|
||||
"import_error": "일부 파일을 가져올 수 없습니다. 다시 시도하세요.",
|
||||
"import_notes_ready": "{count}개의 노트를 가져왔습니다!",
|
||||
"action_write_title": "첫 번째 실제 노트 작성",
|
||||
"action_write_desc": "노트를 만들고 아이디어를 기록하세요.",
|
||||
"action_flashcards_title": "첫 번째 플래시카드 생성",
|
||||
"action_flashcards_desc": "노트를 열고 플래시카드 버튼을 클릭하세요.",
|
||||
"action_brainstorm_title": "AI 브레인스토밍 시작",
|
||||
"action_brainstorm_desc": "AI 에이전트와 아이디어를 탐색하세요.",
|
||||
"action_try": "해보기",
|
||||
"step_features_cta_all": "모두 준비됨 — 시작합시다!",
|
||||
"action_write_where": "닫기 → 사이드바의 \"+ 새 노트\" 클릭",
|
||||
"action_flashcards_where": "닫기 → 노트 열기 → 툴바의 🃏 버튼",
|
||||
"action_brainstorm_where": "닫기 → 사이드바의 \"Canvas\" 섹션",
|
||||
"pill_resume": "✨ 투어 재개",
|
||||
"action_done": "완료!",
|
||||
"editor_hints_title": "Editor tips",
|
||||
"editor_hints_got_it": "Got it!",
|
||||
"hint_slash_title": "\"/\" command — insert blocks",
|
||||
"hint_slash_desc": "In the editor, type \"/\" to open the block menu: heading, list, code block, table, to-do list, and AI commands (Clarify, Shorten, Improve, Expand).",
|
||||
"hint_ai_title": "Built-in AI assistant",
|
||||
"hint_ai_desc": "Click the ✨ button in the toolbar to open the AI panel — ask questions, summarize, rewrite, or brainstorm directly in your note.",
|
||||
"hint_version_title": "Version history",
|
||||
"hint_version_desc": "Click the ⓘ button in the toolbar → \"Versions\" tab. Enable versioning, then save and restore snapshots of your note at any time.",
|
||||
"hint_flashcards_title": "Generate flashcards",
|
||||
"hint_flashcards_desc": "Click the 🎓 button in the toolbar to auto-generate flashcards from your note for spaced repetition review.",
|
||||
"hint_links_title": "Links between notes",
|
||||
"hint_links_desc": "Type \"[[\" in the editor to search and link to another note. Linked notes appear as backlinks at the bottom of the note.",
|
||||
"hint_create_note_title": "Create a note",
|
||||
"hint_create_note_desc": "Click the \"+\" button in the sidebar or press Ctrl+N to create a new note. Then start writing.",
|
||||
"hint_flip_title": "Flip the card",
|
||||
"hint_flip_desc": "Press Space (or click the card) to flip it and reveal the answer.",
|
||||
"hint_rate_keys_title": "Rate with keyboard",
|
||||
"hint_rate_keys_desc": "After flipping, press 1 (Hard), 2 (Difficult), 3 (Good) or 4 (Easy) to rate the card. The SM-2 algorithm schedules your next review automatically.",
|
||||
"hint_generate_from_note_title": "Generate from a note",
|
||||
"hint_generate_from_note_desc": "Open any note and click the 🎓 button in the toolbar to automatically generate flashcards from its content.",
|
||||
"hint_brainstorm_start_title": "Start with an idea",
|
||||
"hint_brainstorm_start_desc": "Type any concept or question in the input field and press Enter. The AI will generate a set of ideas around it.",
|
||||
"hint_brainstorm_deepen_title": "Deepen an idea",
|
||||
"hint_brainstorm_deepen_desc": "Click on any idea card to expand it with sub-ideas and explore it further.",
|
||||
"hint_brainstorm_export_title": "Export your session",
|
||||
"hint_brainstorm_export_desc": "When done, export the entire brainstorm session as a structured note saved to your notebook.",
|
||||
"hint_insights_clusters_title": "Note clusters",
|
||||
"hint_insights_clusters_desc": "Your notes are automatically grouped into thematic clusters. Click a cluster to explore the notes it contains.",
|
||||
"hint_insights_bridge_title": "Bridge notes",
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2176,7 +2176,12 @@
|
||||
"Italiano": "Italiaans",
|
||||
"Chinois": "Chinees",
|
||||
"Japonais": "Japans"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "Exporteren als Markdown",
|
||||
"importMarkdown": "Markdown importeren",
|
||||
"markdownExportSuccess": "Notitie geëxporteerd als Markdown",
|
||||
"markdownExportError": "Exporteren van notitie mislukt",
|
||||
"markdownImportSuccess": "Markdown succesvol geïmporteerd"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2306,7 +2311,16 @@
|
||||
"ownerBadge": "Owner",
|
||||
"waveBadge": "Wave {wave}",
|
||||
"quotaGuest": "De sessiehost heeft zijn AI-limiet bereikt. Vraag om een upgrade van het abonnement.",
|
||||
"quotaHost": "Je hebt je AI-limiet voor deze brainstorm bereikt. Upgrade je abonnement om door te gaan."
|
||||
"quotaHost": "Je hebt je AI-limiet voor deze brainstorm bereikt. Upgrade je abonnement om door te gaan.",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "Downloaden als PowerPoint",
|
||||
"pptxSuccess": "PPTX gedownload",
|
||||
"pptxError": "PPTX-export mislukt",
|
||||
"fitToScreen": "Hercentreren",
|
||||
"legendWave1": "Variaties",
|
||||
"legendWave2": "Analogieën",
|
||||
"legendWave3": "Verstoringen",
|
||||
"legendConverted": "Geconverteerd"
|
||||
},
|
||||
"usageMeter": {
|
||||
"packName": "AI Discovery Pack",
|
||||
@@ -2762,5 +2776,96 @@
|
||||
"uploadError": "Uploadfout",
|
||||
"uploadFailed": "Upload mislukt",
|
||||
"uploading": "Uploaden..."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "Uw AI-versterkt geheugen",
|
||||
"welcome_subtitle": "Momento onthoudt wat u vergeet.",
|
||||
"welcome_cta": "Beginnen",
|
||||
"skip": "Overslaan",
|
||||
"step_notes_title": "Uw notities",
|
||||
"step_notes_empty": "U heeft nog geen notities. Importeer uw eigen of begin met voorbeelden.",
|
||||
"step_notes_import": "Mijn notities importeren",
|
||||
"step_notes_demo": "5 voorbeeldnotities maken",
|
||||
"step_notes_has_notes": "U heeft al {count} notities. Laten we de magie ontdekken.",
|
||||
"step_notes_cta": "Mijn notities zijn klaar",
|
||||
"step_aha_title": "Vind wat u vergeten bent",
|
||||
"step_aha_subtitle": "Stel een vraag. Vind een vergeten notitie.",
|
||||
"step_aha_placeholder": "notities over productiviteit...",
|
||||
"step_aha_cta": "Momento verkennen",
|
||||
"progress": "{current} van {total}",
|
||||
"creating_demo_notes": "Voorbeeldnotities aanmaken...",
|
||||
"demo_notes_ready": "5 voorbeeldnotities aangemaakt!",
|
||||
"badge_credits": "⚡ Nog {count} credits",
|
||||
"badge_upgrade": "Upgraden naar Pro →",
|
||||
"no_results": "Geen resultaten — probeer een andere zoekopdracht.",
|
||||
"search_credit_used": "1 zoekopdracht gebruikt",
|
||||
"quota_exceeded": "Zoeklimiet bereikt — upgrade naar Pro.",
|
||||
"step_aha_search_button": "Zoeken",
|
||||
"step_aha_search_aria": "Zoek in je notities",
|
||||
"step_notes_hint": "💡 Deze notities voeden de AI-zoekdemo in de volgende stap.",
|
||||
"step_features_title": "Uw AI-superkrachten",
|
||||
"step_features_subtitle": "Kies waar u wilt beginnen.",
|
||||
"step_features_cta": "Aan de slag!",
|
||||
"feature_search_title": "Semantisch zoeken",
|
||||
"feature_search_desc": "Vind elke notitie op betekenis, niet alleen op trefwoorden.",
|
||||
"feature_flashcards_title": "AI-flashcards",
|
||||
"feature_flashcards_desc": "Genereer SRS-revisiekaarten uit uw notities met één klik.",
|
||||
"feature_brainstorm_title": "AI-brainstormen",
|
||||
"feature_brainstorm_desc": "AI-gestuurde collaboratieve brainstormsessies.",
|
||||
"feature_chat_title": "Chat met uw notities",
|
||||
"feature_chat_desc": "Stel vragen aan uw persoonlijke kennisbank.",
|
||||
"feature_insights_title": "Semantische inzichten",
|
||||
"feature_insights_desc": "Ontdek verborgen verbanden tussen uw ideeën.",
|
||||
"feature_export_title": "Markdown-export",
|
||||
"feature_export_desc": "Importeer en exporteer uw notities in Markdown-formaat.",
|
||||
"welcome_title_name": "Hallo {name} 👋",
|
||||
"import_formats": "Geaccepteerde formaten: .md, .txt",
|
||||
"import_error": "Sommige bestanden konden niet worden geïmporteerd.",
|
||||
"import_notes_ready": "{count} notitie(s) geïmporteerd!",
|
||||
"action_write_title": "Schrijf uw eerste echte notitie",
|
||||
"action_write_desc": "Maak een notitie en leg uw ideeën vast.",
|
||||
"action_flashcards_title": "Maak uw eerste flashcards",
|
||||
"action_flashcards_desc": "Open een notitie en klik op de flashcards-knop.",
|
||||
"action_brainstorm_title": "Start een AI-brainstorm",
|
||||
"action_brainstorm_desc": "Verken uw ideeën met een AI-agent.",
|
||||
"action_try": "Proberen",
|
||||
"step_features_cta_all": "Alles klaar — aan de slag!",
|
||||
"action_write_where": "Sluit → klik op \"+ Nieuwe notitie\" in de zijbalk",
|
||||
"action_flashcards_where": "Sluit → open een notitie → 🃏-knop in de werkbalk",
|
||||
"action_brainstorm_where": "Sluit → \"Canvas\"-sectie in de zijbalk",
|
||||
"pill_resume": "✨ Tour hervatten",
|
||||
"action_done": "Geprobeerd!",
|
||||
"editor_hints_title": "Editor tips",
|
||||
"editor_hints_got_it": "Got it!",
|
||||
"hint_slash_title": "\"/\" command — insert blocks",
|
||||
"hint_slash_desc": "In the editor, type \"/\" to open the block menu: heading, list, code block, table, to-do list, and AI commands (Clarify, Shorten, Improve, Expand).",
|
||||
"hint_ai_title": "Built-in AI assistant",
|
||||
"hint_ai_desc": "Click the ✨ button in the toolbar to open the AI panel — ask questions, summarize, rewrite, or brainstorm directly in your note.",
|
||||
"hint_version_title": "Version history",
|
||||
"hint_version_desc": "Click the ⓘ button in the toolbar → \"Versions\" tab. Enable versioning, then save and restore snapshots of your note at any time.",
|
||||
"hint_flashcards_title": "Generate flashcards",
|
||||
"hint_flashcards_desc": "Click the 🎓 button in the toolbar to auto-generate flashcards from your note for spaced repetition review.",
|
||||
"hint_links_title": "Links between notes",
|
||||
"hint_links_desc": "Type \"[[\" in the editor to search and link to another note. Linked notes appear as backlinks at the bottom of the note.",
|
||||
"hint_create_note_title": "Create a note",
|
||||
"hint_create_note_desc": "Click the \"+\" button in the sidebar or press Ctrl+N to create a new note. Then start writing.",
|
||||
"hint_flip_title": "Flip the card",
|
||||
"hint_flip_desc": "Press Space (or click the card) to flip it and reveal the answer.",
|
||||
"hint_rate_keys_title": "Rate with keyboard",
|
||||
"hint_rate_keys_desc": "After flipping, press 1 (Hard), 2 (Difficult), 3 (Good) or 4 (Easy) to rate the card. The SM-2 algorithm schedules your next review automatically.",
|
||||
"hint_generate_from_note_title": "Generate from a note",
|
||||
"hint_generate_from_note_desc": "Open any note and click the 🎓 button in the toolbar to automatically generate flashcards from its content.",
|
||||
"hint_brainstorm_start_title": "Start with an idea",
|
||||
"hint_brainstorm_start_desc": "Type any concept or question in the input field and press Enter. The AI will generate a set of ideas around it.",
|
||||
"hint_brainstorm_deepen_title": "Deepen an idea",
|
||||
"hint_brainstorm_deepen_desc": "Click on any idea card to expand it with sub-ideas and explore it further.",
|
||||
"hint_brainstorm_export_title": "Export your session",
|
||||
"hint_brainstorm_export_desc": "When done, export the entire brainstorm session as a structured note saved to your notebook.",
|
||||
"hint_insights_clusters_title": "Note clusters",
|
||||
"hint_insights_clusters_desc": "Your notes are automatically grouped into thematic clusters. Click a cluster to explore the notes it contains.",
|
||||
"hint_insights_bridge_title": "Bridge notes",
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2176,7 +2176,12 @@
|
||||
"Italiano": "Włoski",
|
||||
"Chinois": "Chiński",
|
||||
"Japonais": "Japoński"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "Eksportuj jako Markdown",
|
||||
"importMarkdown": "Importuj Markdown",
|
||||
"markdownExportSuccess": "Notatka wyeksportowana jako Markdown",
|
||||
"markdownExportError": "Eksport notatki nie powiódł się",
|
||||
"markdownImportSuccess": "Markdown zaimportowany pomyślnie"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2306,7 +2311,16 @@
|
||||
"ownerBadge": "Owner",
|
||||
"waveBadge": "Wave {wave}",
|
||||
"quotaGuest": "Gospodarz sesji wyczerpał limit AI. Poproś go o ulepszenie planu.",
|
||||
"quotaHost": "Osiągnąłeś limit AI dla tego brainstormu. Ulepsz plan, aby kontynuować."
|
||||
"quotaHost": "Osiągnąłeś limit AI dla tego brainstormu. Ulepsz plan, aby kontynuować.",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "Pobierz jako PowerPoint",
|
||||
"pptxSuccess": "PPTX pobrany",
|
||||
"pptxError": "Eksport PPTX nie powiódł się",
|
||||
"fitToScreen": "Wycentruj",
|
||||
"legendWave1": "Wariacje",
|
||||
"legendWave2": "Analogie",
|
||||
"legendWave3": "Zakłócenia",
|
||||
"legendConverted": "Przekonwertowane"
|
||||
},
|
||||
"usageMeter": {
|
||||
"packName": "AI Discovery Pack",
|
||||
@@ -2762,5 +2776,96 @@
|
||||
"uploadError": "Błąd przesyłania",
|
||||
"uploadFailed": "Przesyłanie nie powiodło się",
|
||||
"uploading": "Przesyłanie..."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "Twoja pamięć wspomagana AI",
|
||||
"welcome_subtitle": "Momento pamięta to, co zapominasz.",
|
||||
"welcome_cta": "Zacznij",
|
||||
"skip": "Pomiń",
|
||||
"step_notes_title": "Twoje notatki",
|
||||
"step_notes_empty": "Nie masz jeszcze notatek. Zaimportuj swoje lub zacznij od przykładów.",
|
||||
"step_notes_import": "Importuj moje notatki",
|
||||
"step_notes_demo": "Utwórz 5 przykładowych notatek",
|
||||
"step_notes_has_notes": "Masz już {count} notatek. Odkryjmy magię.",
|
||||
"step_notes_cta": "Moje notatki są gotowe",
|
||||
"step_aha_title": "Znajdź to, co zapomniałeś",
|
||||
"step_aha_subtitle": "Zadaj pytanie. Znajdź zapomnianą notatkę.",
|
||||
"step_aha_placeholder": "notatki o produktywności...",
|
||||
"step_aha_cta": "Eksploruj Momento",
|
||||
"progress": "{current} z {total}",
|
||||
"creating_demo_notes": "Tworzenie przykładowych notatek...",
|
||||
"demo_notes_ready": "5 przykładowych notatek utworzonych!",
|
||||
"badge_credits": "⚡ Pozostało {count} kredytów",
|
||||
"badge_upgrade": "Przejdź na Pro →",
|
||||
"no_results": "Brak wyników — spróbuj innego zapytania.",
|
||||
"search_credit_used": "1 wyszukiwanie użyte",
|
||||
"quota_exceeded": "Limit wyszukiwań osiągnięty — przejdź na Pro.",
|
||||
"step_aha_search_button": "Szukaj",
|
||||
"step_aha_search_aria": "Szukaj w notatkach",
|
||||
"step_notes_hint": "💡 Te notatki zasilą demo wyszukiwania AI w następnym kroku.",
|
||||
"step_features_title": "Twoje supermoce AI",
|
||||
"step_features_subtitle": "Wybierz, od czego zacząć.",
|
||||
"step_features_cta": "Zaczynamy!",
|
||||
"feature_search_title": "Wyszukiwanie semantyczne",
|
||||
"feature_search_desc": "Znajdź każdą notatkę według znaczenia, nie tylko słów kluczowych.",
|
||||
"feature_flashcards_title": "Fiszki AI",
|
||||
"feature_flashcards_desc": "Generuj karty powtórek SRS z notatek jednym kliknięciem.",
|
||||
"feature_brainstorm_title": "Burza mózgów AI",
|
||||
"feature_brainstorm_desc": "Sesje wspólnej burzy mózgów wspomaganej przez AI.",
|
||||
"feature_chat_title": "Czatuj z notatkami",
|
||||
"feature_chat_desc": "Zadawaj pytania swojej osobistej bazie wiedzy.",
|
||||
"feature_insights_title": "Spostrzeżenia semantyczne",
|
||||
"feature_insights_desc": "Odkryj ukryte powiązania między swoimi pomysłami.",
|
||||
"feature_export_title": "Eksport Markdown",
|
||||
"feature_export_desc": "Importuj i eksportuj notatki w formacie Markdown.",
|
||||
"welcome_title_name": "Cześć {name} 👋",
|
||||
"import_formats": "Akceptowane formaty: .md, .txt",
|
||||
"import_error": "Nie można zaimportować niektórych plików. Spróbuj ponownie.",
|
||||
"import_notes_ready": "{count} notatka/notatki zaimportowana/e!",
|
||||
"action_write_title": "Napisz swoją pierwszą prawdziwą notatkę",
|
||||
"action_write_desc": "Utwórz notatkę i zacznij zapisywać swoje pomysły.",
|
||||
"action_flashcards_title": "Wygeneruj pierwsze fiszki",
|
||||
"action_flashcards_desc": "Otwórz notatkę i kliknij przycisk fiszek.",
|
||||
"action_brainstorm_title": "Rozpocznij burzę mózgów AI",
|
||||
"action_brainstorm_desc": "Odkrywaj swoje pomysły z agentem AI.",
|
||||
"action_try": "Spróbuj",
|
||||
"step_features_cta_all": "Wszystko gotowe — zaczynamy!",
|
||||
"action_write_where": "Zamknij → kliknij \"+ Nowa notatka\" na pasku bocznym",
|
||||
"action_flashcards_where": "Zamknij → otwórz notatkę → przycisk 🃏 na pasku",
|
||||
"action_brainstorm_where": "Zamknij → sekcja \"Canvas\" na pasku bocznym",
|
||||
"pill_resume": "✨ Wznów tour",
|
||||
"action_done": "Wypróbowano!",
|
||||
"editor_hints_title": "Editor tips",
|
||||
"editor_hints_got_it": "Got it!",
|
||||
"hint_slash_title": "\"/\" command — insert blocks",
|
||||
"hint_slash_desc": "In the editor, type \"/\" to open the block menu: heading, list, code block, table, to-do list, and AI commands (Clarify, Shorten, Improve, Expand).",
|
||||
"hint_ai_title": "Built-in AI assistant",
|
||||
"hint_ai_desc": "Click the ✨ button in the toolbar to open the AI panel — ask questions, summarize, rewrite, or brainstorm directly in your note.",
|
||||
"hint_version_title": "Version history",
|
||||
"hint_version_desc": "Click the ⓘ button in the toolbar → \"Versions\" tab. Enable versioning, then save and restore snapshots of your note at any time.",
|
||||
"hint_flashcards_title": "Generate flashcards",
|
||||
"hint_flashcards_desc": "Click the 🎓 button in the toolbar to auto-generate flashcards from your note for spaced repetition review.",
|
||||
"hint_links_title": "Links between notes",
|
||||
"hint_links_desc": "Type \"[[\" in the editor to search and link to another note. Linked notes appear as backlinks at the bottom of the note.",
|
||||
"hint_create_note_title": "Create a note",
|
||||
"hint_create_note_desc": "Click the \"+\" button in the sidebar or press Ctrl+N to create a new note. Then start writing.",
|
||||
"hint_flip_title": "Flip the card",
|
||||
"hint_flip_desc": "Press Space (or click the card) to flip it and reveal the answer.",
|
||||
"hint_rate_keys_title": "Rate with keyboard",
|
||||
"hint_rate_keys_desc": "After flipping, press 1 (Hard), 2 (Difficult), 3 (Good) or 4 (Easy) to rate the card. The SM-2 algorithm schedules your next review automatically.",
|
||||
"hint_generate_from_note_title": "Generate from a note",
|
||||
"hint_generate_from_note_desc": "Open any note and click the 🎓 button in the toolbar to automatically generate flashcards from its content.",
|
||||
"hint_brainstorm_start_title": "Start with an idea",
|
||||
"hint_brainstorm_start_desc": "Type any concept or question in the input field and press Enter. The AI will generate a set of ideas around it.",
|
||||
"hint_brainstorm_deepen_title": "Deepen an idea",
|
||||
"hint_brainstorm_deepen_desc": "Click on any idea card to expand it with sub-ideas and explore it further.",
|
||||
"hint_brainstorm_export_title": "Export your session",
|
||||
"hint_brainstorm_export_desc": "When done, export the entire brainstorm session as a structured note saved to your notebook.",
|
||||
"hint_insights_clusters_title": "Note clusters",
|
||||
"hint_insights_clusters_desc": "Your notes are automatically grouped into thematic clusters. Click a cluster to explore the notes it contains.",
|
||||
"hint_insights_bridge_title": "Bridge notes",
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2176,7 +2176,12 @@
|
||||
"Italiano": "Italiano",
|
||||
"Chinois": "Chinês",
|
||||
"Japonais": "Japonês"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "Exportar como Markdown",
|
||||
"importMarkdown": "Importar Markdown",
|
||||
"markdownExportSuccess": "Nota exportada como Markdown",
|
||||
"markdownExportError": "Falha ao exportar a nota",
|
||||
"markdownImportSuccess": "Markdown importado com sucesso"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2306,7 +2311,16 @@
|
||||
"ownerBadge": "Owner",
|
||||
"waveBadge": "Wave {wave}",
|
||||
"quotaGuest": "O anfitrião da sessão atingiu o limite de IA. Peça-lhe para atualizar o plano.",
|
||||
"quotaHost": "Atingiu o limite de IA deste brainstorm. Atualize o plano para continuar."
|
||||
"quotaHost": "Atingiu o limite de IA deste brainstorm. Atualize o plano para continuar.",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "Baixar como PowerPoint",
|
||||
"pptxSuccess": "PPTX baixado",
|
||||
"pptxError": "Falha ao exportar PPTX",
|
||||
"fitToScreen": "Recentralizar",
|
||||
"legendWave1": "Variações",
|
||||
"legendWave2": "Analogias",
|
||||
"legendWave3": "Disrupções",
|
||||
"legendConverted": "Convertida"
|
||||
},
|
||||
"usageMeter": {
|
||||
"packName": "AI Discovery Pack",
|
||||
@@ -2762,5 +2776,96 @@
|
||||
"uploadError": "Erro ao enviar",
|
||||
"uploadFailed": "Falha no envio",
|
||||
"uploading": "Enviando..."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "Sua memória aumentada por IA",
|
||||
"welcome_subtitle": "Momento lembra o que você esquece.",
|
||||
"welcome_cta": "Começar",
|
||||
"skip": "Pular",
|
||||
"step_notes_title": "Suas notas",
|
||||
"step_notes_empty": "Você ainda não tem notas. Importe as suas ou comece com exemplos.",
|
||||
"step_notes_import": "Importar minhas notas",
|
||||
"step_notes_demo": "Criar 5 notas de exemplo",
|
||||
"step_notes_has_notes": "Você já tem {count} notas. Vamos descobrir a magia.",
|
||||
"step_notes_cta": "Minhas notas estão prontas",
|
||||
"step_aha_title": "Encontre o que você esqueceu",
|
||||
"step_aha_subtitle": "Faça uma pergunta. Encontre uma nota esquecida.",
|
||||
"step_aha_placeholder": "notas sobre produtividade...",
|
||||
"step_aha_cta": "Explorar Momento",
|
||||
"progress": "{current} de {total}",
|
||||
"creating_demo_notes": "Criando notas de exemplo...",
|
||||
"demo_notes_ready": "5 notas de exemplo criadas!",
|
||||
"badge_credits": "⚡ {count} créditos restantes",
|
||||
"badge_upgrade": "Atualizar para Pro →",
|
||||
"no_results": "Sem resultados — tente outra pesquisa.",
|
||||
"search_credit_used": "1 pesquisa utilizada",
|
||||
"quota_exceeded": "Cota de pesquisa atingida — atualize para Pro.",
|
||||
"step_aha_search_button": "Pesquisar",
|
||||
"step_aha_search_aria": "Pesquisar nas suas notas",
|
||||
"step_notes_hint": "💡 Estas notas alimentarão a demonstração de busca IA no próximo passo.",
|
||||
"step_features_title": "Seus superpoderes de IA",
|
||||
"step_features_subtitle": "Escolha por onde começar.",
|
||||
"step_features_cta": "Vamos lá!",
|
||||
"feature_search_title": "Busca semântica",
|
||||
"feature_search_desc": "Encontre qualquer nota por significado, não apenas por palavras-chave.",
|
||||
"feature_flashcards_title": "Flashcards IA",
|
||||
"feature_flashcards_desc": "Gere cartões de revisão SRS das suas notas com um clique.",
|
||||
"feature_brainstorm_title": "Brainstorming IA",
|
||||
"feature_brainstorm_desc": "Sessões de brainstorming colaborativo com IA.",
|
||||
"feature_chat_title": "Converse com suas notas",
|
||||
"feature_chat_desc": "Faça perguntas à sua base de conhecimento pessoal.",
|
||||
"feature_insights_title": "Insights semânticos",
|
||||
"feature_insights_desc": "Descubra conexões ocultas entre suas ideias.",
|
||||
"feature_export_title": "Exportação Markdown",
|
||||
"feature_export_desc": "Importe e exporte suas notas em formato Markdown padrão.",
|
||||
"welcome_title_name": "Olá {name} 👋",
|
||||
"import_formats": "Formatos aceites: .md, .txt",
|
||||
"import_error": "Não foi possível importar alguns ficheiros. Tente novamente.",
|
||||
"import_notes_ready": "{count} nota(s) importada(s)!",
|
||||
"action_write_title": "Escreva sua primeira nota real",
|
||||
"action_write_desc": "Crie uma nota e comece a capturar suas ideias.",
|
||||
"action_flashcards_title": "Gere seus primeiros flashcards",
|
||||
"action_flashcards_desc": "Abra uma nota e clique no botão flashcards.",
|
||||
"action_brainstorm_title": "Inicie um brainstorm IA",
|
||||
"action_brainstorm_desc": "Explore suas ideias com um agente IA.",
|
||||
"action_try": "Tentar",
|
||||
"step_features_cta_all": "Tudo pronto — vamos mergulhar!",
|
||||
"action_write_where": "Feche → clique em \"+ Nova nota\" na barra lateral",
|
||||
"action_flashcards_where": "Feche → abra uma nota → botão 🃏 na barra",
|
||||
"action_brainstorm_where": "Feche → seção \"Canvas\" na barra lateral",
|
||||
"pill_resume": "✨ Retomar visita",
|
||||
"action_done": "Testado!",
|
||||
"editor_hints_title": "Editor tips",
|
||||
"editor_hints_got_it": "Got it!",
|
||||
"hint_slash_title": "\"/\" command — insert blocks",
|
||||
"hint_slash_desc": "In the editor, type \"/\" to open the block menu: heading, list, code block, table, to-do list, and AI commands (Clarify, Shorten, Improve, Expand).",
|
||||
"hint_ai_title": "Built-in AI assistant",
|
||||
"hint_ai_desc": "Click the ✨ button in the toolbar to open the AI panel — ask questions, summarize, rewrite, or brainstorm directly in your note.",
|
||||
"hint_version_title": "Version history",
|
||||
"hint_version_desc": "Click the ⓘ button in the toolbar → \"Versions\" tab. Enable versioning, then save and restore snapshots of your note at any time.",
|
||||
"hint_flashcards_title": "Generate flashcards",
|
||||
"hint_flashcards_desc": "Click the 🎓 button in the toolbar to auto-generate flashcards from your note for spaced repetition review.",
|
||||
"hint_links_title": "Links between notes",
|
||||
"hint_links_desc": "Type \"[[\" in the editor to search and link to another note. Linked notes appear as backlinks at the bottom of the note.",
|
||||
"hint_create_note_title": "Create a note",
|
||||
"hint_create_note_desc": "Click the \"+\" button in the sidebar or press Ctrl+N to create a new note. Then start writing.",
|
||||
"hint_flip_title": "Flip the card",
|
||||
"hint_flip_desc": "Press Space (or click the card) to flip it and reveal the answer.",
|
||||
"hint_rate_keys_title": "Rate with keyboard",
|
||||
"hint_rate_keys_desc": "After flipping, press 1 (Hard), 2 (Difficult), 3 (Good) or 4 (Easy) to rate the card. The SM-2 algorithm schedules your next review automatically.",
|
||||
"hint_generate_from_note_title": "Generate from a note",
|
||||
"hint_generate_from_note_desc": "Open any note and click the 🎓 button in the toolbar to automatically generate flashcards from its content.",
|
||||
"hint_brainstorm_start_title": "Start with an idea",
|
||||
"hint_brainstorm_start_desc": "Type any concept or question in the input field and press Enter. The AI will generate a set of ideas around it.",
|
||||
"hint_brainstorm_deepen_title": "Deepen an idea",
|
||||
"hint_brainstorm_deepen_desc": "Click on any idea card to expand it with sub-ideas and explore it further.",
|
||||
"hint_brainstorm_export_title": "Export your session",
|
||||
"hint_brainstorm_export_desc": "When done, export the entire brainstorm session as a structured note saved to your notebook.",
|
||||
"hint_insights_clusters_title": "Note clusters",
|
||||
"hint_insights_clusters_desc": "Your notes are automatically grouped into thematic clusters. Click a cluster to explore the notes it contains.",
|
||||
"hint_insights_bridge_title": "Bridge notes",
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2176,7 +2176,12 @@
|
||||
"Italiano": "Итальянский",
|
||||
"Chinois": "Китайский",
|
||||
"Japonais": "Японский"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "Экспорт в Markdown",
|
||||
"importMarkdown": "Импорт Markdown",
|
||||
"markdownExportSuccess": "Заметка экспортирована в Markdown",
|
||||
"markdownExportError": "Не удалось экспортировать заметку",
|
||||
"markdownImportSuccess": "Markdown успешно импортирован"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2306,7 +2311,16 @@
|
||||
"ownerBadge": "Owner",
|
||||
"waveBadge": "Wave {wave}",
|
||||
"quotaGuest": "Организатор сессии исчерпал лимит ИИ. Попросите его обновить тариф.",
|
||||
"quotaHost": "Вы исчерпали лимит ИИ для этого мозгового штурма. Обновите тариф, чтобы продолжить."
|
||||
"quotaHost": "Вы исчерпали лимит ИИ для этого мозгового штурма. Обновите тариф, чтобы продолжить.",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "Скачать как PowerPoint",
|
||||
"pptxSuccess": "PPTX скачан",
|
||||
"pptxError": "Ошибка экспорта PPTX",
|
||||
"fitToScreen": "Центрировать",
|
||||
"legendWave1": "Вариации",
|
||||
"legendWave2": "Аналогии",
|
||||
"legendWave3": "Нарушения",
|
||||
"legendConverted": "Конвертировано"
|
||||
},
|
||||
"usageMeter": {
|
||||
"packName": "AI Discovery Pack",
|
||||
@@ -2762,5 +2776,96 @@
|
||||
"uploadError": "Ошибка загрузки",
|
||||
"uploadFailed": "Загрузка не удалась",
|
||||
"uploading": "Загрузка..."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "Ваша память, усиленная ИИ",
|
||||
"welcome_subtitle": "Momento помнит то, что вы забываете.",
|
||||
"welcome_cta": "Начать",
|
||||
"skip": "Пропустить",
|
||||
"step_notes_title": "Ваши заметки",
|
||||
"step_notes_empty": "У вас ещё нет заметок. Импортируйте свои или начните с примеров.",
|
||||
"step_notes_import": "Импортировать заметки",
|
||||
"step_notes_demo": "Создать 5 примеров заметок",
|
||||
"step_notes_has_notes": "У вас уже {count} заметок. Давайте откроем магию.",
|
||||
"step_notes_cta": "Мои заметки готовы",
|
||||
"step_aha_title": "Найдите то, что забыли",
|
||||
"step_aha_subtitle": "Задайте вопрос. Найдите забытую заметку.",
|
||||
"step_aha_placeholder": "заметки о продуктивности...",
|
||||
"step_aha_cta": "Исследовать Momento",
|
||||
"progress": "{current} из {total}",
|
||||
"creating_demo_notes": "Создание примеров заметок...",
|
||||
"demo_notes_ready": "5 примеров заметок создано!",
|
||||
"badge_credits": "⚡ Осталось {count} кредитов",
|
||||
"badge_upgrade": "Перейти на Pro →",
|
||||
"no_results": "Нет результатов — попробуйте другой запрос.",
|
||||
"search_credit_used": "1 поиск использован",
|
||||
"quota_exceeded": "Лимит поиска исчерпан — перейдите на Pro.",
|
||||
"step_aha_search_button": "Искать",
|
||||
"step_aha_search_aria": "Поиск по заметкам",
|
||||
"step_notes_hint": "💡 Эти заметки обеспечат демонстрацию ИИ-поиска на следующем шаге.",
|
||||
"step_features_title": "Ваши суперспособности ИИ",
|
||||
"step_features_subtitle": "Выберите, с чего начать.",
|
||||
"step_features_cta": "Поехали!",
|
||||
"feature_search_title": "Семантический поиск",
|
||||
"feature_search_desc": "Находите любую заметку по смыслу, а не только по ключевым словам.",
|
||||
"feature_flashcards_title": "Карточки ИИ",
|
||||
"feature_flashcards_desc": "Создавайте карточки для повторения SRS из заметок одним кликом.",
|
||||
"feature_brainstorm_title": "Мозговой штурм ИИ",
|
||||
"feature_brainstorm_desc": "Совместные сессии мозгового штурма с поддержкой ИИ.",
|
||||
"feature_chat_title": "Чат с заметками",
|
||||
"feature_chat_desc": "Задавайте вопросы своей личной базе знаний.",
|
||||
"feature_insights_title": "Семантические инсайты",
|
||||
"feature_insights_desc": "Откройте скрытые связи между вашими идеями.",
|
||||
"feature_export_title": "Экспорт Markdown",
|
||||
"feature_export_desc": "Импортируйте и экспортируйте заметки в формате Markdown.",
|
||||
"welcome_title_name": "Привет, {name} 👋",
|
||||
"import_formats": "Поддерживаемые форматы: .md, .txt",
|
||||
"import_error": "Не удалось импортировать некоторые файлы. Попробуйте снова.",
|
||||
"import_notes_ready": "{count} заметка(и) импортирована(ы)!",
|
||||
"action_write_title": "Напишите первую настоящую заметку",
|
||||
"action_write_desc": "Создайте заметку и начните фиксировать идеи.",
|
||||
"action_flashcards_title": "Создайте первые карточки",
|
||||
"action_flashcards_desc": "Откройте заметку и нажмите кнопку карточек.",
|
||||
"action_brainstorm_title": "Запустите ИИ-мозговой штурм",
|
||||
"action_brainstorm_desc": "Исследуйте идеи с ИИ-агентом.",
|
||||
"action_try": "Попробовать",
|
||||
"step_features_cta_all": "Всё готово — вперёд!",
|
||||
"action_write_where": "Закройте → нажмите \"+ Новая заметка\" в боковой панели",
|
||||
"action_flashcards_where": "Закройте → откройте заметку → кнопка 🃏 в панели",
|
||||
"action_brainstorm_where": "Закройте → раздел \"Canvas\" в боковой панели",
|
||||
"pill_resume": "✨ Продолжить тур",
|
||||
"action_done": "Готово!",
|
||||
"editor_hints_title": "Editor tips",
|
||||
"editor_hints_got_it": "Got it!",
|
||||
"hint_slash_title": "\"/\" command — insert blocks",
|
||||
"hint_slash_desc": "In the editor, type \"/\" to open the block menu: heading, list, code block, table, to-do list, and AI commands (Clarify, Shorten, Improve, Expand).",
|
||||
"hint_ai_title": "Built-in AI assistant",
|
||||
"hint_ai_desc": "Click the ✨ button in the toolbar to open the AI panel — ask questions, summarize, rewrite, or brainstorm directly in your note.",
|
||||
"hint_version_title": "Version history",
|
||||
"hint_version_desc": "Click the ⓘ button in the toolbar → \"Versions\" tab. Enable versioning, then save and restore snapshots of your note at any time.",
|
||||
"hint_flashcards_title": "Generate flashcards",
|
||||
"hint_flashcards_desc": "Click the 🎓 button in the toolbar to auto-generate flashcards from your note for spaced repetition review.",
|
||||
"hint_links_title": "Links between notes",
|
||||
"hint_links_desc": "Type \"[[\" in the editor to search and link to another note. Linked notes appear as backlinks at the bottom of the note.",
|
||||
"hint_create_note_title": "Create a note",
|
||||
"hint_create_note_desc": "Click the \"+\" button in the sidebar or press Ctrl+N to create a new note. Then start writing.",
|
||||
"hint_flip_title": "Flip the card",
|
||||
"hint_flip_desc": "Press Space (or click the card) to flip it and reveal the answer.",
|
||||
"hint_rate_keys_title": "Rate with keyboard",
|
||||
"hint_rate_keys_desc": "After flipping, press 1 (Hard), 2 (Difficult), 3 (Good) or 4 (Easy) to rate the card. The SM-2 algorithm schedules your next review automatically.",
|
||||
"hint_generate_from_note_title": "Generate from a note",
|
||||
"hint_generate_from_note_desc": "Open any note and click the 🎓 button in the toolbar to automatically generate flashcards from its content.",
|
||||
"hint_brainstorm_start_title": "Start with an idea",
|
||||
"hint_brainstorm_start_desc": "Type any concept or question in the input field and press Enter. The AI will generate a set of ideas around it.",
|
||||
"hint_brainstorm_deepen_title": "Deepen an idea",
|
||||
"hint_brainstorm_deepen_desc": "Click on any idea card to expand it with sub-ideas and explore it further.",
|
||||
"hint_brainstorm_export_title": "Export your session",
|
||||
"hint_brainstorm_export_desc": "When done, export the entire brainstorm session as a structured note saved to your notebook.",
|
||||
"hint_insights_clusters_title": "Note clusters",
|
||||
"hint_insights_clusters_desc": "Your notes are automatically grouped into thematic clusters. Click a cluster to explore the notes it contains.",
|
||||
"hint_insights_bridge_title": "Bridge notes",
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2176,7 +2176,12 @@
|
||||
"Italiano": "意大利语",
|
||||
"Chinois": "中文",
|
||||
"Japonais": "日语"
|
||||
}
|
||||
},
|
||||
"exportMarkdown": "导出为 Markdown",
|
||||
"importMarkdown": "导入 Markdown",
|
||||
"markdownExportSuccess": "笔记已导出为 Markdown",
|
||||
"markdownExportError": "笔记导出失败",
|
||||
"markdownImportSuccess": "Markdown 导入成功"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2306,7 +2311,16 @@
|
||||
"ownerBadge": "Owner",
|
||||
"waveBadge": "Wave {wave}",
|
||||
"quotaGuest": "会话主持人已达到 AI 额度上限。请让对方升级套餐。",
|
||||
"quotaHost": "您已达到此头脑风暴的 AI 额度上限。升级套餐以继续。"
|
||||
"quotaHost": "您已达到此头脑风暴的 AI 额度上限。升级套餐以继续。",
|
||||
"downloadPptx": "PPTX",
|
||||
"downloadPptxDesc": "下载为 PowerPoint",
|
||||
"pptxSuccess": "PPTX 已下载",
|
||||
"pptxError": "PPTX 导出失败",
|
||||
"fitToScreen": "重新居中",
|
||||
"legendWave1": "变体",
|
||||
"legendWave2": "类比",
|
||||
"legendWave3": "颠覆",
|
||||
"legendConverted": "已转换"
|
||||
},
|
||||
"usageMeter": {
|
||||
"packName": "AI Discovery Pack",
|
||||
@@ -2762,5 +2776,96 @@
|
||||
"uploadError": "上传错误",
|
||||
"uploadFailed": "上传失败",
|
||||
"uploading": "上传中..."
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "您的AI增强记忆",
|
||||
"welcome_subtitle": "Momento记住您忘记的事情。",
|
||||
"welcome_cta": "开始",
|
||||
"skip": "跳过",
|
||||
"step_notes_title": "您的笔记",
|
||||
"step_notes_empty": "您还没有笔记。导入您的笔记或从示例开始。",
|
||||
"step_notes_import": "导入我的笔记",
|
||||
"step_notes_demo": "创建5个示例笔记",
|
||||
"step_notes_has_notes": "您已有{count}条笔记。让我们发现魔力。",
|
||||
"step_notes_cta": "我的笔记已准备好",
|
||||
"step_aha_title": "找到您忘记的内容",
|
||||
"step_aha_subtitle": "提问。找到您遗忘的笔记。",
|
||||
"step_aha_placeholder": "关于生产力的笔记...",
|
||||
"step_aha_cta": "探索Momento",
|
||||
"progress": "{current}/{total}",
|
||||
"creating_demo_notes": "正在创建示例笔记...",
|
||||
"demo_notes_ready": "已创建5个示例笔记!",
|
||||
"badge_credits": "⚡ 剩余 {count} 积分",
|
||||
"badge_upgrade": "升级到 Pro →",
|
||||
"no_results": "无结果 — 请尝试其他查询。",
|
||||
"search_credit_used": "已使用 1 次搜索",
|
||||
"quota_exceeded": "搜索配额已用完 — 升级到 Pro。",
|
||||
"step_aha_search_button": "搜索",
|
||||
"step_aha_search_aria": "搜索您的笔记",
|
||||
"step_notes_hint": "💡 这些笔记将在下一步为 AI 搜索演示提供支持。",
|
||||
"step_features_title": "您的 AI 超能力",
|
||||
"step_features_subtitle": "选择从哪里开始。",
|
||||
"step_features_cta": "开始吧!",
|
||||
"feature_search_title": "语义搜索",
|
||||
"feature_search_desc": "按含义查找任何笔记,而不仅仅是关键词。",
|
||||
"feature_flashcards_title": "AI 闪卡",
|
||||
"feature_flashcards_desc": "一键从笔记生成 SRS 复习卡片。",
|
||||
"feature_brainstorm_title": "AI 头脑风暴",
|
||||
"feature_brainstorm_desc": "AI 驱动的协作头脑风暴会话。",
|
||||
"feature_chat_title": "与笔记对话",
|
||||
"feature_chat_desc": "向您的个人知识库提问。",
|
||||
"feature_insights_title": "语义洞察",
|
||||
"feature_insights_desc": "发现您想法之间隐藏的联系。",
|
||||
"feature_export_title": "Markdown 导出",
|
||||
"feature_export_desc": "以标准 Markdown 格式导入和导出笔记。",
|
||||
"welcome_title_name": "你好 {name} 👋",
|
||||
"import_formats": "支持格式:.md, .txt",
|
||||
"import_error": "部分文件无法导入,请重试。",
|
||||
"import_notes_ready": "已导入 {count} 篇笔记!",
|
||||
"action_write_title": "写下您的第一篇真实笔记",
|
||||
"action_write_desc": "创建笔记,开始记录您的想法。",
|
||||
"action_flashcards_title": "生成您的第一批闪卡",
|
||||
"action_flashcards_desc": "打开笔记,点击闪卡按钮。",
|
||||
"action_brainstorm_title": "开始AI头脑风暴",
|
||||
"action_brainstorm_desc": "与AI代理探索您的想法。",
|
||||
"action_try": "试试",
|
||||
"step_features_cta_all": "全部完成——开始吧!",
|
||||
"action_write_where": "关闭此窗口 → 点击侧边栏\"+ 新建笔记\"",
|
||||
"action_flashcards_where": "关闭 → 打开笔记 → 工具栏中的 🃏 按钮",
|
||||
"action_brainstorm_where": "关闭 → 侧边栏\"Canvas\"区域",
|
||||
"pill_resume": "✨ 继续引导",
|
||||
"action_done": "已试用",
|
||||
"editor_hints_title": "Editor tips",
|
||||
"editor_hints_got_it": "Got it!",
|
||||
"hint_slash_title": "\"/\" command — insert blocks",
|
||||
"hint_slash_desc": "In the editor, type \"/\" to open the block menu: heading, list, code block, table, to-do list, and AI commands (Clarify, Shorten, Improve, Expand).",
|
||||
"hint_ai_title": "Built-in AI assistant",
|
||||
"hint_ai_desc": "Click the ✨ button in the toolbar to open the AI panel — ask questions, summarize, rewrite, or brainstorm directly in your note.",
|
||||
"hint_version_title": "Version history",
|
||||
"hint_version_desc": "Click the ⓘ button in the toolbar → \"Versions\" tab. Enable versioning, then save and restore snapshots of your note at any time.",
|
||||
"hint_flashcards_title": "Generate flashcards",
|
||||
"hint_flashcards_desc": "Click the 🎓 button in the toolbar to auto-generate flashcards from your note for spaced repetition review.",
|
||||
"hint_links_title": "Links between notes",
|
||||
"hint_links_desc": "Type \"[[\" in the editor to search and link to another note. Linked notes appear as backlinks at the bottom of the note.",
|
||||
"hint_create_note_title": "Create a note",
|
||||
"hint_create_note_desc": "Click the \"+\" button in the sidebar or press Ctrl+N to create a new note. Then start writing.",
|
||||
"hint_flip_title": "Flip the card",
|
||||
"hint_flip_desc": "Press Space (or click the card) to flip it and reveal the answer.",
|
||||
"hint_rate_keys_title": "Rate with keyboard",
|
||||
"hint_rate_keys_desc": "After flipping, press 1 (Hard), 2 (Difficult), 3 (Good) or 4 (Easy) to rate the card. The SM-2 algorithm schedules your next review automatically.",
|
||||
"hint_generate_from_note_title": "Generate from a note",
|
||||
"hint_generate_from_note_desc": "Open any note and click the 🎓 button in the toolbar to automatically generate flashcards from its content.",
|
||||
"hint_brainstorm_start_title": "Start with an idea",
|
||||
"hint_brainstorm_start_desc": "Type any concept or question in the input field and press Enter. The AI will generate a set of ideas around it.",
|
||||
"hint_brainstorm_deepen_title": "Deepen an idea",
|
||||
"hint_brainstorm_deepen_desc": "Click on any idea card to expand it with sub-ideas and explore it further.",
|
||||
"hint_brainstorm_export_title": "Export your session",
|
||||
"hint_brainstorm_export_desc": "When done, export the entire brainstorm session as a structured note saved to your notebook.",
|
||||
"hint_insights_clusters_title": "Note clusters",
|
||||
"hint_insights_clusters_desc": "Your notes are automatically grouped into thematic clusters. Click a cluster to explore the notes it contains.",
|
||||
"hint_insights_bridge_title": "Bridge notes",
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
}
|
||||
}
|
||||
}
|
||||
53
memento-note/package-lock.json
generated
53
memento-note/package-lock.json
generated
@@ -66,10 +66,12 @@
|
||||
"@tiptap/y-tiptap": "^3.0.3",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"ai": "^6.0.23",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"buffer": "^6.0.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"cheerio": "^1.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -118,6 +120,8 @@
|
||||
"tinyld": "^1.3.4",
|
||||
"tiptap-extension-auto-joiner": "^0.1.3",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"vazirmatn": "^33.0.3",
|
||||
"y-protocols": "^1.0.7",
|
||||
"yjs": "^13.6.30",
|
||||
@@ -128,6 +132,7 @@
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
@@ -2728,6 +2733,12 @@
|
||||
"langium": "3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@mixmark-io/domino": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mozilla/readability": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz",
|
||||
@@ -8270,6 +8281,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/canvas-confetti": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
|
||||
"integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -8738,6 +8756,12 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/turndown": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
|
||||
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -10165,6 +10189,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/canvas-confetti": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
|
||||
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
|
||||
"license": "ISC",
|
||||
"funding": {
|
||||
"type": "donate",
|
||||
"url": "https://www.paypal.me/kirilvatev"
|
||||
}
|
||||
},
|
||||
"node_modules/canvas-roundrect-polyfill": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/canvas-roundrect-polyfill/-/canvas-roundrect-polyfill-0.0.1.tgz",
|
||||
@@ -19065,6 +19099,25 @@
|
||||
"zustand": "^4.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/turndown": {
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
|
||||
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mixmark-io/domino": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
"npm": ">=9"
|
||||
}
|
||||
},
|
||||
"node_modules/turndown-plugin-gfm": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
|
||||
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tw-animate-css": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
|
||||
|
||||
@@ -87,10 +87,12 @@
|
||||
"@tiptap/y-tiptap": "^3.0.3",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"ai": "^6.0.23",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"buffer": "^6.0.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"cheerio": "^1.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -139,6 +141,8 @@
|
||||
"tinyld": "^1.3.4",
|
||||
"tiptap-extension-auto-joiner": "^0.1.3",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"vazirmatn": "^33.0.3",
|
||||
"y-protocols": "^1.0.7",
|
||||
"yjs": "^13.6.30",
|
||||
@@ -149,6 +153,7 @@
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AddColumn: User.onboardingCompleted
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "onboardingCompleted" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AddColumn: User.onboardingStep
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "onboardingStep" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- AddColumn: Note.isDemo
|
||||
ALTER TABLE "Note" ADD COLUMN IF NOT EXISTS "isDemo" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -26,6 +26,8 @@ model User {
|
||||
updatedAt DateTime @updatedAt
|
||||
cardSizeMode String @default("variable")
|
||||
accentColor String @default("#A47148")
|
||||
onboardingCompleted Boolean @default(false)
|
||||
onboardingStep Int @default(0)
|
||||
accounts Account[]
|
||||
agents Agent[]
|
||||
aiFeedback AiFeedback[]
|
||||
@@ -175,6 +177,8 @@ model Note {
|
||||
historyEnabled Boolean @default(false)
|
||||
/// URL d'origine pour les clips web (Web Clipper)
|
||||
sourceUrl String?
|
||||
/// Note de démonstration insérée lors de l'onboarding
|
||||
isDemo Boolean @default(false)
|
||||
/// Illustration SVG (sanitized) for editorial feed thumbnail — optional, peut être généré par IA
|
||||
illustrationSvg String?
|
||||
tsv Unsupported("tsvector")?
|
||||
|
||||
218
memento-note/tests/unit/markdown-export.test.ts
Normal file
218
memento-note/tests/unit/markdown-export.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import {
|
||||
tiptapHTMLToMarkdown,
|
||||
markdownToHTML,
|
||||
looksLikeMarkdown,
|
||||
extractMarkdownTitle,
|
||||
} from '../../lib/editor/markdown-export'
|
||||
|
||||
describe('looksLikeMarkdown', () => {
|
||||
test('detects H1 heading', () => {
|
||||
expect(looksLikeMarkdown('# Hello World')).toBe(true)
|
||||
})
|
||||
|
||||
test('detects unordered list', () => {
|
||||
expect(looksLikeMarkdown('- Item one\n- Item two')).toBe(true)
|
||||
expect(looksLikeMarkdown('* Item one')).toBe(true)
|
||||
})
|
||||
|
||||
test('detects ordered list', () => {
|
||||
expect(looksLikeMarkdown('1. First\n2. Second')).toBe(true)
|
||||
})
|
||||
|
||||
test('detects blockquote', () => {
|
||||
expect(looksLikeMarkdown('> This is a quote')).toBe(true)
|
||||
})
|
||||
|
||||
test('detects code fence', () => {
|
||||
expect(looksLikeMarkdown('```\nconst x = 1\n```')).toBe(true)
|
||||
})
|
||||
|
||||
test('detects inline code', () => {
|
||||
expect(looksLikeMarkdown('Use `console.log()` for debugging')).toBe(true)
|
||||
})
|
||||
|
||||
test('detects bold', () => {
|
||||
expect(looksLikeMarkdown('This is **bold** text')).toBe(true)
|
||||
})
|
||||
|
||||
test('detects italic', () => {
|
||||
expect(looksLikeMarkdown('This is *italic* text')).toBe(true)
|
||||
})
|
||||
|
||||
test('detects table', () => {
|
||||
expect(looksLikeMarkdown('| Col1 | Col2 |\n|------|------|')).toBe(true)
|
||||
})
|
||||
|
||||
test('detects link', () => {
|
||||
expect(looksLikeMarkdown('See [TipTap docs](https://tiptap.dev)')).toBe(true)
|
||||
})
|
||||
|
||||
test('does NOT flag plain prose as Markdown', () => {
|
||||
expect(looksLikeMarkdown('This is a normal sentence without any markdown.')).toBe(false)
|
||||
expect(looksLikeMarkdown('Hello world, this is plain text.')).toBe(false)
|
||||
})
|
||||
|
||||
test('does NOT flag very short text', () => {
|
||||
expect(looksLikeMarkdown('Hi')).toBe(false)
|
||||
expect(looksLikeMarkdown('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tiptapHTMLToMarkdown', () => {
|
||||
test('converts H1 to # heading', () => {
|
||||
const md = tiptapHTMLToMarkdown('<h1>Hello</h1>')
|
||||
expect(md).toBe('# Hello')
|
||||
})
|
||||
|
||||
test('converts H2 to ## heading', () => {
|
||||
const md = tiptapHTMLToMarkdown('<h2>Section</h2>')
|
||||
expect(md).toBe('## Section')
|
||||
})
|
||||
|
||||
test('converts H3 to ### heading', () => {
|
||||
const md = tiptapHTMLToMarkdown('<h3>Sub</h3>')
|
||||
expect(md).toBe('### Sub')
|
||||
})
|
||||
|
||||
test('converts bold text', () => {
|
||||
const md = tiptapHTMLToMarkdown('<p>This is <strong>bold</strong> text.</p>')
|
||||
expect(md).toContain('**bold**')
|
||||
})
|
||||
|
||||
test('converts italic text', () => {
|
||||
const md = tiptapHTMLToMarkdown('<p>This is <em>italic</em> text.</p>')
|
||||
expect(md).toContain('_italic_')
|
||||
})
|
||||
|
||||
test('converts unordered list', () => {
|
||||
const md = tiptapHTMLToMarkdown('<ul><li>Item 1</li><li>Item 2</li></ul>')
|
||||
expect(md).toContain('Item 1')
|
||||
expect(md).toContain('Item 2')
|
||||
expect(md).toMatch(/^[-*+]\s/m)
|
||||
})
|
||||
|
||||
test('converts ordered list', () => {
|
||||
const md = tiptapHTMLToMarkdown('<ol><li>First</li><li>Second</li></ol>')
|
||||
expect(md).toContain('1. First')
|
||||
expect(md).toContain('2. Second')
|
||||
})
|
||||
|
||||
test('converts code block', () => {
|
||||
const md = tiptapHTMLToMarkdown('<pre><code>const x = 1;</code></pre>')
|
||||
expect(md).toContain('```')
|
||||
expect(md).toContain('const x = 1;')
|
||||
})
|
||||
|
||||
test('converts blockquote', () => {
|
||||
const md = tiptapHTMLToMarkdown('<blockquote><p>Quote text</p></blockquote>')
|
||||
expect(md).toContain('> Quote text')
|
||||
})
|
||||
|
||||
test('converts inline code', () => {
|
||||
const md = tiptapHTMLToMarkdown('<p>Use <code>console.log()</code> here.</p>')
|
||||
expect(md).toContain('`console.log()`')
|
||||
})
|
||||
|
||||
test('converts hyperlink', () => {
|
||||
const md = tiptapHTMLToMarkdown('<p><a href="https://example.com">Example</a></p>')
|
||||
expect(md).toContain('[Example](https://example.com)')
|
||||
})
|
||||
|
||||
test('handles empty HTML', () => {
|
||||
expect(tiptapHTMLToMarkdown('')).toBe('')
|
||||
expect(tiptapHTMLToMarkdown(' ')).toBe('')
|
||||
})
|
||||
|
||||
test('preserves liveBlock as HTML comment', () => {
|
||||
const html = '<div data-live-block="true" sourceNoteId="abc" blockId="def"></div>'
|
||||
const md = tiptapHTMLToMarkdown(html)
|
||||
expect(md).toContain('<!-- live-block:')
|
||||
})
|
||||
|
||||
test('preserves structuredViewBlock as HTML comment', () => {
|
||||
const html = '<div data-structured-view-block="true" data-sv-mode="table"></div>'
|
||||
const md = tiptapHTMLToMarkdown(html)
|
||||
expect(md).toContain('<!-- structured-view:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('markdownToHTML', () => {
|
||||
test('converts # H1 to h1 element', () => {
|
||||
const html = markdownToHTML('# Hello')
|
||||
expect(html.toLowerCase()).toContain('<h1>hello</h1>')
|
||||
})
|
||||
|
||||
test('converts ## H2 to h2 element', () => {
|
||||
const html = markdownToHTML('## Section')
|
||||
expect(html.toLowerCase()).toContain('<h2>section</h2>')
|
||||
})
|
||||
|
||||
test('converts bold markdown to <strong>', () => {
|
||||
const html = markdownToHTML('This is **bold** text.')
|
||||
expect(html).toContain('<strong>bold</strong>')
|
||||
})
|
||||
|
||||
test('converts italic markdown to <em>', () => {
|
||||
const html = markdownToHTML('This is *italic* text.')
|
||||
expect(html).toContain('<em>italic</em>')
|
||||
})
|
||||
|
||||
test('converts unordered list to <ul><li>', () => {
|
||||
const html = markdownToHTML('- Item 1\n- Item 2')
|
||||
expect(html).toContain('<ul>')
|
||||
expect(html).toContain('<li>Item 1</li>')
|
||||
})
|
||||
|
||||
test('converts ordered list to <ol><li>', () => {
|
||||
const html = markdownToHTML('1. First\n2. Second')
|
||||
expect(html).toContain('<ol>')
|
||||
expect(html).toContain('<li>First</li>')
|
||||
})
|
||||
|
||||
test('converts fenced code block to <pre><code>', () => {
|
||||
const html = markdownToHTML('```\nconst x = 1;\n```')
|
||||
expect(html).toContain('<pre>')
|
||||
expect(html).toContain('<code>')
|
||||
expect(html).toContain('const x = 1;')
|
||||
})
|
||||
|
||||
test('converts GFM table to <table>', () => {
|
||||
const md = '| Name | Age |\n|------|-----|\n| Alice | 30 |'
|
||||
const html = markdownToHTML(md)
|
||||
expect(html).toContain('<table>')
|
||||
expect(html).toContain('<th>Name</th>')
|
||||
expect(html).toContain('Alice')
|
||||
})
|
||||
|
||||
test('handles empty markdown', () => {
|
||||
expect(markdownToHTML('')).toBe('')
|
||||
expect(markdownToHTML(' ')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractMarkdownTitle', () => {
|
||||
test('extracts first H1', () => {
|
||||
const md = '# My Note\n\nSome content'
|
||||
expect(extractMarkdownTitle(md)).toBe('My Note')
|
||||
})
|
||||
|
||||
test('extracts H1 even with preceding content', () => {
|
||||
const md = 'Some preamble\n# The Title\n\nContent'
|
||||
expect(extractMarkdownTitle(md)).toBe('The Title')
|
||||
})
|
||||
|
||||
test('returns null if no H1', () => {
|
||||
const md = '## Not an H1\n\nContent'
|
||||
expect(extractMarkdownTitle(md)).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null for empty string', () => {
|
||||
expect(extractMarkdownTitle('')).toBeNull()
|
||||
})
|
||||
|
||||
test('handles H1 with leading spaces in title', () => {
|
||||
const md = '# Spaced Title '
|
||||
expect(extractMarkdownTitle(md)).toBe('Spaced Title')
|
||||
})
|
||||
})
|
||||
9
memento-note/types/global.d.ts
vendored
9
memento-note/types/global.d.ts
vendored
@@ -1,4 +1,13 @@
|
||||
declare module 'web-animations-js';
|
||||
|
||||
declare module 'turndown-plugin-gfm' {
|
||||
import TurndownService from 'turndown'
|
||||
export function gfmHeadings(service: TurndownService): void
|
||||
export function tables(service: TurndownService): void
|
||||
export function taskListItems(service: TurndownService): void
|
||||
export function strikethrough(service: TurndownService): void
|
||||
export function gfm(service: TurndownService): void
|
||||
}
|
||||
declare module 'muuri';
|
||||
|
||||
declare module 'jalaali-js' {
|
||||
|
||||
Reference in New Issue
Block a user