4 Commits

Author SHA1 Message Date
Antigravity
a77f3ffb6e fix: batch priorités — thème, scroll, wizard, agents, slides, charts, packs
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m15s
CI / Deploy production (on server) (push) Has been skipped
Anti-flash dark (color-scheme + applyDocumentTheme), scroll pages publiques,
prompts wizard/slides moins génériques, cron agents rattrapage nextRun null,
slides carnet via notebookId, graphiques à 2 crédits + hint, audit dashboard,
packs Stripe marqués non configurés sans price ID.
2026-07-16 20:48:55 +00:00
Antigravity
556a0b2f3f feat(credits): solde IA unique (option M), packs Stripe et polish billing
Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace
les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux
legacy, usage multi-features, hints de coût, anti-flash thème et hydratation
admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
2026-07-16 20:43:18 +00:00
Antigravity
704fed1191 fix: Memory Echo sans vol de scroll, badge IA sidebar, wizard langue
- Ne plus auto-scroller vers Memory Echo : pastille bas de page + reset scroll au titre
- Badges notes/carnets générés par l’IA dans la sidebar
- Wizard : langue du contenu = langue de la requête ; refresh carnet après création
- Voix : erreurs not-allowed/no-speech moins bruyantes
2026-07-16 17:25:57 +00:00
Antigravity
45297da333 fix: images, couverture, slash, agents, wizard, Stripe et admin
- Collage/accès images: ownership path userId + meta legacy, 403 non caché
- Couverture note: explicite (choix/aucune), plus d’auto depuis le corps
- Éditeur: suppression image TipTap, sync immédiate, toolbar réordonnée
- Slash: listes par titre (plus d’index), keywords nettoyés
- Agents: nextRun à la réactivation, cron sans stampede null
- Wizard: titres courts + mode Expert plus robuste
- Stripe checkout hosted fiable; admin métriques live; dark mode IA/settings
- Indexation notes courtes + test unit aligné
2026-07-16 16:58:07 +00:00
104 changed files with 37850 additions and 10982 deletions

View File

@@ -5,15 +5,15 @@
- Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou) ; **nom produit** : **Memento** (jamais « Momento ») — libellés, metadata, pages publiques, docs.
- Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; libellés FR **lisibles** (éviter jargon non expliqué : « wiki », « embed », etc.) et **aide contextuelle** où l'UX l'exige ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle ; **insights Memory Echo** (texte généré/stocké) : langue utilisateur à la génération + fallback i18n à l'affichage (`memoryEcho.defaultInsight`) — jamais d'anglais en dur quand l'UI est FR.
- Base de données : **INTERDIT TOTALEMENT** de lancer `prisma db push --force-reset`, `prisma migrate reset`, `DROP TABLE`, `TRUNCATE`, `pg_restore` avec clean, ou TOUTE commande qui vide/supprime des données — MÊME SI l'utilisateur est d'accord — sans avoir d'abord : (1) dumpé la base avec `bash /home/devparsa/dev/Memento/dump-db.sh`, (2) vérifié le dump fait au moins 1Mo, (3) obtenu un "OUI" explicite de l'utilisateur. **4 incidents de perte de données documentés (14/05, 15/05 x2, 16/05). NE JAMAIS REFAIRE ÇA.**
- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) — similarité sur contenu **représentatif** (pas de troncature arbitraire type 200/800 car.) ; **recherche (sidebar / résultats, ex. flux « ouvrir la note ») et navigation liste des notes** (modes affichage, icônes vs initiales…) : suivre **`SearchModal` et les patterns actuels** dans `architectural-grid`, pas une approximation ou un ancien flux ; **sidebar rail** (`sidebar.tsx`) : une seule icône active ; **largeur sidebar** (`components/sidebar.tsx`) — redimensionnement utilisateur par poignée sur le bord droit (desktop `md+`, 280560px, persisté `localStorage` `memento-sidebar-width`) ; défaut responsive `w-80` puis plus large en `xl`/`2xl` si aucune valeur stockée ; **hauteur zone carnets** ajustable via poignée horizontale (`memento-sidebar-notebooks-height`, min 120px) ; fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur les largeurs par défaut (rail fixe 54px) ; **pas de bouton « Note du jour »** dans la sidebar (retiré volontairement) ; **dashboard Second Brain** (`/home`, `dashboard-view.tsx`) : vue d'ensemble **interactive** (pas dashboard statique/mort) — layout **v5** configurable (catalogue ~15 widgets, persisté `/api/dashboard/layout`) ; widget **« Prochaines pistes »** (`next-paths`) = recommandations actionnables depuis la dernière note éditée (fast paths briefing puis enrichissement async `/api/briefing/paths`) ; widget **« Revue quotidienne »** = **checklist matinale interactive** (inbox, découvertes IA, connexion, flashcards) — **pas** une revue automatique de contenu ; cartes cliquables, section Memory Echo « L'IA a trouvé » exploitable (pas vide après 1ère visite), consentement/provider IA + **quotas** vérifiés sur chaque action IA (dashboard et agents) ; icônes **Lucide** (pas emojis système type 🎯) ; `MindMapCard` remplace l'ancien `MiniResonanceGraph` D3 (graphe décoratif qui débordait) ; navigation sidebar alignée sur `/home` ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer létat dégradé exploitable plutôt quune page vide** ; bouton Menu immersif `/insights` : **cacher la sidebar ET agrandir le contenu principal** (pas de bandeau beige vide — attention `twMerge` qui peut faire perdre `fixed` au profit de `relative`) ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles.
- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) — similarité sur contenu **représentatif** (pas de troncature arbitraire type 200/800 car.) ; **recherche (sidebar / résultats, ex. flux « ouvrir la note ») et navigation liste des notes** (modes affichage, icônes vs initiales…) : suivre **`SearchModal` et les patterns actuels** dans `architectural-grid`, pas une approximation ou un ancien flux ; **sidebar rail** (`sidebar.tsx`) : une seule icône active ; **largeur sidebar** (`components/sidebar.tsx`) — redimensionnement utilisateur par poignée sur le bord droit (desktop `md+`, 280560px, persisté `localStorage` `memento-sidebar-width`) ; défaut responsive `w-80` puis plus large en `xl`/`2xl` si aucune valeur stockée ; **hauteur zone carnets** ajustable via poignée horizontale (`memento-sidebar-notebooks-height`, min 120px) ; fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur les largeurs par défaut (rail fixe 54px) ; **pas de bouton « Note du jour »** dans la sidebar (retiré volontairement) ; **dashboard Second Brain** (`/home`, `dashboard-view.tsx`) : vue d'ensemble **interactive** (pas dashboard statique/mort) — layout **v5** configurable (catalogue ~15 widgets, persisté `/api/dashboard/layout`) ; widget **« Prochaines pistes »** (`next-paths`) = recommandations actionnables depuis la dernière note éditée (fast paths briefing puis enrichissement async `/api/briefing/paths`) ; widget **« Revue quotidienne »** = **checklist matinale interactive** (inbox, découvertes IA, connexion, flashcards) — **pas** une revue automatique de contenu ; cartes cliquables, section Memory Echo « L'IA a trouvé » exploitable (pas vide après 1ère visite), consentement/provider IA + **quotas** vérifiés sur chaque action IA (dashboard et agents) ; chaque widget : **aide contextuelle « ? » i18n** ; couleurs alignées sur le **thème settings** ; icônes **Lucide** (pas emojis système type 🎯) ; `MindMapCard` remplace l'ancien `MiniResonanceGraph` D3 (graphe décoratif qui débordait) ; navigation sidebar alignée sur `/home` ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer létat dégradé exploitable plutôt quune page vide** ; liens/ponts proposés : **pertinence sémantique réelle** (rejeter associations absurdes cross-domaine) ; **pas d'IDs techniques** type « cluster 15 » exposés à l'utilisateur ; bouton Menu immersif `/insights` : **cacher la sidebar ET agrandir le contenu principal** (pas de bandeau beige vide — attention `twMerge` qui peut faire perdre `fixed` au profit de `relative`) ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles ; **landing page** (`components/landing-page.tsx`) : style marketing sombre/premium type **x.ai** (pas de fond blanc plat) ; mettre en avant **Second Brain** ; **multilinguisme** avec sélecteur de langue (icône globe — ne pas confondre avec navigation back) ; harmonie dark mode (ex. zone notifications).
- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global (app **et** extension Web Clipper) ; **Memory Echo** et recherche sémantique doivent fonctionner en persan (RTL, embeddings — pas de contournement « EN only ») ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue.
- Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look).
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur ; **ne jamais hardcoder d'IP/URL** dans le repo ou les défauts CI — uniquement variables d'env (ex. `GRAFANA_BIND`, `GRAFANA_URL`) ; la config runtime reste sur le serveur dans `.env.docker`. **Machines** : agent/dev = **192.168.1.83** (devSanbox) ; prod = **192.168.1.190** (`/opt/memento`) — **ne jamais confondre** les deux au diagnostic services. **CI/CD Gitea spécifique** : (1) CI `ubuntu-24.04` = lint + tests + build (validation) ; deploy `docker-host` = build sur le serveur → `/tmp/web-artifact.tgz``deploy-prod.sh`**sans** upload/download-artifact inter-runners (stockage Gitea instable ; si artifacts utilisés, **@v3** uniquement — @v4`GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages``<Link>` obligatoire pour la navigation interne, pas `<a href="/">`) ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — **avant push `main`** : exécuter localement `npm run lint`, `npm run test:unit`, `npm run build` (même enchaînement que CI) ; chaque push sur `main` déclenche un déploiement automatique en production.
- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée) ; prod : `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `/opt/memento/.env.docker` **et** variables/secrets Gitea Actions (workflow deploy n'écrit pas les valeurs vides) — absence → `invalid_client` ; redirect URI exacte `https://memento-note.com/api/auth/callback/google` ; config via **Google Auth Platform** (Branding → Audience **In production**, pas Testing → Clients Web) ; `ALLOW_REGISTRATION=true` requis pour inscription email (env ou `SystemConfig` en base — la DB peut primer sur l'env).
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design.
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; préférer les **solutions long terme** aux rustines ; si l'utilisateur demande explicitement d'**analyser sans coder**, expliquer d'abord et **ne pas modifier le code** tant qu'il n'a pas validé ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design.
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande ; **avant de développer une story, vérifier d'abord dans le code si la feature existe déjà** — les docs de stories (`docs/story-nextgen-editor.md`, `docs/user-stories.md`) sont **souvent périmés** (features livrées mais marquées « à faire ») ; **dashboard Second Brain** : même rythme — **une feature à la fois**, validation explicite avant la suivante.
- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md` ; **tier BASIC sans accès MCP** ; chaque usage IA (dashboard, agents, MCP si autorisé, etc.) doit **décompter le quota** — ne pas affirmer un déploiement prod sans vérif réelle (ex. travail MCP/quotas encore local).
- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md` ; **tier BASIC sans accès MCP** ; chaque usage IA (dashboard, agents, MCP si autorisé, etc.) doit **décompter le quota** mécanisme = **réservation atomique upfront** (Redis/Postgres) **avant** l'appel IA, sans rollback si l'appel échoue ensuite ; BYOK peut contourner selon config — ne pas affirmer un déploiement prod sans vérif réelle (ex. travail MCP/quotas encore local).
## Learned Workspace Facts

View File

@@ -1,24 +1,16 @@
'use client'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { updateBillingConfig, updatePlanEntitlement } from '@/app/actions/admin-billing'
import { updateBillingConfig } from '@/app/actions/admin-billing'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { CreditCard, Gauge, Settings2 } from 'lucide-react'
type EntitlementRow = {
tier: string
feature: string
limitValue: number | null
mode: 'limited' | 'unlimited' | 'unavailable'
}
import { CreditCard, Gauge, Coins, Package } from 'lucide-react'
type BillingAdminData = {
entitlements: EntitlementRow[]
billingConfig: Record<string, string>
usageOverview: {
period: string
@@ -26,34 +18,55 @@ type BillingAdminData = {
byFeature: Array<{ feature: string; requests: number; tokens: number; users: number }>
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
}
features: string[]
tiers: string[]
/** Allocations mensuelles du solde unique (source de vérité débit) */
creditAllocations?: Array<{
tier: string
monthlyCredits: number | null
unlimited: boolean
}>
creditCosts?: Record<string, number>
creditPacks?: Array<{
id: string
credits: number
defaultDisplay: string
}>
}
function getEntitlement(
entitlements: EntitlementRow[],
tier: string,
feature: string,
): EntitlementRow | undefined {
return entitlements.find((e) => e.tier === tier && e.feature === feature)
const SUBSCRIPTION_PRICE_KEYS = [
'STRIPE_PRICE_PRO_MONTHLY',
'STRIPE_PRICE_PRO_ANNUAL',
'STRIPE_PRICE_BUSINESS_MONTHLY',
'STRIPE_PRICE_BUSINESS_ANNUAL',
] as const
const PACK_PRICE_KEYS = [
'STRIPE_PRICE_CREDITS_S',
'STRIPE_PRICE_CREDITS_M',
'STRIPE_PRICE_CREDITS_L',
] as const
/** Date stable SSR/client (pas de toLocaleString — mismatch locale + fuseau). */
function formatStableUtc(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
}
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
const { t } = useLanguage()
const [activeTier, setActiveTier] = useState(initialData.tiers[0] ?? 'BASIC')
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
const [isSavingBilling, setIsSavingBilling] = useState(false)
const [savingCell, setSavingCell] = useState<string | null>(null)
const handleSaveBilling = async (formData: FormData) => {
setIsSavingBilling(true)
try {
const data: Record<string, string> = {
BILLING_ENABLED: billingEnabled ? 'true' : 'false',
STRIPE_PRICE_PRO_MONTHLY: String(formData.get('STRIPE_PRICE_PRO_MONTHLY') ?? ''),
STRIPE_PRICE_PRO_ANNUAL: String(formData.get('STRIPE_PRICE_PRO_ANNUAL') ?? ''),
STRIPE_PRICE_BUSINESS_MONTHLY: String(formData.get('STRIPE_PRICE_BUSINESS_MONTHLY') ?? ''),
STRIPE_PRICE_BUSINESS_ANNUAL: String(formData.get('STRIPE_PRICE_BUSINESS_ANNUAL') ?? ''),
}
for (const key of [...SUBSCRIPTION_PRICE_KEYS, ...PACK_PRICE_KEYS]) {
data[key] = String(formData.get(key) ?? '')
}
await updateBillingConfig(data)
toast.success(t('admin.billing.configSaved'))
@@ -64,23 +77,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
}
}
const handleEntitlementChange = async (
feature: string,
mode: 'unavailable' | 'unlimited' | 'limited',
limitValue?: number,
) => {
const key = `${activeTier}:${feature}`
setSavingCell(key)
try {
await updatePlanEntitlement(activeTier, feature, mode, limitValue)
toast.success(t('admin.billing.limitSaved'))
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('admin.billing.limitFailed'))
} finally {
setSavingCell(null)
}
}
return (
<div className="space-y-8">
<div>
@@ -98,7 +94,7 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<p className="text-sm text-muted-foreground">{t('admin.billing.stripeConfigDescription')}</p>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-4">
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-6">
<div className="flex items-center space-x-2">
<Checkbox
id="BILLING_ENABLED"
@@ -107,81 +103,105 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
/>
<Label htmlFor="BILLING_ENABLED">{t('admin.billing.enableBilling')}</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{(['STRIPE_PRICE_PRO_MONTHLY', 'STRIPE_PRICE_PRO_ANNUAL', 'STRIPE_PRICE_BUSINESS_MONTHLY', 'STRIPE_PRICE_BUSINESS_ANNUAL'] as const).map((key) => (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
</div>
))}
<div>
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{SUBSCRIPTION_PRICE_KEYS.map((key) => (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
</div>
))}
</div>
</div>
<div className="border-t border-border/50 pt-4">
<div className="flex items-center gap-2 mb-1">
<Package className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">{t('admin.billing.packPricesTitle')}</h3>
</div>
<p className="text-xs text-muted-foreground mb-3">{t('admin.billing.packPricesDescription')}</p>
<div className="grid gap-4 sm:grid-cols-3">
{PACK_PRICE_KEYS.map((key) => (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
</div>
))}
</div>
</div>
<p className="text-xs text-muted-foreground">{t('admin.billing.secretsNote')}</p>
<Button type="submit" disabled={isSavingBilling}>{t('admin.billing.saveConfig')}</Button>
</form>
</div>
{/* Solde unique — source de vérité du débit */}
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Settings2 className="h-5 w-5" />
<Coins className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.limitsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.limitsDescription')}</p>
<h2 className="font-semibold">{t('admin.billing.creditsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.creditsDescription')}</p>
</div>
</div>
<div className="p-6 space-y-4">
<div className="flex flex-wrap gap-2">
{initialData.tiers.map((tier) => (
<button
key={tier}
type="button"
onClick={() => setActiveTier(tier)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
activeTier === tier
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-muted'
}`}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{(initialData.creditAllocations ?? []).map((row) => (
<div
key={row.tier}
className="rounded-xl border border-border/60 bg-muted/30 p-4 space-y-1"
>
{tier}
</button>
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{row.tier}
</p>
<p className="text-2xl font-semibold tabular-nums">
{row.unlimited
? t('admin.billing.modeUnlimited')
: row.monthlyCredits?.toLocaleString('fr-FR')}
</p>
{!row.unlimited && (
<p className="text-[11px] text-muted-foreground">
{t('admin.billing.creditsPerMonth')}
</p>
)}
</div>
))}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">{t('admin.billing.feature')}</th>
<th className="py-2 pr-4">{t('admin.billing.mode')}</th>
<th className="py-2 pr-4">{t('admin.billing.monthlyLimit')}</th>
<th className="py-2">{t('admin.billing.actions')}</th>
</tr>
</thead>
<tbody>
{initialData.features.map((feature) => {
const row = getEntitlement(initialData.entitlements, activeTier, feature)
const mode = row?.mode ?? 'unavailable'
const cellKey = `${activeTier}:${feature}`
return (
<EntitlementRowEditor
key={`${activeTier}:${feature}`}
feature={feature}
mode={mode}
limitValue={row?.limitValue ?? null}
isSaving={savingCell === cellKey}
onSave={handleEntitlementChange}
t={t}
/>
)
})}
</tbody>
</table>
</div>
{(initialData.creditPacks?.length ?? 0) > 0 && (
<div className="border-t border-border/40 pt-3">
<p className="text-xs font-medium mb-2">{t('admin.billing.packsCatalogTitle')}</p>
<div className="flex flex-wrap gap-2">
{(initialData.creditPacks ?? []).map((pack) => (
<span
key={pack.id}
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-muted/20 px-3 py-1.5 text-xs"
>
<span className="font-semibold uppercase">{pack.id}</span>
<span className="text-muted-foreground">
{pack.credits.toLocaleString('fr-FR')} cr. · {pack.defaultDisplay}
</span>
</span>
))}
</div>
</div>
)}
<p className="text-xs text-muted-foreground leading-relaxed border-t border-border/40 pt-3">
{t('admin.billing.creditsCostsNote')}
</p>
<p className="text-xs text-muted-foreground">
{t('admin.billing.creditsResetHint')}
</p>
</div>
</div>
@@ -195,7 +215,10 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<p className="text-sm text-muted-foreground">
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
{initialData.usageOverview.lastSyncedAt
? ` · ${t('admin.billing.lastSync', { date: new Date(initialData.usageOverview.lastSyncedAt).toLocaleString() })}`
? ` · ${t('admin.billing.lastSync', {
// Format fixe UTC (évite mismatch SSR locale/fuseau vs client)
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
})}`
: ` · ${t('admin.billing.notSynced')}`}
</p>
</div>
@@ -236,68 +259,3 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
</div>
)
}
function EntitlementRowEditor({
feature,
mode: initialMode,
limitValue: initialLimit,
isSaving,
onSave,
t,
}: {
feature: string
mode: 'unavailable' | 'unlimited' | 'limited'
limitValue: number | null
isSaving: boolean
onSave: (feature: string, mode: 'unavailable' | 'unlimited' | 'limited', limit?: number) => Promise<void>
t: (key: string, params?: Record<string, string | number>) => string
}) {
const [mode, setMode] = useState(initialMode)
const [limit, setLimit] = useState(initialLimit != null ? String(initialLimit) : '50')
useEffect(() => {
setMode(initialMode)
setLimit(initialLimit != null ? String(initialLimit) : '50')
}, [initialMode, initialLimit])
return (
<tr className="border-b border-border/30">
<td className="py-3 pr-4 font-mono text-xs">{feature}</td>
<td className="py-3 pr-4">
<select
value={mode}
onChange={(e) => setMode(e.target.value as typeof mode)}
className="h-9 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="unavailable">{t('admin.billing.modeUnavailable')}</option>
<option value="limited">{t('admin.billing.modeLimited')}</option>
<option value="unlimited">{t('admin.billing.modeUnlimited')}</option>
</select>
</td>
<td className="py-3 pr-4">
{mode === 'limited' ? (
<Input
type="number"
min={0}
value={limit}
onChange={(e) => setLimit(e.target.value)}
className="h-9 w-24 text-xs"
/>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="py-3">
<Button
type="button"
size="sm"
variant="outline"
disabled={isSaving}
onClick={() => onSave(feature, mode, mode === 'limited' ? parseInt(limit, 10) : undefined)}
>
{isSaving ? '…' : t('admin.billing.saveLimit')}
</Button>
</td>
</tr>
)
}

View File

@@ -1,58 +1,184 @@
import { getUsers } from '@/app/actions/admin'
import { AdminMetrics } from '@/components/admin-metrics'
import { Users, Activity, Database, Zap } from 'lucide-react'
import { Users, Activity, Database, Zap, CreditCard, Bot } from 'lucide-react'
import prisma from '@/lib/prisma'
import Link from 'next/link'
async function getAdminDashboardStats() {
const now = new Date()
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const [
userCount,
noteCount,
notebookCount,
agentsEnabled,
agentRunsToday,
paidSubs,
recentUsers,
] = await Promise.all([
prisma.user.count().catch(() => 0),
prisma.note.count({ where: { trashedAt: null } }).catch(() => 0),
prisma.notebook.count().catch(() => 0),
prisma.agent.count({ where: { isEnabled: true } }).catch(() => 0),
prisma.agentAction.count({ where: { createdAt: { gte: dayAgo } } }).catch(() => 0),
prisma.subscription.count({
where: { tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] }, status: 'ACTIVE' },
}).catch(() => 0),
prisma.user.findMany({
orderBy: { createdAt: 'desc' },
take: 8,
select: { id: true, name: true, email: true, createdAt: true, role: true },
}).catch(() => []),
])
// AI usage this month
let aiRequestsMonth = 0
try {
aiRequestsMonth = await prisma.usageLog.aggregate({
where: { periodStart: { gte: monthStart } },
_sum: { requestsCount: true },
}).then((r) => r._sum.requestsCount ?? 0)
} catch {
try {
aiRequestsMonth = await prisma.agentAction.count({
where: { createdAt: { gte: monthStart } },
})
} catch {
aiRequestsMonth = 0
}
}
return {
userCount,
noteCount,
notebookCount,
agentsEnabled,
agentRunsToday,
paidSubs,
aiRequestsMonth,
recentUsers,
}
}
export default async function AdminPage() {
const users = await getUsers()
const [users, stats] = await Promise.all([getUsers(), getAdminDashboardStats()])
// Mock metrics data - in a real app, these would come from analytics
const metrics = [
{
title: 'Total Users',
value: users.length,
trend: { value: 12, isPositive: true },
title: 'Utilisateurs',
value: stats.userCount,
trend: undefined as { value: number; isPositive: boolean } | undefined,
icon: <Users className="h-5 w-5 text-primary dark:text-primary-foreground" />,
},
{
title: 'Active Sessions',
value: '24',
trend: { value: 8, isPositive: true },
icon: <Activity className="h-5 w-5 text-green-600 dark:text-green-400" />,
},
{
title: 'Total Notes',
value: '1,234',
trend: { value: 24, isPositive: true },
title: 'Notes',
value: stats.noteCount.toLocaleString('fr-FR'),
icon: <Database className="h-5 w-5 text-purple-600 dark:text-purple-400" />,
},
{
title: 'AI Requests',
value: '856',
trend: { value: 5, isPositive: false },
title: 'Abonnements payants',
value: stats.paidSubs,
icon: <CreditCard className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />,
},
{
title: 'Agents actifs',
value: stats.agentsEnabled,
icon: <Bot className="h-5 w-5 text-blue-600 dark:text-blue-400" />,
},
{
title: 'Exécutions agents (24h)',
value: stats.agentRunsToday,
icon: <Activity className="h-5 w-5 text-green-600 dark:text-green-400" />,
},
{
title: 'Usage IA (mois)',
value: stats.aiRequestsMonth.toLocaleString('fr-FR'),
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
},
]
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Dashboard
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Overview of your application metrics
</p>
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
<div>
<h1 className="text-3xl font-bold text-foreground">
Administration
</h1>
<p className="text-muted-foreground mt-1">
Vue d&apos;ensemble en temps réel {stats.notebookCount} carnets · {users.length} comptes listés
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
href="/admin/billing"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Facturation & quotas
</Link>
<Link
href="/admin/ai"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Config IA
</Link>
<Link
href="/admin/users"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Utilisateurs
</Link>
<Link
href="/admin/settings"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Paramètres
</Link>
</div>
</div>
<AdminMetrics metrics={metrics} />
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Recent Activity
</h2>
<p className="text-gray-600 dark:text-gray-400">
Recent activity will be displayed here.
</p>
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
Derniers utilisateurs
</h2>
{stats.recentUsers.length === 0 ? (
<p className="text-muted-foreground text-sm">Aucun utilisateur.</p>
) : (
<ul className="divide-y divide-border">
{stats.recentUsers.map((u) => (
<li key={u.id} className="py-2.5 flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{u.name || u.email}</p>
<p className="text-xs text-muted-foreground truncate">{u.email}</p>
</div>
<div className="text-right shrink-0">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{u.role}</span>
<p className="text-[10px] text-muted-foreground">
{u.createdAt ? new Date(u.createdAt).toLocaleDateString('fr-FR') : '—'}
</p>
</div>
</li>
))}
</ul>
)}
</div>
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
Raccourcis ops
</h2>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>· Configurer les Price IDs Stripe dans <Link href="/admin/billing" className="text-primary underline">Facturation</Link></li>
<li>· Vérifier les quotas IA par feature (BASIC / PRO / BUSINESS)</li>
<li>· Tester les providers dans <Link href="/admin/ai" className="text-primary underline">IA</Link></li>
<li>· CRON_SECRET requis pour agents planifiés (entrypoint Docker)</li>
<li>· Notes indexées via embeddings + fragments (y compris notes courtes)</li>
</ul>
</div>
</div>
</div>
)

View File

@@ -139,11 +139,11 @@ const SUGGESTED_EMBEDDINGS: Record<string, string[]> = {
zai: ['text-embedding-3-small', 'text-embedding-3-large'],
}
type ModelPurpose = 'tags' | 'embeddings' | 'chat'
type ModelPurpose = 'tags' | 'embeddings' | 'chat' | 'slides'
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
const { t } = useLanguage()
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags')
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat' | 'slides'>('tags')
const [isSaving, setIsSaving] = useState(false)
const [isTesting, setIsTesting] = useState(false)
@@ -168,11 +168,15 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v
})
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
const [slidesProvider, setSlidesProvider] = useState<AIProvider>(
(config.AI_PROVIDER_SLIDES as AIProvider) || (config.AI_PROVIDER_CHAT as AIProvider) || 'ollama',
)
// Selected Models State
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
const [selectedChatModel, setSelectedChatModel] = useState<string>(config.AI_MODEL_CHAT || '')
const [selectedSlidesModel, setSelectedSlidesModel] = useState<string>(config.AI_MODEL_SLIDES || '')
const [tagsFallbackProvider, setTagsFallbackProvider] = useState<string>(config.AI_PROVIDER_TAGS_FALLBACK || '')
const [embedFallbackProvider, setEmbedFallbackProvider] = useState<string>(config.AI_PROVIDER_EMBEDDING_FALLBACK || '')
@@ -186,11 +190,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
tags: [],
embeddings: [],
chat: [],
slides: [],
})
const [isLoadingModels, setIsLoadingModels] = useState<Record<ModelPurpose, boolean>>({
tags: false,
embeddings: false,
chat: false,
slides: false,
})
// Fetch models from local providers (Ollama, LM Studio) or cloud providers with /v1/models
@@ -268,6 +274,16 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const key = config[API_KEY_CONFIG[chatProvider]] || ''
if (url && key) await fetchModels('chat', chatProvider, url, key)
}
if (slidesProvider === 'ollama') {
await fetchModels('slides', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
} else if (slidesProvider === 'lmstudio') {
await fetchModels('slides', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
} else if (PROVIDER_META[slidesProvider]?.hasApiKey && slidesProvider !== 'anthropic_custom') {
const url = DEFAULT_BASE_URLS[slidesProvider]
const key = config[API_KEY_CONFIG[slidesProvider]] || ''
if (url && key) await fetchModels('slides', slidesProvider, url, key)
}
}
fetchInitial()
@@ -408,6 +424,32 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
}
}
// Slides provider (dedicated model for deck generation — never auto-renamed)
const slidesProv = formData.get('AI_PROVIDER_SLIDES') as string
data.AI_PROVIDER_SLIDES = slidesProv?.trim() ?? ''
const slidesModel = formData.get('AI_MODEL_SLIDES') as string
data.AI_MODEL_SLIDES = slidesModel?.trim() ?? ''
if (slidesProv) {
if (slidesProv === 'ollama') {
const ollamaUrl = formData.get('BASE_URL_ollama_slides') as string
if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl
} else if (slidesProv === 'lmstudio') {
const url = formData.get('BASE_URL_lmstudio_slides') as string
if (url) data.LMSTUDIO_BASE_URL = url
} else {
const apiKeyConfigKey = API_KEY_CONFIG[slidesProv as AIProvider]
if (apiKeyConfigKey) {
const apiKey = formData.get(`API_KEY_${slidesProv}_slides`) as string
if (apiKey) data[apiKeyConfigKey] = apiKey
}
const baseUrlConfigKey = BASE_URL_CONFIG[slidesProv as AIProvider]
if (baseUrlConfigKey) {
const baseUrl = formData.get(`BASE_URL_${slidesProv}_slides`) as string
if (baseUrl) data[baseUrlConfigKey] = baseUrl
}
}
}
const result = await updateSystemConfig(data)
setIsSaving(false)
@@ -740,6 +782,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<button type="button" onClick={() => setActiveAiTab('tags')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'tags' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🏷 Tags</button>
<button type="button" onClick={() => setActiveAiTab('embeddings')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'embeddings' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🔍 Embeddings</button>
<button type="button" onClick={() => setActiveAiTab('chat')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'chat' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>💬 Chat</button>
<button type="button" onClick={() => setActiveAiTab('slides')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'slides' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>📑 {t('admin.ai.slidesTab')}</button>
</div>
</div>
<div className="p-6 space-y-6">
@@ -945,6 +988,44 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
/>
</div>
</div>
{/* Slides Provider — dedicated model for deck generation */}
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'slides' ? 'block' : 'hidden'}`}>
<h3 className="text-base font-semibold flex items-center gap-2">
<span>📑</span> {t('admin.ai.slidesProvider')}
</h3>
<p className="text-xs text-muted-foreground">
{t('admin.ai.slidesDescription')}
</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_SLIDES">{t('admin.ai.provider')}</Label>
<select
id="AI_PROVIDER_SLIDES"
name="AI_PROVIDER_SLIDES"
value={slidesProvider}
onChange={(e) => {
setSlidesProvider(e.target.value as AIProvider)
setSelectedSlidesModel('')
setDynamicModels((prev) => ({ ...prev, slides: [] }))
}}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{providerOptions.map((opt) => (
<option key={`slides-${opt.value}`} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<input type="hidden" name="AI_MODEL_SLIDES" value={selectedSlidesModel} />
{renderProviderConfig(slidesProvider, 'slides', selectedSlidesModel, setSelectedSlidesModel)}
<p className="text-xs text-muted-foreground border-t border-border/40 pt-3">
{t('admin.ai.slidesQuotaNote')}
</p>
</div>
</div>
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>

View File

@@ -3,9 +3,9 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { Button } from '@/components/ui/button'
import { deleteUser, updateUserRole, updateUserSubscription } from '@/app/actions/admin'
import { deleteUser, updateUserRole, updateUserSubscription, resetUserQuotas } from '@/app/actions/admin'
import { toast } from 'sonner'
import { Trash2, Shield, ShieldOff, Crown, ChevronDown } from 'lucide-react'
import { Trash2, Shield, ShieldOff, Crown, ChevronDown, RotateCcw } from 'lucide-react'
import { format } from 'date-fns'
import { useLanguage } from '@/lib/i18n'
@@ -91,6 +91,7 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
const { t } = useLanguage()
const [users, setUsers] = useState(initialUsers)
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
const [resettingId, setResettingId] = useState<string | null>(null)
const handleDelete = async (id: string) => {
if (!confirm(t('admin.users.confirmDelete'))) return
@@ -103,6 +104,27 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
}
}
const handleResetQuotas = async (user: { id: string; email?: string | null; name?: string | null }) => {
const label = user.email || user.name || user.id
if (!confirm(t('admin.users.confirmResetQuotas', { user: label }))) return
setResettingId(user.id)
try {
const result = await resetUserQuotas(user.id)
toast.success(
t('admin.users.resetQuotasSuccess', {
period: result.period,
count: result.featuresReset.length,
}),
)
} catch (e) {
toast.error(
e instanceof Error ? e.message : t('admin.users.resetQuotasFailed'),
)
} finally {
setResettingId(null)
}
}
const handleRoleToggle = async (user: any) => {
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
try {
@@ -163,6 +185,16 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
<td className="p-4 align-middle text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleResetQuotas(user)}
disabled={resettingId === user.id}
title={t('admin.users.resetQuotas')}
className="text-amber-700 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200"
>
<RotateCcw className={`h-4 w-4 ${resettingId === user.id ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="ghost"
size="sm"

View File

@@ -1,8 +1,35 @@
'use client';
import { LanguageProvider } from '@/lib/i18n/LanguageProvider';
import { LanguageProvider, useLanguage } from '@/lib/i18n/LanguageProvider';
import Link from 'next/link';
import { Globe } from 'lucide-react';
import { ArrowLeft } from 'lucide-react';
function AuthHeader() {
const { t } = useLanguage();
return (
<header className="p-6 md:p-8 flex justify-between items-center relative z-10">
<Link
href="/"
aria-label={t('general.back')}
className="flex items-center gap-2 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors group"
>
<div className="w-8 h-8 rounded-full border border-[var(--border)] flex items-center justify-center group-hover:border-[var(--color-brand-accent)] transition-colors">
<ArrowLeft size={14} className="group-hover:-translate-x-0.5 transition-transform rtl:rotate-180" />
</div>
</Link>
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-[var(--foreground)] text-[var(--background)] rounded-xl flex items-center justify-center shadow-lg">
<span className="font-serif font-bold text-xl">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
</Link>
<div className="w-8" />
</header>
);
}
export default function AuthLayout({
children,
@@ -15,31 +42,13 @@ export default function AuthLayout({
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-[var(--color-brand-accent)]/5 blur-[120px] rounded-full pointer-events-none" />
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-[#D4A373]/5 blur-[120px] rounded-full pointer-events-none" />
<header className="p-6 md:p-8 flex justify-between items-center relative z-10">
<Link
href="/"
className="flex items-center gap-2 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors group"
>
<div className="w-8 h-8 rounded-full border border-[var(--border)] flex items-center justify-center group-hover:border-[var(--color-brand-accent)] transition-colors">
<Globe size={14} className="group-hover:rotate-12 transition-transform" />
</div>
</Link>
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-[var(--foreground)] text-[var(--background)] rounded-xl flex items-center justify-center shadow-lg">
<span className="font-serif font-bold text-xl">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
</Link>
<div className="w-8" />
</header>
<AuthHeader />
<main className="flex-1 flex items-center justify-center p-4 md:p-6 relative z-10">
<div className="w-full max-w-md">
{children}
<p className="text-center mt-8 text-[9px] text-[var(--muted-foreground)] font-bold uppercase tracking-[0.3em] opacity-40 select-none">
© 2025 Memento Labs
© 2026 Memento
</p>
</div>
</main>

View File

@@ -35,13 +35,13 @@ export default async function MainLayout({
initialTranslations={initialTranslations}
initialAiProcessingConsent={aiSettings?.aiProcessingConsent === true}
>
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
<div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background">
<Suspense fallback={<div className="hidden w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 md:block h-full self-stretch" />}>
{/* Fond via --background (suit .dark) — évite le flash beige/clair au changement de route */}
<div className="flex h-screen overflow-hidden bg-background">
<Suspense fallback={<div className="hidden w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 md:block h-full self-stretch bg-sidebar" />}>
<Sidebar user={session?.user} />
</Suspense>
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper dark:bg-background" id="main-content">
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-background" id="main-content">
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-[9999] focus:top-2 focus:left-2 focus:bg-brand-accent focus:text-white focus:px-4 focus:py-2 focus:rounded-lg focus:text-sm focus:font-bold">
Skip to content
</a>

View File

@@ -6,7 +6,7 @@ import { updateUserSettings } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react'
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
import { persistThemePreference, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
import {
NOTES_LAYOUT_STORAGE_KEY,
parseNotesLayoutMode,
@@ -65,8 +65,7 @@ export function AppearanceSettingsClient({
const handleThemeChange = async (value: string) => {
const next = normalizeThemeId(value)
setTheme(next)
localStorage.setItem('theme-preference', next)
applyDocumentTheme(next)
persistThemePreference(next)
await updateUserSettings({ theme: next })
toast.success(t('settings.settingsSaved'))
}

View File

@@ -11,21 +11,21 @@ export default function SettingsLayout({
}) {
const { t } = useLanguage()
return (
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-dark-paper">
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-zinc-950">
<header className="px-4 sm:px-8 md:px-12 pt-8 sm:pt-14 md:pt-20 pb-6 sm:pb-10 md:pb-16 space-y-6 sm:space-y-10 md:space-y-12 shrink-0">
<div className="flex items-start gap-3">
<button
className="md:hidden p-2 -ms-1 text-ink/70 hover:bg-ink/5 rounded-lg transition-colors shrink-0 mt-1"
className="md:hidden p-2 -ms-1 text-ink dark:text-zinc-200 hover:bg-ink/5 dark:hover:bg-white/10 rounded-lg transition-colors shrink-0 mt-1"
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
aria-label={t('settings.title')}
>
<Menu size={22} />
</button>
<div>
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium">
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink dark:text-zinc-50 tracking-tight leading-none italic font-medium">
{t('settings.title')}
</h1>
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60 mt-4">
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete dark:text-zinc-500 opacity-60 mt-4">
{t('settings.description')}
</p>
</div>

View File

@@ -1,16 +1,12 @@
'use client'
import { motion } from 'motion/react'
/**
* Plus danimation dentrée (opacity / translate) :
* ça laissait voir le fond clair une fraction de seconde en mode sombre.
* Conteneur neutre uniquement.
*/
export default function MainTemplate({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
className="flex-1 flex flex-col min-h-0"
>
<div className="flex min-h-0 flex-1 flex-col bg-background text-foreground">
{children}
</motion.div>
</div>
)
}

View File

@@ -3,6 +3,12 @@ import { detectUserLanguage, parseAcceptLanguage } from '@/lib/i18n/detect-user-
import { loadTranslations } from '@/lib/i18n/load-translations'
import { PublicProviders } from '@/components/public-providers'
/**
* Le body racine est en hauteur décran avec débordement masqué
* (pour lapplication connectée). Les pages publiques (accueil, tarifs,
* notes publiées) doivent pouvoir défiler : on crée un conteneur de
* défilement qui couvre tout lécran, indépendant du body.
*/
export default async function PublicLayout({ children }: { children: React.ReactNode }) {
const headersList = await headers()
const browserLang = parseAcceptLanguage(headersList.get('accept-language'))
@@ -11,7 +17,13 @@ export default async function PublicLayout({ children }: { children: React.React
return (
<PublicProviders initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
{children}
<div
data-public-scroll-root
className="fixed inset-0 z-[1] h-[100dvh] w-full overflow-y-auto overflow-x-hidden overscroll-y-contain"
style={{ WebkitOverflowScrolling: 'touch', touchAction: 'pan-y' }}
>
{children}
</div>
</PublicProviders>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,9 @@ const BILLING_CONFIG_KEYS = [
'STRIPE_PRICE_PRO_ANNUAL',
'STRIPE_PRICE_BUSINESS_MONTHLY',
'STRIPE_PRICE_BUSINESS_ANNUAL',
'STRIPE_PRICE_CREDITS_S',
'STRIPE_PRICE_CREDITS_M',
'STRIPE_PRICE_CREDITS_L',
] as const
const TIERS: TierType[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE']
@@ -54,7 +57,42 @@ export async function getBillingAdminData() {
BILLING_CONFIG_KEYS.map((key) => [key, config[key] ?? '']),
)
return { entitlements, billingConfig, usageOverview, features: [...VALID_FEATURES], tiers: TIERS }
const { CREDIT_ALLOCATIONS, CREDIT_COSTS, slideGenerateCreditCost } = await import('@/lib/credits')
const { CREDIT_PACKS, CREDIT_PACK_IDS } = await import('@/lib/billing/credit-packs')
const creditAllocations = TIERS.map((tier) => {
const raw = CREDIT_ALLOCATIONS[tier]
return {
tier,
monthlyCredits: raw === 'unlimited' ? null : raw,
unlimited: raw === 'unlimited',
}
})
const creditCosts = {
...CREDIT_COSTS,
slide_generate_example: slideGenerateCreditCost(7),
}
const creditPacks = CREDIT_PACK_IDS.map((id) => {
const p = CREDIT_PACKS[id]
return {
id: p.id,
credits: p.credits,
defaultDisplay: p.defaultDisplay,
}
})
return {
entitlements,
billingConfig,
usageOverview,
features: [...VALID_FEATURES],
tiers: TIERS,
creditAllocations,
creditCosts,
creditPacks,
}
}
export async function updatePlanEntitlement(

View File

@@ -42,24 +42,47 @@ export async function getSystemConfig() {
return getCachedConfig()
}
/** Keys that may be cleared (empty string) to fall back to chat / defaults. */
const CLEARABLE_CONFIG_KEYS = new Set([
'AI_PROVIDER_SLIDES',
'AI_MODEL_SLIDES',
])
export async function updateSystemConfig(data: Record<string, string>) {
await checkAdmin()
try {
// Filter out empty values but keep 'false' as valid
const filteredData = Object.fromEntries(
Object.entries(data).filter(([key, value]) => value !== '' && value !== null && value !== undefined)
)
// Filter out empty values but keep 'false' as valid.
// Slides provider/model may be empty intentionally (= use Chat fallback).
const toUpsert: Record<string, string> = {}
const toDelete: string[] = []
const operations = Object.entries(filteredData).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value }
})
)
for (const [key, value] of Object.entries(data)) {
if (value === null || value === undefined) continue
if (value === '' && CLEARABLE_CONFIG_KEYS.has(key)) {
toDelete.push(key)
continue
}
if (value === '') continue
toUpsert[key] = value
}
await prisma.$transaction(operations)
const operations = [
...Object.entries(toUpsert).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value },
}),
),
...toDelete.map((key) =>
prisma.systemConfig.deleteMany({ where: { key } }),
),
]
if (operations.length > 0) {
await prisma.$transaction(operations)
}
return { success: true }
} catch (error) {

View File

@@ -186,3 +186,60 @@ export async function updateUserSubscription(userId: string, tier: string) {
throw new Error('Failed to update subscription')
}
}
/**
* Remet à zéro les crédits IA + anciens compteurs feature du mois pour un utilisateur.
* @param feature (optionnel, legacy) — le reset crédits est global ; feature ignorée pour le solde.
*/
export async function resetUserQuotas(userId: string, feature?: string) {
const session = await checkAdmin()
if (!userId?.trim()) {
throw new Error('Identifiant utilisateur manquant')
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true },
})
if (!user) {
throw new Error('Utilisateur introuvable')
}
// Nouveau système : solde de crédits global
const { adminResetCredits } = await import('@/lib/credits')
const balance = await adminResetCredits(userId, { clearPurchased: false })
// Ancien système feature (compat / métriques)
const { resetUserUsageForCurrentPeriod } = await import('@/lib/entitlements')
const legacy = await resetUserUsageForCurrentPeriod(
userId,
feature ? { features: [feature] } : undefined,
)
const { logAuditEventAsync } = await import('@/lib/audit-log')
await logAuditEventAsync({
userId: session.user?.id,
action: 'QUOTA_RESET',
resource: userId,
metadata: {
targetUserId: userId,
targetEmail: user.email,
period: balance.period,
creditsRemaining: balance.unlimited ? 'unlimited' : balance.totalRemaining,
legacyFeaturesReset: legacy.featuresReset,
scope: feature || 'all',
},
})
revalidatePath('/admin')
revalidatePath('/admin/users')
return {
success: true as const,
period: balance.period,
featuresReset: legacy.featuresReset,
redisDeleted: legacy.redisDeleted,
dbUpdated: legacy.dbUpdated,
creditsRemaining: balance.unlimited ? null : balance.totalRemaining,
}
}

View File

@@ -240,10 +240,10 @@ export async function runAgent(id: string) {
return { success: false, agentId: id, error: 'Agent introuvable' }
}
// Fire and forget — return immediately so the UI doesn't block
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(id, userId))
.then(() => { /* revalidation is handled client-side via polling */ })
// Load module first so import failures surface immediately (not silently after response)
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
void executeAgent(id, userId)
.catch(err => console.error('[runAgent] Background error:', err))
return { success: true, agentId: id, status: 'running' }
@@ -319,9 +319,35 @@ export async function toggleAgent(id: string, isEnabled: boolean) {
}
try {
const existing = await prisma.agent.findFirst({
where: { id, userId: session.user.id },
select: {
id: true,
frequency: true,
scheduledTime: true,
scheduledDay: true,
timezone: true,
nextRun: true,
},
})
if (!existing) throw new Error('Agent introuvable')
const data: { isEnabled: boolean; nextRun?: Date | null } = { isEnabled }
// When re-enabling a scheduled agent, ensure nextRun is set (was often null → never cron'd)
if (isEnabled && existing.frequency !== 'manual' && existing.frequency !== 'one-shot') {
const nextRun = calculateNextRun({
frequency: existing.frequency,
scheduledTime: existing.scheduledTime,
scheduledDay: existing.scheduledDay,
timezone: existing.timezone,
})
data.nextRun = nextRun
}
const agent = await prisma.agent.update({
where: { id, userId: session.user.id },
data: { isEnabled }
data,
})
return { success: true, agent }
} catch (error) {

View File

@@ -1,8 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { QuotaExceededError } from '@/lib/entitlements'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { slideGenerateCreditCost, notebookSlideCreditCost, resolveCreditCost } from '@/lib/credits'
import type { FeatureName } from '@/lib/quota-utils'
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
import {
encodeSlideIntentDescription,
normalizeSlideIntent,
type SlideAudience,
type SlidePurpose,
} from '@/lib/ai/services/slide-intent'
type GenerateType = 'slide-generator' | 'excalidraw-generator'
@@ -12,9 +21,10 @@ const TYPE_DEFAULTS: Record<GenerateType, {
maxSteps: number
}> = {
'slide-generator': {
role: 'Génère une présentation professionnelle à partir du contenu de la note fournie. Appelle generate_slides avec un objet JSON structuré {title, theme, slides:[...]}.',
tools: ['note_search', 'note_read', 'generate_slides'],
maxSteps: 6,
// tools unused: slide-generator runs a structured-output pipeline (no function calling)
role: 'Génère un deck exécutif de haute qualité (titres d\'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).',
tools: [],
maxSteps: 1,
},
'excalidraw-generator': {
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
@@ -32,36 +42,100 @@ export async function POST(req: NextRequest) {
const userId = session.user.id
const body = await req.json()
const { noteId, type, theme, style, language, template } = body as {
noteId: string
const { noteId, notebookId, type, theme, style, language, template, purpose, audience, slideCount } = body as {
noteId?: string
/** Si fourni (slides), génère un deck multi-notes du carnet */
notebookId?: string
type: GenerateType
theme?: string
style?: string
language?: string
template?: string
purpose?: SlidePurpose
audience?: SlideAudience
slideCount?: number
}
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
if ((!noteId && !notebookId) || !type || !TYPE_DEFAULTS[type]) {
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
}
if (notebookId && type !== 'slide-generator') {
return NextResponse.json({ error: 'notebookId réservé aux présentations' }, { status: 400 })
}
// Quota check — feature key depends on generation type
const featureKey = type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate'
let noteCountForPack = 1
if (notebookId) {
noteCountForPack = await prisma.note.count({
where: {
notebookId,
userId,
isArchived: false,
trashedAt: null,
},
})
if (noteCountForPack < 1) {
return NextResponse.json({ error: 'Carnet vide ou introuvable' }, { status: 404 })
}
}
// Crédits globaux (respecte le BYOK) — slides note = 1+N ; carnet = coût notebook
const featureKey = (type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate') as FeatureName
const creditCost =
type === 'slide-generator'
? notebookId
? notebookSlideCreditCost({ slideCount, noteCount: noteCountForPack })
: slideGenerateCreditCost(slideCount)
: resolveCreditCost(featureKey)
try {
await reserveUsageOrThrow(userId, featureKey)
await reserveAiUsageOrThrow(userId, featureKey, {
amount: creditCost,
slideCount: type === 'slide-generator' ? slideCount : undefined,
metadata: {
noteId: noteId ?? null,
notebookId: notebookId ?? null,
type,
slideCount: slideCount ?? null,
noteCount: noteCountForPack,
},
lane: 'chat',
})
} catch (e) {
if (e instanceof QuotaExceededError) {
return NextResponse.json({ error: e.message }, { status: 402 })
return NextResponse.json(
{
error: e.message,
code: 'QUOTA_EXCEEDED',
feature: featureKey,
creditCost,
},
{ status: 402 },
)
}
throw e
}
const note = await prisma.note.findFirst({
where: { id: noteId, userId },
select: { id: true, title: true, notebookId: true },
})
if (!note) {
return NextResponse.json({ error: 'Note introuvable' }, { status: 404 })
let note: { id: string; title: string | null; notebookId: string | null } | null = null
if (noteId) {
note = await prisma.note.findFirst({
where: { id: noteId, userId },
select: { id: true, title: true, notebookId: true },
})
if (!note) {
return NextResponse.json({ error: 'Note introuvable' }, { status: 404 })
}
} else if (notebookId) {
const notebook = await prisma.notebook.findFirst({
where: { id: notebookId, userId },
select: { id: true, name: true },
})
if (!notebook) {
return NextResponse.json({ error: 'Carnet introuvable' }, { status: 404 })
}
note = {
id: notebookId,
title: notebook.name,
notebookId,
}
}
const defaults = TYPE_DEFAULTS[type]
@@ -71,31 +145,62 @@ export async function POST(req: NextRequest) {
if (isEn) {
if (type === 'slide-generator') {
const recipeHint = (theme && theme !== 'auto') ? ` Use theme:"${theme}".` : ''
role = `Generate a professional presentation from the provided note content.${recipeHint} Call generate_slides with structured JSON {title, theme, slides:[...]}.`
role = `Generate a high-quality executive deck (action titles, one idea per slide, no filler, charts only with real numbers).${recipeHint}`
} else {
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
}
} else if (type === 'slide-generator') {
const recipeHint = (theme && theme !== 'auto') ? ` Utilise le thème "${theme}".` : ''
role = `Génère une présentation professionnelle à partir du contenu de la note fournie.${recipeHint} Appelle generate_slides avec le JSON structuré {title, theme, slides:[...]}.`
role = `Génère un deck exécutif de haute qualité (titres d'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).${recipeHint}`
}
if (!note) {
return NextResponse.json({ error: 'Source introuvable' }, { status: 404 })
}
const agentName = type === 'slide-generator'
? `${isEn ? 'Slides' : 'Présentation'}${(note.title || 'Note').substring(0, 40)}`
: `${isEn ? 'Diagram' : 'Diagramme'}${(note.title || 'Note').substring(0, 40)}`
const slideIntent =
type === 'slide-generator'
? normalizeSlideIntent({
purpose,
audience,
slideCount,
template: template || 'auto',
})
: null
// Map purpose → legacy template when user picks intent without template
if (slideIntent && (!slideIntent.template || slideIntent.template === 'auto')) {
const purposeToTemplate: Record<string, string> = {
board: 'board-update',
project: 'project-status',
strategy: 'strategy-review',
}
if (slideIntent.purpose && purposeToTemplate[slideIntent.purpose]) {
slideIntent.template = purposeToTemplate[slideIntent.purpose]
}
}
const sourceNotebookId = notebookId || note.notebookId || undefined
const agent = await prisma.agent.create({
data: {
name: agentName,
type,
role,
description: (type === 'slide-generator' && template && template !== 'auto') ? `template:${template}` : undefined,
description:
type === 'slide-generator' && slideIntent
? encodeSlideIntentDescription(slideIntent)
: undefined,
tools: JSON.stringify(defaults.tools),
maxSteps: defaults.maxSteps,
frequency: 'one-shot',
isEnabled: true,
sourceNoteIds: JSON.stringify([noteId]),
targetNotebookId: note.notebookId ?? undefined,
sourceNoteIds: noteId ? JSON.stringify([noteId]) : JSON.stringify([]),
sourceNotebookId: notebookId ? notebookId : undefined,
targetNotebookId: sourceNotebookId,
slideTheme: theme ?? 'keynote',
slideStyle: style ?? 'soft',
userId,
@@ -104,19 +209,31 @@ export async function POST(req: NextRequest) {
// ── Fire and forget — do NOT await so the HTTP response returns immediately ──
// In Node.js / Docker self-hosted, the process keeps running after response.
// skipQuota: units already reserved above (avoids double-charge in executeAgent).
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(agent.id, userId))
.then(({ executeAgent }) => executeAgent(agent.id, userId, undefined, { skipQuota: true }))
.catch(err => console.error('[run-for-note] Background agent error:', err))
logAuditEvent({
userId,
action: 'AI_REQUEST',
resource: featureKey,
metadata: { agentId: agent.id, noteId, featureKey },
metadata: {
agentId: agent.id,
noteId: noteId ?? null,
notebookId: notebookId ?? null,
featureKey,
creditCost,
},
ip: getClientIp(req),
})
return NextResponse.json({ success: true, agentId: agent.id, status: 'running' })
return NextResponse.json({
success: true,
agentId: agent.id,
status: 'running',
creditCost,
})
}
// ─── GET : poll current agent status ──────────────────────────────────────

View File

@@ -33,19 +33,35 @@ export async function POST(request: NextRequest) {
throw err
}
// language = UI locale (fallback only). Content language = language of the topic string.
const result = await notebookWizardService.generateCarnet(
profile as WizardProfile,
topic,
{ level, count, language: language || 'fr' }
)
// Prefer AI short title over raw user prompt (notebookName from client is often the full message)
const resolvedName = (
result.notebookName?.trim()
|| (notebookName && notebookName.trim().length <= 60 ? notebookName.trim() : '')
|| result.notes[0]?.title?.trim()
|| topic.trim().slice(0, 60)
).slice(0, 80)
// Place new notebook at top of manual order (before existing min order)
const minOrder = await prisma.notebook.aggregate({
where: { userId: session.user.id, trashedAt: null },
_min: { order: true },
})
const nextOrder = (minOrder._min.order ?? 0) - 1
// 1. Create notebook
const notebook = await prisma.notebook.create({
data: {
name: notebookName || topic,
name: resolvedName,
icon: notebookIcon || '📚',
userId: session.user.id,
order: 0,
order: nextOrder,
},
})
@@ -87,6 +103,8 @@ export async function POST(request: NextRequest) {
notebookId: notebook.id,
type: 'richtext',
order: noteIndex++,
autoGenerated: true,
aiProvider: 'notebook-wizard',
},
})

View File

@@ -1,13 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { stripe } from '@/lib/stripe';
import { resolvePriceId } from '@/lib/billing/stripe-prices';
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
const bodySchema = z.object({
tier: z.enum(['PRO', 'BUSINESS']),
interval: z.enum(['month', 'year']),
/** Prefer hosted redirect when embedded checkout is unavailable */
mode: z.enum(['hosted', 'embedded']).optional(),
});
export async function POST(req: NextRequest) {
@@ -16,17 +18,46 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!(await isBillingEnabled())) {
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 });
}
const secret = process.env.STRIPE_SECRET_KEY;
if (!secret || secret === 'sk_test_placeholder') {
return NextResponse.json(
{ error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' },
{ status: 503 },
);
}
const parsed = bodySchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const { tier, interval } = parsed.data;
const preferredMode = parsed.data.mode ?? 'hosted';
const userId = session.user.id;
const userEmail = session.user.email;
try {
const priceId = await resolvePriceId(tier, interval);
let priceId: string;
try {
priceId = await resolvePriceId(tier, interval);
} catch (e) {
console.error('[billing/create-checkout] price resolve failed:', e);
return NextResponse.json(
{ error: 'Stripe price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
);
}
if (priceId.startsWith('price_mock_')) {
return NextResponse.json(
{ error: 'Stripe price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
);
}
const subscription = await prisma.subscription.findUnique({ where: { userId } });
let customerId = subscription?.stripeCustomerId ?? undefined;
@@ -41,8 +72,7 @@ export async function POST(req: NextRequest) {
metadata: { userId },
});
customerId = customer.id;
// Update DB to save the real Stripe customer ID
await prisma.subscription.upsert({
where: { userId },
update: { stripeCustomerId: customerId },
@@ -52,8 +82,8 @@ export async function POST(req: NextRequest) {
tier: 'BASIC',
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000), // temp basic dates
}
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000),
},
});
}
@@ -61,29 +91,51 @@ export async function POST(req: NextRequest) {
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
const origin = `${proto}://${host}`;
const sessionParams = {
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
if (preferredMode === 'embedded') {
try {
const embedded = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded' as any,
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
customer_update: { address: 'auto' },
allow_promotion_codes: true,
} as any);
if (embedded.client_secret) {
return NextResponse.json({
clientSecret: embedded.client_secret,
sessionId: embedded.id,
});
}
} catch (embeddedErr) {
console.warn('[billing/create-checkout] embedded failed, falling back to hosted:', embeddedErr);
}
}
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription' as const,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded_page',
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/settings/billing?canceled=1`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
customer_update: { address: 'auto' },
allow_promotion_codes: true,
};
const checkoutSession = await stripe.checkout.sessions.create(sessionParams as any);
});
if (checkoutSession.client_secret) {
return NextResponse.json({
clientSecret: checkoutSession.client_secret,
sessionId: checkoutSession.id,
});
if (!checkoutSession.url) {
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
}
return NextResponse.json({ url: checkoutSession.url });
return NextResponse.json({ url: checkoutSession.url, sessionId: checkoutSession.id });
} catch (error) {
console.error('[billing/create-checkout]', error);
return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 });
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View File

@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { stripe } from '@/lib/stripe'
import { isBillingEnabled } from '@/lib/billing/stripe-prices'
import {
isCreditPackId,
resolvePackPriceId,
getCreditPack,
} from '@/lib/billing/credit-packs'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
const bodySchema = z.object({
packId: z.enum(['S', 'M', 'L']),
})
export async function POST(req: NextRequest) {
const session = await auth()
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await isBillingEnabled())) {
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 })
}
const secret = process.env.STRIPE_SECRET_KEY
if (!secret || secret === 'sk_test_placeholder') {
return NextResponse.json(
{ error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' },
{ status: 503 },
)
}
const parsed = bodySchema.safeParse(await req.json())
if (!parsed.success || !isCreditPackId(parsed.data.packId)) {
return NextResponse.json({ error: 'Invalid pack' }, { status: 400 })
}
const packId = parsed.data.packId
const pack = getCreditPack(packId)
const userId = session.user.id
const userEmail = session.user.email
try {
let priceId: string
try {
priceId = await resolvePackPriceId(packId)
} catch (e) {
console.error('[billing/create-pack-checkout] price resolve failed:', e)
return NextResponse.json(
{ error: 'Credit pack price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
)
}
if (priceId.startsWith('price_mock_')) {
return NextResponse.json(
{ error: 'Credit pack price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
)
}
const subscription = await prisma.subscription.findUnique({ where: { userId } })
let customerId = subscription?.stripeCustomerId ?? undefined
if (customerId && customerId.startsWith('cus_mock')) {
customerId = undefined
}
if (!customerId) {
const customer = await stripe.customers.create({
email: userEmail,
metadata: { userId },
})
customerId = customer.id
await prisma.subscription.upsert({
where: { userId },
update: { stripeCustomerId: customerId },
create: {
userId,
stripeCustomerId: customerId,
tier: 'BASIC',
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000),
},
})
}
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost:3000'
const proto = req.headers.get('x-forwarded-proto') ?? 'http'
const origin = `${proto}://${host}`
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'payment',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}&pack=1`,
cancel_url: `${origin}/settings/billing?canceled=1`,
metadata: {
userId,
type: 'credit_pack',
packId,
credits: String(pack.credits),
},
payment_intent_data: {
metadata: {
userId,
type: 'credit_pack',
packId,
credits: String(pack.credits),
},
},
customer_update: { address: 'auto' },
allow_promotion_codes: true,
})
if (!checkoutSession.url) {
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 })
}
return NextResponse.json({
url: checkoutSession.url,
sessionId: checkoutSession.id,
packId,
credits: pack.credits,
})
} catch (error) {
console.error('[billing/create-pack-checkout]', error)
const msg = error instanceof Error ? error.message : 'Failed to create pack checkout'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
import { stripe } from '@/lib/stripe';
import type Stripe from 'stripe';
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
export const dynamic = 'force-dynamic';
@@ -76,6 +75,42 @@ export async function GET(req: NextRequest) {
const subscription = await prisma.subscription.findUnique({ where: { userId } });
const prices = await getDynamicPrices();
const billingEnabled = await isBillingEnabled();
const { getPackPublicPrices } = await import('@/lib/billing/credit-packs');
const creditPacks = await getPackPublicPrices();
// Si retour checkout pack : créditer au cas où le webhook n'a pas encore tourné
if (sessionId && sessionId.startsWith('cs_')) {
try {
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
if (
checkoutSession.mode === 'payment' &&
checkoutSession.status === 'complete' &&
(checkoutSession.metadata?.type === 'credit_pack' || checkoutSession.metadata?.packId) &&
checkoutSession.metadata?.userId === userId
) {
const { addPurchasedCredits } = await import('@/lib/credits');
const {
getCreditPack,
isCreditPackId,
} = await import('@/lib/billing/credit-packs');
const packId = checkoutSession.metadata?.packId;
let credits = Number(checkoutSession.metadata?.credits ?? 0);
if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) {
credits = getCreditPack(packId).credits;
}
if (Number.isFinite(credits) && credits > 0) {
await addPurchasedCredits(userId, credits, {
stripeSessionId: checkoutSession.id,
packId: packId ?? null,
type: 'credit_pack',
source: 'billing_status_sync',
});
}
}
} catch (packSyncErr) {
console.error('[billing/status] pack sync failed:', packSyncErr);
}
}
return NextResponse.json({
tier,
@@ -86,6 +121,7 @@ export async function GET(req: NextRequest) {
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
prices,
creditPacks,
billingEnabled,
});
} catch (error) {

View File

@@ -6,11 +6,79 @@ import {
handleSubscriptionDeleted,
resolveUserIdFromStripeEvent,
} from '@/lib/billing/sync-subscription-from-stripe';
import { prisma } from '@/lib/prisma';
import { addPurchasedCredits } from '@/lib/credits';
import {
getCreditPack,
isCreditPackId,
resolvePackFromPriceId,
} from '@/lib/billing/credit-packs';
import type Stripe from 'stripe';
export const runtime = 'nodejs';
async function fulfillCreditPackPurchase(session: Stripe.Checkout.Session) {
if (session.mode !== 'payment') return;
if (session.payment_status && session.payment_status !== 'paid' && session.payment_status !== 'no_payment_required') {
console.warn('[billing/webhook] pack checkout not paid yet', session.id, session.payment_status);
return;
}
const userId = session.metadata?.userId as string | undefined;
if (!userId) {
console.warn('[billing/webhook] credit pack: no userId', session.id);
return;
}
let packId = session.metadata?.packId as string | undefined;
let credits = Number(session.metadata?.credits ?? 0);
if ((!packId || !Number.isFinite(credits) || credits <= 0) && session.line_items == null) {
try {
const full = await stripe.checkout.sessions.retrieve(session.id, {
expand: ['line_items.data.price'],
});
const priceId = full.line_items?.data?.[0]?.price?.id;
if (priceId) {
const resolved = await resolvePackFromPriceId(priceId);
if (resolved) {
packId = resolved.packId;
credits = resolved.credits;
}
}
} catch (err) {
console.error('[billing/webhook] expand line_items failed', err);
}
}
if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) {
credits = getCreditPack(packId).credits;
}
if (!Number.isFinite(credits) || credits <= 0) {
console.warn('[billing/webhook] credit pack: invalid credits', session.id, { packId, credits });
return;
}
const result = await addPurchasedCredits(userId, credits, {
stripeSessionId: session.id,
packId: packId ?? null,
type: 'credit_pack',
paymentIntent:
typeof session.payment_intent === 'string'
? session.payment_intent
: session.payment_intent?.id ?? null,
});
if (result.applied) {
console.info('[billing/webhook] credited pack', {
userId,
credits: result.units,
packId,
sessionId: session.id,
});
}
}
export async function POST(req: NextRequest) {
const body = await req.text();
const headersList = await headers();
@@ -49,6 +117,11 @@ export async function POST(req: NextRequest) {
} else {
console.warn('[billing/webhook] checkout.session.completed: no userId in metadata', session.id);
}
} else if (
session.mode === 'payment' &&
(session.metadata?.type === 'credit_pack' || session.metadata?.packId)
) {
await fulfillCreditPackPurchase(session);
}
break;
}

View File

@@ -27,10 +27,12 @@ export async function POST(request: NextRequest) {
try {
const now = new Date()
// Due = nextRun in the past; also backfill agents whose nextRun was never set
// (legacy / toggle bug) — but only schedule them, don't stampede-execute all nulls
const dueAgents = await prisma.agent.findMany({
where: {
isEnabled: true,
frequency: { not: 'manual' },
frequency: { notIn: ['manual', 'one-shot'] },
nextRun: { lte: now },
},
select: {
@@ -40,23 +42,77 @@ export async function POST(request: NextRequest) {
scheduledTime: true,
scheduledDay: true,
timezone: true,
nextRun: true,
},
orderBy: { nextRun: 'asc' },
})
if (dueAgents.length === 0) {
return NextResponse.json({ success: true, executed: 0 })
// Repair: set nextRun for enabled scheduled agents stuck with null
const unscheduled = await prisma.agent.findMany({
where: {
isEnabled: true,
frequency: { notIn: ['manual', 'one-shot'] },
nextRun: null,
},
select: {
id: true,
userId: true,
frequency: true,
scheduledTime: true,
scheduledDay: true,
timezone: true,
},
take: 50,
})
const repairedDue: typeof dueAgents = []
for (const a of unscheduled) {
const nextRun = calculateNextRun({
frequency: a.frequency,
scheduledTime: a.scheduledTime,
scheduledDay: a.scheduledDay,
timezone: a.timezone,
})
// Si le créneau calculé est déjà passé / immédiat → exécuter ce cycle
const effective = !nextRun || nextRun <= now ? now : nextRun
await prisma.agent.update({ where: { id: a.id }, data: { nextRun: effective } })
if (effective <= now) {
repairedDue.push({
id: a.id,
userId: a.userId,
frequency: a.frequency,
scheduledTime: a.scheduledTime,
scheduledDay: a.scheduledDay,
timezone: a.timezone,
nextRun: effective,
})
}
}
const queue = [...dueAgents]
const seen = new Set(dueAgents.map((a) => a.id))
for (const a of repairedDue) {
if (!seen.has(a.id)) {
queue.push(a)
seen.add(a.id)
}
}
if (queue.length === 0) {
return NextResponse.json({
success: true,
executed: 0,
repairedSchedules: unscheduled.length,
})
}
const results: { id: string; success: boolean; error?: string }[] = []
// Execute agents sequentially (max 3 per cycle)
for (const agent of dueAgents.slice(0, 3)) {
// Execute agents sequentially (max 5 per cycle — rattrapage + due)
for (const agent of queue.slice(0, 5)) {
try {
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
const result = await executeAgent(agent.id, agent.userId)
// Calculate and set next run
const nextRun = calculateNextRun({
frequency: agent.frequency,
scheduledTime: agent.scheduledTime,
@@ -64,7 +120,6 @@ export async function POST(request: NextRequest) {
timezone: agent.timezone,
})
await prisma.agent.update({
where: { id: agent.id },
data: { nextRun },
@@ -75,7 +130,6 @@ export async function POST(request: NextRequest) {
const msg = error instanceof Error ? error.message : 'Unknown error'
console.error(`[CronAgents] Agent ${agent.id} failed:`, msg)
// Still schedule next run even on failure
const nextRun = calculateNextRun({
frequency: agent.frequency,
scheduledTime: agent.scheduledTime,

View File

@@ -3,6 +3,7 @@ import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { randomUUID } from 'crypto'
import { auth } from '@/auth'
import { writeUploadMeta } from '@/lib/upload-access'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
@@ -14,14 +15,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json(
{ error: 'No file uploaded' },
{ status: 400 }
)
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 })
}
if (!ALLOWED_TYPES.includes(file.type)) {
@@ -33,7 +32,6 @@ export async function POST(request: NextRequest) {
}
const buffer = Buffer.from(await file.arrayBuffer())
// Resolve extension from file name, falling back to MIME type (e.g. clipboard pastes)
let ext = path.extname(file.name).toLowerCase()
if (!['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
const mimeToExt: Record<string, string> = {
@@ -44,23 +42,28 @@ export async function POST(request: NextRequest) {
}
ext = mimeToExt[file.type] || '.png'
}
const filename = `${randomUUID()}${ext}`
// Ensure directory exists (data/uploads is outside public/, served via API route)
const uploadDir = path.join(process.cwd(), 'data/uploads/notes')
// Ownership in path: /uploads/notes/{userId}/{filename}
const relativeUnderNotes = `${userId}/${filename}`
const uploadDir = path.join(process.cwd(), 'data', 'uploads', 'notes', userId)
await mkdir(uploadDir, { recursive: true })
const filePath = path.join(uploadDir, filename)
await writeFile(filePath, buffer)
await writeUploadMeta(relativeUnderNotes, userId)
return NextResponse.json({
success: true,
url: `/uploads/notes/${filename}`
const url = `/uploads/notes/${relativeUnderNotes}`
return NextResponse.json({
success: true,
url,
})
} catch (error) {
console.error('[upload]', error)
return NextResponse.json(
{ error: 'Failed to upload file' },
{ status: 500 }
{ status: 500 },
)
}
}

View File

@@ -21,16 +21,30 @@ export async function GET(
const session = await auth()
const { path: segments } = await params
// Only serve from uploads/notes/ subdirectory
if (segments[0] !== 'notes') {
// Only serve from uploads/notes/ (flat or userId/file)
if (!segments.length || segments[0] !== 'notes') {
return new NextResponse('Not found', { status: 404 })
}
// Reject path traversal
if (segments.some((s) => s === '..' || s.includes('\0'))) {
return new NextResponse('Forbidden', { status: 403 })
}
const filename = segments[segments.length - 1]
const allowed = await canAccessUploadedNoteImage(filename, session?.user?.id)
// Never serve meta sidecars as images
if (filename.endsWith('.meta.json')) {
return new NextResponse('Not found', { status: 404 })
}
const allowed = await canAccessUploadedNoteImage(segments, session?.user?.id)
if (!allowed) {
return new NextResponse(session?.user?.id ? 'Forbidden' : 'Unauthorized', {
status: session?.user?.id ? 403 : 401,
headers: {
// Do not let browsers / CDNs cache a denial (would stick a broken image)
'Cache-Control': 'no-store',
},
})
}
@@ -40,9 +54,8 @@ export async function GET(
return new NextResponse('Unsupported file type', { status: 400 })
}
// Prevent path traversal
const safePath = path.join(UPLOAD_DIR, ...segments)
if (!safePath.startsWith(UPLOAD_DIR)) {
if (!safePath.startsWith(UPLOAD_DIR + path.sep) && safePath !== UPLOAD_DIR) {
return new NextResponse('Forbidden', { status: 403 })
}
@@ -57,7 +70,8 @@ export async function GET(
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
// private: only for authenticated session fetches; still long cache after auth ok
'Cache-Control': 'private, max-age=31536000, immutable',
'Content-Length': String(buffer.length),
},
})

View File

@@ -1,27 +1,48 @@
import { NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserQuotas, getEffectiveTier } from '@/lib/entitlements';
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getCreditUsageSummary, getQuotasCompatFromCredits } from '@/lib/credits'
export const dynamic = 'force-dynamic';
export const dynamic = 'force-dynamic'
export async function GET() {
const session = await auth();
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const [quotas, tier] = await Promise.all([
getUserQuotas(session.user.id),
getEffectiveTier(session.user.id),
]);
return NextResponse.json({ quotas, tier });
const summary = await getCreditUsageSummary(session.user.id)
// Compat anciens clients : quotas dérivés des crédits
const quotas = await getQuotasCompatFromCredits(session.user.id)
return NextResponse.json({
tier: summary.tier,
period: summary.period,
balance: {
totalRemaining: summary.balance.unlimited
? null
: summary.balance.totalRemaining,
subscriptionRemaining: summary.balance.unlimited
? null
: summary.balance.subscriptionRemaining,
purchasedRemaining: summary.balance.purchasedRemaining,
subscriptionGranted: summary.balance.unlimited
? null
: summary.balance.subscriptionGranted,
subscriptionSpent: summary.balance.subscriptionSpent,
spentThisPeriod: summary.balance.spentThisPeriod,
unlimited: summary.balance.unlimited,
},
breakdown: summary.breakdown,
// legacy
quotas,
})
} catch (error) {
console.error('[usage/current] Failed to fetch quotas:', error);
console.error('[usage/current] Failed to fetch usage data:', error)
return NextResponse.json(
{ error: 'Failed to fetch usage data' },
{ status: 503 },
);
)
}
}

View File

@@ -4,7 +4,21 @@
@import "vazirmatn/Vazirmatn-font-face.css";
@import "katex/dist/katex.min.css";
@custom-variant dark (&:is(.dark *));
/* Inclure .dark lui-même + ses descendants (évite styles clairs manquants) */
@custom-variant dark (&:where(.dark, .dark *));
/* Filet de sécurité anti-flash : fond sombre dès que html a .dark,
même si une frame Tailwind na pas encore appliqué dark: sur un enfant. */
html.dark {
color-scheme: dark;
background-color: #202020;
}
html.dark body {
color-scheme: dark;
background-color: #202020;
color: #ffffff;
}
/* ── Global UX fixes (UI/UX Pro Max audit) ────────────────────────────── */
@@ -1623,7 +1637,11 @@ html.font-system * {
height: auto;
display: block;
margin: 0.5em 0;
transition: filter 0.15s ease;
transition: filter 0.15s ease, outline 0.15s ease;
cursor: pointer;
/* Ensure clicks select the node (not swallowed by surrounding layout) */
position: relative;
z-index: 1;
}
.notion-editor-wrapper .ProseMirror img:hover {
@@ -1635,6 +1653,14 @@ html.font-system * {
outline-offset: 2px;
}
/* Image node wrapper when selected as block */
.notion-editor-wrapper .ProseMirror .ProseMirror-selectednode img,
.notion-editor-wrapper .ProseMirror img.ProseMirror-selectednode {
outline: 2px solid var(--primary);
outline-offset: 2px;
box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 25%, transparent);
}
/* --- Links --- */
.notion-editor-wrapper .ProseMirror a {
color: var(--primary);

View File

@@ -5,14 +5,17 @@ import { SessionProviderWrapper } from "@/components/session-provider-wrapper";
import { getAISettings } from "@/app/actions/ai-settings";
import { getUserSettings } from "@/app/actions/user-settings";
import { ThemeInitializer } from "@/components/theme-initializer";
import { ThemeClassGuard } from "@/components/theme-class-guard";
import { DirectionInitializer } from "@/components/direction-initializer";
import { ErrorReporter } from "@/components/error-reporter";
import { auth } from "@/auth";
import { CookieConsentRoot } from "@/components/legal/cookie-consent-root";
import { LanguageProvider } from "@/lib/i18n/LanguageProvider";
import Script from "next/script";
import type { CSSProperties } from "react";
import { normalizeThemeId } from "@/lib/apply-document-theme";
import { cookies } from "next/headers";
import { normalizeThemeId, THEME_COOKIE_NAME } from "@/lib/apply-document-theme";
import { THEME_INIT_SCRIPT, DIRECTION_INIT_SCRIPT } from "@/lib/inline-boot-scripts";
import { SwCleanup } from "@/components/sw-cleanup";
import { Inter, Manrope, Playfair_Display, JetBrains_Mono } from "next/font/google";
@@ -76,16 +79,29 @@ export default async function RootLayout({
const session = await auth();
const userId = session?.user?.id;
const [aiSettings, userSettings] = await Promise.all([
const [aiSettings, userSettings, cookieStore] = await Promise.all([
getAISettings(userId),
getUserSettings(userId),
cookies(),
])
const htmlTheme = serverHtmlThemeState(userSettings.theme)
// Cookie = préférence immédiate (bascule barre latérale) ; base = profil utilisateur.
// Sans cookie, un dark seulement en localStorage était écrasé par le serveur → flash clair.
const cookieTheme = cookieStore.get(THEME_COOKIE_NAME)?.value
const resolvedTheme = normalizeThemeId(cookieTheme || userSettings.theme || 'light')
const htmlTheme = serverHtmlThemeState(resolvedTheme)
const serverAccent = userSettings.accentColor ?? '#A47148'
const serverTheme = normalizeThemeId(userSettings.theme || 'light')
const serverTheme = resolvedTheme
const wantsDarkClass =
resolvedTheme === 'dark' ||
resolvedTheme === 'midnight' ||
// auto : le serveur ne connaît pas prefers-color-scheme → laisser le script client
false
const htmlStyle = {
'--color-brand-accent': serverAccent,
// Aide le navigateur à peindre scrollbars/inputs dans le bon mode
colorScheme: wantsDarkClass || resolvedTheme === 'dark' || resolvedTheme === 'midnight' ? 'dark' : resolvedTheme === 'light' ? 'light' : undefined,
} as CSSProperties
return (
@@ -97,14 +113,18 @@ export default async function RootLayout({
data-server-accent={serverAccent}
style={htmlStyle}
>
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable} h-screen overflow-hidden`}>
<Script id="theme-init" src="/scripts/theme-init.js" strategy="beforeInteractive" />
<Script id="direction-init" src="/scripts/direction-init.js" strategy="beforeInteractive" />
<Script id="sw-cleanup" src="/scripts/sw-cleanup.js" strategy="afterInteractive" />
<head>
{/* Scripts boot inline : anti-flash thème/RTL, sans next/script (React 19). */}
<script id="theme-init" dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
<script id="direction-init" dangerouslySetInnerHTML={{ __html: DIRECTION_INIT_SCRIPT }} />
</head>
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable} h-screen overflow-hidden bg-background text-foreground`}>
<SessionProviderWrapper>
<SwCleanup />
<ErrorReporter />
<DirectionInitializer />
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} accentColor={userSettings.accentColor} />
<ThemeInitializer theme={resolvedTheme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} accentColor={userSettings.accentColor} />
<ThemeClassGuard />
{children}
<LanguageProvider initialLanguage="en">
<CookieConsentRoot />

View File

@@ -25,14 +25,14 @@ export function AdminMetrics({ metrics, className }: AdminMetricsProps) {
return (
<div
className={cn(
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4',
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4',
className
)}
>
{metrics.map((metric, index) => (
<Card
key={index}
className="p-6 bg-white dark:bg-zinc-900 border-gray-200 dark:border-gray-800"
className="p-6 bg-card border-border"
>
<div className="flex items-start justify-between">
<div className="flex-1">

View File

@@ -406,7 +406,12 @@ export function BlockActionMenu({
{/* Création de diagramme */}
<button type="button" className="block-action-item" onClick={() => { void handleCreateDiagram() }}>
<Sparkles size={16} className="text-amber-500 transition-all duration-200" />
<span className="font-medium text-amber-700 dark:text-amber-400">{t('blockAction.createDiagram')}</span>
<span className="font-medium text-amber-700 dark:text-amber-400 flex flex-col items-start gap-0.5">
<span>{t('blockAction.createDiagram')}</span>
<span className="text-[9px] font-normal text-muted-foreground normal-case tracking-normal">
{t('blockAction.createDiagramCostHint')}
</span>
</span>
</button>
<div className="block-action-separator" />

View File

@@ -64,7 +64,7 @@ export function ChartSuggestionsDialog({
console.error('[ChartSuggestionsDialog] Error:', err)
setResponse({
suggestions: [],
analyzedText: textToAnalyze?.substring(0, 100) || '',
analyzedText: (selection || content || '').substring(0, 100),
detectedData: 'Error occurred',
hasData: false,
error: err.message || 'Network error - check console',
@@ -124,7 +124,7 @@ export function ChartSuggestionsDialog({
<div className="flex items-center gap-3">
<BarChart3 className="w-5 h-5 text-blue-500" />
<div>
<h2 className="text-lg font-semibold">Chart Suggestions</h2>
<h2 className="text-lg font-semibold">{t('chart.suggestionsTitle') || 'Suggestions de graphiques'}</h2>
<p className="text-sm text-muted-foreground">
{loading ? (
<span className="flex items-center gap-2">
@@ -137,6 +137,9 @@ export function ChartSuggestionsDialog({
t('chart.noDataDetected')
)}
</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{t('chart.creditCostHint') || 'Coût estimé : 2 crédits IA'}
</p>
</div>
</div>
<button

View File

@@ -229,6 +229,9 @@ export function ContextualAIChat({
const [slideTheme, setSlideTheme] = useState('auto')
const [slideStyle, setSlideStyle] = useState('professional')
const [slideTemplate, setSlideTemplate] = useState('auto')
const [slidePurpose, setSlidePurpose] = useState('auto')
const [slideAudience, setSlideAudience] = useState('auto')
const [slideCount, setSlideCount] = useState('auto')
const [diagramType, setDiagramType] = useState('logic_flow')
const [diagramStyle, setDiagramStyle] = useState('polished')
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
@@ -444,6 +447,12 @@ export function ContextualAIChat({
style: type === 'slides' ? slideStyle : diagramStyle,
language: language === 'fr' ? 'French' : 'English',
template: type === 'slides' ? slideTemplate : undefined,
purpose: type === 'slides' ? slidePurpose : undefined,
audience: type === 'slides' ? slideAudience : undefined,
slideCount:
type === 'slides' && slideCount !== 'auto'
? parseInt(slideCount, 10)
: undefined,
}),
})
const data = await res.json()
@@ -1064,13 +1073,13 @@ export function ContextualAIChat({
<div className="h-px flex-1 bg-border/40" />
</div>
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="group relative p-6 rounded-2xl bg-card dark:bg-white/5 border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Layout size={80} className="text-brand-accent" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><Layout size={18} /></div>
<div className="p-2 bg-slate-50 dark:bg-white/10 rounded-lg text-brand-accent"><Layout size={18} /></div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.slides')}</h5>
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.sectionLabel')}</p>
@@ -1105,16 +1114,66 @@ export function ContextualAIChat({
</select>
</div>
</div>
<div className="space-y-1.5">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.template')}</span>
<select value={slideTemplate} onChange={e => setSlideTemplate(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
<option value="auto">{t('ai.generate.templateAuto')}</option>
<option value="board-update">{t('ai.generate.templateBoard')}</option>
<option value="project-status">{t('ai.generate.templateProject')}</option>
<option value="strategy-review">{t('ai.generate.templateStrategy')}</option>
<option value="quarterly-results">{t('ai.generate.templateQuarterly')}</option>
</select>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.purpose') || 'Type de deck'}</span>
<select value={slidePurpose} onChange={e => setSlidePurpose(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
<option value="auto">{t('ai.generate.purposeAuto') || 'Auto (selon la note)'}</option>
<option value="course">{t('ai.generate.purposeCourse') || 'Cours / leçon'}</option>
<option value="board">{t('ai.generate.purposeBoard') || 'Comité / board'}</option>
<option value="project">{t('ai.generate.purposeProject') || 'Suivi projet'}</option>
<option value="strategy">{t('ai.generate.purposeStrategy') || 'Stratégie'}</option>
<option value="pitch">{t('ai.generate.purposePitch') || 'Pitch'}</option>
<option value="summary">{t('ai.generate.purposeSummary') || 'Synthèse courte'}</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.audience') || 'Audience'}</span>
<select value={slideAudience} onChange={e => setSlideAudience(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
<option value="auto">{t('ai.generate.audienceAuto') || 'Auto'}</option>
<option value="student">{t('ai.generate.audienceStudent') || 'Étudiants'}</option>
<option value="board">{t('ai.generate.audienceBoard') || 'Direction'}</option>
<option value="team">{t('ai.generate.audienceTeam') || 'Équipe'}</option>
<option value="client">{t('ai.generate.audienceClient') || 'Client'}</option>
<option value="general">{t('ai.generate.audienceGeneral') || 'Grand public'}</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.slideCount') || 'Nb de slides'}</span>
<select value={slideCount} onChange={e => setSlideCount(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
<option value="auto">{t('ai.generate.slideCountAuto') || 'Auto (selon longueur)'}</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.template')}</span>
<select value={slideTemplate} onChange={e => setSlideTemplate(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
<option value="auto">{t('ai.generate.templateAuto')}</option>
<option value="board-update">{t('ai.generate.templateBoard')}</option>
<option value="project-status">{t('ai.generate.templateProject')}</option>
<option value="strategy-review">{t('ai.generate.templateStrategy')}</option>
<option value="quarterly-results">{t('ai.generate.templateQuarterly')}</option>
</select>
</div>
</div>
{(() => {
const n = slideCount === 'auto' ? 6 : Math.min(8, Math.max(3, parseInt(slideCount, 10) || 6))
const creditCost = 1 + n
return (
<p className="text-[10px] text-muted-foreground px-0.5 leading-relaxed">
{t('ai.generate.creditCostEstimate', {
cost: String(creditCost),
slides: String(n),
})}
</p>
)
})()}
<button onClick={() => handleGenerate('slides')} disabled={!!generateLoading} className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em] disabled:opacity-50">
{generateLoading === 'slides' ? <Loader2 size={14} className="animate-spin" /> : <><Presentation size={14} className="opacity-80" /> {t('ai.generating')}</>}
</button>
@@ -1170,13 +1229,13 @@ export function ContextualAIChat({
</div>
</div>
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="group relative p-6 rounded-2xl bg-card dark:bg-white/5 border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<BookOpen size={80} className="text-brand-accent" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><BookOpen size={18} /></div>
<div className="p-2 bg-slate-50 dark:bg-white/10 rounded-lg text-brand-accent"><BookOpen size={18} /></div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.diagram')}</h5>
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.diagramReadyHint')}</p>
@@ -1280,7 +1339,7 @@ export function ContextualAIChat({
{ id: 'complete', label: t('ai.resource.modeComplete'), sub: t('ai.resource.modeCompleteDesc') },
{ id: 'merge', label: t('ai.resource.modeMerge'), sub: t('ai.resource.modeMergeDesc') },
].map((mode) => (
<button key={mode.id} onClick={() => setResourceMode(mode.id as any)} className={cn("flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center", resourceMode === mode.id ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white border-border hover:bg-slate-50 dark:hover:bg-white/5')}>
<button key={mode.id} onClick={() => setResourceMode(mode.id as any)} className={cn("flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center", resourceMode === mode.id ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-card dark:bg-white/5 border-border hover:bg-slate-50 dark:hover:bg-white/10')}>
<span className={cn("text-[10px] font-bold", resourceMode === mode.id ? 'text-brand-accent' : 'text-ink')}>{mode.label}</span>
<span className="text-[8px] text-concrete opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
</button>

View File

@@ -126,9 +126,12 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
const { t } = useLanguage()
const iframeRef = useRef<HTMLIFrameElement>(null)
const stageRef = useRef<HTMLDivElement>(null)
const [currentSlide, setCurrentSlide] = useState(1)
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
const totalSlides = data.slideCount || 1
// Always preview the generated HTML (KaTeX, themes, density).
// React SlidesRenderer used an older layout schema and looked broken vs HTML.
const [isLoaded, setIsLoaded] = useState(false)
const totalSlides = data.slideCount || data.spec?.slides?.length || 1
// Hide the global AI floating button while slides are displayed
useEffect(() => {
@@ -149,10 +152,36 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
return () => window.removeEventListener('message', handler)
}, [])
// Fit a 16:9 stage into the available panel (letterbox, no crop)
useEffect(() => {
const stage = stageRef.current
if (!stage) return
const parent = stage.parentElement
if (!parent) return
const fit = () => {
const pw = parent.clientWidth
const ph = parent.clientHeight
if (pw < 32 || ph < 32) return
// Design frame 16:9 — pick max size that fits
const designW = 1280
const designH = 720
const scale = Math.min(pw / designW, ph / designH)
const w = Math.round(designW * scale)
const h = Math.round(designH * scale)
stage.style.width = `${w}px`
stage.style.height = `${h}px`
}
fit()
const ro = new ResizeObserver(fit)
ro.observe(parent)
return () => ro.disconnect()
}, [])
const navigate = (dir: number) => {
const iframe = iframeRef.current
if (!iframe) return
// Direct contentWindow access (works with allow-same-origin)
try {
const win = iframe.contentWindow as any
if (typeof win?.changeSlide === 'function') {
@@ -160,7 +189,6 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
return
}
} catch (_) {}
// Fallback: postMessage
iframe.contentWindow?.postMessage({ type: 'navigate', dir }, '*')
}
@@ -183,6 +211,9 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
setTimeout(() => URL.revokeObjectURL(url), 5000)
}
// Prefer polished HTML; fall back to React only if html is missing
const useHtml = Boolean(data.html && data.html.length > 200)
return (
<div className="absolute inset-0 flex flex-col bg-zinc-950">
{/* Toolbar */}
@@ -192,7 +223,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
<span className="text-sm font-medium truncate">{name}</span>
{totalSlides > 1 && (
<span className="text-xs bg-white/10 px-2 py-0.5 rounded-full shrink-0 tabular-nums">
{currentSlide} / {totalSlides}
{currentSlide}&nbsp;/&nbsp;{totalSlides}
</span>
)}
</div>
@@ -226,45 +257,53 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
</button>
</div>
</div>
{/* Slides: React renderer (spec) or iframe (HTML fallback) */}
<div className="flex-1 relative overflow-hidden group">
{data.spec ? (
<SlidesRenderer spec={data.spec} />
) : (
{/* Stage: 16:9 letterboxed HTML (source of visual truth) */}
<div className="flex-1 relative overflow-hidden group flex items-center justify-center bg-zinc-950 p-3 sm:p-4">
{useHtml ? (
<>
{/* Loading overlay — visible until iframe fires onLoad */}
{!isLoaded && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3">
<div className="w-8 h-8 rounded-full border-2 border-white/10 border-t-white/60 animate-spin" />
<span className="text-xs text-white/30">{t('lab.loadingPresentation')}</span>
</div>
)}
<iframe
ref={iframeRef}
srcDoc={data.html}
className="absolute inset-0 w-full h-full border-0"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
title={name}
allow="fullscreen"
onLoad={() => setIsLoaded(true)}
/>
{/* Prev button */}
<div
ref={stageRef}
className="relative overflow-hidden rounded-lg shadow-2xl shadow-black/50 border border-white/10 bg-black"
style={{ width: '100%', maxWidth: '100%', aspectRatio: '16 / 9' }}
>
<iframe
ref={iframeRef}
srcDoc={data.html}
className="absolute inset-0 w-full h-full border-0"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
title={name}
allow="fullscreen"
onLoad={() => setIsLoaded(true)}
/>
</div>
<button
onClick={() => navigate(-1)}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 hover:bg-black/80 backdrop-blur-sm border border-white/10 text-white/50 hover:text-white transition-all opacity-0 group-hover:opacity-100"
aria-label={t('lab.previousSlide')}
>
<ChevronLeft className="w-5 h-5" />
</button>
{/* Next button */}
<button
onClick={() => navigate(1)}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 hover:bg-black/80 backdrop-blur-sm border border-white/10 text-white/50 hover:text-white transition-all opacity-0 group-hover:opacity-100"
aria-label={t('lab.nextSlide')}
>
<ChevronRight className="w-5 h-5" />
</button>
</>
) : data.spec ? (
<div className="w-full h-full">
<SlidesRenderer spec={data.spec} />
</div>
) : (
<div className="text-white/40 text-sm">{t('lab.loadingPresentation')}</div>
)}
</div>
</div>

View File

@@ -1,31 +1,72 @@
'use client'
import { motion } from 'motion/react'
import { motion, AnimatePresence } from 'motion/react'
import {
BrainCircuit, Search, MessageSquare, Zap, Cpu, Workflow,
Globe, Shield, ArrowRight, Sparkles, Layers, Activity,
Box
ArrowRight, Menu, X, Check, BrainCircuit,
Network, GraduationCap, Bot, KeyRound, Globe, ChevronDown
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import { useLanguage } from '@/lib/i18n'
import { useState } from 'react'
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
import { useEffect, useRef, useState, type ReactNode } from 'react'
const ECHO_LINES = ['echo0', 'echo1', 'echo2'] as const
const LANDING_LANGS: { code: SupportedLanguage; labelKey: string }[] = [
{ code: 'fr', labelKey: 'languages.fr' },
{ code: 'en', labelKey: 'languages.en' },
{ code: 'es', labelKey: 'languages.es' },
{ code: 'de', labelKey: 'languages.de' },
{ code: 'it', labelKey: 'languages.it' },
{ code: 'pt', labelKey: 'languages.pt' },
{ code: 'nl', labelKey: 'languages.nl' },
{ code: 'pl', labelKey: 'languages.pl' },
{ code: 'ru', labelKey: 'languages.ru' },
{ code: 'zh', labelKey: 'languages.zh' },
{ code: 'ja', labelKey: 'languages.ja' },
{ code: 'ko', labelKey: 'languages.ko' },
{ code: 'ar', labelKey: 'languages.ar' },
{ code: 'fa', labelKey: 'languages.fa' },
{ code: 'hi', labelKey: 'languages.hi' },
]
export function LandingPage() {
const { t } = useLanguage()
const router = useRouter()
const { t, language, setLanguage } = useLanguage()
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
const [menuOpen, setMenuOpen] = useState(false)
const [langOpen, setLangOpen] = useState(false)
const [echoIndex, setEchoIndex] = useState(0)
const langRef = useRef<HTMLDivElement>(null)
const AGENTS = [
{ key: 'scraper', icon: <Globe size={24} /> },
{ key: 'researcher', icon: <Search size={24} /> },
{ key: 'slideGen', icon: <Layers size={24} /> },
{ key: 'monitor', icon: <Activity size={24} /> },
{ key: 'diagramGen', icon: <Box size={24} /> },
{ key: 'custom', icon: <Workflow size={24} /> },
]
useEffect(() => {
if (!langOpen) return
const onPointer = (e: MouseEvent) => {
if (langRef.current && !langRef.current.contains(e.target as Node)) setLangOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setLangOpen(false)
}
document.addEventListener('mousedown', onPointer)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onPointer)
document.removeEventListener('keydown', onKey)
}
}, [langOpen])
useEffect(() => {
const id = setInterval(() => setEchoIndex((i) => (i + 1) % ECHO_LINES.length), 3200)
return () => clearInterval(id)
}, [])
const BRAINSTORM_ITEMS = ['waveGeneration', 'collaboration', 'export']
useEffect(() => {
const root = document.querySelector<HTMLElement>('[data-public-scroll-root]')
if (!root) return
const prev = root.style.overflow
if (menuOpen) root.style.overflow = 'hidden'
else root.style.overflow = prev || ''
return () => { root.style.overflow = prev }
}, [menuOpen])
const PLANS = [
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
@@ -34,380 +75,556 @@ export function LandingPage() {
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
]
const TECH_TIERS = [
{ key: 'tags', color: 'bg-brand-accent' },
{ key: 'embeddings', color: 'bg-ochre' },
{ key: 'chatRag', color: 'bg-ink' },
const NAV = [
{ href: '#product', label: t('landing.nav.secondBrain') },
{ href: '#echo', label: t('landing.nav.echo') },
{ href: '#agents', label: t('landing.nav.agents') },
{ href: '#pricing', label: t('landing.nav.pricing') },
]
const FOOTER_SECTIONS = ['product', 'community', 'legal'] as const
return (
<div className="min-h-screen bg-paper text-ink font-sans selection:bg-ochre/30 selection:text-ink">
{/* Navigation */}
<nav className="fixed top-0 left-0 right-0 z-[100] bg-paper/80 backdrop-blur-md border-b border-border px-4 sm:px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-ink flex items-center justify-center rounded-xl shadow-lg rotate-3 group hover:rotate-0 transition-transform cursor-pointer">
<span className="text-paper font-serif text-2xl font-bold">M</span>
<div className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
{/* Nav */}
<nav className="fixed top-0 left-0 right-0 z-[100] px-5 sm:px-8 py-4 flex items-center justify-between bg-[#0B0A09]/70 backdrop-blur-xl border-b border-white/[0.06]">
<Link href="/" className="flex items-center gap-2.5 group">
<div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg transition-transform group-hover:scale-105">
<span className="font-serif text-xl font-bold leading-none">M</span>
</div>
<span className="font-serif text-2xl font-medium tracking-tight">Memento</span>
<span className="font-serif text-xl font-medium tracking-tight text-[#F4F1EA]">Memento</span>
</Link>
<div className="hidden lg:flex items-center gap-8">
{NAV.map((l) => (
<a key={l.href} href={l.href} className="text-[13px] text-white/55 hover:text-white transition-colors">
{l.label}
</a>
))}
</div>
<div className="flex items-center gap-2 sm:gap-3">
{/* Language switcher */}
<div ref={langRef} className="relative">
<button
type="button"
onClick={() => setLangOpen((o) => !o)}
aria-expanded={langOpen}
aria-haspopup="listbox"
aria-label={t('landing.nav.language')}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-full border border-white/15 text-[12px] text-white/70 hover:text-white hover:border-white/30 transition-colors"
>
<Globe size={14} />
<span className="uppercase font-semibold tracking-wide">{language}</span>
<ChevronDown size={12} className={`opacity-60 transition-transform ${langOpen ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{langOpen && (
<motion.ul
role="listbox"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ duration: 0.15 }}
className="absolute end-0 mt-2 w-48 max-h-72 overflow-y-auto rounded-2xl border border-white/10 bg-[#141210] shadow-2xl py-1.5 z-[110]"
>
{LANDING_LANGS.map((lang) => (
<li key={lang.code} role="option" aria-selected={language === lang.code}>
<button
type="button"
onClick={() => {
setLanguage(lang.code)
setLangOpen(false)
}}
className={`w-full text-start px-4 py-2.5 text-[13px] transition-colors ${
language === lang.code
? 'bg-[#D4A373]/15 text-[#D4A373]'
: 'text-white/70 hover:bg-white/5 hover:text-white'
}`}
>
{t(lang.labelKey)}
</button>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
<div className="hidden md:flex items-center gap-10">
<a href="#features" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.features')}</a>
<a href="#agents" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.agents')}</a>
<a href="#brainstorm" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.brainstorm')}</a>
<a href="#pricing" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.pricing')}</a>
<a href="#tech" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.tech')}</a>
</div>
<div className="flex items-center gap-4">
<button onClick={() => router.push('/login')} className="hidden md:block px-6 py-2.5 text-concrete hover:text-ink text-[11px] font-bold uppercase tracking-widest transition-colors">
<Link href="/login" className="hidden sm:inline text-[13px] text-white/55 hover:text-white transition-colors px-2">
{t('landing.nav.login')}
</button>
<button onClick={() => router.push('/register')} className="px-6 py-2.5 bg-ink text-paper rounded-full text-[11px] font-bold uppercase tracking-widest hover:opacity-90 transition-all flex items-center gap-2 group shadow-xl shadow-ink/10">
</Link>
<Link
href="/register"
className="hidden sm:inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[13px] font-semibold hover:bg-white transition-colors"
>
{t('landing.nav.cta')}
<ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
</Link>
<button
type="button"
aria-label={menuOpen ? t('landing.nav.closeMenu') : t('landing.nav.openMenu')}
onClick={() => setMenuOpen((o) => !o)}
className="lg:hidden w-10 h-10 rounded-full border border-white/15 flex items-center justify-center text-white/80"
>
{menuOpen ? <X size={18} /> : <Menu size={18} />}
</button>
</div>
</nav>
{/* Hero */}
<section className="relative pt-40 pb-32 px-8 overflow-hidden">
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-ochre/5 rounded-full blur-[120px] -translate-y-1/2 translate-x-1/4 -z-10" />
<div className="absolute bottom-0 left-0 w-[600px] h-[600px] bg-brand-accent/5 rounded-full blur-[100px] translate-y-1/2 -translate-x-1/4 -z-10" />
<AnimatePresence>
{menuOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[99] bg-[#0B0A09] pt-24 px-8 lg:hidden"
>
<div className="flex flex-col gap-1">
{NAV.map((l) => (
<a
key={l.href}
href={l.href}
onClick={() => setMenuOpen(false)}
className="py-4 text-3xl font-serif border-b border-white/10"
>
{l.label}
</a>
))}
<Link href="/register" onClick={() => setMenuOpen(false)} className="mt-10 py-4 rounded-2xl bg-[#F4F1EA] text-[#0B0A09] text-center font-semibold">
{t('landing.nav.cta')}
</Link>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="max-w-6xl mx-auto text-center">
<motion.div initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease: [0.23, 1, 0.32, 1] }}>
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-ochre/10 border border-ochre/20 text-ochre text-[10px] font-bold uppercase tracking-[0.2em] mb-8">
<Sparkles size={12} />
{/* ── HERO ── */}
<section className="relative min-h-[100dvh] flex flex-col justify-center pt-28 pb-16 px-5 sm:px-8 overflow-hidden">
{/* Atmosphere — warm, not purple neon */}
<div className="pointer-events-none absolute inset-0">
<div className="absolute -top-32 left-1/2 -translate-x-1/2 w-[900px] h-[500px] bg-[#D4A373]/15 blur-[120px] rounded-full" />
<div className="absolute bottom-0 right-0 w-[500px] h-[400px] bg-[#A47148]/10 blur-[100px] rounded-full" />
<div
className="absolute inset-0 opacity-[0.07]"
style={{
backgroundImage: 'radial-gradient(circle at 1px 1px, #F4F1EA 1px, transparent 0)',
backgroundSize: '28px 28px',
}}
/>
</div>
<div className="relative z-10 max-w-5xl mx-auto w-full text-center">
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full border border-[#D4A373]/35 bg-[#D4A373]/10 mb-7">
<BrainCircuit size={14} className="text-[#D4A373]" />
<span className="text-[12px] tracking-[0.18em] uppercase text-[#D4A373] font-semibold">
{t('landing.hero.badge')}
</div>
<h1 className="text-6xl md:text-8xl font-serif font-medium tracking-tight text-ink mb-8 leading-[1.1]">
{t('landing.hero.title1')} <br />
<span className="italic">{t('landing.hero.title2')}</span>
</h1>
<p className="max-w-2xl mx-auto text-lg md:text-xl text-concrete font-light leading-relaxed mb-12">
{t('landing.hero.subtitle')}
</p>
</span>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button onClick={() => router.push('/register')} className="px-10 py-5 bg-ink text-paper rounded-2xl text-sm font-bold uppercase tracking-[0.2em] hover:opacity-95 transition-all shadow-2xl shadow-ink/20 flex items-center gap-4 group">
{t('landing.hero.cta')}
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
</button>
<a href="#features" className="px-10 py-5 border border-border rounded-2xl text-sm font-bold uppercase tracking-[0.2em] hover:bg-slate-50 transition-all">
{t('landing.hero.secondary')}
</a>
</div>
</motion.div>
<p className="text-[13px] tracking-[0.12em] uppercase text-white/40 mb-5 font-medium">
{t('landing.hero.eyebrow')}
</p>
{/* App Preview Mockup */}
<motion.div initial={{ opacity: 0, y: 100 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 1, delay: 0.2, ease: [0.23, 1, 0.32, 1] }} className="mt-24 relative">
<div className="relative mx-auto max-w-5xl aspect-[16/10] bg-white rounded-[32px] shadow-[0_40px_100px_-20px_rgba(0,0,0,0.15)] border border-border p-4 overflow-hidden group">
<Image src="/images/workspace-hero.jpg" alt="Memento Workspace" width={1200} height={750} className="w-full h-full object-cover rounded-2xl filter saturate-[0.8]" priority />
<div className="absolute inset-0 bg-ink/10 group-hover:bg-ink/0 transition-colors duration-500" />
<h1 className="font-serif text-[clamp(2.5rem,7.5vw,5.4rem)] font-medium tracking-tight leading-[1.05] text-[#F4F1EA] mb-6">
{t('landing.hero.headline')}
<br />
<span className="italic text-[#D4A373]">{t('landing.hero.headlineAccent')}</span>
</h1>
<div className="absolute top-10 right-10 w-64 bg-paper/90 backdrop-blur-xl border border-border p-6 rounded-2xl shadow-2xl">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-full bg-brand-accent/20 flex items-center justify-center text-brand-accent">
<BrainCircuit size={16} />
</div>
<span className="text-[10px] font-bold uppercase tracking-widest">{t('landing.hero.memoryEcho')}</span>
</div>
<p className="text-xs font-serif italic text-ink/70">{t('landing.hero.memoryEchoText')}</p>
</div>
<div className="absolute bottom-10 left-10 w-72 bg-ink text-paper p-6 rounded-2xl shadow-2xl">
<div className="flex items-center gap-3 mb-4">
<Activity size={16} className="text-ochre" />
<span className="text-[10px] font-bold uppercase tracking-widest text-ochre">{t('landing.hero.brainstormLive')}</span>
</div>
<div className="flex items-center -space-x-2">
{[1, 2, 3].map(i => (
<div key={i} className="w-6 h-6 rounded-full border-2 border-ink bg-concrete text-[8px] flex items-center justify-center font-bold">JD</div>
))}
<span className="text-[10px] ml-4 text-paper/60">{t('landing.hero.ideasGenerated')}</span>
<p className="max-w-xl mx-auto text-[17px] sm:text-lg text-white/55 leading-relaxed mb-10">
{t('landing.hero.subtitle')}
</p>
<div className="flex flex-col items-center gap-3">
<Link
href="/register"
className="group inline-flex items-center justify-center gap-3 px-9 py-4 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[15px] font-semibold hover:bg-white transition-all hover:scale-[1.02] shadow-[0_0_40px_-8px_rgba(212,163,115,0.45)]"
>
{t('landing.hero.cta')}
<ArrowRight size={18} className="transition-transform group-hover:translate-x-0.5" />
</Link>
<p className="text-[12px] text-white/35">{t('landing.hero.ctaHint')}</p>
</div>
</div>
{/* Product stage */}
<div className="relative z-10 mt-14 sm:mt-20 max-w-5xl mx-auto w-full">
<div className="relative rounded-[20px] sm:rounded-[28px] overflow-hidden border border-white/10 shadow-[0_40px_100px_-20px_rgba(0,0,0,0.7)]">
<div className="absolute inset-0 bg-gradient-to-t from-[#0B0A09] via-transparent to-transparent z-10 pointer-events-none" />
<Image
src="/images/workspace-hero.jpg"
alt={t('landing.hero.imageAlt')}
width={2000}
height={1200}
priority
className="w-full h-auto object-cover aspect-[16/10] opacity-90"
/>
{/* Live Memory Echo chip */}
<div className="absolute bottom-5 left-5 right-5 sm:bottom-8 sm:left-8 sm:right-auto sm:max-w-md z-20">
<div className="rounded-2xl border border-white/15 bg-[#0B0A09]/85 backdrop-blur-xl p-4 sm:p-5 text-left shadow-2xl">
<div className="flex items-center gap-2 mb-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#D4A373] opacity-60" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-[#D4A373]" />
</span>
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[#D4A373]">
{t('landing.hero.echoLive')}
</span>
</div>
<AnimatePresence mode="wait">
<motion.p
key={ECHO_LINES[echoIndex]}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.35 }}
className="text-sm sm:text-[15px] font-serif italic text-[#F4F1EA]/90 leading-relaxed"
>
{t(`landing.hero.${ECHO_LINES[echoIndex]}`)}
</motion.p>
</AnimatePresence>
</div>
</div>
</motion.div>
</div>
</div>
</section>
{/* Features */}
<section id="features" className="py-32 px-8 bg-paper">
<div className="max-w-6xl mx-auto">
<div className="mb-24 flex flex-col md:flex-row md:items-end justify-between gap-8">
<div className="max-w-2xl">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.features.label')}</span>
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink">{t('landing.features.title')} <br />{t('landing.features.title2')}</h2>
</div>
<div className="text-concrete font-light">{t('landing.features.desc')}</div>
</div>
{/* Trust strip */}
<section className="px-5 sm:px-8 py-10 border-y border-white/[0.06]">
<div className="max-w-5xl mx-auto flex flex-wrap items-center justify-center gap-x-10 gap-y-4 text-[12px] sm:text-[13px] text-white/40 tracking-wide">
{['trust0', 'trust1', 'trust2', 'trust3'].map((k) => (
<span key={k} className="flex items-center gap-2">
<Check size={14} className="text-[#D4A373]" />
{t(`landing.trust.${k}`)}
</span>
))}
</div>
</section>
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
{['f1', 'f2', 'f3'].map((f, i) => {
const icons = [
<Search key="s" className="text-brand-accent" />,
<MessageSquare key="m" className="text-ochre" />,
<Zap key="z" className="text-ink" />
]
return (
<div key={f} className="group">
<div className="w-14 h-14 bg-slate-50 border border-border rounded-2xl flex items-center justify-center mb-8 group-hover:bg-ink group-hover:text-paper transition-all duration-300">
{icons[i]}
</div>
<h3 className="text-xl font-serif font-medium mb-4">{t(`landing.features.${f}Title`)}</h3>
<p className="text-sm text-concrete leading-relaxed font-light">{t(`landing.features.${f}Desc`)}</p>
{/* Pain → Promise */}
<section className="px-5 sm:px-8 py-24 sm:py-32">
<div className="max-w-3xl mx-auto text-center">
<p className="text-[12px] uppercase tracking-[0.25em] text-[#D4A373] mb-5 font-medium">{t('landing.pain.label')}</p>
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight leading-[1.15] mb-6">
{t('landing.pain.title')}
</h2>
<p className="text-lg text-white/50 leading-relaxed mb-8">{t('landing.pain.desc')}</p>
<p className="text-base sm:text-lg font-serif italic text-[#D4A373]/90 leading-relaxed">
{t('landing.pain.secondBrain')}
</p>
</div>
</section>
{/* Product moments — large narrative blocks, not wireframe cards */}
<section id="product" className="px-5 sm:px-8 pb-8">
<div className="max-w-6xl mx-auto space-y-4">
<ProductMoment
id="echo"
eyebrow={t('landing.moments.echo.eyebrow')}
title={t('landing.moments.echo.title')}
desc={t('landing.moments.echo.desc')}
dark
>
<div className="space-y-3 p-2">
{[0, 1, 2].map((i) => (
<div
key={i}
className={`rounded-xl border p-4 ${i === 0 ? 'border-[#D4A373]/40 bg-[#D4A373]/10' : 'border-white/10 bg-white/[0.03]'}`}
>
<p className="text-[11px] uppercase tracking-wider text-[#D4A373] mb-1">
{t(`landing.moments.echo.card${i}Label`)}
</p>
<p className="text-sm text-white/75 font-serif italic leading-relaxed">
{t(`landing.moments.echo.card${i}`)}
</p>
</div>
)
})}
))}
</div>
</ProductMoment>
<ProductMoment
eyebrow={t('landing.moments.dashboard.eyebrow')}
title={t('landing.moments.dashboard.title')}
desc={t('landing.moments.dashboard.desc')}
>
<div className="grid grid-cols-2 gap-2 p-2">
{['w0', 'w1', 'w2', 'w3'].map((w) => (
<div key={w} className="rounded-xl bg-[#0B0A09]/40 border border-black/10 p-4 min-h-[88px]">
<p className="text-[10px] uppercase tracking-wider text-[#A47148] mb-2">{t(`landing.moments.dashboard.${w}Label`)}</p>
<p className="text-sm font-medium text-[#0B0A09]/80">{t(`landing.moments.dashboard.${w}`)}</p>
</div>
))}
</div>
</ProductMoment>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ProductMoment
compact
eyebrow={t('landing.moments.insights.eyebrow')}
title={t('landing.moments.insights.title')}
desc={t('landing.moments.insights.desc')}
dark
>
<div className="relative h-36 flex items-center justify-center">
<Network className="text-[#D4A373]/80" size={48} strokeWidth={1.25} />
<p className="absolute bottom-2 left-4 text-[11px] uppercase tracking-wider text-white/35">
{t('landing.moments.insights.chip')}
</p>
</div>
</ProductMoment>
<ProductMoment
compact
eyebrow={t('landing.moments.revision.eyebrow')}
title={t('landing.moments.revision.title')}
desc={t('landing.moments.revision.desc')}
>
<div className="rounded-xl bg-white border border-black/5 p-5 mx-2 mb-2">
<GraduationCap className="text-[#A47148] mb-3" size={22} />
<p className="font-serif text-[#0B0A09] text-base mb-3">{t('landing.moments.revision.card')}</p>
<div className="flex gap-2">
<span className="flex-1 py-2 rounded-lg bg-[#0B0A09]/5 text-center text-[11px] font-semibold text-[#0B0A09]/50">?</span>
<span className="flex-1 py-2 rounded-lg bg-[#0B0A09] text-center text-[11px] font-semibold text-[#F4F1EA]">SM-2</span>
</div>
</div>
</ProductMoment>
</div>
</div>
</section>
{/* How it works */}
<section className="px-5 sm:px-8 py-28">
<div className="max-w-5xl mx-auto">
<h2 className="font-serif text-3xl sm:text-4xl tracking-tight text-center mb-16">
{t('landing.how.title')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-10 md:gap-6">
{['s0', 's1', 's2'].map((s, i) => (
<div key={s} className="relative text-center md:text-left">
<div className="text-[13px] font-semibold text-[#D4A373] mb-4 tracking-widest">0{i + 1}</div>
<h3 className="font-serif text-xl mb-3">{t(`landing.how.${s}.title`)}</h3>
<p className="text-sm text-white/45 leading-relaxed">{t(`landing.how.${s}.desc`)}</p>
</div>
))}
</div>
</div>
</section>
{/* Agents */}
<section id="agents" className="py-32 px-8 bg-ink text-paper overflow-hidden relative">
<div className="absolute top-0 right-0 w-[1000px] h-[1000px] bg-brand-accent/10 rounded-full blur-[150px] -translate-y-1/2 translate-x-1/2" />
<div className="max-w-6xl mx-auto relative z-10">
<div className="text-center mb-24">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.agents.label')}</span>
<h2 className="text-4xl md:text-6xl font-serif tracking-tight mb-8">{t('landing.agents.title')}</h2>
<p className="text-paper/60 max-w-xl mx-auto font-light">{t('landing.agents.desc')}</p>
<section id="agents" className="px-5 sm:px-8 py-28 border-t border-white/[0.06]">
<div className="max-w-5xl mx-auto">
<div className="max-w-2xl mb-14">
<p className="text-[12px] uppercase tracking-[0.25em] text-[#D4A373] mb-4 font-medium">{t('landing.agents.label')}</p>
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-5">{t('landing.agents.title')}</h2>
<p className="text-white/50 text-lg leading-relaxed">{t('landing.agents.desc')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{AGENTS.map((agent, i) => (
<div key={i} className="p-8 rounded-3xl bg-white/5 border border-white/10 hover:bg-white/10 transition-all cursor-default group">
<div className="text-ochre mb-6 transition-transform group-hover:scale-110 duration-300">{agent.icon}</div>
<h4 className="text-xl font-serif font-medium mb-4">{t(`landing.agents.${agent.key}.title`)}</h4>
<p className="text-sm text-paper/50 leading-relaxed font-light">{t(`landing.agents.${agent.key}.desc`)}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{(['scraper', 'researcher', 'slideGen', 'monitor', 'diagramGen', 'custom'] as const).map((key) => (
<div key={key} className="p-6 rounded-2xl border border-white/[0.08] bg-white/[0.03] hover:bg-white/[0.06] transition-colors">
<Bot size={18} className="text-[#D4A373] mb-4" />
<h4 className="font-serif text-lg mb-2">{t(`landing.agents.${key}.title`)}</h4>
<p className="text-sm text-white/40 leading-relaxed">{t(`landing.agents.${key}.desc`)}</p>
</div>
))}
</div>
</div>
</section>
{/* Brainstorm */}
<section id="brainstorm" className="py-32 px-8 bg-paper">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center gap-24">
{/* BYOK */}
<section className="px-5 sm:px-8 py-20">
<div className="max-w-5xl mx-auto rounded-[28px] border border-white/10 bg-gradient-to-br from-[#1a1612] to-[#0B0A09] p-8 sm:p-12 flex flex-col md:flex-row gap-10 items-start">
<div className="flex-1">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.brainstorm.label')}</span>
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-8 leading-tight">{t('landing.brainstorm.title')}</h2>
<div className="space-y-8">
{BRAINSTORM_ITEMS.map((item, i) => (
<div key={item} className="flex gap-6">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-ochre/10 text-ochre flex items-center justify-center font-bold text-xs">{i + 1}</div>
<div>
<h5 className="font-bold text-sm mb-1">{t(`landing.brainstorm.${item}.title`)}</h5>
<p className="text-sm text-concrete font-light leading-relaxed">{t(`landing.brainstorm.${item}.desc`)}</p>
</div>
</div>
))}
<div className="flex items-center gap-2 text-[#D4A373] mb-4">
<KeyRound size={18} />
<span className="text-[12px] uppercase tracking-[0.2em] font-semibold">{t('landing.byok.label')}</span>
</div>
<h3 className="font-serif text-3xl tracking-tight mb-4">{t('landing.byok.title')}</h3>
<p className="text-white/50 leading-relaxed">{t('landing.byok.desc')}</p>
</div>
<div className="flex-1 relative">
<div className="w-[450px] h-[450px] border-2 border-dashed border-border rounded-full flex items-center justify-center relative">
<div className="absolute top-0 right-1/2 translate-x-1/2 -translate-y-1/2 w-4 h-4 bg-ink rounded-full" />
<div className="absolute bottom-0 right-1/2 translate-x-1/2 translate-y-1/2 w-4 h-4 bg-ochre rounded-full" />
<div className="w-[300px] h-[300px] border-2 border-dashed border-border rounded-full flex items-center justify-center">
<div className="w-[150px] h-[150px] border-2 border-dashed border-border rounded-full flex items-center justify-center">
<div className="w-12 h-12 bg-ink rounded-xl shadow-2xl flex items-center justify-center text-paper font-serif text-xl">M</div>
</div>
</div>
</div>
<div className="absolute top-10 right-0 p-4 bg-white border border-border rounded-xl shadow-xl">
<p className="text-[10px] font-bold text-ochre">{t('landing.brainstorm.disruptionLabel')}</p>
<p className="text-xs font-serif italic text-ink">{t('landing.brainstorm.disruptionText')}</p>
</div>
<div className="absolute bottom-20 -left-10 p-4 bg-white border border-border rounded-xl shadow-xl">
<p className="text-[10px] font-bold text-brand-accent">{t('landing.brainstorm.analogyLabel')}</p>
<p className="text-xs font-serif italic text-ink">{t('landing.brainstorm.analogyText')}</p>
</div>
</div>
</div>
</section>
{/* Tech */}
<section id="tech" className="py-32 px-8 bg-slate-50 border-y border-border">
<div className="max-w-6xl mx-auto text-center">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.tech.label')}</span>
<h2 className="text-4xl font-serif tracking-tight mb-16">{t('landing.tech.title')}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-8 grayscale opacity-50 hover:grayscale-0 hover:opacity-100 transition-all duration-700">
{['OpenAI', 'Google', 'Anthropic', 'DeepSeek', 'Mistral', 'Meta', 'Ollama', 'Groq', 'X.AI', 'Custom'].map((brand, i) => (
<div key={i} className="flex flex-col items-center gap-3">
<div className="w-12 h-12 bg-white rounded-xl border border-border flex items-center justify-center text-xs font-black tracking-tighter">
{brand.slice(0, 2).toUpperCase()}
</div>
<span className="text-[10px] font-bold uppercase tracking-widest">{brand}</span>
</div>
))}
</div>
<div className="mt-24 max-w-2xl mx-auto p-1 bg-white rounded-3xl border border-border shadow-sm flex flex-col md:flex-row gap-0.5">
{TECH_TIERS.map((tier, i) => (
<div key={i} className="flex-1 p-6 text-left">
<div className={`w-1.5 h-1.5 rounded-full ${tier.color} mb-4`} />
<h6 className="text-[10px] font-bold uppercase tracking-widest text-concrete mb-2">{t(`landing.tech.${tier.key}.title`)}</h6>
<p className="text-xs font-light text-concrete">{t(`landing.tech.${tier.key}.desc`)}</p>
</div>
))}
<div className="w-full md:w-[320px] font-mono text-[11px] text-white/35 space-y-1.5 rounded-2xl border border-white/10 bg-black/40 p-5">
<p className="text-[#D4A373]">{'{'}</p>
<p className="pl-3">&quot;provider&quot;: &quot;anthropic&quot;,</p>
<p className="pl-3">&quot;model&quot;: &quot;claude-sonnet&quot;,</p>
<p className="pl-3 text-[#F4F1EA]/70">&quot;apiKey&quot;: &quot;sk-ant-&quot;,</p>
<p className="pl-3">&quot;yours&quot;: true</p>
<p className="text-[#D4A373]">{'}'}</p>
</div>
</div>
</section>
{/* Pricing */}
<section id="pricing" className="py-32 px-8 bg-paper">
<div className="max-w-7xl mx-auto">
<section id="pricing" className="px-5 sm:px-8 py-28 border-t border-white/[0.06]">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.pricing.label')}</span>
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-6">{t('landing.pricing.title')}</h2>
<p className="text-concrete font-light max-w-xl mx-auto mb-12">{t('landing.pricing.desc')}</p>
<div className="flex items-center justify-center gap-10 mb-8">
<button onClick={() => setBillingInterval('monthly')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'monthly' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.monthly')}</span>
{billingInterval === 'monthly' && (
<motion.div layoutId="interval-active" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
)}
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-4">{t('landing.pricing.title')}</h2>
<p className="text-white/45 mb-8">{t('landing.pricing.desc')}</p>
<div className="inline-flex p-1 rounded-full border border-white/10 bg-white/[0.03]">
<button
type="button"
onClick={() => setBillingInterval('monthly')}
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all ${billingInterval === 'monthly' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`}
>
{t('landing.pricing.monthly')}
</button>
<button
type="button"
onClick={() => setBillingInterval('annual')}
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`}
>
{t('landing.pricing.annual')}
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373]">-20%</span>
</button>
<div className="relative">
<button onClick={() => setBillingInterval('annual')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'annual' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.annual')}</span>
{billingInterval === 'annual' && (
<motion.div layoutId="interval-active" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
)}
</button>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">(-20%)</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
{PLANS.map((plan, i) => (
<div key={plan.key} className={`relative p-8 rounded-[32px] border flex flex-col transition-all duration-300 hover:shadow-2xl hover:shadow-ink/5 ${plan.popular ? 'bg-ink text-paper border-ink ring-4 ring-ochre/20' : 'bg-white border-border text-ink'}`}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{PLANS.map((plan) => (
<div
key={plan.key}
className={`rounded-2xl border p-6 flex flex-col ${
plan.popular
? 'border-[#D4A373]/50 bg-[#D4A373]/10'
: 'border-white/[0.08] bg-white/[0.02]'
}`}
>
{plan.popular && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-ochre text-ink text-[10px] font-bold uppercase tracking-widest rounded-full">
<span className="text-[10px] font-bold uppercase tracking-widest text-[#D4A373] mb-3">
{t('landing.pricing.popular')}
</div>
</span>
)}
<div className="mb-8">
<h4 className="text-[11px] font-bold uppercase tracking-widest mb-2 opacity-60">{t(`landing.pricing.${plan.key}.name`)}</h4>
<div className="flex items-baseline gap-1 mb-4">
<span className="text-4xl font-serif font-medium">{plan.price}</span>
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
</div>
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
<h4 className="text-[12px] uppercase tracking-widest text-white/40 mb-2">
{t(`landing.pricing.${plan.key}.name`)}
</h4>
<div className="flex items-baseline gap-1 mb-2">
<span className="text-3xl font-serif">{plan.price}</span>
{plan.period && <span className="text-xs text-white/35">{plan.period}</span>}
</div>
<div className="flex-1 space-y-4 mb-10">
{[0, 1, 2, 3, 4, 5].map(j => {
<p className="text-sm text-white/45 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
<ul className="space-y-2.5 mb-8 flex-1">
{[0, 1, 2, 3, 4, 5].map((j) => {
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
if (!feat || feat.startsWith('landing.')) return null
return (
<div key={j} className="flex items-start gap-3">
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
<Shield size={10} fill="currentColor" />
</div>
<span className="text-xs font-light">{feat}</span>
</div>
<li key={j} className="flex gap-2 text-xs text-white/60">
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
{feat}
</li>
)
})}
</div>
<button onClick={() => router.push('/register')} className={`w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-widest transition-all ${plan.popular ? 'bg-ochre text-ink hover:opacity-90' : 'bg-ink text-paper hover:bg-ink/90'}`}>
</ul>
<Link
href="/register"
className={`py-3 rounded-xl text-center text-[12px] font-semibold transition-colors ${
plan.popular
? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white'
: 'bg-white/10 text-white hover:bg-white/15'
}`}
>
{t(`landing.pricing.${plan.key}.cta`)}
</button>
</Link>
</div>
))}
</div>
{/* BYOK */}
<div className="mt-20 p-12 bg-slate-50 border border-border rounded-[40px] flex flex-col md:flex-row items-center gap-12">
<div className="flex-1">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-accent/10 text-brand-accent text-[9px] font-bold uppercase tracking-widest mb-6">
<Cpu size={12} />
{t('landing.byok.label')}
</div>
<h3 className="text-3xl font-serif font-medium mb-4">{t('landing.byok.title')}</h3>
<p className="text-concrete font-light leading-relaxed mb-6">{t('landing.byok.desc')}</p>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-white rounded-2xl border border-border">
<h5 className="text-[10px] font-bold uppercase tracking-widest mb-2">{t('landing.byok.noLockin')}</h5>
<p className="text-[10px] text-concrete font-light">{t('landing.byok.noLockinDesc')}</p>
</div>
<div className="p-4 bg-white rounded-2xl border border-border">
<h5 className="text-[10px] font-bold uppercase tracking-widest mb-2">{t('landing.byok.cost')}</h5>
<p className="text-[10px] text-concrete font-light">{t('landing.byok.costDesc')}</p>
</div>
</div>
</div>
<div className="w-full md:w-[400px] bg-ink rounded-3xl p-8 relative overflow-hidden group">
<div className="absolute inset-0 bg-brand-accent/10 blur-[50px] group-hover:bg-ochre/10 transition-colors" />
<div className="relative z-10 font-mono text-[10px] text-paper/40 space-y-2">
<p className="text-ochre">{"{"}</p>
<p className="pl-4">"provider": "anthropic",</p>
<p className="pl-4">"model": "claude-3-opus",</p>
<p className="pl-4 border-l border-brand-accent/30 bg-brand-accent/5">"apiKey": "sk-ant-at03-..."</p>
<p className="pl-4">"useSystemKey": false</p>
<p className="text-ochre">{"}"}</p>
</div>
<div className="mt-8 flex items-center justify-between relative z-10">
<span className="text-[10px] font-bold text-paper uppercase tracking-widest">{t('landing.byok.configLabel')}</span>
<div className="flex gap-1">{[1, 2, 3].map(i => <div key={i} className="w-1 h-1 rounded-full bg-paper/20" />)}</div>
</div>
</div>
</div>
</div>
</section>
{/* Final CTA */}
<section className="py-40 px-8 text-center bg-paper relative overflow-hidden">
<div className="max-w-3xl mx-auto relative z-10">
<h2 className="text-5xl md:text-7xl font-serif tracking-tight mb-8 leading-tight">{t('landing.cta.title1')} <br /><span className="italic">{t('landing.cta.title2')}</span></h2>
<p className="text-lg text-concrete font-light mb-12">{t('landing.cta.desc')}</p>
<button onClick={() => router.push('/register')} className="px-16 py-6 bg-ink text-paper rounded-[32px] text-lg font-bold uppercase tracking-[0.2em] hover:scale-105 transition-all shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)]">
<section className="relative px-5 sm:px-8 py-36 text-center overflow-hidden">
<div className="pointer-events-none absolute inset-0">
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[300px] bg-[#D4A373]/20 blur-[100px] rounded-full" />
</div>
<div className="relative z-10 max-w-2xl mx-auto">
<BrainCircuit className="mx-auto text-[#D4A373] mb-6" size={32} strokeWidth={1.25} />
<h2 className="font-serif text-4xl sm:text-6xl tracking-tight leading-tight mb-6">
{t('landing.cta.title')}
</h2>
<p className="text-white/50 text-lg mb-10">{t('landing.cta.desc')}</p>
<Link
href="/register"
className="inline-flex items-center gap-3 px-10 py-4 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[15px] font-semibold hover:bg-white transition-all hover:scale-[1.02]"
>
{t('landing.cta.button')}
</button>
<ArrowRight size={18} />
</Link>
<p className="mt-4 text-[12px] text-white/30">{t('landing.hero.ctaHint')}</p>
</div>
</section>
{/* Footer */}
<footer className="py-20 px-8 border-t border-border bg-paper">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between gap-12">
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-ink flex items-center justify-center rounded-lg">
<span className="text-paper font-serif text-lg font-bold">M</span>
<footer className="px-5 sm:px-8 py-14 border-t border-white/[0.06]">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between gap-10">
<div className="max-w-xs">
<div className="flex items-center gap-2 mb-3">
<div className="w-7 h-7 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-md">
<span className="font-serif font-bold text-sm">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
<span className="font-serif text-lg">Memento</span>
</div>
<p className="text-sm text-concrete font-light max-w-xs">{t('landing.footer.desc')}</p>
<p className="text-sm text-white/35">{t('landing.footer.desc')}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-16">
{FOOTER_SECTIONS.map(section => (
<div key={section} className="space-y-4">
<h6 className="text-[10px] font-bold uppercase tracking-widest text-ink">{t(`landing.footer.${section}.title`)}</h6>
<ul className="space-y-2 text-sm text-concrete font-light">
{[0, 1, 2].map(j => {
<div className="grid grid-cols-3 gap-10 text-sm">
{(['product', 'community', 'legal'] as const).map((section) => (
<div key={section}>
<p className="text-[11px] uppercase tracking-widest text-white/40 mb-3">
{t(`landing.footer.${section}.title`)}
</p>
<ul className="space-y-2 text-white/50">
{[0, 1, 2].map((j) => {
const label = t(`landing.footer.${section}.link${j}`)
const href = t(`landing.footer.${section}.link${j}Href`)
if (!label || label.startsWith('landing.')) return null
return <li key={j}><a href={href} className="hover:text-ink">{label}</a></li>
return (
<li key={j}>
<a href={href} className="hover:text-white transition-colors">{label}</a>
</li>
)
})}
</ul>
</div>
))}
</div>
</div>
<div className="max-w-6xl mx-auto mt-20 pt-10 border-t border-border flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] uppercase font-bold tracking-widest text-concrete">
<div>© 2026 MOMENTO LABS. ALL RIGHTS RESERVED.</div>
<div className="flex gap-8"><span>DESIGNED BY ANTIGRAVITY</span></div>
</div>
<p className="max-w-6xl mx-auto mt-12 pt-8 border-t border-white/[0.06] text-[11px] text-white/25 tracking-wide">
© 2026 Memento. {t('landing.footer.rights')}
</p>
</footer>
</div>
)
}
function ProductMoment({
id,
eyebrow,
title,
desc,
children,
dark,
compact,
}: {
id?: string
eyebrow: string
title: string
desc: string
children: ReactNode
dark?: boolean
compact?: boolean
}) {
return (
<motion.div
id={id}
initial={{ opacity: 0, y: 28 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.55 }}
className={`rounded-[24px] sm:rounded-[32px] border overflow-hidden ${
dark
? 'bg-[#141210] border-white/[0.08] text-[#F4F1EA]'
: 'bg-[#EDE8DF] border-transparent text-[#0B0A09]'
} ${compact ? '' : 'grid grid-cols-1 lg:grid-cols-2'}`}
>
<div className={`p-8 sm:p-10 flex flex-col justify-center ${compact ? 'pb-4' : ''}`}>
<p className={`text-[11px] uppercase tracking-[0.22em] font-semibold mb-3 ${dark ? 'text-[#D4A373]' : 'text-[#A47148]'}`}>
{eyebrow}
</p>
<h3 className="font-serif text-2xl sm:text-3xl tracking-tight mb-3 leading-tight">{title}</h3>
<p className={`text-[15px] leading-relaxed ${dark ? 'text-white/50' : 'text-[#0B0A09]/55'}`}>{desc}</p>
</div>
<div className={`${compact ? 'px-4 pb-6' : 'p-6 sm:p-8'} flex items-center`}>
<div className="w-full">{children}</div>
</div>
</motion.div>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
import { ChevronDown, ChevronUp, Sparkles, Link2, X, Loader2, HelpCircle } from 'lucide-react'
import { LinkedNotePreviewDialog } from '@/components/linked-note-preview-dialog'
import { Button } from '@/components/ui/button'
@@ -12,7 +12,6 @@ import { toast } from 'sonner'
import { useNoteEditorContext } from '@/components/note-editor/note-editor-context'
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
import { detectTextDirection } from '@/lib/clip/rtl-content'
import { SEMANTIC_SIMILARITY_FLOOR_CLIP } from '@/lib/ai/semantic-proximity'
interface ConnectionData {
noteId: string
@@ -106,6 +105,9 @@ export function MemoryEchoSection({
const [embeddingId, setEmbeddingId] = useState<string | null>(null)
const [helpOpen, setHelpOpen] = useState(false)
const [previewTarget, setPreviewTarget] = useState<PreviewTarget | null>(null)
const sectionRef = useRef<HTMLDivElement>(null)
const [sectionInView, setSectionInView] = useState(false)
const [cueDismissed, setCueDismissed] = useState(false)
useEffect(() => {
let cancelled = false
@@ -113,6 +115,8 @@ export function MemoryEchoSection({
setConsentRequired(false)
setConnections([])
setRetroRefs([])
setCueDismissed(false)
setSectionInView(false)
const load = async () => {
try {
@@ -154,18 +158,24 @@ export function MemoryEchoSection({
}
}, [noteId])
// Scroll doux vers la section quand une forte connexion apparaît (une fois par note)
// Observe section visibility — never auto-scroll; cue only when off-screen
useEffect(() => {
if (isLoading || connections.length === 0) return
const top = connections[0]
if (!top || top.similarity < SEMANTIC_SIMILARITY_FLOOR_CLIP) return
const key = `memory-echo-scroll-${noteId}`
if (sessionStorage.getItem(key)) return
sessionStorage.setItem(key, '1')
requestAnimationFrame(() => {
document.getElementById('memory-echo-section')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
}, [isLoading, connections, noteId])
const el = sectionRef.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
setSectionInView(entry.isIntersecting && entry.intersectionRatio > 0.08)
},
{ root: null, threshold: [0, 0.08, 0.2, 0.5] },
)
observer.observe(el)
return () => observer.disconnect()
}, [noteId, isLoading, connections.length, retroRefs.length, consentRequired, isVisible])
const scrollToSection = useCallback(() => {
sectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [])
const handleEmbed = useCallback(async (conn: ConnectionData) => {
setEmbeddingId(conn.noteId)
@@ -251,8 +261,29 @@ export function MemoryEchoSection({
const topConnection = connections[0]
const restConnections = connections.slice(1)
// Floating cue: signal activity below without stealing the title viewport
const showBottomCue =
!cueDismissed &&
!sectionInView &&
(isLoading || hasConnections || hasRetro || consentRequired)
const cueLabel = isLoading
? t('memoryEcho.editorSection.bottomCueLoading')
: hasConnections
? connections.length === 1
? t('memoryEcho.editorSection.bottomCueFoundOne')
: t('memoryEcho.editorSection.bottomCueFound', { count: connections.length })
: hasRetro
? t('memoryEcho.editorSection.bottomCueRetro', { count: retroRefs.length })
: t('memoryEcho.editorSection.bottomCueConsent')
return (
<div id="memory-echo-section" className="mt-10 space-y-6 scroll-mt-24">
<>
<div
id="memory-echo-section"
ref={sectionRef}
className="mt-10 space-y-6 scroll-mt-24"
>
{isLoading && (
<div
className="rounded-2xl border border-indigo-500/10 bg-gradient-to-br from-indigo-500/[0.03] to-transparent p-5 animate-pulse space-y-3"
@@ -551,6 +582,47 @@ export function MemoryEchoSection({
/>
)}
</div>
{showBottomCue && (
<div
className={cn(
'fixed bottom-6 left-1/2 z-40 -translate-x-1/2',
'flex items-center gap-1 rounded-full border border-indigo-500/25',
'bg-background/95 shadow-lg shadow-indigo-500/10 backdrop-blur-md',
'pl-3 pr-1.5 py-1.5 max-w-[min(92vw,22rem)]',
'animate-in fade-in slide-in-from-bottom-2 duration-300',
)}
role="status"
aria-live="polite"
>
<button
type="button"
onClick={scrollToSection}
className="flex min-w-0 flex-1 items-center gap-2 rounded-full px-1 py-0.5 text-left transition-colors hover:bg-indigo-500/5"
title={t('memoryEcho.editorSection.bottomCueScroll')}
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-indigo-500" />
) : (
<Sparkles className="h-3.5 w-3.5 shrink-0 text-indigo-500" />
)}
<span className="truncate text-xs font-medium text-foreground/90">
{cueLabel}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-indigo-500/80 animate-bounce" />
</button>
<button
type="button"
onClick={() => setCueDismissed(true)}
className="shrink-0 rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={t('memoryEcho.editorSection.bottomCueDismiss')}
title={t('memoryEcho.editorSection.bottomCueDismiss')}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</>
)
}

View File

@@ -112,6 +112,7 @@ export function NoteContentArea() {
ref={richTextEditorRef}
content={state.content}
onChange={(v: string) => actions.setContent(v)}
onChangeImmediate={(v: string) => actions.setContentImmediate(v)}
className="min-h-[280px]"
onImageUpload={uploadImageFile}
noteId={note.id}
@@ -129,6 +130,7 @@ export function NoteContentArea() {
ref={richTextEditorRef}
content={state.content}
onChange={actions.setContent}
onChangeImmediate={actions.setContentImmediate}
className="min-h-[200px]"
onImageUpload={uploadImageFile}
noteId={note.id}

View File

@@ -0,0 +1,192 @@
'use client'
/**
* Couverture sous le titre (full-page).
* - Affichée uniquement si une image de couverture est **explicite** (pas auto depuis le corps).
* - Permet de retirer la couverture sans supprimer les images du contenu.
* - Permet de choisir parmi les images de la note (corps + galerie).
*/
import { useState } from 'react'
import { ImageIcon, X, Check, Images } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
interface NoteCoverHeroProps {
/** URL de couverture explicite (note.images[0]), null = pas de couverture */
coverUrl: string | null
/** Candidats (images du contenu + galerie) pour le sélecteur */
candidates: string[]
readOnly?: boolean
title?: string
onSetCover: (url: string) => void
onClearCover: () => void
}
export function NoteCoverHero({
coverUrl,
candidates,
readOnly,
title,
onSetCover,
onClearCover,
}: NoteCoverHeroProps) {
const { t } = useLanguage()
const [pickerOpen, setPickerOpen] = useState(false)
const uniqueCandidates = Array.from(new Set(candidates.filter(Boolean)))
const hasCover = Boolean(coverUrl)
const canPick = !readOnly && uniqueCandidates.length > 0
// Rien à montrer : pas de cover et pas de candidats (ou lecture seule sans cover)
if (!hasCover && (readOnly || uniqueCandidates.length === 0)) {
return null
}
// Pas de cover mais des images dans la note → barre discrète pour en choisir une
if (!hasCover && canPick) {
return (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/30 px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<ImageIcon className="h-4 w-4 shrink-0 opacity-70" />
<span className="truncate">
{t('notes.coverNoneHint') || 'Aucune image de couverture — choisissez une image de la note ou laissez vide.'}
</span>
</div>
<button
type="button"
onClick={() => setPickerOpen((v) => !v)}
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-border bg-background hover:bg-muted transition-colors shrink-0"
>
<Images className="h-3.5 w-3.5" />
{t('notes.coverChoose') || 'Choisir une couverture'}
</button>
{pickerOpen && (
<CoverPicker
candidates={uniqueCandidates}
selected={null}
onSelect={(url) => {
onSetCover(url)
setPickerOpen(false)
}}
onClose={() => setPickerOpen(false)}
t={t}
/>
)}
</div>
)
}
if (!hasCover || !coverUrl) return null
return (
<div className="group/cover relative aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
<img
src={coverUrl}
alt={title || ''}
className="w-full h-full object-cover grayscale contrast-110 group-hover/cover:grayscale-0 transition-all duration-500"
/>
{!readOnly && (
<div className="absolute inset-x-0 top-0 p-3 flex items-start justify-end gap-2 opacity-0 group-hover/cover:opacity-100 focus-within:opacity-100 transition-opacity">
{uniqueCandidates.length > 1 && (
<button
type="button"
onClick={() => setPickerOpen((v) => !v)}
className="inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1.5 rounded-lg bg-black/60 text-white hover:bg-black/75 backdrop-blur-sm transition-colors"
title={t('notes.coverChoose') || 'Changer la couverture'}
>
<Images className="h-3.5 w-3.5" />
{t('notes.coverChange') || 'Changer'}
</button>
)}
<button
type="button"
onClick={onClearCover}
className="inline-flex items-center justify-center h-8 w-8 rounded-lg bg-black/60 text-white hover:bg-red-600/90 backdrop-blur-sm transition-colors"
title={t('notes.coverRemove') || 'Retirer la couverture'}
aria-label={t('notes.coverRemove') || 'Retirer la couverture'}
>
<X className="h-4 w-4" />
</button>
</div>
)}
{pickerOpen && !readOnly && (
<div className="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/70 to-transparent">
<CoverPicker
candidates={uniqueCandidates}
selected={coverUrl}
onSelect={(url) => {
onSetCover(url)
setPickerOpen(false)
}}
onClose={() => setPickerOpen(false)}
t={t}
dark
/>
</div>
)}
</div>
)
}
function CoverPicker({
candidates,
selected,
onSelect,
onClose,
t,
dark,
}: {
candidates: string[]
selected: string | null
onSelect: (url: string) => void
onClose: () => void
t: (key: string) => string
dark?: boolean
}) {
return (
<div className={cn('w-full', dark ? '' : 'col-span-full pt-1')}>
<div className="flex items-center justify-between gap-2 mb-2">
<p className={cn('text-[11px] font-medium', dark ? 'text-white/80' : 'text-muted-foreground')}>
{t('notes.coverPickLabel') || 'Image de couverture'}
</p>
<button
type="button"
onClick={onClose}
className={cn('text-[11px] underline-offset-2 hover:underline', dark ? 'text-white/70' : 'text-muted-foreground')}
>
{t('notes.close') || 'Fermer'}
</button>
</div>
<div className="flex gap-2 overflow-x-auto pb-1">
{candidates.map((url) => {
const isSelected = selected === url
return (
<button
key={url}
type="button"
onClick={() => onSelect(url)}
className={cn(
'relative shrink-0 h-16 w-24 rounded-lg overflow-hidden border-2 transition-all',
isSelected
? 'border-brand-accent ring-2 ring-brand-accent/40'
: dark
? 'border-white/20 hover:border-white/50'
: 'border-border hover:border-foreground/30',
)}
>
<img src={url} alt="" className="h-full w-full object-cover" />
{isSelected && (
<span className="absolute inset-0 flex items-center justify-center bg-black/30">
<Check className="h-5 w-5 text-white drop-shadow" />
</span>
)}
</button>
)
})}
</div>
</div>
)
}

View File

@@ -332,10 +332,29 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setLinks(links.filter((_, i) => i !== index))
}
/** Images extraites du corps (TipTap / markdown) — pas de couverture auto */
const contentImages = useMemo(() => {
if (isMarkdown) {
const urls = new Set<string>()
for (const match of content.matchAll(/!\[.*?\]\((.*?)\)/g)) {
const src = match[1]?.trim()
if (src) urls.add(src)
}
return Array.from(urls)
}
return extractImagesFromHTML(content)
}, [content, isMarkdown])
/**
* allImages = galerie explicite + corps (pour IA, sélecteur de couverture).
* La couverture hero n'utilise PAS allImages[0] automatiquement.
*/
const allImages = useMemo(() => {
const extracted = !isMarkdown ? extractImagesFromHTML(content) : []
return Array.from(new Set([...images, ...extracted]))
}, [images, content, isMarkdown])
return Array.from(new Set([...images, ...contentImages]))
}, [images, contentImages])
/** Couverture explicite = première entrée de note.images (jamais auto depuis le corps) */
const coverImage = images[0] ?? null
const resolveContentForSave = useCallback((): string => {
if (!isMarkdown) {
@@ -345,27 +364,32 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return contentRef.current
}, [isMarkdown])
const resolveImagesForSave = useCallback((contentToSave: string): string[] => {
// Images présentes dans le contenu de l'éditeur (inline dans le HTML ou Markdown)
let contentImages: string[] = []
if (contentToSave) {
if (!isMarkdown) {
contentImages = extractImagesFromHTML(contentToSave)
} else {
const urls = new Set<string>()
const matches = contentToSave.matchAll(/!\[.*?\]\((.*?)\)/g)
for (const match of matches) {
const src = match[1]?.trim()
if (src) urls.add(src)
}
contentImages = Array.from(urls)
}
}
// Images "standalone" uploadées séparément (hors contenu éditeur), non supprimées
const standaloneImages = images.filter(url => !removedImageUrls.includes(url) && !contentImages.includes(url))
// Fusion dédupliquée : images du contenu + images standalone conservées
return Array.from(new Set([...contentImages, ...standaloneImages]))
}, [isMarkdown, images, removedImageUrls])
/**
* note.images = couverture / galerie **explicite** uniquement.
* Ne plus fusionner les images du corps : sinon, coller une image dans l'éditeur
* la plaçait sous le titre, et la supprimer du corps la laissait en "standalone" forever.
*/
const resolveImagesForSave = useCallback((_contentToSave: string): string[] => {
return images.filter((url) => !removedImageUrls.includes(url))
}, [images, removedImageUrls])
const setCoverImage = useCallback((url: string) => {
setImages((prev) => {
const rest = prev.filter((u) => u !== url)
return [url, ...rest]
})
setRemovedImageUrls((prev) => prev.filter((u) => u !== url))
setIsDirty(true)
}, [])
const clearCoverImage = useCallback(() => {
setImages((prev) => {
if (prev.length === 0) return prev
setRemovedImageUrls((r) => Array.from(new Set([...r, ...prev])))
return []
})
setIsDirty(true)
}, [])
const handleGenerateTitles = async () => {
@@ -902,6 +926,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
isAnalyzingSuggestions,
isMarkdown,
allImages,
contentImages,
coverImage,
colorClasses,
quotaExceededFeature,
autoSaveEnabled,
@@ -911,13 +937,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses, quotaExceededFeature, autoSaveEnabled
isAnalyzingSuggestions, isMarkdown, allImages, contentImages, coverImage, colorClasses, quotaExceededFeature, autoSaveEnabled
])
const actions: NoteEditorActions = useMemo(() => ({
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
setDismissedTitleSuggestions,
setContent: (c) => { setContent(c); setIsDirty(true) },
setContentImmediate: (c) => { setContentImmediate(c); setIsDirty(true) },
setCheckItems,
handleCheckItem,
handleUpdateCheckItem,
@@ -930,6 +957,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setImages,
handleImageUpload,
handleRemoveImage,
setCoverImage,
clearCoverImage,
uploadImageFile,
setLinks,
handleAddLink,
@@ -974,6 +1003,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setQuotaExceededFeature,
toggleAutoSave,
}), [
setContent, setContentImmediate, setCoverImage, clearCoverImage,
handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem,
handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload,
handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave,

View File

@@ -4,7 +4,7 @@ import { useNoteEditorContext } from './note-editor-context'
import { NoteEditorToolbar } from './note-editor-toolbar'
import { NoteTitleBlock } from './note-title-block'
import { NoteContentArea } from './note-content-area'
import { EditorImages } from '@/components/editor-images'
import { NoteCoverHero } from './note-cover-hero'
import { ComparisonModal } from '@/components/comparison-modal'
import { FusionModal } from '@/components/fusion-modal'
import { ReminderDialog } from '@/components/reminder-dialog'
@@ -23,7 +23,7 @@ import { NoteAttachments } from '@/components/note-attachments'
import { DocumentQAOverlay } from '@/components/document-qa-overlay'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel'
import { MemoryEchoSection } from '@/components/memory-echo-section'
@@ -40,6 +40,14 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
const [attachmentsCount, setAttachmentsCount] = useState(0)
const [uploadTrigger, setUploadTrigger] = useState(0)
const [comparisonSimilarity, setComparisonSimilarity] = useState<number | undefined>()
const scrollRootRef = useRef<HTMLDivElement>(null)
// Always open a note at the top (title visible) — never inherit previous scroll
useEffect(() => {
const el = scrollRootRef.current
if (!el) return
el.scrollTop = 0
}, [note.id])
const fetchNotesByIds = async (noteIds: string[]) => {
const notes = await Promise.all(noteIds.map(async (id) => {
@@ -67,7 +75,10 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
{/* ── outer container ── */}
<div className="flex flex-1 min-h-0 h-full w-full items-stretch overflow-hidden">
{/* ── main scrollable column ── */}
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background min-w-0">
<div
ref={scrollRootRef}
className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background min-w-0"
>
{/* TOOLBAR */}
<NoteEditorToolbar mode="fullPage" onClose={onClose} onToggleAttachments={() => setUploadTrigger(v => v + 1)} attachmentsCount={attachmentsCount} />
@@ -119,16 +130,15 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
)}
</div>
{/* Hero image — show first note image if present */}
{state.allImages.length > 0 && (
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
<img
src={state.allImages[0]}
alt={state.title}
className="w-full h-full object-cover grayscale contrast-110 hover:grayscale-0 transition-all duration-500"
/>
</div>
)}
{/* Couverture explicite — pas d'auto-promo des images collées dans le corps */}
<NoteCoverHero
coverUrl={state.coverImage}
candidates={state.allImages}
readOnly={readOnly}
title={state.title}
onSetCover={actions.setCoverImage}
onClearCover={actions.clearCoverImage}
/>
{/* Content area — max-w-4xl for wider reading column */}
<div className="max-w-4xl mx-auto w-full space-y-8 pb-32">

View File

@@ -117,6 +117,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
const { state: voiceState, toggle: toggleVoice, isSupported: voiceSupported } = useVoiceTranscription({
onTranscript: handleTranscript,
onError: (message) => {
toast.error(message)
},
})
// ── Markdown export ───────────────────────────────────────────────────────
@@ -539,8 +542,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
<span className="text-sm font-medium">{t('notes.backToCollection')}</span>
</button>
<div className="flex items-center gap-4">
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
<div className="flex items-center gap-1.5 sm:gap-2">
{/* 1. Status */}
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none me-1">
{state.isSaving
? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving')}</span></>
: state.isDirty
@@ -557,165 +561,25 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</span>
)}
{state.isMarkdown && !readOnly && (
{/* 2. Save (primary) */}
{!readOnly && (
<button
title={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
onClick={() => actions.handleSaveInPlace()}
disabled={state.isSaving || !state.isDirty}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.showMarkdownPreview
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
state.isDirty
? 'bg-foreground text-background border-foreground hover:opacity-80'
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
)}
>
<Eye size={16} />
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
</button>
)}
{state.isMarkdown && !readOnly && (
<button
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
onClick={handleConvertToRichtext}
disabled={isConverting}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
isConverting && 'opacity-50 cursor-not-allowed'
)}
>
{isConverting ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
</button>
)}
<button
title={t('ai.openAssistant')}
aria-label={t('ai.openAssistant')}
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.aiOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Sparkles size={16} />
</button>
<button
title={t('notes.brainstormThisIdea')}
aria-label={t('notes.brainstormThisIdeaAria')}
onClick={() => {
const title = note.title || ''
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
const seed = title ? `${title}. ${summary}` : summary
if (!seed.trim()) return
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
}}
className="p-1.5 rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all"
>
<Wind size={16} />
</button>
{!readOnly && (
<div className="relative">
<button
title={t('flashcards.toolbarGenerate')}
aria-label={t('flashcards.toolbarGenerate')}
onClick={() => setShowEduMenu(v => !v)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<GraduationCap size={16} />
</button>
{showEduMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowEduMenu(false)} />
<div className="absolute top-full right-0 mt-1 z-50 w-56 rounded-xl border border-border bg-card shadow-xl overflow-hidden">
<button
onClick={() => { setShowEduMenu(false); setFlashcardsOpen(true) }}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left"
>
<div className="p-1.5 rounded-lg bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400">
<GraduationCap size={16} />
</div>
<div>
<div className="text-sm font-medium">{t('flashcards.toolbarGenerate')}</div>
<div className="text-[10px] text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Révision espacée SM-2'}</div>
</div>
</button>
<button
onClick={() => { setShowEduMenu(false); handleGenerateExercises() }}
disabled={generatingExercises}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left border-t border-border/30"
>
<div className="p-1.5 rounded-lg bg-brand-accent/10 text-brand-accent">
{generatingExercises ? <Loader2Icon size={16} className="animate-spin" /> : <PenTool size={16} />}
</div>
<div>
<div className="text-sm font-medium">{t('richTextEditor.generateExercises') || 'Générer des exercices'}</div>
<div className="text-[10px] text-muted-foreground">{t('richTextEditor.generateExercisesHint') || '5 exercices + corrigés'}</div>
</div>
</button>
</div>
</>
)}
</div>
)}
{!readOnly && voiceSupported && (
<button
title={voiceState === 'listening'
? (t('editor.voiceStop') || 'Arrêter la dictée')
: (t('editor.voiceStart') || 'Dicter du texte')}
aria-label={voiceState === 'listening' ? (t('richTextEditor.stopVoice') || 'Stop voice') : (t('richTextEditor.startVoice') || 'Start voice')}
onClick={toggleVoice}
className={cn(
'p-1.5 rounded-full border transition-all',
voiceState === 'listening'
? 'border-red-400 bg-red-50 dark:bg-red-950/30 text-red-500 animate-pulse'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
)}
>
{voiceState === 'listening' ? <MicOff size={16} /> : <Mic size={16} />}
</button>
)}
{!readOnly && onToggleAttachments && (
<button
title={t('notes.attachments') || 'Attachments'}
aria-label={t('notes.attachments') || 'Attachments'}
onClick={onToggleAttachments}
className="relative p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<Paperclip size={16} />
{(attachmentsCount ?? 0) > 0 && (
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full flex items-center justify-center">
{attachmentsCount}
</span>
)}
</button>
)}
{!readOnly && (
<div className="flex items-center gap-1.5">
<button
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
onClick={() => actions.handleSaveInPlace()}
disabled={state.isSaving || !state.isDirty}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.isDirty
? 'bg-foreground text-background border-foreground hover:opacity-80'
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
)}
>
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
</button>
</div>
)}
{/* 3. Publish */}
{!readOnly && (
<DropdownMenu open={publishOpen} onOpenChange={setPublishOpen}>
<DropdownMenuTrigger asChild>
@@ -897,6 +761,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
)}
{t('richTextEditor.publishWithAi')}
</button>
<p className="text-[10px] text-muted-foreground text-center">
{t('richTextEditor.publishWithAiCostHint')}
</p>
</div>
</div>
)}
@@ -904,6 +771,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</DropdownMenu>
)}
{/* 4. Share */}
{!readOnly && (
<button
title={t('notes.shareNoteTitle')}
@@ -915,6 +783,151 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</button>
)}
{/* 5. AI assistant */}
<button
title={t('ai.openAssistant')}
aria-label={t('ai.openAssistant')}
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.aiOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Sparkles size={16} />
</button>
{/* 6. Brainstorm */}
<button
title={t('notes.brainstormThisIdea')}
aria-label={t('notes.brainstormThisIdeaAria')}
onClick={() => {
const title = note.title || ''
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
const seed = title ? `${title}. ${summary}` : summary
if (!seed.trim()) return
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
}}
className="p-1.5 rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all"
>
<Wind size={16} />
</button>
{/* 7. Education (flashcards / exercises) */}
{!readOnly && (
<div className="relative">
<button
title={t('flashcards.toolbarGenerate')}
aria-label={t('flashcards.toolbarGenerate')}
onClick={() => setShowEduMenu(v => !v)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<GraduationCap size={16} />
</button>
{showEduMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowEduMenu(false)} />
<div className="absolute top-full right-0 mt-1 z-50 w-56 rounded-xl border border-border bg-card shadow-xl overflow-hidden">
<button
onClick={() => { setShowEduMenu(false); setFlashcardsOpen(true) }}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left"
>
<div className="p-1.5 rounded-lg bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400">
<GraduationCap size={16} />
</div>
<div>
<div className="text-sm font-medium">{t('flashcards.toolbarGenerate')}</div>
<div className="text-[10px] text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Révision espacée SM-2'}</div>
</div>
</button>
<button
onClick={() => { setShowEduMenu(false); handleGenerateExercises() }}
disabled={generatingExercises}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left border-t border-border/30"
>
<div className="p-1.5 rounded-lg bg-brand-accent/10 text-brand-accent">
{generatingExercises ? <Loader2Icon size={16} className="animate-spin" /> : <PenTool size={16} />}
</div>
<div>
<div className="text-sm font-medium">{t('richTextEditor.generateExercises') || 'Générer des exercices'}</div>
<div className="text-[10px] text-muted-foreground">{t('richTextEditor.generateExercisesHint') || '5 exercices + corrigés'}</div>
</div>
</button>
</div>
</>
)}
</div>
)}
{/* 8. Voice / attachments / markdown tools */}
{!readOnly && voiceSupported && (
<button
title={voiceState === 'listening'
? (t('editor.voiceStop') || 'Arrêter la dictée')
: (t('editor.voiceStart') || 'Dicter du texte')}
aria-label={voiceState === 'listening' ? (t('richTextEditor.stopVoice') || 'Stop voice') : (t('richTextEditor.startVoice') || 'Start voice')}
onClick={toggleVoice}
className={cn(
'p-1.5 rounded-full border transition-all',
voiceState === 'listening'
? 'border-red-400 bg-red-50 dark:bg-red-950/30 text-red-500 animate-pulse'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
)}
>
{voiceState === 'listening' ? <MicOff size={16} /> : <Mic size={16} />}
</button>
)}
{!readOnly && onToggleAttachments && (
<button
title={t('notes.attachments') || 'Attachments'}
aria-label={t('notes.attachments') || 'Attachments'}
onClick={onToggleAttachments}
className="relative p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<Paperclip size={16} />
{(attachmentsCount ?? 0) > 0 && (
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full flex items-center justify-center">
{attachmentsCount}
</span>
)}
</button>
)}
{state.isMarkdown && !readOnly && (
<button
title={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.showMarkdownPreview
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Eye size={16} />
</button>
)}
{state.isMarkdown && !readOnly && (
<button
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
onClick={handleConvertToRichtext}
disabled={isConverting}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
isConverting && 'opacity-50 cursor-not-allowed'
)}
>
{isConverting ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
</button>
)}
{/* 9. Overflow menu */}
{!readOnly && (
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -52,6 +52,10 @@ export interface NoteEditorState {
isMarkdown: boolean
allImages: string[]
/** Images extraites du corps de la note (pas la couverture) */
contentImages: string[]
/** URL de couverture explicite (images[0]), null si aucune */
coverImage: string | null
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
quotaExceededFeature: string | null
}
@@ -61,6 +65,8 @@ export interface NoteEditorActions {
setDismissedTitleSuggestions: (dismissed: boolean) => void
setContent: (content: string) => void
/** Immediate content state (no 800ms debounce) — image paste, conversions, etc. */
setContentImmediate: (content: string) => void
setCheckItems: (items: CheckItem[]) => void
handleCheckItem: (id: string) => void
@@ -76,6 +82,10 @@ export interface NoteEditorActions {
setImages: (images: string[]) => void
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
handleRemoveImage: (index: number) => void
/** Définit l'image de couverture (sous le titre) sans retirer l'image du corps */
setCoverImage: (url: string) => void
/** Retire la couverture (les images du corps restent) */
clearCoverImage: () => void
uploadImageFile: (file: File) => Promise<string>
setLinks: (links: LinkMetadata[]) => void

View File

@@ -2,9 +2,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2, Download, Pencil, Presentation, Wind, Scissors } from 'lucide-react'
import { Bell, Check, X, Clock, AlertCircle, Circle, Share2, Bot, Download, Pencil, Presentation, Wind, Scissors } from 'lucide-react'
import {
Popover,
PopoverContent,
@@ -46,15 +44,6 @@ interface ReminderNote {
isReminderDone: boolean
}
// ── Memento brand tokens ──────────────────────────────────────────────────────
const C = {
blue: '#FDFDFE',
gold: '#D4A373',
green: '#A3B18A',
dark: '#1C1C1C',
beige: '#FDFDFE',
}
export function NotificationPanel() {
const { t } = useLanguage()
const router = useRouter()
@@ -171,38 +160,32 @@ export function NotificationPanel() {
const hasContent = requests.length > 0 || brainstormShares.length > 0 || activeReminders.length > 0 || appNotifications.length > 0
// ── icon bg/color per notification type ──────────────────────────────────
const notifIconStyle = (type: string) => {
if (type === 'agent_success') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
if (type === 'agent_slides_ready') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
if (type === 'agent_canvas_ready') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
if (type === 'agent_failure') return { bg: 'rgba(239,68,68,0.12)', color: '#EF4444' }
if (type === 'brainstorm_invite') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
if (type === 'brainstorm_joined') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
if (type === 'clip') return { bg: 'rgba(99,102,241,0.12)', color: '#6366F1' }
return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
// ── icon styles per notification type (theme-aware via opacity) ─────────
const notifIconClass = (type: string) => {
if (type === 'agent_failure') return 'bg-red-500/15 text-red-500'
if (type.startsWith('agent')) return 'bg-brand-accent/15 text-brand-accent'
if (type.startsWith('brainstorm')) return 'bg-ochre/15 text-ochre'
if (type === 'clip') return 'bg-brand-accent/15 text-brand-accent'
return 'bg-muted text-muted-foreground'
}
const notifLabelColor = (type: string) => {
if (type.startsWith('agent')) {
if (type === 'agent_failure') return '#EF4444'
if (type === 'brainstorm_invite') return '#A3B18A'
if (type === 'brainstorm_joined') return '#A3B18A'
return '#A47148'
}
return '#A3B18A'
const notifLabelClass = (type: string) => {
if (type === 'agent_failure') return 'text-red-500'
if (type.startsWith('agent')) return 'text-brand-accent'
if (type.startsWith('brainstorm')) return 'text-ochre'
return 'text-muted-foreground'
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className="relative p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
className="relative p-1.5 text-muted-foreground hover:text-foreground transition-all hover:bg-black/5 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
>
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
{pendingCount > 0 && (
<span
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border border-white shadow-sm bg-brand-accent"
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border-2 border-background shadow-sm bg-brand-accent"
>
{pendingCount > 9 ? '9+' : pendingCount}
</span>
@@ -210,12 +193,15 @@ export function NotificationPanel() {
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0 rounded-2xl overflow-hidden shadow-2xl border border-black/20">
<PopoverContent
align="end"
className="w-80 p-0 rounded-2xl overflow-hidden shadow-2xl border border-border bg-popover text-popover-foreground"
>
{/* Header */}
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ background: '#FDFDFE' }}>
<div className="px-4 py-3 border-b border-border bg-muted/40 dark:bg-white/[0.03] flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4" style={{ color: C.dark }} />
<span className="font-bold text-sm tracking-tight" style={{ color: C.dark }}>
<Bell className="h-4 w-4 text-foreground/70" />
<span className="font-bold text-sm tracking-tight text-foreground">
{t('notification.notifications')}
</span>
</div>
@@ -223,7 +209,7 @@ export function NotificationPanel() {
{appNotifications.length > 0 && (
<button
onClick={handleMarkAllRead}
className="text-[10px] text-foreground/40 hover:text-foreground transition-colors"
className="text-muted-foreground hover:text-foreground transition-colors p-1 rounded-md hover:bg-black/5 dark:hover:bg-white/10"
title={t('notification.markAllRead') || 'Mark all read'}
>
<Check className="h-3.5 w-3.5" />
@@ -241,25 +227,24 @@ export function NotificationPanel() {
{isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
<div className="animate-spin h-6 w-6 border-2 border-t-transparent rounded-full mx-auto mb-2" style={{ borderColor: C.blue, borderTopColor: 'transparent' }} />
<div className="animate-spin h-6 w-6 border-2 border-brand-accent border-t-transparent rounded-full mx-auto mb-2" />
</div>
) : !hasContent ? (
<div className="p-8 text-center">
<Bell className="h-9 w-9 mx-auto mb-3 opacity-20" />
<p className="text-[12px] font-medium text-foreground/40">{t('notification.noNotifications') || 'Aucune notification'}</p>
<Bell className="h-9 w-9 mx-auto mb-3 text-muted-foreground/40" />
<p className="text-[12px] font-medium text-muted-foreground">{t('notification.noNotifications') || 'Aucune notification'}</p>
</div>
) : (
<div className="max-h-96 overflow-y-auto divide-y divide-black/5">
<div className="max-h-96 overflow-y-auto divide-y divide-border">
{/* ── App notifications (agents, system) ── */}
{appNotifications.map((notif) => {
const isSlides = notif.type === 'agent_slides_ready'
const isCanvas = notif.type === 'agent_canvas_ready'
const canvasId = notif.relatedId
const iconStyle = notifIconStyle(notif.type)
return (
<div key={notif.id} className="p-3 hover:bg-black/[0.02] transition-colors">
<div key={notif.id} className="p-3 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors">
<div
className="flex items-start gap-3 cursor-pointer"
onClick={() => {
@@ -270,11 +255,7 @@ export function NotificationPanel() {
}
}}
>
{/* Icon badge */}
<div
className="mt-0.5 flex-none rounded-lg p-1.5"
style={{ background: iconStyle.bg, color: iconStyle.color }}
>
<div className={cn('mt-0.5 flex-none rounded-lg p-1.5', notifIconClass(notif.type))}>
{isSlides ? <Presentation className="w-3.5 h-3.5" />
: isCanvas ? <Pencil className="w-3.5 h-3.5" />
: notif.type === 'brainstorm_invite' ? <Wind className="w-3.5 h-3.5" />
@@ -285,10 +266,7 @@ export function NotificationPanel() {
</div>
<div className="flex-1 min-w-0">
<span
className="text-[9px] font-bold uppercase tracking-[0.2em]"
style={{ color: notifLabelColor(notif.type) }}
>
<span className={cn('text-[9px] font-bold uppercase tracking-[0.2em]', notifLabelClass(notif.type))}>
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Présentation prête')}
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagramme prêt')}
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent terminé')}
@@ -298,11 +276,11 @@ export function NotificationPanel() {
{notif.type === 'clip' && (t('notification.clipSaved') || 'Web clip')}
{notif.type === 'system' && t('notification.systemNotification')}
</span>
<p className="text-[13px] font-semibold truncate mt-0.5">{notif.title}</p>
<p className="text-[13px] font-semibold truncate mt-0.5 text-foreground">{notif.title}</p>
{notif.message && (
<p className="text-[11px] text-foreground/50 mt-0.5 line-clamp-2">{notif.message}</p>
<p className="text-[11px] text-muted-foreground mt-0.5 line-clamp-2">{notif.message}</p>
)}
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/70">
<Clock className="w-3 h-3" />
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
</div>
@@ -310,13 +288,12 @@ export function NotificationPanel() {
<button
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
className="mt-0.5 text-foreground/20 hover:text-foreground transition-colors"
className="mt-0.5 text-muted-foreground/50 hover:text-foreground transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
{/* Download PPTX button */}
{isSlides && canvasId && (
<div className="mt-2 ml-8">
<button
@@ -338,8 +315,7 @@ export function NotificationPanel() {
document.body.removeChild(a); URL.revokeObjectURL(url)
} catch { toast.error(t('notification.downloadFailed')) }
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold rounded-lg text-white uppercase tracking-wide transition-all hover:opacity-90 active:scale-95 shadow-sm"
style={{ background: C.blue }}
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold rounded-lg bg-brand-accent text-white uppercase tracking-wide transition-all hover:opacity-90 active:scale-95 shadow-sm"
>
<Download className="w-3 h-3" />
{t('notification.downloadPptx') || 'Télécharger .pptx'}
@@ -352,25 +328,24 @@ export function NotificationPanel() {
{/* ── Overdue reminders ── */}
{overdueReminders.map((note) => (
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
<div key={note.id} className="p-3 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors">
<div className="flex items-start gap-3">
<button
onClick={() => handleToggleReminder(note.id, true)}
className="mt-0.5 flex-none transition-colors hover:opacity-70"
style={{ color: C.green }}
className="mt-0.5 flex-none text-ochre transition-colors hover:opacity-70"
title={t('reminders.markDone')}
>
<Circle className="w-4 h-4" />
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<AlertCircle className="w-3 h-3" style={{ color: C.green }} />
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.green }}>
<AlertCircle className="w-3 h-3 text-ochre" />
<span className="text-[9px] font-bold uppercase tracking-[0.2em] text-ochre">
{t('reminders.overdue')}
</span>
</div>
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
<p className="text-[13px] font-semibold truncate text-foreground">{note.title || t('notification.untitled')}</p>
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/70">
<Clock className="w-3 h-3" />
{note.reminder && formatDistanceToNow(new Date(note.reminder), { addSuffix: true })}
</div>
@@ -381,12 +356,12 @@ export function NotificationPanel() {
{/* ── Upcoming reminders ── */}
{upcomingReminders.slice(0, 5).map((note) => (
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
<div key={note.id} className="p-3 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors">
<div className="flex items-start gap-3">
<Clock className="w-4 h-4 mt-0.5 flex-none" style={{ color: C.blue }} />
<Clock className="w-4 h-4 mt-0.5 flex-none text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
<div className="text-[11px] text-foreground/40 mt-0.5">
<p className="text-[13px] font-semibold truncate text-foreground">{note.title || t('notification.untitled')}</p>
<div className="text-[11px] text-muted-foreground mt-0.5">
{note.reminder && new Date(note.reminder).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
})}
@@ -398,25 +373,22 @@ export function NotificationPanel() {
{/* ── Brainstorm share invites ── */}
{brainstormShares.map((share) => (
<div key={share.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
<div key={share.id} className="p-4 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors space-y-3">
<div className="flex items-start gap-3">
<div
className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
style={{ background: `linear-gradient(135deg, #A47148, #A47148)` }}
>
<div className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm bg-brand-accent">
{(share.sharer?.name || share.sharer?.email || '?')[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<Wind className="w-3 h-3" style={{ color: '#A47148' }} />
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: '#A47148' }}>
Brainstorm
<Wind className="w-3 h-3 text-brand-accent" />
<span className="text-[9px] font-bold uppercase tracking-[0.2em] text-brand-accent">
{t('notification.brainstormInvite')}
</span>
</div>
<p className="text-[13px] font-semibold truncate">
<p className="text-[13px] font-semibold truncate text-foreground">
{share.sharer?.name || share.sharer?.email}
</p>
<p className="text-[11px] text-foreground/50 truncate">
<p className="text-[11px] text-muted-foreground truncate">
{t('notification.brainstormShared') || 'invited you to a brainstorm'} « {share.session?.seedIdea?.length > 35 ? share.session.seedIdea.substring(0, 35) + '…' : share.session?.seedIdea} »
</p>
</div>
@@ -424,7 +396,7 @@ export function NotificationPanel() {
<div className="flex gap-2 ml-11">
<button
onClick={() => handleDeclineBrainstorm(share.id)}
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-black/15 text-foreground/60 hover:bg-black/5 transition-all active:scale-95 flex items-center justify-center gap-1"
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-border text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-all active:scale-95 flex items-center justify-center gap-1"
>
<X className="h-3 w-3" />
{t('notification.decline') || 'Decline'}
@@ -442,26 +414,22 @@ export function NotificationPanel() {
{/* ── Share requests ── */}
{requests.map((request) => (
<div key={request.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
<div key={request.id} className="p-4 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors space-y-3">
<div className="flex items-start gap-3">
{/* Avatar */}
<div
className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
style={{ background: `linear-gradient(135deg, ${C.blue}, ${C.green})` }}
>
<div className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm bg-brand-accent">
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<Share2 className="w-3 h-3" style={{ color: C.blue }} />
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.blue }}>
Partage
<Share2 className="w-3 h-3 text-brand-accent" />
<span className="text-[9px] font-bold uppercase tracking-[0.2em] text-brand-accent">
{t('notification.shareLabel')}
</span>
</div>
<p className="text-[13px] font-semibold truncate">
<p className="text-[13px] font-semibold truncate text-foreground">
{request.sharer.name || request.sharer.email}
</p>
<p className="text-[11px] text-foreground/50 truncate">
<p className="text-[11px] text-muted-foreground truncate">
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
</p>
</div>
@@ -470,15 +438,14 @@ export function NotificationPanel() {
<div className="flex gap-2 ml-11">
<button
onClick={() => handleDecline(request.id)}
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-black/15 text-foreground/60 hover:bg-black/5 transition-all active:scale-95 flex items-center justify-center gap-1"
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-border text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-all active:scale-95 flex items-center justify-center gap-1"
>
<X className="h-3 w-3" />
{t('notification.decline') || 'Refuser'}
</button>
<button
onClick={() => handleAccept(request.id)}
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90"
style={{ background: C.blue }}
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white bg-brand-accent transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90"
>
<Check className="h-3 w-3" />
{t('notification.accept') || 'Accepter'}
@@ -489,13 +456,11 @@ export function NotificationPanel() {
</div>
)}
{/* Footer */}
{activeReminders.length > 0 && (
<div className="px-4 py-2.5 border-t bg-black/[0.02]">
<div className="px-4 py-2.5 border-t border-border bg-muted/30 dark:bg-white/[0.02]">
<a
href="/reminders"
className="text-[11px] font-semibold hover:opacity-70 transition-opacity"
style={{ color: C.blue }}
className="text-[11px] font-semibold text-brand-accent hover:opacity-80 transition-opacity"
>
{t('reminders.viewAll') || 'Voir tous les rappels →'}
</a>

View File

@@ -0,0 +1,42 @@
'use client'
import { useState, type CSSProperties } from 'react'
import { Check, Link2 } from 'lucide-react'
export function CopyLinkButton({
label = 'Copier le lien',
copiedLabel = 'Copié',
className,
style,
}: {
label?: string
copiedLabel?: string
className?: string
style?: CSSProperties
}) {
const [copied, setCopied] = useState(false)
const onCopy = async () => {
try {
await navigator.clipboard.writeText(window.location.href)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
} catch {
// ignore
}
}
return (
<button
type="button"
onClick={() => void onCopy()}
className={className}
style={style}
aria-label={copied ? copiedLabel : label}
title={copied ? copiedLabel : label}
>
{copied ? <Check size={13} strokeWidth={2.25} /> : <Link2 size={13} strokeWidth={2.25} />}
<span>{copied ? copiedLabel : label}</span>
</button>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import { useEffect, useState } from 'react'
/**
* Thin reading progress bar. Tracks the nearest scrollable ancestor
* (public layout uses a fixed-height overflow container, not window).
*/
export function ReadingProgress({ accent = '#A47148' }: { accent?: string }) {
const [progress, setProgress] = useState(0)
useEffect(() => {
const findScrollRoot = (): HTMLElement | Window => {
let el: HTMLElement | null = document.querySelector('[data-pub-root]') as HTMLElement | null
while (el) {
const { overflowY } = getComputedStyle(el)
if (overflowY === 'auto' || overflowY === 'scroll') return el
el = el.parentElement
}
return window
}
const root = findScrollRoot()
const update = () => {
let scrollTop: number
let scrollHeight: number
let clientHeight: number
if (root === window) {
scrollTop = window.scrollY
scrollHeight = document.documentElement.scrollHeight
clientHeight = window.innerHeight
} else {
const node = root as HTMLElement
scrollTop = node.scrollTop
scrollHeight = node.scrollHeight
clientHeight = node.clientHeight
}
const max = Math.max(1, scrollHeight - clientHeight)
setProgress(Math.min(100, Math.max(0, (scrollTop / max) * 100)))
}
update()
root.addEventListener('scroll', update, { passive: true })
window.addEventListener('resize', update, { passive: true })
return () => {
root.removeEventListener('scroll', update)
window.removeEventListener('resize', update)
}
}, [])
return (
<div
aria-hidden
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: 3,
zIndex: 100,
background: 'transparent',
pointerEvents: 'none',
}}
>
<div
style={{
height: '100%',
width: `${progress}%`,
background: accent,
boxShadow: `0 0 12px ${accent}66`,
transition: 'width 80ms linear',
}}
/>
</div>
)
}

View File

@@ -70,10 +70,11 @@ import {
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { NodeSelection } from '@tiptap/pm/state'
export interface RichTextEditorHandle {
getEditor: () => Editor | null
@@ -88,6 +89,8 @@ export interface RichTextEditorHandle {
export interface RichTextEditorProps {
content?: string
onChange?: (content: string) => void
/** Immediate parent state update (image paste, structural inserts) — skips typing debounce. */
onChangeImmediate?: (content: string) => void
className?: string
placeholder?: string
onImageUpload?: (file: File) => Promise<string>
@@ -137,6 +140,11 @@ const TRANSLATE_TARGET_API_VALUES = ['Francais', 'English', 'Espanol', 'Deutsch'
const AI_REFORMULATE_FALLBACK = '__RICH_TEXT_AI_FALLBACK__'
const CustomImage = Image.extend({
// Treat image as a single selectable unit (Backspace/Delete must remove it)
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
@@ -149,22 +157,98 @@ const CustomImage = Image.extend({
}
}
}
}
},
addKeyboardShortcuts() {
const deleteSelectedImage = () => {
const { selection } = this.editor.state
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
return this.editor.chain().focus().deleteSelection().run()
}
// Cursor just after an image block → Backspace removes it
const { $from, empty } = selection
if (!empty) return false
const before = $from.nodeBefore
if (before?.type.name === 'image') {
const from = $from.pos - before.nodeSize
return this.editor.chain().focus().deleteRange({ from, to: $from.pos }).run()
}
// Cursor at start of paragraph right after image (common after paste under title)
if ($from.parentOffset === 0 && $from.depth >= 1) {
const index = $from.index($from.depth - 1)
if (index > 0) {
const prev = $from.node($from.depth - 1).child(index - 1)
if (prev.type.name === 'image') {
const pos = $from.before($from.depth) - prev.nodeSize
return this.editor.chain().focus().deleteRange({ from: pos, to: pos + prev.nodeSize }).run()
}
}
}
return false
}
return {
Backspace: deleteSelectedImage,
Delete: () => {
const { selection } = this.editor.state
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
return this.editor.chain().focus().deleteSelection().run()
}
// Cursor just before an image → Delete removes it
const { $from, empty } = selection
if (!empty) return false
const after = $from.nodeAfter
if (after?.type.name === 'image') {
return this.editor.chain().focus().deleteRange({ from: $from.pos, to: $from.pos + after.nodeSize }).run()
}
return false
},
}
},
})
/** Insert / toggle a bullet list without the toggle-off → re-insert glitch. */
function insertBulletList(editor: Editor) {
// Already a bullet list → toggle off (do not re-insert)
if (editor.isActive('bulletList')) {
editor.chain().focus().toggleBulletList().run()
return
}
// Convert from other list types if needed
if (editor.isActive('orderedList')) editor.chain().focus().toggleOrderedList().run()
if (editor.isActive('taskList')) editor.chain().focus().toggleTaskList().run()
const ok = editor.chain().focus().toggleBulletList().run()
if (!ok) {
editor.chain().focus().insertContent('<ul><li><p></p></li></ul>').run()
}
}
function insertOrderedList(editor: Editor) {
if (editor.isActive('orderedList')) {
editor.chain().focus().toggleOrderedList().run()
return
}
if (editor.isActive('bulletList')) editor.chain().focus().toggleBulletList().run()
if (editor.isActive('taskList')) editor.chain().focus().toggleTaskList().run()
const ok = editor.chain().focus().toggleOrderedList().run()
if (!ok) {
editor.chain().focus().insertContent('<ol><li><p></p></li></ol>').run()
}
}
const slashCommands: SlashItem[] = [
// Basic blocks
// Basic blocks — lists BEFORE table so defaults / shortcuts never collide with Tableau
{ title: 'Text', description: 'Plain paragraph', icon: Pilcrow, category: 'Basic blocks', shortcut: '¶', command: (e) => e.chain().focus().setParagraph().run() },
{ title: 'Heading 1', description: 'Big section heading', icon: Heading1, category: 'Basic blocks', shortcut: '#', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
{ title: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'Basic blocks', shortcut: '##', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
{ title: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'Basic blocks', shortcut: '###', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
{ title: 'Table', description: 'Insert a simple table', icon: () => <span className="text-xs font-bold border rounded px-1">TBL</span>, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
{ title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => e.chain().focus().toggleBulletList().run() },
{ title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => e.chain().focus().toggleOrderedList().run() },
{ title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => insertBulletList(e) },
{ title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => insertOrderedList(e) },
{ title: 'To-do List', description: 'Checkboxes for tasks', icon: CheckSquare, category: 'Basic blocks', shortcut: '[]', command: (e) => e.chain().focus().toggleTaskList().run() },
{ title: 'Quote', description: 'Capture a quote', icon: Quote, category: 'Basic blocks', shortcut: '>', command: (e) => e.chain().focus().toggleBlockquote().run() },
{ title: 'Code Block', description: 'Code snippet', icon: CodeXml, category: 'Basic blocks', shortcut: '```', command: (e) => e.chain().focus().toggleCodeBlock().run() },
{ title: 'Divider', description: 'Horizontal separator', icon: Minus, category: 'Basic blocks', shortcut: '---', command: (e) => e.chain().focus().setHorizontalRule().run() },
{ title: 'Table', description: 'Insert a simple table', icon: () => <span className="text-xs font-bold border rounded px-1">TBL</span>, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
// Media
{ title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => { } },
// Formatting
@@ -300,7 +384,7 @@ function useImageInsert() {
}
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
function RichTextEditor({ content, onChange, onChangeImmediate, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const imageInsert = useImageInsert()
@@ -361,11 +445,22 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
const editorInstanceRef = useRef<Editor | null>(null)
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const onChangeImmediateRef = useRef(onChangeImmediate)
onChangeImmediateRef.current = onChangeImmediate
const htmlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const emitContentChange = useCallback((html: string) => {
const emitContentChange = useCallback((html: string, opts?: { immediate?: boolean }) => {
lastEmittedContent.current = html
onChangeRef.current?.(html)
// Cancel pending debounced HTML emit so it cannot overwrite with stale doc
if (htmlDebounceRef.current) {
clearTimeout(htmlDebounceRef.current)
htmlDebounceRef.current = null
}
if (opts?.immediate && onChangeImmediateRef.current) {
onChangeImmediateRef.current(html)
} else {
onChangeRef.current?.(html)
}
}, [])
// Debounced version for onUpdate — avoids calling getHTML() on every keystroke
@@ -581,6 +676,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
const hasImage = items.some(item => item.type.startsWith('image/'))
if (!hasImage) return false
event.preventDefault()
event.stopPropagation()
const imageFiles = items
.filter(item => item.type.startsWith('image/'))
.map(item => item.getAsFile())
@@ -592,20 +688,82 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
toast.info(t('notes.uploading'))
const url = await onImageUpload(file)
const ed = editorInstanceRef.current
if (!ed) continue
if (!ed || ed.isDestroyed) continue
// Insert at cursor; force immediate parent sync so save/ref don't drop the img
const inserted = ed.chain().focus().setImage({ src: url }).run()
if (inserted) {
emitContentChange(ed.getHTML())
const html = ed.getHTML()
emitContentChange(html, { immediate: true })
// Prefetch so the img is warm in the browser cache
const preload = new window.Image()
preload.src = url
toast.success(t('notes.uploadSuccess') || 'Image ajoutée')
} else {
toast.error(t('notes.uploadFailed') || 'Insertion image échouée')
}
} catch {
} catch (err) {
console.error('[editor] image paste failed:', err)
toast.error(t('notes.uploadFailed'))
}
}
})()
return true
}
},
// ProseMirror signature: (view, pos, event)
handleClick: (view, pos, event) => {
// Click on img → NodeSelection so Backspace/Delete and bubble toolbar work
const target = event.target as HTMLElement | null
if (!target || target.tagName !== 'IMG') return false
if (!view.editable) return false
try {
let imagePos: number | null = null
const nodeAt = view.state.doc.nodeAt(pos)
if (nodeAt?.type.name === 'image') {
imagePos = pos
} else {
const $pos = view.state.doc.resolve(pos)
if ($pos.nodeAfter?.type.name === 'image') {
imagePos = $pos.pos
} else if ($pos.nodeBefore?.type.name === 'image') {
imagePos = $pos.pos - $pos.nodeBefore.nodeSize
} else {
// Walk up: pos may sit inside / around the leaf
for (let d = $pos.depth; d > 0; d--) {
if ($pos.node(d).type.name === 'image') {
imagePos = $pos.before(d)
break
}
}
}
}
if (imagePos == null) {
// Last resort: posAtDOM on the img element
const domPos = view.posAtDOM(target, 0)
const $dom = view.state.doc.resolve(domPos)
if ($dom.nodeAfter?.type.name === 'image') imagePos = $dom.pos
else if (view.state.doc.nodeAt(domPos)?.type.name === 'image') imagePos = domPos
}
if (imagePos == null) return false
const node = view.state.doc.nodeAt(imagePos)
if (!node || node.type.name !== 'image') return false
const tr = view.state.tr.setSelection(NodeSelection.create(view.state.doc, imagePos))
view.dispatch(tr)
view.focus()
return true
} catch {
return false
}
},
},
onUpdate: ({ editor: e }) => {
onUpdate: ({ editor: e, transaction }) => {
// Image removed → immediate parent sync so a stale content prop cannot resurrect it
if (transaction.docChanged && (lastEmittedContent.current || '').includes('<img')) {
const html = e.getHTML()
if (!html.includes('<img')) {
emitContentChange(html, { immediate: true })
return
}
}
// Debounced getHTML() — avoids traversing the whole doc on every keystroke
emitContentChangeDebounced(e)
if (!e.isEditable) return
@@ -763,6 +921,11 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
useEffect(() => {
if (editor && content !== undefined && content !== lastEmittedContent.current) {
// While the user is editing, never clobber local deletes (e.g. removed image)
// with a stale parent `content` that has not caught up yet.
if (editor.isFocused) {
return
}
const html = content || ''
lastEmittedContent.current = html
queueMicrotask(() => {
@@ -774,7 +937,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
}
})
}
if (content !== undefined) {
if (content !== undefined && !editor?.isFocused) {
setCurrentNoteContent(content || '')
}
}, [content, editor, sourceUrl])
@@ -1103,7 +1266,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
if (!editor) return
editor.chain().focus().setImage({ src: url }).run()
setSmartPasteExtended(null)
emitContentChange(editor.getHTML())
emitContentChange(editor.getHTML(), { immediate: true })
}, [editor, emitContentChange])
const handlePasteUrlVideo = useCallback((url: string) => {
@@ -1521,9 +1684,47 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS
{editorState.isImage && (
<>
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">25%</button>
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">50%</button>
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">100%</button>
<button
type="button"
onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()}
className="notion-bubble-btn rounded-md text-xs font-medium px-1"
title="25%"
>
25%
</button>
<button
type="button"
onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()}
className="notion-bubble-btn rounded-md text-xs font-medium px-1"
title="50%"
>
50%
</button>
<button
type="button"
onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()}
className="notion-bubble-btn rounded-md text-xs font-medium px-1"
title="100%"
>
100%
</button>
<button
type="button"
onClick={() => {
const { selection } = editor.state
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
editor.chain().focus().deleteSelection().run()
} else if (editor.isActive('image')) {
// Fallback: delete the nearest selected image node
editor.chain().focus().deleteSelection().run()
}
}}
className="notion-bubble-btn rounded-md text-red-600 dark:text-red-400"
title={t('richTextEditor.deleteImage') || t('common.delete') || 'Supprimer l\'image'}
aria-label={t('richTextEditor.deleteImage') || 'Supprimer l\'image'}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
{aiOpen && (
@@ -1728,45 +1929,52 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
const menuInteracting = useRef(false)
const [frequentCommands, setFrequentCommands] = useState<SlashMenuItem[]>([])
// Resolve by English command title — NEVER by array index (reorder-safe)
const sc = (enTitle: string) => {
const cmd = slashCommands.find(c => c.title === enTitle)
if (!cmd) throw new Error(`Slash command missing: ${enTitle}`)
return cmd
}
const localCommands: SlashMenuItem[] = [
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'text' },
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'text' },
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'text' },
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'text' },
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'data' },
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'text' },
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'text' },
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'text' },
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'text' },
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'text' },
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'text' },
{ ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' },
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'text' },
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'text' },
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'text' },
{ ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' },
{ ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' },
{ ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' },
{ ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' },
{ ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'text' },
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'text' },
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'text' },
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'text' },
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'text' },
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'text' },
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' },
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
{ ...slashCommands[28], title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
{ ...slashCommands[29], title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' },
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'tableau', 'structured', 'structuree', 'structurée'] },
{ ...slashCommands[31], title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
{ ...slashCommands[32], title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
{ ...slashCommands[33], title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'table', 'matieres', 'matières', 'plan'] },
{ ...slashCommands[34], title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] },
{ ...slashCommands[35], title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] },
{ ...slashCommands[36], title: t('richTextEditor.slashColumns'), description: t('richTextEditor.slashColumnsDesc'), categoryId: 'text', slashKeywords: ['columns', 'colonnes', 'cols', 'layout', 'mise', 'page', 'cote', 'côte'] },
{ ...slashCommands[37], title: t('richTextEditor.slashAiWriter') || 'Écrire avec l\'IA', description: t('richTextEditor.slashAiWriterDesc') || 'Générer du contenu au curseur', categoryId: 'ai', slashKeywords: ['ecrire', 'écrire', 'write', 'ia', 'ai', 'generer', 'générer', 'rediger', 'rédiger'] },
{ ...sc('Text'), title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'text' },
{ ...sc('Heading 1'), title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'text' },
{ ...sc('Heading 2'), title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'text' },
{ ...sc('Heading 3'), title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'text' },
{ ...sc('Bullet List'), title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'text', slashKeywords: ['bullet', 'liste', 'puce', 'ul', 'puces'] },
{ ...sc('Numbered List'), title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'text', slashKeywords: ['numbered', 'numérotée', 'numerotee', 'ol', '1.'] },
{ ...sc('To-do List'), title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'text', slashKeywords: ['todo', 'tache', 'tâche', 'checkbox'] },
{ ...sc('Quote'), title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'text' },
{ ...sc('Code Block'), title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'text' },
{ ...sc('Divider'), title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'text' },
{ ...sc('Table'), title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'data', slashKeywords: ['table', 'tableau', 'grid', 'grille'] },
{ ...sc('Image'), title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' },
{ ...sc('Align Left'), title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'text' },
{ ...sc('Align Center'), title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'text' },
{ ...sc('Align Right'), title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'text' },
{ ...sc('Clarifier'), title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' },
{ ...sc('Raccourcir'), title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' },
{ ...sc('Améliorer'), title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' },
{ ...sc('Développer'), title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' },
{ ...sc('Bold'), title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'text' },
{ ...sc('Italic'), title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'text' },
{ ...sc('Underline'), title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'text' },
{ ...sc('Strike'), title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'text' },
{ ...sc('Highlight'), title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'text' },
{ ...sc('Superscript'), title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'text' },
{ ...sc('Subscript'), title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' },
{ ...sc('Diagramme'), title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
{ ...sc('Présentation'), title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
{ ...sc('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' },
{ ...sc('Database'), title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'structured', 'structuree', 'structurée'] },
{ ...sc('Toggle'), title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
{ ...sc('Callout'), title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
{ ...sc('Outline'), title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'matieres', 'matières', 'plan'] },
{ ...sc('Link Preview'), title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] },
{ ...sc('Math'), title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] },
{ ...sc('Columns'), title: t('richTextEditor.slashColumns'), description: t('richTextEditor.slashColumnsDesc'), categoryId: 'text', slashKeywords: ['columns', 'colonnes', 'cols', 'layout', 'mise', 'page', 'cote', 'côte'] },
{ ...sc("Écrire avec l'IA"), title: t('richTextEditor.slashAiWriter') || 'Écrire avec l\'IA', description: t('richTextEditor.slashAiWriterDesc') || 'Générer du contenu au curseur', categoryId: 'ai', slashKeywords: ['ecrire', 'écrire', 'write', 'ia', 'ai', 'generer', 'générer', 'rediger', 'rédiger'] },
{
title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'),
@@ -1870,7 +2078,10 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
toastAi(err)
}
finally { setAiLoading(false) }
} else if (item.title === 'Suggest Charts') {
} else if (
item.title === 'Suggest Charts'
|| item.title === (t('richTextEditor.slashCharts') || 'Graphiques IA')
) {
deleteSlashText(); closeMenu(); onSuggestCharts()
} else if (item.title === t('richTextEditor.slashDatabase')) {
deleteSlashText(); closeMenu()

View File

@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
import { Check, Shield, Zap, Crown, X, ExternalLink, Loader2, CheckCircle2, Activity, Clock, ArrowRight } from 'lucide-react';
import { Check, Shield, Zap, Crown, X, ExternalLink, Loader2, CheckCircle2, ArrowRight, Coins } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useLanguage } from '@/lib/i18n';
import { toast } from 'sonner';
@@ -33,6 +33,14 @@ interface BillingStatus {
year: { display: string; amount: number; currency: string };
};
};
creditPacks?: Array<{
id: 'S' | 'M' | 'L';
credits: number;
display: string;
amount: number;
currency: string;
configured: boolean;
}>;
}
const billingEnabledEnvFallback = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development';
@@ -53,6 +61,7 @@ export function BillingPlans() {
const [checkoutClientSecret, setCheckoutClientSecret] = useState<string | null>(null);
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
const [checkoutLoading, setCheckoutLoading] = useState<Tier | null>(null);
const [packLoading, setPackLoading] = useState<'S' | 'M' | 'L' | null>(null);
const [portalLoading, setPortalLoading] = useState(false);
const [cancelLoading, setCancelLoading] = useState(false);
const [successBanner, setSuccessBanner] = useState<string | null>(null);
@@ -78,16 +87,35 @@ export function BillingPlans() {
});
const quotas = usageData?.quotas;
const balance = usageData?.balance as
| {
totalRemaining: number | null
subscriptionGranted: number | null
purchasedRemaining: number
spentThisPeriod: number
unlimited: boolean
}
| undefined
const breakdown = (usageData?.breakdown ?? []) as Array<{
feature: string
creditsUsed: number
actionsCount: number
}>
const billingEnabled = status?.billingEnabled ?? billingEnabledEnvFallback;
const stripe = getStripePromise(billingEnabled);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session_id');
const isPack = params.get('pack') === '1';
if (sessionId) {
const tier = status?.effectiveTier ?? 'Pro';
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
if (isPack) {
setSuccessBanner(t('billing.packCheckoutSuccess'));
} else {
const tier = status?.effectiveTier ?? 'Pro';
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
}
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
window.history.replaceState({}, '', '/settings/billing');
@@ -128,6 +156,30 @@ export function BillingPlans() {
}
};
const handleBuyPack = async (packId: 'S' | 'M' | 'L') => {
if (!billingEnabled) return;
setPackLoading(packId);
try {
const res = await fetch('/api/billing/create-pack-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ packId }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? 'Failed to create pack checkout');
if (data.url) {
window.location.href = data.url;
} else {
throw new Error('No checkout URL');
}
} catch (err) {
console.error('[BillingPlans] pack checkout error:', err);
toast.error(t('billing.packCheckoutFailed'));
} finally {
setPackLoading(null);
}
};
const handlePortal = async (action: 'portal' | 'cancel' | React.MouseEvent = 'portal') => {
const actualAction = typeof action === 'string' ? action : 'portal';
setPortalLoading(true);
@@ -199,23 +251,19 @@ export function BillingPlans() {
const effectiveTier = status?.effectiveTier ?? 'BASIC';
const isPaid = effectiveTier !== 'BASIC';
const aiUsed = quotas?.semantic_search?.used ?? 0;
const aiLimit = quotas?.semantic_search?.limit ?? 50;
const aiPct = aiLimit > 0 ? (aiUsed / aiLimit) * 100 : 0;
const plans = [
{
id: 'free',
name: t('billing.freePlan'),
price: t('billing.freePrice') || 'Gratuit',
period: '',
description: t('billing.freeDescription') || 'Pour découvrir la magie de Memento.',
description: t('billing.freeDescription') || 'Pour découvrir Memento.',
features: [
t('billing.freeF1') || '100 Notes max',
t('billing.freeF2') || '3 Carnets',
t('billing.freeF3') || '50 crédits IA (Lifetime)',
t('billing.freeF4') || 'Recherche sémantique',
t('billing.freeF5') || 'Historique 7 jours',
t('billing.freeF1'),
t('billing.freeF2'),
t('billing.freeF3'),
t('billing.freeF4'),
t('billing.freeF5'),
],
current: effectiveTier === 'BASIC',
buttonText: effectiveTier === 'BASIC' ? (t('billing.currentPlan') || 'Plan Actuel') : t('billing.startCheckout'),
@@ -232,12 +280,12 @@ export function BillingPlans() {
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
features: [
t('billing.proFeature1') || 'Notes illimitées',
t('billing.proFeature2') || 'BYOK (OpenAI/Anthropic)',
t('billing.proFeature3') || '200 recherches sémantiques',
t('billing.proFeature4') || 'Agents (12 runs/mois)',
t('billing.proFeature5') || 'Historique 30 jours',
t('billing.proFeature6') || 'Support Email',
t('billing.proFeature1'),
t('billing.proFeature2'),
t('billing.proFeature3'),
t('billing.proFeature4'),
t('billing.proFeature5'),
t('billing.proFeature6'),
],
current: effectiveTier === 'PRO',
popular: true,
@@ -254,12 +302,12 @@ export function BillingPlans() {
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
features: [
t('billing.businessFeature1') || '10 Collaborateurs inclus',
t('billing.businessFeature2') || 'BYOK (13 fournisseurs)',
t('billing.businessFeature3') || '1000 recherches sémantiques',
t('billing.businessFeature4') || 'Agents (60 runs/mois)',
t('billing.businessFeature5') || 'Brainstorm illimité',
t('billing.businessFeature6') || 'Accès API',
t('billing.businessFeature1'),
t('billing.businessFeature2'),
t('billing.businessFeature3'),
t('billing.businessFeature4'),
t('billing.businessFeature5'),
t('billing.businessFeature6'),
],
current: effectiveTier === 'BUSINESS',
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.businessCta') || 'Choisir Plan Business'),
@@ -273,13 +321,13 @@ export function BillingPlans() {
name: t('billing.enterpriseTitle') || 'Enterprise',
price: t('billing.contactSales') || 'Sur devis',
period: '',
description: t('billing.enterpriseDescription') || 'Quotas personnalisés, SSO, support prioritaire.',
description: t('billing.enterpriseDescription') || 'Crédits illimités ou pool dédié, SSO, support prioritaire.',
features: [
t('billing.enterpriseFeature1') || 'Quotas illimités',
t('billing.enterpriseFeature2') || 'SSO / SAML',
t('billing.enterpriseFeature3') || 'Support dédié',
t('billing.enterpriseFeature4') || 'Facturation personnalisée',
t('billing.enterpriseFeature5') || 'SLA garanti',
t('billing.enterpriseFeature1'),
t('billing.enterpriseFeature2'),
t('billing.enterpriseFeature3'),
t('billing.enterpriseFeature4'),
t('billing.enterpriseFeature5'),
],
current: effectiveTier === 'ENTERPRISE',
buttonText: effectiveTier === 'ENTERPRISE' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.contactSales') || 'Contact Sales'),
@@ -307,18 +355,21 @@ export function BillingPlans() {
}
};
// Sommes pour l'usage global agrégé (donut)
let totalUsed = 0;
let totalLimit = 0;
if (quotas) {
Object.entries(quotas as Record<string, any>).forEach(([_, q]) => {
if (q.limit > 0 && q.limit !== Infinity) {
totalUsed += q.used;
totalLimit += q.limit;
}
});
}
const globalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
// Solde crédits global (modèle A) — pas la somme des anciens seaux
const unlimitedCredits = !!balance?.unlimited
const totalUsed = balance?.spentThisPeriod ?? 0
const remainingCredits = unlimitedCredits ? null : (balance?.totalRemaining ?? 0)
const granted = unlimitedCredits
? null
: (balance?.subscriptionGranted ?? 0) + (balance?.purchasedRemaining ?? 0)
// pot ≈ restants + déjà utilisés sur la fenêtre courante
const totalLimit = unlimitedCredits
? 0
: Math.max(granted ?? 0, totalUsed + (remainingCredits ?? 0))
const globalPct =
unlimitedCredits || totalLimit <= 0
? 0
: Math.min(100, (totalUsed / totalLimit) * 100)
// SVG Donut Config
const radius = 28;
@@ -450,79 +501,162 @@ export function BillingPlans() {
/>
</svg>
<div className="absolute flex flex-col items-center justify-center">
<span className="text-xl font-bold text-ink">{Math.round(globalPct)}%</span>
<span className="text-xl font-bold text-ink">
{unlimitedCredits ? '∞' : `${Math.round(globalPct)}%`}
</span>
<span className="text-[8px] text-concrete uppercase tracking-widest">{t('billing.used')}</span>
</div>
</div>
<div className="mt-4 text-center">
<p className="text-xs font-bold text-ink">
{totalUsed} <span className="text-concrete font-light">/ {totalLimit} {t('billing.aiCredits') || 'crédits IA'}</span>
{unlimitedCredits ? (
<span className="text-concrete font-light">{t('billing.unlimited')}</span>
) : (
<>
{totalUsed}{' '}
<span className="text-concrete font-light">
/ {totalLimit} {t('billing.aiCredits') || 'crédits IA'}
</span>
</>
)}
</p>
{!unlimitedCredits && remainingCredits != null && (
<p className="text-[10px] text-concrete mt-1">
{remainingCredits} {t('billing.creditsRemaining')?.toLowerCase() || 'restants'}
</p>
)}
{!unlimitedCredits && (balance?.purchasedRemaining ?? 0) > 0 && (
<p className="text-[10px] text-brand-accent mt-1">
{balance?.purchasedRemaining} {t('billing.creditsFromPacks')?.toLowerCase() || 'de packs'}
</p>
)}
</div>
</div>
</div>
{/* Usage breakdown per feature */}
{/* Packs de crédits (one-shot) */}
{!unlimitedCredits && billingEnabled && (status?.creditPacks?.length ?? 0) > 0 && (
<div className="space-y-6">
<div className="flex items-start gap-3">
<div className="p-2.5 bg-brand-accent/10 text-brand-accent rounded-2xl shrink-0">
<Coins size={20} />
</div>
<div>
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
{t('billing.packsTitle')}
</h3>
<p className="text-[11px] text-concrete mt-1 leading-relaxed max-w-xl">
{t('billing.packsDescription')}
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{(status?.creditPacks ?? []).map((pack) => (
<div
key={pack.id}
className="p-6 rounded-3xl border border-border bg-white/40 dark:bg-white/5 flex flex-col gap-4"
>
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
{t(`billing.pack${pack.id}Name`) || `Pack ${pack.id}`}
</p>
<p className="text-2xl font-serif font-bold text-ink mt-1 tabular-nums">
{pack.credits.toLocaleString()}
<span className="text-sm font-sans font-normal text-concrete ms-1">
{t('billing.aiCredits') || 'crédits'}
</span>
</p>
<p className="text-lg font-semibold text-ink mt-2">{pack.display}</p>
</div>
<button
type="button"
onClick={() => handleBuyPack(pack.id)}
disabled={!pack.configured || packLoading !== null}
className="mt-auto w-full py-3 rounded-2xl bg-ink text-white dark:bg-white dark:text-black text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 disabled:opacity-40 transition-all"
>
{packLoading === pack.id ? (
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
) : pack.configured ? (
t('billing.buyPack')
) : (
t('billing.packNotConfigured') || 'Config Stripe requise'
)}
</button>
</div>
))}
</div>
</div>
)}
{/* Répartition crédits par fonction */}
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
{t('billing.usageThisPeriod') || 'Utilisation sur cette période'}
{t('billing.creditsWhereTitle') || t('billing.usageThisPeriod')}
</h3>
<p className="text-[10px] text-concrete mt-1">
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
`${formatDate(status.currentPeriodStart)} ${formatDate(status.currentPeriodEnd)}`
) : (
'—'
)}
{t('billing.creditsUsageLegend')}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{!quotas && (
{!usageData && (
<div className="col-span-full flex items-center justify-center py-12 bg-white/40 dark:bg-white/5 border border-border rounded-3xl">
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
</div>
)}
{quotas && Object.entries(quotas as Record<string, any>).map(([key, q]) => {
const pct = q.limit > 0 && q.limit !== Infinity ? (q.used / q.limit) * 100 : 0;
const isUnlimited = q.limit === Infinity || q.limit <= 0;
const featureLabels: Record<string, string> = {
semantic_search: t('usageMeter.featureSearch') || 'Recherche Sémantique',
auto_tag: t('usageMeter.featureTags') || 'Tags Automatiques',
auto_title: t('usageMeter.featureTitles') || 'Titres Automatiques',
reformulate: t('usageMeter.featureReformulate') || 'Reformulation IA',
chat: t('usageMeter.featureChat') || 'Chat IA',
brainstorm_create: t('usageMeter.featureBrainstormCreate') || 'Création de Brainstorm',
brainstorm_expand: t('usageMeter.featureBrainstormExpand') || 'Expansion de Brainstorm',
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich') || 'Enrichissement de Brainstorm',
};
// Couleur de barre : normal (violet), warning >= 75% (ambre), exhausted >= 100% (rose)
const barFillColor = pct >= 100 ? 'bg-rose-400' : pct >= 75 ? 'bg-amber-400' : 'bg-gradient-to-r from-violet-400 to-purple-400';
return (
<div key={key} className="p-5 rounded-2xl bg-white/40 dark:bg-white/5 border border-border space-y-3">
<div className="flex justify-between items-center">
<span className="text-[11px] font-bold text-ink uppercase tracking-wider truncate">
{featureLabels[key] || key}
</span>
<span className="text-[11px] font-medium text-concrete tabular-nums">
{isUnlimited ? t('billing.unlimited') || 'Illimité' : `${q.used} / ${q.limit}`}
</span>
</div>
<div className="h-2 w-full bg-secondary/40 rounded-full overflow-hidden">
{usageData &&
(() => {
const featureLabels: Record<string, string> = {
semantic_search: t('usageMeter.featureSearch'),
auto_tag: t('usageMeter.featureTags'),
auto_title: t('usageMeter.featureTitles'),
reformulate: t('usageMeter.featureReformulate'),
chat: t('usageMeter.featureChat'),
brainstorm_create: t('usageMeter.featureBrainstormSessions'),
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
suggest_charts: t('usageMeter.featureCharts'),
publish_enhance: t('usageMeter.featurePublishEnhance'),
ai_flashcard: t('usageMeter.featureFlashcards'),
voice_transcribe: t('usageMeter.featureVoice'),
slide_generate: t('usageMeter.featureSlides'),
excalidraw_generate: t('usageMeter.featureDiagrams'),
}
const byFeature = new Map(breakdown.map((r) => [r.feature, r]))
return Object.keys(featureLabels).map((key) => {
const row = byFeature.get(key)
const used = row?.creditsUsed ?? 0
const pct = totalUsed > 0 && used > 0 ? (used / totalUsed) * 100 : 0
const barFillColor =
pct >= 40
? 'bg-gradient-to-r from-violet-400 to-purple-400'
: 'bg-gradient-to-r from-violet-300/80 to-purple-300/80'
return (
<div
className={cn('h-full rounded-full transition-all duration-500', barFillColor)}
style={{ width: `${isUnlimited ? 100 : Math.min(pct, 100)}%` }}
/>
</div>
</div>
);
})}
key={key}
className="p-5 rounded-2xl bg-white/40 dark:bg-white/5 border border-border space-y-3"
>
<div className="flex justify-between items-center gap-2">
<span className="text-[11px] font-bold text-ink uppercase tracking-wider truncate">
{featureLabels[key]}
</span>
<span className="text-[11px] font-medium text-concrete tabular-nums shrink-0">
{used > 0 ? `${used} cr.` : '0'}
</span>
</div>
<div className="h-2 w-full bg-secondary/40 rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', barFillColor)}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
</div>
)
})
})()}
</div>
</div>

View File

@@ -1,55 +1,84 @@
'use client';
'use client'
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'motion/react';
import { AlertTriangle, Sparkles, Key, X, ArrowRight } from 'lucide-react';
import { useLanguage } from '@/lib/i18n';
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'motion/react'
import { AlertTriangle, Sparkles, Key, X, ArrowRight } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
interface InlinePaywallProps {
feature: string;
onDismiss: () => void;
/** Action en cours (pour contexte uniquement — le solde est global) */
feature: string
onDismiss: () => void
/** Coût de l'action si connu */
creditCost?: number
/** Solde restant si connu */
creditsRemaining?: number
}
export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
const { t } = useLanguage();
const router = useRouter();
const [timeLeft, setTimeLeft] = useState(10);
export function InlinePaywall({
feature,
onDismiss,
creditCost,
creditsRemaining,
}: InlinePaywallProps) {
const { t } = useLanguage()
const router = useRouter()
const [timeLeft, setTimeLeft] = useState(12)
useEffect(() => {
if (timeLeft <= 0) {
onDismiss();
return;
onDismiss()
return
}
const timer = setTimeout(() => {
setTimeLeft((prev) => prev - 1);
}, 1000);
return () => clearTimeout(timer);
}, [timeLeft, onDismiss]);
setTimeLeft((prev) => prev - 1)
}, 1000)
return () => clearTimeout(timer)
}, [timeLeft, onDismiss])
const handleUpgrade = () => {
router.push('/settings/billing');
onDismiss();
};
router.push('/settings/billing')
onDismiss()
}
const handleByok = () => {
router.push('/settings/ai#byok');
onDismiss();
};
router.push('/settings/ai#byok')
onDismiss()
}
const featureLabels: Record<string, string> = {
semantic_search: t('usageMeter.featureSearch') || 'Recherche Sémantique',
auto_tag: t('usageMeter.featureTags') || 'Tags Automatiques',
auto_title: t('usageMeter.featureTitles') || 'Titres Automatiques',
reformulate: t('usageMeter.featureReformulate') || 'Reformulation',
chat: t('usageMeter.featureChat') || 'Chat IA',
brainstorm_create: t('usageMeter.featureBrainstormCreate') || 'Création de Brainstorm',
brainstorm_expand: t('usageMeter.featureBrainstormExpand') || 'Expansion de Brainstorm',
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich') || 'Enrichissement de Brainstorm',
publish_enhance: t('usageMeter.featurePublishEnhance') || 'Publication IA',
};
semantic_search: t('usageMeter.featureSearch'),
auto_tag: t('usageMeter.featureTags'),
auto_title: t('usageMeter.featureTitles'),
reformulate: t('usageMeter.featureReformulate'),
chat: t('usageMeter.featureChat'),
brainstorm_create: t('usageMeter.featureBrainstormSessions'),
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
publish_enhance: t('usageMeter.featurePublishEnhance'),
slide_generate: t('usageMeter.featureSlides'),
excalidraw_generate: t('usageMeter.featureDiagrams'),
ai_flashcard: t('usageMeter.featureFlashcards'),
voice_transcribe: t('usageMeter.featureVoice'),
credits: t('billing.aiCredits'),
}
const currentFeatureLabel = featureLabels[feature] || feature;
const currentFeatureLabel = featureLabels[feature] || feature
let description = t('quotaPaywall.description').replace('{feature}', currentFeatureLabel)
if (
typeof creditCost === 'number' &&
typeof creditsRemaining === 'number' &&
Number.isFinite(creditCost) &&
Number.isFinite(creditsRemaining)
) {
description = t('quotaPaywall.descriptionWithCost', {
remaining: String(creditsRemaining),
cost: String(creditCost),
feature: currentFeatureLabel,
})
}
return (
<AnimatePresence>
@@ -66,17 +95,17 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
</div>
<div className="flex-1 space-y-1">
<h4 className="text-sm font-bold text-rose-800 dark:text-rose-400">
{t('quotaPaywall.title') || 'Limite mensuelle atteinte'}
{t('quotaPaywall.title')}
</h4>
<p className="text-xs text-rose-700/80 dark:text-rose-400/70 font-light leading-relaxed">
{(t('quotaPaywall.description') || "Vous avez épuisé vos crédits pour la fonctionnalité {feature} ce mois-ci.").replace('{feature}', currentFeatureLabel)}
{description}
</p>
</div>
<button
type="button"
onClick={onDismiss}
className="text-rose-500/60 hover:text-rose-500 dark:text-rose-400/50 dark:hover:text-rose-400 p-1 hover:bg-rose-500/5 dark:hover:bg-rose-400/5 rounded-lg transition-all"
title={t('quotaPaywall.later') || 'Peut-être plus tard'}
title={t('quotaPaywall.later')}
>
<X size={16} />
</button>
@@ -89,17 +118,17 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[#D4A373] text-white hover:bg-[#C49363] text-xs font-semibold rounded-xl transition-all shadow-sm shadow-[#D4A373]/15"
>
<Sparkles size={14} />
{t('quotaPaywall.upgrade') || 'Passer au plan Pro'}
{t('quotaPaywall.upgrade')}
<ArrowRight size={12} className="ml-1" />
</button>
<button
type="button"
onClick={handleByok}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 border border-rose-300 dark:border-rose-800/40 text-rose-800 dark:text-rose-400 hover:bg-rose-100/30 dark:hover:bg-rose-400/5 text-xs font-semibold rounded-xl transition-all"
>
<Key size={14} />
{t('quotaPaywall.useOwnKey') || 'Utiliser ma propre clé'}
{t('quotaPaywall.useOwnKey')}
</button>
<div className="sm:ml-auto flex items-center justify-center gap-1.5 text-[10px] text-rose-500/60 dark:text-rose-400/40 font-mono">
@@ -108,5 +137,5 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
</div>
</motion.div>
</AnimatePresence>
);
)
}

View File

@@ -1,59 +1,88 @@
'use client';
'use client'
import { useQuery } from '@tanstack/react-query';
import { Loader2, BarChart2 } from 'lucide-react';
import { useLanguage } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { useQuery } from '@tanstack/react-query'
import { Loader2, BarChart2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
interface QuotaEntry {
remaining: number;
limit: number;
used: number;
interface BalanceData {
totalRemaining: number | null
subscriptionRemaining: number | null
purchasedRemaining: number
subscriptionGranted: number | null
spentThisPeriod: number
unlimited: boolean
}
type Quotas = Record<string, QuotaEntry>;
interface BreakdownRow {
feature: string
creditsUsed: number
actionsCount: number
percentOfSpent: number
}
/** Toutes les fonctions IA — même liste que le compteur sidebar. */
const FEATURE_LABEL_KEYS: Record<string, string> = {
aiSummary: 'sidebar.aiSummary',
aiFlashcards: 'sidebar.aiFlashcards',
aiMindmap: 'sidebar.aiMindmap',
aiTranscribe: 'sidebar.aiTranscribe',
aiDiagram: 'sidebar.aiDiagram',
aiAgent: 'sidebar.aiAgent',
semantic_search: 'usageMeter.featureSearch',
auto_tag: 'usageMeter.featureTags',
auto_title: 'usageMeter.featureTitles',
reformulate: 'usageMeter.featureReformulate',
chat: 'usageMeter.featureChat',
brainstorm_create: 'usageMeter.featureBrainstormSessions',
brainstorm_expand: 'usageMeter.featureBrainstormExpand',
brainstorm_enrich: 'usageMeter.featureBrainstormEnrich',
suggest_charts: 'usageMeter.featureCharts',
publish_enhance: 'usageMeter.featurePublishEnhance',
};
function UsageBar({ used, limit, isUnlimited }: { used: number; limit: number; isUnlimited: boolean }) {
const pct = isUnlimited ? 0 : Math.min(100, limit > 0 ? (used / limit) * 100 : 0);
const color =
pct >= 90 ? 'bg-rose-500' : pct >= 70 ? 'bg-amber-500' : 'bg-primary';
ai_flashcard: 'usageMeter.featureFlashcards',
voice_transcribe: 'usageMeter.featureVoice',
slide_generate: 'usageMeter.featureSlides',
excalidraw_generate: 'usageMeter.featureDiagrams',
}
function UsageBar({ percent }: { percent: number }) {
return (
<div className="h-1.5 w-full rounded-full bg-foreground/10 overflow-hidden">
{!isUnlimited && (
<div
className={cn('h-full rounded-full transition-all', color)}
style={{ width: `${pct}%` }}
/>
)}
<div
className={cn(
'h-full rounded-full transition-all',
percent >= 40 ? 'bg-primary' : 'bg-primary/60',
)}
style={{ width: `${Math.min(Math.max(percent, 0), 100)}%` }}
/>
</div>
);
)
}
export function UsageBreakdown() {
const { t } = useLanguage();
const { t } = useLanguage()
const { data, isLoading } = useQuery<{ quotas: Quotas }>({
const { data, isLoading } = useQuery({
queryKey: ['usage', 'current'],
queryFn: async () => {
const res = await fetch('/api/usage/current');
if (!res.ok) throw new Error('Failed to fetch usage');
return res.json();
const res = await fetch('/api/usage/current')
if (!res.ok) throw new Error('Failed to fetch usage')
return res.json() as Promise<{
balance: BalanceData
breakdown: BreakdownRow[]
period: string
tier: string
}>
},
});
})
const quotas = data?.quotas ?? {};
const entries = Object.entries(quotas);
const balance = data?.balance
const byFeature = new Map((data?.breakdown ?? []).map((r) => [r.feature, r]))
const spent = balance?.spentThisPeriod ?? 0
const rows = Object.keys(FEATURE_LABEL_KEYS).map((key) => {
const row = byFeature.get(key)
return {
feature: key,
label: t(FEATURE_LABEL_KEYS[key]),
creditsUsed: row?.creditsUsed ?? 0,
actionsCount: row?.actionsCount ?? 0,
}
})
return (
<div className="rounded-xl border border-border/40 bg-paper p-6 space-y-4">
@@ -68,33 +97,72 @@ export function UsageBreakdown() {
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : entries.length === 0 ? (
) : !balance ? (
<p className="text-xs text-muted-foreground py-2">{t('billing.noUsage')}</p>
) : (
<div className="space-y-3">
{entries.map(([feature, quota]) => {
const isUnlimited = quota.limit === -1 || !isFinite(quota.limit);
const labelKey = FEATURE_LABEL_KEYS[feature];
const label = labelKey ? t(labelKey) : feature;
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="rounded-lg border border-border/40 p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
{t('billing.creditsRemaining')}
</p>
<p className="text-lg font-semibold tabular-nums mt-1">
{balance.unlimited ? t('billing.unlimited') : balance.totalRemaining}
</p>
</div>
<div className="rounded-lg border border-border/40 p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
{t('billing.creditsSpent')}
</p>
<p className="text-lg font-semibold tabular-nums mt-1">
{balance.spentThisPeriod}
</p>
</div>
<div className="rounded-lg border border-border/40 p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
{t('billing.creditsFromPacks')}
</p>
<p className="text-lg font-semibold tabular-nums mt-1">
{balance.purchasedRemaining}
</p>
</div>
</div>
return (
<div key={feature} className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-xs font-medium text-foreground tabular-nums">
{isUnlimited ? (
<span className="text-primary/80 dark:text-primary">{t('billing.unlimited')}</span>
) : (
`${quota.used} / ${quota.limit}`
)}
</span>
<p className="text-xs font-medium text-foreground pt-1">
{t('billing.creditsWhereTitle')}
</p>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t('billing.creditsUsageLegend')}
</p>
<div className="space-y-3">
{rows.map((row) => {
const barPct =
spent > 0 && row.creditsUsed > 0
? Math.min(100, (row.creditsUsed / spent) * 100)
: 0
return (
<div key={row.feature} className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">{row.label}</span>
<span
className={cn(
'text-xs font-medium tabular-nums shrink-0',
row.creditsUsed > 0 ? 'text-foreground' : 'text-muted-foreground/60',
)}
>
{row.creditsUsed > 0
? `${row.creditsUsed} cr. · ${row.actionsCount}×`
: '0'}
</span>
</div>
<UsageBar percent={barPct} />
</div>
<UsageBar used={quota.used} limit={quota.limit} isUnlimited={isUnlimited} />
</div>
);
})}
</div>
)
})}
</div>
</>
)}
</div>
);
)
}

View File

@@ -40,7 +40,7 @@ import {
import { useSearchModal } from '@/context/search-modal-context'
import { useLanguage } from '@/lib/i18n'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { applyDocumentTheme } from '@/lib/apply-document-theme'
import { persistThemePreference } from '@/lib/apply-document-theme'
import { getAllNotes, getTrashCount, getNotesWithReminders, toggleReminderDone, getInboxCount } from '@/app/actions/notes'
import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
import { useNotebooks } from '@/context/notebooks-context'
@@ -48,6 +48,7 @@ import { Notebook, Note } from '@/lib/types'
import { toast } from 'sonner'
import { motion, AnimatePresence } from 'motion/react'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import { listAiCreatedNotebookIds, isRecentlyCreated } from '@/lib/ai-created-highlight'
import dynamic from 'next/dynamic'
const CreateNotebookDialog = dynamic(() => import('./create-notebook-dialog').then(m => ({ default: m.CreateNotebookDialog })), { ssr: false })
@@ -79,14 +80,20 @@ function NoteLink({
title,
isActive,
isPinned,
autoGenerated,
isRecent,
onClick,
}: {
title: string
isActive: boolean
isPinned?: boolean
/** Note créée par l'IA (agents, wizard, …) */
autoGenerated?: boolean
/** Créée récemment (< 48h) — point discret « nouveau » */
isRecent?: boolean
onClick: () => void
}) {
const { language } = useLanguage()
const { language, t } = useLanguage()
const slideX = language === 'fa' || language === 'ar' ? 10 : -10
return (
<motion.button
@@ -97,14 +104,38 @@ function NoteLink({
'w-full flex items-center gap-2 ps-6 pe-3 py-1.5 text-[11px] transition-all rounded-lg text-start',
isActive
? 'bg-white dark:bg-white/10 shadow-sm border border-border/50 text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground hover:bg-white/30 dark:hover:bg-white/5',
: autoGenerated
? 'text-foreground/90 hover:text-foreground hover:bg-brand-accent/5 border border-transparent'
: 'text-muted-foreground hover:text-foreground hover:bg-white/30 dark:hover:bg-white/5',
autoGenerated && !isActive && 'bg-brand-accent/[0.04]',
)}
title={autoGenerated ? (t('sidebar.aiGeneratedNote') || 'Note générée par l\'IA') : undefined}
>
<FileText
size={12}
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-muted-foreground/70')}
/>
{autoGenerated ? (
<Sparkles
size={12}
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-brand-accent/80')}
aria-hidden
/>
) : (
<FileText
size={12}
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-muted-foreground/70')}
/>
)}
<span className="truncate flex-1">{title}</span>
{autoGenerated && (
<span className="shrink-0 text-[8px] font-bold uppercase tracking-wider text-brand-accent/90 bg-brand-accent/10 px-1.5 py-0.5 rounded">
{t('sidebar.aiBadge') || 'IA'}
</span>
)}
{isRecent && !autoGenerated && (
<span
className="shrink-0 w-1.5 h-1.5 rounded-full bg-brand-accent"
title={t('sidebar.recentNote') || 'Récemment créée'}
aria-label={t('sidebar.recentNote') || 'Récemment créée'}
/>
)}
{isPinned && <Pin size={10} className="text-amber-500 fill-amber-500 shrink-0" />}
</motion.button>
)
@@ -347,6 +378,7 @@ function SidebarCarnetItem({
onDelete,
onTogglePin,
isPinned,
isAiCreated,
children,
isDragging,
dragHandleProps,
@@ -358,7 +390,7 @@ function SidebarCarnetItem({
}: {
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
isActive: boolean
notes: { id: string; title: string; isPinned?: boolean }[]
notes: { id: string; title: string; isPinned?: boolean; autoGenerated?: boolean; isRecent?: boolean }[]
activeNoteId: string | null
onCarnetClick: () => void
onNoteClick: (noteId: string, carnetId: string) => void
@@ -367,6 +399,8 @@ function SidebarCarnetItem({
onDelete: () => void
onTogglePin: () => void
isPinned: boolean
/** Carnet créé via wizard IA (repère temporaire) */
isAiCreated?: boolean
children?: React.ReactNode
isDragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>
@@ -436,8 +470,13 @@ function SidebarCarnetItem({
onDoubleClick={(e) => { e.stopPropagation(); onRename() }}
className={cn(
'flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative overflow-hidden',
isActive ? 'bg-white dark:bg-white/10 shadow-sm border border-border/40' : 'hover:bg-white/40 dark:hover:bg-white/5'
isActive
? 'bg-white dark:bg-white/10 shadow-sm border border-border/40'
: isAiCreated
? 'hover:bg-brand-accent/10 border border-brand-accent/20 bg-brand-accent/[0.06]'
: 'hover:bg-white/40 dark:hover:bg-white/5',
)}
title={isAiCreated ? (t('sidebar.aiGeneratedNotebook') || 'Carnet créé avec l\'IA') : undefined}
>
{isActive && (
<motion.div
@@ -446,6 +485,9 @@ function SidebarCarnetItem({
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
)}
{isAiCreated && !isActive && (
<span className="absolute -start-1 w-1 h-4 bg-brand-accent/70 rounded-full" aria-hidden />
)}
<div className={cn(
'w-5 h-5 flex items-center justify-center shrink-0 transition-colors',
isActive ? 'text-brand-accent' : 'text-muted-foreground/80',
@@ -460,6 +502,12 @@ function SidebarCarnetItem({
)}>
{carnet.name}
</span>
{isAiCreated && (
<span className="shrink-0 inline-flex items-center gap-0.5 text-[8px] font-bold uppercase tracking-wider text-brand-accent bg-brand-accent/15 px-1.5 py-0.5 rounded">
<Sparkles size={9} aria-hidden />
{t('sidebar.aiBadge') || 'IA'}
</span>
)}
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
{!isActive && hasActiveDescendant && (
<span className="w-1.5 h-1.5 rounded-full bg-brand-accent/70 shrink-0" />
@@ -573,6 +621,8 @@ function SidebarCarnetItem({
key={note.id}
title={note.title}
isPinned={note.isPinned}
autoGenerated={note.autoGenerated}
isRecent={note.isRecent}
isActive={activeNoteId === note.id}
onClick={() => onNoteClick(note.id, carnet.id)}
/>
@@ -626,16 +676,17 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const toggleTheme = useCallback(() => {
const next = !isDark
setIsDark(next)
const theme = next ? 'dark' : 'light'
localStorage.setItem('theme-preference', theme)
applyDocumentTheme(theme)
// Cookie + localStorage : le serveur doit connaître le dark, sinon flash clair
// à chaque changement de page (layout racine réécrit <html>).
persistThemePreference(next ? 'dark' : 'light')
}, [isDark])
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [isRenaming, setIsRenaming] = useState(false)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string; isPinned?: boolean }[]>>({})
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string; isPinned?: boolean; autoGenerated?: boolean; isRecent?: boolean }[]>>({})
const [aiNotebookIds, setAiNotebookIds] = useState<Set<string>>(() => new Set())
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
const [showSortMenu, setShowSortMenu] = useState(false)
@@ -886,6 +937,18 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const notebookIdsKey = useMemo(() => notebooks.map(nb => nb.id).sort().join(','), [notebooks])
// Repères carnets IA (localStorage TTL 7j)
useEffect(() => {
const refresh = () => setAiNotebookIds(listAiCreatedNotebookIds())
refresh()
window.addEventListener('memento-ai-created-changed', refresh)
window.addEventListener('storage', refresh)
return () => {
window.removeEventListener('memento-ai-created-changed', refresh)
window.removeEventListener('storage', refresh)
}
}, [])
useEffect(() => {
if (!notebookIdsKey) return
let cancelled = false
@@ -897,6 +960,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
id: n.id,
title: getNoteDisplayTitle(n, t('notes.untitled')),
isPinned: n.isPinned,
autoGenerated: Boolean(n.autoGenerated),
isRecent: isRecentlyCreated(n.createdAt, 48),
}))
return [nb.id, mapped] as const
})
@@ -1261,6 +1326,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
onDelete={() => setDeletingNotebook(notebook)}
onTogglePin={() => togglePin(notebook.id)}
isPinned={pinnedIds.has(notebook.id)}
isAiCreated={aiNotebookIds.has(notebook.id)}
isDragging={isDragging}
level={level}
isExpanded={isExpanded}
@@ -1273,7 +1339,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</motion.div>
)
})
}, [rootNotebooks, childNotebooks, filteredNotebookIds, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, pinnedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
}, [rootNotebooks, childNotebooks, filteredNotebookIds, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, pinnedIds, aiNotebookIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
return (
<>
@@ -1977,8 +2043,34 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{showAiWizard && (
<AiNotebookWizard
onClose={() => setShowAiWizard(false)}
onComplete={(notebookId: string) => {
onComplete={async (notebookId: string) => {
// 1) Marquer IA + expand dans la sidebar
try {
const { markAiCreatedNotebook } = await import('@/lib/ai-created-highlight')
markAiCreatedNotebook(notebookId)
} catch { /* ignore */ }
setAiNotebookIds((prev) => new Set(prev).add(notebookId))
setExpandedIds((prev) => {
const next = new Set(prev)
next.add(notebookId)
return next
})
// 2) Recharger la liste des carnets (sinon le carnet n'apparaît qu'après F5)
await refreshNotebooks()
// 3) Forcer le rechargement des notes de ce carnet dans la sidebar
try {
const notes = await getAllNotes(false, notebookId)
const mapped = notes.map((n: Note) => ({
id: n.id,
title: getNoteDisplayTitle(n, t('notes.untitled')),
isPinned: n.isPinned,
autoGenerated: Boolean(n.autoGenerated),
isRecent: true,
}))
setNotebookNotes((prev) => ({ ...prev, [notebookId]: mapped }))
} catch { /* ignore */ }
setShowAiWizard(false)
// 4) Ouvrir le carnet
router.push(`/home?notebook=${notebookId}`)
}}
/>

View File

@@ -0,0 +1,16 @@
'use client'
import { useEffect } from 'react'
/** Désenregistre danciens service workers (remplace /scripts/sw-cleanup.js). */
export function SwCleanup() {
useEffect(() => {
if (!('serviceWorker' in navigator)) return
void navigator.serviceWorker.getRegistrations().then((rs) => {
rs.forEach((r) => {
void r.unregister()
})
})
}, [])
return null
}

View File

@@ -0,0 +1,102 @@
'use client'
import { useLayoutEffect } from 'react'
import { persistThemePreference, normalizeThemeId } from '@/lib/apply-document-theme'
/**
* Empêche le flash clair en mode sombre.
*
* Au changement de route, le layout racine Next peut réécrire la classe de
* <html> depuis le serveur (souvent « light »). Sans garde, une frame claire
* apparaît avant le prochain effet React.
*
* useLayoutEffect = avant le paint navigateur.
* MutationObserver = remet le thème si React retire « dark » à tort.
*/
export function ThemeClassGuard() {
useLayoutEffect(() => {
let applying = false
const preferredTheme = (): string => {
try {
const local = localStorage.getItem('theme-preference')
if (local) return normalizeThemeId(local)
} catch {
/* localStorage indisponible */
}
const server =
document.documentElement.getAttribute('data-server-theme') || 'light'
return normalizeThemeId(server)
}
const wantsDarkClass = (theme: string): boolean => {
const t = normalizeThemeId(theme)
if (t === 'dark' || t === 'midnight') return true
if (t === 'auto') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
return false
}
const sync = () => {
if (applying) return
applying = true
try {
persistThemePreference(preferredTheme())
} finally {
// Laisser le navigateur appliquer le DOM avant de réarmer
queueMicrotask(() => {
applying = false
})
}
}
// Immédiat (avant paint)
sync()
// Après commit React éventuel qui a écrasé <html>
const raf1 = requestAnimationFrame(() => {
sync()
requestAnimationFrame(sync)
})
const root = document.documentElement
const observer = new MutationObserver(() => {
if (applying) return
const theme = preferredTheme()
const needDark = wantsDarkClass(theme)
const hasDark = root.classList.contains('dark')
if (needDark && !hasDark) {
sync()
return
}
// data-theme retiré à tort pour une palette nommée
if (
theme !== 'light' &&
theme !== 'dark' &&
theme !== 'auto' &&
root.getAttribute('data-theme') !== theme
) {
sync()
}
})
observer.observe(root, {
attributes: true,
attributeFilter: ['class', 'data-theme'],
})
const onStorage = (e: StorageEvent) => {
if (e.key === 'theme-preference') sync()
}
window.addEventListener('storage', onStorage)
return () => {
cancelAnimationFrame(raf1)
observer.disconnect()
window.removeEventListener('storage', onStorage)
}
}, [])
return null
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect } from 'react'
import { applyDocumentTheme, normalizeThemeId } from '@/lib/apply-document-theme'
import { useLayoutEffect } from 'react'
import { applyDocumentTheme, normalizeThemeId, persistThemePreference } from '@/lib/apply-document-theme'
interface ThemeInitializerProps {
theme?: string
@@ -11,7 +11,10 @@ interface ThemeInitializerProps {
}
export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: ThemeInitializerProps) {
useEffect(() => {
// useLayoutEffect : avant le paint (useEffect = trop tard = flash clair)
useLayoutEffect(() => {
const root = document.documentElement
const applyFontSize = (s?: string) => {
const size = s || 'medium'
@@ -29,25 +32,33 @@ export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: T
'extra-large': 1.25,
}
const root = document.documentElement
root.style.setProperty('--user-font-size', fontSizeMap[size] || '16px')
root.style.setProperty('--user-font-size-factor', (fontSizeFactorMap[size] || 1).toString())
}
// Préférence locale prioritaire (jamais écrasée par un rendu serveur « light »)
const localTheme = localStorage.getItem('theme-preference')
const effectiveTheme = localTheme || theme || 'light'
const effectiveTheme = normalizeThemeId(localTheme || theme || 'light')
// Appliquer le thème AVANT paint (cookie + class dark atomique)
applyDocumentTheme(effectiveTheme)
persistThemePreference(effectiveTheme)
if (!localTheme && theme) {
localStorage.setItem('theme-preference', normalizeThemeId(theme))
// color-scheme navigateur (inputs, scrollbars) — évite flash chrome clair
try {
const dark =
effectiveTheme === 'dark' ||
effectiveTheme === 'midnight' ||
(effectiveTheme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
root.style.colorScheme = dark ? 'dark' : 'light'
} catch {
/* ignore */
}
applyFontSize(fontSize)
const localFontFamily = localStorage.getItem('font-family')
const effectiveFontFamily = localFontFamily || fontFamily || 'inter'
const root = document.documentElement
root.classList.remove('font-system', 'font-playfair', 'font-jetbrains')
if (effectiveFontFamily === 'system') root.classList.add('font-system')
if (effectiveFontFamily === 'playfair') root.classList.add('font-playfair')
@@ -58,7 +69,7 @@ export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: T
const localAccent = localStorage.getItem('accent-color')
const effectiveAccent = localAccent || accentColor || '#A47148'
document.documentElement.style.setProperty('--color-brand-accent', effectiveAccent)
root.style.setProperty('--color-brand-accent', effectiveAccent)
if (!localAccent && accentColor) {
localStorage.setItem('accent-color', accentColor)
}

View File

@@ -1,123 +1,128 @@
'use client';
'use client'
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { Sparkles, ChevronDown, X, Crown } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { useLanguage } from '@/lib/i18n';
import { useState, useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { cn } from '@/lib/utils'
import { Sparkles, ChevronDown, X, Crown } from 'lucide-react'
import { motion, AnimatePresence } from 'motion/react'
import { useLanguage } from '@/lib/i18n'
interface QuotaData {
remaining: number;
limit: number;
used: number;
interface BalanceData {
totalRemaining: number | null
subscriptionRemaining: number | null
purchasedRemaining: number
subscriptionGranted: number | null
subscriptionSpent: number
spentThisPeriod: number
unlimited: boolean
}
interface BreakdownRow {
feature: string
creditsUsed: number
actionsCount: number
percentOfSpent: number
}
interface UsageMeterProps {
className?: string;
className?: string
}
/** Même liste que l'ancien compteur — toutes les actions IA, pas seulement les slides. */
const FEATURE_LABEL_KEYS: Record<string, string> = {
semantic_search: 'usageMeter.featureSearch',
auto_tag: 'usageMeter.featureTags',
auto_title: 'usageMeter.featureTitles',
reformulate: 'usageMeter.featureReformulate',
chat: 'usageMeter.featureChat',
brainstorm_create: 'usageMeter.featureBrainstormSessions',
brainstorm_expand: 'usageMeter.featureBrainstormExpand',
brainstorm_enrich: 'usageMeter.featureBrainstormEnrich',
suggest_charts: 'usageMeter.featureCharts',
publish_enhance: 'usageMeter.featurePublishEnhance',
ai_flashcard: 'usageMeter.featureFlashcards',
voice_transcribe: 'usageMeter.featureVoice',
slide_generate: 'usageMeter.featureSlides',
excalidraw_generate: 'usageMeter.featureDiagrams',
}
export function UsageMeter({ className }: UsageMeterProps) {
const { t } = useLanguage();
const router = useRouter();
const queryClient = useQueryClient();
const [expanded, setExpanded] = useState(false);
const [showModal, setShowModal] = useState(false);
const { t } = useLanguage()
const router = useRouter()
const queryClient = useQueryClient()
const [expanded, setExpanded] = useState(false)
const [showModal, setShowModal] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['usage', 'current'],
queryFn: async () => {
const res = await fetch('/api/usage/current');
if (!res.ok) throw new Error('Failed to fetch quotas');
const json = await res.json();
return { quotas: json.quotas as Record<string, QuotaData>, tier: json.tier as string };
const res = await fetch('/api/usage/current')
if (!res.ok) throw new Error('Failed to fetch usage')
const json = await res.json()
return {
tier: json.tier as string,
period: json.period as string,
balance: json.balance as BalanceData,
breakdown: (json.breakdown ?? []) as BreakdownRow[],
}
},
staleTime: 0,
refetchInterval: 5000,
});
})
useEffect(() => {
const handler = () => queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
window.addEventListener('ai-usage-changed', handler);
return () => window.removeEventListener('ai-usage-changed', handler);
}, [queryClient]);
const handler = () => queryClient.invalidateQueries({ queryKey: ['usage', 'current'] })
window.addEventListener('ai-usage-changed', handler)
return () => window.removeEventListener('ai-usage-changed', handler)
}, [queryClient])
if (isLoading || !data || !data.quotas) {
if (isLoading || !data?.balance) {
return (
<div className={cn('px-2 py-2', className)}>
<div className="h-8 rounded-lg bg-paper/50 animate-pulse" />
</div>
);
)
}
const getPackLabel = () => {
if (!data.tier) return t('usageMeter.packName');
if (!data.tier) return t('usageMeter.packName')
switch (data.tier) {
case 'PRO': return t('usageMeter.packPro');
case 'BUSINESS': return t('usageMeter.packBusiness');
case 'ENTERPRISE': return t('usageMeter.packEnterprise');
default: return t('usageMeter.packName');
case 'PRO':
return t('usageMeter.packPro')
case 'BUSINESS':
return t('usageMeter.packBusiness')
case 'ENTERPRISE':
return t('usageMeter.packEnterprise')
default:
return t('usageMeter.packName')
}
};
}
const isProPlus = data.tier && data.tier !== 'BASIC';
const isProPlus = data.tier && data.tier !== 'BASIC'
const unlimited = !!data.balance.unlimited
const remaining = unlimited ? Infinity : (data.balance.totalRemaining ?? 0)
const granted = unlimited ? Infinity : (data.balance.subscriptionGranted ?? 0)
const spent = data.balance.spentThisPeriod ?? 0
// Barre = part du forfait consommée (crédits utilisés / allocation du mois)
const totalPct =
!unlimited && Number.isFinite(granted) && granted > 0
? Math.min(100, (spent / granted) * 100)
: 0
const isExhausted = !unlimited && remaining <= 0
// Features visibles dans l'UI (les features techniques comme expand/enrich sont cachées)
const VISIBLE_FEATURES = new Set([
'semantic_search',
'auto_tag',
'auto_title',
'reformulate',
'chat',
'brainstorm_create',
'brainstorm_expand',
'brainstorm_enrich',
'suggest_charts',
'publish_enhance',
'ai_flashcard',
'voice_transcribe',
'slide_generate',
'excalidraw_generate',
]);
const featureLabels: Record<string, string> = {
semantic_search: t('usageMeter.featureSearch'),
auto_tag: t('usageMeter.featureTags'),
auto_title: t('usageMeter.featureTitles'),
reformulate: t('usageMeter.featureReformulate'),
chat: t('usageMeter.featureChat'),
brainstorm_create: t('usageMeter.featureBrainstormSessions'),
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
suggest_charts: t('usageMeter.featureCharts'),
publish_enhance: t('usageMeter.featurePublishEnhance'),
ai_flashcard: t('usageMeter.featureFlashcards'),
voice_transcribe: t('usageMeter.featureVoice'),
slide_generate: t('usageMeter.featureSlides'),
excalidraw_generate: t('usageMeter.featureDiagrams'),
};
const featureQuotas = Object.entries(data.quotas)
.filter(([key, q]) => q.limit > 0 && VISIBLE_FEATURES.has(key))
.map(([key, quota]) => ({
key: key === 'brainstorm_create' ? 'brainstorm_sessions' : key, // Renommer pour l'affichage
label: featureLabels[key] || key,
used: quota.used,
limit: quota.limit,
remaining: quota.remaining,
}));
const isUnlimited = featureQuotas.every((f) => !Number.isFinite(f.limit));
const totalUsed = featureQuotas.reduce(
(sum, f) => sum + (Number.isFinite(f.used) ? f.used : 0), 0,
);
const totalLimit = featureQuotas.reduce(
(sum, f) => sum + (Number.isFinite(f.limit) ? f.limit : 0), 0,
);
const totalRemaining = totalLimit - totalUsed;
const totalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
const isExhausted = !isUnlimited && totalRemaining <= 0;
// Map breakdown → toutes les features du tableau (ordre API = déjà trié, on garde l'ordre labels)
const byFeature = new Map(data.breakdown.map((r) => [r.feature, r]))
const featureRows = Object.keys(FEATURE_LABEL_KEYS).map((key) => {
const row = byFeature.get(key)
return {
key,
label: t(FEATURE_LABEL_KEYS[key]),
creditsUsed: row?.creditsUsed ?? 0,
actionsCount: row?.actionsCount ?? 0,
percent: row?.percentOfSpent ?? 0,
}
})
return (
<>
@@ -134,39 +139,54 @@ export function UsageMeter({ className }: UsageMeterProps) {
'shrink-0',
isExhausted
? 'text-rose-400 fill-rose-400/20'
: isUnlimited
: unlimited
? 'text-emerald-400 fill-emerald-400/20'
: 'text-brand-accent fill-brand-accent/20'
: 'text-brand-accent fill-brand-accent/20',
)}
/>
<span className="text-[11px] font-bold text-ink/70">{getPackLabel()}</span>
{!isUnlimited && (
{!unlimited && (
<>
<div className="flex-1 h-1 bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden mx-2">
<div
className={cn(
'h-full rounded-full',
totalPct >= 90 ? 'bg-rose-400' : totalPct >= 70 ? 'bg-amber-400' : 'bg-brand-accent',
totalPct >= 90
? 'bg-rose-400'
: totalPct >= 70
? 'bg-amber-400'
: 'bg-brand-accent',
)}
style={{ width: `${Math.min(totalPct, 100)}%` }}
/>
</div>
<span className={cn(
'text-[10px] font-medium tabular-nums shrink-0',
totalPct >= 90 ? 'text-rose-500' : totalPct >= 70 ? 'text-amber-500' : 'text-concrete'
)}>
{totalRemaining}
<span
className={cn(
'text-[10px] font-medium tabular-nums shrink-0',
totalPct >= 90
? 'text-rose-500'
: totalPct >= 70
? 'text-amber-500'
: 'text-concrete',
)}
title={t('usageMeter.creditsRemaining')}
>
{remaining}
</span>
</>
)}
{isUnlimited && (
<span className="text-[10px] font-medium text-emerald-500 ml-auto">{t('usageMeter.unlimited')}</span>
{unlimited && (
<span className="text-[10px] font-medium text-emerald-500 ml-auto">
{t('usageMeter.unlimited')}
</span>
)}
{isProPlus && !isUnlimited && (
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest ml-auto">{data.tier}</span>
{isProPlus && !unlimited && (
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest ml-auto">
{data.tier}
</span>
)}
<ChevronDown
@@ -189,37 +209,76 @@ export function UsageMeter({ className }: UsageMeterProps) {
>
<div className="px-3 pb-3 space-y-2">
<div className="border-t border-border/40 pt-2" />
{featureQuotas.map((f) => {
const fPct = Number.isFinite(f.limit) && f.limit > 0 ? (f.used / f.limit) * 100 : 0
<p className="text-[9px] font-bold uppercase tracking-widest text-concrete">
{t('usageMeter.creditsWhereTitle')}
</p>
{!unlimited && (
<p className="text-[10px] text-muted-foreground">
{t('usageMeter.creditsSummary', {
remaining: String(remaining),
spent: String(spent),
granted: String(granted),
})}
</p>
)}
{featureRows.map((f) => {
// Barre = part de la conso totale, ou 0 si rien
const barPct =
spent > 0 && f.creditsUsed > 0
? Math.min(100, (f.creditsUsed / spent) * 100)
: 0
return (
<div key={f.key} className="space-y-1">
<div className="flex justify-between text-[10px]">
<div className="flex justify-between text-[10px] gap-2">
<span className="text-concrete truncate">{f.label}</span>
<span className={cn(
'font-medium tabular-nums',
fPct >= 90 ? 'text-rose-500' : fPct >= 70 ? 'text-amber-500' : 'text-ink/60'
)}>
{!Number.isFinite(f.remaining) ? '∞' : f.remaining}/{!Number.isFinite(f.limit) ? '∞' : f.limit}
<span
className={cn(
'font-medium tabular-nums shrink-0',
f.creditsUsed > 0 ? 'text-ink/70' : 'text-concrete/50',
)}
>
{f.creditsUsed > 0
? t('usageMeter.creditsUsedLine', {
credits: String(f.creditsUsed),
actions: String(f.actionsCount),
})
: '0'}
</span>
</div>
{Number.isFinite(f.limit) && (
<div className="h-1 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-300',
fPct >= 90 ? 'bg-rose-400' : fPct >= 70 ? 'bg-amber-400' : 'bg-brand-accent',
)}
style={{ width: `${Math.min(fPct, 100)}%` }}
/>
</div>
)}
<div className="h-1 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-300',
barPct >= 40 ? 'bg-brand-accent' : 'bg-brand-accent/50',
)}
style={{ width: `${barPct}%` }}
/>
</div>
</div>
)
})}
{!isProPlus && (
{isExhausted && (
<button
onClick={(e) => { e.stopPropagation(); router.push('/settings/billing'); }}
type="button"
onClick={(e) => {
e.stopPropagation()
setShowModal(true)
}}
className="w-full mt-1 py-2 bg-rose-500/10 hover:bg-rose-500 text-rose-600 hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all border border-rose-500/20"
>
{t('usageMeter.outOfCredits')}
</button>
)}
{!isProPlus && !isExhausted && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
router.push('/settings/billing')
}}
className="w-full mt-2 py-2 bg-brand-accent/10 hover:bg-brand-accent text-brand-accent hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all duration-300 border border-brand-accent/20"
>
{t('usageMeter.upgradePricing')}
@@ -253,44 +312,31 @@ export function UsageMeter({ className }: UsageMeterProps) {
{t('usageMeter.upgradeDescription')}
</p>
<div className="space-y-2 mb-4">
<div className="text-[11px] font-medium text-ink">{t('usageMeter.proIncludes')}</div>
<ul className="text-[11px] text-muted-ink space-y-1">
<li> {t('usageMeter.proSearch')}</li>
<li> {t('usageMeter.proTags')}</li>
<li> {t('usageMeter.proTitles')}</li>
<li> {t('usageMeter.proReformulate')}</li>
<li> {t('usageMeter.proChat')}</li>
</ul>
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 px-3 py-2 text-[12px] rounded-xl border border-border hover:bg-foreground/5 transition-colors"
>
{t('usageMeter.later')}
</button>
<a
href="/settings/billing"
className="flex-1 px-3 py-2 text-[12px] font-medium rounded-xl bg-brand-accent text-white text-center hover:opacity-90 transition-colors"
>
{t('usageMeter.upgradePricing')}
</a>
</div>
<a
href="/settings/billing"
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl bg-brand-accent text-white text-center hover:opacity-90 transition-colors"
>
{t('usageMeter.upgradePricing')}
</a>
<a
href="/settings/ai#byok"
onClick={() => setShowModal(false)}
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl border border-brand-accent/40 text-brand-accent dark:text-brand-accent text-center hover:bg-brand-accent/10 transition-colors"
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl border border-brand-accent/40 text-brand-accent text-center hover:bg-brand-accent/10 transition-colors"
>
{t('usageMeter.addApiKey')}
</a>
<button
type="button"
onClick={() => setShowModal(false)}
className="w-full px-3 py-2 text-[12px] rounded-xl border border-border hover:bg-foreground/5 transition-colors"
>
{t('usageMeter.later')}
</button>
</div>
</div>
</div>
)}
</>
);
)
}

View File

@@ -1,10 +1,11 @@
'use client'
import { useState } from 'react'
import { useState, useRef } from 'react'
import { GraduationCap, BookOpen, Wrench, Sparkles, Loader2, X, ArrowRight, ArrowLeft, Check, FileText } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { markAiCreatedNotebook } from '@/lib/ai-created-highlight'
type WizardProfile = 'student' | 'teacher' | 'engineer'
@@ -42,7 +43,7 @@ const PROFILES: ProfileOption[] = [
const LEVELS = ['wizard.levelBeginner', 'wizard.levelIntermediate', 'wizard.levelAdvanced', 'wizard.levelExpert']
export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; onComplete: (notebookId: string) => void }) {
export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; onComplete: (notebookId: string) => void | Promise<void> }) {
const { t, language } = useLanguage()
const [step, setStep] = useState<0 | 1 | 2>(0)
const [profile, setProfile] = useState<WizardProfile | null>(null)
@@ -52,6 +53,25 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
const [loading, setLoading] = useState(false)
const [progressMsg, setProgressMsg] = useState('')
const [success, setSuccess] = useState<{ notebookId: string; notebookName: string; noteTitles: string[] } | null>(null)
const [opening, setOpening] = useState(false)
const completedRef = useRef(false)
const finishAndOpen = async (notebookId: string, notebookName: string) => {
if (completedRef.current) return
completedRef.current = true
setOpening(true)
try {
markAiCreatedNotebook(notebookId)
const toastMsg = (t('wizard.createdToast') || 'Carnet « {name} » prêt — notes IA disponibles')
.replace('{name}', notebookName)
toast.success(toastMsg)
await onComplete(notebookId)
} catch (e: any) {
completedRef.current = false
setOpening(false)
toast.error(e?.message || t('general.error') || 'Erreur')
}
}
const handleSubmit = async () => {
if (!profile || !topic.trim()) return
@@ -68,7 +88,6 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
level: t(LEVELS[level]),
count,
language,
notebookName: topic.trim(),
}),
})
@@ -87,14 +106,16 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
return
}
setProgressMsg(t('wizard.progressCreating') || 'Création du carnet et des notes...')
const notebookId = data.notebookId as string
const notebookName = (data.notebookName || topic) as string
setSuccess({
notebookId: data.notebookId,
notebookName: data.notebookName || topic,
notebookId,
notebookName,
noteTitles: data.noteTitles || [],
})
setLoading(false)
// Ouvre tout de suite : refresh sidebar + navigation (plus besoin de F5)
void finishAndOpen(notebookId, notebookName)
} catch (e: any) {
toast.error(e.message || 'Erreur')
setLoading(false)
@@ -159,12 +180,19 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
))}
</div>
<button
onClick={() => onComplete(success.notebookId)}
className="flex items-center gap-2 px-5 py-2.5 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors font-medium"
type="button"
disabled={opening}
onClick={() => void finishAndOpen(success.notebookId, success.notebookName)}
className="flex items-center gap-2 px-5 py-2.5 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors font-medium disabled:opacity-60"
>
<Sparkles className="h-4 w-4" />
{t('wizard.openNotebook') || 'Ouvrir le carnet'}
{opening ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
{opening
? (t('wizard.openingNotebook') || 'Ouverture…')
: (t('wizard.openNotebook') || 'Ouvrir le carnet')}
</button>
<p className="text-[11px] text-muted-foreground text-center max-w-sm">
{t('wizard.autoOpenHint') || 'Ouverture automatique du carnet et mise à jour de la barre latérale…'}
</p>
</div>
) : loading ? (
<div className="flex flex-col items-center justify-center py-16 gap-4">

View File

@@ -1,161 +1,142 @@
# Guide d'utilisation — Admin Facturation & Quotas
# Guide admin — Facturation et crédits IA
## Objectif
La page **Admin > Facturation & Quotas** permet de gérer, sans redéploiement :
La page **Admin Facturation & crédits IA** sert à :
- les limites mensuelles par fonctionnalité IA (par tier)
- les Price IDs Stripe utilisés par le checkout
- l'activation/désactivation de l'interface de facturation
- un aperçu d'usage (requêtes/tokens)
1. Configurer Stripe (prix dabonnement + **packs de crédits** one-shot, activation)
2. **Comprendre le solde de crédits** par palier (source de vérité du débit)
3. Consulter un aperçu dusage agrégé
Le **débit réel** des utilisateurs nest **pas** un plafond par fonction (chat 50, slides 20…).
Cest un **pot unique de crédits** (modèle A, option M).
---
## Accès
1. Connectez-vous avec un compte **ADMIN**.
2. Ouvrez la console admin (`/admin`).
3. Cliquez sur **Facturation & quotas** dans la sidebar.
1. Compte **ADMIN**
2. Console admin **Facturation & crédits IA**
> Si vous n'êtes pas admin, les actions sont refusées côté serveur.
Les actions non-admin sont refusées côté serveur.
---
## Section 1 — Config Stripe (métier)
## 1. Config Stripe (métier)
Cette section sert à configurer les éléments business de Stripe :
### Abonnements
- `STRIPE_PRICE_PRO_MONTHLY`
- `STRIPE_PRICE_PRO_ANNUAL`
- `STRIPE_PRICE_BUSINESS_MONTHLY`
- `STRIPE_PRICE_BUSINESS_ANNUAL`
- toggle **Activer la facturation**
- `STRIPE_PRICE_PRO_MONTHLY` / `ANNUAL`
- `STRIPE_PRICE_BUSINESS_MONTHLY` / `ANNUAL`
- Case **Activer la facturation**
### Procédure
### Packs de crédits (paiement unique)
1. Renseignez/mettez à jour les `price_...` IDs.
2. Activez ou désactivez la facturation via la case.
3. Cliquez sur **Enregistrer la config**.
Produits Stripe en **mode payment** (one-shot), **pas** abonnement :
### Important sécurité
| Clé admin | Pack | Crédits | Prix catalogue (défaut UI) |
|-----------|------|---------|----------------------------|
| `STRIPE_PRICE_CREDITS_S` | S | **100** | 4,90 € |
| `STRIPE_PRICE_CREDITS_M` | M | **500** | 19,90 € |
| `STRIPE_PRICE_CREDITS_L` | L | **2 000** | 49,90 € |
- `STRIPE_SECRET_KEY` et `STRIPE_WEBHOOK_SECRET` **ne sont pas gérés ici**.
- Ils doivent rester dans les secrets d'environnement serveur (Docker/env/vault).
Dans Stripe Dashboard :
1. Créer 3 **Products** one-time (ex. « Memento crédits S / M / L »)
2. Prix uniques (EUR)
3. Copier les `price_...` dans Admin → Facturation
4. Webhook : événement `checkout.session.completed` déjà géré (mode `payment` + metadata `type=credit_pack`)
Les secrets (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`) restent dans lenvironnement serveur (Docker), **jamais** dans cette page.
---
## Section 2 — Quotas par palier
## 2. Crédits IA par palier (source de vérité)
Vous pouvez définir, pour chaque tier (**BASIC**, **PRO**, **BUSINESS**, **ENTERPRISE**) :
### Allocations mensuelles (option M)
- **Indisponible** : fonctionnalité non accessible
- **Limité** : plafond mensuel (nombre entier >= 0)
- **Illimité** : pas de plafond
| Palier | Crédits / mois | Notes |
|--------|----------------|--------|
| **BASIC** | **100** | Découverte |
| **PRO** | **1 000** | Aligné marché type « ~1 000 crédits » Pro |
| **BUSINESS** | **4 000** | Usage intensif / équipe |
| **ENTERPRISE** | **Illimité** | Contrat |
### Procédure
Ces valeurs sont définies dans le code : `lib/credits.ts``CREDIT_ALLOCATIONS`.
Elles sappliquent au premier usage / grant de période, et après **reset** utilisateur.
1. Cliquez sur le tier ciblé.
2. Pour chaque fonctionnalité :
- choisissez le mode d'accès
- si mode **Limité**, entrez la valeur mensuelle
3. Cliquez sur **Enregistrer** sur la ligne concernée.
### Packs achetés
### Délai d'effet
- Crédités via `addPurchasedCredits` (ledger `PURCHASE_PACK`)
- Stockés dans `purchasedBalance`**reportables** dun mois à lautre
- Consommés **après** lallocation abonnement du mois
Les changements sont appliqués rapidement (cache), en général sous **~60 secondes**.
### Coûts typiques (extrait)
| Action | Coût |
|--------|------|
| Recherche, chat, étiquettes, titres, reformulation… | **1** crédit |
| Flashcards, publication IA | **3** |
| Diagramme Excalidraw | **4** |
| Création brainstorm | **5** |
| **Présentation** (note) | **1 + N** (N = nombre de diapositives, 38) |
Exemple : 7 diapositives ≈ **8 crédits**.
### Réinitialiser un utilisateur
**Admin → Utilisateurs** → bouton **réinitialiser les crédits** sur la ligne du compte.
Cela recharge lallocation du palier pour le mois en cours (journal `ADMIN_RESET`). Les packs achetés sont **conservés** par défaut.
### Clé personnelle (BYOK)
Si lutilisateur a une clé active pour la voie concernée (chat / tags / embeddings), lusage **ne débite en général pas** le solde Memento.
---
## Section 3 — Aperçu consommation
## 3. Aperçu consommation
La section affiche :
- Agrégats par fonction (historique PostgreSQL / sync)
- Top utilisateurs
- Sync : cron `/api/cron/sync-usage` (compteurs legacy éventuels)
- usage par fonctionnalité (requêtes + tokens)
- top utilisateurs
- date de dernière synchronisation
### Source des données
- Compteurs temps réel : Redis
- Consolidation historique : table `UsageLog`
- Sync via cron (`/api/cron/sync-usage`)
Si la synchronisation n'a pas encore tourné, l'interface peut indiquer un état non synchronisé.
Le compteur **utilisateur** (sidebar / facturation) affiche le **solde de crédits** et la **répartition par fonction** (journal des consommations).
---
## Cas d'usage fréquents
## 4. Côté produit (rappel)
## 1) Augmenter temporairement le quota chat de PRO
1. Tier: **PRO**
2. Ligne `chat` -> mode **Limité**
3. Mettre par exemple `50 -> 100`
4. **Enregistrer**
## 2) Désactiver la vente (maintenance Stripe)
1. Décochez **Activer la facturation**
2. **Enregistrer la config**
3. Vérifiez côté utilisateur: `/settings/billing` montre l'état désactivé
## 3) Rendre une feature inaccessible en BASIC
1. Tier: **BASIC**
2. Ligne feature -> mode **Indisponible**
3. **Enregistrer**
| Surface | Message attendu |
|---------|-----------------|
| Landing / tarifs | Basic 100 · Pro 1 000 · Business 4 000 crédits / mois |
| Paramètres facturation | Même grille + **acheter des packs** (S/M/L) |
| Sidebar | Solde restant + tableau multi-fonctions |
| Publication IA / diagramme | Hint coût (3 / 4 crédits) |
| Fin de solde | Plan supérieur, pack de crédits, ou clé personnelle |
---
## Validation rapide après changement
## 5. Délai deffet
Après une modification importante, vérifier :
1. Le toast de succès en admin
2. Le comportement côté utilisateur (`/settings/billing` / appels IA)
3. Les logs serveur en cas d'erreur
4. L'`AuditLog` pour tracer qui a modifié quoi
- Allocations code : au prochain grant / reset / appel `ensureCreditAccount`
- Config Stripe (abos + packs) : immédiat après enregistrement
- Crédit pack après paiement : webhook `checkout.session.completed` (+ filet de sécurité sur `/api/billing/status?session_id=`)
---
## Dépannage
## 6. Fichiers techniques de référence
## "Le bouton Enregistrer ne semble rien faire"
- Vérifiez que vous êtes bien connecté en ADMIN.
- Regardez le toast d'erreur.
- Vérifiez les logs serveur (action rejetée, validation invalide, etc.).
## "Les quotas ne changent pas immédiatement"
- Attendez ~60 secondes (cache).
- Relancez une action IA qui consomme réellement le quota.
- Vérifiez que la feature est bien en mode attendu (indisponible/limité/illimité).
## "Le checkout ne démarre pas"
- Vérifiez les 4 `STRIPE_PRICE_*` IDs.
- Vérifiez que la facturation est activée.
- Vérifiez que les secrets Stripe serveur sont présents (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`).
| Rôle | Fichier |
|------|---------|
| Allocations + coûts + réserve + packs ledger | `lib/credits.ts` |
| Catalogue packs + price IDs | `lib/billing/credit-packs.ts` |
| Checkout pack | `app/api/billing/create-pack-checkout/route.ts` |
| Webhook abo + pack | `app/api/billing/webhook/route.ts` |
| UI admin | `app/(admin)/admin/billing/billing-admin-client.tsx` |
| UI facturation utilisateur | `components/settings/billing-plans.tsx` |
---
## Bonnes pratiques d'exploitation
- Éviter les changements massifs sans validation progressive.
- Journaliser les changements critiques (tiers très utilisés).
- Préférer des paliers de quota cohérents entre fonctionnalités similaires.
- Garder un rituel de revue hebdo des usages (section Aperçu consommation).
---
## Référence technique (fichiers clés)
- `app/(admin)/admin/billing/page.tsx`
- `app/(admin)/admin/billing/billing-admin-client.tsx`
- `app/actions/admin-billing.ts`
- `lib/plan-entitlements.ts`
- `lib/entitlements.ts`
- `lib/billing/stripe-prices.ts`
- `app/api/billing/status/route.ts`
## Note legacy
Le modèle `PlanEntitlement` (anciens seaux par fonction) peut encore exister en base pour déventuels garde-fous « fonction indisponible », mais **napparaît plus** dans lUI admin et **ne pilote plus** le débit principal.

View File

@@ -0,0 +1,36 @@
# Audit dashboard Second Brain (`/home`) — 2026-07-16
## Portée
Revue code de `dashboard-view.tsx` + widgets catalogue / paths / briefing.
## Points sains
| Zone | Constat |
|------|---------|
| Layout v5 configurable | Catalogue widgets + `/api/dashboard/layout` |
| Prochaines pistes | Fast paths + enrich async `/api/briefing/paths` |
| Revue quotidienne | Checklist interactive (pas revue auto) |
| Quotas | `emitAiUsageChanged` + consent IA sur actions |
| Aide « ? » | `DashboardWidgetHelp` i18n |
| Empty states | Widgets catalogue gèrent listes vides |
## Risques / écarts
| # | Problème | Sévérité | Action |
|---|----------|----------|--------|
| 1 | Briefing dépend du cron / première visite : widgets peuvent rester vides temporairement | Moyenne | UX « chargement / aucune donnée » déjà partielle — dogfood |
| 2 | Bridges / clusters sémantiques absurdes cross-domaine | Moyenne | Produit insights (hors dashboard pur) |
| 3 | Widget usage encore mentalité « seaux » vs solde crédits | Basse | `DashboardUsageWidget` lit `/api/usage/current` (balance multi) — OK si API à jour |
| 4 | Agent suggestions vides si cron agent-suggestions off | Basse | Ops CRON_SECRET |
## Verdict
Dashboard **utilisable** et aligné prototype sur larchitecture. Pas de bug bloquant code identifié dans cette passe.
**Reste dogfood** : première visite, dark mode, clics paths, quotas 402.
## Suite recommandée
1. Dogfood 10 min sur compte réel
2. Si widget usage affiche encore danciens seaux → forcer refresh API (déjà multi-feature dans usage-current)
3. Prioriser pertinence bridges (insights) plutôt que refonte dashboard

View File

@@ -47,8 +47,22 @@ export function useVoiceTranscription({ onTranscript, onError, lang = 'fr-FR' }:
}
rec.onerror = (event: SpeechRecognitionErrorEvent) => {
console.error('[voice] SpeechRecognition error:', event.error)
onError?.(event.error === 'not-allowed' ? 'Microphone non autorisé.' : `Erreur: ${event.error}`)
// "not-allowed" = permission micro refusée — attendu, pas une erreur applicative
// "aborted" / "no-speech" = fin normale / silence — ne pas alarmer
const benign = event.error === 'not-allowed' || event.error === 'aborted' || event.error === 'no-speech'
if (!benign) {
console.warn('[voice] SpeechRecognition error:', event.error)
}
if (event.error === 'not-allowed') {
onError?.('Microphone non autorisé. Autorisez le micro dans le navigateur pour dicter.')
setState('idle')
return
}
if (event.error === 'aborted' || event.error === 'no-speech') {
setState('idle')
return
}
onError?.(`Erreur: ${event.error}`)
setState('error')
}

View File

@@ -0,0 +1,59 @@
/**
* Mise en évidence temporaire des carnets créés via IA (wizard, etc.).
* Les notes ont déjà `autoGenerated` en base ; les carnets n'ont pas ce flag
* → on stocke un repère local (TTL 7 jours) pour les retrouver facilement.
*/
const STORAGE_KEY = 'memento-ai-created-notebooks'
const TTL_MS = 7 * 24 * 60 * 60 * 1000
type Entry = { id: string; at: number }
function readEntries(): Entry[] {
if (typeof window === 'undefined') return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as Entry[]
if (!Array.isArray(parsed)) return []
const now = Date.now()
return parsed.filter((e) => e?.id && typeof e.at === 'number' && now - e.at < TTL_MS)
} catch {
return []
}
}
function writeEntries(entries: Entry[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries.slice(0, 50)))
} catch {
/* quota / private mode */
}
}
/** Marque un carnet comme créé via IA (repère sidebar ~7 jours). */
export function markAiCreatedNotebook(notebookId: string) {
if (!notebookId) return
const now = Date.now()
const next = [{ id: notebookId, at: now }, ...readEntries().filter((e) => e.id !== notebookId)]
writeEntries(next)
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('memento-ai-created-changed'))
}
}
export function isAiCreatedNotebook(notebookId: string): boolean {
return readEntries().some((e) => e.id === notebookId)
}
export function listAiCreatedNotebookIds(): Set<string> {
return new Set(readEntries().map((e) => e.id))
}
/** Note créée récemment (pour point « nouveau » optionnel). */
export function isRecentlyCreated(createdAt: Date | string | null | undefined, hours = 48): boolean {
if (!createdAt) return false
const t = typeof createdAt === 'string' ? new Date(createdAt).getTime() : createdAt.getTime()
if (Number.isNaN(t)) return false
return Date.now() - t < hours * 60 * 60 * 1000
}

View File

@@ -2,12 +2,15 @@ import { willUseByokForLane } from '@/lib/ai/provider-for-user'
import type { AiFeatureLane } from '@/lib/ai/router'
import { getSystemConfig } from '@/lib/config'
import {
reserveUsageOrThrow,
releaseUsage,
checkSessionEntitlementOrThrow,
QuotaExceededError,
QuotaServiceUnavailableError,
} from '@/lib/entitlements'
import {
releaseCredits,
reserveCreditsOrThrow,
resolveCreditCost,
} from '@/lib/credits'
import type { FeatureName } from '@/lib/quota-utils'
import { emitAiUsageChanged } from '@/lib/ai-usage-sync'
@@ -15,22 +18,34 @@ export type ReserveAiUsageOptions = {
lane?: AiFeatureLane
/** Defaults to userId — host-pays brainstorm bills this account. */
billingUserId?: string
/** Units / crédits (default 1). Multi-step features (slides) pass amount > 1. */
amount?: number
/** Pour coût slides précis (1+N). */
slideCount?: number | null
metadata?: Record<string, unknown>
}
/** Reserve one AI unit; skips when BYOK will be used for the lane. */
/** Reserve crédits IA ; saute si BYOK pour le lane. */
export async function reserveAiUsageOrThrow(
userId: string,
feature: FeatureName,
options?: ReserveAiUsageOptions,
): Promise<{ usedByok: boolean; billedUserId: string }> {
): Promise<{ usedByok: boolean; billedUserId: string; amount: number }> {
const billedUserId = options?.billingUserId ?? userId
const lane = options?.lane ?? 'chat'
const cost = resolveCreditCost(feature, {
amount: options?.amount,
slideCount: options?.slideCount,
})
const config = await getSystemConfig()
const { usedByok } = await willUseByokForLane(lane, config, billedUserId)
if (!usedByok) {
await reserveUsageOrThrow(billedUserId, feature)
await reserveCreditsOrThrow(billedUserId, cost, {
feature,
metadata: options?.metadata,
})
}
return { usedByok, billedUserId }
return { usedByok, billedUserId, amount: cost }
}
/** Host-pays: reserve on session owner with guest metadata on 402. */
@@ -39,30 +54,49 @@ export async function reserveSessionAiUsageOrThrow(
triggeredByUserId: string,
isGuestActor: boolean,
feature: FeatureName,
options?: { lane?: AiFeatureLane },
): Promise<{ usedByok: boolean }> {
options?: { lane?: AiFeatureLane; amount?: number; slideCount?: number | null },
): Promise<{ usedByok: boolean; amount: number }> {
const lane = options?.lane ?? 'chat'
const cost = resolveCreditCost(feature, {
amount: options?.amount,
slideCount: options?.slideCount,
})
const config = await getSystemConfig()
const { usedByok } = await willUseByokForLane(lane, config, billingOwnerId)
if (!usedByok) {
await checkSessionEntitlementOrThrow(
billingOwnerId,
triggeredByUserId,
isGuestActor,
feature,
)
try {
await reserveCreditsOrThrow(billingOwnerId, cost, { feature })
} catch (err) {
if (err instanceof QuotaExceededError) {
err.billingOwnerId = billingOwnerId
err.triggeredByUserId = triggeredByUserId
err.isGuestActor = isGuestActor
// Also keep session path for legacy callers
try {
await checkSessionEntitlementOrThrow(
billingOwnerId,
triggeredByUserId,
isGuestActor,
feature,
)
} catch {
/* already QuotaExceeded from credits */
}
}
throw err
}
}
return { usedByok }
return { usedByok, amount: cost }
}
/** Run fn under quota; releases the unit if fn throws (unless BYOK). */
/** Run fn under crédits ; libère si fn throw (sauf BYOK). */
export async function withAiQuota<T>(
userId: string,
feature: FeatureName,
fn: () => Promise<T>,
options?: ReserveAiUsageOptions,
): Promise<T> {
const { usedByok, billedUserId } = await reserveAiUsageOrThrow(userId, feature, options)
const { usedByok, billedUserId, amount } = await reserveAiUsageOrThrow(userId, feature, options)
try {
const result = await fn()
if (!usedByok) {
@@ -70,8 +104,8 @@ export async function withAiQuota<T>(
}
return result
} catch (err) {
if (!usedByok) {
await releaseUsage(billedUserId, feature)
if (!usedByok && amount > 0) {
await releaseCredits(billedUserId, amount, { feature })
}
throw err
}
@@ -84,9 +118,9 @@ export async function withSessionAiQuota<T>(
isGuestActor: boolean,
feature: FeatureName,
fn: () => Promise<T>,
options?: { lane?: AiFeatureLane },
options?: { lane?: AiFeatureLane; amount?: number; slideCount?: number | null },
): Promise<T> {
const { usedByok } = await reserveSessionAiUsageOrThrow(
const { usedByok, amount } = await reserveSessionAiUsageOrThrow(
billingOwnerId,
triggeredByUserId,
isGuestActor,
@@ -100,8 +134,8 @@ export async function withSessionAiQuota<T>(
}
return result
} catch (err) {
if (!usedByok) {
await releaseUsage(billingOwnerId, feature)
if (!usedByok && amount > 0) {
await releaseCredits(billingOwnerId, amount, { feature })
}
throw err
}

View File

@@ -281,5 +281,40 @@ export function getChatProvider(config?: Record<string, string>): AIProvider {
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
}
/**
* Provider for slide deck generation.
* Uses AI_PROVIDER_SLIDES + AI_MODEL_SLIDES when both are set in admin.
* If only AI_MODEL_SLIDES is set, reuses the chat provider with that exact model id.
* Otherwise falls back to the chat route — never rewrites or invents model names.
*/
export function getSlidesProvider(config?: Record<string, string>): AIProvider {
const cfg = config || {};
const slidesModel = (cfg.AI_MODEL_SLIDES || process.env.AI_MODEL_SLIDES || '').trim();
const slidesProvider = (cfg.AI_PROVIDER_SLIDES || process.env.AI_PROVIDER_SLIDES || '').trim();
const route = resolveAiRoute('chat', cfg);
if (slidesModel && slidesProvider) {
return getProviderInstance(
slidesProvider as ProviderType,
cfg,
slidesModel,
route.embeddingModelName,
route.ollamaBaseUrl,
);
}
if (slidesModel) {
return getProviderInstance(
route.providerType as ProviderType,
cfg,
slidesModel,
route.embeddingModelName,
route.ollamaBaseUrl,
);
}
return getChatProvider(cfg);
}
// Export for use by admin settings form and deploy scripts
export { getProviderConfigKeys };

View File

@@ -18,7 +18,26 @@ import { calculateNextRun } from '@/lib/agents/schedule'
import { markdownToHtml } from '@/lib/markdown-to-html'
import { createNotification } from '@/app/actions/notifications'
import { withAiQuota } from '@/lib/ai-quota'
import { releaseCredits, slideGenerateCreditCost } from '@/lib/credits'
import type { FeatureName } from '@/lib/quota-utils'
import {
countNoteWords,
prepareNoteTextForSlides,
slideLimitFromWordCount,
SLIDE_CONTENT_PRINCIPLES_EN,
SLIDE_CONTENT_PRINCIPLES_FR,
normalizeSlideDeck,
} from '@/lib/ai/services/slide-content-quality'
import { generateSlideDeck } from '@/lib/ai/services/slide-deck-generator.service'
import { decodeSlideIntentDescription } from '@/lib/ai/services/slide-intent'
export type ExecuteAgentOptions = {
/**
* When true, caller already reserved quota (e.g. run-for-note multi-unit pre-check).
* Avoids double-charging on fire-and-forget generation.
*/
skipQuota?: boolean
}
// Import tools for side-effect registration
import '../tools'
@@ -915,94 +934,54 @@ This format AUTOMATICALLY creates shapes, text, AND arrows with correct bindings
},
'slide-generator': {
fr: `Tu es un expert en design de présentations exécutives. Tu analyses la note et génères une structure JSON qui sera rendue en HTML automatiquement par le serveur.
fr: `Tu es un consultant senior (niveau McKinsey/BCG) qui transforme une note Memento en deck exécutif de classe mondiale.
APPELLE OBLIGATOIREMENT generate_slides avec le JSON structuré. NE GÉNÈRE JAMAIS de HTML brut.
═══ ÉTAPE 1 — LECTURE (OBLIGATOIRE) ═══
1. Lis la note avec note_read. Extrais TOUTES les données exploitables (chiffres, listes, citations, comparaisons, étapes).
2. Identifie les types de slides les plus adaptés au contenu réel.
${SLIDE_CONTENT_PRINCIPLES_FR}
═══ ÉTAPE 2 — APPEL generate_slides ═══
Appelle generate_slides avec un objet JSON structuré :
═══ MÉTHODE ═══
1. Lis la note (contenu fourni + note_read si besoin). Extrais : claims, chiffres, citations, étapes, oppositions, décisions.
2. Construis un storyboard mental (max N slides selon le message user) avant d'appeler l'outil.
3. Appelle generate_slides :
{
"title": "Titre court (6 mots max)",
"theme": "architectural-saas", // ou midnight-cathedral, venture-pitch, clinical-precision, etc.
"slides": [ ... tableau de slides ... ]
"title": "Titre du deck (≤8 mots)",
"theme": "architectural-saas",
"slides": [ ... ]
}
═══ TYPES DE SLIDES DISPONIBLES ═══
1. "title" → { type:"title", title:"...", subtitle:"..." }
2. "bullets" → { type:"bullets", title:"...", items:["phrase 1 (15+ mots)", "phrase 2", ...] }
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
6. "cards" → { type:"cards", title:"...", cards:[{title:"Titre", description:"Description détaillée..."}, ...] }
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Lancement", description:"..."}, ...] }
8. "quote" → { type:"quote", quote:"Citation exacte", author:"Auteur", context:"Analyse..." }
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Énergie"}], explanation:"..." }
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
12. "summary" → { type:"summary", title:"...", items:["Point clé 1", "Point clé 2", ...] }
═══ TYPES (choisir selon le contenu, pas pour « remplir ») ═══
title | bullets | chart* | stats* | table | cards | timeline | quote | comparison | equation | image | summary
* chart/stats seulement avec chiffres réels dans la note.
═══ RÈGLES ═══
- Nombre de slides : adapté au contenu réel de la note (la règle ⚠️ dans le message utilisateur est ABSOLUE)
- Slide 1 OBLIGATOIREMENT type "title"
- Dernière slide OBLIGATOIREMENT type "summary"
- Au moins 1 slide "chart" ou "stats" si des chiffres existent dans la note
- VARIER les types — jamais 2 types identiques consécutifs
- Chaque texte (bullet, description) = 15+ mots, phrase complète
- TOUTES les données viennent de la note (JAMAIS inventer de chiffres)
- Les données chart doivent refléter les vrais chiffres de la note
═══ THÈMES DISPONIBLES ═══
═══ THÈMES ═══
Sombres : midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
Clairs : sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
Choisir selon le sujet : business/board → architectural-saas ou midnight-cathedral, tech → cyberpunk-terminal ou clinical-precision, créatif → aurora-borealis ou tokyo-neon`,
en: `You are an executive presentation design expert. You analyze the note and generate a structured JSON spec that will be rendered to HTML automatically by the server.
Business → architectural-saas / midnight-cathedral ; tech → clinical-precision ; créatif → aurora-borealis`,
en: `You are a senior consultant (McKinsey/BCG level) turning a Memento note into a world-class executive deck.
You MUST call generate_slides with structured JSON. NEVER output raw HTML.
═══ STEP 1 — READ (MANDATORY) ═══
1. Read the note with note_read. Extract ALL usable data (numbers, lists, quotes, comparisons, steps).
2. Identify the best slide types matching the actual content.
${SLIDE_CONTENT_PRINCIPLES_EN}
═══ STEP 2 — CALL generate_slides ═══
Call generate_slides with a structured JSON object:
═══ METHOD ═══
1. Read the note (provided content + note_read if needed). Extract: claims, numbers, quotes, steps, trade-offs, decisions.
2. Storyboard mentally (respect max N slides from the user message) before calling the tool.
3. Call generate_slides:
{
"title": "Short title (6 words max)",
"title": "Deck title (≤8 words)",
"theme": "architectural-saas",
"slides": [ ... array of slides ... ]
"slides": [ ... ]
}
═══ AVAILABLE SLIDE TYPES ═══
1. "title" → { type:"title", title:"...", subtitle:"..." }
2. "bullets" → { type:"bullets", title:"...", items:["sentence 1 (15+ words)", "sentence 2", ...] }
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
6. "cards" → { type:"cards", title:"...", cards:[{title:"Title", description:"Detailed description..."}, ...] }
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Launch", description:"..."}, ...] }
8. "quote" → { type:"quote", quote:"Exact quote", author:"Author", context:"Analysis..." }
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Energy"}], explanation:"..." }
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
12. "summary" → { type:"summary", title:"...", items:["Key point 1", "Key point 2", ...] }
═══ TYPES (match content — do not pad) ═══
title | bullets | chart* | stats* | table | cards | timeline | quote | comparison | equation | image | summary
* chart/stats only with real numbers from the note.
═══ RULES ═══
- Slide count: adapt to the real content (the ⚠️ rule in the user message is ABSOLUTE)
- Slide 1 MUST be type "title"
- Last slide MUST be type "summary"
- At least 1 "chart" or "stats" slide if numbers exist in the note
- VARY types — never 2 identical types in a row
- Each text (bullet, description) = 15+ words, complete sentence
- ALL data comes from the note (NEVER invent numbers)
- Chart data must reflect actual numbers from the note
═══ AVAILABLE THEMES ═══
═══ THEMES ═══
Dark: midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
Light: sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
Choose based on topic: business/board → architectural-saas or midnight-cathedral, tech → cyberpunk-terminal or clinical-precision, creative → aurora-borealis or tokyo-neon`,
Business → architectural-saas / midnight-cathedral; tech → clinical-precision; creative → aurora-borealis`,
},
'task-extractor': {
fr: `Tu es un expert en gestion de tâches et extraction d'action items. Tu analyses des notes et documents pour identifier toutes les tâches, TODOs, et actions à accomplir.
@@ -1028,49 +1007,6 @@ You MUST use the task_extract tool. Do NOT respond with text, call the tool dire
},
}
/**
* Strip markdown syntax, URLs, metadata blocks, and frontmatter from note content.
* Returns only the semantic plain text, used to count real words before generating slides.
*/
function stripNoteMarkdown(raw: string): string {
return raw
// Remove frontmatter / YAML blocks
.replace(/^---[\s\S]*?---/m, '')
// Remove markdown headers
.replace(/^#{1,6}\s+/gm, '')
// Remove bold/italic
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
// Remove inline links — keep label only
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
// Remove bare URLs
.replace(/https?:\/\/\S+/g, '')
// Remove metadata lines (Connection to seed, Novelty score, Source brainstorm, Origin, Derived from, etc.)
.replace(/^\*{0,2}(Connection to seed|Novelty score|Source brainstorm|Source note|Origin|Derived from|## Origin)[^:\n]*:?.*$/gim, '')
// Remove horizontal rules
.replace(/^---+$/gm, '')
// Remove blockquote markers
.replace(/^>\s*/gm, '')
// Remove code blocks
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]+`/g, '')
// Collapse whitespace
.replace(/\s+/g, ' ')
.trim()
}
/** Count real semantic words in a note */
function countNoteWords(raw: string): number {
return stripNoteMarkdown(raw).split(/\s+/).filter(Boolean).length
}
/** Return max slides based on real word count */
function slideLimit(wordCount: number): { max: number; label: string } {
if (wordCount < 50) return { max: 3, label: '3 slides MAX (note très courte)' }
if (wordCount < 150) return { max: 4, label: '4 slides MAX (note courte)' }
if (wordCount < 350) return { max: 6, label: '6 slides MAX (note moyenne)' }
return { max: 8, label: '8 slides MAX (note longue)' }
}
function extractJsonFromText(text: string): any {
if (!text) return null
@@ -1323,52 +1259,54 @@ async function executeToolUseAgent(
? LANG_NAMES[noteLanguage]
: (lang === 'fr' ? 'French' : 'English')
prompt = `Create a professional presentation using the content from the notes below. Call generate_slides with a structured JSON object (title, theme, slides[]).`
prompt += `\n\n⚠ LANGUAGE RULE: ALL slide text (titles, bullets, labels, subtitles) MUST be written in ${contentLang}. Do NOT translate to another language.`
prompt = lang === 'fr'
? `Construis un deck exécutif de haute qualité à partir des notes ci-dessous. Appelle generate_slides avec {title, theme, slides[]}.`
: `Build a high-quality executive deck from the notes below. Call generate_slides with {title, theme, slides[]}.`
prompt += `\n\n⚠ LANGUAGE: ALL on-slide text (titles, bullets, labels, subtitles, summary) MUST be in ${contentLang}.`
if (notes.length > 0) {
const notesContext = notes.map(n =>
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 2000)}`
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${prepareNoteTextForSlides(n.content || '', 12_000)}`
).join('\n\n')
// Compute real word count (stripped of markdown/metadata) to determine slide limit
const totalWords = notes.reduce((sum, n) => sum + countNoteWords(n.content || ''), 0)
const { max: maxSlides, label: slideRule } = slideLimit(totalWords)
const limit = slideLimitFromWordCount(totalWords)
const slideRule = lang === 'fr' ? limit.labelFr : limit.labelEn
prompt += `\n\n${lang === 'fr' ? 'Notes source' : 'Source notes'}:\n\n${notesContext}`
prompt += `\n\n${lang === 'fr' ? '## Notes source (texte intégral utile)' : '## Source notes (usable full text)'}:\n\n${notesContext}`
prompt += `\n\n${lang === 'fr'
? `⚠️ RÈGLE ABSOLUE : Cette note fait ${totalWords} mots réels (hors markdown/URLs/métadonnées). Tu DOIS générer EXACTEMENT ${slideRule}. Respecter cette limite est PLUS IMPORTANT que la diversité ou la densité.`
: `⚠️ ABSOLUTE RULE: This note has ${totalWords} real words (excluding markdown/URLs/metadata). You MUST generate EXACTLY ${slideRule}. This limit is MORE IMPORTANT than diversity or density.`
? `⚠️ LIMITE DECK : ${totalWords} mots source → ${slideRule}. Mieux vaut ${limit.min} slides excellentes que ${limit.max} slides diluées. Ne dépasse JAMAIS ${limit.max}.`
: `⚠️ DECK LIMIT: ${totalWords} source words ${slideRule}. Prefer ${limit.min} excellent slides over ${limit.max} diluted ones. NEVER exceed ${limit.max}.`
}`
}
// ── Executive Template Structure (HTML-compatible) ──
// ── Executive templates (max 8 slides — matches hard cap) ──
if (slideTemplate && slideTemplate !== 'auto') {
const templates: Record<string, { fr: string; en: string }> = {
'board-update': {
fr: `Structure imposée "Board Update" (10 slides) :
1. TITRE avec date | 2. KPIs majeurs (3-4 gros chiffres) | 3. Bar chart progression objectifs | 4. Grille de métriques (6 cards) | 5. Timeline jalons | 6. Cards réalisations | 7. Line chart tendance | 8. Deux colonnes risques/mitigations | 9. Bullets prochaines étapes | 10. Conclusion`,
en: `Mandatory "Board Update" structure (10 slides):
1. TITLE with date | 2. Key KPIs (3-4 big numbers) | 3. Bar chart goal progress | 4. Metrics grid (6 cards) | 5. Timeline milestones | 6. Achievement cards | 7. Line chart trend | 8. Two-column risks/mitigations | 9. Next steps bullets | 10. Conclusion`
fr: `Structure "Board Update" (≤8 slides, titres d'action) :
1. title | 2. stats KPIs réels seulement | 3. insight + bullets sur performance | 4. chart progression SI chiffres | 5. cards réalisations | 6. comparison risques/mitigations | 7. bullets prochaines décisions | 8. summary`,
en: `"Board Update" structure (≤8 slides, action titles):
1. title | 2. stats real KPIs only | 3. insight + performance bullets | 4. chart ONLY if real numbers | 5. achievement cards | 6. risks/mitigations comparison | 7. next decisions bullets | 8. summary`,
},
'project-status': {
fr: `Structure imposée "Project Status" (10 slides) :
1. TITRE + statut 🟢🟡🔴 | 2. KPIs (% avancement, jours, budget) | 3. Bar chart par workstream | 4. Cards livrables | 5. Timeline roadmap | 6. Line chart burn-down | 7. Deux colonnes blockers/actions | 8. Tableau de risques | 9. Bullets décisions requises | 10. Synthèse`,
en: `Mandatory "Project Status" structure (10 slides):
1. TITLE + status 🟢🟡🔴 | 2. KPIs (% complete, days, budget) | 3. Bar chart by workstream | 4. Deliverable cards | 5. Timeline roadmap | 6. Line chart burn-down | 7. Two-column blockers/actions | 8. Risk table | 9. Required decisions bullets | 10. Summary`
fr: `Structure "Project Status" (≤8) :
1. title + statut | 2. stats avancement (réels) | 3. cards livrables | 4. timeline jalons | 5. comparison blockers/actions | 6. table risques SI présent | 7. bullets décisions | 8. summary`,
en: `"Project Status" structure (≤8):
1. title + status | 2. real progress stats | 3. deliverable cards | 4. milestone timeline | 5. blockers/actions comparison | 6. risk table IF present | 7. decisions bullets | 8. summary`,
},
'strategy-review': {
fr: `Structure imposée "Strategy Review" (10 slides) :
1. TITRE stratégique | 2. Contexte marché (5+ bullets) | 3. Radar/pie positionnement | 4. SWOT deux colonnes | 5. Area chart tendances | 6. Cards axes stratégiques | 7. Timeline roadmap 12 mois | 8. Bar chart projections | 9. KPIs objectifs cibles | 10. Call to action`,
en: `Mandatory "Strategy Review" structure (10 slides):
1. Strategic TITLE | 2. Market context (5+ bullets) | 3. Radar/pie positioning | 4. SWOT two-column | 5. Area chart trends | 6. Strategic axes cards | 7. 12-month roadmap timeline | 8. Bar chart projections | 9. Target KPIs | 10. Call to action`
fr: `Structure "Strategy Review" (≤8) :
1. title stratégique | 2. bullets contexte (claims) | 3. comparison options ou SWOT | 4. cards axes stratégiques | 5. timeline roadmap | 6. stats/chart UNIQUEMENT si données | 7. implications | 8. summary CTA`,
en: `"Strategy Review" structure (≤8):
1. strategic title | 2. context claim bullets | 3. options/SWOT comparison | 4. strategic pillars cards | 5. roadmap timeline | 6. stats/chart ONLY if data | 7. implications | 8. summary CTA`,
},
'quarterly-results': {
fr: `Structure imposée "Quarterly Results" (10 slides) :
1. TITRE "Résultats Q? Année" | 2. 4 KPIs (Revenue, Croissance, Marge, NPS) | 3. Bar chart revenue/mois | 4. Line chart croissance YoY | 5. Cards faits marquants | 6. Grille métriques opérationnelles | 7. Donut répartition clients | 8. Deux colonnes succès/challenges | 9. Area chart forecast | 10. Conclusion outlook`,
en: `Mandatory "Quarterly Results" structure (10 slides):
1. TITLE "Q? Year Results" | 2. 4 KPIs (Revenue, Growth, Margin, NPS) | 3. Bar chart revenue/month | 4. Line chart YoY growth | 5. Highlights cards | 6. Operational metrics grid | 7. Donut client breakdown | 8. Two-column successes/challenges | 9. Area chart forecast | 10. Conclusion outlook`
}
fr: `Structure "Quarterly Results" (≤8) :
1. title trimestre | 2. stats 34 KPIs réels | 3. chart SI séries chiffrées | 4. cards faits marquants | 5. comparison succès/défis | 6. bullets outlook | 7. summary`,
en: `"Quarterly Results" structure (≤8):
1. quarter title | 2. 34 real KPI stats | 3. chart IF numeric series | 4. highlight cards | 5. wins/challenges comparison | 6. outlook bullets | 7. summary`,
},
}
const tmpl = templates[slideTemplate]
if (tmpl) {
@@ -1376,19 +1314,24 @@ async function executeToolUseAgent(
}
}
// ── Density rules ──
prompt += lang === 'fr'
? `\n\nDENSITÉ OBLIGATOIRE : Chaque slide doit avoir un CONTENU RICHE. Minimum 5 bullets par slide "bullets" (≥15 mots chaque). Minimum 4 cards quand tu fais "cards". Minimum 4 data points quand tu fais "chart". Inclus OBLIGATOIREMENT au moins 1 slide "chart" avec données RÉELLES extraites des notes. Si pas de chiffres dans la note, fais un radar de maturité ou une comparaison qualitative notée sur 5.`
: `\n\nMANDATORY DENSITY: Every slide must have RICH CONTENT. Minimum 5 items per "bullets" slide (≥15 words each). Minimum 4 cards when using "cards". Minimum 4 data points for "chart". You MUST include at least 1 "chart" slide with REAL data from the notes. If no numbers in notes, create a maturity radar or qualitative comparison scored out of 5.`
prompt += `\n\n${lang === 'fr'
? 'IMPORTANT : Appelle OBLIGATOIREMENT generate_slides avec le JSON structuré {title, theme, slides:[...]}. Ne réponds JAMAIS avec du texte brut. Respecte ABSOLUMENT la limite de slides indiquée.'
: 'IMPORTANT: You MUST call generate_slides with structured JSON {title, theme, slides:[...]}. NEVER respond with plain text. STRICTLY respect the slide count limit indicated.'}`
? `\n\nQUALITÉ > QUANTITÉ :
- Titres d'action (insight), une idée par slide
- 35 bullets courts max ; pas de filler ni de paragraphes
- Graphiques UNIQUEMENT avec chiffres de la note — JAMAIS inventer de KPI/radar
- Arc : problème → insight → preuve → suite
- Appelle generate_slides maintenant (JSON structuré uniquement).`
: `\n\nQUALITY > QUANTITY:
- Action titles (insights), one idea per slide
- 35 short bullets max; no filler or paragraphs
- Charts ONLY with numbers from the note — NEVER invent KPI/radar scores
- Arc: problem → insight → evidence → next steps
- Call generate_slides now (structured JSON only).`
if (agent.slideTheme && agent.slideTheme !== 'auto') {
prompt += `\n${lang === 'fr'
? `Thème visuel imposé : utilise "theme":"${agent.slideTheme}" dans l'appel generate_slides.`
: `Visual theme required: use "theme":"${agent.slideTheme}" in the generate_slides call.`}`
? `Thème visuel imposé : "theme":"${agent.slideTheme}" dans generate_slides.`
: `Required visual theme: "theme":"${agent.slideTheme}" in the generate_slides call.`}`
}
break
}
@@ -1510,7 +1453,12 @@ async function executeToolUseAgent(
const registered = toolRegistry.get('generate_slides')
if (registered) {
const slideTool = registered.buildTool(ctx)
const executionResult = await slideTool.execute({ title, theme, slides })
const normalized = normalizeSlideDeck({ title, theme, slides })
const executionResult = await slideTool.execute({
title: normalized.title,
theme: normalized.theme,
slides: normalized.slides as any,
})
if (executionResult && executionResult.success && executionResult.canvasId) {
canvasId = executionResult.canvasId
specificToolCalled = true
@@ -1747,9 +1695,124 @@ async function sendAgentEmail(agentId: string, userId: string, agentName: string
}
}
/**
* Dedicated slide path: structured JSON → normalize → HTML canvas.
* Never depends on the model calling generate_slides.
*/
async function executeSlideGeneratorAgent(
agent: {
id: string
name: string
description?: string | null
userId: string
sourceNoteIds?: string | null
sourceNotebookId?: string | null
slideTheme?: string | null
},
actionId: string,
lang: Lang,
): Promise<AgentExecutionResult> {
const startTime = Date.now()
let noteIds: string[] = []
try {
noteIds = agent.sourceNoteIds ? JSON.parse(agent.sourceNoteIds) : []
} catch {
noteIds = []
}
const intent = decodeSlideIntentDescription(agent.description)
const template = intent.template || 'auto'
await prisma.agentAction.update({
where: { id: actionId },
data: {
input: JSON.stringify({
pipeline: 'structured-slide-deck',
noteIds,
sourceNotebookId: agent.sourceNotebookId,
theme: agent.slideTheme,
template,
intent,
lang,
}),
},
})
const result = await generateSlideDeck({
userId: agent.userId,
noteIds: noteIds.length > 0 ? noteIds : undefined,
sourceNotebookId: agent.sourceNotebookId,
theme: agent.slideTheme,
template,
intent,
lang,
actionId,
})
const duration = Date.now() - startTime
if (!result.success || !result.canvasId) {
await prisma.agentAction.update({
where: { id: actionId },
data: {
status: 'failure',
log:
(result.error ||
(lang === 'fr'
? 'Échec de la génération de présentation (pipeline structuré).'
: 'Slide generation failed (structured pipeline).')) +
` (${Math.round(duration / 1000)}s)`,
},
})
return {
success: false,
actionId,
error: result.error || 'Slide generation failed',
}
}
await prisma.agentAction.update({
where: { id: actionId },
data: {
status: 'success',
result: result.canvasId,
log: `Slides OK (${result.mode || 'structured'}): ${result.slideCount} slides — ${result.canvasName} (${Math.round(duration / 1000)}s)`,
toolLog: JSON.stringify([
{
step: 1,
text: 'structured-slide-deck pipeline (no tool-calling)',
toolCalls: [],
toolResults: [
{
toolName: 'generate_slides_structured',
preview: JSON.stringify({
canvasId: result.canvasId,
slideCount: result.slideCount,
mode: result.mode,
}),
},
],
},
]),
},
})
return {
success: true,
actionId,
canvasId: result.canvasId,
}
}
// --- Main Executor ---
export async function executeAgent(agentId: string, userId: string, promptOverride?: string): Promise<AgentExecutionResult> {
export async function executeAgent(
agentId: string,
userId: string,
promptOverride?: string,
options?: ExecuteAgentOptions,
): Promise<AgentExecutionResult> {
const agent = await prisma.agent.findUnique({
where: { id: agentId }
})
@@ -1768,44 +1831,53 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
// Detect user language
const lang = await getUserLanguage(userId)
const quotaFeature = quotaFeatureForAgentType(agent.type || 'scraper')
const agentType = agent.type || 'scraper'
try {
const result = await withAiQuota(
userId,
quotaFeature,
async () => {
let inner: AgentExecutionResult
// Multi-step slide pipeline: 1 + N crédits (sauf si déjà réservé par run-for-note)
let quotaAmount = 1
let slideCountForCost: number | null = null
if (agentType === 'slide-generator') {
const intent = decodeSlideIntentDescription(agent.description)
slideCountForCost = intent.slideCount ?? null
quotaAmount = slideGenerateCreditCost(intent.slideCount)
}
const runAgent = async (): Promise<AgentExecutionResult> => {
// Slide generation uses a dedicated structured-output pipeline (no tool-calling).
// Lite models often fail at function calling; generateObject/JSON is reliable.
if (agentType === 'slide-generator') {
return executeSlideGeneratorAgent(agent, action.id, lang)
}
const hasTools = agent.tools && agent.tools !== '[]' && agent.tools !== 'null'
if (hasTools) {
inner = await executeToolUseAgent(agent, action.id, lang, promptOverride)
} else {
switch ((agent.type || 'scraper') as AgentType) {
case 'scraper':
inner = await executeScraperAgent(agent, action.id, lang)
break
case 'researcher':
inner = await executeResearcherAgent(agent, action.id, lang)
break
case 'monitor':
inner = await executeMonitorAgent(agent, action.id, lang)
break
case 'custom':
inner = await executeCustomAgent(agent, action.id, lang)
break
case 'slide-generator':
case 'excalidraw-generator':
inner = await executeToolUseAgent(agent, action.id, lang, promptOverride)
break
default:
inner = await executeScraperAgent(agent, action.id, lang)
}
return executeToolUseAgent(agent, action.id, lang, promptOverride)
}
return inner
},
{ lane: 'chat' },
)
switch (agentType as AgentType) {
case 'scraper':
return executeScraperAgent(agent, action.id, lang)
case 'researcher':
return executeResearcherAgent(agent, action.id, lang)
case 'monitor':
return executeMonitorAgent(agent, action.id, lang)
case 'custom':
return executeCustomAgent(agent, action.id, lang)
case 'excalidraw-generator':
return executeToolUseAgent(agent, action.id, lang, promptOverride)
default:
return executeScraperAgent(agent, action.id, lang)
}
}
try {
const result = options?.skipQuota
? await runAgent()
: await withAiQuota(userId, quotaFeature, runAgent, {
lane: 'chat',
amount: quotaAmount,
slideCount: slideCountForCost,
})
const nextRunUpdate: Record<string, Date | null> = {}
if (agent.frequency !== 'manual') {
@@ -1861,6 +1933,11 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
const message = error instanceof Error ? error.message : 'Unknown error'
console.error(`[AgentExecutor] Agent ${agentId} failed:`, message)
// Pré-réservé par run-for-note : rembourser les crédits
if (options?.skipQuota && quotaAmount > 0) {
await releaseCredits(userId, quotaAmount, { feature: quotaFeature }).catch(() => {})
}
await prisma.agentAction.update({
where: { id: action.id },
data: { status: 'failure', log: message }

View File

@@ -19,6 +19,7 @@
* - Pas de race condition (verrou par noteId)
*/
import { createHash } from 'crypto'
import PQueue from 'p-queue'
import { prisma } from '@/lib/prisma'
import { embeddingService } from './embedding.service'
@@ -78,7 +79,16 @@ export class ChunkIndexingService {
): Promise<IndexResult> {
const start = Date.now()
const plain = prepareNoteTextForEmbedding(title, content)
const newChunks = chunkNoteContent(noteId, plain)
// Index even very short notes (title-only or one-liners)
let newChunks = chunkNoteContent(noteId, plain)
if (newChunks.length === 0 && plain.trim().length > 0) {
// Force a single fragment when paragraph split yields nothing
const fragmentId = createHash('sha256')
.update(`${noteId}::${plain}`)
.digest('hex')
.slice(0, 32)
newChunks = [{ fragmentId, content: plain, chunkIndex: 0, charCount: plain.length }]
}
const newFragmentIds = new Set(newChunks.map((c) => c.fragmentId))
const existing = await prisma.noteEmbeddingChunk.findMany({

View File

@@ -1,114 +1,406 @@
import { getChatProvider } from '../factory'
import { getSystemConfig } from '@/lib/config'
import { preprocessMathInHtml } from '@/lib/text/math-preprocess'
import { LanguageDetectionService } from './language-detection.service'
export interface GeneratedNote {
title: string
content: string
difficulty?: 'facile' | 'moyen' | 'difficile'
difficulty?: 'facile' | 'moyen' | 'difficile' | 'easy' | 'medium' | 'hard'
}
export interface GeneratedCarnet {
/** Short notebook title (not the raw user prompt). */
notebookName?: string
notes: GeneratedNote[]
schemaProperties: Array<{ name: string; type: string; options?: string[] }>
/** Language actually used for generation (ISO 639-1). */
contentLanguage?: string
}
export type WizardProfile = 'student' | 'teacher' | 'engineer'
const LANG_NAMES: Record<string, string> = {
fr: 'français',
en: 'English',
fa: 'فارسی',
ar: 'العربية',
de: 'Deutsch',
es: 'Español',
it: 'Italiano',
pt: 'Português',
ru: 'Русский',
zh: '中文',
ja: '日本語',
ko: '한국어',
nl: 'Nederlands',
pl: 'Polski',
hi: 'हिन्दी',
}
/** Word-count targets by level */
function wordTargetsForLevel(level?: string): { min: number; max: number; label: string } {
const l = (level || '').toLowerCase()
if (/expert|avancé|advanced|fort/.test(l)) {
// Borné pour éviter timeouts JSON / mode expert qui plantait
return { min: 350, max: 700, label: 'expert (350700 words per note, dense)' }
}
if (/intermédiaire|intermediate|moyen/.test(l)) {
return { min: 300, max: 550, label: 'intermediate (300550 words per note)' }
}
if (/débutant|beginner|facile|easy/.test(l)) {
return { min: 250, max: 450, label: 'beginner (250450 words per note)' }
}
return { min: 300, max: 550, label: 'standard (300550 words per note)' }
}
function countWords(html: string): number {
const plain = html
.replace(/<[^>]+>/g, ' ')
.replace(/&[a-z]+;/gi, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!plain) return 0
return plain.split(/\s+/).filter(Boolean).length
}
export class NotebookWizardService {
private languageDetection = new LanguageDetectionService()
/**
* Langue de sortie = langue du **sujet / requête** (pas l'UI), sauf si
* la requête est trop courte / ambiguë (tinyld confond souvent fr↔it).
*
* Ex. UI=fr + "I want a list of…" → en
* UI=fr + "calcul différentiel" → fr (accents / UI, pas "it" de tinyld)
*/
async resolveOutputLanguage(topic: string, uiLanguage?: string): Promise<string> {
const ui = (uiLanguage || 'fr').slice(0, 2).toLowerCase()
const text = (topic || '').trim()
if (!text) return ui
const wordCount = text.split(/\s+/).filter(Boolean).length
// Scripts non latins : fiables même sur peu de mots
if (/[\u0600-\u06FF]/.test(text)) {
if (/\b(است|های|می‌|برای|که|این)\b/.test(text)) return 'fa'
return 'ar'
}
if (/[\u4e00-\u9fff]/.test(text)) return 'zh'
if (/[\u3040-\u30ff]/.test(text)) return 'ja'
if (/[\uac00-\ud7af]/.test(text)) return 'ko'
if (/[\u0400-\u04ff]/.test(text)) return 'ru'
if (/[\u0900-\u097f]/.test(text)) return 'hi'
// Anglais explicite (phrases courantes)
if (/\b(i want|i need|i'd like|please|make me|list of|how to|for a student|for students|write in english)\b/i.test(text)) {
return 'en'
}
// Français explicite
if (/\b(je veux|j'aimerais|peux-tu|créer|génère|cours de|pour un étudiant|pour les étudiants|en français)\b/i.test(text)) {
return 'fr'
}
// Accents / digrammes typiquement français → fr (évite "calcul différentiel" → it)
if (/[àâäéèêëïîôùûüçœæ]/i.test(text) || /\b(différentiel|différentielle|mathématiques|géométrie|algèbre|fonction|limite|dérivée)\b/i.test(text)) {
return 'fr'
}
// Allemand (umlauts / ß)
if (/[äöüß]/i.test(text)) return 'de'
// Espagnol (ñ, ¿, ¡)
if (/[ñ¿¡]/i.test(text)) return 'es'
// Sujet court (≤ 4 mots) : tinyld est peu fiable (ex. "calcul différentiel" → it @ 5%)
// → on suit l'UI sauf signal anglais fort déjà géré ci-dessus
if (wordCount <= 4) {
return ui
}
// Texte plus long : détection, mais on ignore un résultat peu crédible
try {
const { language, confidence } = await this.languageDetection.detectLanguage(text)
const code = (language || '').slice(0, 2).toLowerCase()
if (code && code !== 'unknown' && confidence >= 0.75) {
// Romance court : si UI=fr et détecté it/es/pt/ro, rester en fr (faux positifs fréquents)
if (ui === 'fr' && ['it', 'es', 'pt', 'ro'].includes(code) && wordCount < 12) {
return 'fr'
}
return code
}
} catch {
/* fall through */
}
return ui
}
private langName(code: string): string {
return LANG_NAMES[code] || code
}
private languageRuleBlock(lang: string): string {
const name = this.langName(lang)
return `CRITICAL LANGUAGE RULE (non-negotiable):
- The USER REQUEST language is: ${name} (code: ${lang}).
- The app UI language is IRRELEVANT — do NOT follow UI language.
- Write ALL human-readable text in ${name}: notebookName, note titles, HTML body, callouts, examples, exercises, table headers, difficulty free-text if any.
- JSON keys stay in English (notebookName, notes, title, content, difficulty, schemaProperties).
- Do NOT mix languages. Do NOT answer in French if the request is English (and vice versa).
- Math LaTeX may stay standard (symbols), but surrounding explanations must be in ${name}.`
}
async generateCarnet(
profile: WizardProfile,
topic: string,
options?: { level?: string; count?: number; language?: string }
): Promise<GeneratedCarnet> {
const lang = options?.language || 'fr'
const count = options?.count || 6
const uiLang = options?.language || 'fr'
const contentLang = await this.resolveOutputLanguage(topic, uiLang)
const level = options?.level
const isExpert = /expert/i.test(level || '')
// Fewer notes + richer content (quality over stuffing one JSON)
const count = Math.min(options?.count || 6, isExpert ? 5 : 6)
const words = wordTargetsForLevel(level)
const prompts = this.buildPrompt(profile, topic, lang, count, options?.level)
const config = await getSystemConfig()
const provider = getChatProvider(config)
const raw = await provider.generateText(prompts)
return this.parseResponse(raw, profile, lang)
// Phase 1 — outline only (name + titles)
const plan = await this.generatePlan(provider, profile, topic, contentLang, count, level)
const schemaLabels = this.getSchemaLabels(contentLang)
// Phase 2 — one note at a time (avoids shallow multi-note dumps)
const notes: GeneratedNote[] = []
for (let i = 0; i < plan.titles.length; i++) {
const title = plan.titles[i]
const difficulty = i % 3 === 0 ? 'facile' : i % 3 === 1 ? 'moyen' : 'difficile'
let note = await this.generateOneNote(provider, {
profile,
topic,
title,
lang: contentLang,
level,
words,
difficulty,
noteIndex: i + 1,
totalNotes: plan.titles.length,
})
// Retry once if too short
if (countWords(note.content) < Math.max(120, Math.floor(words.min * 0.55))) {
note = await this.generateOneNote(provider, {
profile,
topic,
title,
lang: contentLang,
level,
words,
difficulty,
noteIndex: i + 1,
totalNotes: plan.titles.length,
forceLonger: true,
})
}
notes.push(note)
}
return {
notebookName: plan.notebookName || this.deriveNotebookName(topic, notes, contentLang),
notes,
schemaProperties: [
{ name: schemaLabels.status, type: 'select', options: [schemaLabels.statusTodo, schemaLabels.statusInProgress, schemaLabels.statusDone] },
{ name: schemaLabels.difficulty, type: 'select', options: [schemaLabels.easy, schemaLabels.medium, schemaLabels.hard] },
],
contentLanguage: contentLang,
}
}
private buildPrompt(
private async generatePlan(
provider: { generateText: (p: string) => Promise<string> },
profile: WizardProfile,
topic: string,
lang: string,
count: number,
level?: string
): string {
const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang === 'ar' ? 'العربية' : lang === 'de' ? 'Deutsch' : lang === 'es' ? 'Español' : lang === 'it' ? 'Italiano' : lang === 'pt' ? 'Português' : lang === 'ru' ? 'Русский' : lang === 'zh' ? '中文' : lang === 'ja' ? '日本語' : lang === 'ko' ? '한국어' : lang === 'nl' ? 'Nederlands' : lang === 'pl' ? 'Polski' : lang === 'hi' ? 'हिन्दी' : lang
level?: string,
): Promise<{ notebookName: string; titles: string[] }> {
const prompt = `${this.languageRuleBlock(lang)}
const schemaLabels = this.getSchemaLabels(lang)
const profileContext = {
student: `Tu crées des notes de cours pour un étudiant. Le contenu doit être pédagogique, clair, avec des exemples.`,
teacher: `Tu crées la structure d'un cours pour un professeur. Chaque note est un chapitre avec des sections à remplir, des objectifs, et des exercices.`,
engineer: `Tu crées une documentation technique. Le contenu doit être précis, structuré, avec des spécifications et des références.`,
}[profile]
You are planning a ${profile} notebook on the user request below.
User request / topic: "${topic}"
${level ? `Level: ${level}` : ''}
Produce a short JSON plan only.
return `Tu es un expert pédagogue et créateur de contenu de haut niveau. ${profileContext}
Sujet : "${topic}"
${level ? `Niveau : ${level}` : ''}
Langue : ${langName}
Nombre de notes à créer : ${count}
CRÉE ${count} NOTES COMPLÈTES ET DÉTAILLÉES sur ce sujet. Chaque note doit faire ENTRE 800 ET 1500 MOTS minimum. C'est non négociable.
Le contenu doit être :
- COMPLET et APPROFONDI — pas de résumé superficiel
- Structuré avec des titres <h2> et <h3> pour les sections
- Des paragraphes développés avec des explications détaillées
- Des exemples concrets et des cas d'usage
- Des définitions précises dans des encadrés callout
${profile === 'student' ? '- Des "Points clés à retenir" en callout tip\n- Des "Pièges à éviter" en callout danger\n- Des exemples d\'application' : ''}
${profile === 'teacher' ? '- Des objectifs pédagogiques en début de note\n- Des sections "Exercices" avec 5 questions détaillées\n- Des corrigés indicatifs' : ''}
${profile === 'engineer' ? '- Des spécifications techniques précises\n- Des tableaux comparatifs\n- Des références aux normes et standards' : ''}
FORMAT DE SORTIE — JSON UNIQUEMENT :
Return ONLY:
\`\`\`json
{
"notes": [
{
"title": "Titre détaillé et descriptif",
"difficulty": "facile",
"content": "<h2>Introduction</h2><p>Plusieurs paragraphes détaillés...</p>..."
}
],
"schemaProperties": [
{ "name": "${schemaLabels.status}", "type": "select", "options": ["${schemaLabels.statusTodo}", "${schemaLabels.statusInProgress}", "${schemaLabels.statusDone}"] },
{ "name": "${schemaLabels.difficulty}", "type": "select", "options": ["${schemaLabels.easy}", "${schemaLabels.medium}", "${schemaLabels.hard}"] }
]
"notebookName": "38 words, short, in ${this.langName(lang)}",
"titles": ["chapter title 1", "chapter title 2", "... exactly ${count} titles"]
}
\`\`\`
BLOCS HTML DISPONIBLES — UTILISE-LES ABONDAMMENT :
Rules:
- Exactly ${count} titles, progressive learning order.
- Titles in ${this.langName(lang)} only.
- notebookName ≠ full user sentence (38 words, concrete, topic-specific).
- FORBIDDEN generic notebook names / chapter patterns (any language):
"Fondements", "Foundations", "Maîtrise", "Mastery", "Introduction",
"Basics", "Overview", "Getting started", "Chapitre 1", "Chapter 1",
"Partie 1", "Bases", "Notions de base", "Synthèse finale" alone.
- Prefer specific titles that name the real subject (e.g. topic keywords + angle).
- Each title must be unique and non-interchangeable with another topic.`
1. **Callout** (encadré coloré) :
<div data-type="callout-block" data-callout-type="info"><p>Définition importante...</p></div>
Types : info, warning, tip, success, danger
const raw = await provider.generateText(prompt)
const parsed = this.parseJsonObject(raw)
const titles = Array.isArray(parsed?.titles)
? parsed.titles.map((t: unknown) => String(t || '').trim()).filter(Boolean).slice(0, count)
: []
while (titles.length < count) {
titles.push(lang === 'fr' ? `Chapitre ${titles.length + 1}` : `Chapter ${titles.length + 1}`)
}
let notebookName = String(parsed?.notebookName || '').trim().slice(0, 80)
if (this.isGenericNotebookName(notebookName, topic)) {
notebookName = this.deriveNotebookName(topic, titles.map((t) => ({ title: t, content: '' })), lang)
}
const cleanedTitles = titles.map((t, i) =>
this.isGenericChapterTitle(t) ? this.fallbackChapterTitle(topic, i, lang) : t,
)
return {
notebookName,
titles: cleanedTitles,
}
}
2. **Toggle** (section repliable pour détails) :
<div data-type="toggle-block" data-opened="true"><p>Cliquer pour voir les détails</p><p>Contenu détaillé...</p></div>
private isGenericNotebookName(name: string, topic: string): boolean {
const n = (name || '').trim().toLowerCase()
if (!n || n.length < 3) return true
if (n === topic.trim().toLowerCase() && topic.trim().split(/\s+/).length > 10) return true
const banned =
/^(fondements?|foundations?|ma[iî]trise|mastery|introduction|bases?|basics?|overview|getting started|notions de base|synth[eè]se|summary)$/i
return banned.test(n)
}
3. **Math** (formules LaTeX) — UTILISE LES BALISES DIRECTEMENT, pas de $$ :
Block : <div data-type="math-equation" data-latex="E = mc^2"></div>
Inline : <span data-type="inline-math" data-latex="x^2">x^2</span>
N'UTILISE JAMAIS $$ ou $ comme délimiteur — utilise TOUJOURS les balises HTML ci-dessus.
private isGenericChapterTitle(title: string): boolean {
const t = (title || '').trim()
if (!t) return true
return /^(chapitre|chapter|partie|part|section)\s*\d+$/i.test(t)
|| /^(fondements?|foundations?|ma[iî]trise|introduction|bases?|basics?|overview)$/i.test(t)
}
4. **Colonnes** (comparaison côte à côte) :
<div data-type="columns" cols="2"><div data-type="column" index="0"><p>Concept A...</p></div><div data-type="column" index="1"><p>Concept B...</p></div></div>
private fallbackChapterTitle(topic: string, index: number, lang: string): string {
const short = topic.trim().slice(0, 48) || (lang === 'fr' ? 'Sujet' : 'Topic')
const anglesFr = ['Concepts clés', 'Méthodes', 'Exemples guidés', 'Pièges fréquents', 'Mise en pratique', 'Bilan']
const anglesEn = ['Key concepts', 'Methods', 'Guided examples', 'Common pitfalls', 'Practice', 'Recap']
const angles = lang === 'fr' ? anglesFr : anglesEn
return `${angles[index % angles.length]}${short}`
}
5. **Sommaire** (début de note) :
<div data-type="outline-block"></div>
private async generateOneNote(
provider: { generateText: (p: string) => Promise<string> },
opts: {
profile: WizardProfile
topic: string
title: string
lang: string
level?: string
words: { min: number; max: number; label: string }
difficulty: string
noteIndex: number
totalNotes: number
forceLonger?: boolean
},
): Promise<GeneratedNote> {
const { profile, topic, title, lang, level, words, difficulty, noteIndex, totalNotes, forceLonger } = opts
const profileExtra = {
student: `Pedagogical note for a student: worked examples, tip + danger callouts, at least 2 exercises with brief solution hints.`,
teacher: `Lesson plan style: learning objectives, main content, 45 exercises, indicative answers.`,
engineer: `Technical documentation: precise definitions, comparison table or specs, practical examples.`,
}[profile]
6. HTML standard : <h1>/<h2>/<h3>, <p>, <ul>/<ol>/<li>, <table>, <blockquote>, <pre><code>
const prompt = `${this.languageRuleBlock(lang)}
IMPORTANT : Chaque note DOIT faire entre 800 et 1500 mots. Sois exhaustif. Développe chaque concept avec des exemples, des explications, et du contexte. N'écris pas de résumés courts.
You write ONE complete notebook chapter as HTML for a ${profile}.
${profileExtra}
Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
Overall topic: "${topic}"
Chapter title: "${title}"
Chapter ${noteIndex}/${totalNotes}
${level ? `Level: ${level}` : ''}
Target length: ${words.label}
${forceLonger ? `WARNING: previous draft was TOO SHORT. Write at least ${words.min} words of real explanation — not a summary.` : ''}
Mandatory structure (all section headings and text in ${this.langName(lang)}):
1. Short intro (why this chapter matters)
2. Core definitions / concepts with <h2>/<h3>
3. At least one worked example (step by step)
4. Callouts: info definition + tip + danger (using HTML blocks below)
5. At least one math equation if the topic is mathematical
6. Closing: key takeaways list
7. For student/teacher: short exercises section
HTML blocks:
- Callout: <div data-type="callout-block" data-callout-type="info|tip|danger|warning|success"><p>...</p></div>
- Math: <div data-type="math-equation" data-latex="..."></div> (never $$)
- Toggle optional: <div data-type="toggle-block" data-opened="false"><p>title</p><p>body</p></div>
- table, ul/ol allowed
Return ONLY:
\`\`\`json
{
"title": "${title.replace(/"/g, '\\"')}",
"difficulty": "${difficulty}",
"content": "<h2>...</h2><p>...</p>..."
}
\`\`\`
content must be rich HTML, NOT empty, NOT a 2-paragraph summary.
Escape quotes inside JSON properly.`
const raw = await provider.generateText(prompt)
const parsed = this.parseJsonObject(raw)
const content = preprocessMathInHtml(String(parsed?.content || raw || '<p></p>'))
return {
title: String(parsed?.title || title).trim() || title,
content,
difficulty: (parsed?.difficulty as GeneratedNote['difficulty']) || (difficulty as GeneratedNote['difficulty']),
}
}
private parseJsonObject(raw: string): any {
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
let jsonStr = jsonMatch ? jsonMatch[1] : raw
const start = jsonStr.indexOf('{')
const end = jsonStr.lastIndexOf('}')
if (start >= 0 && end > start) jsonStr = jsonStr.slice(start, end + 1)
try {
return JSON.parse(jsonStr)
} catch {
try {
const fixed = jsonStr
.replace(/\\(?!["\\\/bfnrtu])/g, '\\\\')
.replace(/\n/g, '\\n')
return JSON.parse(fixed)
} catch {
return null
}
}
}
private deriveNotebookName(topic: string, notes: GeneratedNote[], lang: string): string {
const fromNote = notes[0]?.title?.trim()
if (fromNote && fromNote.length <= 80 && fromNote.length >= 3) {
return fromNote.replace(/^\d+[\.\):\-]\s*/, '').slice(0, 80)
}
const cleaned = topic
.replace(/\s+/g, ' ')
.trim()
.replace(/^(je veux|i want|i need|please|peux-tu|can you|crée|create|génère|generate)\b[\s,:-]*/i, '')
if (cleaned.length > 0 && cleaned.length <= 60) return cleaned
if (cleaned.length > 60) {
const cut = cleaned.slice(0, 57)
const lastSpace = cut.lastIndexOf(' ')
return (lastSpace > 20 ? cut.slice(0, lastSpace) : cut) + '…'
}
return lang === 'fr' ? 'Nouveau carnet' : lang === 'en' ? 'New notebook' : 'Notebook'
}
private getSchemaLabels(lang: string) {
@@ -131,70 +423,6 @@ Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
}
return map[lang] || map.en
}
private parseResponse(raw: string, profile: WizardProfile, lang?: string): GeneratedCarnet {
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
let jsonStr = jsonMatch ? jsonMatch[1] : raw
// Extract the JSON object boundaries
const start = jsonStr.indexOf('{')
const end = jsonStr.lastIndexOf('}')
if (start >= 0 && end > start) {
jsonStr = jsonStr.slice(start, end + 1)
}
let parsed: any
try {
parsed = JSON.parse(jsonStr)
} catch {
// Fix common AI JSON issues: unescaped backslashes in LaTeX
try {
const fixed = jsonStr
.replace(/\\(?!["\\\/bfnrtu])/g, '\\\\') // Escape lone backslashes (LaTeX \frac etc.)
.replace(/\n/g, '\\n') // Fix raw newlines in strings
parsed = JSON.parse(fixed)
} catch {
try {
// Last resort: extract notes manually with regex
const notes: GeneratedNote[] = []
const titleMatches = [...jsonStr.matchAll(/"title"\s*:\s*"([^"]+)"/g)]
const contentMatches = [...jsonStr.matchAll(/"content"\s*:\s*"([\s\S]*?)"(?:,|\s*})/g)]
for (let i = 0; i < titleMatches.length; i++) {
notes.push({
title: titleMatches[i][1],
content: contentMatches[i]?.[1]?.replace(/\\n/g, '\n').replace(/\\"/g, '"') || '<p></p>',
difficulty: i % 3 === 0 ? 'facile' : i % 3 === 1 ? 'moyen' : 'difficile',
})
}
if (notes.length === 0) throw new Error('No notes found in response')
const fallbackLabels = this.getSchemaLabels(lang || 'fr')
return {
notes,
schemaProperties: [
{ name: fallbackLabels.status, type: 'select', options: [fallbackLabels.statusTodo, fallbackLabels.statusInProgress, fallbackLabels.statusDone] },
{ name: fallbackLabels.difficulty, type: 'select', options: [fallbackLabels.easy, fallbackLabels.medium, fallbackLabels.hard] },
],
}
} catch {
throw new Error('Failed to parse AI response')
}
}
}
const notes: GeneratedNote[] = (parsed.notes || []).map((n: any) => ({
title: String(n.title || 'Sans titre'),
content: preprocessMathInHtml(String(n.content || '<p></p>')),
difficulty: n.difficulty || undefined,
}))
const defaultLabels = this.getSchemaLabels(lang || 'fr')
const schemaProperties = parsed.schemaProperties || [
{ name: defaultLabels.status, type: 'select', options: [defaultLabels.statusTodo, defaultLabels.statusInProgress, defaultLabels.statusDone] },
{ name: defaultLabels.difficulty, type: 'select', options: [defaultLabels.easy, defaultLabels.medium, defaultLabels.hard] },
]
return { notes, schemaProperties }
}
}
export const notebookWizardService = new NotebookWizardService()

View File

@@ -0,0 +1,643 @@
/**
* Slide deck content quality: limits, normalization, narrative guards.
* Principles (consulting / PMC / think-cell):
* - One idea per slide
* - Action titles (insight, not topic labels)
* - Short claims, not walls of text
* - Charts/stats only with real numbers
* - Narrative arc, not density for density's sake
*/
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
export const SLIDE_HARD_CAP = 8
export type SlideType =
| 'title'
| 'bullets'
| 'chart'
| 'stats'
| 'table'
| 'cards'
| 'timeline'
| 'quote'
| 'comparison'
| 'equation'
| 'image'
| 'summary'
export interface SlideLimit {
max: number
min: number
labelFr: string
labelEn: string
}
/** Max deck size from real semantic word count of source notes. */
export function slideLimitFromWordCount(wordCount: number): SlideLimit {
if (wordCount < 50) {
return {
max: 3,
min: 2,
labelFr: '23 slides MAX (note très courte)',
labelEn: '23 slides MAX (very short note)',
}
}
if (wordCount < 150) {
return {
max: 4,
min: 3,
labelFr: '34 slides MAX (note courte)',
labelEn: '34 slides MAX (short note)',
}
}
if (wordCount < 400) {
return {
max: 6,
min: 4,
labelFr: '46 slides MAX (note moyenne)',
labelEn: '46 slides MAX (medium note)',
}
}
return {
max: SLIDE_HARD_CAP,
min: 5,
labelFr: '58 slides MAX (note longue) — ne jamais dépasser 8',
labelEn: '58 slides MAX (long note) — never exceed 8',
}
}
export function stripNoteMarkdown(raw: string): string {
return raw
.replace(/^---[\s\S]*?---/m, '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/https?:\/\/\S+/g, '')
.replace(
/^\*{0,2}(Connection to seed|Novelty score|Source brainstorm|Source note|Origin|Derived from|## Origin)[^:\n]*:?.*$/gim,
'',
)
.replace(/^---+$/gm, '')
.replace(/^>\s*/gm, '')
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]+`/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function countNoteWords(raw: string): number {
const plain = stripHtmlToPlainText(raw || '')
const cleaned = stripNoteMarkdown(plain || raw || '')
return cleaned.split(/\s+/).filter(Boolean).length
}
/** Plain text for the LLM: full note when possible, smart head+tail if huge. */
export function prepareNoteTextForSlides(raw: string, maxChars = 12_000): string {
const plain = stripHtmlToPlainText(raw || '')
const cleaned = stripNoteMarkdown(plain || raw || '').trim()
if (cleaned.length <= maxChars) return cleaned
const head = Math.floor(maxChars * 0.7)
const tail = maxChars - head - 40
return `${cleaned.slice(0, head)}\n\n[…]\n\n${cleaned.slice(-tail)}`
}
function trimStr(s: unknown, max = 220): string {
if (typeof s !== 'string') return ''
const t = s.replace(/\s+/g, ' ').trim()
if (t.length <= max) return t
return `${t.slice(0, max - 1).trim()}`
}
function cleanList(items: unknown, maxItems: number, maxLen: number): string[] {
if (!Array.isArray(items)) return []
const out: string[] = []
for (const it of items) {
const s = trimStr(it, maxLen)
if (!s) continue
// Keep short tokens (math symbols, single-letter variables) — only drop empty
out.push(s)
if (out.length >= maxItems) break
}
return out
}
function isNumericLike(value: string): boolean {
return /[\d]/.test(value) || /%|€|\$|k\b|m\b|bn\b/i.test(value)
}
/**
* Normalize model output into a coherent deck.
* Does not invent content — only trims, caps, fixes structure.
*/
export function normalizeSlideDeck(input: {
title?: string
theme?: string
slides?: unknown[]
}): { title: string; theme: string; slides: Record<string, unknown>[] } {
const title = trimStr(input.title || 'Présentation', 80) || 'Présentation'
const theme = (typeof input.theme === 'string' && input.theme.trim()) || 'architectural-saas'
const raw = Array.isArray(input.slides) ? input.slides : []
let slides = raw
.filter((s): s is Record<string, unknown> => Boolean(s) && typeof s === 'object')
.map((s) => normalizeOneSlide(s))
.filter(Boolean) as Record<string, unknown>[]
slides = slides.slice(0, SLIDE_HARD_CAP)
// Ensure title first
if (slides.length === 0) {
slides = [
{ type: 'title', title, subtitle: '' },
{ type: 'summary', title: 'Points clés', items: ['Contenu source insuffisant pour un deck détaillé.'] },
]
} else if (slides[0]?.type !== 'title') {
slides.unshift({
type: 'title',
title,
subtitle: typeof slides[0]?.title === 'string' ? String(slides[0].title) : '',
})
slides = slides.slice(0, SLIDE_HARD_CAP)
}
// Ensure summary last (if ≥2 slides)
if (slides.length >= 2 && slides[slides.length - 1]?.type !== 'summary') {
if (slides.length >= SLIDE_HARD_CAP) {
slides[slides.length - 1] = {
type: 'summary',
title: 'À retenir',
items: extractKeyClaims(slides).slice(0, 4),
}
} else {
slides.push({
type: 'summary',
title: 'À retenir',
items: extractKeyClaims(slides).slice(0, 4),
})
}
}
// Drop empty / invalid charts (no inventing)
slides = slides.map((s) => {
if (s.type === 'chart') {
const data = Array.isArray(s.data) ? s.data : []
const valid = data.filter(
(d: any) => d && typeof d.label === 'string' && typeof d.value === 'number' && Number.isFinite(d.value),
)
if (valid.length < 2) {
return {
type: 'bullets',
title: trimStr(s.title || 'Données', 90),
items: extractKeyClaims([s]).slice(0, 4).length
? extractKeyClaims([s]).slice(0, 4)
: ['Pas de données numériques exploitables dans la source pour un graphique.'],
}
}
return { ...s, data: valid.slice(0, 8) }
}
if (s.type === 'stats') {
const stats = Array.isArray(s.stats) ? s.stats : []
const valid = stats.filter(
(st: any) => st && typeof st.value === 'string' && typeof st.label === 'string' && isNumericLike(String(st.value)),
)
if (valid.length === 0) {
return {
type: 'bullets',
title: trimStr(s.title || 'Indicateurs', 90),
items: cleanList(
stats.map((st: any) => (st?.label ? `${st.label}: ${st.value || ''}` : st?.value)).filter(Boolean),
4,
120,
),
}
}
return { ...s, stats: valid.slice(0, 4) }
}
return s
})
// Avoid two identical types in a row when possible (keep content, only if we can skip empty)
// No reordering that would break narrative — just leave as is after structural fixes.
return { title, theme, slides }
}
function normalizeOneSlide(s: Record<string, unknown>): Record<string, unknown> | null {
const type = String(s.type || 'bullets') as SlideType
switch (type) {
case 'title':
return {
type: 'title',
title: trimStr(s.title || 'Présentation', 80),
subtitle: trimStr(s.subtitle, 140) || undefined,
notes: trimStr(s.notes, 400) || undefined,
}
case 'bullets':
return {
type: 'bullets',
title: trimStr(s.title || 'Points clés', 90),
items: cleanList(s.items, 5, 140),
notes: trimStr(s.notes, 400) || undefined,
}
case 'summary':
return {
type: 'summary',
title: trimStr(s.title || 'À retenir', 90),
items: cleanList(s.items, 5, 140),
notes: trimStr(s.notes, 400) || undefined,
}
case 'cards': {
const cards = Array.isArray(s.cards) ? s.cards : []
const cleaned = cards
.slice(0, 6)
.map((c: any) => ({
title: trimStr(c?.title, 60),
description: trimStr(c?.description, 160),
}))
.filter((c) => c.title || c.description)
return {
type: 'cards',
title: trimStr(s.title || 'Piliers', 90),
cards: cleaned,
notes: trimStr(s.notes, 400) || undefined,
}
}
case 'chart':
return {
type: 'chart',
title: trimStr(s.title || 'Données', 90),
chartType: ['bar', 'horizontal-bar', 'line', 'donut', 'radar'].includes(String(s.chartType))
? s.chartType
: 'bar',
data: Array.isArray(s.data) ? s.data : [],
subtitle: trimStr(s.subtitle, 120) || undefined,
notes: trimStr(s.notes, 400) || undefined,
}
case 'stats':
return {
type: 'stats',
title: trimStr(s.title || 'Chiffres clés', 90),
stats: Array.isArray(s.stats)
? s.stats.slice(0, 4).map((st: any) => ({
value: trimStr(st?.value, 24),
label: trimStr(st?.label, 48),
}))
: [],
notes: trimStr(s.notes, 400) || undefined,
}
case 'table':
return {
type: 'table',
title: trimStr(s.title || 'Tableau', 90),
headers: cleanList(s.headers, 6, 40),
rows: Array.isArray(s.rows)
? s.rows.slice(0, 8).map((r: unknown) => cleanList(r, 6, 60))
: [],
notes: trimStr(s.notes, 400) || undefined,
}
case 'timeline': {
const events = Array.isArray(s.events) ? s.events : []
return {
type: 'timeline',
title: trimStr(s.title || 'Chronologie', 90),
events: events.slice(0, 6).map((e: any) => ({
date: trimStr(e?.date, 40),
title: trimStr(e?.title, 80),
description: trimStr(e?.description, 140) || undefined,
})),
notes: trimStr(s.notes, 400) || undefined,
}
}
case 'quote':
return {
type: 'quote',
quote: trimStr(s.quote, 280),
author: trimStr(s.author, 60) || undefined,
context: trimStr(s.context, 160) || undefined,
notes: trimStr(s.notes, 400) || undefined,
}
case 'comparison': {
const left = (s.left && typeof s.left === 'object' ? s.left : {}) as any
const right = (s.right && typeof s.right === 'object' ? s.right : {}) as any
return {
type: 'comparison',
title: trimStr(s.title || 'Comparaison', 90),
left: {
title: trimStr(left.title || 'A', 40),
points: cleanList(left.points, 4, 100),
score: trimStr(left.score, 16) || undefined,
},
right: {
title: trimStr(right.title || 'B', 40),
points: cleanList(right.points, 4, 100),
score: trimStr(right.score, 16) || undefined,
},
notes: trimStr(s.notes, 400) || undefined,
}
}
case 'equation':
return {
type: 'equation',
title: trimStr(s.title || 'Formules', 90),
equations: Array.isArray(s.equations)
? s.equations.slice(0, 4).map((eq: any) => ({
latex: trimStr(eq?.latex, 120),
label: trimStr(eq?.label, 60) || undefined,
}))
: [],
explanation: trimStr(s.explanation, 200) || undefined,
notes: trimStr(s.notes, 400) || undefined,
}
case 'image':
return {
type: 'image',
title: trimStr(s.title || 'Illustration', 90),
url: typeof s.url === 'string' ? s.url.trim() : undefined,
caption: trimStr(s.caption, 140) || undefined,
notes: trimStr(s.notes, 400) || undefined,
}
default:
return {
type: 'bullets',
title: trimStr(s.title || 'Points', 90),
items: cleanList(s.items || s.content, 5, 140),
}
}
}
/**
* A body slide is empty if it has a title but no real payload
* (the bug that shipped title-only decks).
*/
export function isSlideBodyEmpty(slide: Record<string, unknown>): boolean {
const type = String(slide.type || '')
if (type === 'title') {
// Title slides need a non-empty title (and ideally a subtitle, not required)
return !String(slide.title || '').trim()
}
switch (type) {
case 'bullets':
case 'summary':
return !Array.isArray(slide.items) || slide.items.filter((x) => String(x || '').trim()).length < 2
case 'cards':
return (
!Array.isArray(slide.cards) ||
slide.cards.filter((c: any) => String(c?.title || c?.description || '').trim()).length < 2
)
case 'stats':
return (
!Array.isArray(slide.stats) ||
slide.stats.filter((s: any) => String(s?.value || '').trim() && String(s?.label || '').trim()).length < 2
)
case 'chart':
return (
!Array.isArray(slide.data) ||
slide.data.filter((d: any) => d && typeof d.value === 'number' && String(d.label || '').trim()).length < 2
)
case 'table':
return !Array.isArray(slide.rows) || slide.rows.length < 1
case 'timeline':
return (
!Array.isArray(slide.events) ||
slide.events.filter((e: any) => String(e?.title || '').trim()).length < 2
)
case 'quote':
return !String(slide.quote || '').trim()
case 'comparison': {
const left = (slide.left || {}) as any
const right = (slide.right || {}) as any
const lp = Array.isArray(left.points) ? left.points.filter(Boolean).length : 0
const rp = Array.isArray(right.points) ? right.points.filter(Boolean).length : 0
return lp < 1 || rp < 1
}
case 'equation':
return !Array.isArray(slide.equations) || slide.equations.length < 1
case 'image':
// image without URL is weak but caption-only is still something; require title at least
return !String(slide.title || '').trim()
default:
return true
}
}
export function listEmptyBodySlides(slides: Record<string, unknown>[]): number[] {
const empty: number[] = []
slides.forEach((s, i) => {
if (s.type === 'title') return
if (isSlideBodyEmpty(s)) empty.push(i)
})
return empty
}
/**
* Hard gate: refuse to ship a deck that is mostly titles.
* Content slides (non-title) must have real bodies.
* STEM: if formulas were extracted from the note, deck MUST include equation slides with latex.
*/
export function assertDeckHasSubstance(
deck: {
title?: string
slides: Record<string, unknown>[]
},
opts?: { requireFormulas?: string[] },
): { ok: true } | { ok: false; reason: string; emptyIndexes: number[] } {
const slides = deck.slides || []
if (slides.length < 2) {
return { ok: false, reason: 'Deck has fewer than 2 slides', emptyIndexes: [] }
}
const bodySlides = slides.filter((s) => s.type !== 'title')
if (bodySlides.length === 0) {
return { ok: false, reason: 'Deck has no body slides', emptyIndexes: [] }
}
const emptyIndexes = listEmptyBodySlides(slides)
if (emptyIndexes.length > 0) {
return {
ok: false,
reason: `${emptyIndexes.length} body slide(s) have titles only (no bullets/cards/stats/…)`,
emptyIndexes,
}
}
// At least 2 content claims across the deck
const claims = extractKeyClaims(slides)
if (claims.length < 2) {
return { ok: false, reason: 'Deck has insufficient claims/body text', emptyIndexes: [] }
}
// STEM gate: note had formulas → at least one equation slide with non-empty latex
const required = opts?.requireFormulas?.filter((f) => f && f.trim()) || []
if (required.length > 0) {
const eqSlides = slides.filter((s) => s.type === 'equation')
const latexCount = eqSlides.reduce((n, s) => {
const eqs = Array.isArray(s.equations) ? s.equations : []
return (
n +
eqs.filter((e: any) => typeof e?.latex === 'string' && e.latex.trim().length >= 2).length
)
}, 0)
if (latexCount < 1) {
return {
ok: false,
reason: `STEM gate: note has ${required.length} formula(s) but deck has no equation slides with LaTeX`,
emptyIndexes: [],
}
}
}
return { ok: true }
}
/** Inject equation slides from extracted formulas (deterministic STEM fix). */
export function injectEquationSlidesFromFormulas(
deck: { title: string; theme: string; slides: Record<string, unknown>[] },
formulas: string[],
opts?: { lang?: 'fr' | 'en' },
): { title: string; theme: string; slides: Record<string, unknown>[] } {
const forms = formulas.map((f) => f.trim()).filter((f) => f.length >= 2).slice(0, 8)
if (forms.length === 0) return deck
const hasEq = deck.slides.some((s) => {
if (s.type !== 'equation') return false
const eqs = Array.isArray(s.equations) ? s.equations : []
return eqs.some((e: any) => String(e?.latex || '').trim().length >= 2)
})
if (hasEq) return deck
const lang = opts?.lang || 'fr'
const titleSlide = deck.slides.find((s) => s.type === 'title')
const summarySlide = deck.slides.find((s) => s.type === 'summary')
const middle = deck.slides.filter((s) => s.type !== 'title' && s.type !== 'summary')
const groups: string[][] = []
for (let i = 0; i < forms.length; i += 2) {
groups.push(forms.slice(i, i + 2))
}
const eqSlides = groups.slice(0, 2).map((group, gi) => ({
type: 'equation',
title:
gi === 0
? lang === 'fr'
? 'Équations fondamentales'
: 'Core equations'
: lang === 'fr'
? 'Formules complémentaires'
: 'Additional formulas',
equations: group.map((latex, i) => ({
latex,
label: lang === 'fr' ? `Formule ${gi * 2 + i + 1}` : `Formula ${gi * 2 + i + 1}`,
})),
explanation:
lang === 'fr'
? 'Formules extraites de la note source'
: 'Formulas extracted from the source note',
}))
const slides = [
titleSlide || { type: 'title', title: deck.title },
...eqSlides,
...middle,
summarySlide || {
type: 'summary',
title: lang === 'fr' ? 'À retenir' : 'Key takeaways',
items:
lang === 'fr'
? ['Revoir les formules', 'Appliquer sur un exemple', 'Vérifier les conditions']
: ['Review formulas', 'Apply on an example', 'Check conditions'],
},
].slice(0, SLIDE_HARD_CAP)
return normalizeSlideDeck({ title: deck.title, theme: deck.theme, slides })
}
function extractKeyClaims(slides: Record<string, unknown>[]): string[] {
const claims: string[] = []
for (const s of slides) {
if (s.type === 'title' || s.type === 'summary') continue
if (Array.isArray(s.items)) {
for (const it of s.items) {
if (typeof it === 'string' && it.trim()) claims.push(trimStr(it, 120))
}
}
if (Array.isArray(s.cards)) {
for (const c of s.cards as any[]) {
const line = [c?.title, c?.description].filter(Boolean).join(' — ')
if (line) claims.push(trimStr(line, 120))
}
}
if (typeof s.title === 'string' && s.type !== 'bullets') {
claims.push(trimStr(s.title, 100))
}
}
return [...new Set(claims)].filter(Boolean)
}
/** System-prompt quality rules (shared FR/EN cores built in agent-executor). */
export const SLIDE_CONTENT_PRINCIPLES_FR = `
═══ QUALITÉ DU CONTENU (OBLIGATOIRE) ═══
Tu es un consultant McKinsey/BCG qui structure un deck exécutif. Le public lit les titres d'abord.
1. UNE IDÉE PAR SLIDE
- Le "title" de chaque slide est un TITRE D'ACTION : une phrase d'insight, pas un libellé de sujet.
- Mauvais : "Contexte" / "Analyse" / "Résultats"
- Bon : "Le délai de livraison a doublé en 6 mois" / "Trois leviers expliquent 80 % du retard"
2. ARC NARRATIF (dans l'ordre)
title → contexte/problème → insight principal → preuves (chiffres ou exemples) → implications/options → summary (next steps)
Ne pas coller une liste de sujets sans fil conducteur.
3. TEXTE COURT ET UTILE
- Bullets : 3 à 5 MAX, 1 claim distinct par ligne (818 mots idéalement).
- INTERDIT : paragraphes, répétition du titre, filler ("Il est important de noter…", "En conclusion on peut dire…").
- Chaque bullet doit apporter une information nouvelle tirée de la note.
4. DONNÉES HONNÊTES
- "chart" / "stats" UNIQUEMENT si des chiffres REELS figurent dans la note.
- INTERDIT d'inventer des KPI, scores radar, pourcentages ou tendances.
- Sans chiffres : cards, comparison, timeline, quote, bullets — jamais de faux graphiques.
5. VARIÉTÉ UTILE
- Varier les types selon le contenu (pas 4× bullets d'affilée si d'autres formats collent mieux).
- Premier slide = "title", dernier = "summary" avec 35 takeaways actionnables.
6. FIDÉLITÉ À LA SOURCE
- Reformuler pour la clarté, ne pas inventer de faits, noms, dates ou conclusions absents de la note.
- Si la note est courte : peu de slides, contenu dense et fidèle — pas de padding.
7. NOTES ORATEUR (optionnel)
- Champ "notes" : 12 phrases pour le présentateur (non affiché sur la slide).
`
export const SLIDE_CONTENT_PRINCIPLES_EN = `
═══ CONTENT QUALITY (MANDATORY) ═══
You are a McKinsey/BCG consultant building an executive deck. Audiences read titles first.
1. ONE IDEA PER SLIDE
- Each slide "title" is an ACTION TITLE: an insight sentence, not a topic label.
- Bad: "Context" / "Analysis" / "Results"
- Good: "Delivery time doubled in 6 months" / "Three levers explain 80% of the delay"
2. NARRATIVE ARC (in order)
title → context/problem → key insight → evidence (numbers or examples) → implications/options → summary (next steps)
Do not dump a topic list without a storyline.
3. SHORT, USEFUL TEXT
- Bullets: 35 MAX, one distinct claim per line (ideally 818 words).
- FORBIDDEN: paragraphs, restating the title, filler ("It is important to note…").
- Each bullet must add new information from the note.
4. HONEST DATA
- "chart" / "stats" ONLY if REAL numbers appear in the note.
- NEVER invent KPIs, radar scores, percentages, or trends.
- No numbers: use cards, comparison, timeline, quote, bullets — never fake charts.
5. USEFUL VARIETY
- Vary types based on content (not 4× bullets if another type fits better).
- First slide = "title", last = "summary" with 35 actionable takeaways.
6. FIDELITY TO SOURCE
- Rephrase for clarity; do not invent facts, names, dates, or conclusions absent from the note.
- Short notes → few slides, faithful content — no padding.
7. SPEAKER NOTES (optional)
- "notes" field: 12 sentences for the presenter (not shown on the slide).
`

View File

@@ -0,0 +1,696 @@
/**
* Slide deck generation — DeckForge / PPTAgent / Hermes-inspired pipeline.
*
* Sources of truth (research):
* - DeckForge: intent → outline → expand PER SLIDE → refine → validate IR
* https://github.com/Whatsonyourmind/deckforge
* - PPTAgent: outline with retrieved document content, then iterative fill
* https://github.com/icip-cas/PPTAgent arXiv:2501.03936
* - Hermes PowerPoint skill: domain palettes, no empty slides, QA before ship
* https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/productivity/productivity-powerpoint
* - OpenClaw ppt-html-pipeline: per-page blueprint, no large blank regions
* https://github.com/Yuancircle/openclaw-ppt-html-pipeline
*
* NEVER persists a deck that fails substance validation.
*/
import { generateObject, generateText } from 'ai'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { buildPresentationHTML } from '@/lib/ai/tools/slides-html-builder'
import {
assertDeckHasSubstance,
countNoteWords,
injectEquationSlidesFromFormulas,
normalizeSlideDeck,
prepareNoteTextForSlides,
slideLimitFromWordCount,
SLIDE_HARD_CAP,
} from '@/lib/ai/services/slide-content-quality'
import { extractSourceAssets, type SourceAssets } from '@/lib/ai/services/slide-source-assets'
import {
intentPromptHints,
normalizeSlideIntent,
type SlideIntent,
} from '@/lib/ai/services/slide-intent'
import { getSystemConfig } from '@/lib/config'
import { getSlidesProvider } from '@/lib/ai/factory'
// ── Types catalog (includes equation — critical for STEM notes) ──────────────
const SlideTypeEnum = z.enum([
'title',
'bullets',
'equation',
'cards',
'comparison',
'timeline',
'stats',
'chart',
'table',
'quote',
'summary',
])
const OutlineSlideSchema = z.object({
position: z.number(),
type: SlideTypeEnum,
/** Assertive headline, max ~10 words (DeckForge: max 8) */
headline: z.string(),
/** 25 key points that MUST appear on the slide body */
keyPoints: z.array(z.string()).min(1).max(6),
/** For equation slides: latex strings to include */
formulas: z.array(z.string()).optional(),
narrativeRole: z.enum(['opening', 'evidence', 'transition', 'conclusion', 'data']).optional(),
})
const OutlineSchema = z.object({
title: z.string(),
narrativeArc: z.enum(['pyramid', 'scr', 'mece', 'chronological', 'pedagogical']).optional(),
slides: z.array(OutlineSlideSchema).min(2).max(SLIDE_HARD_CAP),
})
const ExpandedSlideSchema = z.object({
type: SlideTypeEnum,
title: z.string(),
subtitle: z.string().optional(),
items: z.array(z.string()).optional(),
equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })).optional(),
explanation: z.string().optional(),
cards: z.array(z.object({ title: z.string(), description: z.string() })).optional(),
left: z
.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() })
.optional(),
right: z
.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() })
.optional(),
events: z
.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() }))
.optional(),
stats: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']).optional(),
data: z.array(z.object({ label: z.string(), value: z.number() })).optional(),
headers: z.array(z.string()).optional(),
rows: z.array(z.array(z.string())).optional(),
quote: z.string().optional(),
author: z.string().optional(),
context: z.string().optional(),
notes: z.string().optional(),
})
export interface GenerateSlideDeckParams {
userId: string
noteIds?: string[]
sourceNotebookId?: string | null
theme?: string | null
template?: string | null
lang?: 'fr' | 'en'
contentLanguage?: string | null
actionId?: string | null
/** Product intent: purpose, audience, slide count */
intent?: SlideIntent | null
}
export interface GenerateSlideDeckResult {
success: boolean
canvasId?: string
canvasName?: string
slideCount?: number
error?: string
mode?: string
}
const LANG_NAMES: Record<string, string> = {
fr: 'French',
en: 'English',
es: 'Spanish',
de: 'German',
it: 'Italian',
pt: 'Portuguese',
nl: 'Dutch',
pl: 'Polish',
ru: 'Russian',
zh: 'Chinese',
ja: 'Japanese',
ko: 'Korean',
ar: 'Arabic',
fa: 'Persian (Farsi)',
hi: 'Hindi',
}
// ── LLM helpers ──────────────────────────────────────────────────────────────
export function extractJsonPayload(text: string): unknown | null {
if (!text) return null
const raw = text.trim()
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)
const candidate = fenced?.[1]?.trim() || raw
const tryParse = (s: string) => {
try {
return JSON.parse(s)
} catch {
return null
}
}
let parsed = tryParse(candidate)
if (parsed) return parsed
const brace = candidate.match(/\{[\s\S]*\}/)
if (brace) parsed = tryParse(brace[0])
return parsed
}
async function llmObject<T>(opts: {
model: any
schema: z.ZodType<T>
system: string
prompt: string
}): Promise<T> {
const { model, schema, system, prompt } = opts
try {
const { object } = await generateObject({ model, schema, system, prompt })
return object as T
} catch (err) {
console.warn('[SlideDeck] generateObject failed → text JSON:', (err as Error)?.message)
const { text } = await generateText({
model,
system,
prompt: `${prompt}\n\nOutput ONE JSON object only.`,
})
const raw = extractJsonPayload(text)
if (!raw) throw new Error(`Non-JSON model output: ${String(text).slice(0, 180)}`)
const parsed = schema.safeParse(raw)
if (parsed.success) return parsed.data
return raw as T
}
}
// ── Notes load ───────────────────────────────────────────────────────────────
async function loadNotes(params: GenerateSlideDeckParams) {
if (params.noteIds?.length) {
return prisma.note.findMany({
where: {
id: { in: params.noteIds },
userId: params.userId,
isArchived: false,
trashedAt: null,
},
select: { id: true, title: true, content: true, createdAt: true, language: true },
})
}
if (params.sourceNotebookId) {
return prisma.note.findMany({
where: {
notebookId: params.sourceNotebookId,
userId: params.userId,
isArchived: false,
trashedAt: null,
},
orderBy: { createdAt: 'desc' },
take: 15,
select: { id: true, title: true, content: true, createdAt: true, language: true },
})
}
return []
}
// ── Stage prompts (DeckForge-style) ──────────────────────────────────────────
function outlineSystem(lang: 'fr' | 'en', maxSlides: number, assets: SourceAssets): string {
const mathRule = assets.hasMath
? lang === 'fr'
? `DOMAINE MATH/STEM DÉTECTÉ: tu DOIS inclure au moins ${Math.min(2, Math.max(1, assets.formulas.length))} slides de type "equation" qui portent les formules extraites. Ne remplace PAS les formules par des bullets vagues.`
: `MATH/STEM DOMAIN DETECTED: you MUST include at least ${Math.min(2, Math.max(1, assets.formulas.length))} slides of type "equation" carrying the extracted formulas. Do NOT replace formulas with vague bullets.`
: ''
if (lang === 'fr') {
return `Tu es l'architecte narratif DeckForge/PPTAgent pour Memento.
Tu produis UNIQUEMENT un outline JSON (pas le corps final des slides).
Règles (DeckForge + think-cell):
- Max ${maxSlides} slides
- Headline ASSERTIVE, max 10 mots (INTERDIT: "Contexte", "Introduction", "Présentation", "Overview", "Points clés", "Conclusion" seuls)
- keyPoints: 25 FAITS CONCRETS tirés de la note (chiffres, noms, formules) — jamais de filler
- Chaque slide = UNE idée actionnable (titre = insight, pas libellé de section)
- Arc pédagogique pour cours: title → définitions/équations → propriétés → exemples → summary
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
- Slide 1 = title, dernière = summary
- INTERDIT slides vides, listes de 1 mot, ou répéter le titre de la note
${mathRule}
Réponds en JSON OutlineSchema.`
}
return `You are DeckForge/PPTAgent narrative architect for Memento.
Output ONLY outline JSON (not full slide bodies).
Rules:
- Max ${maxSlides} slides
- ASSERTIVE headlines, max 10 words (FORBIDDEN alone: Context, Introduction, Overview, Key points, Conclusion)
- keyPoints: 25 CONCRETE facts from the note (numbers, names, formulas) — no filler
- One actionable idea per slide (title = insight, not section label)
- Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
- First=title, last=summary
- FORBIDDEN empty slides, one-word lists, or repeating the note title alone
${mathRule}
JSON OutlineSchema only.`
}
function expandOneSystem(lang: 'fr' | 'en'): string {
if (lang === 'fr') {
return `Tu es le rédacteur de slides DeckForge. Tu EXPANDES UNE slide à la fois.
INTERDIT: slide avec seulement un titre.
OBLIGATOIRE selon type:
- title: title + subtitle (1 phrase)
- equation: title + equations[{latex,label?}] (min 1, latex fidèle à la note) + explanation courte
- bullets: title + items[3-5] claims ≤18 mots
- cards: title + cards[2-4] {title, description}
- comparison: title + left/right avec points[2-4] chacun
- timeline: title + events[2-5]
- stats: title + stats[2-4] SEULEMENT si chiffres réels fournis
- chart: title + data[2+] SEULEMENT si chiffres réels
- summary: title + items[3-5] actionnables
- quote: quote non vide
JSON ExpandedSlide uniquement.`
}
return `You are DeckForge slide writer. Expand ONE slide only.
FORBIDDEN: title-only slides.
REQUIRED by type:
- title: title + subtitle
- equation: title + equations[{latex,label?}] min 1 + short explanation
- bullets: title + items[3-5] ≤18 words each
- cards: title + cards[2-4]
- comparison: left/right points[2-4] each
- timeline: events[2-5]
- stats/chart: only with real numbers provided
- summary: items[3-5]
- quote: non-empty quote
ExpandedSlide JSON only.`
}
/** Force math slides into outline when server extracted formulas (PPTAgent retrieval). */
function enforceMathOutline(
outline: z.infer<typeof OutlineSchema>,
assets: SourceAssets,
maxSlides: number,
): z.infer<typeof OutlineSchema> {
if (!assets.hasMath || assets.formulas.length === 0) return outline
const hasEq = outline.slides.some((s) => s.type === 'equation')
if (hasEq) {
// Ensure equation slides list formulas
return {
...outline,
slides: outline.slides.map((s, i) => {
if (s.type !== 'equation') return s
const chunk = assets.formulas.slice(i % assets.formulas.length, (i % assets.formulas.length) + 2)
return {
...s,
formulas: s.formulas?.length ? s.formulas : chunk.length ? chunk : assets.formulas.slice(0, 2),
}
}),
}
}
// Inject equation slides after title
const title = outline.slides.find((s) => s.type === 'title') || {
position: 1,
type: 'title' as const,
headline: outline.title,
keyPoints: assets.keySentences.slice(0, 2),
narrativeRole: 'opening' as const,
}
const rest = outline.slides.filter((s) => s.type !== 'title' && s.type !== 'summary')
const summary = outline.slides.find((s) => s.type === 'summary') || {
position: 99,
type: 'summary' as const,
headline: 'Points clés',
keyPoints: assets.keySentences.slice(0, 3),
narrativeRole: 'conclusion' as const,
}
const eqSlides: z.infer<typeof OutlineSlideSchema>[] = []
const groups = Math.min(2, Math.ceil(assets.formulas.length / 2))
for (let g = 0; g < groups; g++) {
const formulas = assets.formulas.slice(g * 2, g * 2 + 2)
eqSlides.push({
position: g + 2,
type: 'equation',
headline: g === 0 ? 'Équations fondamentales' : 'Formules complémentaires',
keyPoints: formulas.map((f) => f.slice(0, 80)),
formulas,
narrativeRole: 'evidence',
})
}
let slides = [title, ...eqSlides, ...rest, summary].slice(0, maxSlides)
// Ensure last is summary
if (slides[slides.length - 1]?.type !== 'summary') {
slides = [...slides.slice(0, maxSlides - 1), summary]
}
return {
...outline,
slides: slides.map((s, i) => ({ ...s, position: i + 1 })),
}
}
function fallbackExpandFromOutline(
o: z.infer<typeof OutlineSlideSchema>,
assets: SourceAssets,
): z.infer<typeof ExpandedSlideSchema> {
const type = o.type
const title = o.headline
if (type === 'title') {
return {
type: 'title',
title,
subtitle: o.keyPoints[0] || assets.keySentences[0] || '',
}
}
if (type === 'equation') {
const forms = o.formulas?.length ? o.formulas : assets.formulas.slice(0, 3)
return {
type: 'equation',
title,
equations: (forms.length ? forms : ['f(x) = ?']).map((latex, i) => ({
latex,
label: o.keyPoints[i] || `Formule ${i + 1}`,
})),
explanation: o.keyPoints.join(' · ').slice(0, 200),
}
}
if (type === 'cards') {
const pts = o.keyPoints.length >= 2 ? o.keyPoints : assets.keySentences.slice(0, 3)
return {
type: 'cards',
title,
cards: pts.slice(0, 4).map((p, i) => ({
title: `Point ${i + 1}`,
description: p,
})),
}
}
if (type === 'summary') {
return {
type: 'summary',
title,
items: (o.keyPoints.length ? o.keyPoints : assets.keySentences).slice(0, 5),
}
}
if (type === 'stats' && assets.numbers.length >= 2) {
return {
type: 'stats',
title,
stats: assets.numbers.slice(0, 4).map((n) => ({
value: n.raw,
label: n.label || title,
})),
}
}
if (type === 'chart' && assets.numbers.length >= 2) {
return {
type: 'chart',
title,
chartType: 'bar',
data: assets.numbers.slice(0, 6).map((n) => ({
label: n.label.slice(0, 20) || n.raw,
value: n.value,
})),
}
}
// default bullets — use keyPoints + sentences to fill density
const items = [
...o.keyPoints,
...assets.keySentences.filter((s) => !o.keyPoints.some((k) => s.includes(k.slice(0, 20)))),
].slice(0, 5)
while (items.length < 3 && assets.keySentences[items.length]) {
items.push(assets.keySentences[items.length]!)
}
return {
type: type === 'quote' ? 'bullets' : type === 'comparison' ? 'bullets' : type === 'timeline' ? 'bullets' : 'bullets',
title,
items: items.length >= 2 ? items : [title, assets.keySentences[0] || 'Voir la note source'].filter(Boolean),
}
}
// ── Persist ──────────────────────────────────────────────────────────────────
async function persist(
userId: string,
deck: ReturnType<typeof normalizeSlideDeck>,
actionId: string | null | undefined,
mode: string,
) {
const html = buildPresentationHTML({
title: deck.title,
theme: deck.theme,
slides: deck.slides as any,
})
const canvas = await prisma.canvas.create({
data: {
name: deck.title || 'Présentation',
data: JSON.stringify({
type: 'slides',
title: deck.title,
html,
slideCount: deck.slides.length,
theme: deck.theme,
mode,
spec: { title: deck.title, theme: deck.theme, slides: deck.slides },
}),
userId,
},
})
if (actionId) {
await prisma.agentAction
.update({
where: { id: actionId },
data: {
status: 'success',
result: canvas.id,
log: `Slides OK (${mode}): ${deck.slides.length} slides, ${Math.round(html.length / 1024)}KB, substance OK`,
},
})
.catch(() => {})
}
return canvas
}
// ── Main ─────────────────────────────────────────────────────────────────────
export async function generateSlideDeck(params: GenerateSlideDeckParams): Promise<GenerateSlideDeckResult> {
const lang = params.lang === 'en' ? 'en' : 'fr'
const notes = await loadNotes(params)
if (!notes.length) {
return {
success: false,
error: lang === 'fr' ? 'Aucune note source.' : 'No source notes.',
}
}
const noteLang = notes[0]?.language
const contentLang =
params.contentLanguage ||
(noteLang && LANG_NAMES[noteLang] ? LANG_NAMES[noteLang] : lang === 'fr' ? 'French' : 'English')
const combined = notes.map((n) => n.content || '').join('\n\n')
const assets = extractSourceAssets(combined)
const notesText = notes
.map((n) => `### ${n.title || 'Note'}\n${prepareNoteTextForSlides(n.content || '', 10_000)}`)
.join('\n\n')
const wordCount = notes.reduce((s, n) => s + countNoteWords(n.content || ''), 0)
const limit = slideLimitFromWordCount(wordCount)
const intent = normalizeSlideIntent(params.intent || { template: params.template || 'auto' })
// User-chosen count overrides word-count heuristic
const targetMax = intent.slideCount || limit.max
const targetMin = intent.slideCount || limit.min
const theme =
params.theme && params.theme !== 'auto'
? params.theme
: assets.hasMath || intent.purpose === 'course'
? 'clinical-precision'
: 'architectural-saas'
// Admin-configured slides model (or chat if slides not set). Never rename model IDs.
const sysConfig = await getSystemConfig()
const model = getSlidesProvider(sysConfig).getModel()
const intentHints = intentPromptHints(intent, lang)
try {
// ── Stage 1: Outline (DeckForge outliner) ──
const assetsBlock = [
assets.formulas.length
? `FORMULES EXTRAITES (à placer dans des slides equation):\n${assets.formulas.map((f, i) => `${i + 1}. ${f}`).join('\n')}`
: '',
assets.numbers.length
? `CHIFFRES:\n${assets.numbers
.slice(0, 10)
.map((n) => `- ${n.label}: ${n.raw}`)
.join('\n')}`
: '',
assets.keySentences.length
? `PHRASES CLÉS:\n${assets.keySentences
.slice(0, 8)
.map((s) => `- ${s}`)
.join('\n')}`
: '',
]
.filter(Boolean)
.join('\n\n')
let outline = await llmObject({
model,
schema: OutlineSchema,
system: outlineSystem(lang, targetMax, assets),
prompt:
lang === 'fr'
? `Langue du deck: ${contentLang}.\nNombre de slides cible: ${targetMax} (min ${targetMin}).\nThème: ${theme}.\n${intentHints ? `\n## Intent produit\n${intentHints}\n` : ''}\n## Assets serveur\n${assetsBlock || '(aucun)'}\n\n## Note\n${notesText}\n\nProduis l'outline JSON.`
: `Deck language: ${contentLang}.\nTarget slides: ${targetMax} (min ${targetMin}).\nTheme: ${theme}.\n${intentHints ? `\n## Product intent\n${intentHints}\n` : ''}\n## Server assets\n${assetsBlock || '(none)'}\n\n## Note\n${notesText}\n\nProduce outline JSON.`,
})
// STEM or course purpose → force equation slides when formulas exist
if (assets.hasMath || intent.purpose === 'course') {
outline = enforceMathOutline(outline, assets, targetMax)
}
// ── Stage 2: Expand PER SLIDE (DeckForge SlideWriter loop) ──
const expanded: z.infer<typeof ExpandedSlideSchema>[] = []
for (const slideOutline of outline.slides) {
try {
const formulaHint =
slideOutline.type === 'equation'
? `\nFORMULES OBLIGATOIRES pour cette slide:\n${(slideOutline.formulas || assets.formulas).slice(0, 4).join('\n')}`
: assets.formulas.length && slideOutline.type === 'bullets'
? `\n(Formules dispo si besoin: ${assets.formulas.slice(0, 2).join(' ; ')})`
: ''
const one = await llmObject({
model,
schema: ExpandedSlideSchema,
system: expandOneSystem(lang),
prompt:
lang === 'fr'
? `Langue: ${contentLang}.
${intentHints ? `Intent: ${intentHints}\n` : ''}Slide ${slideOutline.position}/${outline.slides.length}
type: ${slideOutline.type}
headline: ${slideOutline.headline}
keyPoints: ${JSON.stringify(slideOutline.keyPoints)}
role: ${slideOutline.narrativeRole || 'evidence'}
${formulaHint}
Contexte note (extrait):\n${notesText.slice(0, 6000)}
Expand cette slide en JSON ExpandedSlide COMPLET (corps non vide).`
: `Language: ${contentLang}.
${intentHints ? `Intent: ${intentHints}\n` : ''}Slide ${slideOutline.position}/${outline.slides.length}
type: ${slideOutline.type}
headline: ${slideOutline.headline}
keyPoints: ${JSON.stringify(slideOutline.keyPoints)}
${formulaHint}
Note excerpt:\n${notesText.slice(0, 6000)}
Expand into full ExpandedSlide JSON (non-empty body).`,
})
// Force type/title from outline if model drifts
one.type = slideOutline.type
if (!one.title) one.title = slideOutline.headline
// Inject formulas if equation empty
if (one.type === 'equation' && (!one.equations || one.equations.length === 0)) {
const forms = slideOutline.formulas?.length ? slideOutline.formulas : assets.formulas
one.equations = forms.slice(0, 4).map((latex, i) => ({
latex,
label: slideOutline.keyPoints[i] || `Eq. ${i + 1}`,
}))
}
expanded.push(one)
} catch (e) {
console.warn('[SlideDeck] expand failed for slide, using deterministic fallback', e)
expanded.push(fallbackExpandFromOutline(slideOutline, assets))
}
}
// ── Stage 3: Normalize + STEM inject + substance gate ──
let normalized = normalizeSlideDeck({
title: outline.title,
theme,
slides: expanded as unknown[],
})
// Always inject formulas if missing (deterministic — never ship STEM without equations)
if (assets.formulas.length > 0) {
normalized = injectEquationSlidesFromFormulas(normalized, assets.formulas, { lang })
}
const stemOpts = {
requireFormulas: assets.formulas.length > 0 ? assets.formulas : undefined,
}
let gate = assertDeckHasSubstance(normalized, stemOpts)
let mode = 'outline+per-slide-expand'
if (!gate.ok) {
// Deterministic fill for empty body slides using outline + assets
const slides = normalized.slides.map((s, i) => {
if (s.type === 'title') return s
if (!gate.ok && gate.emptyIndexes.includes(i)) {
const o = outline.slides[Math.min(i, outline.slides.length - 1)]!
return fallbackExpandFromOutline(o, assets) as unknown as Record<string, unknown>
}
return s
})
normalized = normalizeSlideDeck({
title: outline.title,
theme,
slides,
})
if (assets.formulas.length > 0) {
normalized = injectEquationSlidesFromFormulas(normalized, assets.formulas, { lang })
}
gate = assertDeckHasSubstance(normalized, stemOpts)
mode = 'outline+expand+deterministic-fill'
}
if (!gate.ok) {
const msg =
lang === 'fr'
? `Refus de livrer un deck incomplet: ${gate.reason}`
: `Refusing incomplete deck: ${gate.reason}`
if (params.actionId) {
await prisma.agentAction
.update({ where: { id: params.actionId }, data: { status: 'failure', log: msg } })
.catch(() => {})
}
return { success: false, error: msg, mode }
}
if (!normalized.theme) normalized.theme = theme
const canvas = await persist(params.userId, normalized, params.actionId, mode)
return {
success: true,
canvasId: canvas.id,
canvasName: canvas.name,
slideCount: normalized.slides.length,
mode,
}
} catch (e: any) {
console.error('[SlideDeck] FATAL', e)
const message = e?.message || String(e)
if (params.actionId) {
await prisma.agentAction
.update({
where: { id: params.actionId },
data: {
status: 'failure',
log: lang === 'fr' ? `Échec slides: ${message}` : `Slide fail: ${message}`,
},
})
.catch(() => {})
}
return { success: false, error: message }
}
}

View File

@@ -0,0 +1,151 @@
/**
* Slide generation intent (Sprint A product layer).
* Passed from UI → API → agent.description → generateSlideDeck.
*/
export type SlidePurpose =
| 'auto'
| 'course'
| 'board'
| 'project'
| 'strategy'
| 'pitch'
| 'summary'
export type SlideAudience = 'auto' | 'student' | 'board' | 'team' | 'client' | 'general'
export interface SlideIntent {
purpose?: SlidePurpose
audience?: SlideAudience
/** Target body slides including title+summary, clamped 38 */
slideCount?: number
/** Existing executive template id or auto */
template?: string
}
const PURPOSE_SET = new Set<string>([
'auto',
'course',
'board',
'project',
'strategy',
'pitch',
'summary',
])
const AUDIENCE_SET = new Set<string>([
'auto',
'student',
'board',
'team',
'client',
'general',
])
export function clampSlideCount(n: unknown): number | undefined {
const v = typeof n === 'number' ? n : typeof n === 'string' ? parseInt(n, 10) : NaN
if (!Number.isFinite(v)) return undefined
return Math.min(8, Math.max(3, Math.round(v)))
}
export function normalizeSlideIntent(raw: Partial<SlideIntent> | null | undefined): SlideIntent {
if (!raw || typeof raw !== 'object') return {}
const purpose = raw.purpose && PURPOSE_SET.has(raw.purpose) ? raw.purpose : 'auto'
const audience = raw.audience && AUDIENCE_SET.has(raw.audience) ? raw.audience : 'auto'
const slideCount = clampSlideCount(raw.slideCount)
const template =
typeof raw.template === 'string' && raw.template.trim() ? raw.template.trim() : 'auto'
return { purpose, audience, slideCount, template }
}
/** Encode intent into agent.description (backward compatible with template:xxx). */
export function encodeSlideIntentDescription(intent: SlideIntent): string {
const n = normalizeSlideIntent(intent)
// Prefer structured JSON for the structured pipeline
return `slide-intent:${JSON.stringify(n)}`
}
/** Decode agent.description → intent */
export function decodeSlideIntentDescription(description?: string | null): SlideIntent {
if (!description) return {}
if (description.startsWith('slide-intent:')) {
try {
return normalizeSlideIntent(JSON.parse(description.slice('slide-intent:'.length)))
} catch {
return {}
}
}
if (description.startsWith('template:')) {
const template = description.replace('template:', '').trim() || 'auto'
// Map legacy templates to purpose
const purposeMap: Record<string, SlidePurpose> = {
'board-update': 'board',
'project-status': 'project',
'strategy-review': 'strategy',
'quarterly-results': 'board',
}
return normalizeSlideIntent({
template,
purpose: purposeMap[template] || 'auto',
})
}
return {}
}
/** Human guidance injected into outline/expand prompts */
export function intentPromptHints(intent: SlideIntent, lang: 'fr' | 'en'): string {
const n = normalizeSlideIntent(intent)
const lines: string[] = []
if (n.purpose && n.purpose !== 'auto') {
const mapFr: Record<SlidePurpose, string> = {
auto: '',
course: 'Type: COURS / leçon — arc pédagogique (définitions → formules → exemples → résumé). Priorité aux slides equation si maths.',
board: 'Type: COMITÉ / board — KPIs, décisions, risques, next steps.',
project: 'Type: SUIVI PROJET — avancement, blockers, livrables, roadmap.',
strategy: 'Type: STRATÉGIE — options, trade-offs, recommandations.',
pitch: 'Type: PITCH — problème, solution, preuve, call to action.',
summary: 'Type: SYNTHÈSE — peu de slides, dense, takeaways actionnables.',
}
const mapEn: Record<SlidePurpose, string> = {
auto: '',
course: 'Type: LESSON — pedagogical arc (defs → formulas → examples → summary). Prefer equation slides for math.',
board: 'Type: BOARD — KPIs, decisions, risks, next steps.',
project: 'Type: PROJECT STATUS — progress, blockers, deliverables, roadmap.',
strategy: 'Type: STRATEGY — options, trade-offs, recommendations.',
pitch: 'Type: PITCH — problem, solution, proof, CTA.',
summary: 'Type: SUMMARY — few dense slides, actionable takeaways.',
}
lines.push(lang === 'fr' ? mapFr[n.purpose] : mapEn[n.purpose])
}
if (n.audience && n.audience !== 'auto') {
const mapFr: Record<SlideAudience, string> = {
auto: '',
student: 'Audience: étudiants — clarté, définitions, exemples, formules explicites.',
board: 'Audience: comité de direction — concis, chiffres, décisions.',
team: 'Audience: équipe — opérationnel, actions, responsabilités.',
client: 'Audience: client — bénéfices, preuves, sans jargon interne.',
general: 'Audience: grand public — langage simple.',
}
const mapEn: Record<SlideAudience, string> = {
auto: '',
student: 'Audience: students — clarity, definitions, examples, explicit formulas.',
board: 'Audience: board — concise, numbers, decisions.',
team: 'Audience: team — operational, actions, ownership.',
client: 'Audience: client — benefits, proof, no internal jargon.',
general: 'Audience: general — plain language.',
}
lines.push(lang === 'fr' ? mapFr[n.audience] : mapEn[n.audience])
}
if (n.slideCount) {
lines.push(
lang === 'fr'
? `Nombre de slides imposé: EXACTEMENT ${n.slideCount} (title + corps + summary inclus).`
: `Slide count required: EXACTLY ${n.slideCount} (including title + body + summary).`,
)
}
return lines.filter(Boolean).join('\n')
}

View File

@@ -0,0 +1,103 @@
/**
* Server-side extraction of usable assets from a note BEFORE any slide LLM call.
* Inspired by PPTAgent (retrieve relevant content per slide) and DeckForge (data_needs).
* Critical for STEM notes: formulas must be harvested, not hoped for from the model.
*/
export interface SourceAssets {
formulas: string[]
numbers: Array<{ label: string; value: number; raw: string }>
keySentences: string[]
hasMath: boolean
hasNumbers: boolean
wordCount: number
}
/** Extract LaTeX / equation-like fragments from note plain text or HTML. */
export function extractFormulas(raw: string): string[] {
if (!raw) return []
const found: string[] = []
const push = (s: string) => {
const t = s.replace(/\s+/g, ' ').trim()
if (t.length >= 2 && t.length <= 280 && !found.includes(t)) found.push(t)
}
// $$ ... $$
for (const m of raw.matchAll(/\$\$([\s\S]+?)\$\$/g)) push(m[1] || '')
// \[ ... \]
for (const m of raw.matchAll(/\\\[([\s\S]+?)\\\]/g)) push(m[1] || '')
// \( ... \)
for (const m of raw.matchAll(/\\\(([\s\S]+?)\\\)/g)) push(m[1] || '')
// $ ... $ (single line)
for (const m of raw.matchAll(/(?<![\\$])\$([^$\n]{2,120})\$(?!\$)/g)) push(m[1] || '')
// Common latex commands in free text
for (const m of raw.matchAll(
/(?:^|[\s(])((?:\\frac\{[^}]+\}\{[^}]+\}|\\partial|\\nabla|\\sum|\\int|\\frac|\\cdot|\\times|\\left|\\right|y'|y''|d[xy]\/d[xy]|∂)[^\n]{0,100})/g,
)) {
push(m[1] || '')
}
// Equation-like: f(x) = ..., dy/dx = ...
for (const m of raw.matchAll(
/(?:^|\n)\s*([A-Za-zΑ-ω∂∇][A-Za-z0-9_'"]*\s*(?:\([^)]*\))?\s*[=≈≡]\s*[^\n]{3,100})/g,
)) {
const line = (m[1] || '').trim()
if (/[=≈]/.test(line) && /[a-zA-Z0-9]/.test(line)) push(line)
}
return found.slice(0, 24)
}
export function extractNumbers(raw: string): Array<{ label: string; value: number; raw: string }> {
if (!raw) return []
const out: Array<{ label: string; value: number; raw: string }> = []
const re =
/(?:^|[^\d])((?:≈|~)?\s*-?\d+(?:[.,]\d+)?)\s*(%|€|\$|k|m|bn|ms|s|°C)?\b/gi
let m: RegExpExecArray | null
while ((m = re.exec(raw)) !== null && out.length < 20) {
const numStr = (m[1] || '').replace(/[≈~\s]/g, '').replace(',', '.')
const value = parseFloat(numStr)
if (!Number.isFinite(value)) continue
const unit = m[2] || ''
// crude label: 40 chars before
const start = Math.max(0, m.index - 40)
const ctx = raw.slice(start, m.index).replace(/\s+/g, ' ').trim()
const label = ctx.split(/[.;:!?\n]/).pop()?.trim().slice(-32) || `n${out.length + 1}`
out.push({ label, value: unit === '%' ? value : value, raw: `${numStr}${unit}` })
}
return out
}
export function extractKeySentences(raw: string, max = 16): string[] {
if (!raw) return []
const plain = raw
.replace(/<[^>]+>/g, ' ')
.replace(/\$\$[\s\S]+?\$\$/g, ' ')
.replace(/\\\[[\s\S]+?\\\]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const parts = plain
.split(/(?<=[.!?])\s+/)
.map((s) => s.trim())
.filter((s) => s.length >= 40 && s.length <= 220)
const out: string[] = []
for (const p of parts) {
if (out.length >= max) break
if (!out.some((x) => x.slice(0, 40) === p.slice(0, 40))) out.push(p)
}
return out
}
export function extractSourceAssets(raw: string): SourceAssets {
const formulas = extractFormulas(raw)
const numbers = extractNumbers(raw)
const keySentences = extractKeySentences(raw)
const wordCount = raw.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length
return {
formulas,
numbers,
keySentences,
hasMath: formulas.length > 0 || /équat|equat|différen|differen|dériv|deriv|intégr|integr|EDO|ODE|PDE|latex/i.test(raw),
hasNumbers: numbers.length >= 2,
wordCount,
}
}

View File

@@ -72,17 +72,19 @@ function renderTitle(slide: SlideTitle, r: Recipe, idx: number): string {
}
function renderBullets(slide: SlideBullets, r: Recipe, idx: number): string {
const items = slide.items.map((item, i) => `
<div class="reveal" style="display:flex;align-items:flex-start;gap:16px;padding:8px 0;">
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:10px;flex-shrink:0;"></span>
<span style="font-size:1.1rem;line-height:1.65;color:${r.text};">${esc(item)}</span>
const list = (slide.items || []).filter(Boolean)
// Dense layout: 2-col grid when 4+ bullets (Hermes/OpenClaw: avoid sparse empty slides)
const twoCol = list.length >= 4
const items = list.map((item) => `
<div class="reveal" style="display:flex;align-items:flex-start;gap:14px;padding:12px 14px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;">
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:8px;flex-shrink:0;"></span>
<span style="font-size:clamp(0.95rem,1.6vw,1.15rem);line-height:1.55;color:${r.text};">${esc(item)}</span>
</div>`).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
<div style="display:flex;flex-direction:column;gap:6px;">${items}</div>
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
<div style="display:grid;grid-template-columns:${twoCol ? '1fr 1fr' : '1fr'};gap:12px;">${items}</div>
</div>
</div>`
}
@@ -109,8 +111,11 @@ function renderChart(slide: SlideChart, r: Recipe, idx: number): string {
}
function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
const cols = Math.min(slide.stats.length, 4)
const items = slide.stats.map(s => `
const stats = (slide.stats || []).filter((s) => s && (s.value || s.label))
// Never force empty columns: 1 stat = full width, 2 = half, 34 = equal
const n = Math.max(1, stats.length)
const cols = n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 4
const items = stats.map(s => `
<div class="reveal" style="text-align:center;padding:28px 16px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:16px;">
<div style="font-size:clamp(2.2rem,4vw,3.5rem);font-weight:900;line-height:1;background:linear-gradient(135deg,${r.accent1},${r.accent2});-webkit-background-clip:text;-webkit-text-fill-color:transparent;" data-count="${extractNum(s.value)}" data-suffix="${extractSuffix(s.value)}">${esc(s.value)}</div>
<div style="font-size:0.8rem;color:${r.textMuted};margin-top:10px;letter-spacing:0.12em;text-transform:uppercase;font-weight:600;">${esc(s.label)}</div>
@@ -118,9 +123,8 @@ function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:20px;">${items}</div>
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 24px;">${esc(slide.title)}</h2>
<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:16px;">${items}</div>
</div>
</div>`
}
@@ -144,20 +148,22 @@ function renderTable(slide: SlideTable, r: Recipe, idx: number): string {
}
function renderCards(slide: SlideCards, r: Recipe, idx: number): string {
const cols = slide.cards.length <= 2 ? 2 : slide.cards.length === 4 ? 2 : 3
const items = slide.cards.map((c, i) => `
<div class="reveal" style="padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;position:relative;overflow:hidden;">
const cards = (slide.cards || []).filter((c) => c && (c.title || c.description))
// 1 card → full width (no empty second column). 2 → 2 cols. 3 → 3. 4+ → 2x2
const n = Math.max(1, cards.length)
const cols = n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 2
const items = cards.map((c, i) => `
<div class="reveal" style="padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;position:relative;overflow:hidden;min-height:120px;">
<span style="position:absolute;top:10px;right:14px;font-size:1.8rem;font-weight:900;color:${r.accent1};opacity:0.12;">${String(i + 1).padStart(2, '0')}</span>
<div style="width:24px;height:3px;background:${r.accent1};border-radius:2px;margin-bottom:12px;"></div>
<div style="font-size:1rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
<div style="font-size:0.88rem;color:${r.textSecondary};line-height:1.6;">${esc(c.description)}</div>
<div style="font-size:1.05rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
<div style="font-size:0.9rem;color:${r.textSecondary};line-height:1.6;">${esc(c.description)}</div>
</div>`).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:16px;">${items}</div>
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 24px;">${esc(slide.title)}</h2>
<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:16px;">${items}</div>
</div>
</div>`
}
@@ -214,18 +220,22 @@ function renderComparison(slide: SlideComparison, r: Recipe, idx: number): strin
}
function renderEquation(slide: SlideEquation, r: Recipe, idx: number): string {
const eqs = slide.equations.map(eq => `
<div class="reveal" style="text-align:center;padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;margin-bottom:12px;">
<div style="font-size:clamp(1.4rem,3vw,2.2rem);font-family:'KaTeX_Main',serif;color:${r.text};letter-spacing:0.02em;">${esc(eq.latex)}</div>
${eq.label ? `<div style="font-size:0.8rem;color:${r.textMuted};margin-top:8px;">${esc(eq.label)}</div>` : ''}
</div>`).join('')
const eqs = (slide.equations || []).map((eq) => {
// data-latex for KaTeX client render; also show fallback monospaced text
const latex = eq.latex || ''
return `
<div class="reveal" style="text-align:center;padding:22px 24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;margin-bottom:14px;">
<div class="eq-latex" data-latex="${esc(latex)}" style="font-size:clamp(1.25rem,2.6vw,1.85rem);color:${r.text};overflow-x:auto;padding:8px 0;"></div>
<noscript><code style="font-size:1.1rem;">${esc(latex)}</code></noscript>
${eq.label ? `<div style="font-size:0.85rem;color:${r.textMuted};margin-top:10px;font-weight:600;">${esc(eq.label)}</div>` : ''}
</div>`
}).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
${eqs}
${slide.explanation ? `<p class="reveal" style="font-size:0.95rem;color:${r.textSecondary};line-height:1.7;margin-top:16px;text-align:center;max-width:700px;margin-inline:auto;">${esc(slide.explanation)}</p>` : ''}
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
${eqs || `<div class="reveal" style="color:${r.textMuted};">Aucune équation</div>`}
${slide.explanation ? `<p class="reveal" style="font-size:1rem;color:${r.textSecondary};line-height:1.7;margin-top:18px;max-width:780px;">${esc(slide.explanation)}</p>` : ''}
</div>
</div>`
}
@@ -429,14 +439,19 @@ export function buildPresentationHTML(input: PresentationInput): string {
<title>${esc(input.title)}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="${r.fontUrl}" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<style>
:root{--color-bg:${r.bg};--color-text:${r.text};--color-text-secondary:${r.textSecondary};--color-text-muted:${r.textMuted};--color-accent-1:${r.accent1};--color-accent-2:${r.accent2};--color-glass-bg:${r.glassBg};--color-glass-border:${r.glassBorder};--svg-grid-line:${r.svgGrid};--font-display:${r.fontDisplay},serif;--font-body:${r.fontBody},system-ui,sans-serif;}
*,*::before,*::after{box-sizing:border-box;}
html,body{margin:0;padding:0;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
.deck{width:100vw;height:100vh;position:relative;}
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.96);transition:opacity 0.6s ease,transform 0.6s ease;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center;}
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
/* Fill iframe/container — use % not 100vw (vw breaks lab preview letterboxing) */
.deck{width:100%;height:100%;position:relative;overflow:hidden;}
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.98);transition:opacity 0.45s ease,transform 0.45s ease;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center;}
.slide.active{opacity:1;transform:scale(1);pointer-events:all;}
.slide>.content{position:relative;z-index:2;width:100%;max-width:1100px;padding:clamp(1.5rem,4vw,4rem);}
.slide>.content{position:relative;z-index:2;width:100%;max-width:min(1100px,94%);height:100%;max-height:100%;padding:clamp(1.25rem,3.5vmin,3.5rem);display:flex;flex-direction:column;justify-content:center;box-sizing:border-box;}
.eq-latex{max-width:100%;}
.eq-latex .katex-display{margin:0.4em 0;overflow-x:auto;overflow-y:hidden;}
.gradient-mesh{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0;}
.blob{position:absolute;border-radius:50%;filter:blur(80px);animation:float-slow var(--dur,18s) ease-in-out infinite;}
@keyframes float-slow{0%{transform:translate(0,0) scale(1);}50%{transform:translate(-40px,40px) scale(0.92);}100%{transform:translate(0,0) scale(1);}}
@@ -464,13 +479,14 @@ ${slidesHtml}
<script>
var current=1,slides=document.querySelectorAll('.slide'),total=slides.length;
(function(){var d=document.getElementById('nav-dots');for(var i=1;i<=total;i++){var b=document.createElement('button');b.className='nav-dot'+(i===1?' active':'');(function(n){b.addEventListener('click',function(){goToSlide(n)});})(i);d.appendChild(b);}updateNav();})();
function goToSlide(n){if(n<1||n>total||n===current)return;var p=document.querySelector('.slide.active'),nx=document.querySelector('.slide[data-slide="'+n+'"]');if(!nx)return;if(p)p.classList.remove('active');nx.classList.add('active');current=n;updateNav();animateSlide(nx);}
function renderKatex(){if(typeof katex==='undefined')return;document.querySelectorAll('.eq-latex[data-latex]').forEach(function(el){var tex=el.getAttribute('data-latex')||'';try{katex.render(tex,el,{throwOnError:false,displayMode:true});}catch(e){el.textContent=tex;}});}
function goToSlide(n){if(n<1||n>total||n===current)return;var p=document.querySelector('.slide.active'),nx=document.querySelector('.slide[data-slide="'+n+'"]');if(!nx)return;if(p)p.classList.remove('active');nx.classList.add('active');current=n;updateNav();animateSlide(nx);setTimeout(renderKatex,40);}
function changeSlide(dir){var n=current+dir;if(n<1)n=total;if(n>total)n=1;goToSlide(n);}
function updateNav(){document.querySelectorAll('.nav-dot').forEach(function(d,i){d.classList.toggle('active',(i+1)===current);});var c=document.getElementById('slide-counter');if(c)c.textContent=current+' / '+total;try{parent.postMessage({type:'slideChange',current:current,total:total},'*');}catch(e){}}
document.addEventListener('keydown',function(e){if(e.key==='ArrowRight'||e.key===' ')changeSlide(1);if(e.key==='ArrowLeft')changeSlide(-1);});
var tx=0;document.addEventListener('touchstart',function(e){tx=e.touches[0].clientX;},{passive:true});document.addEventListener('touchend',function(e){var dx=tx-e.changedTouches[0].clientX;if(Math.abs(dx)>50)changeSlide(dx>0?1:-1);},{passive:true});
function animateSlide(s){s.querySelectorAll('.reveal').forEach(function(el,i){el.style.transition='none';el.style.opacity='0';el.style.transform='translateY(18px)';el.offsetHeight;el.style.transition='opacity 0.35s ease '+(i*0.07)+'s, transform 0.35s ease '+(i*0.07)+'s';el.style.opacity='1';el.style.transform='translateY(0)';});s.querySelectorAll('.bar[data-height]').forEach(function(b){b.style.height='0%';setTimeout(function(){b.style.height=b.dataset.height+'%';},100);});s.querySelectorAll('.bar-fill[data-width]').forEach(function(b){b.style.width='0%';setTimeout(function(){b.style.width=b.dataset.width+'%';},100);});s.querySelectorAll('.line-path').forEach(function(p){var l=p.getTotalLength?p.getTotalLength():2000;p.style.strokeDasharray=l;p.style.strokeDashoffset=l;setTimeout(function(){p.style.strokeDashoffset='0';},100);});s.querySelectorAll('[data-count]').forEach(function(el){var t=parseFloat(el.dataset.count),sf=el.dataset.suffix||'',st=30,inc=t/st,i=0,v=0;var iv=setInterval(function(){v+=inc;i++;el.textContent=(i>=st?t:Math.round(v))+sf;if(i>=st)clearInterval(iv);},30);});}
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);},300);}
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);renderKatex();},300);}else{setTimeout(renderKatex,400);}
// Particles
document.querySelectorAll('canvas[id^="particles-"]').forEach(function(c){c.width=window.innerWidth;c.height=window.innerHeight;var ctx=c.getContext('2d'),pts=[];for(var i=0;i<50;i++)pts.push({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3,r:Math.random()*2+0.5});function draw(){ctx.clearRect(0,0,c.width,c.height);pts.forEach(function(p){p.x+=p.vx;p.y+=p.vy;if(p.x<0)p.x=c.width;if(p.x>c.width)p.x=0;if(p.y<0)p.y=c.height;if(p.y>c.height)p.y=0;ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fillStyle='${r.accent1}80';ctx.fill();});requestAnimationFrame(draw);}draw();});
</script>

View File

@@ -5,100 +5,193 @@ import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
import { buildPresentationHTML } from './slides-html-builder'
import { normalizeSlideDeck, SLIDE_HARD_CAP } from '@/lib/ai/services/slide-content-quality'
const notesField = z.string().optional().describe('Optional speaker notes (12 sentences for the presenter, not shown on slide)')
const slideSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('title'), title: z.string(), subtitle: z.string().optional() }),
z.object({ type: z.literal('bullets'), title: z.string(), items: z.array(z.string()) }),
z.object({ type: z.literal('chart'), title: z.string(), chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']), data: z.array(z.object({ label: z.string(), value: z.number() })), subtitle: z.string().optional() }),
z.object({ type: z.literal('stats'), title: z.string(), stats: z.array(z.object({ value: z.string(), label: z.string() })) }),
z.object({ type: z.literal('table'), title: z.string(), headers: z.array(z.string()), rows: z.array(z.array(z.string())) }),
z.object({ type: z.literal('cards'), title: z.string(), cards: z.array(z.object({ title: z.string(), description: z.string() })) }),
z.object({ type: z.literal('timeline'), title: z.string(), events: z.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() })) }),
z.object({ type: z.literal('quote'), quote: z.string(), author: z.string().optional(), context: z.string().optional() }),
z.object({ type: z.literal('comparison'), title: z.string(), left: z.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }), right: z.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }) }),
z.object({ type: z.literal('equation'), title: z.string(), equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })), explanation: z.string().optional() }),
z.object({ type: z.literal('image'), title: z.string(), url: z.string().optional(), caption: z.string().optional() }),
z.object({ type: z.literal('summary'), title: z.string(), items: z.array(z.string()) }),
z.object({
type: z.literal('title'),
title: z.string().describe('Action title / deck name'),
subtitle: z.string().optional().describe('One-line context or audience promise'),
notes: notesField,
}),
z.object({
type: z.literal('bullets'),
title: z.string().describe('Action title = the insight, not a topic label'),
items: z.array(z.string()).max(5).describe('35 short claims (818 words), each distinct, from the note'),
notes: notesField,
}),
z.object({
type: z.literal('chart'),
title: z.string().describe('Action title stating what the chart proves'),
chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']),
data: z.array(z.object({ label: z.string(), value: z.number() })).min(2).max(8),
subtitle: z.string().optional(),
notes: notesField,
}),
z.object({
type: z.literal('stats'),
title: z.string(),
stats: z.array(z.object({ value: z.string(), label: z.string() })).min(2).max(4),
notes: notesField,
}),
z.object({
type: z.literal('table'),
title: z.string(),
headers: z.array(z.string()).max(6),
rows: z.array(z.array(z.string())).max(8),
notes: notesField,
}),
z.object({
type: z.literal('cards'),
title: z.string(),
cards: z
.array(z.object({ title: z.string(), description: z.string() }))
.min(2)
.max(6)
.describe('24 cards preferred; short titles + 12 sentence descriptions'),
notes: notesField,
}),
z.object({
type: z.literal('timeline'),
title: z.string(),
events: z
.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() }))
.min(2)
.max(6),
notes: notesField,
}),
z.object({
type: z.literal('quote'),
quote: z.string().describe('Exact or faithful quote from the note'),
author: z.string().optional(),
context: z.string().optional().describe('Why this quote matters'),
notes: notesField,
}),
z.object({
type: z.literal('comparison'),
title: z.string().describe('Action title of the comparison insight'),
left: z.object({
title: z.string(),
points: z.array(z.string()).max(4),
score: z.string().optional(),
}),
right: z.object({
title: z.string(),
points: z.array(z.string()).max(4),
score: z.string().optional(),
}),
notes: notesField,
}),
z.object({
type: z.literal('equation'),
title: z.string(),
equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })).max(4),
explanation: z.string().optional(),
notes: notesField,
}),
z.object({
type: z.literal('image'),
title: z.string(),
url: z.string().optional().describe('Only if a real image URL exists in the note'),
caption: z.string().optional(),
notes: notesField,
}),
z.object({
type: z.literal('summary'),
title: z.string().describe('e.g. Next steps / Key takeaways'),
items: z.array(z.string()).max(5).describe('35 actionable takeaways'),
notes: notesField,
}),
])
toolRegistry.register({
name: 'generate_slides',
description: 'Renders a structured presentation from JSON data into a full animated HTML file and saves it.',
description: `Create an executive presentation from structured slide data (rendered to HTML by the server).
CONTENT RULES (quality > quantity):
- One idea per slide; titles are ACTION TITLES (insights), not topic labels like "Context" or "Analysis"
- Narrative: title → problem/context → insight → evidence → implications → summary
- Bullets: 35 max, short claims (≈818 words), no filler paragraphs
- chart/stats ONLY with real numbers from the source note — NEVER invent KPIs or radar scores
- First slide type "title", last type "summary"
- Hard limit: ${SLIDE_HARD_CAP} slides; fewer for short notes
- All facts from the note; optional "notes" field for speaker cues
Slide types: title | bullets | chart | stats | table | cards | timeline | quote | comparison | equation | image | summary`,
isInternal: true,
buildTool: (ctx) =>
tool({
description: `Create a presentation from structured slide data. Each slide has a type and corresponding content.
description: `Create a high-quality executive deck. Prefer short action titles and sparse, high-signal content over dense filler.
Available slide types:
- "title": title, subtitle (opening slide with particles)
- "bullets": title, items[] (bullet list with 4-6 items of 15+ words each)
- "chart": title, chartType (bar|horizontal-bar|line|donut|radar), data[{label,value}], subtitle
- "stats": title, stats[{value:"98%", label:"KPI name"}] (3-4 animated KPIs)
- "table": title, headers[], rows[][] (data table)
- "cards": title, cards[{title, description}] (3-6 info cards)
- "timeline": title, events[{date, title, description}] (chronological events)
- "quote": quote, author, context (citation with analysis)
- "comparison": title, left{title, points[], score}, right{...} (A vs B)
- "equation": title, equations[{latex, label}], explanation (math formulas)
- "image": title, url, caption (image slide)
- "summary": title, items[] (conclusion with checkmarks)
Types:
- title: title, subtitle?
- bullets: title (insight), items[3-5] short claims
- chart: ONLY real numbers — chartType bar|horizontal-bar|line|donut|radar, data[{label,value}]
- stats: 24 real KPIs {value,label}
- table / cards / timeline / quote / comparison / equation / image / summary
RULES:
- Count the plain-text words in the note (ignore markdown, URLs, metadata, headers)
- <50 words → 2-3 slides MAXIMUM (title + 1 content + summary)
- 50-150 words → 3-4 slides max
- 150-400 words → 5-6 slides max
- >400 words → 7-8 slides max — HARD LIMIT, NEVER exceed 8
- First slide MUST be type "title"
- Last slide MUST be type "summary"
- Include "chart" ONLY if real numeric data exists in the note — otherwise FORBIDDEN
- Use VARIED types — never 2 identical types in a row
- All text must come from the source note — never invent data`,
Rules: first=title, last=summary, max ${SLIDE_HARD_CAP} slides, no invented data, no fake charts.`,
inputSchema: z.object({
title: z.string().describe('Short presentation title (6 words max)'),
theme: z.string().optional().describe('Visual recipe: architectural-saas, midnight-cathedral, aurora-borealis, venture-pitch, clinical-precision, coastal-morning, etc.'),
slides: z.array(slideSchema).max(8).describe('Array of slide objects, 3-8 slides MAX'),
title: z.string().describe('Short deck title (max ~8 words)'),
theme: z
.string()
.optional()
.describe(
'Visual recipe: architectural-saas, midnight-cathedral, aurora-borealis, venture-pitch, clinical-precision, coastal-morning, etc.',
),
slides: z.array(slideSchema).max(SLIDE_HARD_CAP).describe(`Slide array, 2${SLIDE_HARD_CAP} items`),
}),
execute: async ({ title, theme, slides }) => {
try {
// Hard cap: never more than 8 slides regardless of what the model outputs
const cappedSlides = slides.slice(0, 8)
const normalized = normalizeSlideDeck({ title, theme, slides: slides as unknown[] })
const html = buildPresentationHTML({ title, theme, slides: cappedSlides as any })
const html = buildPresentationHTML({
title: normalized.title,
theme: normalized.theme,
slides: normalized.slides as any,
})
const canvas = await prisma.canvas.create({
data: {
name: title || 'Présentation',
name: normalized.title || 'Présentation',
data: JSON.stringify({
type: 'slides',
title: title || 'Présentation',
title: normalized.title || 'Présentation',
html,
slideCount: cappedSlides.length,
theme: theme || 'architectural-saas',
spec: { title, theme, slides: cappedSlides },
slideCount: normalized.slides.length,
theme: normalized.theme || 'architectural-saas',
spec: {
title: normalized.title,
theme: normalized.theme,
slides: normalized.slides,
},
}),
userId: ctx.userId,
},
})
if (ctx.actionId) {
await prisma.agentAction.update({
where: { id: ctx.actionId },
data: {
status: 'success',
result: canvas.id,
log: `Slides generated: ${cappedSlides.length} slides, ${Math.round(html.length / 1024)}KB`,
},
}).catch(err => console.error('[Slides Tool] Failed to update action status:', err))
await prisma.agentAction
.update({
where: { id: ctx.actionId },
data: {
status: 'success',
result: canvas.id,
log: `Slides generated: ${normalized.slides.length} slides, ${Math.round(html.length / 1024)}KB (quality-normalized)`,
},
})
.catch((err) => console.error('[Slides Tool] Failed to update action status:', err))
}
return {
success: true,
canvasId: canvas.id,
canvasName: canvas.name,
slideCount: cappedSlides.length,
message: `Presentation "${canvas.name}" created with ${cappedSlides.length} slides.`,
slideCount: normalized.slides.length,
message: `Presentation "${canvas.name}" created with ${normalized.slides.length} slides.`,
}
} catch (e: any) {
console.error('[Slides Tool] FATAL:', e)

View File

@@ -17,6 +17,12 @@ export interface ToolUseOptions {
systemPrompt?: string
messages?: any[]
prompt?: string
/**
* Force tool usage when models (e.g. lite Gemini) prefer free text.
* - 'auto' | 'required' | 'none'
* - { type: 'tool', toolName: 'generate_slides' }
*/
toolChoice?: any
}
export interface ToolCallResult {

View File

@@ -43,41 +43,68 @@ export function normalizeThemeId(raw: string | null | undefined): ThemeId {
return 'light'
}
/** Cookie lu par le layout serveur pour éviter le flash clair au changement de route. */
export const THEME_COOKIE_NAME = 'memento-theme'
/**
* Applique le thème sur `<html>` : `class="dark"` et/ou `data-theme`.
* - `light` → papier par défaut (`:root`), pas de `data-theme`
* - `dark` → sombre global (`.dark`)
* - `auto` → `.dark` si prefers-color-scheme: dark
* - palettes nommées → `data-theme="<id>"` ; `midnight` force aussi `.dark` (variante sombre du thème)
*
* Important : on nenlève JAMAIS `dark` avant de le remettre (sinon flash clair
* dune frame à chaque navigation / re-render de ThemeInitializer).
*/
export function applyDocumentTheme(theme: string): void {
if (typeof document === 'undefined') return
const t = normalizeThemeId(theme)
const root = document.documentElement
root.classList.remove('dark')
root.removeAttribute('data-theme')
if (t === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
root.classList.add('dark')
}
return
}
const wantsDark =
t === 'dark' ||
t === 'midnight' ||
(t === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
if (t === 'dark') {
// Classe dark : bascule atomique (pas de remove puis add)
if (wantsDark) {
root.classList.add('dark')
return
} else {
root.classList.remove('dark')
}
if (t === 'light') {
return
try {
root.style.colorScheme = wantsDark ? 'dark' : 'light'
} catch {
/* ignore */
}
if ((NAMED_PALETTE_IDS as readonly string[]).includes(t)) {
// data-theme pour les palettes nommées uniquement
if (t === 'auto' || t === 'dark' || t === 'light') {
root.removeAttribute('data-theme')
} else if ((NAMED_PALETTE_IDS as readonly string[]).includes(t)) {
root.setAttribute('data-theme', t)
if (t === 'midnight') {
root.classList.add('dark')
}
} else {
root.removeAttribute('data-theme')
}
root.dataset.appliedTheme = t
}
/** Persiste le thème (localStorage + cookie) pour que le serveur connaisse le dark. */
export function persistThemePreference(theme: string): void {
const t = normalizeThemeId(theme)
try {
localStorage.setItem('theme-preference', t)
} catch {
/* ignore */
}
try {
// 1 an — lu par le layout racine via cookies()
document.cookie = `${THEME_COOKIE_NAME}=${encodeURIComponent(t)}; path=/; max-age=31536000; SameSite=Lax`
} catch {
/* ignore */
}
applyDocumentTheme(t)
}

View File

@@ -13,6 +13,7 @@ export type AuditAction =
| 'SUBSCRIPTION_OVERRIDE'
| 'BILLING_CONFIG_UPDATED'
| 'PLAN_ENTITLEMENT_UPDATED'
| 'QUOTA_RESET'
export interface AuditLogParams {
userId?: string | null

View File

@@ -0,0 +1,159 @@
/**
* Packs de crédits IA (achat one-shot Stripe, mode payment).
* Les crédits achetés sajoutent à `purchasedBalance` (reportables entre mois).
*/
import { getConfigValue } from '@/lib/config'
import { stripe } from '@/lib/stripe'
export type CreditPackId = 'S' | 'M' | 'L'
export type CreditPackDef = {
id: CreditPackId
credits: number
/** Prix daffichage par défaut (si Stripe non configuré) */
defaultDisplay: string
defaultAmount: number
currency: string
priceConfigKey: string
}
/** Catalogue fixe — crédits par pack (option M). */
export const CREDIT_PACKS: Record<CreditPackId, CreditPackDef> = {
S: {
id: 'S',
credits: 100,
defaultDisplay: '4,90 €',
defaultAmount: 4.9,
currency: 'EUR',
priceConfigKey: 'STRIPE_PRICE_CREDITS_S',
},
M: {
id: 'M',
credits: 500,
defaultDisplay: '19,90 €',
defaultAmount: 19.9,
currency: 'EUR',
priceConfigKey: 'STRIPE_PRICE_CREDITS_M',
},
L: {
id: 'L',
credits: 2000,
defaultDisplay: '49,90 €',
defaultAmount: 49.9,
currency: 'EUR',
priceConfigKey: 'STRIPE_PRICE_CREDITS_L',
},
}
export const CREDIT_PACK_IDS = Object.keys(CREDIT_PACKS) as CreditPackId[]
export function isCreditPackId(value: string): value is CreditPackId {
return value === 'S' || value === 'M' || value === 'L'
}
export function getCreditPack(id: CreditPackId): CreditPackDef {
return CREDIT_PACKS[id]
}
export async function resolvePackPriceId(packId: CreditPackId): Promise<string> {
const pack = CREDIT_PACKS[packId]
const fromDb = await getConfigValue(pack.priceConfigKey, '')
const priceId = fromDb || process.env[pack.priceConfigKey] || ''
if (priceId) return priceId
const isMock =
!process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'
if (isMock && process.env.NODE_ENV !== 'test') {
return `price_mock_credits_${packId.toLowerCase()}`
}
throw new Error(`No Stripe price ID configured for credit pack ${packId}`)
}
export type PackPublicPrice = {
id: CreditPackId
credits: number
display: string
amount: number
currency: string
configured: boolean
}
function formatMoney(amount: number, currency: string): string {
const cur = currency.toUpperCase()
if (cur === 'EUR') {
return `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} €`
}
if (cur === 'USD') {
return `$${amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
}
if (cur === 'GBP') {
return `£${amount.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
}
return `${amount} ${cur}`
}
export async function getPackPublicPrices(): Promise<PackPublicPrice[]> {
const isMock =
!process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'
return Promise.all(
CREDIT_PACK_IDS.map(async (id) => {
const pack = CREDIT_PACKS[id]
let display = pack.defaultDisplay
let amount = pack.defaultAmount
let currency = pack.currency
let configured = false
try {
const priceId = await resolvePackPriceId(id)
if (priceId && !priceId.startsWith('price_mock_')) {
configured = true
if (!isMock) {
const price = await stripe.prices.retrieve(priceId)
if (price.unit_amount != null) {
amount = price.unit_amount / 100
currency = price.currency.toUpperCase()
display = formatMoney(amount, currency)
}
}
}
} catch {
/* pack non configuré — on garde le prix catalogue */
}
return {
id,
credits: pack.credits,
display,
amount,
currency,
// true seulement si un vrai price_… est en admin/env (pas les mocks)
configured,
}
}),
)
}
/** Retrouve packId + crédits depuis un price ID Stripe (webhook). */
export async function resolvePackFromPriceId(
priceId: string,
): Promise<{ packId: CreditPackId; credits: number } | null> {
if (!priceId) return null
if (priceId.startsWith('price_mock_credits_')) {
const suffix = priceId.replace('price_mock_credits_', '').toUpperCase()
if (isCreditPackId(suffix)) {
return { packId: suffix, credits: CREDIT_PACKS[suffix].credits }
}
}
for (const id of CREDIT_PACK_IDS) {
const pack = CREDIT_PACKS[id]
const fromDb = await getConfigValue(pack.priceConfigKey, '')
const configured = fromDb || process.env[pack.priceConfigKey] || ''
if (configured && configured === priceId) {
return { packId: id, credits: pack.credits }
}
}
return null
}

View File

@@ -11,6 +11,9 @@ const ENV_FALLBACKS: Record<string, string> = {
AI_MODEL_CHAT: process.env.AI_MODEL_CHAT || '',
AI_PROVIDER_CHAT_FALLBACK: process.env.AI_PROVIDER_CHAT_FALLBACK || '',
AI_MODEL_CHAT_FALLBACK: process.env.AI_MODEL_CHAT_FALLBACK || '',
// Slides generation — optional dedicated model (never auto-renamed)
AI_PROVIDER_SLIDES: process.env.AI_PROVIDER_SLIDES || '',
AI_MODEL_SLIDES: process.env.AI_MODEL_SLIDES || '',
AI_PROVIDER_TAGS_FALLBACK: process.env.AI_PROVIDER_TAGS_FALLBACK || '',
AI_MODEL_TAGS_FALLBACK: process.env.AI_MODEL_TAGS_FALLBACK || '',
AI_PROVIDER_EMBEDDING_FALLBACK: process.env.AI_PROVIDER_EMBEDDING_FALLBACK || '',

753
memento-note/lib/credits.ts Normal file
View File

@@ -0,0 +1,753 @@
/**
* Crédits IA globaux (modèle A).
* Un seul solde ; chaque action a un coût ; le tableau d'usage agrège le ledger par feature.
*/
import { prisma } from '@/lib/prisma'
import { redis } from '@/lib/redis'
import { getCurrentPeriodKey, VALID_FEATURES, type FeatureName } from '@/lib/quota-utils'
import type { SubscriptionTier } from '@/lib/plan-entitlements'
// Imports dynamiques vers entitlements pour éviter les cycles
// (entitlements délègue aussi vers credits pour reserveUsageOrThrow).
async function getEffectiveTier(userId: string): Promise<SubscriptionTier> {
const { getEffectiveTier: fn } = await import('@/lib/entitlements')
return fn(userId)
}
async function makeQuotaExceeded(
feature: string,
limit: number,
used: number,
userId: string,
) {
const { QuotaExceededError } = await import('@/lib/entitlements')
const tier = await getEffectiveTier(userId)
return new QuotaExceededError('PRO', feature, limit, used, false, { currentTier: tier })
}
/**
* Allocations mensuelles — option M (alignée marché IA 20252026).
* Sources marché : enveloppes type 100 / ~1 000 / ~4 000 (agrégateurs multi-modèles,
* ordres de grandeur Copilot ~1 000 crédits Pro, grilles Starter/Pro/Ultra).
* BASIC 100 · PRO 1 000 · BUSINESS 4 000 · ENTERPRISE illimité.
*/
export const CREDIT_ALLOCATIONS: Record<SubscriptionTier, number | 'unlimited'> = {
BASIC: 100,
PRO: 1000,
BUSINESS: 4000,
ENTERPRISE: 'unlimited',
}
/** Coût unitaire fixe par feature (slides : voir slideGenerateCreditCost). */
export const CREDIT_COSTS: Record<string, number> = {
semantic_search: 1,
auto_tag: 1,
auto_title: 1,
reformulate: 1,
chat: 1,
brainstorm_create: 5,
brainstorm_expand: 1,
brainstorm_enrich: 1,
suggest_charts: 2,
publish_enhance: 3,
ai_flashcard: 3,
voice_transcribe: 1,
excalidraw_generate: 4,
slide_generate: 7, // défaut si pas de slideCount (1+6)
}
export function slideGenerateCreditCost(slideCount?: number | null): number {
const n =
typeof slideCount === 'number' && Number.isFinite(slideCount)
? Math.min(8, Math.max(3, Math.round(slideCount)))
: 6
return 1 + n
}
export function notebookSlideCreditCost(opts: {
slideCount?: number | null
noteCount?: number | null
}): number {
const n =
typeof opts.slideCount === 'number' && Number.isFinite(opts.slideCount)
? Math.min(12, Math.max(5, Math.round(opts.slideCount)))
: 8
const notes =
typeof opts.noteCount === 'number' && Number.isFinite(opts.noteCount)
? Math.max(0, Math.round(opts.noteCount))
: 0
return 2 + n + Math.floor(notes / 5)
}
export function resolveCreditCost(
feature: string,
options?: { amount?: number; slideCount?: number | null },
): number {
if (feature === 'slide_generate') {
if (options?.slideCount != null) return slideGenerateCreditCost(options.slideCount)
if (options?.amount != null && options.amount > 1) return Math.max(1, Math.floor(options.amount))
return slideGenerateCreditCost(null)
}
if (options?.amount != null && options.amount > 1) {
return Math.max(1, Math.floor(options.amount))
}
return CREDIT_COSTS[feature] ?? 1
}
// ── Types ───────────────────────────────────────────────────────────────────
export type CreditBalance = {
period: string
unlimited: boolean
totalRemaining: number
subscriptionRemaining: number
purchasedRemaining: number
subscriptionGranted: number
subscriptionSpent: number
spentThisPeriod: number
}
export type CreditBreakdownRow = {
feature: string
creditsUsed: number
actionsCount: number
percentOfSpent: number
}
export type CreditUsageSummary = {
tier: SubscriptionTier
period: string
balance: CreditBalance
breakdown: CreditBreakdownRow[]
}
const TTL_SECONDS = 90 * 24 * 60 * 60
function redisKeys(userId: string, period: string) {
return {
subSpent: `credits:${userId}:${period}:sub_spent`,
purchased: `credits:${userId}:${period}:purchased`,
granted: `credits:${userId}:${period}:granted`,
}
}
// ── Ensure account / monthly grant ──────────────────────────────────────────
export async function ensureCreditAccount(userId: string): Promise<{
periodKey: string
subscriptionGranted: number
subscriptionSpent: number
purchasedBalance: number
unlimited: boolean
}> {
const periodKey = getCurrentPeriodKey()
const tier = await getEffectiveTier(userId)
const allocation = CREDIT_ALLOCATIONS[tier]
const unlimited = allocation === 'unlimited'
const grant = unlimited ? 0 : (allocation as number)
let account = await prisma.aiCreditAccount.findUnique({ where: { userId } })
if (!account) {
account = await prisma.aiCreditAccount.create({
data: {
userId,
periodKey,
subscriptionGranted: grant,
subscriptionSpent: 0,
purchasedBalance: 0,
},
})
if (!unlimited && grant > 0) {
await appendLedger(userId, {
kind: 'GRANT_SUBSCRIPTION',
amount: grant,
feature: null,
balanceAfter: grant,
metadata: { periodKey, tier },
})
}
await syncRedisFromAccount(userId, account)
return {
periodKey,
subscriptionGranted: account.subscriptionGranted,
subscriptionSpent: account.subscriptionSpent,
purchasedBalance: account.purchasedBalance,
unlimited,
}
}
// Nouvelle période : expire reliquat abonnement, conserve packs, re-grant
if (account.periodKey !== periodKey) {
const purchased = account.purchasedBalance
account = await prisma.aiCreditAccount.update({
where: { userId },
data: {
periodKey,
subscriptionGranted: grant,
subscriptionSpent: 0,
purchasedBalance: purchased,
},
})
if (!unlimited && grant > 0) {
await appendLedger(userId, {
kind: 'GRANT_SUBSCRIPTION',
amount: grant,
feature: null,
balanceAfter: grant + purchased,
metadata: { periodKey, tier, rolledPurchased: purchased },
})
}
await syncRedisFromAccount(userId, account)
} else if (!unlimited && account.subscriptionGranted !== grant) {
// Palier changé en cours de mois : met à jour l'allocation (ne retire pas le déjà consommé)
account = await prisma.aiCreditAccount.update({
where: { userId },
data: { subscriptionGranted: grant },
})
await syncRedisFromAccount(userId, account)
}
return {
periodKey: account.periodKey,
subscriptionGranted: account.subscriptionGranted,
subscriptionSpent: account.subscriptionSpent,
purchasedBalance: account.purchasedBalance,
unlimited,
}
}
async function syncRedisFromAccount(
userId: string,
account: {
periodKey: string
subscriptionGranted: number
subscriptionSpent: number
purchasedBalance: number
},
) {
const k = redisKeys(userId, account.periodKey)
try {
await redis
.multi()
.set(k.granted, String(account.subscriptionGranted), 'EX', TTL_SECONDS)
.set(k.subSpent, String(account.subscriptionSpent), 'EX', TTL_SECONDS)
.set(k.purchased, String(account.purchasedBalance), 'EX', TTL_SECONDS)
.exec()
} catch (err) {
console.error('[credits] Redis sync failed:', err)
}
}
async function appendLedger(
userId: string,
entry: {
kind: string
amount: number
feature: string | null
balanceAfter: number | null
metadata?: Record<string, unknown>
},
) {
try {
await prisma.aiCreditLedger.create({
data: {
userId,
kind: entry.kind,
amount: entry.amount,
feature: entry.feature,
balanceAfter: entry.balanceAfter,
metadata: entry.metadata ? JSON.stringify(entry.metadata) : null,
},
})
} catch (err) {
console.error('[credits] ledger write failed:', err)
}
}
// ── Balance ─────────────────────────────────────────────────────────────────
export async function getCreditBalance(userId: string): Promise<CreditBalance> {
const acc = await ensureCreditAccount(userId)
const period = acc.periodKey
if (acc.unlimited) {
return {
period,
unlimited: true,
totalRemaining: Infinity,
subscriptionRemaining: Infinity,
purchasedRemaining: acc.purchasedBalance,
subscriptionGranted: Infinity,
subscriptionSpent: acc.subscriptionSpent,
spentThisPeriod: acc.subscriptionSpent,
}
}
// Prefer Redis if present
const k = redisKeys(userId, period)
try {
const [subSpentRaw, purchasedRaw, grantedRaw] = await redis.mget(
k.subSpent,
k.purchased,
k.granted,
)
if (subSpentRaw != null || purchasedRaw != null) {
const subscriptionSpent = Math.max(0, parseInt(subSpentRaw || '0', 10) || 0)
const purchasedRemaining = Math.max(0, parseInt(purchasedRaw || '0', 10) || 0)
const subscriptionGranted = Math.max(
0,
parseInt(grantedRaw || String(acc.subscriptionGranted), 10) || acc.subscriptionGranted,
)
const subscriptionRemaining = Math.max(0, subscriptionGranted - subscriptionSpent)
return {
period,
unlimited: false,
totalRemaining: subscriptionRemaining + purchasedRemaining,
subscriptionRemaining,
purchasedRemaining,
subscriptionGranted,
subscriptionSpent,
spentThisPeriod: subscriptionSpent, // packs spend tracked separately via ledger
}
}
} catch {
/* fall through to DB */
}
const subscriptionRemaining = Math.max(0, acc.subscriptionGranted - acc.subscriptionSpent)
return {
period,
unlimited: false,
totalRemaining: subscriptionRemaining + acc.purchasedBalance,
subscriptionRemaining,
purchasedRemaining: acc.purchasedBalance,
subscriptionGranted: acc.subscriptionGranted,
subscriptionSpent: acc.subscriptionSpent,
spentThisPeriod: acc.subscriptionSpent,
}
}
// ── Reserve / release ───────────────────────────────────────────────────────
const RESERVE_CREDITS_LUA = `
local cost = tonumber(ARGV[1]) or 1
local granted = tonumber(redis.call('GET', KEYS[1]) or '0')
local subSpent = tonumber(redis.call('GET', KEYS[2]) or '0')
local purchased = tonumber(redis.call('GET', KEYS[3]) or '0')
local ttl = tonumber(ARGV[2]) or 7776000
if cost < 1 then cost = 1 end
local subRem = math.max(0, granted - subSpent)
local total = subRem + purchased
if total < cost then
return {-1, subSpent, purchased}
end
local fromSub = math.min(subRem, cost)
local fromPurch = cost - fromSub
subSpent = subSpent + fromSub
purchased = purchased - fromPurch
redis.call('SET', KEYS[2], tostring(subSpent))
redis.call('EXPIRE', KEYS[2], ttl)
redis.call('SET', KEYS[3], tostring(purchased))
redis.call('EXPIRE', KEYS[3], ttl)
if redis.call('TTL', KEYS[1]) < 0 then
redis.call('EXPIRE', KEYS[1], ttl)
end
return {cost, subSpent, purchased, fromSub, fromPurch}
`
/**
* Réserve `cost` crédits. Lance QuotaExceededError si insuffisant.
* Enterprise unlimited : no-op.
*/
export async function reserveCreditsOrThrow(
userId: string,
cost: number,
options?: { feature?: string; metadata?: Record<string, unknown> },
): Promise<{ cost: number; unlimited: boolean }> {
const units = Math.max(1, Math.floor(Number(cost) || 1))
const acc = await ensureCreditAccount(userId)
if (acc.unlimited) {
// Journal informatif (montant 0) pour le tableau si feature fournie
if (options?.feature) {
await appendLedger(userId, {
kind: 'CONSUME',
amount: 0,
feature: options.feature,
balanceAfter: null,
metadata: { ...(options.metadata || {}), unlimited: true, notionalCost: units },
})
}
return { cost: 0, unlimited: true }
}
const period = acc.periodKey
const k = redisKeys(userId, period)
// Ensure granted key is set
try {
const g = await redis.get(k.granted)
if (g == null) {
await syncRedisFromAccount(userId, {
periodKey: period,
subscriptionGranted: acc.subscriptionGranted,
subscriptionSpent: acc.subscriptionSpent,
purchasedBalance: acc.purchasedBalance,
})
}
} catch {
/* continue */
}
let fromSub = units
let fromPurch = 0
let newSubSpent = acc.subscriptionSpent + units
let newPurchased = acc.purchasedBalance
try {
const raw = (await redis.eval(
RESERVE_CREDITS_LUA,
3,
k.granted,
k.subSpent,
k.purchased,
String(units),
String(TTL_SECONDS),
)) as number[]
if (!raw || raw[0] === -1) {
const bal = await getCreditBalance(userId)
throw await makeQuotaExceeded(
options?.feature || 'credits',
bal.subscriptionGranted + bal.purchasedRemaining,
bal.spentThisPeriod,
userId,
)
}
newSubSpent = Number(raw[1]) || 0
newPurchased = Number(raw[2]) || 0
fromSub = Number(raw[3]) ?? units
fromPurch = Number(raw[4]) ?? 0
} catch (err) {
if (err instanceof QuotaExceededError) throw err
// DB fallback
const subRem = Math.max(0, acc.subscriptionGranted - acc.subscriptionSpent)
const total = subRem + acc.purchasedBalance
if (total < units) {
throw await makeQuotaExceeded(
options?.feature || 'credits',
acc.subscriptionGranted + acc.purchasedBalance,
acc.subscriptionSpent,
userId,
)
}
fromSub = Math.min(subRem, units)
fromPurch = units - fromSub
newSubSpent = acc.subscriptionSpent + fromSub
newPurchased = acc.purchasedBalance - fromPurch
}
await prisma.aiCreditAccount.update({
where: { userId },
data: {
subscriptionSpent: newSubSpent,
purchasedBalance: newPurchased,
},
})
const remaining =
Math.max(0, acc.subscriptionGranted - newSubSpent) + Math.max(0, newPurchased)
await appendLedger(userId, {
kind: 'CONSUME',
amount: -units,
feature: options?.feature ?? null,
balanceAfter: remaining,
metadata: {
...(options?.metadata || {}),
fromSubscription: fromSub,
fromPurchased: fromPurch,
},
})
return { cost: units, unlimited: false }
}
/**
* Remet des crédits après échec (best-effort).
* Simplification : recrédite d'abord l'abonnement (réduit subscriptionSpent).
*/
export async function releaseCredits(
userId: string,
cost: number,
options?: { feature?: string },
): Promise<void> {
const units = Math.max(1, Math.floor(Number(cost) || 1))
const acc = await ensureCreditAccount(userId)
if (acc.unlimited) return
const period = acc.periodKey
const k = redisKeys(userId, period)
let newSubSpent = Math.max(0, acc.subscriptionSpent - units)
// Si on a plus dépensé en abonnement que available, le reste va en packs
let refundPurch = 0
const subRefund = Math.min(units, acc.subscriptionSpent)
refundPurch = units - subRefund
newSubSpent = acc.subscriptionSpent - subRefund
const newPurchased = acc.purchasedBalance + refundPurch
try {
await redis
.multi()
.set(k.subSpent, String(newSubSpent), 'EX', TTL_SECONDS)
.set(k.purchased, String(newPurchased), 'EX', TTL_SECONDS)
.exec()
} catch (err) {
console.error('[credits] release Redis failed:', err)
}
await prisma.aiCreditAccount.update({
where: { userId },
data: {
subscriptionSpent: newSubSpent,
purchasedBalance: newPurchased,
},
})
const remaining =
Math.max(0, acc.subscriptionGranted - newSubSpent) + Math.max(0, newPurchased)
await appendLedger(userId, {
kind: 'RELEASE',
amount: units,
feature: options?.feature ?? null,
balanceAfter: remaining,
metadata: { refundSubscription: subRefund, refundPurchased: refundPurch },
})
}
export async function addPurchasedCredits(
userId: string,
amount: number,
metadata?: Record<string, unknown>,
): Promise<{ applied: boolean; units: number }> {
const units = Math.max(1, Math.floor(amount))
// Idempotence webhook : un même checkout Stripe ne crédite qu'une fois
const stripeSessionId =
typeof metadata?.stripeSessionId === 'string' ? metadata.stripeSessionId : null
if (stripeSessionId) {
const existing = await prisma.aiCreditLedger.findFirst({
where: {
userId,
kind: 'PURCHASE_PACK',
metadata: { contains: stripeSessionId },
},
select: { id: true },
})
if (existing) {
return { applied: false, units }
}
}
const acc = await ensureCreditAccount(userId)
const newPurchased = acc.purchasedBalance + units
await prisma.aiCreditAccount.update({
where: { userId },
data: { purchasedBalance: newPurchased },
})
const period = acc.periodKey
const k = redisKeys(userId, period)
try {
await redis.set(k.purchased, String(newPurchased), 'EX', TTL_SECONDS)
} catch {
/* ignore */
}
const bal = await getCreditBalance(userId)
await appendLedger(userId, {
kind: 'PURCHASE_PACK',
amount: units,
feature: null,
balanceAfter: bal.unlimited ? null : bal.totalRemaining,
metadata,
})
return { applied: true, units }
}
/** Reset admin : remet conso à 0 et re-grant allocation, packs optionnels conservés. */
export async function adminResetCredits(
userId: string,
options?: { clearPurchased?: boolean },
): Promise<CreditBalance> {
const periodKey = getCurrentPeriodKey()
const tier = await getEffectiveTier(userId)
const allocation = CREDIT_ALLOCATIONS[tier]
const unlimited = allocation === 'unlimited'
const grant = unlimited ? 0 : (allocation as number)
const existing = await prisma.aiCreditAccount.findUnique({ where: { userId } })
const purchased = options?.clearPurchased ? 0 : (existing?.purchasedBalance ?? 0)
const account = await prisma.aiCreditAccount.upsert({
where: { userId },
create: {
userId,
periodKey,
subscriptionGranted: grant,
subscriptionSpent: 0,
purchasedBalance: purchased,
},
update: {
periodKey,
subscriptionGranted: grant,
subscriptionSpent: 0,
purchasedBalance: purchased,
},
})
await syncRedisFromAccount(userId, account)
await appendLedger(userId, {
kind: 'ADMIN_RESET',
amount: grant,
feature: null,
balanceAfter: grant + purchased,
metadata: { clearPurchased: !!options?.clearPurchased, tier },
})
return getCreditBalance(userId)
}
// ── Breakdown for usage table ───────────────────────────────────────────────
/**
* Date de début pour le détail d'usage : après le dernier ADMIN_RESET du mois,
* sinon début de période. Évite « 300 restants + 16 utilisés » après un reset admin.
*/
async function usageWindowStart(userId: string): Promise<Date> {
const period = getCurrentPeriodKey()
const periodStart = new Date(`${period}-01T00:00:00.000Z`)
const lastReset = await prisma.aiCreditLedger.findFirst({
where: {
userId,
kind: 'ADMIN_RESET',
createdAt: { gte: periodStart },
},
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
})
return lastReset?.createdAt ?? periodStart
}
export async function getCreditBreakdown(userId: string): Promise<CreditBreakdownRow[]> {
const since = await usageWindowStart(userId)
const rows = await prisma.aiCreditLedger.groupBy({
by: ['feature'],
where: {
userId,
kind: 'CONSUME',
createdAt: { gte: since },
feature: { not: null },
},
_sum: { amount: true },
_count: { _all: true },
})
// Toutes les fonctionnalités connues (comme l'ancien tableau) — pas seulement slides
const usedMap = new Map<string, { creditsUsed: number; actionsCount: number }>()
for (const feature of VALID_FEATURES) {
usedMap.set(feature, { creditsUsed: 0, actionsCount: 0 })
}
for (const r of rows) {
if (!r.feature) continue
const creditsUsed = Math.abs(r._sum.amount ?? 0)
usedMap.set(r.feature, {
creditsUsed,
actionsCount: r._count._all,
})
}
const mapped: CreditBreakdownRow[] = []
let totalSpent = 0
for (const [feature, v] of usedMap) {
totalSpent += v.creditsUsed
mapped.push({
feature,
creditsUsed: v.creditsUsed,
actionsCount: v.actionsCount,
percentOfSpent: 0,
})
}
for (const r of mapped) {
r.percentOfSpent =
totalSpent > 0 ? Math.round((r.creditsUsed / totalSpent) * 1000) / 10 : 0
}
// Tri : d'abord celles avec usage, puis ordre alphabétique du feature key
mapped.sort((a, b) => {
if (b.creditsUsed !== a.creditsUsed) return b.creditsUsed - a.creditsUsed
if (b.actionsCount !== a.actionsCount) return b.actionsCount - a.actionsCount
return a.feature.localeCompare(b.feature)
})
return mapped
}
export async function getCreditUsageSummary(userId: string): Promise<CreditUsageSummary> {
const tier = await getEffectiveTier(userId)
const balance = await getCreditBalance(userId)
const breakdown = await getCreditBreakdown(userId)
// Conso affichée = somme réelle des CONSUME dans la fenêtre (après dernier reset)
const spentFromBreakdown = breakdown.reduce((s, r) => s + r.creditsUsed, 0)
balance.spentThisPeriod = spentFromBreakdown
return {
tier,
period: balance.period,
balance,
breakdown,
}
}
/** Compat : fabrique un objet quotas-like pour l'ancien UI le temps de la migration. */
export async function getQuotasCompatFromCredits(
userId: string,
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {
const summary = await getCreditUsageSummary(userId)
const result: Record<string, { remaining: number; limit: number; used: number }> = {}
// Lignes du breakdown
const usedByFeature = new Map(summary.breakdown.map((b) => [b.feature, b.creditsUsed]))
for (const feature of VALID_FEATURES) {
const used = usedByFeature.get(feature) ?? 0
if (summary.balance.unlimited) {
result[feature] = { remaining: Infinity, limit: Infinity, used }
} else {
// Pas de plafond par feature : on expose used + remaining global pour info
result[feature] = {
used,
limit: summary.balance.subscriptionGranted + summary.balance.purchasedRemaining + used,
remaining: summary.balance.totalRemaining,
}
}
}
return result
}
export function isCreditFeature(feature: string): feature is FeatureName {
return (VALID_FEATURES as readonly string[]).includes(feature as FeatureName)
}

View File

@@ -6,6 +6,7 @@ import {
getRedisKey,
parseRedisInt,
isValidFeature,
VALID_FEATURES,
} from './quota-utils';
import {
getLimitAsync,
@@ -138,15 +139,17 @@ async function reserveUsageInDatabase(
userId: string,
feature: string,
limit: number,
amount: number = 1,
): Promise<number> {
const { periodStart, periodEnd } = getPeriodDates();
const units = Math.max(1, Math.floor(amount));
return prisma.$transaction(async (tx) => {
const existing = await tx.usageLog.findUnique({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
});
const current = existing?.requestsCount ?? 0;
if (current >= limit) return -1;
if (current + units > limit) return -1;
const updated = await tx.usageLog.upsert({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
@@ -155,12 +158,12 @@ async function reserveUsageInDatabase(
feature,
periodStart,
periodEnd,
requestsCount: 1,
requestsCount: units,
tokensUsed: 0,
syncedAt: new Date(),
},
update: {
requestsCount: { increment: 1 },
requestsCount: { increment: units },
syncedAt: new Date(),
},
});
@@ -191,53 +194,37 @@ return newCount
`;
const RELEASE_LUA = `
local amount = tonumber(ARGV[1]) or 1
if amount < 1 then amount = 1 end
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current <= 0 then return 0 end
redis.call('DECR', KEYS[1])
local dec = math.min(amount, current)
redis.call('DECRBY', KEYS[1], dec)
return tonumber(redis.call('GET', KEYS[1]) or '0')
`;
export async function releaseUsage(userId: string, feature: string): Promise<void> {
/** Libère des crédits précédemment réservés (modèle A). */
export async function releaseUsage(
userId: string,
feature: string,
amount: number = 1,
): Promise<void> {
if (!isValidFeature(feature)) return;
const tier = await getEffectiveTier(userId);
const limit = await getLimitAsync(tier, feature);
if (limit === undefined || limit === Infinity) return;
try {
const key = getRedisKey(userId, feature);
const raw = await redis.eval(RELEASE_LUA, 1, key);
const newCount = parseReserveResult(raw);
if (Number.isFinite(newCount) && newCount >= 0) {
await mirrorUsageCountToDatabase(userId, feature, newCount);
}
} catch (err) {
console.error('[entitlements] Redis release failed, trying DB fallback:', err);
try {
const { periodStart } = getPeriodDates();
const existing = await prisma.usageLog.findUnique({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
});
const current = existing?.requestsCount ?? 0;
if (current <= 0) return;
await prisma.usageLog.update({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
data: { requestsCount: current - 1, syncedAt: new Date() },
});
} catch (dbErr) {
console.error('[entitlements] DB release fallback failed:', dbErr);
}
}
const units = Math.max(1, Math.floor(Number(amount) || 1));
const { releaseCredits } = await import('@/lib/credits');
await releaseCredits(userId, units, { feature });
}
const RESERVE_LUA = `
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local amount = tonumber(ARGV[3]) or 1
if amount < 1 then amount = 1 end
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current >= limit then
if current + amount > limit then
return -1
end
redis.call('INCRBY', KEYS[1], 1)
redis.call('INCRBY', KEYS[1], amount)
local ttlResult = redis.call('TTL', KEYS[1])
if ttlResult == -1 then
redis.call('EXPIRE', KEYS[1], ttl)
@@ -370,86 +357,47 @@ export async function checkEntitlementOrThrow(
}
}
/** Mappe une feature de quota vers la voie IA (pour le BYOK). */
function featureToAiLane(feature: string): 'chat' | 'tags' | 'embedding' {
if (feature === 'auto_tag' || feature === 'auto_title') return 'tags';
if (feature === 'semantic_search') return 'embedding';
return 'chat';
}
/**
* Réserve des crédits globaux (modèle A).
* `amount` = coût en crédits (ex. slides multi-étapes : 1+N).
* Si l'utilisateur a une clé perso (BYOK) pour la voie concernée, aucun débit.
* Conserve le nom historique pour ne pas casser tous les appelants.
*/
export async function reserveUsageOrThrow(
userId: string,
feature: string,
amount: number = 1,
): Promise<void> {
if (!isValidFeature(feature)) {
throw new QuotaExceededError('PRO', feature, 0, 0, false, { currentTier: 'BASIC' });
}
const tier = await getEffectiveTier(userId);
const limit = await getLimitAsync(tier, feature);
const { getSystemConfig } = await import('@/lib/config');
const { willUseByokForLane } = await import('@/lib/ai/provider-for-user');
const config = await getSystemConfig();
const lane = featureToAiLane(feature);
const { usedByok } = await willUseByokForLane(lane, config, userId);
if (usedByok) return;
if (limit === undefined) {
throw new QuotaExceededError(
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
feature,
0,
0,
false,
{ currentTier: tier },
);
}
const { reserveCreditsOrThrow, resolveCreditCost } = await import('@/lib/credits');
const cost = resolveCreditCost(feature, { amount });
await reserveCreditsOrThrow(userId, cost, { feature });
}
if (limit === Infinity) return;
try {
const key = getRedisKey(userId, feature);
const raw = await redis.eval(
RESERVE_LUA,
1,
key,
String(limit),
String(TTL_SECONDS),
);
const newCount = parseReserveResult(raw);
if (newCount === -1) {
const byokConfigured = await hasAnyActiveByok(userId);
throw new QuotaExceededError(
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
feature,
limit,
limit,
byokConfigured,
{ currentTier: tier },
);
}
if (!Number.isFinite(newCount) || newCount < 1) {
throw new Error(`Invalid Redis reserve result: ${String(raw)}`);
}
await mirrorUsageCountToDatabase(userId, feature, newCount);
} catch (err) {
if (err instanceof QuotaExceededError) throw err;
console.error('[entitlements] Redis reserve failed, trying DB fallback:', err);
try {
const dbCount = await reserveUsageInDatabase(userId, feature, limit);
if (dbCount === -1) {
const byokConfigured = await hasAnyActiveByok(userId);
throw new QuotaExceededError(
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
feature,
limit,
limit,
byokConfigured,
{ currentTier: tier },
);
}
return;
} catch (dbErr) {
if (dbErr instanceof QuotaExceededError) throw dbErr;
console.error('[entitlements] DB reserve fallback failed:', dbErr);
}
if (shouldFailClosedOnRedisError()) {
throw new QuotaServiceUnavailableError();
}
}
/** Units charged for one multi-step slide generation (outline + per-slide expand). */
export function slideGenerateQuotaUnits(slideCount?: number | null): number {
// 1 outline + N expand calls (clamped). Reflects real multi-call cost.
const n = typeof slideCount === 'number' && Number.isFinite(slideCount)
? Math.min(8, Math.max(3, Math.round(slideCount)))
: 6;
return 1 + n;
}
/** Host-pays: bill session owner, attach actor metadata for 402 responses (Story 3.4). */
@@ -480,6 +428,68 @@ export function incrementUsageAsync(userId: string, feature: string, count: numb
});
}
export type ResetUserUsageOptions = {
/** Si fourni, ne remet à zéro que ces fonctionnalités. Sinon : toutes. */
features?: string[];
};
/**
* Remet à zéro la consommation IA dun utilisateur pour la période mensuelle en cours
* (compteurs Redis + lignes UsageLog en base).
* Réservé aux actions admin.
*/
export async function resetUserUsageForCurrentPeriod(
userId: string,
options?: ResetUserUsageOptions,
): Promise<{ period: string; featuresReset: string[]; redisDeleted: number; dbUpdated: number }> {
if (!userId?.trim()) {
throw new Error('userId required');
}
const period = getCurrentPeriodKey();
const { periodStart } = getPeriodDates();
const features = (options?.features?.length
? options.features.filter((f) => isValidFeature(f))
: [...VALID_FEATURES]) as string[];
if (features.length === 0) {
return { period, featuresReset: [], redisDeleted: 0, dbUpdated: 0 };
}
const keys = features.map((f) => getRedisKey(userId, f));
let redisDeleted = 0;
try {
if (keys.length === 1) {
redisDeleted = await redis.del(keys[0]);
} else {
redisDeleted = await redis.del(...keys);
}
} catch (err) {
console.error('[entitlements] resetUserUsage Redis del failed:', err);
}
const dbResult = await prisma.usageLog.updateMany({
where: {
userId,
periodStart,
feature: { in: features },
},
data: {
requestsCount: 0,
tokensUsed: 0,
syncedAt: new Date(),
},
});
return {
period,
featuresReset: features,
redisDeleted,
dbUpdated: dbResult.count,
};
}
export async function getUserQuotas(
userId: string,
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {

View File

@@ -20,14 +20,26 @@ export async function deleteImageFileSafely(imageUrl: string, excludeNoteId?: st
try {
const notes = await prisma.note.findMany({
where: { images: { contains: imageUrl } },
where: {
OR: [
{ images: { contains: imageUrl } },
{ content: { contains: imageUrl } },
],
},
select: { id: true },
})
const otherRefs = notes.filter(n => n.id !== excludeNoteId)
if (otherRefs.length > 0) return // File still referenced elsewhere
const filePath = path.join(process.cwd(), 'data', imageUrl)
// imageUrl = /uploads/notes/... → data/uploads/notes/...
const filePath = path.join(process.cwd(), 'data', imageUrl.replace(/^\//, ''))
await fs.unlink(filePath)
// Remove ownership sidecar if present
try {
await fs.unlink(filePath + '.meta.json')
} catch {
/* no meta */
}
} catch {
// File already gone or unreadable -- silently skip
}

View File

@@ -0,0 +1,59 @@
/**
* Scripts boot inline dans le <head> (évite next/script + warning React 19
* « Scripts inside React components are never executed… »).
* Doivent rester synchrones et sans import.
*/
export const THEME_INIT_SCRIPT = `(function () {
try {
var root = document.documentElement;
var fallback = root.getAttribute('data-server-theme') || 'light';
var defaultAccent = root.getAttribute('data-server-accent') || '#A47148';
var stored = localStorage.getItem('theme-preference');
var raw = stored || fallback;
if (raw === 'slate') raw = 'light';
var allowed = {
light: 1, dark: 1, auto: 1, sepia: 1, midnight: 1,
rose: 1, green: 1, lavender: 1, sand: 1, ocean: 1, sunset: 1, blue: 1
};
var theme = allowed[raw] ? raw : 'light';
try {
document.cookie = 'memento-theme=' + encodeURIComponent(theme) + '; path=/; max-age=31536000; SameSite=Lax';
if (!stored) localStorage.setItem('theme-preference', theme);
} catch (e) {}
var wantsDark = false;
if (theme === 'auto') {
wantsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
} else if (theme === 'dark' || theme === 'midnight') {
wantsDark = true;
}
if (wantsDark) root.classList.add('dark');
else root.classList.remove('dark');
try { root.style.colorScheme = wantsDark ? 'dark' : 'light'; } catch (e) {}
if (theme === 'auto' || theme === 'dark' || theme === 'light') {
root.removeAttribute('data-theme');
} else {
root.setAttribute('data-theme', theme);
}
root.setAttribute('data-applied-theme', theme);
var accentStored = localStorage.getItem('accent-color');
root.style.setProperty('--color-brand-accent', accentStored || defaultAccent);
} catch (e) {
console.error('Theme script error', e);
}
})();`
export const DIRECTION_INIT_SCRIPT = `(function () {
try {
var lang = localStorage.getItem('user-language');
if (!lang) {
var c = document.cookie.split(';').map(function (s) { return s.trim(); })
.find(function (s) { return s.startsWith('user-language='); });
if (c) lang = c.split('=')[1];
}
if (lang === 'fa' || lang === 'ar') {
document.documentElement.dir = 'rtl';
document.documentElement.lang = lang;
}
} catch (e) {}
})();`

View File

@@ -19,6 +19,9 @@ export const FALLBACK_TIER_LIMITS: Record<
brainstorm_expand: 10,
brainstorm_enrich: 20,
suggest_charts: 5,
// Multi-step pipeline charges ~49 units per deck → low monthly budget
slide_generate: 10,
excalidraw_generate: 5,
ai_flashcard: 5,
voice_transcribe: 20,
publish_enhance: 2,
@@ -33,8 +36,9 @@ export const FALLBACK_TIER_LIMITS: Record<
brainstorm_expand: 100,
brainstorm_enrich: 200,
suggest_charts: 50,
slide_generate: 20,
excalidraw_generate: 20,
// ~7 units/deck → ~20 decks/month at 140 units
slide_generate: 140,
excalidraw_generate: 40,
ai_flashcard: 100,
voice_transcribe: 500,
publish_enhance: 15,
@@ -49,8 +53,9 @@ export const FALLBACK_TIER_LIMITS: Record<
brainstorm_expand: 500,
brainstorm_enrich: 1000,
suggest_charts: 200,
slide_generate: 100,
excalidraw_generate: 100,
// ~7 units/deck → ~70 decks/month
slide_generate: 500,
excalidraw_generate: 200,
ai_flashcard: 'unlimited',
voice_transcribe: 'unlimited',
publish_enhance: 100,

View File

@@ -23,27 +23,29 @@ export const REWRITE_SHARED_CSS = `
${KATEX_PUBLISH_CSS}
/* ─── Résumé introductif ─────────────────────────────── */
.pub-rewrite-summary {
font-size: 1.2em;
font-size: 1.18em;
line-height: 1.7;
font-style: italic;
color: var(--pub-summary-color, #444);
margin-bottom: 2em;
padding-bottom: 1.5em;
padding: 0 0 1.5em;
border-bottom: 2px solid var(--pub-accent, #A47148);
text-wrap: pretty;
}
/* ─── Exercices ──────────────────────────────────────── */
.pub-rewrite-body .pub-exercise {
border: 2px solid var(--pub-exercise-border, #e0d4c3);
border-radius: 12px;
border: 1px solid var(--pub-exercise-border, #e0d4c3);
border-radius: 14px;
margin: 1.75em 0;
overflow: hidden;
box-shadow: 0 8px 28px rgba(0,0,0,0.04);
}
.pub-rewrite-body .pub-exercise-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
padding: 12px 18px;
background: var(--pub-exercise-header-bg, #f6f1ea);
border-bottom: 1px solid var(--pub-exercise-border, #e0d4c3);
}

View File

@@ -14,7 +14,8 @@ import { createHash } from 'crypto'
const CHUNK_TARGET_CHARS = 1000
const CHUNK_OVERLAP_CHARS = 200
const MIN_FRAGMENT_CHARS = 10
/** Allow short notes (flashcards, reminders, one-liners) to be indexed. */
const MIN_FRAGMENT_CHARS = 1
const MAX_PARAGRAPH_BEFORE_SPLIT = 1500
export interface NoteChunk {

View File

@@ -16,18 +16,18 @@ export function getThemeScript(serverTheme: string = 'light', serverAccentColor:
var allowed = { light:1, dark:1, auto:1, sepia:1, midnight:1, rose:1, green:1, lavender:1, sand:1, ocean:1, sunset:1, blue:1 };
var theme = allowed[raw] ? raw : 'light';
var root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
if (theme === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark');
} else if (theme === 'dark') {
root.classList.add('dark');
} else if (theme === 'light') {
/* :root papier */
} else {
try {
document.cookie = 'memento-theme=' + encodeURIComponent(theme) + '; path=/; max-age=31536000; SameSite=Lax';
if (!stored) localStorage.setItem('theme-preference', theme);
} catch (e) {}
var wantsDark = theme === 'dark' || theme === 'midnight' || (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (wantsDark) root.classList.add('dark');
else root.classList.remove('dark');
if (theme === 'auto' || theme === 'dark' || theme === 'light') root.removeAttribute('data-theme');
else {
root.setAttribute('data-theme', theme);
if (theme === 'midnight') root.classList.add('dark');
}
root.setAttribute('data-applied-theme', theme);
var accentStored = localStorage.getItem('accent-color');
var effectiveAccent = accentStored || ${JSON.stringify(defaultAccent)};
root.style.setProperty('--color-brand-accent', effectiveAccent);

View File

@@ -1,37 +1,118 @@
import { readFile } from 'fs/promises'
import path from 'path'
import { prisma } from './prisma'
/** Whether a note image upload may be served to the current viewer. */
const NOTES_UPLOAD_ROOT = path.join(process.cwd(), 'data', 'uploads', 'notes')
export type UploadMeta = {
userId: string
createdAt: number
}
/**
* Canonical URL form:
* /uploads/notes/{userId}/{filename} ← preferred (ownership in path)
* Legacy:
* /uploads/notes/{filename} ← meta sidecar or note content
*/
export function uploadMetaPathForRelative(relativeUnderNotes: string): string {
// relativeUnderNotes e.g. "userId/uuid.png" or "uuid.png"
return path.join(NOTES_UPLOAD_ROOT, `${relativeUnderNotes}.meta.json`)
}
export async function writeUploadMeta(relativeUnderNotes: string, userId: string): Promise<void> {
const { writeFile, mkdir } = await import('fs/promises')
const full = uploadMetaPathForRelative(relativeUnderNotes)
await mkdir(path.dirname(full), { recursive: true })
const meta: UploadMeta = { userId, createdAt: Date.now() }
await writeFile(full, JSON.stringify(meta), 'utf8')
}
async function readUploadMeta(relativeUnderNotes: string): Promise<UploadMeta | null> {
try {
const raw = await readFile(uploadMetaPathForRelative(relativeUnderNotes), 'utf8')
const parsed = JSON.parse(raw) as UploadMeta
if (parsed?.userId && typeof parsed.userId === 'string') return parsed
return null
} catch {
return null
}
}
/**
* @param pathSegments path after `/uploads/` e.g. `['notes', userId, 'file.png']` or `['notes', 'file.png']`
*/
export async function canAccessUploadedNoteImage(
filename: string,
pathSegments: string[],
userId: string | null | undefined,
): Promise<boolean> {
if (pathSegments.length < 2 || pathSegments[0] !== 'notes') return false
// notes/{userId}/{filename}
if (pathSegments.length >= 3) {
const ownerId = pathSegments[1]
const filename = pathSegments[pathSegments.length - 1]
const relative = pathSegments.slice(1).join('/') // userId/file.png
const imagePath = `/uploads/notes/${relative}`
// 1) Path ownership — definitive for new uploads
if (userId && ownerId === userId) return true
// 2) Meta (redundant but safe)
if (userId) {
const meta = await readUploadMeta(relative)
if (meta?.userId === userId) return true
}
// 3) Public note embeds this URL
const published = await noteContainsImage({ imagePath, filename, isPublic: true })
if (published) return true
if (!userId) return false
// 4) Private note owned by user embeds this URL
return noteContainsImage({ imagePath, filename, userId })
}
// Legacy: notes/{filename}
const filename = pathSegments[1]
const imagePath = `/uploads/notes/${filename}`
const published = await prisma.note.findFirst({
where: {
isPublic: true,
trashedAt: null,
OR: [
{ content: { contains: imagePath } },
{ images: { contains: filename } },
],
},
select: { id: true },
})
if (userId) {
const meta = await readUploadMeta(filename)
if (meta?.userId === userId) return true
}
const published = await noteContainsImage({ imagePath, filename, isPublic: true })
if (published) return true
if (!userId) return false
return noteContainsImage({ imagePath, filename, userId })
}
const owned = await prisma.note.findFirst({
where: {
userId,
trashedAt: null,
OR: [
{ content: { contains: imagePath } },
{ images: { contains: filename } },
],
},
async function noteContainsImage(opts: {
imagePath: string
filename: string
userId?: string
isPublic?: boolean
}): Promise<boolean> {
// Match full URL only (not bare filename) to avoid accidental cross-access
const where: Record<string, unknown> = {
trashedAt: null,
OR: [
{ content: { contains: opts.imagePath } },
{ images: { contains: opts.imagePath } },
// images JSON sometimes stored without leading path prefix
{ images: { contains: opts.filename } },
],
}
if (opts.isPublic) where.isPublic = true
if (opts.userId) where.userId = opts.userId
const row = await prisma.note.findFirst({
where,
select: { id: true },
})
return !!owned
return !!row
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -65,6 +65,10 @@
"freezePinnedNotebook": "Pin notebook sidebar order",
"unfreezePinnedNotebook": "Unpin notebook sidebar order",
"newSubNotebook": "New sub-notebook",
"aiBadge": "AI",
"aiGeneratedNote": "AI-generated note",
"aiGeneratedNotebook": "Notebook created with AI — recently",
"recentNote": "Recently created",
"notebookEmpty": "Empty",
"renameNotebook": "Rename",
"sharedNotebookBadge": "· Shared",
@@ -132,6 +136,11 @@
"reminderPastError": "Reminder must be in the future",
"reminderRemoved": "Reminder removed",
"addImage": "Add image",
"coverNoneHint": "No cover image — pick one from the note or leave empty.",
"coverChoose": "Choose cover",
"coverChange": "Change",
"coverRemove": "Remove cover",
"coverPickLabel": "Cover image",
"addLink": "Add link",
"linkAdded": "Link added",
"linkMetadataFailed": "Could not fetch link metadata",
@@ -327,7 +336,9 @@
"heading2": "Heading 2",
"heading3": "Heading 3",
"importSuccess": "{{count}} notes imported!",
"unpublished": "Note unpublished"
"unpublished": "Note unpublished",
"deleteError": "Failed to delete illustration",
"uploadSuccess": "Image added"
},
"pagination": {
"previous": "←",
@@ -612,6 +623,24 @@
"templateProject": "Project Status",
"templateStrategy": "Strategy Review",
"templateQuarterly": "Quarterly Results",
"purpose": "Deck type",
"purposeAuto": "Auto (from note)",
"purposeCourse": "Lesson / course",
"purposeBoard": "Board update",
"purposeProject": "Project status",
"purposeStrategy": "Strategy",
"purposePitch": "Pitch",
"purposeSummary": "Short summary",
"audience": "Audience",
"audienceAuto": "Auto",
"audienceStudent": "Students",
"audienceBoard": "Leadership",
"audienceTeam": "Team",
"audienceClient": "Client",
"audienceGeneral": "General public",
"slideCount": "Slide count",
"slideCountAuto": "Auto (from length)",
"creditCostEstimate": "Estimated cost: {cost} credits (1 outline + {slides} slides).",
"diagram": "Generate Diagram",
"diagramReadyHint": "Convert note into visual flow",
"diagramType": "Diagram Type",
@@ -732,7 +761,7 @@
"inlineSummaryMarkdown": "**Summary:**",
"networkErrorShort": "Network error.",
"featureLocked": "This feature requires the PRO plan or higher.",
"quotaExceeded": "Monthly limit reached. Resets next month.",
"quotaExceeded": "Not enough AI credits. Upgrade your plan, or use your own API key.",
"noHistory": "No history",
"webSearchDisabled": "Web search disabled",
"webSearchEnabled": "Web search enabled",
@@ -841,7 +870,14 @@
"hideAll": "Hide extra connections ({count})",
"retroTitle": "Notes that cite this content",
"retroDescription": "This passage is quoted in {count} other note(s):",
"consentRequired": "Enable AI processing in Settings → AI to see semantic connections for this note."
"consentRequired": "Enable AI processing in Settings → AI to see semantic connections for this note.",
"bottomCueLoading": "Memory Echo is scanning below…",
"bottomCueFound": "{count} related notes below",
"bottomCueFoundOne": "1 related note below",
"bottomCueRetro": "Cited in {count} note(s) below",
"bottomCueConsent": "AI connections available below",
"bottomCueScroll": "Scroll to Memory Echo",
"bottomCueDismiss": "Dismiss"
},
"fusion": {
"title": "🔗 Intelligent Fusion",
@@ -892,7 +928,8 @@
"systemNotification": "System",
"clipSaved": "Web clip saved",
"downloadFailed": "Download failed",
"brainstormShared": "Brainstorm shared"
"brainstormShared": "Brainstorm shared",
"shareLabel": "Share"
},
"clipper": {
"webClipper": "Web Clipper",
@@ -1281,6 +1318,10 @@
"embeddingsDescription": "AI provider for semantic search embeddings. Recommended: OpenAI (best quality).",
"chatProvider": "Chat Provider",
"chatDescription": "AI provider for the chat assistant. Falls back to Tags provider if not configured.",
"slidesTab": "Slides",
"slidesProvider": "Slides model",
"slidesDescription": "Dedicated provider and model for presentation generation. If the model is empty, the Chat model is used. Model IDs are never renamed automatically. The multi-step pipeline uses several credits from the global AI balance (1 outline + 1 credit per slide).",
"slidesQuotaNote": "Credit cost: 1 + slide count (e.g. 7 slides ≈ 8 credits), taken from the single balance (BASIC 100 · PRO 1,000 · BUSINESS 4,000 / month). Admin → Users to reset a user's balance.",
"provider": "Provider",
"baseUrl": "Base URL",
"model": "Model",
@@ -1417,6 +1458,10 @@
"roleUpdateFailed": "Failed to update role",
"tierUpdateSuccess": "Subscription updated to {tier}",
"tierUpdateFailed": "Failed to update subscription",
"resetQuotas": "Reset AI credits (current month)",
"confirmResetQuotas": "Reset the AI credit balance to the plan allocation for {user} (BASIC 100 · PRO 1,000 · BUSINESS 4,000)? All AI features become available again within that balance.",
"resetQuotasSuccess": "AI credits reset for period {period} ({count} counters).",
"resetQuotasFailed": "Failed to reset credits",
"demote": "Demote to User",
"promote": "Promote to Admin",
"confirmDelete": "Are you sure? This action cannot be undone.",
@@ -1515,31 +1560,32 @@
},
"settingsDescription": "Configure application-wide settings",
"billing": {
"title": "Billing & Quotas",
"description": "Manage subscription tiers, AI quotas, and Stripe business settings without redeploying.",
"title": "Billing & AI credits",
"description": "Stripe settings, monthly credit allocations by tier, and advanced tools. User spend follows the single credit balance.",
"stripeConfigTitle": "Stripe business config",
"stripeConfigDescription": "Price IDs and billing toggle. Secret keys stay in server environment only.",
"enableBilling": "Enable billing UI and checkout",
"subscriptionPricesTitle": "Subscriptions (price IDs)",
"STRIPE_PRICE_PRO_MONTHLY": "Pro — monthly price ID",
"STRIPE_PRICE_PRO_ANNUAL": "Pro — annual price ID",
"STRIPE_PRICE_BUSINESS_MONTHLY": "Business — monthly price ID",
"STRIPE_PRICE_BUSINESS_ANNUAL": "Business — annual price ID",
"packPricesTitle": "Credit packs (one-shot price IDs)",
"packPricesDescription": "Stripe one-time payment products (payment mode). Purchased credits add to the balance and roll over across months.",
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
"packsCatalogTitle": "Pack catalogue (code)",
"secretsNote": "STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET must remain in Docker/env secrets — never stored here.",
"saveConfig": "Save billing config",
"configSaved": "Billing configuration saved",
"configFailed": "Failed to save billing configuration",
"limitsTitle": "Tier quotas",
"limitsDescription": "Monthly request limits per feature. Changes apply within ~60 seconds.",
"feature": "Feature",
"mode": "Access",
"monthlyLimit": "Monthly limit",
"actions": "Actions",
"modeUnavailable": "Not available",
"modeLimited": "Limited",
"creditsTitle": "AI credits by tier (option M)",
"creditsDescription": "Source of truth for billing usage: one balance for all AI actions. Values live in code (lib/credits.ts).",
"creditsPerMonth": "credits / month",
"creditsCostsNote": "Typical costs: search, chat, tags = 1 credit; diagram = 4; AI publish = 3; presentation = 1 + slide count (e.g. 7 slides ≈ 8 credits).",
"creditsResetHint": "To refill a user: Admin → Users → reset AI credits button.",
"modeUnlimited": "Unlimited",
"saveLimit": "Save",
"limitSaved": "Quota updated",
"limitFailed": "Failed to update quota",
"usageTitle": "Usage overview",
"usagePeriod": "Period {period}",
"lastSync": "Last sync {date}",
@@ -1731,8 +1777,8 @@
"accentColorDescription": "Set the main color of your workspace"
},
"usageMeter": {
"packName": "AI Discovery Pack",
"packPro": "Pro Pack",
"packName": "Basic plan",
"packPro": "Pro plan",
"packBusiness": "Business Pack",
"packEnterprise": "Enterprise Pack",
"featureSearch": "Search",
@@ -1741,13 +1787,13 @@
"unlimited": "Unlimited",
"remaining": "{count} left",
"upgradeTitle": "Upgrade to Pro",
"upgradeDescription": "You've used all your AI Discovery Pack credits. Upgrade to Pro for higher limits and additional features.",
"upgradeDescription": "Your AI credit balance is empty. Upgrade for more monthly credits, or use your own API key (BYOK).",
"proIncludes": "Pro includes:",
"proSearch": "100 semantic searches / month",
"proTags": "200 auto-tags / month",
"proTitles": "200 auto-titles / month",
"proReformulate": "50 reformulations / month",
"proChat": "100 chat messages / month",
"proSearch": "1,000 AI credits / month",
"proTags": "BYOK (your own keys)",
"proTitles": "Agents (use credits)",
"proReformulate": "Unlimited notes",
"proChat": "Email support",
"later": "Later",
"upgradePricing": "Upgrade to Pro",
"addApiKey": "Use your own API key (BYOK)",
@@ -1761,8 +1807,15 @@
"featureBrainstormEnrich": "Brainstorm enrichments",
"featureFlashcards": "AI flashcards",
"featureVoice": "Voice transcription",
"featureSlides": "Slide generation",
"featureDiagrams": "Diagram generation"
"featureSlides": "Presentations",
"featureDiagrams": "Diagrams",
"creditsRemaining": "Credits remaining",
"creditsWhereTitle": "Credit usage by feature",
"creditsSummary": "{remaining} left · {spent} used of {granted} from plan",
"creditsUsedLine": "{credits} cr. · {actions}×",
"actionsOnly": "{actions} actions",
"noCreditUsage": "No credit usage this month",
"outOfCredits": "Out of credits — options"
},
"generalSettings": {
"title": "General",
@@ -2422,8 +2475,8 @@
"noNotesFoundForContext": "No relevant notes found for this question. Answer with your general knowledge.",
"webSearch": "Web Search",
"timeoutWarning": "Response is taking longer than expected...",
"quotaExceededBasic": "AI Chat is available from the PRO plan onwards.",
"quotaExceededTier": "Monthly quota reached for {tier} plan. It will reset next month."
"quotaExceededBasic": "AI chat requires the PRO plan or higher (or use your own API key).",
"quotaExceededTier": "AI credit balance empty on the {tier} plan. Upgrade or use your own API key."
},
"labHeader": {
"title": "The Workshop",
@@ -2657,6 +2710,7 @@
"publishSimpleHint": "Free — your note content as-is",
"publishWithAi": "Publish with AI",
"publishWithAiHint": "Enhanced layout · {count} credits left",
"publishWithAiCostHint": "Estimated cost: 3 AI credits",
"publishWithAiLocked": "Available on Pro plan",
"publishWithAiGenerating": "Generating layout…",
"publishTemplateMagazine": "Magazine article",
@@ -2728,6 +2782,9 @@
"created": "Notebook created:",
"notesCreated": "notes created",
"openNotebook": "Open notebook",
"openingNotebook": "Opening…",
"autoOpenHint": "Opening the notebook and refreshing the sidebar…",
"createdToast": "Notebook « {name} » ready — AI notes available",
"studyPlanner": "Study Plan",
"studyPlannerDesc": "AI creates a revision plan based on spaced repetition.",
"examDate": "Exam date",
@@ -2765,7 +2822,8 @@
"slashVideo": "Video",
"startVoice": "Start voice input",
"stopVoice": "Stop voice input",
"collapsibleSection": "Collapsible section"
"collapsibleSection": "Collapsible section",
"deleteImage": "Delete image"
},
"flashcards": {
"generateTitle": "Generate flashcards",
@@ -3110,8 +3168,8 @@
"seedNodeBadge": "SEED",
"originalSeedDescription": "Original seed idea",
"convertedToNoteStatus": "Converted to note",
"quotaGuest": "The session host has reached their AI limit. Ask them to upgrade their plan.",
"quotaHost": "You've reached your AI limit for this brainstorm. Upgrade your plan to continue.",
"quotaGuest": "The session host has no AI credits left. Ask them to upgrade or use their own API key.",
"quotaHost": "No AI credits left for this brainstorm. Upgrade your plan or use your own API key to continue.",
"toastExpandSuccess": "Ideas expanded!",
"toastExpandFailed": "Failed to expand",
"toastDismissSuccess": "Idea dismissed",
@@ -3157,7 +3215,7 @@
},
"byokSettings": {
"title": "Your API keys (BYOK)",
"description": "Connect your own LLM provider keys to bypass Discovery Pack quotas. Keys are encrypted at rest.",
"description": "Connect your own provider keys. With BYOK, usage typically does not consume your Memento credits. Keys are encrypted at rest.",
"badgeActive": "BYOK active",
"tierRequired": "BYOK requires a Pro plan or higher. Upgrade to connect your API keys.",
"provider": "Provider",
@@ -3212,16 +3270,16 @@
"businessPrice": "€29.90",
"proAnnualPrice": "€99",
"businessAnnualPrice": "€299",
"proFeature1": "100 semantic searches / month",
"proFeature2": "200 auto-tags / month",
"proFeature3": "50 reformulations / month",
"proFeature4": "100 chat messages / month",
"businessFeature1": "1,000 semantic searches / month",
"businessFeature2": "1,000 auto-tags / month",
"businessFeature3": "500 reformulations / month",
"businessFeature4": "1,000 chat messages / month",
"proFeature1": "Unlimited notes",
"proFeature2": "1,000 AI credits / month",
"proFeature3": "BYOK (your own keys)",
"proFeature4": "Agents (use credits)",
"businessFeature1": "10 collaborators included",
"businessFeature2": "4,000 AI credits / month",
"businessFeature3": "BYOK · 13 providers",
"businessFeature4": "Agents & brainstorm (credits)",
"enterpriseTitle": "Enterprise",
"enterpriseDescription": "Custom quotas, SSO, priority support.",
"enterpriseDescription": "Unlimited credits or dedicated pool, SSO, priority support.",
"contactSales": "Contact us",
"startCheckout": "Get Started",
"checkoutLoading": "Loading checkout…",
@@ -3238,6 +3296,20 @@
"periodRange": "Period",
"unlimited": "Unlimited",
"noUsage": "No usage data",
"creditsRemaining": "Credits remaining",
"creditsSpent": "Credits used this month",
"creditsFromPacks": "Pack credits",
"packsTitle": "Buy credits",
"packsDescription": "One-time packs. Purchased credits add to your balance and roll over across months (unlike the subscription allocation).",
"packSName": "Starter pack",
"packMName": "Standard pack",
"packLName": "Power pack",
"buyPack": "Buy",
"packNotConfigured": "Stripe config required",
"packCheckoutSuccess": "Credit pack added to your balance!",
"packCheckoutFailed": "Could not start pack purchase. Check Stripe config or try again.",
"creditsWhereTitle": "Credit usage by feature",
"creditsUsageLegend": "Every AI action draws from the same credit balance (search, chat, tags, slides, diagrams, etc.).",
"billingHistory": "Billing History",
"viewInvoices": "Manage invoices in portal",
"cancelSubscription": "Cancel subscription",
@@ -3258,26 +3330,26 @@
"renewal": "Renewal",
"paidPlanDesc": "Your subscription renews automatically.",
"businessDescription": "For teams and product leaders.",
"enterpriseFeature1": "Unlimited quotas",
"enterpriseFeature1": "Unlimited AI credits or dedicated pool",
"enterpriseFeature2": "SSO / SAML",
"enterpriseFeature3": "Dedicated support",
"enterpriseFeature4": "Custom invoicing",
"enterpriseFeature5": "Guaranteed SLA",
"subtitle": "Choose the plan that suits you",
"freeDescription": "To discover Memento",
"freeF1": "30 semantic searches",
"freeF2": "15 auto tags",
"freeF3": "5 auto titles",
"freeF4": "100 notes",
"freeF5": "3 notebooks",
"freeF1": "Up to 100 notes",
"freeF2": "3 notebooks",
"freeF3": "100 AI credits / month",
"freeF4": "Search, chat, tags (via credits)",
"freeF5": "7-day history",
"freePlanDesc": "Perfect to discover Memento",
"freePrice": "Free",
"proDescription": "For consultants and freelancers",
"proFeature5": "50 reformulations/month",
"proFeature6": "50 AI messages/month",
"proFeature5": "30-day history",
"proFeature6": "Email support",
"proCta": "Upgrade to Pro",
"businessFeature5": "500 reformulations/month",
"businessFeature6": "500 AI messages/month",
"businessFeature5": "API / MCP",
"businessFeature6": "Priority support",
"businessCta": "Upgrade to Business",
"recommended": "Recommended",
"instantActivation": "Instant activation",
@@ -3297,214 +3369,232 @@
"cancelFailed": "Failed to cancel subscription.",
"downloadPdf": "Download PDF",
"fetchStatusFailed": "Failed to fetch billing status",
"fetchQuotasFailed": "Failed to fetch quotas",
"fetchQuotasFailed": "Failed to load credit usage",
"fetchInvoicesFailed": "Failed to load billing history.",
"savePercent": "Save ~17%"
},
"quotaPaywall": {
"title": "Monthly limit reached",
"description": "You've used all your {feature} credits for this month.",
"upgrade": "Upgrade plan",
"useOwnKey": "Use your own API key",
"later": "Maybe later",
"title": "Out of AI credits",
"description": "Your credit balance is too low for this action ({feature}). All AI actions share the same balance. Upgrade your plan or use your own API key.",
"descriptionWithCost": "You have {remaining} credit(s) left; this action ({feature}) costs {cost}. Upgrade your plan or use your own API key.",
"upgrade": "View plans",
"useOwnKey": "Use my own key",
"later": "Later",
"closingIn": "Closing in {n}s"
},
"landing": {
"nav": {
"features": "Features",
"agents": "AI Agents",
"brainstorm": "Brainstorm",
"products": "Product",
"secondBrain": "Second Brain",
"echo": "Memory Echo",
"agents": "Agents",
"pricing": "Pricing",
"tech": "Architecture",
"login": "Log in",
"cta": "Get started"
"cta": "Start free",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"language": "Language"
},
"hero": {
"badge": "Powered by Artificial Intelligence",
"title1": "Your second brain,",
"title2": "finally amplified.",
"subtitle": "Memento is more than a note-taking app. It's an intelligent ecosystem that connects, analyzes and develops your ideas in real time with 6 types of AI agents and cutting-edge semantic search.",
"cta": "Sign up now",
"secondary": "See features",
"memoryEcho": "Memory Echo",
"memoryEchoText": "\"Connection detected with your sustainable design project from March 2024...\"",
"brainstormLive": "Brainstorm Live",
"ideasGenerated": "+12 ideas generated"
"badge": "Second Brain",
"eyebrow": "Not another notes app — a living second brain",
"headline": "Your second brain.",
"headlineAccent": "It remembers what you forgot.",
"subtitle": "Memento is your Second Brain: it connects ideas across months, surfaces hidden links, and turns notebooks into a system you can search, brief, and act on every morning.",
"cta": "Build my second brain",
"ctaHint": "Free to start · Google sign-in · No credit card",
"imageAlt": "Memento Second Brain workspace",
"echoLive": "Memory Echo",
"echo0": "\"Linked to your March design brief — same constraint, new angle.\"",
"echo1": "\"This echoes a research note from 8 months ago. Want to open it?\"",
"echo2": "\"Three notebooks touch this idea. Here's the bridge note.\""
},
"features": {
"label": "AI Capabilities",
"title": "Fluid intelligence,",
"title2": "woven into every word.",
"desc": "Memento orchestrates your ideas through a multi-provider architecture.",
"f1Title": "Semantic Search",
"f1Desc": "Stop searching by keywords. Find by concept. Our hybrid Vector + FTS engine understands the intent behind your notes.",
"f2Title": "Contextual RAG Chat",
"f2Desc": "Chat with your knowledge. Our agents read your notes, explore the web and analyze your documents to respond with precision.",
"f3Title": "Augmented Writing",
"f3Desc": "Reformulation, title suggestions, auto-tagging and summaries. AI works in the background to structure your thinking."
"trust": {
"trust0": "Your Second Brain, not a folder dump",
"trust1": "Bring your own AI keys",
"trust2": "15 languages, including Persian RTL",
"trust3": "Agents that write into your notebooks"
},
"pain": {
"label": "The real problem",
"title": "You don't need more notes. You need a second brain.",
"desc": "Folders fill up. Search fails. Ideas die in silence. A Second Brain finds the threads between what you capture — so yesterday's thinking powers tomorrow's decisions.",
"secondBrain": "That's Memento: a Second Brain that connects itself while you write."
},
"moments": {
"echo": {
"eyebrow": "Memory Echo",
"title": "The moment your Second Brain talks back.",
"desc": "While you write, Memento detects semantic links across notebooks — not keyword matches, real conceptual bridges you can open in a split peek.",
"card0Label": "Just detected",
"card0": "\"Your note on pricing mirrors the competitor teardown from last autumn.\"",
"card1Label": "Bridge",
"card1": "Shared theme: positioning under constraint",
"card2Label": "Action",
"card2": "Open both notes side by side — keep writing"
},
"dashboard": {
"eyebrow": "Second Brain dashboard",
"title": "A morning that tells you what matters.",
"desc": "Your Second Brain briefing: next paths, checklist, discoveries, revisions — widgets you arrange yourself.",
"w0Label": "Next paths",
"w0": "3 actions from your last note",
"w1Label": "Daily review",
"w1": "Inbox · AI · Flashcards",
"w2Label": "Memory Echo",
"w2": "2 new connections overnight",
"w3Label": "Agents",
"w3": "1 suggestion ready to run"
},
"insights": {
"eyebrow": "Insights",
"title": "See the architecture of your Second Brain.",
"desc": "Semantic clusters and bridge notes — a living map of how your knowledge actually connects.",
"chip": "Cluster network"
},
"revision": {
"eyebrow": "Revision",
"title": "Remember on purpose.",
"desc": "Generate flashcards from any note. SM-2 spaced repetition built in — knowledge that sticks in your Second Brain.",
"card": "What connects Memory Echo to your notes?"
}
},
"how": {
"title": "Three steps. Then your Second Brain compounds.",
"s0": {
"title": "Capture freely",
"desc": "Write in a next-gen editor — blocks, structured views, smart paste. Clip the web when you need it."
},
"s1": {
"title": "Let it connect",
"desc": "Memory Echo and semantic search weave links you didn't plan. Your past work becomes fuel."
},
"s2": {
"title": "Act every morning",
"desc": "Dashboard paths, agents, and flashcards turn a pile of notes into Second Brain momentum."
}
},
"agents": {
"label": "Specialized Agents",
"title": "Delegate the complex work.",
"desc": "6 types of autonomous AI agents to automate your research, summaries and presentations.",
"label": "AI agents",
"title": "Delegate the heavy lifting.",
"desc": "Research, scrape, slides, diagrams, monitoring — agents that write into your Second Brain, not another chat tab you'll forget.",
"scraper": {
"title": "Scraper",
"desc": "Scrapes URLs, parses RSS feeds and synthesizes information with smart image placement."
"desc": "URLs & RSS → synthesized notes with smart images."
},
"researcher": {
"title": "Researcher",
"desc": "Generates complex queries, explores web sources and writes structured research notes."
"desc": "Deep queries, sources, structured research notes."
},
"slideGen": {
"title": "Slide Gen",
"desc": "Transforms your notes into professional PowerPoint presentations or interactive HTML Slides."
"desc": "Notes → decks or interactive HTML slides."
},
"monitor": {
"title": "Monitor",
"desc": "Continuously analyzes your notebooks to detect trends and new insights."
"desc": "Watch notebooks for trends and new insights."
},
"diagramGen": {
"title": "Diagram Gen",
"desc": "Converts your ideas into fluid Excalidraw diagrams (Mindmaps, Flowcharts) with auto-layout."
"desc": "Ideas → Excalidraw mind maps & flows."
},
"custom": {
"title": "Custom",
"desc": "Define your own agents with specific roles and data sources."
"desc": "Your roles, sources, and schedules."
}
},
"brainstorm": {
"label": "Thought Waves",
"title": "Real-time radial brainstorming.",
"waveGeneration": {
"title": "Wave Generation",
"desc": "Variations, Analogies, then Disruptions. AI pushes your initial concept to its limits."
},
"collaboration": {
"title": "Native Collaboration",
"desc": "AI ghost cursors, synced avatars and real-time node movement."
},
"export": {
"title": "Semantic Export",
"desc": "Convert your entire brainstorm into structured notes in one click."
},
"disruptionLabel": "DISRUPTION",
"disruptionText": "Modular Architecture 2.0",
"analogyLabel": "ANALOGY",
"analogyText": "The tidal cycle"
},
"tech": {
"label": "Architecture & Providers",
"title": "Connect your own AI model.",
"tags": {
"title": "Tags",
"desc": "Independently configurable with any model."
},
"embeddings": {
"title": "Embeddings",
"desc": "Independently configurable with any model."
},
"chatRag": {
"title": "Chat RAG",
"desc": "Independently configurable with any model."
}
"byok": {
"label": "No lock-in",
"title": "Your keys. Your models. Your Second Brain.",
"desc": "Use Memento credits or plug OpenAI, Anthropic, Google and more. Switch providers in a click — the product stays yours."
},
"pricing": {
"label": "Plans & Pricing",
"title": "Choose your level of amplification.",
"desc": "Flexible options for creative minds, from individual use to large organizations.",
"label": "Pricing",
"title": "Start free. Grow your Second Brain when it clicks.",
"desc": "Clear tiers — from first notes to organizational memory.",
"monthly": "Monthly",
"annual": "Annual",
"perMonth": "/month",
"perMonthAnnual": "/month, billed annually",
"perUser": "+ 3.90/user",
"perUserAnnual": "+ 2.90/user, billed annually",
"popular": "Most popular",
"perMonth": "/mo",
"perMonthAnnual": "/mo, billed yearly",
"perUser": "+ 3.90/user",
"perUserAnnual": "+ 2.90/user, yearly",
"popular": "Most chosen",
"basic": {
"name": "Basic",
"desc": "Discover the magic of Memento.",
"cta": "Get started",
"feature0": "100 Notes max",
"feature1": "3 Notebooks",
"feature2": "50 AI credits (Lifetime)",
"feature3": "Semantic search",
"desc": "Start your Second Brain.",
"cta": "Start free",
"feature0": "Up to 100 notes",
"feature1": "3 notebooks",
"feature2": "100 AI credits / month",
"feature3": "Search, chat, tags (via credits)",
"feature4": "7-day history"
},
"pro": {
"name": "Pro",
"desc": "For demanding consultants and creators.",
"cta": "Upgrade to Pro",
"desc": "For serious thinkers.",
"cta": "Go Pro",
"feature0": "Unlimited notes",
"feature1": "BYOK (OpenAI/Anthropic)",
"feature2": "200 semantic searches",
"feature3": "Agents (12 runs/month)",
"feature1": "1,000 AI credits / month",
"feature2": "BYOK (your own keys)",
"feature3": "Agents (use credits)",
"feature4": "30-day history",
"feature5": "Email support"
},
"business": {
"name": "Business",
"desc": "For teams and product managers.",
"desc": "Team Second Brain.",
"cta": "Choose Business",
"feature0": "10 Collaborators included",
"feature1": "BYOK (13 providers)",
"feature2": "1000 semantic searches",
"feature3": "Agents (60 runs/month)",
"feature4": "Unlimited brainstorming",
"feature5": "API access"
"feature0": "10 collaborators",
"feature1": "4,000 AI credits / month",
"feature2": "BYOK · 13 providers",
"feature3": "Agents & brainstorm (credits)",
"feature4": "API / MCP",
"feature5": "Priority support"
},
"enterprise": {
"name": "Enterprise",
"desc": "Secure organizational memory.",
"cta": "Contact Sales",
"feature0": "Everything in Business",
"feature1": "Unlimited agents",
"desc": "Org-wide Second Brain.",
"cta": "Talk to us",
"feature0": "Unlimited AI credits or dedicated pool",
"feature1": "Everything in Business",
"feature2": "SSO / SAML",
"feature3": "Audit Logs & SLA",
"feature3": "Audit logs & SLA",
"feature4": "Dedicated support",
"feature5": "Live onboarding"
},
"basicPrice": "Free"
},
"byok": {
"label": "Open Cloud Technology",
"title": "The BYOK Strategy",
"desc": "Already have OpenAI, Anthropic or Google API keys? Connect them directly to Memento. Use AI without imposed credit limits, paying only what you actually consume from your favorite provider.",
"noLockin": "No lock-in",
"noLockinDesc": "Switch providers in 1 click.",
"cost": "Optimized costs",
"costDesc": "Pay the direct API price.",
"configLabel": "Multi-Provider Config"
},
"cta": {
"title1": "Ready to unlock your",
"title2": "full potential?",
"desc": "Join thousands of researchers, designers and thinkers already using Memento to build their future.",
"button": "Launch Memento"
"title": "Stop losing your best ideas.",
"desc": "Open Memento. Write one note. Watch your Second Brain find the ones you forgot.",
"button": "Create my free Second Brain"
},
"footer": {
"desc": "The AI-amplified second brain. Designed for creative minds.",
"desc": "Your AI-amplified Second Brain — it connects itself.",
"rights": "All rights reserved.",
"product": {
"title": "Product",
"link0": "Changelog",
"link1": "Documentation",
"link2": "Roadmap",
"link0Href": "#",
"link1Href": "#",
"link2Href": "#"
"link0": "Second Brain",
"link1": "Pricing",
"link2": "Agents",
"link0Href": "#product",
"link1Href": "#pricing",
"link2Href": "#agents"
},
"community": {
"title": "Community",
"link0": "Discord",
"link1": "Twitter / X",
"link2": "LinkedIn",
"link0Href": "#",
"link1Href": "#",
"link2Href": "#"
"title": "Account",
"link0": "Log in",
"link1": "Sign up",
"link2": "Support",
"link0Href": "/login",
"link1Href": "/register",
"link2Href": "/support"
},
"legal": {
"title": "Legal",
"link0": "Privacy Policy",
"link1": "Terms of Service",
"link2": "Cookie Policy",
"link0": "Privacy",
"link1": "Terms",
"link2": "Cookies",
"link0Href": "#",
"link1Href": "#",
"link2Href": "#"
@@ -3781,6 +3871,7 @@
"copyRefUnsupported": "This block type cannot be referenced yet",
"createDiagram": "Create diagram",
"createDiagramDesc": "Create an Excalidraw diagram from this block",
"createDiagramCostHint": "≈ 4 AI credits",
"createDiagramLoading": "Generating Excalidraw diagram by AI...",
"createDiagramSuccess": "Diagram generated and inserted successfully!",
"createDiagramEmpty": "The block text is empty or too short to generate a diagram."
@@ -3908,8 +3999,8 @@
"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.",
"search_credit_used": "1 credit used (search)",
"quota_exceeded": "Out of AI credits — upgrade your plan or use your own API key.",
"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.",
@@ -4330,6 +4421,9 @@
"created": "Notebook created:",
"notesCreated": "notes created",
"openNotebook": "Open notebook",
"openingNotebook": "Opening…",
"autoOpenHint": "Opening the notebook and refreshing the sidebar…",
"createdToast": "Notebook « {name} » ready — AI notes available",
"studyPlanner": "Study Plan",
"studyPlannerDesc": "AI creates a revision plan based on spaced repetition.",
"examDate": "Exam date",
@@ -4434,6 +4528,8 @@
"invalidDescription": "This chart block contains invalid or empty data.",
"editSource": "Edit chart source",
"suggestions": "Chart Suggestions",
"suggestionsTitle": "Chart suggestions",
"creditCostHint": "Estimated cost: 2 AI credits",
"analyzing": "Analyzing your content for chart data…",
"debugInfo": "Debug info",
"escCancel": "Esc Cancel",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -65,6 +65,10 @@
"freezePinnedNotebook": "Figer l'état du carnet",
"unfreezePinnedNotebook": "Défiger l'état du carnet",
"newSubNotebook": "Nouveau sous-carnet",
"aiBadge": "IA",
"aiGeneratedNote": "Note générée par l'IA",
"aiGeneratedNotebook": "Carnet créé avec l'IA — récemment",
"recentNote": "Note récente",
"notebookEmpty": "Vide",
"renameNotebook": "Renommer",
"sharedNotebookBadge": "· partagé",
@@ -132,6 +136,11 @@
"reminderPastError": "Le rappel doit être dans le futur",
"reminderRemoved": "Rappel supprimé",
"addImage": "Ajouter une image",
"coverNoneHint": "Aucune image de couverture — choisissez une image de la note ou laissez vide.",
"coverChoose": "Choisir une couverture",
"coverChange": "Changer",
"coverRemove": "Retirer la couverture",
"coverPickLabel": "Image de couverture",
"addLink": "Ajouter un lien",
"linkAdded": "Lien ajouté",
"linkMetadataFailed": "Impossible de récupérer les métadonnées du lien",
@@ -333,7 +342,9 @@
"heading2": "Titre 2",
"heading3": "Titre 3",
"importSuccess": "{{count}} notes importées !",
"unpublished": "Note dépubliée"
"unpublished": "Note dépubliée",
"deleteError": "Échec de la suppression de l'illustration",
"uploadSuccess": "Image ajoutée"
},
"pagination": {
"previous": "←",
@@ -618,6 +629,24 @@
"templateProject": "Statut de projet",
"templateStrategy": "Revue stratégique",
"templateQuarterly": "Résultats trimestriels",
"purpose": "Type de présentation",
"purposeAuto": "Auto (selon la note)",
"purposeCourse": "Cours / leçon",
"purposeBoard": "Comité / board",
"purposeProject": "Suivi projet",
"purposeStrategy": "Stratégie",
"purposePitch": "Pitch",
"purposeSummary": "Synthèse courte",
"audience": "Audience",
"audienceAuto": "Auto",
"audienceStudent": "Étudiants",
"audienceBoard": "Direction",
"audienceTeam": "Équipe",
"audienceClient": "Client",
"audienceGeneral": "Grand public",
"slideCount": "Nombre de diapositives",
"slideCountAuto": "Auto (selon la longueur)",
"creditCostEstimate": "Coût estimé : {cost} crédits (1 plan + {slides} diapositives).",
"diagram": "Générer Diagramme",
"diagramReadyHint": "Convertir en flux visuel",
"diagramType": "Type de Diagramme",
@@ -738,7 +767,7 @@
"inlineSummaryMarkdown": "**Résumé :**",
"networkErrorShort": "Erreur réseau.",
"featureLocked": "Cette fonctionnalité nécessite le plan PRO ou supérieur.",
"quotaExceeded": "Limite mensuelle atteinte. Se réinitialise le mois prochain.",
"quotaExceeded": "Solde de crédits IA insuffisant. Rechargez via un plan supérieur, ou utilisez votre propre clé.",
"noHistory": "Aucun historique",
"webSearchDisabled": "Recherche web désactivée",
"webSearchEnabled": "Recherche web activée",
@@ -847,7 +876,14 @@
"hideAll": "Masquer les connexions ({count})",
"retroTitle": "Notes qui citent ce contenu",
"retroDescription": "Ce passage est repris dans {count} autre(s) note(s) :",
"consentRequired": "Activez le traitement IA dans Paramètres → IA pour voir les connexions sémantiques de cette note."
"consentRequired": "Activez le traitement IA dans Paramètres → IA pour voir les connexions sémantiques de cette note.",
"bottomCueLoading": "Memory Echo analyse en bas…",
"bottomCueFound": "{count} notes liées plus bas",
"bottomCueFoundOne": "1 note liée plus bas",
"bottomCueRetro": "Cité dans {count} note(s) plus bas",
"bottomCueConsent": "Connexions IA disponibles plus bas",
"bottomCueScroll": "Aller à Memory Echo",
"bottomCueDismiss": "Masquer"
},
"fusion": {
"title": "🔗 Fusion intelligente",
@@ -898,7 +934,8 @@
"systemNotification": "Système",
"clipSaved": "Clip web enregistré",
"downloadFailed": "Échec du téléchargement",
"brainstormShared": "Brainstorm partagé"
"brainstormShared": "Brainstorm partagé",
"shareLabel": "Partage"
},
"clipper": {
"webClipper": "Web Clipper",
@@ -1287,6 +1324,10 @@
"embeddingsDescription": "Fournisseur IA pour la recherche sémantique. Recommandé : OpenAI (meilleure qualité).",
"chatProvider": "Fournisseur de chat",
"chatDescription": "Fournisseur IA pour l'assistant chat. Utilise le fournisseur d'étiquettes si non configuré.",
"slidesTab": "Slides",
"slidesProvider": "Modèle slides",
"slidesDescription": "Fournisseur et modèle dédiés à la génération de présentations. Si le modèle est vide, le modèle Chat est utilisé. Les identifiants de modèle ne sont jamais renommés automatiquement. Le pipeline multi-étapes consomme plusieurs crédits du solde IA global (1 plan + 1 crédit par diapositive).",
"slidesQuotaNote": "Coût en crédits : 1 + nombre de diapositives (ex. 7 diapositives ≈ 8 crédits), prélevé sur le solde unique (BASIC 100 · PRO 1 000 · BUSINESS 4 000 / mois). Admin → Utilisateurs pour réinitialiser le solde dun compte.",
"provider": "Fournisseur",
"baseUrl": "URL de base",
"model": "Modèle",
@@ -1423,6 +1464,10 @@
"roleUpdateFailed": "Échec de la mise à jour du rôle",
"tierUpdateSuccess": "Abonnement mis à jour à {tier}",
"tierUpdateFailed": "Échec de la mise à jour de l'abonnement",
"resetQuotas": "Réinitialiser les crédits IA (mois en cours)",
"confirmResetQuotas": "Remettre le solde de crédits IA à lallocation du plan pour {user} (BASIC 100 · PRO 1 000 · BUSINESS 4 000) ? Toutes les fonctions IA redeviennent disponibles selon ce solde.",
"resetQuotasSuccess": "Crédits IA réinitialisés pour la période {period} ({count} compteurs).",
"resetQuotasFailed": "Échec de la réinitialisation des crédits",
"demote": "Rétrograder en utilisateur",
"promote": "Promouvoir en admin",
"confirmDelete": "Êtes-vous sûr ? Cette action est irréversible.",
@@ -1521,31 +1566,32 @@
},
"settingsDescription": "Configurer les paramètres de l'application",
"billing": {
"title": "Facturation & quotas",
"description": "Gérez les paliers, quotas IA et la config Stripe métier sans redéploiement.",
"title": "Facturation & crédits IA",
"description": "Stripe métier, solde de crédits par palier, et outils avancés. Le débit utilisateur suit le solde unique de crédits.",
"stripeConfigTitle": "Config Stripe (métier)",
"stripeConfigDescription": "Identifiants de prix et activation de la facturation. Les clés secrètes restent dans l'environnement serveur.",
"enableBilling": "Activer l'interface et le paiement Stripe",
"subscriptionPricesTitle": "Abonnements (price IDs)",
"STRIPE_PRICE_PRO_MONTHLY": "Pro — prix mensuel (price ID)",
"STRIPE_PRICE_PRO_ANNUAL": "Pro — prix annuel (price ID)",
"STRIPE_PRICE_BUSINESS_MONTHLY": "Business — prix mensuel (price ID)",
"STRIPE_PRICE_BUSINESS_ANNUAL": "Business — prix annuel (price ID)",
"packPricesTitle": "Packs de crédits (price IDs one-shot)",
"packPricesDescription": "Produits Stripe en paiement unique (mode payment). Les crédits achetés sajoutent au solde et se reportent dun mois à lautre.",
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 crédits (price ID)",
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 crédits (price ID)",
"STRIPE_PRICE_CREDITS_L": "Pack L — 2 000 crédits (price ID)",
"packsCatalogTitle": "Catalogue packs (code)",
"secretsNote": "STRIPE_SECRET_KEY et STRIPE_WEBHOOK_SECRET doivent rester dans Docker/env — jamais stockés ici.",
"saveConfig": "Enregistrer la config",
"configSaved": "Configuration enregistrée",
"configFailed": "Échec de l'enregistrement",
"limitsTitle": "Quotas par palier",
"limitsDescription": "Limites mensuelles par fonctionnalité. Prise en effet en ~60 s.",
"feature": "Fonctionnalité",
"mode": "Accès",
"monthlyLimit": "Limite mensuelle",
"actions": "Actions",
"modeUnavailable": "Indisponible",
"modeLimited": "Limité",
"creditsTitle": "Crédits IA par palier (option M)",
"creditsDescription": "Source de vérité du débit : un seul solde pour toutes les actions IA. Valeurs dans le code (lib/credits.ts).",
"creditsPerMonth": "crédits / mois",
"creditsCostsNote": "Coûts typiques : recherche, chat, tags = 1 crédit ; diagramme = 4 ; publication IA = 3 ; présentation = 1 + nombre de diapositives (ex. 7 diapositives ≈ 8 crédits).",
"creditsResetHint": "Pour recharger un utilisateur : Admin → Utilisateurs → bouton réinitialiser les crédits.",
"modeUnlimited": "Illimité",
"saveLimit": "Enregistrer",
"limitSaved": "Quota mis à jour",
"limitFailed": "Échec de la mise à jour",
"usageTitle": "Aperçu consommation",
"usagePeriod": "Période {period}",
"lastSync": "Dernière sync {date}",
@@ -1737,8 +1783,8 @@
"accentColorDescription": "Définissez la couleur principale de votre espace de travail"
},
"usageMeter": {
"packName": "Pack découverte IA",
"packPro": "Pack Pro",
"packName": "Plan Basic",
"packPro": "Plan Pro",
"packBusiness": "Pack Business",
"packEnterprise": "Pack Enterprise",
"featureSearch": "Recherche",
@@ -1747,13 +1793,13 @@
"unlimited": "Illimité",
"remaining": "{count} restants",
"upgradeTitle": "Passer à Pro",
"upgradeDescription": "Vous avez utilisé tous les crédits du pack découverte IA. Passez à Pro pour des plafonds plus élevés et des fonctionnalités supplémentaires.",
"upgradeDescription": "Votre solde de crédits IA est épuisé. Passez à un plan supérieur (plus de crédits mensuels) ou utilisez votre propre clé (BYOK).",
"proIncludes": "Pro inclut :",
"proSearch": "100 recherches sémantiques / mois",
"proTags": "200 étiquettes auto / mois",
"proTitles": "200 titres auto / mois",
"proReformulate": "50 reformulations / mois",
"proChat": "100 messages de chat / mois",
"proSearch": "1 000 crédits IA / mois",
"proTags": "BYOK (vos propres clés)",
"proTitles": "Agents (consomment des crédits)",
"proReformulate": "Notes illimitées",
"proChat": "Support e-mail",
"later": "Plus tard",
"upgradePricing": "Passer à Pro",
"addApiKey": "Utiliser votre propre clé API (BYOK)",
@@ -1767,8 +1813,15 @@
"featureBrainstormEnrich": "Enrichissements brainstorm",
"featureFlashcards": "Flashcards IA",
"featureVoice": "Transcription vocale",
"featureSlides": "Génération de diapositives",
"featureDiagrams": "Génération de diagrammes"
"featureSlides": "Présentations",
"featureDiagrams": "Diagrammes",
"creditsRemaining": "Crédits restants",
"creditsWhereTitle": "Utilisation des crédits par fonction",
"creditsSummary": "{remaining} restants · {spent} utilisés sur {granted} du forfait",
"creditsUsedLine": "{credits} cr. · {actions}×",
"actionsOnly": "{actions} actions",
"noCreditUsage": "Aucune consommation de crédits ce mois-ci",
"outOfCredits": "Plus de crédits — options"
},
"generalSettings": {
"title": "Généraux",
@@ -2428,8 +2481,8 @@
"noNotesFoundForContext": "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
"webSearch": "Recherche web",
"timeoutWarning": "La réponse met plus de temps que prévu...",
"quotaExceededBasic": "Le chat IA est réservé au plan PRO et supérieur.",
"quotaExceededTier": "Limite mensuelle atteinte pour le plan {tier}. Elle se réinitialise le mois prochain."
"quotaExceededBasic": "Le chat IA est réservé au plan PRO ou supérieur (ou utilisez votre propre clé).",
"quotaExceededTier": "Solde de crédits IA épuisé pour le plan {tier}. Passez à un plan supérieur ou utilisez votre propre clé."
},
"labHeader": {
"title": "L'Atelier",
@@ -2663,6 +2716,7 @@
"publishSimpleHint": "Gratuit — le contenu de votre note",
"publishWithAi": "Publier avec l'IA",
"publishWithAiHint": "Mise en page soignée · {count} crédits restants",
"publishWithAiCostHint": "Coût estimé : 3 crédits IA",
"publishWithAiLocked": "Disponible avec le plan Pro",
"publishWithAiGenerating": "Génération de la mise en page…",
"publishTemplateMagazine": "Article magazine",
@@ -2734,6 +2788,9 @@
"created": "Carnet créé :",
"notesCreated": "notes créées",
"openNotebook": "Ouvrir le carnet",
"openingNotebook": "Ouverture…",
"autoOpenHint": "Ouverture automatique du carnet et mise à jour de la barre latérale…",
"createdToast": "Carnet « {name} » prêt — notes IA disponibles",
"studyPlanner": "Planning de révision",
"studyPlannerDesc": "L'IA crée un planning de révision basé sur la répétition espacée.",
"examDate": "Date de l'examen",
@@ -2771,7 +2828,8 @@
"slashVideo": "Vidéo",
"startVoice": "Démarrer la dictée vocale",
"stopVoice": "Arrêter la dictée vocale",
"collapsibleSection": "Section repliable"
"collapsibleSection": "Section repliable",
"deleteImage": "Supprimer l'image"
},
"flashcards": {
"generateTitle": "Générer des flashcards",
@@ -3116,8 +3174,8 @@
"seedNodeBadge": "GRAINE",
"originalSeedDescription": "Idée source initiale",
"convertedToNoteStatus": "Convertie en note",
"quotaGuest": "L'hôte de la session a atteint sa limite d'IA. Demandez-lui de mettre à niveau son forfait.",
"quotaHost": "Vous avez atteint votre limite d'IA pour ce brainstorm. Passez à un forfait supérieur pour continuer.",
"quotaGuest": "L'hôte de la session n'a plus de crédits IA. Demandez-lui de passer à un plan supérieur ou d'utiliser sa propre clé.",
"quotaHost": "Plus de crédits IA pour ce brainstorm. Passez à un plan supérieur ou utilisez votre propre clé pour continuer.",
"toastExpandSuccess": "Idées élargies !",
"toastExpandFailed": "Échec de l'élargissement",
"toastDismissSuccess": "Idée écartée",
@@ -3163,7 +3221,7 @@
},
"byokSettings": {
"title": "Vos clés API (BYOK)",
"description": "Connectez vos propres clés fournisseur pour contourner les quotas du pack Découverte. Les clés sont chiffrées au repos.",
"description": "Connectez vos propres clés fournisseur. Avec le BYOK, lusage ne consomme en général pas vos crédits Memento. Les clés sont chiffrées au repos.",
"badgeActive": "BYOK actif",
"tierRequired": "Le BYOK nécessite un abonnement Pro ou supérieur.",
"provider": "Fournisseur",
@@ -3218,16 +3276,16 @@
"businessPrice": "29,90 €",
"proAnnualPrice": "99 €",
"businessAnnualPrice": "299 €",
"proFeature1": "100 recherches sémantiques / mois",
"proFeature2": "200 tags automatiques / mois",
"proFeature3": "50 reformulations / mois",
"proFeature4": "100 messages de chat / mois",
"businessFeature1": "1 000 recherches sémantiques / mois",
"businessFeature2": "1 000 tags automatiques / mois",
"businessFeature3": "500 reformulations / mois",
"businessFeature4": "1 000 messages de chat / mois",
"proFeature1": "Notes illimitées",
"proFeature2": "1 000 crédits IA / mois",
"proFeature3": "BYOK (vos propres clés)",
"proFeature4": "Agents (consomment des crédits)",
"businessFeature1": "10 collaborateurs inclus",
"businessFeature2": "4 000 crédits IA / mois",
"businessFeature3": "BYOK · 13 fournisseurs",
"businessFeature4": "Agents & brainstorm (crédits)",
"enterpriseTitle": "Entreprise",
"enterpriseDescription": "Quotas personnalisés, SSO, support prioritaire.",
"enterpriseDescription": "Crédits illimités ou pool dédié, SSO, support prioritaire.",
"contactSales": "Contactez-nous",
"startCheckout": "Commencer",
"checkoutLoading": "Chargement du paiement…",
@@ -3244,6 +3302,20 @@
"periodRange": "Période",
"unlimited": "Illimité",
"noUsage": "Aucune donnée d'utilisation",
"creditsRemaining": "Crédits restants",
"creditsSpent": "Crédits utilisés ce mois",
"creditsFromPacks": "Crédits de packs",
"packsTitle": "Acheter des crédits",
"packsDescription": "Packs à usage unique. Les crédits achetés sajoutent à votre solde et se reportent dun mois à lautre (contrairement à lallocation dabonnement).",
"packSName": "Pack découverte",
"packMName": "Pack standard",
"packLName": "Pack intensif",
"buyPack": "Acheter",
"packNotConfigured": "Config Stripe requise",
"packCheckoutSuccess": "Pack de crédits ajouté à votre solde !",
"packCheckoutFailed": "Échec de lachat du pack. Vérifiez la config Stripe ou réessayez.",
"creditsWhereTitle": "Utilisation des crédits par fonction",
"creditsUsageLegend": "Chaque action IA consomme des crédits du même solde (recherche, chat, étiquettes, diapositives, diagrammes, etc.).",
"billingHistory": "Historique de facturation",
"viewInvoices": "Gérer les factures dans le portail",
"cancelSubscription": "Résilier l'abonnement",
@@ -3264,26 +3336,26 @@
"renewal": "Renouvellement",
"paidPlanDesc": "Votre abonnement se renouvelle automatiquement.",
"businessDescription": "Pour les équipes et chefs de produit.",
"enterpriseFeature1": "Quotas illimités",
"enterpriseFeature1": "Crédits IA illimités ou pool dédié",
"enterpriseFeature2": "SSO / SAML",
"enterpriseFeature3": "Support dédié",
"enterpriseFeature4": "Facturation personnalisée",
"enterpriseFeature5": "SLA garanti",
"subtitle": "Choisissez le plan qui vous convient",
"freeDescription": "Pour découvrir Memento",
"freeF1": "30 recherches sémantiques",
"freeF2": "15 étiquettes auto",
"freeF3": "5 titres auto",
"freeF4": "100 notes",
"freeF5": "3 carnets",
"freeF1": "Jusqu'à 100 notes",
"freeF2": "3 carnets",
"freeF3": "100 crédits IA / mois",
"freeF4": "Recherche, chat, tags (via crédits)",
"freeF5": "Historique 7 jours",
"freePlanDesc": "Idéal pour découvrir Memento",
"freePrice": "Gratuit",
"proDescription": "Pour les consultants et freelances",
"proFeature5": "50 reformulations/mois",
"proFeature6": "50 messages IA/mois",
"proFeature5": "Historique 30 jours",
"proFeature6": "Support e-mail",
"proCta": "Passer à Pro",
"businessFeature5": "500 reformulations/mois",
"businessFeature6": "500 messages IA/mois",
"businessFeature5": "API / MCP",
"businessFeature6": "Support prioritaire",
"businessCta": "Passer à Business",
"recommended": "Recommandé",
"instantActivation": "Activation instantanée",
@@ -3303,213 +3375,231 @@
"cancelFailed": "Échec de l'annulation de l'abonnement.",
"downloadPdf": "Télécharger le PDF",
"fetchStatusFailed": "Échec du chargement des informations de facturation",
"fetchQuotasFailed": "Échec du chargement des quotas",
"fetchQuotasFailed": "Échec du chargement des crédits",
"fetchInvoicesFailed": "Impossible de charger l'historique de facturation.",
"savePercent": "Économisez ~17%"
},
"quotaPaywall": {
"title": "Limite mensuelle atteinte",
"description": "Vous avez épuisé vos crédits pour la fonctionnalité {feature} ce mois-ci.",
"upgrade": "Passer au plan supérieur",
"useOwnKey": "Clé API perso",
"later": "Peut-être plus tard",
"title": "Plus de crédits IA",
"description": "Votre solde de crédits est insuffisant pour cette action ({feature}). Toutes les actions IA partagent le même solde. Passez à un plan supérieur ou utilisez votre propre clé.",
"descriptionWithCost": "Il vous reste {remaining} crédit(s) ; cette action ({feature}) en coûte {cost}. Passez à un plan supérieur ou utilisez votre propre clé.",
"upgrade": "Voir les plans",
"useOwnKey": "Utiliser ma propre clé",
"later": "Plus tard",
"closingIn": "Fermeture dans {n}s"
},
"landing": {
"nav": {
"features": "Fonctionnalités",
"agents": "Agents IA",
"brainstorm": "Brainstorm",
"products": "Produit",
"secondBrain": "Second Brain",
"echo": "Memory Echo",
"agents": "Agents",
"pricing": "Tarifs",
"tech": "Architecture",
"login": "Se connecter",
"cta": "Commencez"
"cta": "Essayer gratuitement",
"openMenu": "Ouvrir le menu",
"closeMenu": "Fermer le menu",
"language": "Langue"
},
"hero": {
"badge": "Augmenté par l'Intelligence Artificielle",
"title1": "Votre second cerveau,",
"title2": "enfin amplifié.",
"subtitle": "Memento n'est pas qu'une simple application de notes. C'est un écosystème intelligent qui connecte, analyse et développe vos idées en temps réel grâce à 6 types d'agents IA et une recherche sémantique de pointe.",
"cta": "S'inscrire maintenant",
"secondary": "Voir les fonctionnalités",
"memoryEcho": "Memory Echo",
"memoryEchoText": "\"Connexion détectée avec votre projet de design durable de Mars 2024...\"",
"brainstormLive": "Brainstorm Live",
"ideasGenerated": "+12 idées générées"
"badge": "Second Brain",
"eyebrow": "Pas une app de notes de plus — un second cerveau vivant",
"headline": "Votre second cerveau.",
"headlineAccent": "Il se souvient de ce que vous avez oublié.",
"subtitle": "Memento, c'est votre Second Brain : il relie vos idées à travers les mois, fait émerger les liens cachés, et transforme vos carnets en système que vous pouvez chercher, briefer et actionner chaque matin.",
"cta": "Créer mon second cerveau",
"ctaHint": "Gratuit pour commencer · Google · Sans carte bancaire",
"imageAlt": "Espace Second Brain Memento",
"echoLive": "Memory Echo",
"echo0": "« Lié à votre brief design de mars — même contrainte, nouvel angle. »",
"echo1": "« Ça fait écho à une note de recherche d'il y a 8 mois. L'ouvrir ? »",
"echo2": "« Trois carnets touchent cette idée. Voici la note pont. »"
},
"features": {
"label": "Capacités IA",
"title": "Une intelligence fluide,",
"title2": "intégrée à chaque mot.",
"desc": "Memento orchestre vos idées grâce à une architecture multi-fournisseurs.",
"f1Title": "Recherche Sémantique",
"f1Desc": "Ne cherchez plus par mots-clés. Trouvez par concept. Notre moteur hybride Vector + FTS comprend l'intention derrière vos notes.",
"f2Title": "Chat RAG Contextuel",
"f2Desc": "Discutez avec votre savoir. Nos agents lisent vos notes, explorent le web et analysent vos documents pour répondre avec précision.",
"f3Title": "Écriture Augmentée",
"f3Desc": "Reformulation, suggestions de titres, tagging automatique et résumés. L'IA travaille en arrière-plan pour structurer votre pensée."
"trust": {
"trust0": "Un vrai Second Brain, pas un tas de dossiers",
"trust1": "Vos propres clés IA (BYOK)",
"trust2": "15 langues, dont le persan RTL",
"trust3": "Des agents qui écrivent dans vos carnets"
},
"pain": {
"label": "Le vrai problème",
"title": "Vous n'avez pas besoin de plus de notes. Vous avez besoin d'un second cerveau.",
"desc": "Les dossiers débordent. La recherche rate. Les idées meurent en silence. Un Second Brain retrouve les fils entre ce que vous capturez — pour que la pensée d'hier alimente les décisions de demain.",
"secondBrain": "C'est Memento : un Second Brain qui se connecte pendant que vous écrivez."
},
"moments": {
"echo": {
"eyebrow": "Memory Echo",
"title": "Le moment où votre Second Brain vous répond.",
"desc": "Pendant que vous écrivez, Memento détecte des liens sémantiques entre carnets — pas des mots-clés, de vrais ponts conceptuels, ouvrables en aperçu partagé.",
"card0Label": "À l'instant",
"card0": "« Votre note sur le pricing fait écho à l'analyse concurrente de l'automne. »",
"card1Label": "Pont",
"card1": "Thème partagé : positionnement sous contrainte",
"card2Label": "Action",
"card2": "Ouvrir les deux notes côte à côte — continuez d'écrire"
},
"dashboard": {
"eyebrow": "Dashboard Second Brain",
"title": "Un matin qui vous dit ce qui compte.",
"desc": "Le briefing de votre Second Brain : prochaines pistes, checklist, découvertes, révisions — des widgets que vous arrangez.",
"w0Label": "Prochaines pistes",
"w0": "3 actions depuis votre dernière note",
"w1Label": "Revue du jour",
"w1": "Inbox · IA · Flashcards",
"w2Label": "Memory Echo",
"w2": "2 nouvelles connexions cette nuit",
"w3Label": "Agents",
"w3": "1 suggestion prête à lancer"
},
"insights": {
"eyebrow": "Insights",
"title": "Voyez l'architecture de votre Second Brain.",
"desc": "Clusters sémantiques et notes pont — la carte vivante de vos connexions.",
"chip": "Réseau de clusters"
},
"revision": {
"eyebrow": "Révision",
"title": "Mémorisez exprès.",
"desc": "Générez des flashcards depuis n'importe quelle note. Répétition espacée SM-2 — le savoir qui tient dans votre Second Brain.",
"card": "Qu'est-ce qui relie Memory Echo à vos notes ?"
}
},
"how": {
"title": "Trois étapes. Puis votre Second Brain compound.",
"s0": {
"title": "Capturez librement",
"desc": "Écrivez dans un éditeur nouvelle génération — blocs, vues structurées, collage intelligent. Clippez le web si besoin."
},
"s1": {
"title": "Laissez connecter",
"desc": "Memory Echo et la recherche sémantique tissent des liens que vous n'aviez pas prévus. Votre passé devient du carburant."
},
"s2": {
"title": "Agissez chaque matin",
"desc": "Pistes du dashboard, agents et flashcards transforment une pile de notes en élan Second Brain."
}
},
"agents": {
"label": "Agents Spécialisés",
"title": "Déléguez le travail complexe.",
"desc": "6 types d'agents IA autonomes pour automatiser vos recherches, vos résumés et vos présentations.",
"label": "Agents IA",
"title": "Déléguez le gros du travail.",
"desc": "Recherche, scrape, slides, diagrammes, veille — des agents qui écrivent dans votre Second Brain, pas un chat de plus que vous oublierez.",
"scraper": {
"title": "Scraper",
"desc": "Scrape des URLs, parse les flux RSS et synthétise l'info avec placement d'images intelligent."
"desc": "URL & RSS → notes synthétisées avec images."
},
"researcher": {
"title": "Researcher",
"desc": "Génère des requêtes complexes, explore les sources web et rédige des notes de recherche structurées."
"desc": "Requêtes profondes, sources, notes structurées."
},
"slideGen": {
"title": "Slide Gen",
"desc": "Transforme vos notes en présentations PowerPoint professionnelles ou Slides HTML Interactives."
"desc": "Notes → decks ou slides HTML interactives."
},
"monitor": {
"title": "Monitor",
"desc": "Analyse continuellement vos carnets pour détecter les tendances et les nouveaux insights."
"desc": "Surveille vos carnets : tendances et insights."
},
"diagramGen": {
"title": "Diagram Gen",
"desc": "Convertit vos idées en diagrammes Excalidraw fluides (Mindmaps, Flowcharts) avec auto-layout."
"desc": "Idées → mindmaps & flows Excalidraw."
},
"custom": {
"title": "Custom",
"desc": "Définissez vos propres agents avec des rôles et des sources de données spécifiques."
"desc": "Vos rôles, sources et planifications."
}
},
"brainstorm": {
"label": "Vagues de Pensée",
"title": "Brainstorming radial en temps réel.",
"waveGeneration": {
"title": "Génération par Vagues",
"desc": "Variations, Analogies, puis Disruptions. L'IA pousse votre concept initial dans ses retranchements."
},
"collaboration": {
"title": "Collaboration Native",
"desc": "Curseurs fantômes IA, avatars synchronisés et déplacement de nœuds en temps réel."
},
"export": {
"title": "Export Sémantique",
"desc": "Convertissez tout votre brainstorm en notes structurées d'un seul clic."
},
"disruptionLabel": "DISRUPTION",
"disruptionText": "Architecture Modulaire 2.0",
"analogyLabel": "ANALOGIE",
"analogyText": "Le cycle des marées"
},
"tech": {
"label": "Architecture & Fournisseurs",
"title": "Connectez votre propre intelligence.",
"tags": {
"title": "Tags",
"desc": "Indépendamment configurable avec n'importe quel modèle."
},
"embeddings": {
"title": "Embeddings",
"desc": "Indépendamment configurable avec n'importe quel modèle."
},
"chatRag": {
"title": "Chat RAG",
"desc": "Indépendamment configurable avec n'importe quel modèle."
}
"byok": {
"label": "Sans lock-in",
"title": "Vos clés. Vos modèles. Votre Second Brain.",
"desc": "Crédits Memento ou OpenAI, Anthropic, Google… Changez de fournisseur en un clic — le produit reste le vôtre."
},
"pricing": {
"label": "Plans & Tarification",
"title": "Choisissez votre niveau d'amplification.",
"desc": "Des options flexibles pour les esprits créatifs, de l'usage individuel aux grandes organisations.",
"label": "Tarifs",
"title": "Gratuit pour commencer. Faites grandir votre Second Brain.",
"desc": "Des paliers clairs — des premières notes à la mémoire d'équipe.",
"monthly": "Mensuel",
"annual": "Annuel",
"perMonth": "/mois",
"perMonthAnnual": "/mois, facturé annuellement",
"perMonthAnnual": "/mois, facturé à l'année",
"perUser": "+ 3,90€/user",
"perUserAnnual": "+ 2,90€/user, facturé annuellement",
"popular": "Le plus populaire",
"perUserAnnual": "+ 2,90€/user, à l'année",
"popular": "Le plus choisi",
"basic": {
"name": "Basic",
"desc": "Pour découvrir la magie de Memento.",
"cta": "Commencer",
"feature0": "100 Notes max",
"feature1": "3 Carnets",
"feature2": "50 crédits IA (Lifetime)",
"feature3": "Recherche sémantique",
"desc": "Démarrez votre Second Brain.",
"cta": "Commencer gratuit",
"feature0": "Jusqu'à 100 notes",
"feature1": "3 carnets",
"feature2": "100 crédits IA / mois",
"feature3": "Recherche, chat, tags (via crédits)",
"feature4": "Historique 7 jours"
},
"pro": {
"name": "Pro",
"desc": "Pour les consultants et créateurs exigeants.",
"desc": "Pour les esprits exigeants.",
"cta": "Passer Pro",
"feature0": "Notes illimitées",
"feature1": "BYOK (OpenAI/Anthropic)",
"feature2": "200 recherches sémantiques",
"feature3": "Agents (12 runs/mois)",
"feature1": "1 000 crédits IA / mois",
"feature2": "BYOK (vos propres clés)",
"feature3": "Agents (consomment des crédits)",
"feature4": "Historique 30 jours",
"feature5": "Support Email"
"feature5": "Support e-mail"
},
"business": {
"name": "Business",
"desc": "Pour les équipes et chefs de produit.",
"desc": "Second Brain d'équipe.",
"cta": "Choisir Business",
"feature0": "10 Collaborateurs inclus",
"feature1": "BYOK (13 fournisseurs)",
"feature2": "1000 recherches sémantiques",
"feature3": "Agents (60 runs/mois)",
"feature4": "Brainstorm illimité",
"feature5": "Accès API"
"feature0": "10 collaborateurs",
"feature1": "4 000 crédits IA / mois",
"feature2": "BYOK · 13 fournisseurs",
"feature3": "Agents & brainstorm (crédits)",
"feature4": "API / MCP",
"feature5": "Support prioritaire"
},
"enterprise": {
"name": "Enterprise",
"desc": "Mémoire organisationnelle sécurisée.",
"cta": "Contacter Ventes",
"feature0": "Tout Business",
"feature1": "Agents illimités",
"desc": "Second Brain organisationnel.",
"cta": "Nous parler",
"feature0": "Crédits IA illimités ou pool dédié",
"feature1": "Tout Business",
"feature2": "SSO / SAML",
"feature3": "Audit Logs & SLA",
"feature4": "Support Dédié",
"feature5": "Onboarding Live"
"feature3": "Audit logs & SLA",
"feature4": "Support dédié",
"feature5": "Onboarding live"
},
"basicPrice": "Gratuit"
},
"byok": {
"label": "Technologie Cloud Ouverte",
"title": "La stratégie BYOK",
"desc": "Vous possédez déjà des clés API OpenAI, Anthropic ou Google ? Connectez-les directement à Memento. Utilisez l'IA sans limites de crédits imposées, en payant uniquement ce que vous consommez chez votre fournisseur favori.",
"noLockin": "Pas de lock-in",
"noLockinDesc": "Changez de fournisseur en 1 clic.",
"cost": "Coûts optimisés",
"costDesc": "Payez le prix direct API.",
"configLabel": "Config Multi-Fournisseurs"
},
"cta": {
"title1": "Prêt à libérer votre",
"title2": "plein potentiel ?",
"desc": "Rejoignez des milliers de chercheurs, designers et penseurs qui utilisent déjà Memento pour construire leur futur.",
"button": "Lancer Memento"
"title": "Arrêtez de perdre vos meilleures idées.",
"desc": "Ouvrez Memento. Écrivez une note. Regardez votre Second Brain retrouver celles que vous aviez oubliées.",
"button": "Créer mon Second Brain gratuit"
},
"footer": {
"desc": "Le second cerveau amplifié par l'IA. Pensé pour les esprits créatifs.",
"desc": "Votre Second Brain amplifié par l'IA — il se connecte tout seul.",
"rights": "Tous droits réservés.",
"product": {
"title": "Produit",
"link0": "Changelog",
"link1": "Documentation",
"link2": "Roadmap",
"link0Href": "#",
"link1Href": "#",
"link2Href": "#"
"link0": "Second Brain",
"link1": "Tarifs",
"link2": "Agents",
"link0Href": "#product",
"link1Href": "#pricing",
"link2Href": "#agents"
},
"community": {
"title": "Communauté",
"link0": "Discord",
"link1": "Twitter / X",
"link2": "LinkedIn",
"link0Href": "#",
"link1Href": "#",
"link2Href": "#"
"title": "Compte",
"link0": "Se connecter",
"link1": "S'inscrire",
"link2": "Support",
"link0Href": "/login",
"link1Href": "/register",
"link2Href": "/support"
},
"legal": {
"title": "Légal",
"link0": "Politique de confidentialité",
"link1": "Conditions d'utilisation",
"link0": "Confidentialité",
"link1": "Conditions",
"link2": "Cookies",
"link0Href": "#",
"link1Href": "#",
@@ -3787,6 +3877,7 @@
"copyRefUnsupported": "Ce type de bloc ne peut pas encore être référencé",
"createDiagram": "Créer un diagramme",
"createDiagramDesc": "Créer un diagramme Excalidraw à partir de ce bloc",
"createDiagramCostHint": "≈ 4 crédits IA",
"createDiagramLoading": "Génération du diagramme Excalidraw par l'IA...",
"createDiagramSuccess": "Diagramme généré et inséré avec succès !",
"createDiagramEmpty": "Le texte du bloc est vide ou trop court pour générer un diagramme."
@@ -3911,11 +4002,11 @@
"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_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é.",
"search_credit_used": "1 crédit utilisé (recherche)",
"quota_exceeded": "Plus de crédits IA — passez à un plan supérieur ou utilisez votre propre clé.",
"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.",
@@ -4336,6 +4427,9 @@
"created": "Carnet créé :",
"notesCreated": "notes créées",
"openNotebook": "Ouvrir le carnet",
"openingNotebook": "Ouverture…",
"autoOpenHint": "Ouverture automatique du carnet et mise à jour de la barre latérale…",
"createdToast": "Carnet « {name} » prêt — notes IA disponibles",
"studyPlanner": "Planning de révision",
"studyPlannerDesc": "L'IA crée un planning de révision basé sur la répétition espacée.",
"examDate": "Date de l'examen",
@@ -4440,6 +4534,8 @@
"invalidDescription": "Ce bloc de graphique contient des données invalides ou vides.",
"editSource": "Modifier le code source",
"suggestions": "Suggestions de graphiques",
"suggestionsTitle": "Suggestions de graphiques",
"creditCostHint": "Coût estimé : 2 crédits IA",
"analyzing": "Analyse de votre contenu pour des données graphiques…",
"debugInfo": "Infos de débogage",
"escCancel": "Échap Annuler",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
-- Crédits IA globaux (modèle A) — tables additives uniquement
CREATE TABLE IF NOT EXISTS "AiCreditAccount" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"periodKey" TEXT NOT NULL,
"subscriptionGranted" INTEGER NOT NULL DEFAULT 0,
"subscriptionSpent" INTEGER NOT NULL DEFAULT 0,
"purchasedBalance" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AiCreditAccount_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "AiCreditAccount_userId_key" ON "AiCreditAccount"("userId");
CREATE INDEX IF NOT EXISTS "AiCreditAccount_periodKey_idx" ON "AiCreditAccount"("periodKey");
CREATE TABLE IF NOT EXISTS "AiCreditLedger" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"feature" TEXT,
"balanceAfter" INTEGER,
"metadata" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AiCreditLedger_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "AiCreditLedger_userId_createdAt_idx" ON "AiCreditLedger"("userId", "createdAt");
CREATE INDEX IF NOT EXISTS "AiCreditLedger_userId_feature_createdAt_idx" ON "AiCreditLedger"("userId", "feature", "createdAt");
CREATE INDEX IF NOT EXISTS "AiCreditLedger_userId_kind_createdAt_idx" ON "AiCreditLedger"("userId", "kind", "createdAt");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'AiCreditAccount_userId_fkey'
) THEN
ALTER TABLE "AiCreditAccount"
ADD CONSTRAINT "AiCreditAccount_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'AiCreditLedger_userId_fkey'
) THEN
ALTER TABLE "AiCreditLedger"
ADD CONSTRAINT "AiCreditLedger_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;

View File

@@ -60,6 +60,8 @@ model User {
flashcardDecks FlashcardDeck[]
errorLogs ErrorLog[]
gmailScanHistory GmailScanHistory[]
aiCreditAccount AiCreditAccount?
aiCreditLedger AiCreditLedger[]
}
model Account {
@@ -890,6 +892,49 @@ model PlanEntitlement {
@@index([tier])
}
// ===== CRÉDITS IA GLOBAUX (modèle A) =====
/// Solde de crédits par utilisateur pour la période mensuelle en cours.
model AiCreditAccount {
id String @id @default(cuid())
userId String @unique
/// Clé de période YYYY-MM
periodKey String
/// Allocation abonnement du mois (ex. 300 pour PRO)
subscriptionGranted Int @default(0)
/// Crédits d'abonnement déjà consommés ce mois
subscriptionSpent Int @default(0)
/// Crédits de packs achetés encore disponibles (reportables)
purchasedBalance Int @default(0)
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([periodKey])
}
/// Journal immuable des mouvements de crédits (transparence d'usage).
model AiCreditLedger {
id String @id @default(cuid())
userId String
/// GRANT_SUBSCRIPTION | PURCHASE_PACK | CONSUME | RELEASE | ADMIN_ADJUST | EXPIRE_SUBSCRIPTION | ADMIN_RESET
kind String
/// Positif = crédit, négatif = débit
amount Int
/// Feature technique pour le tableau de répartition (ex. slide_generate)
feature String?
balanceAfter Int?
metadata String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([userId, feature, createdAt])
@@index([userId, kind, createdAt])
}
// ===== CLUSTERING & BRIDGE NOTES =====
model NoteCluster {

Some files were not shown because too many files have changed in this diff Show More