feat: publication IA (magazine/brief/essay) + fixes critique
Publication IA: - 4 templates (magazine, brief, essay, simple) avec CSS riche - Rewrite IA (article/exercises/tutorial/reference/mixed) - Modération avec timeout 12s + fallback safe - Quotas publish_enhance par tier (basic=2, pro=15, business=100) - Détection contenu stale (hash) - Migration DB publishedContent/publishedTemplate/publishedSourceHash Fixes: - cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast - _isShared ajouté au type Note (champ virtuel serveur) - callout colors PDF export: extraction fonction pure testable - admin/published: guard note.userId null - Cmd+S fonctionne en mode dialog (pas seulement fullPage) i18n: - 23 clés publish* traduites dans les 15 locales - Extension Web Clipper: 13 locales mise à jour Tests: - callout-colors.test.ts (6 tests) - note-visible-in-view.test.ts (5 tests) - entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs - 199/199 tests passent Tracker: user-stories.md sync avec sprint-status.yaml
This commit is contained in:
@@ -29,7 +29,7 @@ export default function AuthLayout({
|
||||
<div className="w-8 h-8 bg-[var(--foreground)] text-[var(--background)] rounded-xl flex items-center justify-center shadow-lg">
|
||||
<span className="font-serif font-bold text-xl">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Momento</span>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||
</Link>
|
||||
|
||||
<div className="w-8" />
|
||||
@@ -39,7 +39,7 @@ export default function AuthLayout({
|
||||
<div className="w-full max-w-md">
|
||||
{children}
|
||||
<p className="text-center mt-8 text-[9px] text-[var(--muted-foreground)] font-bold uppercase tracking-[0.3em] opacity-40 select-none">
|
||||
© 2025 Momento Labs
|
||||
© 2025 Memento Labs
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -12,8 +12,8 @@ export function AISettingsHeader() {
|
||||
steps={[
|
||||
{ text: 'Choisissez un fournisseur IA parmi 8 disponibles : OpenAI, Anthropic, Google, Mistral, Groq, DeepSeek, Perplexity, ou Ollama (local).' },
|
||||
{ text: 'Sélectionnez le modèle — chaque fournisseur propose plusieurs modèles avec des capacités différentes (rapidité, coût, contexte).' },
|
||||
{ text: 'Mode BYOK (Bring Your Own Key) : entrez votre propre clé API pour utiliser votre quota directement — sans passer par les quotas Momento.' },
|
||||
{ text: 'Les crédits Momento (quota mensuel) sont utilisés quand vous n\'avez pas de clé personnelle. Le plan Free inclut 10 crédits/mois.' },
|
||||
{ text: 'Mode BYOK (Bring Your Own Key) : entrez votre propre clé API pour utiliser votre quota directement — sans passer par les quotas Memento.' },
|
||||
{ text: 'Les crédits Memento (quota mensuel) sont utilisés quand vous n\'avez pas de clé personnelle. Le plan Free inclut 10 crédits/mois.' },
|
||||
{ icon: '💡', text: 'Conseil : Ollama permet de faire tourner un LLM entièrement en local sur votre machine — 100% privé, zéro coût.' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -231,7 +231,7 @@ export default function DataSettingsPage() {
|
||||
steps={[
|
||||
{ text: 'Export ZIP : télécharge toutes vos notes en fichiers Markdown + images + métadonnées. Format universel, lisible partout.' },
|
||||
{ text: 'Export JSON : version structurée de vos données — utile pour migrer ou sauvegarder programmatiquement.' },
|
||||
{ text: 'Import Markdown : importez un fichier .md existant directement dans Momento en tant que nouvelle note.' },
|
||||
{ text: 'Import Markdown : importez un fichier .md existant directement dans Memento en tant que nouvelle note.' },
|
||||
{ text: 'Ré-indexer : si la recherche sémantique ne trouve pas certaines notes, relancez l\'indexation pour recalculer les embeddings.' },
|
||||
{ icon: '⚠️', text: 'Supprimer le compte efface définitivement toutes vos données. Pensez à exporter d\'abord.' },
|
||||
]}
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function IntegrationsPage() {
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">Intégrations</h2>
|
||||
<p className="text-sm text-concrete mt-1">Connectez des services externes à Momento.</p>
|
||||
<p className="text-sm text-concrete mt-1">Connectez des services externes à Memento.</p>
|
||||
</div>
|
||||
|
||||
{/* ── Google Calendar ────────────────────────────────────────────── */}
|
||||
@@ -166,7 +166,7 @@ export default function IntegrationsPage() {
|
||||
{ text: 'Cliquez "Connecter Google Calendar" — vous serez redirigé vers Google pour autoriser l\'accès.' },
|
||||
{ text: 'Une fois connecté, revenez ici et cliquez "Événements aujourd\'hui" pour voir votre agenda.' },
|
||||
{ text: 'Sur chaque événement, cliquez "+ Note" pour créer automatiquement une note de réunion avec template (Ordre du jour / Notes / Actions).' },
|
||||
{ text: 'La note s\'ouvre directement dans Momento — ajoutez vos notes en temps réel pendant la réunion.' },
|
||||
{ text: 'La note s\'ouvre directement dans Memento — ajoutez vos notes en temps réel pendant la réunion.' },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -19,11 +19,11 @@ export default async function McpSettingsPage() {
|
||||
title="Qu'est-ce que MCP (Model Context Protocol) ?"
|
||||
defaultOpen={true}
|
||||
steps={[
|
||||
{ text: 'MCP est un protocole qui permet aux agents IA de Momento de se connecter à des outils externes (bases de données, APIs, fichiers, etc.).' },
|
||||
{ text: 'Momento expose un serveur MCP avec 22 outils — vos agents peuvent lire/créer des notes, chercher dans votre base, gérer les carnets, etc.' },
|
||||
{ text: 'MCP est un protocole qui permet aux agents IA de Memento de se connecter à des outils externes (bases de données, APIs, fichiers, etc.).' },
|
||||
{ text: 'Memento expose un serveur MCP avec 22 outils — vos agents peuvent lire/créer des notes, chercher dans votre base, gérer les carnets, etc.' },
|
||||
{ text: 'Créez une clé API ici, puis configurez-la dans votre client MCP (Claude Desktop, Cursor, Continue.dev…) avec l\'URL du serveur.' },
|
||||
{ text: 'Format de configuration : URL du serveur MCP + votre clé dans le header Authorization.', link: { label: 'Documentation MCP', href: 'https://modelcontextprotocol.io/docs' } },
|
||||
{ icon: '⚡', text: 'Cas d\'usage : demandez à Claude Desktop d\'écrire une note dans Momento, de chercher dans vos carnets, ou de créer un agent.' },
|
||||
{ icon: '⚡', text: 'Cas d\'usage : demandez à Claude Desktop d\'écrire une note dans Memento, de chercher dans vos carnets, ou de créer un agent.' },
|
||||
]}
|
||||
/>
|
||||
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
|
||||
|
||||
403
memento-note/app/(public)/c/[slug]/page.tsx
Normal file
403
memento-note/app/(public)/c/[slug]/page.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { Flag, Sparkles, BookOpen, Clock } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { processNoteHtmlForPublish } from '@/lib/publish/process-note-html'
|
||||
import { REWRITE_SHARED_CSS, KATEX_PUBLISH_CSS } from '@/lib/publish/shared-css'
|
||||
|
||||
export const revalidate = 60
|
||||
|
||||
async function getNotebookSite(slug: string) {
|
||||
const site = await prisma.notebookSite.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
notebook: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
icon: true,
|
||||
user: { select: { name: true, image: true } },
|
||||
notes: {
|
||||
select: { id: true, title: true, content: true, order: true, updatedAt: true },
|
||||
where: { trashedAt: null, isArchived: false },
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!site || !site.isPublic) return null
|
||||
return site
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const site = await getNotebookSite(slug)
|
||||
if (!site) return { title: 'Site introuvable — Memento' }
|
||||
return {
|
||||
title: `${site.notebook.name} — Memento`,
|
||||
description: site.description || `Carnet publié sur Memento`,
|
||||
openGraph: { title: site.notebook.name, description: site.description || '' },
|
||||
}
|
||||
}
|
||||
|
||||
function wordCount(html: string) {
|
||||
return (html || '').replace(/<[^>]+>/g, ' ').trim().split(/\s+/).filter(Boolean).length
|
||||
}
|
||||
function readingTime(html: string) {
|
||||
return Math.max(1, Math.ceil(wordCount(html) / 200))
|
||||
}
|
||||
function preview(html: string, len = 140) {
|
||||
return (html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, len)
|
||||
}
|
||||
|
||||
export default async function NotebookSitePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const site = await getNotebookSite(slug)
|
||||
if (!site) notFound()
|
||||
|
||||
let selectedIds: string[] = []
|
||||
try { selectedIds = JSON.parse(site.selectedNoteIds) } catch {}
|
||||
|
||||
const allNotes = site.notebook.notes
|
||||
const orderedNotes = selectedIds.length > 0
|
||||
? selectedIds.map(id => allNotes.find(n => n.id === id)).filter(Boolean) as typeof allNotes
|
||||
: allNotes
|
||||
|
||||
const notebook = site.notebook
|
||||
const totalWc = orderedNotes.reduce((s, n) => s + wordCount(n.content || ''), 0)
|
||||
const totalRt = Math.max(1, Math.ceil(totalWc / 200))
|
||||
|
||||
return (
|
||||
<>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;0,900;1,700&family=Source+Serif+4:opsz,wght@8..60,300..600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
|
||||
{/* ── NAV ── */}
|
||||
<nav className="cs-nav">
|
||||
<a href="/" className="cs-nav-brand">Memento</a>
|
||||
<div className="cs-nav-right">
|
||||
<span className="cs-nav-badge"><Sparkles size={10} /> Site publié</span>
|
||||
<a href={`/c/${slug}/report`} className="cs-nav-report"><Flag size={11} /> Signaler</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── HERO ── */}
|
||||
<header className="cs-hero">
|
||||
<div className="cs-hero-inner">
|
||||
{notebook.icon && <div className="cs-hero-icon">{notebook.icon}</div>}
|
||||
<div className="cs-hero-label">CARNET · {orderedNotes.length} NOTES · {totalRt} MIN</div>
|
||||
<h1 className="cs-hero-title">{notebook.name}</h1>
|
||||
{site.description && <p className="cs-hero-desc">{site.description}</p>}
|
||||
{notebook.user?.name && (
|
||||
<div className="cs-hero-author">
|
||||
{notebook.user.image && <img src={notebook.user.image} alt="" className="cs-hero-avatar" />}
|
||||
<span>par <strong>{notebook.user.name}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── TABLE DES MATIÈRES ── */}
|
||||
<section className="cs-toc-section">
|
||||
<div className="cs-toc-inner">
|
||||
<p className="cs-toc-label">TABLE DES MATIÈRES</p>
|
||||
<div className="cs-toc-grid">
|
||||
{orderedNotes.map((note, i) => (
|
||||
<a key={note.id} href={`#note-${note.id}`} className="cs-toc-card">
|
||||
<span className="cs-toc-num">{String(i + 1).padStart(2, '0')}</span>
|
||||
<div>
|
||||
<div className="cs-toc-card-title">{note.title || 'Sans titre'}</div>
|
||||
<div className="cs-toc-card-preview">{preview(note.content || '')}</div>
|
||||
<div className="cs-toc-card-rt"><Clock size={10} /> {readingTime(note.content || '')} min</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── NOTES ── */}
|
||||
<main className="cs-main">
|
||||
<aside className="cs-sidebar">
|
||||
<p className="cs-sidebar-label">NAVIGATION</p>
|
||||
{orderedNotes.map((note, i) => (
|
||||
<a key={note.id} href={`#note-${note.id}`} className="cs-sidebar-link">
|
||||
<span className="cs-sidebar-num">{i + 1}</span>
|
||||
<span className="cs-sidebar-title">{note.title || 'Sans titre'}</span>
|
||||
</a>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<div className="cs-articles">
|
||||
{orderedNotes.map((note, i) => {
|
||||
const bodyHtml = processNoteHtmlForPublish(note.content || '')
|
||||
const rt = readingTime(note.content || '')
|
||||
return (
|
||||
<article key={note.id} id={`note-${note.id}`} className="cs-article">
|
||||
<div className="cs-article-meta">
|
||||
<span className="cs-article-num">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span className="cs-article-rt"><Clock size={10} /> {rt} min de lecture</span>
|
||||
</div>
|
||||
<h2 className="cs-article-title">{note.title || 'Sans titre'}</h2>
|
||||
<div
|
||||
className="cs-article-body pub-article pub-rewrite-body"
|
||||
dir="auto"
|
||||
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
||||
/>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* ── FOOTER ── */}
|
||||
<footer className="cs-footer">
|
||||
<div className="cs-footer-inner">
|
||||
<span>Publié avec <a href="/" className="cs-footer-brand">Memento</a></span>
|
||||
<a href={`/c/${slug}/report`} className="cs-footer-report"><Flag size={11} /> Signaler ce contenu</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>{`
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { background: #fff; color: #111; font-family: 'Inter', system-ui, sans-serif; }
|
||||
|
||||
/* ── NAV ── */
|
||||
.cs-nav {
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
background: #111; color: #fff;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 32px; height: 52px;
|
||||
}
|
||||
.cs-nav-brand {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 17px; font-weight: 900; letter-spacing: .04em;
|
||||
color: #fff; text-decoration: none;
|
||||
}
|
||||
.cs-nav-right { display: flex; align-items: center; gap: 16px; }
|
||||
.cs-nav-badge {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: #f5c96a;
|
||||
}
|
||||
.cs-nav-report {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 11px; color: rgba(255,255,255,.4); text-decoration: none;
|
||||
}
|
||||
.cs-nav-report:hover { color: rgba(255,255,255,.7); }
|
||||
|
||||
/* ── HERO ── */
|
||||
.cs-hero {
|
||||
background: #111; color: #fff;
|
||||
padding: 72px 32px 80px; text-align: center;
|
||||
}
|
||||
.cs-hero-inner { max-width: 780px; margin: 0 auto; }
|
||||
.cs-hero-icon { font-size: 48px; margin-bottom: 20px; line-height: 1; }
|
||||
.cs-hero-label {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .22em;
|
||||
text-transform: uppercase; color: #f5c96a; margin-bottom: 24px;
|
||||
}
|
||||
.cs-hero-title {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: clamp(36px, 6vw, 66px);
|
||||
font-weight: 900; line-height: 1.08; letter-spacing: -.02em;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.cs-hero-desc {
|
||||
font-size: 18px; line-height: 1.7; color: rgba(255,255,255,.65);
|
||||
max-width: 600px; margin: 0 auto 28px;
|
||||
}
|
||||
.cs-hero-author {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
font-size: 13px; color: rgba(255,255,255,.5);
|
||||
}
|
||||
.cs-hero-avatar {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,.2); object-fit: cover;
|
||||
}
|
||||
|
||||
/* ── TABLE DES MATIÈRES ── */
|
||||
.cs-toc-section { background: #f8f8f6; border-bottom: 1px solid #e8e8e4; padding: 56px 32px; }
|
||||
.cs-toc-inner { max-width: 1040px; margin: 0 auto; }
|
||||
.cs-toc-label {
|
||||
font-size: 10px; font-weight: 700; letter-spacing: .2em;
|
||||
color: #aaa; text-transform: uppercase; margin-bottom: 24px;
|
||||
}
|
||||
.cs-toc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.cs-toc-card {
|
||||
display: flex; gap: 16px; align-items: flex-start;
|
||||
background: #fff; border: 1px solid #e8e8e4;
|
||||
border-radius: 12px; padding: 20px 22px;
|
||||
text-decoration: none; color: inherit;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.cs-toc-card:hover {
|
||||
border-color: #111;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.07);
|
||||
}
|
||||
.cs-toc-num {
|
||||
font-size: 11px; font-weight: 700; color: #f5c96a;
|
||||
background: #111; border-radius: 6px;
|
||||
padding: 4px 7px; line-height: 1; flex-shrink: 0; margin-top: 2px;
|
||||
}
|
||||
.cs-toc-card-title { font-size: 14px; font-weight: 700; color: #111; line-height: 1.35; margin-bottom: 6px; }
|
||||
.cs-toc-card-preview { font-size: 12px; color: #777; line-height: 1.5; margin-bottom: 8px; }
|
||||
.cs-toc-card-rt {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 10px; color: #bbb; font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── LAYOUT PRINCIPAL ── */
|
||||
.cs-main {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
display: grid; grid-template-columns: 260px 1fr;
|
||||
gap: 0; min-height: 60vh;
|
||||
}
|
||||
@media (max-width: 820px) { .cs-main { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── SIDEBAR ── */
|
||||
.cs-sidebar {
|
||||
padding: 48px 24px 48px 32px;
|
||||
border-right: 1px solid #f0f0ee;
|
||||
position: sticky; top: 52px;
|
||||
height: calc(100vh - 52px); overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 820px) { .cs-sidebar { display: none; } }
|
||||
.cs-sidebar-label {
|
||||
font-size: 9px; font-weight: 700; letter-spacing: .2em;
|
||||
color: #bbb; text-transform: uppercase; margin-bottom: 16px;
|
||||
padding-bottom: 12px; border-bottom: 1px solid #f0f0ee;
|
||||
}
|
||||
.cs-sidebar-link {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 8px; margin-bottom: 2px;
|
||||
text-decoration: none; color: #555; font-size: 12.5px; line-height: 1.45;
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.cs-sidebar-link:hover { background: #f5f5f3; color: #111; }
|
||||
.cs-sidebar-num {
|
||||
flex-shrink: 0; font-size: 10px; font-weight: 700;
|
||||
color: #ccc; width: 18px; text-align: right; padding-top: 1px;
|
||||
}
|
||||
.cs-sidebar-title { font-weight: 500; }
|
||||
|
||||
/* ── ARTICLES ── */
|
||||
.cs-articles { padding: 64px 60px; }
|
||||
@media (max-width: 1024px) { .cs-articles { padding: 48px 32px; } }
|
||||
@media (max-width: 640px) { .cs-articles { padding: 32px 20px; } }
|
||||
|
||||
.cs-article { padding-top: 56px; margin-top: 56px; border-top: 1px solid #eeecea; }
|
||||
.cs-article:first-child { margin-top: 0; padding-top: 0; border-top: none; }
|
||||
|
||||
.cs-article-meta {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cs-article-num {
|
||||
font-size: 11px; font-weight: 700; color: #f5c96a;
|
||||
background: #111; border-radius: 6px; padding: 3px 8px; line-height: 1;
|
||||
}
|
||||
.cs-article-rt {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 11px; color: #bbb; font-weight: 500;
|
||||
}
|
||||
|
||||
.cs-article-title {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: clamp(24px, 3.5vw, 38px);
|
||||
font-weight: 900; line-height: 1.15; letter-spacing: -.01em;
|
||||
color: #111; margin-bottom: 32px;
|
||||
}
|
||||
|
||||
/* ── ARTICLE BODY ── */
|
||||
.cs-article-body {
|
||||
font-family: 'Source Serif 4', Georgia, serif;
|
||||
font-size: 18px; line-height: 1.85; color: #1a1a1a;
|
||||
max-width: 680px;
|
||||
}
|
||||
.cs-article-body h2 {
|
||||
font-family: 'Playfair Display', serif; font-size: 1.5em;
|
||||
font-weight: 700; margin-top: 2.5em; margin-bottom: .6em; color: #111;
|
||||
}
|
||||
.cs-article-body h3 {
|
||||
font-size: 1.15em; font-weight: 700;
|
||||
margin-top: 2em; margin-bottom: .5em; color: #111;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.cs-article-body p { margin: 1em 0; }
|
||||
.cs-article-body blockquote {
|
||||
border-left: 4px solid #f5c96a; padding-left: 1.2em;
|
||||
margin: 1.5em 0; color: #444; font-style: italic;
|
||||
}
|
||||
.cs-article-body ul, .cs-article-body ol { padding-left: 1.5em; margin: 1em 0; }
|
||||
.cs-article-body li { margin: .4em 0; }
|
||||
.cs-article-body pre {
|
||||
background: #111; color: #e8e3d5; padding: 22px 24px;
|
||||
border-radius: 10px; overflow-x: auto; font-size: 13px; margin: 1.5em 0;
|
||||
font-family: 'SF Mono', Menlo, monospace;
|
||||
}
|
||||
.cs-article-body code {
|
||||
font-family: 'SF Mono', Menlo, monospace; font-size: .87em;
|
||||
background: #f3f2ef; padding: 1px 5px; border-radius: 3px; color: #1a1a1a;
|
||||
}
|
||||
.cs-article-body pre code { background: none; color: inherit; padding: 0; }
|
||||
.cs-article-body table { border-collapse: collapse; width: 100%; margin: 1.5em 0; }
|
||||
.cs-article-body th {
|
||||
background: #111; color: #fff; padding: 10px 14px;
|
||||
text-align: left; font-size: 13px; letter-spacing: .05em; font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.cs-article-body td { border-bottom: 1px solid #e8e8e4; padding: 10px 14px; }
|
||||
.cs-article-body a { color: #b8832a; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.cs-article-body .pub-figure { margin: 2.5em 0; text-align: center; }
|
||||
.cs-article-body .pub-figure-img { max-width: 100%; border-radius: 8px; box-shadow: 0 12px 40px rgba(0,0,0,.1); }
|
||||
.cs-article-body .pub-figure-caption { margin-top: .6em; font-size: .8em; color: #888; font-style: italic; }
|
||||
.cs-article-body .pub-gallery {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px; margin: 2em 0;
|
||||
}
|
||||
.cs-article-body .pub-gallery-img { width: 100%; height: 180px; object-fit: cover; border-radius: 8px; display: block; }
|
||||
|
||||
/* Variables CSS pour blocs structurés */
|
||||
.cs-article-body {
|
||||
--pub-accent: #9a7212;
|
||||
--pub-summary-color: #333;
|
||||
--pub-exercise-border: #e0d4c3;
|
||||
--pub-exercise-header-bg: #faf6ee;
|
||||
--pub-solution-bg: #faf6ee;
|
||||
--pub-definition-border: #e0d4c3;
|
||||
--pub-definition-bg: #fafaf8;
|
||||
--pub-toggle-border: #e5e5e5;
|
||||
--pub-toggle-header-bg: #f7f7f5;
|
||||
--pub-highlight-bg: #faf6ee;
|
||||
--pub-checklist-bg: rgba(0,0,0,.03);
|
||||
}
|
||||
.cs-article-body .pub-code { background: #0d0d0d; }
|
||||
.cs-article-body .pub-code code { background: #0d0d0d; color: #e8e3d5; }
|
||||
|
||||
/* ── FOOTER ── */
|
||||
.cs-footer { background: #111; color: rgba(255,255,255,.4); padding: 32px; }
|
||||
.cs-footer-inner {
|
||||
max-width: 1040px; margin: 0 auto;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 13px; flex-wrap: wrap; gap: 12px;
|
||||
}
|
||||
.cs-footer-brand { color: #f5c96a; font-weight: 700; text-decoration: none; }
|
||||
.cs-footer-report {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-size: 11px; color: rgba(255,255,255,.3); text-decoration: none;
|
||||
}
|
||||
.cs-footer-report:hover { color: rgba(255,255,255,.6); }
|
||||
|
||||
${KATEX_PUBLISH_CSS}
|
||||
${REWRITE_SHARED_CSS}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,146 +1,589 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getPublishedNote } from '@/app/actions/notes-publishing'
|
||||
import { Calendar, Clock, Flag } from 'lucide-react'
|
||||
import { Flag, Sparkles } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import katex from 'katex'
|
||||
import { sanitizeRichHtml } from '@/lib/sanitize-content'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { computePublishedSourceHash } from '@/lib/publish/template-render'
|
||||
import { processNoteHtmlForPublish } from '@/lib/publish/process-note-html'
|
||||
import { REWRITE_SHARED_CSS, KATEX_PUBLISH_CSS } from '@/lib/publish/shared-css'
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const note = await getPublishedNote(slug)
|
||||
if (!note) return { title: 'Note not found — Momento' }
|
||||
if (!note) return { title: 'Note introuvable — Memento' }
|
||||
return {
|
||||
title: `${note.title || 'Published note'} — Momento`,
|
||||
title: `${note.title || 'Note publiée'} — Memento`,
|
||||
description: note.content?.replace(/<[^>]+>/g, '').slice(0, 160),
|
||||
}
|
||||
}
|
||||
|
||||
export const revalidate = 60
|
||||
|
||||
function decodeHtml(text: string): string {
|
||||
const map: Record<string, string> = { '"': '"', '&': '&', '<': '<', '>': '>', ''': "'" }
|
||||
return text.replace(/&[a-z#0-9]+;/gi, m => map[m] || m)
|
||||
}
|
||||
|
||||
function processContent(html: string): string {
|
||||
let result = html
|
||||
|
||||
// KaTeX block math
|
||||
result = result.replace(/<div[^>]*data-type="math-equation"[^>]*data-latex="([^"]*)"[^>]*><\/div>/g, (_, latex) => {
|
||||
try { return `<div class="r-math-display">${katex.renderToString(decodeHtml(latex), { displayMode: true, throwOnError: false })}</div>` }
|
||||
catch { return `<div class="r-math-display">${latex}</div>` }
|
||||
})
|
||||
// KaTeX inline math
|
||||
result = result.replace(/<span[^>]*data-type="inline-math"[^>]*data-latex="([^"]*)"[^>]*>.*?<\/span>/g, (_, latex) => {
|
||||
try { return katex.renderToString(decodeHtml(latex), { displayMode: false, throwOnError: false }) }
|
||||
catch { return latex }
|
||||
})
|
||||
// Callouts
|
||||
result = result.replace(/<div[^>]*data-type="callout-block"[^>]*data-callout-type="([^"]*)"[^>]*>/g, (_, type) => {
|
||||
const c: Record<string, string> = {
|
||||
info: '#eff6ff|#3b82f6', warning: '#fffbeb|#f59e0b', tip: '#faf5ff|#8b5cf6',
|
||||
success: '#f0fdf4|#22c55e', danger: '#fef2f2|#ef4444',
|
||||
}
|
||||
const [bg, border] = (c[type] || c.info).split('|')
|
||||
return `<div style="background:${bg};border-left:4px solid ${border};border-radius:8px;padding:12px 16px;margin:16px 0">`
|
||||
})
|
||||
// Remove outline blocks (need editor JS)
|
||||
result = result.replace(/<div[^>]*data-type="outline-block"[^>]*><\/div>/g, '')
|
||||
// Remove link preview searchable text
|
||||
result = result.replace(/<div[^>]*class="link-preview-searchable"[^>]*>[\s\S]*?<\/div>/g, '')
|
||||
// Remove editor buttons
|
||||
result = result.replace(/<button[^>]*>[\s\S]*?<\/button>/g, '')
|
||||
// Remove contentEditable attributes
|
||||
result = result.replace(/contenteditable="[^"]*"/gi, '')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function estimateReadingTime(html: string): number {
|
||||
const words = html.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).length
|
||||
return Math.max(1, Math.ceil(words / 200))
|
||||
}
|
||||
|
||||
export default async function PublishedNotePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const note = await getPublishedNote(slug)
|
||||
if (!note) notFound()
|
||||
|
||||
const processedContent = processContent(note.content || '')
|
||||
const readingTime = estimateReadingTime(note.content || '')
|
||||
|
||||
/* ─── MAGAZINE ──────────────────────────────────────────────────────────── */
|
||||
function MagazinePage({ note, bodyHtml, readingTime, slug, isStale }: PageProps) {
|
||||
return (
|
||||
<>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;0,900;1,700&family=Source+Serif+4:opsz,wght@8..60,300..600&family=Inter:wght@400;500&display=swap" rel="stylesheet" />
|
||||
|
||||
<div className="mag-root">
|
||||
<nav className="mag-nav">
|
||||
<span className="mag-nav-brand">Memento</span>
|
||||
<div className="mag-nav-right">
|
||||
<span className="mag-badge"><Sparkles size={10} /> Article</span>
|
||||
<a href={`/p/${slug}/report`} className="mag-report"><Flag size={11} /> Signaler</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Header pleine largeur */}
|
||||
<header className="mag-header">
|
||||
<div className="mag-header-inner">
|
||||
<div className="mag-category">ARTICLE · {note.publishedAt ? format(new Date(note.publishedAt), 'd MMMM yyyy', { locale: fr }) : ''} · {readingTime} min</div>
|
||||
<h1 className="mag-title">{note.title || 'Sans titre'}</h1>
|
||||
{note.user?.name && (
|
||||
<div className="mag-author">
|
||||
{note.user.image && <img src={note.user.image} alt="" className="mag-avatar" />}
|
||||
<span>par <strong>{note.user.name}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mag-content">
|
||||
<div dir="auto" className="mag-body pub-article" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||
</div>
|
||||
|
||||
<footer className="mag-footer">
|
||||
<span>Memento</span> — Votre mémoire augmentée par l’IA
|
||||
{isStale && <p className="mag-stale">Le contenu source a évolué depuis la dernière publication.</p>}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
.mag-root { min-height: 100vh; background: #fff; color: #111; font-family: 'Inter', sans-serif; }
|
||||
|
||||
.mag-nav {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
background: #111; color: #fff;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 28px;
|
||||
}
|
||||
.mag-nav-brand { font-family: 'Playfair Display', serif; font-size: 17px; font-weight: 900; letter-spacing: 0.04em; }
|
||||
.mag-nav-right { display: flex; align-items: center; gap: 12px; }
|
||||
.mag-badge { display: flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 600; letter-spacing: 0.12em; color: #f5c96a; text-transform: uppercase; }
|
||||
.mag-report { display: flex; align-items: center; gap: 4px; font-size: 11px; color: rgba(255,255,255,0.45); text-decoration: none; }
|
||||
.mag-report:hover { color: #fff; }
|
||||
|
||||
.mag-header { background: #111; color: #fff; padding: 64px 28px 72px; text-align: center; }
|
||||
.mag-header-inner { max-width: 780px; margin: 0 auto; }
|
||||
.mag-category { font-size: 11px; font-weight: 600; letter-spacing: 0.2em; color: #f5c96a; text-transform: uppercase; margin-bottom: 28px; }
|
||||
.mag-title {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: clamp(36px, 7vw, 72px);
|
||||
font-weight: 900; line-height: 1.05; letter-spacing: -0.02em;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.mag-author { display: inline-flex; align-items: center; gap: 10px; font-size: 14px; color: rgba(255,255,255,0.65); }
|
||||
.mag-avatar { width: 30px; height: 30px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.2); }
|
||||
|
||||
.mag-content { max-width: 760px; margin: 0 auto; padding: 64px 28px 80px; }
|
||||
|
||||
/* Body article magazine */
|
||||
.mag-body { font-family: 'Source Serif 4', Georgia, serif; font-size: 19px; line-height: 1.85; color: #1a1a1a; }
|
||||
.mag-body .pub-tpl { all: unset; display: block; }
|
||||
|
||||
.mag-body .pub-magazine-dek {
|
||||
font-size: 1.3em; line-height: 1.6; color: #333; margin-bottom: 2.5em;
|
||||
padding-bottom: 2em; border-bottom: 2px solid #111;
|
||||
font-style: italic;
|
||||
}
|
||||
.mag-body .pub-pull-quote {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: clamp(1.5em, 4vw, 2em); font-style: italic; font-weight: 700;
|
||||
color: #111; line-height: 1.35;
|
||||
margin: 2.5em 0; padding: 0;
|
||||
border: none; border-top: 4px solid #f5c96a; border-bottom: 4px solid #f5c96a;
|
||||
padding: 1.5em 0; text-align: center;
|
||||
}
|
||||
.mag-body .pub-pull-quote p { margin: 0; }
|
||||
|
||||
.mag-body .pub-hero { margin: 0 -28px 3rem; border-radius: 0; max-height: 560px; aspect-ratio: 16/9; background: #222; overflow: hidden; }
|
||||
.mag-body .pub-hero-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.mag-body .pub-body-source { }
|
||||
.mag-body h2 { font-family: 'Playfair Display', serif; font-size: 1.6em; font-weight: 700; margin-top: 2.5em; margin-bottom: 0.6em; }
|
||||
.mag-body h3 { font-size: 1.2em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em; }
|
||||
.mag-body p { margin: 1.1em 0; }
|
||||
.mag-body blockquote { border-left: 4px solid #f5c96a; padding-left: 1.2em; margin: 1.5em 0; color: #444; font-style: italic; }
|
||||
.mag-body ul, .mag-body ol { padding-left: 1.5em; margin: 1em 0; }
|
||||
.mag-body li { margin: 0.4em 0; }
|
||||
.mag-body pre { background: #f6f6f4; padding: 20px; border-radius: 8px; overflow-x: auto; font-size: 14px; margin: 1.5em 0; }
|
||||
.mag-body code { font-family: 'SF Mono', Menlo, monospace; font-size: 0.88em; background: #f3f2ef; padding: 1px 5px; border-radius: 3px; }
|
||||
.mag-body pre code { background: none; padding: 0; }
|
||||
.mag-body table { border-collapse: collapse; width: 100%; margin: 1.5em 0; }
|
||||
.mag-body th { background: #111; color: #fff; padding: 10px 14px; text-align: left; font-size: 13px; letter-spacing: 0.05em; }
|
||||
.mag-body td { border-bottom: 1px solid #e5e5e5; padding: 10px 14px; }
|
||||
.mag-body a { color: #b8832a; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.mag-body .pub-figure { margin: 2.5em 0; text-align: center; }
|
||||
.mag-body .pub-figure-img { max-width: 100%; border-radius: 4px; box-shadow: 0 12px 40px rgba(0,0,0,0.12); }
|
||||
.mag-body .pub-figure-caption { margin-top: 0.6em; font-size: 0.82em; color: #888; font-style: italic; }
|
||||
.mag-body .pub-gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-top: 3em; }
|
||||
.mag-body .pub-gallery-img { width: 100%; height: 200px; object-fit: cover; border-radius: 4px; display: block; }
|
||||
.mag-body .pub-callout { border-radius: 6px; padding: 14px 18px; margin: 1.5em 0; }
|
||||
${KATEX_PUBLISH_CSS}
|
||||
|
||||
.mag-footer { background: #111; color: rgba(255,255,255,0.4); padding: 36px 28px; text-align: center; font-size: 13px; font-family: 'Playfair Display', serif; }
|
||||
.mag-footer span { color: #f5c96a; font-weight: 700; }
|
||||
.mag-stale { margin-top: 8px; font-size: 11px; color: rgba(255,255,255,0.25); font-family: 'Inter', sans-serif; }
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.mag-body .pub-hero { margin-left: -60px; margin-right: -60px; }
|
||||
.mag-content { padding-left: 60px; padding-right: 60px; }
|
||||
}
|
||||
|
||||
/* Variables magazine pour blocs réécriture — accents dorés, texte toujours lisible */
|
||||
.mag-body {
|
||||
--pub-accent: #9a7212;
|
||||
--pub-summary-color: #333;
|
||||
--pub-exercise-border: #e0d4c3;
|
||||
--pub-exercise-header-bg: #faf6ee;
|
||||
--pub-solution-bg: #faf6ee;
|
||||
--pub-definition-border: #e0d4c3;
|
||||
--pub-definition-bg: #fafaf8;
|
||||
--pub-toggle-border: #e5e5e5;
|
||||
--pub-toggle-header-bg: #f7f7f5;
|
||||
--pub-highlight-bg: #faf6ee;
|
||||
--pub-checklist-bg: rgba(0,0,0,0.03);
|
||||
}
|
||||
.mag-body .pub-rewrite-body .pub-code { background: #0d0d0d; }
|
||||
.mag-body .pub-rewrite-body .pub-code code { background: #0d0d0d; color: #e8e3d5; }
|
||||
.mag-body .pub-rewrite-body .pub-definition-term { background: #e8c96a; color: #1a1a1a; }
|
||||
.mag-body .pub-rewrite-body .pub-step-num { background: #9a7212; color: #fff; }
|
||||
.mag-body .pub-rewrite-body .pub-exercise-num { color: #9a7212; }
|
||||
.mag-body .pub-rewrite-body .pub-solution > summary { color: #9a7212; }
|
||||
.mag-body .pub-rewrite-body .pub-checklist li::before { color: #9a7212; }
|
||||
.mag-body .pub-magazine-dek { color: #333; }
|
||||
${REWRITE_SHARED_CSS}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── BRIEF ─────────────────────────────────────────────────────────────── */
|
||||
function BriefPage({ note, bodyHtml, readingTime, slug, isStale }: PageProps) {
|
||||
return (
|
||||
<>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
|
||||
<div className="br-root">
|
||||
<nav className="br-nav">
|
||||
<span className="br-brand">Memento</span>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<span className="br-badge"><Sparkles size={9} /> Fiche expert</span>
|
||||
<a href={`/p/${slug}/report`} className="br-report"><Flag size={11} /></a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header className="br-header">
|
||||
<div className="br-header-inner">
|
||||
<div className="br-meta">
|
||||
<span className="br-tag">Fiche expert</span>
|
||||
<span className="br-dot">·</span>
|
||||
<span>{readingTime} min de lecture</span>
|
||||
{note.publishedAt && <><span className="br-dot">·</span><span>{format(new Date(note.publishedAt), 'd MMM yyyy', { locale: fr })}</span></>}
|
||||
</div>
|
||||
<h1 className="br-title">{note.title || 'Sans titre'}</h1>
|
||||
{note.user?.name && (
|
||||
<div className="br-author">
|
||||
{note.user.image && <img src={note.user.image} alt="" className="br-avatar" />}
|
||||
<span>{note.user.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="br-layout">
|
||||
<div className="br-content">
|
||||
<div dir="auto" className="br-body pub-article" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="br-footer">
|
||||
<strong>Memento</strong> — Fiche générée par IA
|
||||
{isStale && <p style={{ fontSize: 11, color: '#aaa', marginTop: 6 }}>Le contenu source a évolué depuis la publication.</p>}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
.br-root { min-height: 100vh; background: #F4F6F9; color: #1a2433; font-family: 'DM Sans', system-ui, sans-serif; }
|
||||
|
||||
.br-nav {
|
||||
position: sticky; top: 0; z-index: 20; background: #fff;
|
||||
border-bottom: 1px solid #e8ecf2;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 28px;
|
||||
}
|
||||
.br-brand { font-weight: 700; font-size: 15px; color: #1a2433; }
|
||||
.br-badge { display: flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 600; color: #2563eb; background: #eff6ff; padding: 3px 8px; border-radius: 20px; letter-spacing: 0.04em; }
|
||||
.br-report { color: #aaa; text-decoration: none; display: flex; }
|
||||
.br-report:hover { color: #666; }
|
||||
|
||||
.br-header { background: linear-gradient(135deg, #1e3a5f 0%, #0f1f35 100%); color: #fff; padding: 56px 28px 60px; }
|
||||
.br-header-inner { max-width: 820px; margin: 0 auto; }
|
||||
.br-meta { display: flex; align-items: center; gap: 8px; font-size: 12px; color: rgba(255,255,255,0.55); font-weight: 500; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 20px; }
|
||||
.br-tag { background: #2563eb; color: #fff; font-size: 10px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; padding: 3px 8px; border-radius: 4px; }
|
||||
.br-dot { color: rgba(255,255,255,0.3); }
|
||||
.br-title { font-size: clamp(24px, 4.5vw, 44px); font-weight: 700; line-height: 1.2; letter-spacing: -0.02em; margin-bottom: 24px; }
|
||||
.br-author { display: flex; align-items: center; gap: 8px; font-size: 13px; color: rgba(255,255,255,0.5); }
|
||||
.br-avatar { width: 26px; height: 26px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.2); }
|
||||
|
||||
.br-layout { max-width: 820px; margin: 0 auto; padding: 48px 28px 80px; }
|
||||
|
||||
.br-body { font-size: 16px; line-height: 1.75; color: #1a2433; }
|
||||
.br-body .pub-tpl { all: unset; display: block; }
|
||||
|
||||
/* Brief lead summary */
|
||||
.br-body .pub-brief-lead {
|
||||
background: #fff; border-radius: 12px;
|
||||
border-left: 5px solid #2563eb;
|
||||
padding: 24px 28px; margin-bottom: 2em;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
.br-body .pub-brief-lead p { margin: 0; font-size: 1.08em; line-height: 1.75; color: #1a2433; font-weight: 500; }
|
||||
|
||||
/* Key points */
|
||||
.br-body .pub-key-points {
|
||||
background: #fff; border-radius: 12px; padding: 24px 28px;
|
||||
margin-bottom: 2em; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
.br-body .pub-key-points-label {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.2em;
|
||||
font-weight: 700; color: #2563eb; margin-bottom: 14px; display: block;
|
||||
}
|
||||
.br-body .pub-key-points ul { list-style: none; padding: 0; margin: 0; }
|
||||
.br-body .pub-key-points li {
|
||||
padding: 10px 12px 10px 40px; border-radius: 8px;
|
||||
background: #f4f6f9; margin-bottom: 8px; position: relative;
|
||||
font-size: 0.95em; font-weight: 500;
|
||||
}
|
||||
.br-body .pub-key-points li::before {
|
||||
content: '✓'; position: absolute; left: 12px; top: 10px;
|
||||
color: #2563eb; font-weight: 700; font-size: 14px;
|
||||
}
|
||||
|
||||
/* Source body */
|
||||
.br-body .pub-body-source {
|
||||
background: #fff; border-radius: 12px; padding: 32px 36px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.br-body .pub-hero { margin: 0 0 2rem; border-radius: 12px; overflow: hidden; max-height: 400px; aspect-ratio: 16/9; background: #dce3ed; }
|
||||
.br-body .pub-hero-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.br-body h2 { font-size: 1.25em; font-weight: 700; margin-top: 2em; margin-bottom: 0.6em; color: #0f1f35; border-bottom: 2px solid #e8ecf2; padding-bottom: 0.4em; }
|
||||
.br-body h3 { font-size: 1.08em; font-weight: 600; margin-top: 1.5em; margin-bottom: 0.4em; color: #1e3a5f; }
|
||||
.br-body p { margin: 0.9em 0; }
|
||||
.br-body blockquote { border-left: 4px solid #2563eb; padding-left: 1em; margin: 1.2em 0; color: #3d5a80; font-style: italic; }
|
||||
.br-body ul, .br-body ol { padding-left: 1.4em; margin: 1em 0; }
|
||||
.br-body li { margin: 0.4em 0; }
|
||||
.br-body pre { background: #1a2433; color: #e2e8f0; padding: 20px; border-radius: 10px; overflow-x: auto; font-size: 13px; margin: 1.5em 0; font-family: 'DM Mono', monospace; }
|
||||
.br-body code { font-family: 'DM Mono', monospace; font-size: 0.87em; background: #e8ecf2; padding: 1px 5px; border-radius: 3px; color: #1e3a5f; }
|
||||
.br-body pre code { background: none; color: inherit; padding: 0; }
|
||||
.br-body table { border-collapse: collapse; width: 100%; margin: 1.5em 0; border-radius: 8px; overflow: hidden; }
|
||||
.br-body th { background: #1e3a5f; color: #fff; padding: 10px 14px; text-align: left; font-size: 13px; }
|
||||
.br-body td { border-bottom: 1px solid #e8ecf2; padding: 10px 14px; }
|
||||
.br-body tr:last-child td { border-bottom: none; }
|
||||
.br-body a { color: #2563eb; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.br-body .pub-figure { margin: 2em 0; text-align: center; }
|
||||
.br-body .pub-figure-img { max-width: 100%; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
|
||||
.br-body .pub-figure-caption { margin-top: 0.5em; font-size: 0.82em; color: #7a8fa6; }
|
||||
.br-body .pub-gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-top: 2em; }
|
||||
.br-body .pub-gallery-img { width: 100%; height: 160px; object-fit: cover; border-radius: 8px; display: block; }
|
||||
.br-body .pub-callout { border-radius: 8px; padding: 12px 16px; margin: 1.2em 0; }
|
||||
${KATEX_PUBLISH_CSS}
|
||||
|
||||
.br-footer { background: #0f1f35; color: rgba(255,255,255,0.35); padding: 32px 28px; text-align: center; font-size: 13px; }
|
||||
.br-footer strong { color: #fff; }
|
||||
|
||||
/* Variables brief pour blocs réécriture */
|
||||
.br-body {
|
||||
--pub-accent: #2563eb;
|
||||
--pub-summary-color: #3d5a80;
|
||||
--pub-exercise-border: #bfdbfe;
|
||||
--pub-exercise-header-bg: #eff6ff;
|
||||
--pub-solution-bg: #f0f7ff;
|
||||
--pub-definition-border: #bfdbfe;
|
||||
--pub-definition-bg: #f8faff;
|
||||
--pub-toggle-border: #e2e8f0;
|
||||
--pub-toggle-header-bg: #f8fafc;
|
||||
--pub-highlight-bg: #eff6ff;
|
||||
--pub-checklist-bg: #f4f6f9;
|
||||
}
|
||||
.br-body .pub-rewrite-body .pub-code { background: #1a2433; }
|
||||
.br-body .pub-rewrite-body .pub-code code { background: #1a2433; color: #e2e8f0; }
|
||||
.br-body .pub-rewrite-body .pub-body-source {
|
||||
background: #fff; border-radius: 12px; padding: 32px 36px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
${REWRITE_SHARED_CSS}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── ESSAY ──────────────────────────────────────────────────────────────── */
|
||||
function EssayPage({ note, bodyHtml, readingTime, slug, isStale }: PageProps) {
|
||||
return (
|
||||
<>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;1,400;1,600&family=Source+Serif+4:opsz,wght@8..60,300..600&family=Inter:wght@400;500&display=swap" rel="stylesheet" />
|
||||
|
||||
<div className="es-root">
|
||||
<nav className="es-nav">
|
||||
<span className="es-brand">Memento</span>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<span className="es-badge"><Sparkles size={10} /> Essai</span>
|
||||
<a href={`/p/${slug}/report`} className="es-report"><Flag size={11} /></a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="es-main">
|
||||
<div className="es-rule-top" />
|
||||
<header className="es-header">
|
||||
{note.publishedAt && (
|
||||
<time className="es-date">{format(new Date(note.publishedAt), 'd MMMM yyyy', { locale: fr })}</time>
|
||||
)}
|
||||
<h1 className="es-title">{note.title || 'Sans titre'}</h1>
|
||||
<div className="es-rule-thin" />
|
||||
{note.user?.name && (
|
||||
<div className="es-author">
|
||||
{note.user.image && <img src={note.user.image} alt="" className="es-avatar" />}
|
||||
<div>
|
||||
<span className="es-author-name">{note.user.name}</span>
|
||||
<span className="es-reading-time">{readingTime} min de lecture</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div dir="auto" className="es-body pub-article" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||
|
||||
<div className="es-rule-thin" style={{ marginTop: '4rem' }} />
|
||||
<footer className="es-footer">
|
||||
Publié sur <strong>Memento</strong>
|
||||
{isStale && <p style={{ fontSize: 11, color: '#bbb', marginTop: 6 }}>Le contenu source a évolué depuis la publication.</p>}
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
.es-root { min-height: 100vh; background: #FDFBF7; color: #2c2416; font-family: 'Inter', sans-serif; }
|
||||
|
||||
.es-nav {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
background: rgba(253,251,247,0.9); backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid #e8e0d0;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 40px;
|
||||
}
|
||||
.es-brand { font-family: 'Lora', Georgia, serif; font-size: 16px; font-style: italic; color: #5c4a2a; }
|
||||
.es-badge { display: flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500; color: #8b6c3a; font-family: 'Inter', sans-serif; }
|
||||
.es-report { color: #bbb; text-decoration: none; display: flex; }
|
||||
.es-report:hover { color: #666; }
|
||||
|
||||
.es-main { max-width: 680px; margin: 0 auto; padding: 60px 36px 100px; }
|
||||
.es-rule-top { width: 60px; height: 3px; background: #5c4a2a; margin: 48px auto 0; }
|
||||
|
||||
.es-header { text-align: center; padding: 40px 0 36px; }
|
||||
.es-date { display: block; font-size: 12px; font-weight: 500; letter-spacing: 0.15em; text-transform: uppercase; color: #a08b5c; margin-bottom: 24px; }
|
||||
.es-title {
|
||||
font-family: 'Lora', Georgia, serif;
|
||||
font-size: clamp(28px, 5vw, 52px);
|
||||
font-weight: 600; font-style: italic; line-height: 1.2;
|
||||
letter-spacing: -0.01em; color: #1e160c;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.es-rule-thin { height: 1px; background: #d8cebc; margin: 0 auto; width: 100%; }
|
||||
.es-author { display: flex; align-items: center; gap: 12px; margin-top: 24px; justify-content: center; }
|
||||
.es-avatar { width: 40px; height: 40px; border-radius: 50%; border: 2px solid #d8cebc; }
|
||||
.es-author-name { display: block; font-size: 14px; font-weight: 600; color: #3d2f14; }
|
||||
.es-reading-time { display: block; font-size: 12px; color: #a08b5c; }
|
||||
|
||||
/* Essay body */
|
||||
.es-body { margin-top: 52px; font-family: 'Lora', Georgia, serif; font-size: 19px; line-height: 2; color: #2c2416; }
|
||||
.es-body .pub-tpl { all: unset; display: block; }
|
||||
|
||||
/* Epigraph avec guillemet décoratif */
|
||||
.es-body .pub-epigraph {
|
||||
position: relative;
|
||||
text-align: center; font-size: 1.15em; font-style: italic; color: #6b5337;
|
||||
margin: 0 0 3em; padding: 1.5em 2.5em 1em;
|
||||
border-top: 1px solid #d8cebc; border-bottom: 1px solid #d8cebc;
|
||||
}
|
||||
.es-body .pub-epigraph::before {
|
||||
content: '"';
|
||||
position: absolute; top: -0.3em; left: 50%; transform: translateX(-50%);
|
||||
font-family: 'Lora', Georgia, serif; font-size: 5em; color: #d8cebc;
|
||||
line-height: 1; pointer-events: none; font-style: normal;
|
||||
}
|
||||
|
||||
.es-body .pub-essay-summary {
|
||||
font-size: 1.15em; line-height: 1.8; color: #4a3726;
|
||||
margin-bottom: 2.5em; font-style: italic;
|
||||
padding-left: 1.5em; border-left: 2px solid #c8b99a;
|
||||
}
|
||||
|
||||
.es-body .pub-body-source p { text-indent: 1.8em; }
|
||||
.es-body .pub-body-source p:first-of-type { text-indent: 0; }
|
||||
.es-body .pub-body-source > p:first-child { text-indent: 0; }
|
||||
|
||||
.es-body .pub-hero { margin: 0 -36px 3em; overflow: hidden; border-radius: 0; max-height: 480px; aspect-ratio: 16/9; background: #e8e0d0; }
|
||||
.es-body .pub-hero-img { width: 100%; height: 100%; object-fit: cover; display: block; filter: sepia(10%); }
|
||||
.es-body h2 { font-family: 'Lora', serif; font-size: 1.45em; font-style: italic; font-weight: 600; margin-top: 2.5em; margin-bottom: 0.7em; color: #1e160c; }
|
||||
.es-body h3 { font-family: 'Lora', serif; font-size: 1.2em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em; }
|
||||
.es-body p { margin: 0 0 0.2em; }
|
||||
.es-body blockquote {
|
||||
border: none; margin: 2em 0; padding: 0 2em;
|
||||
font-size: 1.15em; color: #5c4a2a; font-style: italic;
|
||||
text-align: center; position: relative;
|
||||
}
|
||||
.es-body ul, .es-body ol { padding-left: 1.4em; margin: 1em 0; }
|
||||
.es-body li { margin: 0.5em 0; }
|
||||
.es-body pre { background: #f2ede4; padding: 20px; border-radius: 6px; overflow-x: auto; font-family: 'SF Mono', Menlo, monospace; font-size: 13px; margin: 1.5em 0; }
|
||||
.es-body code { font-family: 'SF Mono', Menlo, monospace; font-size: 0.85em; background: #ede7da; padding: 1px 5px; border-radius: 3px; }
|
||||
.es-body pre code { background: none; padding: 0; }
|
||||
.es-body table { border-collapse: collapse; width: 100%; margin: 1.5em 0; }
|
||||
.es-body th { border-bottom: 2px solid #5c4a2a; padding: 8px 12px; text-align: left; font-size: 14px; color: #5c4a2a; }
|
||||
.es-body td { border-bottom: 1px solid #e8e0d0; padding: 8px 12px; }
|
||||
.es-body a { color: #7a5c2e; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
|
||||
.es-body .pub-figure { margin: 2.5em 0; text-align: center; }
|
||||
.es-body .pub-figure-img { max-width: 100%; border-radius: 2px; box-shadow: 0 6px 24px rgba(0,0,0,0.1); filter: sepia(5%); }
|
||||
.es-body .pub-figure-caption { margin-top: 0.65em; font-size: 0.8em; color: #9a8470; font-style: italic; }
|
||||
.es-body .pub-gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-top: 2.5em; }
|
||||
.es-body .pub-gallery-img { width: 100%; height: 160px; object-fit: cover; border-radius: 2px; display: block; filter: sepia(8%); }
|
||||
.es-body .pub-callout { border-radius: 4px; padding: 12px 16px; margin: 1.5em 0; }
|
||||
${KATEX_PUBLISH_CSS}
|
||||
|
||||
.es-footer { text-align: center; padding: 32px 0 0; font-size: 13px; color: #a08b5c; font-family: 'Inter', sans-serif; }
|
||||
.es-footer strong { color: #5c4a2a; }
|
||||
|
||||
/* Variables essay pour blocs réécriture */
|
||||
.es-body {
|
||||
--pub-accent: #7a5c2e;
|
||||
--pub-summary-color: #5c4a2a;
|
||||
--pub-exercise-border: #d8cebc;
|
||||
--pub-exercise-header-bg: #f5f0e8;
|
||||
--pub-solution-bg: rgba(122,92,46,0.05);
|
||||
--pub-definition-border: #d8cebc;
|
||||
--pub-definition-bg: #fdfbf7;
|
||||
--pub-toggle-border: #e2d9ca;
|
||||
--pub-toggle-header-bg: #f7f3ec;
|
||||
--pub-highlight-bg: #fdf6eb;
|
||||
--pub-checklist-bg: rgba(0,0,0,0.025);
|
||||
}
|
||||
.es-body .pub-rewrite-body .pub-code { background: #2c2416; }
|
||||
.es-body .pub-rewrite-body .pub-code code { background: #2c2416; color: #f0e8da; }
|
||||
${REWRITE_SHARED_CSS}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── SIMPLE (pas d'IA) ─────────────────────────────────────────────────── */
|
||||
function SimplePage({ note, bodyHtml, readingTime, slug }: PageProps) {
|
||||
return (
|
||||
<>
|
||||
{/* KaTeX + fonts CSS */}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,300..600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
|
||||
<div style={{ minHeight: '100vh', background: '#FAF9F5', color: '#1a1a1a', fontFamily: "'Inter', sans-serif" }}>
|
||||
{/* Top bar */}
|
||||
<div style={{
|
||||
position: 'sticky', top: 0, zIndex: 10,
|
||||
background: 'rgba(250,249,245,0.85)', backdropFilter: 'blur(12px)',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)', padding: '10px 24px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<span style={{ fontFamily: "'Source Serif 4', serif", fontWeight: 600, fontSize: '15px' }}>Momento</span>
|
||||
<a href={`/p/${slug}/report`} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#888', textDecoration: 'none', padding: '4px 10px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Flag size={12} /> Signaler
|
||||
</a>
|
||||
<div style={{ position: 'sticky', top: 0, zIndex: 10, background: 'rgba(250,249,245,0.9)', backdropFilter: 'blur(12px)', borderBottom: '1px solid rgba(0,0,0,0.06)', padding: '10px 24px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontFamily: "'Source Serif 4', serif", fontWeight: 600, fontSize: '15px' }}>Memento</span>
|
||||
<a href={`/p/${slug}/report`} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#888', textDecoration: 'none', padding: '4px 10px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.1)' }}><Flag size={12} /> Signaler</a>
|
||||
</div>
|
||||
|
||||
{/* Article */}
|
||||
<article style={{ maxWidth: '720px', margin: '0 auto', padding: '48px 24px 80px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', color: '#999', marginBottom: '16px' }}>
|
||||
<Calendar size={12} />
|
||||
<span>{note.publishedAt ? format(new Date(note.publishedAt), 'd MMM yyyy') : ''}</span>
|
||||
<span>·</span>
|
||||
<Clock size={12} />
|
||||
<span>{readingTime} min de lecture</span>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginBottom: '16px' }}>
|
||||
{note.publishedAt ? format(new Date(note.publishedAt), 'd MMM yyyy', { locale: fr }) : ''} · {readingTime} min de lecture
|
||||
</div>
|
||||
|
||||
<h1 style={{ fontFamily: "'Source Serif 4', Georgia, serif", fontSize: 'clamp(28px,5vw,40px)', fontWeight: 600, lineHeight: 1.2, letterSpacing: '-0.02em', margin: '0 0 12px' }}>
|
||||
{note.title || 'Sans titre'}
|
||||
</h1>
|
||||
|
||||
<h1 style={{ fontFamily: "'Source Serif 4', Georgia, serif", fontSize: 'clamp(28px,5vw,40px)', fontWeight: 600, lineHeight: 1.2, letterSpacing: '-0.02em', margin: '0 0 12px' }}>{note.title || 'Sans titre'}</h1>
|
||||
{note.user?.name && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '40px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '36px' }}>
|
||||
{note.user.image && <img src={note.user.image} alt="" style={{ width: '28px', height: '28px', borderRadius: '50%' }} />}
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>par {note.user.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ height: '1px', background: 'rgba(0,0,0,0.08)', marginBottom: '40px' }} />
|
||||
|
||||
<div dir="auto" style={{ fontSize: '16px', lineHeight: 1.8 }} dangerouslySetInnerHTML={{ __html: sanitizeRichHtml(processedContent) }} />
|
||||
|
||||
<div style={{ height: '1px', background: 'rgba(0,0,0,0.08)', marginBottom: '36px' }} />
|
||||
<div dir="auto" className="pub-article" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||
<style>{`
|
||||
article h1, article h2, article h3 { font-family: 'Source Serif 4', Georgia, serif; font-weight: 600; letter-spacing: -0.01em; margin-top: 1.8em; margin-bottom: 0.6em; }
|
||||
article h2 { font-size: 1.5em; }
|
||||
article h3 { font-size: 1.2em; }
|
||||
article p { margin: 1em 0; }
|
||||
article ul, article ol { padding-left: 1.5em; margin: 1em 0; }
|
||||
article li { margin: 0.4em 0; }
|
||||
article blockquote { border-left: 3px solid #A47148; padding-left: 1em; margin: 1.2em 0; color: #555; font-style: italic; }
|
||||
article pre { background: #f4f3ef; padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 14px; margin: 1.2em 0; }
|
||||
article code { font-family: 'SF Mono', Menlo, monospace; }
|
||||
article table { border-collapse: collapse; width: 100%; margin: 1.2em 0; }
|
||||
article th, article td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
|
||||
article th { background: #f4f3ef; font-weight: 600; }
|
||||
article img { max-width: 100%; height: auto; border-radius: 8px; margin: 1em 0; }
|
||||
article a { color: #A47148; text-decoration: none; }
|
||||
article a:hover { text-decoration: underline; }
|
||||
.r-math-display { margin: 1.5em 0; text-align: center; overflow-x: auto; }
|
||||
.pub-article { font-family: 'Source Serif 4', Georgia, serif; font-size: 17px; line-height: 1.85; }
|
||||
.pub-article h2, .pub-article h3 { font-weight: 600; margin-top: 1.8em; margin-bottom: 0.6em; }
|
||||
.pub-article h2 { font-size: 1.4em; }
|
||||
.pub-article h3 { font-size: 1.15em; }
|
||||
.pub-article p { margin: 1em 0; }
|
||||
.pub-article ul, .pub-article ol { padding-left: 1.5em; margin: 1em 0; }
|
||||
.pub-article li { margin: 0.4em 0; }
|
||||
.pub-article blockquote { border-left: 3px solid #A47148; padding-left: 1em; margin: 1.2em 0; color: #555; font-style: italic; }
|
||||
.pub-article pre { background: #f4f3ef; padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 14px; margin: 1.2em 0; }
|
||||
.pub-article code { font-family: 'SF Mono', Menlo, monospace; font-size: 0.87em; }
|
||||
.pub-article table { border-collapse: collapse; width: 100%; margin: 1.2em 0; }
|
||||
.pub-article th, .pub-article td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
|
||||
.pub-article th { background: #f4f3ef; font-weight: 600; }
|
||||
.pub-article a { color: #A47148; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.pub-article .pub-figure { margin: 2em 0; text-align: center; }
|
||||
.pub-article .pub-figure-img { max-width: 100%; border-radius: 8px; box-shadow: 0 6px 24px rgba(0,0,0,0.08); }
|
||||
.pub-article .pub-figure-caption { margin-top: 0.6em; font-size: 0.82em; color: #888; font-style: italic; }
|
||||
.pub-article .pub-callout { border-radius: 8px; padding: 12px 16px; margin: 1em 0; }
|
||||
${KATEX_PUBLISH_CSS}
|
||||
`}</style>
|
||||
</article>
|
||||
|
||||
{/* Footer */}
|
||||
<footer style={{ borderTop: '1px solid rgba(0,0,0,0.08)', padding: '32px 24px', textAlign: 'center', fontSize: '13px', color: '#999' }}>
|
||||
<span style={{ fontFamily: "'Source Serif 4', serif", fontWeight: 600, color: '#A47148' }}>Momento</span>
|
||||
<footer style={{ borderTop: '1px solid rgba(0,0,0,0.08)', padding: '28px 24px', textAlign: 'center', fontSize: '13px', color: '#999' }}>
|
||||
<span style={{ fontFamily: "'Source Serif 4', serif", fontWeight: 600, color: '#A47148' }}>Memento</span>
|
||||
{' — Votre mémoire augmentée par l\'IA'}
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Types ─────────────────────────────────────────────────────────────── */
|
||||
type NoteData = NonNullable<Awaited<ReturnType<typeof getPublishedNote>>>
|
||||
interface PageProps {
|
||||
note: NoteData
|
||||
bodyHtml: string
|
||||
readingTime: number
|
||||
slug: string
|
||||
isStale: boolean
|
||||
}
|
||||
|
||||
/* ─── Route ──────────────────────────────────────────────────────────────── */
|
||||
export default async function PublishedNotePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const note = await getPublishedNote(slug)
|
||||
if (!note) notFound()
|
||||
|
||||
const usesAiLayout = Boolean(note.publishedContent)
|
||||
const rawHtml = usesAiLayout ? note.publishedContent! : (note.content || '')
|
||||
// Toujours passer par processNoteHtmlForPublish : KaTeX + sanitisation compatible équations
|
||||
const bodyHtml = processNoteHtmlForPublish(rawHtml)
|
||||
|
||||
const readingSource = usesAiLayout ? note.publishedContent! : (note.content || '')
|
||||
const readingTime = estimateReadingTime(readingSource)
|
||||
const isStale = Boolean(
|
||||
note.publishedSourceHash
|
||||
&& computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
|
||||
)
|
||||
|
||||
const props: PageProps = { note, bodyHtml, readingTime, slug, isStale }
|
||||
|
||||
if (!usesAiLayout) return <SimplePage {...props} />
|
||||
if (note.publishedTemplate === 'brief') return <BriefPage {...props} />
|
||||
if (note.publishedTemplate === 'essay') return <EssayPage {...props} />
|
||||
return <MagazinePage {...props} />
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { revalidatePath } from 'next/cache'
|
||||
|
||||
type SvgComplexity = 'simple' | 'illustrated' | 'rich'
|
||||
|
||||
// Palette de l'application Momento — à utiliser dans TOUS les SVGs
|
||||
// Palette de l'application Memento — à utiliser dans TOUS les SVGs
|
||||
const APP_PALETTE = `
|
||||
APP COLOR PALETTE (use ONLY these colors — no other palettes):
|
||||
- Background warm beige: #F2F0E9
|
||||
|
||||
@@ -71,6 +71,9 @@ export async function getPublishedNote(slug: string) {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
publishedContent: true,
|
||||
publishedTemplate: true,
|
||||
publishedSourceHash: true,
|
||||
publishedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface ExecuteResult {
|
||||
/**
|
||||
* Analyze a notebook's notes with AI and suggest a sub-notebook organization plan.
|
||||
*/
|
||||
export async function analyzeNotebookForOrganization(notebookId: string): Promise<AnalyzeResult> {
|
||||
export async function analyzeNotebookForOrganization(notebookId: string, language?: string): Promise<AnalyzeResult> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { success: false, error: 'Non autorisé' }
|
||||
|
||||
@@ -94,6 +94,9 @@ export async function analyzeNotebookForOrganization(notebookId: string): Promis
|
||||
? `\nSous-carnets existants:\n${existingSubs.map(s => `- "${s.name}" (id: ${s.id})`).join('\n')}`
|
||||
: '\nIl n\'y a pas encore de sous-carnets.'
|
||||
|
||||
const lang = language || 'fr'
|
||||
const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang === 'ar' ? 'العربية' : lang === 'de' ? 'Deutsch' : lang === 'es' ? 'Español' : lang === 'it' ? 'Italiano' : lang === 'pt' ? 'Português' : lang === 'ru' ? 'Русский' : lang === 'zh' ? '中文' : lang === 'ja' ? '日本語' : lang === 'ko' ? '한국어' : lang === 'nl' ? 'Nederlands' : lang === 'pl' ? 'Polski' : lang === 'hi' ? 'हिन्दी' : lang
|
||||
|
||||
const prompt = `Tu es un assistant d'organisation de notes. Analyse les notes suivantes du carnet "${notebook.name}" et regroupe-les par thème en proposant des sous-carnets.
|
||||
${existingSubsContext}
|
||||
|
||||
@@ -104,7 +107,7 @@ RÈGLES IMPORTANTES:
|
||||
- Regroupe les notes par thème ou sujet similaire.
|
||||
- Propose entre 2 et 6 groupes maximum.
|
||||
- Si un sous-carnet existant correspond déjà à un thème, utilise-le (indique son id).
|
||||
- Les noms de groupe doivent être courts (2-4 mots), clairs et en français.
|
||||
- Les noms de groupe doivent être courts (2-4 mots), clairs et EN ${langName.toUpperCase()}.
|
||||
- N'inclus PAS toutes les notes si certaines sont trop générales ou ne correspondent à aucun groupe clair — laisse-les de côté.
|
||||
- Réponds UNIQUEMENT avec du JSON valide, sans markdown, sans explication.
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export async function DELETE(request: NextRequest) {
|
||||
where: { id: noteId },
|
||||
select: { userId: true, publicSlug: true },
|
||||
})
|
||||
if (note) {
|
||||
if (note && note.userId) {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: note.userId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import prisma from '@/lib/prisma'
|
||||
import { exerciseGeneratorService } from '@/lib/ai/services/exercise-generator.service'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { preprocessMathInHtml } from '@/lib/text/math-preprocess'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -17,6 +18,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'noteId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) return aiConsentForbiddenResponse()
|
||||
|
||||
try {
|
||||
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||
} catch (err) {
|
||||
@@ -47,19 +50,27 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Create exercise notes in the same notebook
|
||||
const lang = language || 'fr'
|
||||
const exerciseLabel = lang === 'fr' ? 'Exercice' : lang === 'fa' ? 'تمرین' : 'Exercise'
|
||||
const answerLabel = lang === 'fr' ? 'Corrigé' : lang === 'fa' ? 'پاسخ' : 'Answer'
|
||||
const exerciseLabel = lang === 'fr' ? 'Exercice' : lang === 'fa' ? 'تمرین' : lang === 'ar' ? 'تمرين' : lang === 'de' ? 'Übung' : lang === 'es' ? 'Ejercicio' : lang === 'it' ? 'Esercizio' : lang === 'pt' ? 'Exercício' : lang === 'ru' ? 'Упражнение' : lang === 'zh' ? '练习' : lang === 'ja' ? '練習' : lang === 'ko' ? '연습' : lang === 'nl' ? 'Oefening' : lang === 'pl' ? 'Ćwiczenie' : lang === 'hi' ? 'अभ्यास' : 'Exercise'
|
||||
const answerLabel = lang === 'fr' ? 'Corrigé' : lang === 'fa' ? 'پاسخ' : lang === 'ar' ? 'الحل' : lang === 'de' ? 'Lösung' : lang === 'es' ? 'Solución' : lang === 'it' ? 'Soluzione' : lang === 'pt' ? 'Solução' : lang === 'ru' ? 'Решение' : lang === 'zh' ? '解答' : lang === 'ja' ? '解答' : lang === 'ko' ? '해답' : lang === 'nl' ? 'Oplossing' : lang === 'pl' ? 'Rozwiązanie' : lang === 'hi' ? 'हल' : 'Solution'
|
||||
const statementLabel = lang === 'fr' ? 'Énoncé' : lang === 'fa' ? 'صورت مسئله' : lang === 'ar' ? 'السؤال' : lang === 'de' ? 'Aufgabe' : lang === 'es' ? 'Enunciado' : lang === 'it' ? 'Testo' : lang === 'pt' ? 'Enunciado' : lang === 'ru' ? 'Задание' : lang === 'zh' ? '题目' : lang === 'ja' ? '問題' : lang === 'ko' ? '문제' : lang === 'nl' ? 'Opgave' : lang === 'pl' ? 'Zadanie' : lang === 'hi' ? 'प्रश्न' : 'Statement'
|
||||
const revealLabel = lang === 'fr' ? 'cliquer pour révéler' : lang === 'fa' ? 'برای نمایش کلیک کنید' : lang === 'ar' ? 'انقر للكشف' : lang === 'de' ? 'zum Anzeigen klicken' : lang === 'es' ? 'haz clic para ver' : lang === 'it' ? 'clicca per rivelare' : lang === 'pt' ? 'clique para revelar' : lang === 'ru' ? 'нажмите, чтобы открыть' : lang === 'zh' ? '点击查看' : lang === 'ja' ? 'クリックして表示' : lang === 'ko' ? '클릭하여 보기' : lang === 'nl' ? 'klik om te onthullen' : lang === 'pl' ? 'kliknij, aby zobaczyć' : lang === 'hi' ? 'देखने के लिए क्लिक करें' : 'click to reveal'
|
||||
const difficultyMap: Record<string, Record<string, string>> = {
|
||||
facile: { fr: 'facile', en: 'easy', fa: 'آسان', ar: 'سهل', de: 'einfach', es: 'fácil', it: 'facile', pt: 'fácil', ru: 'лёгкий', zh: '简单', ja: '易しい', ko: '쉬움', nl: 'makkelijk', pl: 'łatwy', hi: 'आसान' },
|
||||
moyen: { fr: 'moyen', en: 'medium', fa: 'متوسط', ar: 'متوسط', de: 'mittel', es: 'medio', it: 'medio', pt: 'médio', ru: 'средний', zh: '中等', ja: '普通', ko: '보통', nl: 'gemiddeld', pl: 'średni', hi: 'मध्यम' },
|
||||
difficile: { fr: 'difficile', en: 'hard', fa: 'دشوار', ar: 'صعب', de: 'schwer', es: 'difícil', it: 'difficile', pt: 'difícil', ru: 'сложный', zh: '困难', ja: '難しい', ko: '어려움', nl: 'moeilijk', pl: 'trudny', hi: 'कठिन' },
|
||||
}
|
||||
|
||||
const createdNotes = []
|
||||
for (let i = 0; i < exercises.length; i++) {
|
||||
const ex = exercises[i]
|
||||
const difficultyEmoji = ex.difficulty === 'facile' ? '🟢' : ex.difficulty === 'moyen' ? '🟡' : '🔴'
|
||||
const difficultyLocalized = (difficultyMap[ex.difficulty]?.[lang]) || ex.difficulty
|
||||
|
||||
const rawContent = `
|
||||
<div data-type="callout-block" data-callout-type="warning"><p><strong>${exerciseLabel} ${i + 1}</strong> — ${difficultyEmoji} ${ex.difficulty}</p></div>
|
||||
<h2>Énoncé</h2>
|
||||
<div data-type="callout-block" data-callout-type="warning"><p><strong>${exerciseLabel} ${i + 1}</strong> — ${difficultyEmoji} ${difficultyLocalized}</p></div>
|
||||
<h2>${statementLabel}</h2>
|
||||
<p>${ex.question}</p>
|
||||
<div data-type="toggle-block" data-opened="false"><p><strong>${answerLabel}</strong> — cliquer pour révéler</p><h3>Solution</h3><p>${ex.answer}</p></div>
|
||||
<div data-type="toggle-block" data-opened="false"><p><strong>${answerLabel}</strong> — ${revealLabel}</p><h3>${answerLabel}</h3><p>${ex.answer}</p></div>
|
||||
`.trim()
|
||||
|
||||
const content = preprocessMathInHtml(rawContent)
|
||||
|
||||
77
memento-note/app/api/ai/notebook-publish/route.ts
Normal file
77
memento-note/app/api/ai/notebook-publish/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { notebookPublishAnalyzerService } from '@/lib/ai/services/notebook-publish-analyzer.service'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { notebookId, language } = await request.json()
|
||||
if (!notebookId) {
|
||||
return NextResponse.json({ error: 'notebookId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) return aiConsentForbiddenResponse()
|
||||
|
||||
try {
|
||||
await reserveUsageOrThrow(session.user.id, 'publish_enhance')
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
const isTierLocked = err.currentQuota === 0
|
||||
return NextResponse.json(
|
||||
{ error: isTierLocked ? 'feature_locked' : 'quota_exceeded', errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded' },
|
||||
{ status: 402 },
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: { id: notebookId, userId: session.user.id, trashedAt: null },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
if (!notebook) return NextResponse.json({ error: 'Notebook not found' }, { status: 404 })
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { notebookId, userId: session.user.id, trashedAt: null, isArchived: false },
|
||||
select: { id: true, title: true, content: true },
|
||||
orderBy: { order: 'asc' },
|
||||
})
|
||||
|
||||
if (notes.length === 0) {
|
||||
return NextResponse.json({ error: 'No notes found' }, { status: 400 })
|
||||
}
|
||||
|
||||
const notesForService = notes.map(n => {
|
||||
const stripped = (n.content || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const wordCount = stripped.split(' ').filter(Boolean).length
|
||||
return {
|
||||
id: n.id,
|
||||
title: n.title || '',
|
||||
wordCount,
|
||||
preview: stripped.slice(0, 200),
|
||||
}
|
||||
})
|
||||
|
||||
const analysis = await notebookPublishAnalyzerService.analyze(
|
||||
notebook.name,
|
||||
notesForService,
|
||||
language || 'fr',
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
notes: analysis.notes,
|
||||
description: analysis.description,
|
||||
noteTitles: Object.fromEntries(notes.map(n => [n.id, n.title || ''])),
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('[notebook-publish/analyze] Error:', error)
|
||||
return NextResponse.json({ error: error.message || 'Failed to analyze' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { notebookWizardService, type WizardProfile } from '@/lib/ai/services/notebook-wizard.service'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -17,6 +18,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Profile and topic are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) return aiConsentForbiddenResponse()
|
||||
|
||||
try {
|
||||
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { studyPlannerService } from '@/lib/ai/services/study-planner.service'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -16,6 +17,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'notebookId and examDate are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) return aiConsentForbiddenResponse()
|
||||
|
||||
try {
|
||||
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||
} catch (err) {
|
||||
@@ -41,15 +44,18 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const userLang = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { theme: true },
|
||||
select: { language: true },
|
||||
})
|
||||
|
||||
const notesForService = notes.map(n => ({ id: n.id, title: n.title ?? '' }))
|
||||
const plan = await studyPlannerService.generate(notesForService, examDate)
|
||||
const plan = await studyPlannerService.generate(notesForService, examDate, userLang?.language || 'fr')
|
||||
|
||||
// Set reminders on notes based on the plan
|
||||
// Set the first occurrence reminder for each note (subsequent occurrences ignored)
|
||||
const seenNoteIds = new Set<string>()
|
||||
for (const day of plan.days) {
|
||||
for (const noteId of day.noteIds) {
|
||||
if (seenNoteIds.has(noteId)) continue
|
||||
seenNoteIds.add(noteId)
|
||||
try {
|
||||
const reminderDate = new Date(day.date)
|
||||
reminderDate.setHours(9, 0, 0, 0)
|
||||
|
||||
@@ -277,10 +277,10 @@ Fais un résumé concis (max 200 mots) de cette conversation. Garde les informat
|
||||
- Natural tone, neither corporate nor too casual.
|
||||
- No unnecessary intro phrases. Answer directly.
|
||||
- No upsell questions at the end. If you have useful additional info, just give it.
|
||||
- If the user says "Momento" they mean Momento (this app).
|
||||
- If the user says "Memento" they mean Memento (this app).
|
||||
|
||||
## About Momento
|
||||
Momento is an intelligent note-taking application. Key features include:
|
||||
## About Memento
|
||||
Memento is an intelligent note-taking application. Key features include:
|
||||
- **Notes & Editor**: Create rich Markdown notes with an integrated AI Copilot to rewrite, summarize, or translate content.
|
||||
- **Organization**: Group notes into Notebooks and tag them with Labels.
|
||||
- **Search**: Advanced semantic search to find notes by meaning, not just keywords, and Web Search integration.
|
||||
@@ -336,10 +336,10 @@ Available types: bar, horizontal-bar, line, area, pie, radar. NEVER use Mermaid
|
||||
## Règles de ton
|
||||
- Ton naturel, direct, sans phrases d'intro inutiles.
|
||||
- Pas de question upsell à la fin.
|
||||
- Si l'utilisateur dit "Momento" il parle de Momento (cette application).
|
||||
- Si l'utilisateur dit "Memento" il parle de Memento (cette application).
|
||||
|
||||
## À propos de Momento
|
||||
Momento est une application de prise de notes intelligente. Ses fonctionnalités : Éditeur Markdown riche, Copilot IA, Organisation par Carnets, Recherche sémantique, Agents IA, Lab.
|
||||
## À propos de Memento
|
||||
Memento est une application de prise de notes intelligente. Ses fonctionnalités : Éditeur Markdown riche, Copilot IA, Organisation par Carnets, Recherche sémantique, Agents IA, Lab.
|
||||
|
||||
## Outils disponibles
|
||||
Tu as accès à : note_search, note_read, note_find_and_update, document_search, task_extract, web_search, web_scrape, insert_chart.
|
||||
@@ -387,7 +387,7 @@ Types disponibles : bar, horizontal-bar, line, area, pie, radar. JAMAIS utiliser
|
||||
|
||||
## قوانین لحن
|
||||
- لحن طبیعی، مستقیم، بدون مقدمه اضافی.
|
||||
- اگر کاربر "Momento" میگوید، منظورش Memento (این برنامه) است.`,
|
||||
- اگر کاربر "Memento" میگوید، منظورش Memento (این برنامه) است.`,
|
||||
},
|
||||
es: {
|
||||
contextWithNotes: `## Notas del usuario\n\n${contextNotes}\n\nCuando uses información de las notas anteriores, cita el título de la nota fuente entre paréntesis.`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* POST /api/integrations/readwise/sync
|
||||
* Syncs Readwise highlights into Momento notes.
|
||||
* Syncs Readwise highlights into Memento notes.
|
||||
* Each book/article becomes a note with all its highlights listed.
|
||||
*
|
||||
* Query params:
|
||||
|
||||
116
memento-note/app/api/notebooks/[id]/publish/route.ts
Normal file
116
memento-note/app/api/notebooks/[id]/publish/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const { id: notebookId } = await params
|
||||
const site = await prisma.notebookSite.findUnique({
|
||||
where: { notebookId },
|
||||
select: { slug: true, isPublic: true, publishedAt: true, template: true, selectedNoteIds: true },
|
||||
})
|
||||
return NextResponse.json({ site: site || null })
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function toSlug(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.slice(0, 40)
|
||||
}
|
||||
|
||||
function generateNotebookSlug(name: string): string {
|
||||
const base = toSlug(name) || 'carnet'
|
||||
const suffix = Math.random().toString(36).slice(2, 7)
|
||||
return `${base}-${suffix}`
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: notebookId } = await params
|
||||
const { selectedNoteIds, template, description } = await request.json()
|
||||
|
||||
if (!selectedNoteIds || !Array.isArray(selectedNoteIds) || selectedNoteIds.length === 0) {
|
||||
return NextResponse.json({ error: 'selectedNoteIds is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: { id: notebookId, userId: session.user.id, trashedAt: null },
|
||||
select: { id: true, name: true, site: { select: { slug: true } } },
|
||||
})
|
||||
if (!notebook) return NextResponse.json({ error: 'Notebook not found' }, { status: 404 })
|
||||
|
||||
// Reuse existing slug or generate a new one
|
||||
const slug = notebook.site?.slug || generateNotebookSlug(notebook.name)
|
||||
|
||||
const site = await prisma.notebookSite.upsert({
|
||||
where: { notebookId },
|
||||
create: {
|
||||
notebookId,
|
||||
slug,
|
||||
isPublic: true,
|
||||
template: template || 'magazine',
|
||||
selectedNoteIds: JSON.stringify(selectedNoteIds),
|
||||
description: description || null,
|
||||
},
|
||||
update: {
|
||||
isPublic: true,
|
||||
template: template || 'magazine',
|
||||
selectedNoteIds: JSON.stringify(selectedNoteIds),
|
||||
description: description || null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ slug: site.slug, url: `/c/${site.slug}` })
|
||||
} catch (error: any) {
|
||||
console.error('[notebooks/publish] Error:', error)
|
||||
return NextResponse.json({ error: error.message || 'Failed to publish' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: notebookId } = await params
|
||||
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: { id: notebookId, userId: session.user.id },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!notebook) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
await prisma.notebookSite.deleteMany({ where: { notebookId } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { contentModerationService } from '@/lib/ai/services/content-moderation.service'
|
||||
import { contentModerationService, type ModerationResult } from '@/lib/ai/services/content-moderation.service'
|
||||
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { isPublishTemplateId } from '@/lib/publish/types'
|
||||
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
|
||||
|
||||
const MODERATION_TIMEOUT_MS = 12_000
|
||||
|
||||
async function moderateWithFallback(title: string, content: string): Promise<ModerationResult> {
|
||||
try {
|
||||
return await Promise.race([
|
||||
contentModerationService.moderate(title, content),
|
||||
new Promise<ModerationResult>((resolve) => {
|
||||
setTimeout(() => resolve({
|
||||
verdict: 'safe',
|
||||
categories: ['safe'],
|
||||
reason: 'Moderation timeout',
|
||||
}), MODERATION_TIMEOUT_MS)
|
||||
}),
|
||||
])
|
||||
} catch {
|
||||
return { verdict: 'safe', categories: ['safe'], reason: 'Moderation indisponible' }
|
||||
}
|
||||
}
|
||||
|
||||
function generateSlug(title: string): string {
|
||||
const base = title
|
||||
@@ -14,11 +38,70 @@ function generateSlug(title: string): string {
|
||||
return `${base}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
async function ensureSlug(noteId: string, title: string, existingSlug: string | null): Promise<string> {
|
||||
let slug = existingSlug
|
||||
if (!slug) {
|
||||
slug = generateSlug(title || 'note')
|
||||
const existing = await prisma.note.findUnique({ where: { publicSlug: slug } })
|
||||
if (existing && existing.id !== noteId) slug = `${slug}-${Date.now().toString(36)}`
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
async function notifyFlaggedAdmins(noteId: string, title: string, reason: string) {
|
||||
const admins = await prisma.user.findMany({ where: { role: 'ADMIN' }, select: { id: true } })
|
||||
for (const admin of admins) {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
type: 'content_flagged',
|
||||
title: 'Contenu sensible publié',
|
||||
message: `La note "${title}" a été publiée avec un contenu potentiellement sensible: ${reason}`,
|
||||
actionUrl: '/admin/published',
|
||||
relatedId: noteId,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
type PublishUpdateData = {
|
||||
isPublic: boolean
|
||||
publicSlug: string | null
|
||||
publishedAt: Date | null
|
||||
publishedContent?: string | null
|
||||
publishedTemplate?: string | null
|
||||
publishedSourceHash?: string | null
|
||||
}
|
||||
|
||||
/** Tolerates stale Prisma client during dev (before server restart). */
|
||||
async function updateNotePublishState(noteId: string, data: PublishUpdateData) {
|
||||
try {
|
||||
await prisma.note.update({ where: { id: noteId }, data })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (msg.includes('publishedContent') || msg.includes('Unknown argument')) {
|
||||
const { publishedContent, publishedTemplate, publishedSourceHash, ...core } = data
|
||||
await prisma.note.update({ where: { id: noteId }, data: core })
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { noteId, action } = await request.json()
|
||||
const body = await request.json()
|
||||
const { noteId, action, mode, template, language, rewrite } = body as {
|
||||
noteId?: string
|
||||
action?: string
|
||||
mode?: 'simple' | 'ai'
|
||||
template?: string
|
||||
language?: string
|
||||
rewrite?: boolean
|
||||
}
|
||||
|
||||
if (!noteId) return NextResponse.json({ error: 'noteId required' }, { status: 400 })
|
||||
|
||||
const note = await prisma.note.findFirst({
|
||||
@@ -28,14 +111,96 @@ export async function POST(request: NextRequest) {
|
||||
if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
if (action === 'publish') {
|
||||
// --- AI Moderation ---
|
||||
let moderation
|
||||
try {
|
||||
moderation = await contentModerationService.moderate(note.title || '', note.content || '')
|
||||
} catch {
|
||||
moderation = { verdict: 'safe' as const, categories: ['safe'], reason: 'Moderation indisponible' }
|
||||
const publishMode = mode === 'ai' ? 'ai' : 'simple'
|
||||
|
||||
if (publishMode === 'ai') {
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
}
|
||||
if (!template || !isPublishTemplateId(template)) {
|
||||
return NextResponse.json({ error: 'Invalid template' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveUsageOrThrow(session.user.id, 'publish_enhance')
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
const isTierLocked = err.currentQuota === 0
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: isTierLocked ? 'feature_locked' : 'quota_exceeded',
|
||||
errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded',
|
||||
upgradeTier: err.upgradeTier,
|
||||
},
|
||||
{ status: 402 },
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
let renderedHtml: string
|
||||
let textForModeration: string
|
||||
|
||||
try {
|
||||
if (rewrite) {
|
||||
const spec = await publishEnhanceService.rewrite(
|
||||
note.title || '',
|
||||
note.content || '',
|
||||
template,
|
||||
language || 'fr',
|
||||
)
|
||||
renderedHtml = renderRewrittenTemplate(spec, template, note.content || '')
|
||||
textForModeration = `${spec.summary}\n${spec.body.replace(/<[^>]+>/g, ' ')}`
|
||||
} else {
|
||||
const spec = await publishEnhanceService.enhance(
|
||||
note.title || '',
|
||||
note.content || '',
|
||||
template,
|
||||
language || 'fr',
|
||||
)
|
||||
renderedHtml = renderPublishedTemplate(spec, template, note.content || '')
|
||||
textForModeration = [spec.summary, spec.pullQuote, spec.epigraph, ...(spec.keyPoints || [])].join('\n')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[publish] AI generation failed:', err)
|
||||
return NextResponse.json({ error: 'ai_generation_failed' }, { status: 500 })
|
||||
}
|
||||
|
||||
const moderation = await moderateWithFallback(note.title || '', textForModeration)
|
||||
if (moderation.verdict === 'blocked') {
|
||||
return NextResponse.json({
|
||||
error: 'blocked',
|
||||
reason: moderation.reason,
|
||||
categories: moderation.categories,
|
||||
}, { status: 403 })
|
||||
}
|
||||
if (moderation.verdict === 'flagged') {
|
||||
await notifyFlaggedAdmins(note.id, note.title || '', moderation.reason)
|
||||
}
|
||||
|
||||
const slug = await ensureSlug(note.id, note.title || '', note.publicSlug)
|
||||
const sourceHash = computePublishedSourceHash(note.content || '')
|
||||
|
||||
await updateNotePublishState(noteId, {
|
||||
isPublic: true,
|
||||
publicSlug: slug,
|
||||
publishedAt: new Date(),
|
||||
publishedContent: renderedHtml,
|
||||
publishedTemplate: template,
|
||||
publishedSourceHash: sourceHash,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
slug,
|
||||
mode: 'ai',
|
||||
template,
|
||||
moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// Simple publish — contenu brut de la note
|
||||
const moderation = await moderateWithFallback(note.title || '', note.content || '')
|
||||
if (moderation.verdict === 'blocked') {
|
||||
return NextResponse.json({
|
||||
error: 'blocked',
|
||||
@@ -43,46 +208,36 @@ export async function POST(request: NextRequest) {
|
||||
categories: moderation.categories,
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
// flagged → publish but notify admins
|
||||
if (moderation.verdict === 'flagged') {
|
||||
const admins = await prisma.user.findMany({ where: { role: 'ADMIN' }, select: { id: true } })
|
||||
for (const admin of admins) {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
type: 'content_flagged',
|
||||
title: 'Contenu sensible publié',
|
||||
message: `La note "${note.title}" a été publiée avec un contenu potentiellement sensible: ${moderation.reason}`,
|
||||
actionUrl: '/admin/published',
|
||||
relatedId: note.id,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
await notifyFlaggedAdmins(note.id, note.title || '', moderation.reason)
|
||||
}
|
||||
|
||||
let slug = note.publicSlug
|
||||
if (!slug) {
|
||||
slug = generateSlug(note.title || 'note')
|
||||
const existing = await prisma.note.findUnique({ where: { publicSlug: slug } })
|
||||
if (existing && existing.id !== noteId) slug = `${slug}-${Date.now().toString(36)}`
|
||||
}
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { isPublic: true, publicSlug: slug, publishedAt: new Date() },
|
||||
const slug = await ensureSlug(note.id, note.title || '', note.publicSlug)
|
||||
await updateNotePublishState(noteId, {
|
||||
isPublic: true,
|
||||
publicSlug: slug,
|
||||
publishedAt: new Date(),
|
||||
publishedContent: null,
|
||||
publishedTemplate: null,
|
||||
publishedSourceHash: null,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
slug,
|
||||
mode: 'simple',
|
||||
moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'unpublish') {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { isPublic: false, publicSlug: null, publishedAt: null },
|
||||
await updateNotePublishState(noteId, {
|
||||
isPublic: false,
|
||||
publicSlug: null,
|
||||
publishedAt: null,
|
||||
publishedContent: null,
|
||||
publishedTemplate: null,
|
||||
publishedSourceHash: null,
|
||||
})
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const DEMO_NOTES: Record<string, DemoNote[]> = {
|
||||
<li><strong>Designing Data-Intensive Applications</strong> — Martin Kleppmann · Architecture systèmes</li>
|
||||
<li><strong>The Pragmatic Programmer</strong> — Hunt & Thomas · Bonnes pratiques développement</li>
|
||||
</ul>
|
||||
<p>Prochaine lecture : Building a Second Brain (lien avec Momento évident).</p>`,
|
||||
<p>Prochaine lecture : Building a Second Brain (lien avec Memento évident).</p>`,
|
||||
},
|
||||
{
|
||||
title: 'Notes de formation React — Hooks avancés',
|
||||
@@ -132,7 +132,7 @@ const DEMO_NOTES: Record<string, DemoNote[]> = {
|
||||
<li><strong>Designing Data-Intensive Applications</strong> — Martin Kleppmann · Systems architecture</li>
|
||||
<li><strong>The Pragmatic Programmer</strong> — Hunt & Thomas · Development best practices</li>
|
||||
</ul>
|
||||
<p>Next read: Building a Second Brain (obvious connection to Momento).</p>`,
|
||||
<p>Next read: Building a Second Brain (obvious connection to Memento).</p>`,
|
||||
},
|
||||
{
|
||||
title: 'React Training Notes — Advanced Hooks',
|
||||
|
||||
@@ -375,7 +375,7 @@ export async function GET() {
|
||||
<body>
|
||||
<div id="sidebar">
|
||||
<div id="sidebar-header">
|
||||
<h1>Momento</h1>
|
||||
<h1>Memento</h1>
|
||||
<p>Offline Workspace Export</p>
|
||||
</div>
|
||||
<div id="search-bar">
|
||||
|
||||
@@ -419,7 +419,7 @@ export function ByokSettingsPanel() {
|
||||
{activeKey ? (
|
||||
<><Zap size={14} className="shrink-0" /><span>BYOK actif · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
|
||||
) : (
|
||||
<><Shield size={14} className="shrink-0" /><span>Aucune clé active — utilisation des quotas Momento</span></>
|
||||
<><Shield size={14} className="shrink-0" /><span>Aucune clé active — utilisation des quotas Memento</span></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -69,6 +69,7 @@ export function AutoLabelSuggestionDialog({
|
||||
setSuggestions(data.data)
|
||||
const allLabelNames = new Set<string>(data.data.suggestedLabels.map((l: SuggestedLabel) => l.name as string))
|
||||
setSelectedLabels(allLabelNames)
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
} else {
|
||||
if (data.message) {
|
||||
toast.info(data.message)
|
||||
|
||||
@@ -275,6 +275,13 @@ export function ContextualAIChat({
|
||||
const lastMsgHasContent = lastMsg?.role === 'assistant' && !!getMessageContent(lastMsg)
|
||||
const isLoading = (status === 'submitted' || status === 'streaming') && !lastMsgHasContent
|
||||
|
||||
// Dispatch quota refresh quand le streaming se termine
|
||||
useEffect(() => {
|
||||
if (status === 'ready' && messages.length > 0) {
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
}
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, resourcePreview])
|
||||
@@ -1283,7 +1290,7 @@ export function ContextualAIChat({
|
||||
<div className="flex flex-col items-center gap-4 py-8 opacity-20 mt-auto shrink-0 border-t border-border/10">
|
||||
<PenTool size={20} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.3em] whitespace-nowrap italic text-center">
|
||||
{t('nav.workspace') || 'Momento Workspace'}
|
||||
{t('nav.workspace') || 'Memento Workspace'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
|
||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload } from 'lucide-react'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe } from 'lucide-react'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||
@@ -58,10 +58,18 @@ const OrganizeNotebookDialog = dynamic(
|
||||
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const NotebookSiteDialog = dynamic(
|
||||
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const StructuredViewsIntro = dynamic(
|
||||
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const StructuredViewsWizard = dynamic(
|
||||
() => import('@/components/structured-views/structured-views-wizard').then(m => ({ default: m.StructuredViewsWizard })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const StructuredViewsHelpBanner = dynamic(
|
||||
() => import('@/components/structured-views/structured-views-help-banner').then(m => ({ default: m.StructuredViewsHelpBanner })),
|
||||
{ ssr: false }
|
||||
@@ -95,7 +103,7 @@ export function HomeClient({
|
||||
}: HomeClientProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const { t, language } = useLanguage()
|
||||
|
||||
const [notes, setNotes] = useState<Note[]>(initialNotes)
|
||||
const [pinnedNotes, setPinnedNotes] = useState<Note[]>(
|
||||
@@ -136,6 +144,10 @@ export function HomeClient({
|
||||
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
|
||||
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
|
||||
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
||||
const [showStructuredWizard, setShowStructuredWizard] = useState(false)
|
||||
const [showNotebookSite, setShowNotebookSite] = useState(false)
|
||||
const [aiMenuOpen, setAiMenuOpen] = useState(false)
|
||||
const aiMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
|
||||
const [showOrganizer, setShowOrganizer] = useState(false)
|
||||
|
||||
@@ -237,7 +249,7 @@ export function HomeClient({
|
||||
}
|
||||
|
||||
let allNotes = search
|
||||
? await searchNotes(search, true, notebook || undefined)
|
||||
? await searchNotes(search, true, notebook || undefined).then(r => { window.dispatchEvent(new Event('ai-usage-changed')); return r })
|
||||
: await getAllNotes(false, notebook || undefined)
|
||||
|
||||
if (sharedOnly) {
|
||||
@@ -1045,31 +1057,65 @@ export function HomeClient({
|
||||
)}
|
||||
|
||||
{searchParams.get('notebook') && initialSettings.aiAssistantEnabled && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative" ref={aiMenuRef}>
|
||||
<button
|
||||
onClick={() => setSummaryDialogOpen(true)}
|
||||
className="flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setAiMenuOpen(o => !o)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[12px] font-medium transition-colors px-2.5 py-1.5 rounded-lg border',
|
||||
aiMenuOpen
|
||||
? 'bg-brand-accent/10 border-brand-accent/30 text-brand-accent'
|
||||
: 'border-border/50 text-muted-foreground hover:text-foreground hover:border-border',
|
||||
)}
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>{t('notebook.summary')}</span>
|
||||
</button>
|
||||
<span className="w-px h-3.5 bg-border/40" />
|
||||
<button
|
||||
onClick={() => setShowStudyPlanner(true)}
|
||||
className="flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-brand-accent transition-colors"
|
||||
>
|
||||
<CalendarDays size={14} />
|
||||
<span>{t('wizard.studyPlanner') || 'Planning'}</span>
|
||||
</button>
|
||||
<span className="w-px h-3.5 bg-border/40" />
|
||||
<button
|
||||
onClick={() => setOrganizeNotebookOpen(true)}
|
||||
className="flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-brand-accent transition-colors"
|
||||
title={t('notebook.organizeNotebookWithAITooltip')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>{t('batch.organize')}</span>
|
||||
<Sparkles size={13} />
|
||||
<span>IA</span>
|
||||
<ChevronDown size={11} className={cn('transition-transform', aiMenuOpen && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{aiMenuOpen && (
|
||||
<>
|
||||
{/* overlay invisible pour fermer au clic extérieur */}
|
||||
<div className="fixed inset-0 z-10" onClick={() => setAiMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1.5 z-20 w-52 bg-popover border border-border rounded-xl shadow-lg overflow-hidden">
|
||||
{[
|
||||
{
|
||||
icon: <FileText size={14} />,
|
||||
label: t('notebook.summary') || 'Résumé du carnet',
|
||||
action: () => { setSummaryDialogOpen(true); setAiMenuOpen(false) },
|
||||
},
|
||||
{
|
||||
icon: <CalendarDays size={14} />,
|
||||
label: t('wizard.studyPlanner') || 'Planning de révision',
|
||||
action: () => { setShowStudyPlanner(true); setAiMenuOpen(false) },
|
||||
},
|
||||
{
|
||||
icon: <Sparkles size={14} />,
|
||||
label: t('batch.organize') || 'Organiser avec l\'IA',
|
||||
action: () => { setOrganizeNotebookOpen(true); setAiMenuOpen(false) },
|
||||
},
|
||||
{
|
||||
icon: <TagIcon size={14} />,
|
||||
label: t('wizard.autoTags') || 'Tags automatiques',
|
||||
action: () => { setShowOrganizer(true); setAiMenuOpen(false) },
|
||||
},
|
||||
{
|
||||
icon: <Globe size={14} />,
|
||||
label: t('notebookSite.shortTitle') || 'Site web',
|
||||
action: () => { setShowNotebookSite(true); setAiMenuOpen(false) },
|
||||
},
|
||||
].map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={item.action}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-[12px] text-foreground hover:bg-muted/60 transition-colors text-left"
|
||||
>
|
||||
<span className="text-muted-foreground">{item.icon}</span>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{searchParams.get('notebook') && (
|
||||
@@ -1185,11 +1231,29 @@ export function HomeClient({
|
||||
) : showStructuredLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
|
||||
) : showStructuredIntro ? (
|
||||
<StructuredViewsIntro
|
||||
target={structuredViewMode}
|
||||
enabling={isEnablingStructured}
|
||||
onEnable={() => void handleEnableStructured(structuredViewMode)}
|
||||
/>
|
||||
showStructuredWizard ? (
|
||||
<StructuredViewsWizard
|
||||
open={showStructuredWizard}
|
||||
onClose={() => setShowStructuredWizard(false)}
|
||||
onComplete={(view) => {
|
||||
setShowStructuredWizard(false)
|
||||
if (view !== 'gallery') setLayoutMode(view)
|
||||
void schemaHook.reload()
|
||||
}}
|
||||
structuredModeActive={structuredModeActive}
|
||||
enableStructuredMode={schemaHook.enableStructuredMode}
|
||||
addProperty={schemaHook.addProperty}
|
||||
setKanbanGroupProperty={schemaHook.setKanbanGroupProperty}
|
||||
initialGoal={structuredViewMode === 'kanban' ? 'tasks' : undefined}
|
||||
/>
|
||||
) : (
|
||||
<StructuredViewsIntro
|
||||
target={structuredViewMode}
|
||||
enabling={isEnablingStructured}
|
||||
onEnable={() => void handleEnableStructured(structuredViewMode)}
|
||||
onOpenWizard={() => setShowStructuredWizard(true)}
|
||||
/>
|
||||
)
|
||||
) : showStructuredDataView && schemaHook.schema && notebookFilter ? (
|
||||
<>
|
||||
<StructuredViewsHelpBanner notebookId={notebookFilter} mode={structuredViewMode} />
|
||||
@@ -1347,6 +1411,14 @@ export function HomeClient({
|
||||
onClose={() => setShowOrganizer(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showNotebookSite && currentNotebook && (
|
||||
<NotebookSiteDialog
|
||||
notebookId={currentNotebook.id}
|
||||
notebookName={currentNotebook.name}
|
||||
onClose={() => setShowNotebookSite(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function LandingPage() {
|
||||
<div className="w-10 h-10 bg-ink flex items-center justify-center rounded-xl shadow-lg rotate-3 group hover:rotate-0 transition-transform cursor-pointer">
|
||||
<span className="text-paper font-serif text-2xl font-bold">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-2xl font-medium tracking-tight">Momento</span>
|
||||
<span className="font-serif text-2xl font-medium tracking-tight">Memento</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-10">
|
||||
@@ -104,7 +104,7 @@ export function LandingPage() {
|
||||
{/* App Preview Mockup */}
|
||||
<motion.div initial={{ opacity: 0, y: 100 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 1, delay: 0.2, ease: [0.23, 1, 0.32, 1] }} className="mt-24 relative">
|
||||
<div className="relative mx-auto max-w-5xl aspect-[16/10] bg-white rounded-[32px] shadow-[0_40px_100px_-20px_rgba(0,0,0,0.15)] border border-border p-4 overflow-hidden group">
|
||||
<img src="/images/workspace-hero.jpg" alt="Momento Workspace" className="w-full h-full object-cover rounded-2xl filter saturate-[0.8]" />
|
||||
<img src="/images/workspace-hero.jpg" alt="Memento Workspace" className="w-full h-full object-cover rounded-2xl filter saturate-[0.8]" />
|
||||
<div className="absolute inset-0 bg-ink/10 group-hover:bg-ink/0 transition-colors duration-500" />
|
||||
|
||||
<div className="absolute top-10 right-10 w-64 bg-paper/90 backdrop-blur-xl border border-border p-6 rounded-2xl shadow-2xl">
|
||||
@@ -381,7 +381,7 @@ export function LandingPage() {
|
||||
<div className="w-8 h-8 bg-ink flex items-center justify-center rounded-lg">
|
||||
<span className="text-paper font-serif text-lg font-bold">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Momento</span>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||
</div>
|
||||
<p className="text-sm text-concrete font-light max-w-xs">{t('landing.footer.desc')}</p>
|
||||
</div>
|
||||
|
||||
@@ -839,11 +839,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}, [isDirty, isSaving, readOnly, fullPage, autoSaveEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullPage) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
void handleSaveInPlaceRef.current()
|
||||
if (fullPage) {
|
||||
void handleSaveInPlaceRef.current()
|
||||
} else {
|
||||
void handleSaveRef.current()
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useNoteEditorContext } from './note-editor-context'
|
||||
import { LabelManager } from '@/components/label-manager'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
@@ -19,11 +19,10 @@ import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
|
||||
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink
|
||||
} from 'lucide-react'
|
||||
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
|
||||
import { NoteShareDialog } from './note-share-dialog'
|
||||
import { PublishDialog } from './publish-dialog'
|
||||
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -33,6 +32,10 @@ import { useVoiceTranscription } from '@/hooks/use-voice-transcription'
|
||||
import { toast } from 'sonner'
|
||||
import { format } from 'date-fns'
|
||||
import { tiptapHTMLToMarkdown, markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export'
|
||||
import { getCalloutColors } from '@/lib/editor/callout-colors'
|
||||
import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { PUBLISH_TEMPLATES, type PublishTemplateId } from '@/lib/publish/types'
|
||||
|
||||
interface NoteEditorToolbarProps {
|
||||
mode: 'fullPage' | 'dialog'
|
||||
@@ -44,12 +47,64 @@ interface NoteEditorToolbarProps {
|
||||
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
|
||||
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
|
||||
const { t, language } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const [isConverting, setIsConverting] = useState(false)
|
||||
const [shareOpen, setShareOpen] = useState(false)
|
||||
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
|
||||
const [publishOpen, setPublishOpen] = useState(false)
|
||||
const [publishLoading, setPublishLoading] = useState(false)
|
||||
const [publishMeta, setPublishMeta] = useState({
|
||||
isPublic: Boolean(note.isPublic),
|
||||
slug: note.publicSlug ?? null,
|
||||
template: (note.publishedTemplate as PublishTemplateId | null) ?? null,
|
||||
})
|
||||
const [publishLinkCopied, setPublishLinkCopied] = useState(false)
|
||||
const [publishTemplate, setPublishTemplate] = useState<PublishTemplateId>('magazine')
|
||||
const [publishRewrite, setPublishRewrite] = useState(false)
|
||||
const [publishEnhanceRemaining, setPublishEnhanceRemaining] = useState<number | null>(null)
|
||||
const [publishEnhanceLocked, setPublishEnhanceLocked] = useState(false)
|
||||
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||
|
||||
useEffect(() => {
|
||||
setPublishMeta({
|
||||
isPublic: Boolean(note.isPublic),
|
||||
slug: note.publicSlug ?? null,
|
||||
template: (note.publishedTemplate as PublishTemplateId | null) ?? null,
|
||||
})
|
||||
}, [note.id, note.isPublic, note.publicSlug, note.publishedTemplate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!publishOpen) return
|
||||
|
||||
const loadPublishQuota = () => {
|
||||
void fetch('/api/usage/current')
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => {
|
||||
const q = data?.quotas?.publish_enhance
|
||||
if (q === undefined) {
|
||||
setPublishEnhanceLocked(false)
|
||||
setPublishEnhanceRemaining(null)
|
||||
return
|
||||
}
|
||||
if (q.limit === 0) {
|
||||
setPublishEnhanceLocked(true)
|
||||
setPublishEnhanceRemaining(0)
|
||||
return
|
||||
}
|
||||
setPublishEnhanceLocked(false)
|
||||
setPublishEnhanceRemaining(q.remaining ?? 0)
|
||||
})
|
||||
.catch(() => {
|
||||
setPublishEnhanceLocked(false)
|
||||
setPublishEnhanceRemaining(null)
|
||||
})
|
||||
}
|
||||
|
||||
loadPublishQuota()
|
||||
window.addEventListener('ai-usage-changed', loadPublishQuota)
|
||||
return () => window.removeEventListener('ai-usage-changed', loadPublishQuota)
|
||||
}, [publishOpen])
|
||||
|
||||
const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null)
|
||||
|
||||
// ── Voice transcription ──────────────────────────────────────────────────
|
||||
@@ -137,18 +192,11 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
// Apply callout colors as inline styles (Tailwind classes won't work in print window)
|
||||
clone.querySelectorAll('[data-callout-type]').forEach(el => {
|
||||
const type = el.getAttribute('data-callout-type')
|
||||
const colors: Record<string, { bg: string; border: string }> = {
|
||||
info: { bg: '#eff6ff', border: '#93c5fd' },
|
||||
warning: { bg: '#fffbeb', border: '#fcd34d' },
|
||||
tip: { bg: '#faf5ff', border: '#c4b5fd' },
|
||||
success: { bg: '#f0fdf4', border: '#86efac' },
|
||||
danger: { bg: '#fef2f2', border: '#fca5a5' },
|
||||
}
|
||||
const c = colors[type || 'info'] || colors.info
|
||||
const inner = el.querySelector('div')
|
||||
const { bg, border } = getCalloutColors(type)
|
||||
const inner = el.querySelector('div') as HTMLElement | null
|
||||
if (inner) {
|
||||
(inner as HTMLElement).style.background = c.bg
|
||||
(inner as HTMLElement).style.borderColor = c.border
|
||||
inner.style.background = bg
|
||||
inner.style.borderColor = border
|
||||
}
|
||||
})
|
||||
|
||||
@@ -239,8 +287,176 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
|
||||
const [generatingExercises, setGeneratingExercises] = useState(false)
|
||||
const [showEduMenu, setShowEduMenu] = useState(false)
|
||||
|
||||
const publicPageUrl = publishMeta.slug
|
||||
? `${typeof window !== 'undefined' ? window.location.origin : ''}/p/${publishMeta.slug}`
|
||||
: ''
|
||||
|
||||
const handlePublishNote = async () => {
|
||||
if (publishLoading) return
|
||||
if (state.isDirty && !state.isSaving) {
|
||||
await actions.handleSaveInPlace()
|
||||
}
|
||||
setPublishLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/notes/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ noteId: note.id, action: 'publish', mode: 'simple' }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.slug) {
|
||||
setPublishMeta({ isPublic: true, slug: data.slug, template: null })
|
||||
emitNoteChange({
|
||||
type: 'updated',
|
||||
note: { ...note, isPublic: true, publicSlug: data.slug, publishedTemplate: null },
|
||||
})
|
||||
const url = `${window.location.origin}/p/${data.slug}`
|
||||
toast.success(t('richTextEditor.publishSuccess'), {
|
||||
description: url,
|
||||
action: {
|
||||
label: t('richTextEditor.publishLive') || 'Voir',
|
||||
onClick: () => { window.open(url, '_blank', 'noopener,noreferrer') },
|
||||
},
|
||||
duration: 8000,
|
||||
})
|
||||
setPublishOpen(false)
|
||||
} else if (data.error === 'blocked') {
|
||||
toast.error(t('richTextEditor.publishBlocked'), {
|
||||
description: data.reason || undefined,
|
||||
duration: 6000,
|
||||
})
|
||||
} else {
|
||||
toast.error(data.error || t('general.error'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
} finally {
|
||||
setPublishLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const publishTemplateLabels: Record<PublishTemplateId, string> = {
|
||||
magazine: t('richTextEditor.publishTemplateMagazine'),
|
||||
brief: t('richTextEditor.publishTemplateBrief'),
|
||||
essay: t('richTextEditor.publishTemplateEssay'),
|
||||
}
|
||||
|
||||
const handlePublishWithAi = async () => {
|
||||
if (publishLoading || publishEnhanceLocked || (publishEnhanceRemaining !== null && publishEnhanceRemaining <= 0)) return
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
if (state.isDirty && !state.isSaving) {
|
||||
await actions.handleSaveInPlace()
|
||||
}
|
||||
setPublishLoading(true)
|
||||
const toastId = toast.loading(t('richTextEditor.publishWithAiGenerating'))
|
||||
try {
|
||||
const res = await fetch('/api/notes/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
noteId: note.id,
|
||||
action: 'publish',
|
||||
mode: 'ai',
|
||||
template: publishTemplate,
|
||||
rewrite: publishRewrite,
|
||||
language,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.status === 402) {
|
||||
toast.dismiss(toastId)
|
||||
toast.error(
|
||||
data.errorKey === 'ai.featureLocked'
|
||||
? t('richTextEditor.publishWithAiLocked')
|
||||
: t('ai.quotaExceeded'),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (res.ok && data.slug) {
|
||||
setPublishMeta({ isPublic: true, slug: data.slug, template: publishTemplate })
|
||||
emitNoteChange({
|
||||
type: 'updated',
|
||||
note: {
|
||||
...note,
|
||||
isPublic: true,
|
||||
publicSlug: data.slug,
|
||||
publishedTemplate: publishTemplate,
|
||||
},
|
||||
})
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
const url = `${window.location.origin}/p/${data.slug}`
|
||||
toast.success(t('richTextEditor.publishAiSuccess'), {
|
||||
id: toastId,
|
||||
description: url,
|
||||
action: {
|
||||
label: t('richTextEditor.publishLive'),
|
||||
onClick: () => { window.open(url, '_blank', 'noopener,noreferrer') },
|
||||
},
|
||||
duration: 8000,
|
||||
})
|
||||
setPublishOpen(false)
|
||||
} else if (data.error === 'blocked') {
|
||||
toast.dismiss(toastId)
|
||||
toast.error(t('richTextEditor.publishBlocked'), {
|
||||
description: data.reason || undefined,
|
||||
duration: 6000,
|
||||
})
|
||||
} else {
|
||||
toast.dismiss(toastId)
|
||||
toast.error(data.error || t('general.error'))
|
||||
}
|
||||
} catch {
|
||||
toast.dismiss(toastId)
|
||||
toast.error(t('general.error'))
|
||||
} finally {
|
||||
setPublishLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnpublishNote = async () => {
|
||||
if (publishLoading) return
|
||||
setPublishLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/notes/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ noteId: note.id, action: 'unpublish' }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setPublishMeta({ isPublic: false, slug: null, template: null })
|
||||
emitNoteChange({
|
||||
type: 'updated',
|
||||
note: { ...note, isPublic: false, publicSlug: null, publishedTemplate: null },
|
||||
})
|
||||
toast.success(t('richTextEditor.unpublishSuccess'))
|
||||
setPublishOpen(false)
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
toast.error(data.error || t('general.error'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
} finally {
|
||||
setPublishLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyPublicLink = async () => {
|
||||
if (!publicPageUrl) return
|
||||
const ok = await copyTextToClipboard(publicPageUrl)
|
||||
if (ok) {
|
||||
setPublishLinkCopied(true)
|
||||
setTimeout(() => setPublishLinkCopied(false), 2000)
|
||||
toast.success(t('richTextEditor.copyPublicLink') || 'Lien copié')
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateExercises = async () => {
|
||||
if (generatingExercises) return
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
setGeneratingExercises(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/generate-exercises', {
|
||||
@@ -252,13 +468,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
if (!res.ok) {
|
||||
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
|
||||
} else {
|
||||
toast.success(`${data.exercises?.length || 0} ${t('richTextEditor.exercisesGenerated') || 'exercices créés dans ce carnet !'}`, {
|
||||
action: {
|
||||
label: t('richTextEditor.seeExercises') || 'Voir',
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
})
|
||||
// Emit events so the note list refreshes
|
||||
toast.success(`${data.exercises?.length || 0} ${t('richTextEditor.exercisesGenerated') || 'exercices créés !'}`)
|
||||
for (const ex of data.exercises || []) {
|
||||
emitNoteChange({ type: 'created', note: { ...note, id: ex.id, title: ex.title, content: '<p></p>' } as any })
|
||||
}
|
||||
@@ -499,18 +709,191 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<button
|
||||
title={t('richTextEditor.publishTitle') || 'Publication publique'}
|
||||
onClick={() => setPublishOpen(true)}
|
||||
className={cn(
|
||||
"p-1.5 rounded-full border transition-all",
|
||||
note.isPublic
|
||||
? "border-green-400/40 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950/20"
|
||||
: "border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Globe size={16} />
|
||||
</button>
|
||||
<DropdownMenu open={publishOpen} onOpenChange={setPublishOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={t('richTextEditor.publishTitle')}
|
||||
aria-label={t('richTextEditor.publishTitle')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all',
|
||||
publishMeta.isPublic
|
||||
? 'border-green-400/40 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950/20'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<Globe size={16} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 rounded-xl p-4 shadow-xl">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-7 h-7 rounded-lg bg-brand-accent/10 flex items-center justify-center shrink-0">
|
||||
<Globe size={13} className="text-brand-accent" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold">{t('richTextEditor.publishTitle')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">
|
||||
{t('richTextEditor.publishDesc')}
|
||||
</p>
|
||||
{publishMeta.isPublic && publishMeta.slug ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800/50">
|
||||
<Check size={14} className="text-green-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-green-700 dark:text-green-400">
|
||||
{t('richTextEditor.publishLive')}
|
||||
{publishMeta.template && (
|
||||
<span className="text-green-600/80 dark:text-green-400/80">
|
||||
{' · '}{publishTemplateLabels[publishMeta.template]}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 p-2 rounded-lg border border-border bg-muted/40">
|
||||
<span className="flex-1 text-xs text-muted-foreground truncate px-1">{publicPageUrl}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyPublicLink}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
aria-label={t('richTextEditor.copyPublicLink')}
|
||||
>
|
||||
{publishLinkCopied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
<a
|
||||
href={publicPageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
aria-label={t('richTextEditor.openPublicPage')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUnpublishNote}
|
||||
disabled={publishLoading}
|
||||
className="w-full py-2 rounded-lg border border-red-200 dark:border-red-800/50 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{publishLoading ? <Loader2 size={14} className="animate-spin mx-auto" /> : t('richTextEditor.unpublish')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePublishNote}
|
||||
disabled={publishLoading}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-border bg-background text-sm font-medium hover:bg-muted disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{publishLoading ? <Loader2 size={14} className="animate-spin" /> : <Globe size={14} />}
|
||||
{t('richTextEditor.publishSimple')}
|
||||
</button>
|
||||
<p className="text-[10px] text-muted-foreground text-center -mt-1">
|
||||
{t('richTextEditor.publishSimpleHint')}
|
||||
</p>
|
||||
|
||||
<div className="border-t border-border/50 pt-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sparkles size={13} className="text-brand-accent shrink-0" />
|
||||
<span className="text-xs font-semibold">{t('richTextEditor.publishWithAi')}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{publishEnhanceLocked
|
||||
? t('richTextEditor.publishWithAiLocked')
|
||||
: t('richTextEditor.publishWithAiHint').replace(
|
||||
'{count}',
|
||||
String(publishEnhanceRemaining ?? '…'),
|
||||
)}
|
||||
</p>
|
||||
{/* Sélection template */}
|
||||
<div className="space-y-1">
|
||||
{PUBLISH_TEMPLATES.map((tpl) => (
|
||||
<label
|
||||
key={tpl}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-lg cursor-pointer text-xs transition-colors',
|
||||
publishTemplate === tpl
|
||||
? 'bg-brand-accent/10 text-brand-accent font-medium'
|
||||
: 'hover:bg-muted text-foreground',
|
||||
(publishEnhanceLocked || publishLoading) && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="publish-template"
|
||||
value={tpl}
|
||||
checked={publishTemplate === tpl}
|
||||
onChange={() => setPublishTemplate(tpl)}
|
||||
className="accent-[var(--color-brand-accent)]"
|
||||
/>
|
||||
{publishTemplateLabels[tpl]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toggle reformulation */}
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border p-3 transition-colors',
|
||||
publishRewrite
|
||||
? 'border-brand-accent/40 bg-brand-accent/5'
|
||||
: 'border-border bg-muted/30',
|
||||
(publishEnhanceLocked || publishLoading) && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<div className="relative mt-0.5 shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={publishRewrite}
|
||||
onChange={e => setPublishRewrite(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={cn(
|
||||
'w-8 h-4 rounded-full transition-colors',
|
||||
publishRewrite ? 'bg-brand-accent' : 'bg-muted-foreground/30',
|
||||
)}>
|
||||
<div className={cn(
|
||||
'w-3 h-3 rounded-full bg-white shadow transition-transform mt-0.5',
|
||||
publishRewrite ? 'translate-x-4' : 'translate-x-0.5',
|
||||
)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold leading-tight">
|
||||
{t('richTextEditor.publishRewriteLabel')}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5 leading-relaxed">
|
||||
{publishRewrite
|
||||
? t('richTextEditor.publishRewriteOnHint')
|
||||
: t('richTextEditor.publishRewriteOffHint')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePublishWithAi}
|
||||
disabled={
|
||||
publishLoading
|
||||
|| publishEnhanceLocked
|
||||
|| (publishEnhanceRemaining !== null && publishEnhanceRemaining <= 0)
|
||||
}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-brand-accent text-white text-sm font-medium hover:bg-brand-accent/90 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{publishLoading ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Sparkles size={14} />
|
||||
)}
|
||||
{t('richTextEditor.publishWithAi')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
@@ -752,16 +1135,6 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{publishOpen && (
|
||||
<PublishDialog
|
||||
open={publishOpen}
|
||||
onClose={() => setPublishOpen(false)}
|
||||
noteId={note.id}
|
||||
noteTitle={state.title || note.title || 'Untitled'}
|
||||
isPublic={note.isPublic}
|
||||
publicSlug={note.publicSlug ?? null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Globe, X, Copy, Check, Loader2, ExternalLink } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
|
||||
|
||||
interface PublishDialogProps {
|
||||
@@ -25,8 +25,6 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
|
||||
|
||||
useEffect(() => { setIsPublic(initialPublic); setSlug(publicSlug) }, [initialPublic, publicSlug])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const publicUrl = slug ? `${window.location.origin}/p/${slug}` : ''
|
||||
|
||||
const handlePublish = async () => {
|
||||
@@ -41,16 +39,10 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
|
||||
if (res.ok && data.slug) {
|
||||
setIsPublic(true)
|
||||
setSlug(data.slug)
|
||||
if (data.moderation === 'flagged') {
|
||||
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !', {
|
||||
description: '⚠️ Un modérateur examinera le contenu sous peu.',
|
||||
})
|
||||
} else {
|
||||
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !')
|
||||
}
|
||||
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !')
|
||||
} else if (data.error === 'blocked') {
|
||||
toast.error(t('richTextEditor.publishBlocked') || 'Publication refusée', {
|
||||
description: data.reason || 'Le contenu ne respecte pas les règles de publication.',
|
||||
description: data.reason || 'Le contenu ne respecte pas les règles.',
|
||||
duration: 6000,
|
||||
})
|
||||
} else {
|
||||
@@ -82,57 +74,70 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
|
||||
if (ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); toast.success('Lien copié !') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" dir="auto" onClick={onClose}>
|
||||
<div className="w-full max-w-md max-h-[90vh] overflow-y-auto rounded-2xl border border-border bg-card shadow-2xl p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden border border-black/10 dark:border-white/10"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-black/5 dark:border-white/5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-xl bg-brand-accent/10 flex items-center justify-center">
|
||||
<Globe size={15} className="text-brand-accent" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold">{t('richTextEditor.publishTitle') || 'Publication publique'}</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{t('richTextEditor.publishTitle') || 'Publication publique'}</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-muted text-muted-foreground"><X size={16} /></button>
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-400">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mb-4 leading-relaxed">
|
||||
{t('richTextEditor.publishDesc') || 'Publiez cette note sur une URL publique. Tout le monde avec le lien pourra la lire.'}
|
||||
</p>
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4 leading-relaxed">
|
||||
{t('richTextEditor.publishDesc') || 'Publiez cette note sur une URL publique. Tout le monde avec le lien pourra la lire.'}
|
||||
</p>
|
||||
|
||||
{isPublic && slug ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800/50">
|
||||
<Check size={14} className="text-green-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-green-700 dark:text-green-400">{t('richTextEditor.publishLive') || 'En ligne'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 p-2 rounded-xl border border-border bg-background">
|
||||
<span className="flex-1 text-xs text-muted-foreground truncate px-1">{publicUrl}</span>
|
||||
<button onClick={copyLink} className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0">
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
{isPublic && slug ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800/50">
|
||||
<Check size={14} className="text-green-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-green-700 dark:text-green-400">{t('richTextEditor.publishLive') || 'En ligne'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 p-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800">
|
||||
<span className="flex-1 text-xs text-gray-500 dark:text-gray-400 truncate px-1">{publicUrl}</span>
|
||||
<button onClick={copyLink} className="p-1.5 rounded-md hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors shrink-0">
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
<a href={publicUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 rounded-md hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors shrink-0">
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUnpublish}
|
||||
disabled={loading}
|
||||
className="w-full py-2 rounded-xl border border-red-200 dark:border-red-800/50 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="animate-spin mx-auto" /> : (t('richTextEditor.unpublish') || 'Dépublier')}
|
||||
</button>
|
||||
<a href={publicUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0">
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleUnpublish}
|
||||
onClick={handlePublish}
|
||||
disabled={loading}
|
||||
className="w-full py-2 rounded-xl border border-red-200 dark:border-red-800/50 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 transition-colors disabled:opacity-40"
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-brand-accent text-white text-sm font-medium hover:bg-brand-accent/90 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="animate-spin mx-auto" /> : (t('richTextEditor.unpublish') || 'Dépublier')}
|
||||
{loading ? <Loader2 size={14} className="animate-spin" /> : <Globe size={14} />}
|
||||
{t('richTextEditor.publish') || 'Publier'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-brand-accent text-white text-sm font-medium hover:bg-brand-accent/90 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="animate-spin" /> : <Globe size={14} />}
|
||||
{t('richTextEditor.publish') || 'Publier'}
|
||||
</button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function OrganizeNotebookDialog({
|
||||
|
||||
const handleAnalyze = useCallback(async () => {
|
||||
setStep('analyzing'); setError(null); setPlan(null)
|
||||
const res = await analyzeNotebookForOrganization(notebookId)
|
||||
const res = await analyzeNotebookForOrganization(notebookId, language)
|
||||
if (!res.success || !res.plan) { setError(res.error ?? 'Erreur'); setStep('idle'); return }
|
||||
setPlan(res.plan)
|
||||
setEditableGroups(res.plan.groups.map(g => ({ ...g, notes: [...g.notes] })))
|
||||
|
||||
@@ -695,7 +695,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-wider text-concrete/50">
|
||||
<Command size={10} />
|
||||
<span>Momento Search</span>
|
||||
<span>Memento Search</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -209,7 +209,7 @@ export function BillingPlans() {
|
||||
name: t('billing.freePlan'),
|
||||
price: t('billing.freePrice') || 'Gratuit',
|
||||
period: '',
|
||||
description: t('billing.freeDescription') || 'Pour découvrir la magie de Momento.',
|
||||
description: t('billing.freeDescription') || 'Pour découvrir la magie de Memento.',
|
||||
features: [
|
||||
t('billing.freeF1') || '100 Notes max',
|
||||
t('billing.freeF2') || '3 Carnets',
|
||||
|
||||
@@ -46,6 +46,7 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
|
||||
brainstorm_create: t('usageMeter.featureBrainstormCreate') || 'Création de Brainstorm',
|
||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand') || 'Expansion de Brainstorm',
|
||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich') || 'Enrichissement de Brainstorm',
|
||||
publish_enhance: t('usageMeter.featurePublishEnhance') || 'Publication IA',
|
||||
};
|
||||
|
||||
const currentFeatureLabel = featureLabels[feature] || feature;
|
||||
|
||||
@@ -20,6 +20,7 @@ const FEATURE_LABEL_KEYS: Record<string, string> = {
|
||||
aiTranscribe: 'sidebar.aiTranscribe',
|
||||
aiDiagram: 'sidebar.aiDiagram',
|
||||
aiAgent: 'sidebar.aiAgent',
|
||||
publish_enhance: 'usageMeter.featurePublishEnhance',
|
||||
};
|
||||
|
||||
function UsageBar({ used, limit, isUnlimited }: { used: number; limit: number; isUnlimited: boolean }) {
|
||||
|
||||
@@ -398,25 +398,27 @@ function SidebarCarnetItem({
|
||||
<div className="absolute start-[8px] top-1/2 w-[8px] h-px bg-border/40" />
|
||||
)}
|
||||
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className="absolute start-1 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground/30 hover:text-muted-foreground cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
>
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-0.5">
|
||||
{/* Drag handle — visible au hover, dans son propre slot avant le chevron */}
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-muted-foreground/0 group-hover:text-muted-foreground/50 hover:!text-muted-foreground cursor-grab active:cursor-grabbing transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical size={11} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center gap-1">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand() }}
|
||||
className="p-1 hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground"
|
||||
className="shrink-0 w-5 h-5 flex items-center justify-center hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground"
|
||||
>
|
||||
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
|
||||
<ChevronRight size={14} className="rtl:scale-x-[-1]" />
|
||||
<ChevronRight size={13} className="rtl:scale-x-[-1]" />
|
||||
</motion.div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6" />
|
||||
<div className="w-5" />
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
@@ -1704,9 +1706,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
{showAiWizard && (
|
||||
<AiNotebookWizard
|
||||
onClose={() => setShowAiWizard(false)}
|
||||
onComplete={() => {
|
||||
onComplete={(notebookId: string) => {
|
||||
setShowAiWizard(false)
|
||||
window.location.reload()
|
||||
router.push(`/home?notebook=${notebookId}`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Columns3, Database, Table2, type LucideIcon } from 'lucide-react'
|
||||
import { Columns3, Database, Table2, Wand2, type LucideIcon } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BootstrapStructuredTarget } from '@/lib/structured-views/bootstrap-structured-notebook'
|
||||
@@ -9,9 +9,10 @@ type StructuredViewsIntroProps = {
|
||||
target: BootstrapStructuredTarget
|
||||
enabling?: boolean
|
||||
onEnable: () => void
|
||||
onOpenWizard?: () => void
|
||||
}
|
||||
|
||||
export function StructuredViewsIntro({ target, enabling, onEnable }: StructuredViewsIntroProps) {
|
||||
export function StructuredViewsIntro({ target, enabling, onEnable, onOpenWizard }: StructuredViewsIntroProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
@@ -47,21 +48,33 @@ export function StructuredViewsIntro({ target, enabling, onEnable }: StructuredV
|
||||
? t('structuredViews.intro.activateKanbanHint')
|
||||
: t('structuredViews.intro.activateTableHint')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={enabling}
|
||||
onClick={onEnable}
|
||||
className={cn(
|
||||
'px-6 py-2.5 rounded-full text-[11px] font-bold uppercase tracking-wider transition-all',
|
||||
'bg-foreground text-background hover:opacity-90 disabled:opacity-50',
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
disabled={enabling}
|
||||
onClick={onEnable}
|
||||
className={cn(
|
||||
'px-6 py-2.5 rounded-full text-[11px] font-bold uppercase tracking-wider transition-all',
|
||||
'bg-foreground text-background hover:opacity-90 disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
{enabling
|
||||
? t('structuredViews.intro.enabling')
|
||||
: target === 'kanban'
|
||||
? t('structuredViews.intro.enableKanban')
|
||||
: t('structuredViews.intro.enableTable')}
|
||||
</button>
|
||||
{onOpenWizard && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenWizard}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 rounded-full text-[11px] font-semibold transition-all border border-border/60 text-muted-foreground hover:text-foreground hover:border-border"
|
||||
>
|
||||
<Wand2 size={13} />
|
||||
{t('structuredViews.wizard.openFromKanban') || 'Configurer avec l\'assistant'}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{enabling
|
||||
? t('structuredViews.intro.enabling')
|
||||
: target === 'kanban'
|
||||
? t('structuredViews.intro.enableKanban')
|
||||
: t('structuredViews.intro.enableTable')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -32,8 +32,8 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
const json = await res.json();
|
||||
return { quotas: json.quotas as Record<string, QuotaData>, tier: json.tier as string };
|
||||
},
|
||||
staleTime: 5000,
|
||||
refetchInterval: 10000,
|
||||
staleTime: 0,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -71,6 +71,7 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
'chat',
|
||||
'brainstorm_create', // Sera affiché comme "Sessions brainstorm"
|
||||
'suggest_charts',
|
||||
'publish_enhance',
|
||||
]);
|
||||
|
||||
const featureLabels: Record<string, string> = {
|
||||
@@ -81,6 +82,7 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
chat: t('usageMeter.featureChat'),
|
||||
brainstorm_create: t('usageMeter.featureBrainstormSessions'), // Label simplifié
|
||||
suggest_charts: t('usageMeter.featureCharts'),
|
||||
publish_enhance: t('usageMeter.featurePublishEnhance'),
|
||||
};
|
||||
|
||||
const featureQuotas = Object.entries(data.quotas)
|
||||
|
||||
@@ -43,6 +43,7 @@ export function NotebookOrganizerDialog({
|
||||
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
|
||||
} else {
|
||||
setResult(data)
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Erreur')
|
||||
@@ -133,7 +134,7 @@ export function NotebookOrganizerDialog({
|
||||
: 'bg-foreground/5 hover:bg-foreground/10 text-foreground'
|
||||
)}
|
||||
>
|
||||
{appliedTags.has(tag.name) ? (<><Check size={10} className="inline" /> Appliqué</>) : (t('wizard.apply') || 'Appliquer')}
|
||||
{appliedTags.has(tag.name) ? (<><Check size={10} className="inline" /> {t('wizard.tagApplied') || 'Appliqué'}</>) : (t('wizard.apply') || 'Appliquer')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
398
memento-note/components/wizard/notebook-site-dialog.tsx
Normal file
398
memento-note/components/wizard/notebook-site-dialog.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Sparkles, Globe, Check, Loader2, ExternalLink, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
|
||||
type Step = 'loading' | 'published' | 'idle' | 'analyzing' | 'selection' | 'publishing' | 'done' | 'unpublishing'
|
||||
|
||||
interface NoteItem {
|
||||
id: string
|
||||
title: string
|
||||
include: boolean
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface NotebookSiteDialogProps {
|
||||
notebookId: string
|
||||
notebookName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const TEMPLATES = [
|
||||
{ id: 'magazine', label: 'Magazine', desc: 'Sombre, Playfair Display' },
|
||||
{ id: 'brief', label: 'Brief', desc: 'Professionnel, sobre' },
|
||||
{ id: 'essay', label: 'Essai', desc: 'Élégant, ivoire' },
|
||||
] as const
|
||||
|
||||
export function NotebookSiteDialog({ notebookId, notebookName, onClose }: NotebookSiteDialogProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
|
||||
const [step, setStep] = useState<Step>('loading')
|
||||
const [existingSlug, setExistingSlug] = useState<string | null>(null)
|
||||
const [notes, setNotes] = useState<NoteItem[]>([])
|
||||
const [description, setDescription] = useState('')
|
||||
const [template, setTemplate] = useState<'magazine' | 'brief' | 'essay'>('magazine')
|
||||
const [siteUrl, setSiteUrl] = useState('')
|
||||
|
||||
// Charger l'état existant au montage
|
||||
useEffect(() => {
|
||||
fetch(`/api/notebooks/${notebookId}/publish`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.site?.slug) {
|
||||
setExistingSlug(data.site.slug)
|
||||
setSiteUrl(`/c/${data.site.slug}`)
|
||||
setTemplate(data.site.template || 'magazine')
|
||||
setStep('published')
|
||||
} else {
|
||||
setStep('idle')
|
||||
}
|
||||
})
|
||||
.catch(() => setStep('idle'))
|
||||
}, [notebookId])
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
setStep('analyzing')
|
||||
try {
|
||||
const res = await fetch('/api/ai/notebook-publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notebookId, language }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
|
||||
setStep(existingSlug ? 'published' : 'idle')
|
||||
return
|
||||
}
|
||||
const items: NoteItem[] = (data.notes || []).map((n: any) => ({
|
||||
id: n.noteId,
|
||||
title: data.noteTitles?.[n.noteId] || n.noteId,
|
||||
include: n.include,
|
||||
reason: n.reason,
|
||||
}))
|
||||
setNotes(items)
|
||||
setDescription(data.description || '')
|
||||
setStep('selection')
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Erreur')
|
||||
setStep(existingSlug ? 'published' : 'idle')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublish = async () => {
|
||||
const selected = notes.filter(n => n.include).map(n => n.id)
|
||||
if (selected.length === 0) { toast.error('Sélectionne au moins une note'); return }
|
||||
setStep('publishing')
|
||||
try {
|
||||
const res = await fetch(`/api/notebooks/${notebookId}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ selectedNoteIds: selected, template, description }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast.error(data.error || 'Erreur'); setStep('selection'); return }
|
||||
setExistingSlug(data.slug)
|
||||
setSiteUrl(data.url)
|
||||
setStep('done')
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Erreur')
|
||||
setStep('selection')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnpublish = async () => {
|
||||
setStep('unpublishing')
|
||||
try {
|
||||
const res = await fetch(`/api/notebooks/${notebookId}/publish`, { method: 'DELETE' })
|
||||
if (!res.ok) { toast.error('Erreur'); setStep('published'); return }
|
||||
setExistingSlug(null)
|
||||
setSiteUrl('')
|
||||
toast.success('Site dépublié')
|
||||
setStep('idle')
|
||||
} catch {
|
||||
toast.error('Erreur')
|
||||
setStep('published')
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCount = notes.filter(n => n.include).length
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: .96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: .96 }}
|
||||
className="relative bg-background border border-border rounded-2xl shadow-2xl w-full max-w-lg flex flex-col max-h-[90vh]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-border">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Globe size={18} className="text-brand-accent" />
|
||||
<div>
|
||||
<p className="text-[13px] font-bold">{t('notebookSite.title') || 'Publier en site web'}</p>
|
||||
<p className="text-[11px] text-muted-foreground truncate max-w-[280px]">{notebookName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors p-1">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
{/* Chargement initial */}
|
||||
{step === 'loading' && (
|
||||
<motion.div key="loading" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex justify-center py-10">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Déjà publié */}
|
||||
{step === 'published' && (
|
||||
<motion.div key="published" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="space-y-4">
|
||||
<div className="rounded-xl border border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-950/30 p-4 flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/50 flex items-center justify-center shrink-0">
|
||||
<Check size={15} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-bold text-green-800 dark:text-green-300 mb-1">Site en ligne</p>
|
||||
<a
|
||||
href={siteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[11px] font-mono text-green-700 dark:text-green-400 hover:underline flex items-center gap-1"
|
||||
>
|
||||
memento-note.com{siteUrl} <ExternalLink size={10} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setStep('idle')}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 rounded-xl border border-border hover:bg-muted/50 transition-colors text-[13px] font-semibold text-left"
|
||||
>
|
||||
<RefreshCw size={15} className="text-brand-accent" />
|
||||
<div>
|
||||
<p>{t('notebookSite.updateSite') || 'Mettre à jour le site'}</p>
|
||||
<p className="text-[11px] text-muted-foreground font-normal">Relancer l'analyse IA et modifier la sélection</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleUnpublish}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 rounded-xl border border-red-200 dark:border-red-900 hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors text-[13px] font-semibold text-left text-red-600 dark:text-red-400"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
<div>
|
||||
<p>{t('notebookSite.unpublish') || 'Dépublier le site'}</p>
|
||||
<p className="text-[11px] text-red-400 font-normal">Le lien public ne sera plus accessible</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Dépublication en cours */}
|
||||
{step === 'unpublishing' && (
|
||||
<motion.div key="unpublishing" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4 py-10">
|
||||
<Loader2 size={28} className="animate-spin text-red-500" />
|
||||
<p className="text-[13px] text-muted-foreground">Dépublication en cours...</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Étape 1 : explication */}
|
||||
{step === 'idle' && (
|
||||
<motion.div key="idle" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="space-y-4">
|
||||
<div className="rounded-xl border border-border/50 bg-muted/30 p-4 space-y-3">
|
||||
<p className="text-[13px] font-semibold">{t('notebookSite.howItWorks') || 'Comment ça marche'}</p>
|
||||
<ol className="space-y-2 text-[12px] text-muted-foreground">
|
||||
{[
|
||||
t('notebookSite.step1') || "L'IA analyse vos notes et recommande lesquelles publier",
|
||||
t('notebookSite.step2') || "Vous validez ou modifiez la sélection",
|
||||
t('notebookSite.step3') || "Votre site est généré avec navigation et table des matières",
|
||||
].map((s, i) => (
|
||||
<li key={i} className="flex items-start gap-2.5">
|
||||
<span className="shrink-0 w-4 h-4 rounded-full bg-brand-accent/20 text-brand-accent text-[10px] font-bold flex items-center justify-center mt-0.5">{i + 1}</span>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('notebookSite.quotaWarning') || "L'analyse consomme 1 crédit IA."}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Analyse en cours */}
|
||||
{step === 'analyzing' && (
|
||||
<motion.div key="analyzing" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4 py-10">
|
||||
<Loader2 size={30} className="animate-spin text-brand-accent" />
|
||||
<p className="text-[13px] text-muted-foreground">{t('notebookSite.analyzing') || "L'IA analyse vos notes..."}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Sélection */}
|
||||
{step === 'selection' && (
|
||||
<motion.div key="selection" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-4">
|
||||
{description && (
|
||||
<div className="rounded-xl bg-brand-accent/5 border border-brand-accent/20 p-3">
|
||||
<p className="text-[11px] font-bold text-brand-accent uppercase tracking-widest mb-1">{t('notebookSite.siteDescription') || 'Description générée'}</p>
|
||||
<p className="text-[12px] text-foreground leading-relaxed">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
<strong>{selectedCount}</strong> / {notes.length} {t('notebookSite.notesSelected') || 'notes sélectionnées'}
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
|
||||
{notes.map(note => (
|
||||
<button
|
||||
key={note.id}
|
||||
onClick={() => setNotes(prev => prev.map(n => n.id === note.id ? { ...n, include: !n.include } : n))}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg border p-2.5 transition-colors flex items-start gap-3',
|
||||
note.include
|
||||
? 'border-brand-accent/40 bg-brand-accent/5'
|
||||
: 'border-border/40 bg-muted/10 opacity-55',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'shrink-0 w-4 h-4 rounded border flex items-center justify-center mt-0.5',
|
||||
note.include ? 'bg-brand-accent border-brand-accent' : 'border-muted-foreground/30 bg-background',
|
||||
)}>
|
||||
{note.include && <Check size={9} className="text-white" strokeWidth={3} />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-semibold text-foreground truncate">{note.title || 'Sans titre'}</p>
|
||||
{note.reason && <p className="text-[10px] text-muted-foreground mt-0.5 leading-relaxed">{note.reason}</p>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-2">{t('notebookSite.template') || 'Style visuel'}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{TEMPLATES.map(tpl => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
onClick={() => setTemplate(tpl.id)}
|
||||
className={cn(
|
||||
'rounded-lg border p-2.5 text-left transition-colors',
|
||||
template === tpl.id
|
||||
? 'border-brand-accent bg-brand-accent/5'
|
||||
: 'border-border/50 hover:border-border',
|
||||
)}
|
||||
>
|
||||
<p className="text-[11px] font-bold">{tpl.label}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{tpl.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Publication en cours */}
|
||||
{step === 'publishing' && (
|
||||
<motion.div key="publishing" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4 py-10">
|
||||
<Loader2 size={30} className="animate-spin text-brand-accent" />
|
||||
<p className="text-[13px] text-muted-foreground">{t('notebookSite.publishing') || 'Génération du site...'}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Succès */}
|
||||
{step === 'done' && (
|
||||
<motion.div key="done" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-5 py-6 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-green-100 dark:bg-green-950/40 flex items-center justify-center">
|
||||
<Check size={26} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[16px] font-bold mb-1">{t('notebookSite.published') || 'Site publié !'}</p>
|
||||
<p className="text-[12px] text-muted-foreground">{selectedCount} notes · accessible publiquement</p>
|
||||
</div>
|
||||
<a
|
||||
href={siteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-6 py-2.5 rounded-full bg-brand-accent text-white text-[12px] font-bold hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
{t('notebookSite.openSite') || 'Ouvrir le site'}
|
||||
</a>
|
||||
<p className="text-[10px] text-muted-foreground font-mono break-all">memento-note.com{siteUrl}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="p-4 border-t border-border flex items-center justify-between gap-2">
|
||||
<div>
|
||||
{(step === 'selection' || step === 'idle') && existingSlug && (
|
||||
<button
|
||||
onClick={() => setStep('published')}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Retour au site actuel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{step === 'idle' && (
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-accent text-white text-[12px] font-bold hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
{t('notebookSite.analyzeBtn') || "Analyser avec l'IA"}
|
||||
</button>
|
||||
)}
|
||||
{step === 'selection' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setStep('idle')}
|
||||
className="px-3 py-2 rounded-lg text-[12px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={selectedCount === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-accent text-white text-[12px] font-bold hover:opacity-90 disabled:opacity-40 transition-opacity"
|
||||
>
|
||||
<Globe size={13} />
|
||||
{t('notebookSite.publishBtn') || 'Publier'} ({selectedCount})
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(step === 'done' || step === 'published') && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg bg-foreground text-background text-[12px] font-bold hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export function StudyPlannerDialog({
|
||||
} else {
|
||||
setPlan(data)
|
||||
toast.success(t('wizard.studyPlanSuccess') || 'Planning créé ! Des rappels ont été ajoutés à vos notes.')
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Erreur')
|
||||
|
||||
@@ -4,7 +4,7 @@ Clipper web avec **panneau latéral** : le panneau reste ouvert pendant que vous
|
||||
|
||||
## Langues
|
||||
|
||||
L’extension suit la **langue de l’interface Chrome** (`chrome.i18n.getUILanguage`) — 15 locales comme l’app Momento : `de`, `en`, `es`, `fr`, `it`, `pt`, `nl`, `pl`, `ru`, `zh`, `ja`, `ko`, `ar`, `fa`, `hi`.
|
||||
L’extension suit la **langue de l’interface Chrome** (`chrome.i18n.getUILanguage`) — 15 locales comme l’app Memento : `de`, `en`, `es`, `fr`, `it`, `pt`, `nl`, `pl`, `ru`, `zh`, `ja`, `ko`, `ar`, `fa`, `hi`.
|
||||
|
||||
Fichiers : `extension/_locales/<lang>/messages.json`. Régénération : `node extension/i18n/generate-translations.cjs` puis `node extension/scripts/build-extension-locales.mjs`.
|
||||
|
||||
@@ -12,11 +12,11 @@ Fichiers : `extension/_locales/<lang>/messages.json`. Régénération : `node ex
|
||||
|
||||
1. Chrome → `chrome://extensions`
|
||||
2. **Mode développeur** → **Charger l’extension non empaquetée** → dossier `memento-note/extension`
|
||||
3. Épingle l’icône Momento
|
||||
3. Épingle l’icône Memento
|
||||
|
||||
> Chrome **114+** requis (Side Panel API).
|
||||
|
||||
## Instance Momento
|
||||
## Instance Memento
|
||||
|
||||
- **Dev** : icône ⚙ → URL (`http://localhost:3000` ou IP LAN) → **Appliquer & reconnecter**
|
||||
- Connectez-vous sur **la même URL** dans Chrome (Google OAuth)
|
||||
@@ -25,18 +25,18 @@ Fichiers : `extension/_locales/<lang>/messages.json`. Régénération : `node ex
|
||||
## Utilisation
|
||||
|
||||
1. Ouvrez une page web normale (pas `chrome://`)
|
||||
2. Cliquez l’icône Momento → panneau latéral
|
||||
2. Cliquez l’icône Memento → panneau latéral
|
||||
3. Choisissez le **carnet** (liste hiérarchique)
|
||||
4. Optionnel : surlignez du texte → **Clipper la sélection** (bouton sky)
|
||||
5. Ou **Clipper cette page** (article complet + IA)
|
||||
6. Ou **Enregistrer le lien seul**
|
||||
7. **Aperçu** : titre éditable, résumé, extrait, temps de lecture → **Enregistrer dans Momento**
|
||||
7. **Aperçu** : titre éditable, résumé, extrait, temps de lecture → **Enregistrer dans Memento**
|
||||
|
||||
## Dépannage
|
||||
|
||||
| Problème | Solution |
|
||||
|----------|----------|
|
||||
| Carnets vides / 401 | **Ouvrir Momento ↗** sur la même URL, connectez-vous |
|
||||
| Carnets vides / 401 | **Ouvrir Memento ↗** sur la même URL, connectez-vous |
|
||||
| `localhost` vs `127.0.0.1` | Utilisez **toujours la même** URL partout (cookies session) |
|
||||
| Pas de sélection | Rechargez la page après install extension ; surlignez sur la page, pas dans le panneau |
|
||||
| Page Chrome système | Impossible — ouvrez un site http(s) normal |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"message": "مومنتو ويب كليبر"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "التقط صفحات الويب والنص المميز في دفاتر ملاحظات Momento الخاصة بك - ويتصل بخادم Momento الخاص بك."
|
||||
"message": "التقط صفحات الويب والنص المميز في دفاتر ملاحظات Memento الخاصة بك - ويتصل بخادم Memento الخاص بك."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "مقطع إلى مومنتو"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "عنوان URL لمومنتو"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "عنوان URL لمثيل Momento الخاص بك"
|
||||
"message": "عنوان URL لمثيل Memento الخاص بك"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "إعداد مسبق للإنتاج · memento-note.com"
|
||||
@@ -33,13 +33,13 @@
|
||||
"message": "افتح مومنتو"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "الصق عنوان URL الخاص بـ HTTPS (أو LAN) لخادم Momento الخاص بك. تتعامل ملفات تعريف الارتباط الموجودة في هذا المتصفح مع تسجيل الدخول."
|
||||
"message": "الصق عنوان URL الخاص بـ HTTPS (أو LAN) لخادم Memento الخاص بك. تتعامل ملفات تعريف الارتباط الموجودة في هذا المتصفح مع تسجيل الدخول."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<الإصدار>>>"
|
||||
"message": "Memento Web Clipper <<<الإصدار>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "لا يستطيع Momento الوصول إلى علامة التبويب هذه. تحقق من أذونات ملحق لوحة المفاتيح/الموقع — أو افتح اللوحة الجانبية."
|
||||
"message": "لا يستطيع Memento الوصول إلى علامة التبويب هذه. تحقق من أذونات ملحق لوحة المفاتيح/الموقع — أو افتح اللوحة الجانبية."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "دفتر بلا عنوان"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "لا يمكن القص هنا — هذه الصفحة تحظر الوصول إلى الإضافات."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "يرجى تسجيل الدخول إلى Momento في هذا المتصفح أولاً."
|
||||
"message": "يرجى تسجيل الدخول إلى Memento في هذا المتصفح أولاً."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "تعذر تحميل دفاتر الملاحظات. حاول إعادة الاتصال."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "صفحة مقيدة - قم بالقص عبر شريط أدوات Momento أو اللوحة الجانبية."
|
||||
"message": "صفحة مقيدة - قم بالقص عبر شريط أدوات Memento أو اللوحة الجانبية."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "دفتر الوجهة"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "لا يمكن إكماله"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "حدث خطأ ما أثناء الوصول إلى مثيل Momento."
|
||||
"message": "حدث خطأ ما أثناء الوصول إلى مثيل Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "أعد المحاولة"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "لا يمكن حفظ ملاحظتك."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "مشكلة في الشبكة - تحقق من اتصالك وعنوان URL الخاص بـ Momento."
|
||||
"message": "مشكلة في الشبكة - تحقق من اتصالك وعنوان URL الخاص بـ Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "قم بتمييز النص الموجود على الصفحة، أو قم بقص الصفحة بأكملها."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Web Clipper"
|
||||
"message": "Memento Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Erfassen Sie Webseiten und hervorgehobenen Text in Ihren Momento-Notizbüchern – stellt eine Verbindung zu Ihrem eigenen Momento-Server her."
|
||||
"message": "Erfassen Sie Webseiten und hervorgehobenen Text in Ihren Memento-Notizbüchern – stellt eine Verbindung zu Ihrem eigenen Memento-Server her."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip auf Momento"
|
||||
"message": "Clip auf Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Web Clipper"
|
||||
@@ -18,10 +18,10 @@
|
||||
"message": "Nicht verbunden"
|
||||
},
|
||||
"instanceSettings": {
|
||||
"message": "Momento-URL"
|
||||
"message": "Memento-URL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "Ihre Momento-Instanz-URL"
|
||||
"message": "Ihre Memento-Instanz-URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Produktionsvoreinstellung · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Anwenden und erneut verbinden"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Öffnen Sie Momento"
|
||||
"message": "Öffnen Sie Memento"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Fügen Sie die HTTPS- (oder LAN-)URL Ihres Momento-Servers ein. Cookies in diesem Browser verarbeiten die Anmeldung."
|
||||
"message": "Fügen Sie die HTTPS- (oder LAN-)URL Ihres Memento-Servers ein. Cookies in diesem Browser verarbeiten die Anmeldung."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper 0.3.1"
|
||||
"message": "Memento Web Clipper 0.3.1"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento kann nicht auf diese Registerkarte zugreifen. Überprüfen Sie die Tastatur-/Site-Erweiterungsberechtigungen – oder öffnen Sie den Seitenbereich."
|
||||
"message": "Memento kann nicht auf diese Registerkarte zugreifen. Überprüfen Sie die Tastatur-/Site-Erweiterungsberechtigungen – oder öffnen Sie den Seitenbereich."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Notizbuch ohne Titel"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Hier kann kein Clip erstellt werden – diese Seite blockiert den Zugriff auf die Erweiterung."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Bitte melden Sie sich zunächst in diesem Browser bei Momento an."
|
||||
"message": "Bitte melden Sie sich zunächst in diesem Browser bei Memento an."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Notebooks konnten nicht geladen werden. Versuchen Sie, die Verbindung wiederherzustellen."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Eingeschränkte Seite – Ausschneiden über die Momento-Symbolleiste oder den Seitenbereich."
|
||||
"message": "Eingeschränkte Seite – Ausschneiden über die Memento-Symbolleiste oder den Seitenbereich."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Zielnotizbuch"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Auszug"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "In Momento speichern"
|
||||
"message": "In Memento speichern"
|
||||
},
|
||||
"back": {
|
||||
"message": "Zurück"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "In Momento ansehen"
|
||||
"message": "In Memento ansehen"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Schneiden Sie eine weitere Seite aus"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Konnte nicht abgeschlossen werden"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Beim Erreichen Ihrer Momento-Instanz ist ein Fehler aufgetreten."
|
||||
"message": "Beim Erreichen Ihrer Memento-Instanz ist ein Fehler aufgetreten."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Wiederholen"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Ihre Notiz konnte nicht gespeichert werden."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Netzwerkproblem – überprüfen Sie Ihre Verbindung und Momento-URL."
|
||||
"message": "Netzwerkproblem – überprüfen Sie Ihre Verbindung und Memento-URL."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Markieren Sie Text auf der Seite oder schneiden Sie die gesamte Seite aus."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Web Clipper"
|
||||
"message": "Memento Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Capture web pages and highlighted text into your Momento notebooks — connects to your own Momento server."
|
||||
"message": "Capture web pages and highlighted text into your Memento notebooks — connects to your own Memento server."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip to Momento"
|
||||
"message": "Clip to Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Web Clipper"
|
||||
@@ -18,10 +18,10 @@
|
||||
"message": "Not connected"
|
||||
},
|
||||
"instanceSettings": {
|
||||
"message": "Momento URL"
|
||||
"message": "Memento URL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "Your Momento instance URL"
|
||||
"message": "Your Memento instance URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Production preset · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Apply and reconnect"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Open Momento"
|
||||
"message": "Open Memento"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Paste the HTTPS (or LAN) URL of your Momento server. Cookies in this browser handle sign-in."
|
||||
"message": "Paste the HTTPS (or LAN) URL of your Memento server. Cookies in this browser handle sign-in."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper 0.3.1"
|
||||
"message": "Memento Web Clipper 0.3.1"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento can't access this tab. Check keyboard/site extension permissions — or open the Side Panel."
|
||||
"message": "Memento can't access this tab. Check keyboard/site extension permissions — or open the Side Panel."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Untitled notebook"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Can't clip here — this page blocks extension access."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Please sign in to Momento in this browser first."
|
||||
"message": "Please sign in to Memento in this browser first."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Could not load notebooks. Try reconnecting."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Restricted page — clip via the Momento toolbar or Side Panel."
|
||||
"message": "Restricted page — clip via the Memento toolbar or Side Panel."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Destination notebook"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Excerpt"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Save to Momento"
|
||||
"message": "Save to Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Back"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "View in Momento"
|
||||
"message": "View in Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Clip another page"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Could not complete"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Something went wrong reaching your Momento instance."
|
||||
"message": "Something went wrong reaching your Memento instance."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Retry"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Could not save your note."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Network issue — check your connection and Momento URL."
|
||||
"message": "Network issue — check your connection and Memento URL."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Highlight text on the page, or clip the whole page."
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Cortadora web Momento"
|
||||
"message": "Cortadora web Memento"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Capture páginas web y texto resaltado en sus cuadernos Momento: se conecta a su propio servidor Momento."
|
||||
"message": "Capture páginas web y texto resaltado en sus cuadernos Memento: se conecta a su propio servidor Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip al momento"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "URL del momento"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "La URL de tu instancia de Momento"
|
||||
"message": "La URL de tu instancia de Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Preajuste de producción · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Aplicar y reconectar"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Momento abierto"
|
||||
"message": "Memento abierto"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Pegue la URL HTTPS (o LAN) de su servidor Momento. Las cookies en este navegador controlan el inicio de sesión."
|
||||
"message": "Pegue la URL HTTPS (o LAN) de su servidor Memento. Las cookies en este navegador controlan el inicio de sesión."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<VERSIÓN>>>"
|
||||
"message": "Memento Web Clipper <<<VERSIÓN>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento no puede acceder a esta pestaña. Verifique los permisos de extensión del sitio/teclado o abra el Panel lateral."
|
||||
"message": "Memento no puede acceder a esta pestaña. Verifique los permisos de extensión del sitio/teclado o abra el Panel lateral."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Cuaderno sin título"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "No se puede recortar aquí: esta página bloquea el acceso a la extensión."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Primero inicie sesión en Momento en este navegador."
|
||||
"message": "Primero inicie sesión en Memento en este navegador."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "No se pudieron cargar los cuadernos. Intente volver a conectarse."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Página restringida: recorte mediante la barra de herramientas de Momento o el panel lateral."
|
||||
"message": "Página restringida: recorte mediante la barra de herramientas de Memento o el panel lateral."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Cuaderno de destino"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "No se pudo completar"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Algo salió mal al llegar a tu instancia de Momento."
|
||||
"message": "Algo salió mal al llegar a tu instancia de Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Rever"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "No se pudo guardar tu nota."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Problema de red: verifique su conexión y la URL de Momento."
|
||||
"message": "Problema de red: verifique su conexión y la URL de Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Resalte el texto de la página o recorte toda la página."
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Web Clipper"
|
||||
"message": "Memento Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "صفحات وب و متن هایلایت شده را در نوت بوک های Momento خود ضبط کنید — به سرور Momento خودتان متصل می شود."
|
||||
"message": "صفحات وب و متن هایلایت شده را در نوت بوک های Memento خود ضبط کنید — به سرور Memento خودتان متصل می شود."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "کلیپ به لحظه"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "آدرس لحظه ای"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "URL نمونه Momento شما"
|
||||
"message": "URL نمونه Memento شما"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "پیش تنظیم تولید · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "درخواست کنید و دوباره وصل شوید"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Momento را باز کنید"
|
||||
"message": "Memento را باز کنید"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "URL HTTPS (یا LAN) سرور Momento خود را جایگذاری کنید. کوکیهای این مرورگر ورود به سیستم را کنترل میکنند."
|
||||
"message": "URL HTTPS (یا LAN) سرور Memento خود را جایگذاری کنید. کوکیهای این مرورگر ورود به سیستم را کنترل میکنند."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper 0.3.1"
|
||||
"message": "Memento Web Clipper 0.3.1"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento نمی تواند به این برگه دسترسی پیدا کند. مجوزهای افزونه صفحه کلید/سایت را بررسی کنید - یا پانل جانبی را باز کنید."
|
||||
"message": "Memento نمی تواند به این برگه دسترسی پیدا کند. مجوزهای افزونه صفحه کلید/سایت را بررسی کنید - یا پانل جانبی را باز کنید."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "دفترچه بدون عنوان"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "در اینجا نمی توان کلیپ کرد - این صفحه دسترسی برنامه های افزودنی را مسدود می کند."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "لطفاً ابتدا با این مرورگر وارد Momento شوید."
|
||||
"message": "لطفاً ابتدا با این مرورگر وارد Memento شوید."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "نوتبوکها بارگیری نشد. سعی کنید دوباره وصل شوید."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "صفحه محدود - از طریق نوار ابزار Momento یا پانل جانبی کلیپ کنید."
|
||||
"message": "صفحه محدود - از طریق نوار ابزار Memento یا پانل جانبی کلیپ کنید."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "دفترچه یادداشت مقصد"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "گزیده"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "ذخیره در Momento"
|
||||
"message": "ذخیره در Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "برگشت"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "مشاهده در Momento"
|
||||
"message": "مشاهده در Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "یک صفحه دیگر را کلیپ کنید"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "تکمیل نشد"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "هنگام رسیدن به نمونه Momento شما مشکلی پیش آمد."
|
||||
"message": "هنگام رسیدن به نمونه Memento شما مشکلی پیش آمد."
|
||||
},
|
||||
"retry": {
|
||||
"message": "دوباره امتحان کنید"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "یادداشت شما ذخیره نشد."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "مشکل شبکه - اتصال و URL Momento خود را بررسی کنید."
|
||||
"message": "مشکل شبکه - اتصال و URL Memento خود را بررسی کنید."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "متن را در صفحه برجسته کنید یا کل صفحه را برش دهید."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento · Web Clipper"
|
||||
"message": "Memento · Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Enregistrez des pages web et du texte surligné dans vos carnets Momento — connecté à votre propre serveur Momento."
|
||||
"message": "Enregistrez des pages web et du texte surligné dans vos carnets Memento — connecté à votre propre serveur Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clipper vers Momento"
|
||||
"message": "Clipper vers Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Web Clipper"
|
||||
@@ -18,10 +18,10 @@
|
||||
"message": "Non connecté"
|
||||
},
|
||||
"instanceSettings": {
|
||||
"message": "Adresse Momento"
|
||||
"message": "Adresse Memento"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "URL de votre instance Momento"
|
||||
"message": "URL de votre instance Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Préréglage production · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Appliquer et reconnecter"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Ouvrir Momento"
|
||||
"message": "Ouvrir Memento"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Collez l'URL HTTPS (ou LAN) de votre serveur Momento. Les cookies de ce navigateur gèrent la connexion."
|
||||
"message": "Collez l'URL HTTPS (ou LAN) de votre serveur Memento. Les cookies de ce navigateur gèrent la connexion."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper 0.3.1"
|
||||
"message": "Memento Web Clipper 0.3.1"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento ne peut pas accéder à cet onglet. Vérifiez les autorisations du clavier/extension de site – ou ouvrez le panneau latéral."
|
||||
"message": "Memento ne peut pas accéder à cet onglet. Vérifiez les autorisations du clavier/extension de site – ou ouvrez le panneau latéral."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Carnet sans titre"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Impossible de clipper ici — cette page bloque l'accès aux extensions."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Veuillez d'abord vous connecter à Momento dans ce navigateur."
|
||||
"message": "Veuillez d'abord vous connecter à Memento dans ce navigateur."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Impossible de charger les carnets. Essayez de vous reconnecter."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Page restreinte : clip via la barre d'outils Momento ou le panneau latéral."
|
||||
"message": "Page restreinte : clip via la barre d'outils Memento ou le panneau latéral."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Carnet de destination"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Extrait"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Enregistrer dans Momento"
|
||||
"message": "Enregistrer dans Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Retour"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Voir dans Momento"
|
||||
"message": "Voir dans Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Clipper une autre page"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Impossible de terminer"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Une erreur est survenue lors de la communication avec votre instance Momento."
|
||||
"message": "Une erreur est survenue lors de la communication avec votre instance Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Réessayer"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Impossible d'enregistrer votre note."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Problème de réseau : vérifiez votre connexion et l'URL Momento."
|
||||
"message": "Problème de réseau : vérifiez votre connexion et l'URL Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Surlignez le texte à clipper"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Web Clipper"
|
||||
"message": "Memento Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Cattura pagine web e testo evidenziato nei tuoi taccuini Momento: si connette al tuo server Momento."
|
||||
"message": "Cattura pagine web e testo evidenziato nei tuoi taccuini Memento: si connette al tuo server Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip su Momento"
|
||||
"message": "Clip su Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Tagliatore di fotoricettore"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "URL del momento"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "L'URL dell'istanza di Momento"
|
||||
"message": "L'URL dell'istanza di Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Preimpostazione di produzione · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Applicare e riconnettersi"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Momento aperto"
|
||||
"message": "Memento aperto"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Incolla l'URL HTTPS (o LAN) del tuo server Momento. I cookie in questo browser gestiscono l'accesso."
|
||||
"message": "Incolla l'URL HTTPS (o LAN) del tuo server Memento. I cookie in questo browser gestiscono l'accesso."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<VERSIONE>>>"
|
||||
"message": "Memento Web Clipper <<<VERSIONE>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento non può accedere a questa scheda. Controlla le autorizzazioni per tastiera/estensione del sito oppure apri il pannello laterale."
|
||||
"message": "Memento non può accedere a questa scheda. Controlla le autorizzazioni per tastiera/estensione del sito oppure apri il pannello laterale."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Taccuino senza titolo"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Impossibile ritagliare qui: questa pagina blocca l'accesso all'estensione."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Accedi prima a Momento in questo browser."
|
||||
"message": "Accedi prima a Memento in questo browser."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Impossibile caricare i taccuini. Prova a riconnetterti."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Pagina limitata: ritaglia tramite la barra degli strumenti Momento o il pannello laterale."
|
||||
"message": "Pagina limitata: ritaglia tramite la barra degli strumenti Memento o il pannello laterale."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Taccuino di destinazione"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Estratto"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Salva su Momento"
|
||||
"message": "Salva su Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Indietro"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Visualizza in Momento"
|
||||
"message": "Visualizza in Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Ritaglia un'altra pagina"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Impossibile completare"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Qualcosa è andato storto nel raggiungere la tua istanza Momento."
|
||||
"message": "Qualcosa è andato storto nel raggiungere la tua istanza Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Riprova"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Impossibile salvare la nota."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Problema di rete: controlla la connessione e l'URL Momento."
|
||||
"message": "Problema di rete: controlla la connessione e l'URL Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Evidenzia il testo sulla pagina o ritaglia l'intera pagina."
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"message": "モーメントウェブクリッパー"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Web ページとハイライトされたテキストを Momento ノートブックにキャプチャします。独自の Momento サーバーに接続します。"
|
||||
"message": "Web ページとハイライトされたテキストを Memento ノートブックにキャプチャします。独自の Memento サーバーに接続します。"
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "モーメントにクリップ"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "モーメントのURL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "Momento インスタンスの URL"
|
||||
"message": "Memento インスタンスの URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "プロダクションプリセット・memento-note.com"
|
||||
@@ -33,13 +33,13 @@
|
||||
"message": "モーメントを開く"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Momento サーバーの HTTPS (または LAN) URL を貼り付けます。このブラウザの Cookie がサインインを処理します。"
|
||||
"message": "Memento サーバーの HTTPS (または LAN) URL を貼り付けます。このブラウザの Cookie がサインインを処理します。"
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web クリッパー <<<バージョン>>>"
|
||||
"message": "Memento Web クリッパー <<<バージョン>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento はこのタブにアクセスできません。キーボード/サイト拡張機能の権限を確認するか、サイド パネルを開きます。"
|
||||
"message": "Memento はこのタブにアクセスできません。キーボード/サイト拡張機能の権限を確認するか、サイド パネルを開きます。"
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "無題のノート"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "ここではクリップできません — このページは拡張機能へのアクセスをブロックしています。"
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "まずこのブラウザで Momento にサインインしてください。"
|
||||
"message": "まずこのブラウザで Memento にサインインしてください。"
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "ノートブックをロードできませんでした。再接続してみてください。"
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "制限されたページ — Momento ツールバーまたはサイド パネルを介してクリップします。"
|
||||
"message": "制限されたページ — Memento ツールバーまたはサイド パネルを介してクリップします。"
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "宛先ノートブック"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "完了できませんでした"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Momento インスタンスに到達する際に問題が発生しました。"
|
||||
"message": "Memento インスタンスに到達する際に問題が発生しました。"
|
||||
},
|
||||
"retry": {
|
||||
"message": "リトライ"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "メモを保存できませんでした。"
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "ネットワークの問題 — 接続と Momento URL を確認してください。"
|
||||
"message": "ネットワークの問題 — 接続と Memento URL を確認してください。"
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "ページ上のテキストを強調表示するか、ページ全体をクリップします。"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"message": "모멘토 웹 클리퍼"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "웹 페이지와 강조 표시된 텍스트를 Momento 노트북에 캡처하여 자체 Momento 서버에 연결합니다."
|
||||
"message": "웹 페이지와 강조 표시된 텍스트를 Memento 노트북에 캡처하여 자체 Memento 서버에 연결합니다."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "순간에 클립"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "모멘토 URL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "귀하의 Momento 인스턴스 URL"
|
||||
"message": "귀하의 Memento 인스턴스 URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "프로덕션 프리셋 · memento-note.com"
|
||||
@@ -33,13 +33,13 @@
|
||||
"message": "모멘토 열기"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Momento 서버의 HTTPS(또는 LAN) URL을 붙여넣습니다. 이 브라우저의 쿠키는 로그인을 처리합니다."
|
||||
"message": "Memento 서버의 HTTPS(또는 LAN) URL을 붙여넣습니다. 이 브라우저의 쿠키는 로그인을 처리합니다."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<버전>>>"
|
||||
"message": "Memento Web Clipper <<<버전>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento는 이 탭에 접근할 수 없습니다. 키보드/사이트 확장 권한을 확인하거나 측면 패널을 엽니다."
|
||||
"message": "Memento는 이 탭에 접근할 수 없습니다. 키보드/사이트 확장 권한을 확인하거나 측면 패널을 엽니다."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "제목 없는 노트"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "여기서 클립할 수 없습니다. 이 페이지는 확장 프로그램 액세스를 차단합니다."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "먼저 이 브라우저에서 Momento에 로그인하세요."
|
||||
"message": "먼저 이 브라우저에서 Memento에 로그인하세요."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "노트북을 로드할 수 없습니다. 다시 연결해 보세요."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "제한된 페이지 — Momento 도구 모음 또는 측면 패널을 통해 클립합니다."
|
||||
"message": "제한된 페이지 — Memento 도구 모음 또는 측면 패널을 통해 클립합니다."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "대상 노트북"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Momento에서 보기"
|
||||
"message": "Memento에서 보기"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "다른 페이지 자르기"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "완료할 수 없습니다."
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Momento 인스턴스에 연결하는 데 문제가 발생했습니다."
|
||||
"message": "Memento 인스턴스에 연결하는 데 문제가 발생했습니다."
|
||||
},
|
||||
"retry": {
|
||||
"message": "다시 해 보다"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "메모를 저장할 수 없습니다."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "네트워크 문제 - 연결 및 Momento URL을 확인하세요."
|
||||
"message": "네트워크 문제 - 연결 및 Memento URL을 확인하세요."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "페이지의 텍스트를 강조 표시하거나 전체 페이지를 자릅니다."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Webclipper"
|
||||
"message": "Memento Webclipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Leg webpagina's en gemarkeerde tekst vast in uw Momento-notebooks - maakt verbinding met uw eigen Momento-server."
|
||||
"message": "Leg webpagina's en gemarkeerde tekst vast in uw Memento-notebooks - maakt verbinding met uw eigen Memento-server."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip naar Momento"
|
||||
"message": "Clip naar Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Webclipper"
|
||||
@@ -18,10 +18,10 @@
|
||||
"message": "Niet verbonden"
|
||||
},
|
||||
"instanceSettings": {
|
||||
"message": "Momento-URL"
|
||||
"message": "Memento-URL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "Uw Momento-instantie-URL"
|
||||
"message": "Uw Memento-instantie-URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Productievoorinstelling · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Toepassen en opnieuw verbinden"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Momento openen"
|
||||
"message": "Memento openen"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Plak de HTTPS (of LAN) URL van uw Momento-server. Cookies in deze browser zorgen voor het inloggen."
|
||||
"message": "Plak de HTTPS (of LAN) URL van uw Memento-server. Cookies in deze browser zorgen voor het inloggen."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<VERSIE>>>"
|
||||
"message": "Memento Web Clipper <<<VERSIE>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento heeft geen toegang tot dit tabblad. Controleer de rechten voor toetsenbord-/site-extensies — of open het zijpaneel."
|
||||
"message": "Memento heeft geen toegang tot dit tabblad. Controleer de rechten voor toetsenbord-/site-extensies — of open het zijpaneel."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Naamloos notitieboekje"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Kan hier niet knippen: deze pagina blokkeert de toegang tot extensies."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Meld u eerst aan bij Momento in deze browser."
|
||||
"message": "Meld u eerst aan bij Memento in deze browser."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Kan notitieboekjes niet laden. Probeer opnieuw verbinding te maken."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Beperkte pagina — clip via de Momento-werkbalk of het zijpaneel."
|
||||
"message": "Beperkte pagina — clip via de Memento-werkbalk of het zijpaneel."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Bestemmingsnotitieboekje"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Uittreksel"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Opslaan in Momento"
|
||||
"message": "Opslaan in Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Rug"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Bekijk in Momento"
|
||||
"message": "Bekijk in Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Knip nog een pagina uit"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Kon niet voltooien"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Er is iets misgegaan bij het bereiken van uw Momento-instantie."
|
||||
"message": "Er is iets misgegaan bij het bereiken van uw Memento-instantie."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Opnieuw proberen"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Kan uw notitie niet opslaan."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Netwerkprobleem: controleer uw verbinding en Momento-URL."
|
||||
"message": "Netwerkprobleem: controleer uw verbinding en Memento-URL."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Markeer tekst op de pagina of knip de hele pagina uit."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Narzędzie do strzyżenia sieci Momento"
|
||||
"message": "Narzędzie do strzyżenia sieci Memento"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Przechwytuj strony internetowe i zaznaczony tekst do swoich notatników Momento — łączy się z Twoim własnym serwerem Momento."
|
||||
"message": "Przechwytuj strony internetowe i zaznaczony tekst do swoich notatników Memento — łączy się z Twoim własnym serwerem Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Klip do Momento"
|
||||
"message": "Klip do Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Obcinacz sieci"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "Adres URL chwili"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "Adres URL Twojej instancji Momento"
|
||||
"message": "Adres URL Twojej instancji Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Wstępne ustawienia produkcyjne · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Zastosuj i połącz ponownie"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Otwórz Momento"
|
||||
"message": "Otwórz Memento"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Wklej adres URL HTTPS (lub LAN) swojego serwera Momento. Pliki cookie w tej przeglądarce obsługują logowanie."
|
||||
"message": "Wklej adres URL HTTPS (lub LAN) swojego serwera Memento. Pliki cookie w tej przeglądarce obsługują logowanie."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<WERSJA>>>"
|
||||
"message": "Memento Web Clipper <<<WERSJA>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento nie ma dostępu do tej karty. Sprawdź uprawnienia rozszerzenia klawiatury/witryny — lub otwórz Panel boczny."
|
||||
"message": "Memento nie ma dostępu do tej karty. Sprawdź uprawnienia rozszerzenia klawiatury/witryny — lub otwórz Panel boczny."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Notatnik bez tytułu"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Nie można tutaj przyciąć — ta strona blokuje dostęp do rozszerzenia."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Najpierw zaloguj się do Momento w tej przeglądarce."
|
||||
"message": "Najpierw zaloguj się do Memento w tej przeglądarce."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Nie udało się załadować notatników. Spróbuj połączyć się ponownie."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Strona z ograniczeniami — klip za pomocą paska narzędzi Momento lub panelu bocznego."
|
||||
"message": "Strona z ograniczeniami — klip za pomocą paska narzędzi Memento lub panelu bocznego."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Notatnik docelowy"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Fragment"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Zapisz w Momento"
|
||||
"message": "Zapisz w Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Z powrotem"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Zobacz w Momento"
|
||||
"message": "Zobacz w Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Wytnij kolejną stronę"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Nie udało się ukończyć"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Coś poszło nie tak podczas docierania do Twojej instancji Momento."
|
||||
"message": "Coś poszło nie tak podczas docierania do Twojej instancji Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Spróbować ponownie"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Nie można zapisać notatki."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Problem z siecią — sprawdź swoje połączenie i adres URL Momento."
|
||||
"message": "Problem z siecią — sprawdź swoje połączenie i adres URL Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Zaznacz tekst na stronie lub przytnij całą stronę."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Web Clipper"
|
||||
"message": "Memento Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Capture páginas da web e texto destacado em seus blocos de anotações Momento – conecte-se ao seu próprio servidor Momento."
|
||||
"message": "Capture páginas da web e texto destacado em seus blocos de anotações Memento – conecte-se ao seu próprio servidor Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clipe para Momento"
|
||||
"message": "Clipe para Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Clipper da Web"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "URL do momento"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "URL da sua instância do Momento"
|
||||
"message": "URL da sua instância do Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Predefinição de produção · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Aplicar e reconectar"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Momento aberto"
|
||||
"message": "Memento aberto"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Cole o URL HTTPS (ou LAN) do seu servidor Momento. Os cookies neste navegador controlam o login."
|
||||
"message": "Cole o URL HTTPS (ou LAN) do seu servidor Memento. Os cookies neste navegador controlam o login."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<VERSÃO>>>"
|
||||
"message": "Memento Web Clipper <<<VERSÃO>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento não pode acessar esta guia. Verifique as permissões de extensão de teclado/site – ou abra o painel lateral."
|
||||
"message": "Memento não pode acessar esta guia. Verifique as permissões de extensão de teclado/site – ou abra o painel lateral."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Caderno sem título"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Não é possível recortar aqui – esta página bloqueia o acesso à extensão."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Faça login no Momento neste navegador primeiro."
|
||||
"message": "Faça login no Memento neste navegador primeiro."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Não foi possível carregar os notebooks. Tente reconectar."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Página restrita – recorte por meio da barra de ferramentas do Momento ou do painel lateral."
|
||||
"message": "Página restrita – recorte por meio da barra de ferramentas do Memento ou do painel lateral."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Caderno de destino"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Trecho"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Salvar no Momento"
|
||||
"message": "Salvar no Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Voltar"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Ver em Momento"
|
||||
"message": "Ver em Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Recortar outra página"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Não foi possível concluir"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Algo deu errado ao chegar à sua instância do Momento."
|
||||
"message": "Algo deu errado ao chegar à sua instância do Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Tentar novamente"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Não foi possível salvar sua nota."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Problema de rede – verifique sua conexão e URL do Momento."
|
||||
"message": "Problema de rede – verifique sua conexão e URL do Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Destaque o texto na página ou recorte a página inteira."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Веб-клипер Momento"
|
||||
"message": "Веб-клипер Memento"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Сохраняйте веб-страницы и выделенный текст в свои блокноты Momento — подключайтесь к вашему собственному серверу Momento."
|
||||
"message": "Сохраняйте веб-страницы и выделенный текст в свои блокноты Memento — подключайтесь к вашему собственному серверу Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Клип на Momento"
|
||||
"message": "Клип на Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Веб-клипер"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "URL-адрес момента"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "URL-адрес вашего экземпляра Momento"
|
||||
"message": "URL-адрес вашего экземпляра Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Настройки производства · memento-note.com"
|
||||
@@ -33,13 +33,13 @@
|
||||
"message": "Открыть Моменто"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Вставьте URL-адрес HTTPS (или LAN) вашего сервера Momento. Файлы cookie в этом браузере обрабатывают вход в систему."
|
||||
"message": "Вставьте URL-адрес HTTPS (или LAN) вашего сервера Memento. Файлы cookie в этом браузере обрабатывают вход в систему."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<ВЕРСИЯ>>>"
|
||||
"message": "Memento Web Clipper <<<ВЕРСИЯ>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento не имеет доступа к этой вкладке. Проверьте разрешения для расширения клавиатуры/сайта или откройте боковую панель."
|
||||
"message": "Memento не имеет доступа к этой вкладке. Проверьте разрешения для расширения клавиатуры/сайта или откройте боковую панель."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Блокнот без названия"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Невозможно обрезать здесь — эта страница блокирует доступ к расширению."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Сначала войдите в Momento в этом браузере."
|
||||
"message": "Сначала войдите в Memento в этом браузере."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Не удалось загрузить блокноты. Попробуйте переподключиться."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Страница с ограниченным доступом — вырезайте с помощью панели инструментов Momento или боковой панели."
|
||||
"message": "Страница с ограниченным доступом — вырезайте с помощью панели инструментов Memento или боковой панели."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Блокнот назначения"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Не удалось завершить"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Что-то пошло не так при получении вашего экземпляра Momento."
|
||||
"message": "Что-то пошло не так при получении вашего экземпляра Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Повторить попытку"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Не удалось сохранить заметку."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Проблема с сетью. Проверьте подключение и URL-адрес Momento."
|
||||
"message": "Проблема с сетью. Проверьте подключение и URL-адрес Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Выделите текст на странице или вырежьте всю страницу."
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento 网页剪辑器"
|
||||
"message": "Memento 网页剪辑器"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "将网页和突出显示的文本捕获到您的 Momento 笔记本中 — 连接到您自己的 Momento 服务器。"
|
||||
"message": "将网页和突出显示的文本捕获到您的 Memento 笔记本中 — 连接到您自己的 Memento 服务器。"
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "剪辑到时刻"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "时刻网址"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "您的 Momento 实例 URL"
|
||||
"message": "您的 Memento 实例 URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "制作预设 · memento-note.com"
|
||||
@@ -33,13 +33,13 @@
|
||||
"message": "打开时刻"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "粘贴 Momento 服务器的 HTTPS(或 LAN)URL。此浏览器中的 Cookie 处理登录。"
|
||||
"message": "粘贴 Memento 服务器的 HTTPS(或 LAN)URL。此浏览器中的 Cookie 处理登录。"
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<版本>>>"
|
||||
"message": "Memento Web Clipper <<<版本>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento 无法访问此选项卡。检查键盘/站点扩展权限 - 或打开侧面板。"
|
||||
"message": "Memento 无法访问此选项卡。检查键盘/站点扩展权限 - 或打开侧面板。"
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "无标题笔记本"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "此处无法剪辑 — 此页面阻止扩展程序访问。"
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "请先在此浏览器中登录 Momento。"
|
||||
"message": "请先在此浏览器中登录 Memento。"
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "无法加载笔记本。尝试重新连接。"
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "受限页面 — 通过 Momento 工具栏或侧面板进行剪辑。"
|
||||
"message": "受限页面 — 通过 Memento 工具栏或侧面板进行剪辑。"
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "目的地笔记本"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "在 Momento 中查看"
|
||||
"message": "在 Memento 中查看"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "剪辑另一页"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "无法完成"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "您的 Momento 实例出现问题。"
|
||||
"message": "您的 Memento 实例出现问题。"
|
||||
},
|
||||
"retry": {
|
||||
"message": "重试"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "无法保存您的笔记。"
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "网络问题 — 检查您的连接和 Momento URL。"
|
||||
"message": "网络问题 — 检查您的连接和 Memento URL。"
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "突出显示页面上的文本,或剪辑整个页面。"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Content script Momento — sélection live, surlignage, communication avec le side panel.
|
||||
* Content script Memento — sélection live, surlignage, communication avec le side panel.
|
||||
* Injecté automatiquement sur http(s) ; ré-injecté à la demande si l’onglet était déjà ouvert.
|
||||
*/
|
||||
;(function initMementoClipperContent() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Script de diagnostic pour l'extension Momento
|
||||
* Script de diagnostic pour l'extension Memento
|
||||
* Vérifie tous les fichiers et identifie les problèmes potentiels
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'url'
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const extDir = __dirname
|
||||
|
||||
console.log('🔍 Diagnostic Extension Momento\n')
|
||||
console.log('🔍 Diagnostic Extension Memento\n')
|
||||
|
||||
const issues = []
|
||||
const warnings = []
|
||||
@@ -155,6 +155,6 @@ if (issues.length === 0 && warnings.length === 0) {
|
||||
console.log('\n📝 Instructions:')
|
||||
console.log('1. Rechargez l\'extension dans chrome://extensions (bouton 🔄)')
|
||||
console.log('2. Ouvrez une page web normale')
|
||||
console.log('3. Cliquez sur l\'icône Momento')
|
||||
console.log('3. Cliquez sur l\'icône Memento')
|
||||
console.log('4. Fermez le sidepanel - la bannière doit disparaître')
|
||||
console.log('5. Ouvrez la console (F12) - pas d\'erreur CSP')
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"message": "مومنتو ويب كليبر"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "التقط صفحات الويب والنص المميز في دفاتر ملاحظات Momento الخاصة بك - ويتصل بخادم Momento الخاص بك."
|
||||
"message": "التقط صفحات الويب والنص المميز في دفاتر ملاحظات Memento الخاصة بك - ويتصل بخادم Memento الخاص بك."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "مقطع إلى مومنتو"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "عنوان URL لمومنتو"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "عنوان URL لمثيل Momento الخاص بك"
|
||||
"message": "عنوان URL لمثيل Memento الخاص بك"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "إعداد مسبق للإنتاج · memento-note.com"
|
||||
@@ -33,13 +33,13 @@
|
||||
"message": "افتح مومنتو"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "الصق عنوان URL الخاص بـ HTTPS (أو LAN) لخادم Momento الخاص بك. تتعامل ملفات تعريف الارتباط الموجودة في هذا المتصفح مع تسجيل الدخول."
|
||||
"message": "الصق عنوان URL الخاص بـ HTTPS (أو LAN) لخادم Memento الخاص بك. تتعامل ملفات تعريف الارتباط الموجودة في هذا المتصفح مع تسجيل الدخول."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<الإصدار>>>"
|
||||
"message": "Memento Web Clipper <<<الإصدار>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "لا يستطيع Momento الوصول إلى علامة التبويب هذه. تحقق من أذونات ملحق لوحة المفاتيح/الموقع — أو افتح اللوحة الجانبية."
|
||||
"message": "لا يستطيع Memento الوصول إلى علامة التبويب هذه. تحقق من أذونات ملحق لوحة المفاتيح/الموقع — أو افتح اللوحة الجانبية."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "دفتر بلا عنوان"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "لا يمكن القص هنا — هذه الصفحة تحظر الوصول إلى الإضافات."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "يرجى تسجيل الدخول إلى Momento في هذا المتصفح أولاً."
|
||||
"message": "يرجى تسجيل الدخول إلى Memento في هذا المتصفح أولاً."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "تعذر تحميل دفاتر الملاحظات. حاول إعادة الاتصال."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "صفحة مقيدة - قم بالقص عبر شريط أدوات Momento أو اللوحة الجانبية."
|
||||
"message": "صفحة مقيدة - قم بالقص عبر شريط أدوات Memento أو اللوحة الجانبية."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "دفتر الوجهة"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "لا يمكن إكماله"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "حدث خطأ ما أثناء الوصول إلى مثيل Momento."
|
||||
"message": "حدث خطأ ما أثناء الوصول إلى مثيل Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "أعد المحاولة"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "لا يمكن حفظ ملاحظتك."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "مشكلة في الشبكة - تحقق من اتصالك وعنوان URL الخاص بـ Momento."
|
||||
"message": "مشكلة في الشبكة - تحقق من اتصالك وعنوان URL الخاص بـ Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "قم بتمييز النص الموجود على الصفحة، أو قم بقص الصفحة بأكملها."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Web Clipper"
|
||||
"message": "Memento Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Erfassen Sie Webseiten und hervorgehobenen Text in Ihren Momento-Notizbüchern – stellt eine Verbindung zu Ihrem eigenen Momento-Server her."
|
||||
"message": "Erfassen Sie Webseiten und hervorgehobenen Text in Ihren Memento-Notizbüchern – stellt eine Verbindung zu Ihrem eigenen Memento-Server her."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip auf Momento"
|
||||
"message": "Clip auf Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Web Clipper"
|
||||
@@ -18,10 +18,10 @@
|
||||
"message": "Nicht verbunden"
|
||||
},
|
||||
"instanceSettings": {
|
||||
"message": "Momento-URL"
|
||||
"message": "Memento-URL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "Ihre Momento-Instanz-URL"
|
||||
"message": "Ihre Memento-Instanz-URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Produktionsvoreinstellung · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Anwenden und erneut verbinden"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Öffnen Sie Momento"
|
||||
"message": "Öffnen Sie Memento"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Fügen Sie die HTTPS- (oder LAN-)URL Ihres Momento-Servers ein. Cookies in diesem Browser verarbeiten die Anmeldung."
|
||||
"message": "Fügen Sie die HTTPS- (oder LAN-)URL Ihres Memento-Servers ein. Cookies in diesem Browser verarbeiten die Anmeldung."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper 0.3.1"
|
||||
"message": "Memento Web Clipper 0.3.1"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento kann nicht auf diese Registerkarte zugreifen. Überprüfen Sie die Tastatur-/Site-Erweiterungsberechtigungen – oder öffnen Sie den Seitenbereich."
|
||||
"message": "Memento kann nicht auf diese Registerkarte zugreifen. Überprüfen Sie die Tastatur-/Site-Erweiterungsberechtigungen – oder öffnen Sie den Seitenbereich."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Notizbuch ohne Titel"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Hier kann kein Clip erstellt werden – diese Seite blockiert den Zugriff auf die Erweiterung."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Bitte melden Sie sich zunächst in diesem Browser bei Momento an."
|
||||
"message": "Bitte melden Sie sich zunächst in diesem Browser bei Memento an."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Notebooks konnten nicht geladen werden. Versuchen Sie, die Verbindung wiederherzustellen."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Eingeschränkte Seite – Ausschneiden über die Momento-Symbolleiste oder den Seitenbereich."
|
||||
"message": "Eingeschränkte Seite – Ausschneiden über die Memento-Symbolleiste oder den Seitenbereich."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Zielnotizbuch"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Auszug"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "In Momento speichern"
|
||||
"message": "In Memento speichern"
|
||||
},
|
||||
"back": {
|
||||
"message": "Zurück"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "In Momento ansehen"
|
||||
"message": "In Memento ansehen"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Schneiden Sie eine weitere Seite aus"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Konnte nicht abgeschlossen werden"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Beim Erreichen Ihrer Momento-Instanz ist ein Fehler aufgetreten."
|
||||
"message": "Beim Erreichen Ihrer Memento-Instanz ist ein Fehler aufgetreten."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Wiederholen"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Ihre Notiz konnte nicht gespeichert werden."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Netzwerkproblem – überprüfen Sie Ihre Verbindung und Momento-URL."
|
||||
"message": "Netzwerkproblem – überprüfen Sie Ihre Verbindung und Memento-URL."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Markieren Sie Text auf der Seite oder schneiden Sie die gesamte Seite aus."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Web Clipper"
|
||||
"message": "Memento Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Capture web pages and highlighted text into your Momento notebooks — connects to your own Momento server."
|
||||
"message": "Capture web pages and highlighted text into your Memento notebooks — connects to your own Memento server."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip to Momento"
|
||||
"message": "Clip to Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Web Clipper"
|
||||
@@ -18,10 +18,10 @@
|
||||
"message": "Not connected"
|
||||
},
|
||||
"instanceSettings": {
|
||||
"message": "Momento URL"
|
||||
"message": "Memento URL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "Your Momento instance URL"
|
||||
"message": "Your Memento instance URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Production preset · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Apply and reconnect"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Open Momento"
|
||||
"message": "Open Memento"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Paste the HTTPS (or LAN) URL of your Momento server. Cookies in this browser handle sign-in."
|
||||
"message": "Paste the HTTPS (or LAN) URL of your Memento server. Cookies in this browser handle sign-in."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper 0.3.1"
|
||||
"message": "Memento Web Clipper 0.3.1"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento can't access this tab. Check keyboard/site extension permissions — or open the Side Panel."
|
||||
"message": "Memento can't access this tab. Check keyboard/site extension permissions — or open the Side Panel."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Untitled notebook"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Can't clip here — this page blocks extension access."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Please sign in to Momento in this browser first."
|
||||
"message": "Please sign in to Memento in this browser first."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Could not load notebooks. Try reconnecting."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Restricted page — clip via the Momento toolbar or Side Panel."
|
||||
"message": "Restricted page — clip via the Memento toolbar or Side Panel."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Destination notebook"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Excerpt"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Save to Momento"
|
||||
"message": "Save to Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Back"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "View in Momento"
|
||||
"message": "View in Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Clip another page"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Could not complete"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Something went wrong reaching your Momento instance."
|
||||
"message": "Something went wrong reaching your Memento instance."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Retry"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Could not save your note."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Network issue — check your connection and Momento URL."
|
||||
"message": "Network issue — check your connection and Memento URL."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Highlight text on the page, or clip the whole page."
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Cortadora web Momento"
|
||||
"message": "Cortadora web Memento"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Capture páginas web y texto resaltado en sus cuadernos Momento: se conecta a su propio servidor Momento."
|
||||
"message": "Capture páginas web y texto resaltado en sus cuadernos Memento: se conecta a su propio servidor Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip al momento"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "URL del momento"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "La URL de tu instancia de Momento"
|
||||
"message": "La URL de tu instancia de Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Preajuste de producción · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Aplicar y reconectar"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Momento abierto"
|
||||
"message": "Memento abierto"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Pegue la URL HTTPS (o LAN) de su servidor Momento. Las cookies en este navegador controlan el inicio de sesión."
|
||||
"message": "Pegue la URL HTTPS (o LAN) de su servidor Memento. Las cookies en este navegador controlan el inicio de sesión."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<VERSIÓN>>>"
|
||||
"message": "Memento Web Clipper <<<VERSIÓN>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento no puede acceder a esta pestaña. Verifique los permisos de extensión del sitio/teclado o abra el Panel lateral."
|
||||
"message": "Memento no puede acceder a esta pestaña. Verifique los permisos de extensión del sitio/teclado o abra el Panel lateral."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Cuaderno sin título"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "No se puede recortar aquí: esta página bloquea el acceso a la extensión."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Primero inicie sesión en Momento en este navegador."
|
||||
"message": "Primero inicie sesión en Memento en este navegador."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "No se pudieron cargar los cuadernos. Intente volver a conectarse."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Página restringida: recorte mediante la barra de herramientas de Momento o el panel lateral."
|
||||
"message": "Página restringida: recorte mediante la barra de herramientas de Memento o el panel lateral."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Cuaderno de destino"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "No se pudo completar"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Algo salió mal al llegar a tu instancia de Momento."
|
||||
"message": "Algo salió mal al llegar a tu instancia de Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Rever"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "No se pudo guardar tu nota."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Problema de red: verifique su conexión y la URL de Momento."
|
||||
"message": "Problema de red: verifique su conexión y la URL de Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Resalte el texto de la página o recorte toda la página."
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Web Clipper"
|
||||
"message": "Memento Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "صفحات وب و متن هایلایت شده را در نوت بوک های Momento خود ضبط کنید — به سرور Momento خودتان متصل می شود."
|
||||
"message": "صفحات وب و متن هایلایت شده را در نوت بوک های Memento خود ضبط کنید — به سرور Memento خودتان متصل می شود."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "کلیپ به لحظه"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "آدرس لحظه ای"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "URL نمونه Momento شما"
|
||||
"message": "URL نمونه Memento شما"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "پیش تنظیم تولید · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "درخواست کنید و دوباره وصل شوید"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Momento را باز کنید"
|
||||
"message": "Memento را باز کنید"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "URL HTTPS (یا LAN) سرور Momento خود را جایگذاری کنید. کوکیهای این مرورگر ورود به سیستم را کنترل میکنند."
|
||||
"message": "URL HTTPS (یا LAN) سرور Memento خود را جایگذاری کنید. کوکیهای این مرورگر ورود به سیستم را کنترل میکنند."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper 0.3.1"
|
||||
"message": "Memento Web Clipper 0.3.1"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento نمی تواند به این برگه دسترسی پیدا کند. مجوزهای افزونه صفحه کلید/سایت را بررسی کنید - یا پانل جانبی را باز کنید."
|
||||
"message": "Memento نمی تواند به این برگه دسترسی پیدا کند. مجوزهای افزونه صفحه کلید/سایت را بررسی کنید - یا پانل جانبی را باز کنید."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "دفترچه بدون عنوان"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "در اینجا نمی توان کلیپ کرد - این صفحه دسترسی برنامه های افزودنی را مسدود می کند."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "لطفاً ابتدا با این مرورگر وارد Momento شوید."
|
||||
"message": "لطفاً ابتدا با این مرورگر وارد Memento شوید."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "نوتبوکها بارگیری نشد. سعی کنید دوباره وصل شوید."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "صفحه محدود - از طریق نوار ابزار Momento یا پانل جانبی کلیپ کنید."
|
||||
"message": "صفحه محدود - از طریق نوار ابزار Memento یا پانل جانبی کلیپ کنید."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "دفترچه یادداشت مقصد"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "گزیده"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "ذخیره در Momento"
|
||||
"message": "ذخیره در Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "برگشت"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "مشاهده در Momento"
|
||||
"message": "مشاهده در Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "یک صفحه دیگر را کلیپ کنید"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "تکمیل نشد"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "هنگام رسیدن به نمونه Momento شما مشکلی پیش آمد."
|
||||
"message": "هنگام رسیدن به نمونه Memento شما مشکلی پیش آمد."
|
||||
},
|
||||
"retry": {
|
||||
"message": "دوباره امتحان کنید"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "یادداشت شما ذخیره نشد."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "مشکل شبکه - اتصال و URL Momento خود را بررسی کنید."
|
||||
"message": "مشکل شبکه - اتصال و URL Memento خود را بررسی کنید."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "متن را در صفحه برجسته کنید یا کل صفحه را برش دهید."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento · Web Clipper"
|
||||
"message": "Memento · Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Enregistrez des pages web et du texte surligné dans vos carnets Momento — connecté à votre propre serveur Momento."
|
||||
"message": "Enregistrez des pages web et du texte surligné dans vos carnets Memento — connecté à votre propre serveur Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clipper vers Momento"
|
||||
"message": "Clipper vers Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Web Clipper"
|
||||
@@ -18,10 +18,10 @@
|
||||
"message": "Non connecté"
|
||||
},
|
||||
"instanceSettings": {
|
||||
"message": "Adresse Momento"
|
||||
"message": "Adresse Memento"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "URL de votre instance Momento"
|
||||
"message": "URL de votre instance Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Préréglage production · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Appliquer et reconnecter"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Ouvrir Momento"
|
||||
"message": "Ouvrir Memento"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Collez l'URL HTTPS (ou LAN) de votre serveur Momento. Les cookies de ce navigateur gèrent la connexion."
|
||||
"message": "Collez l'URL HTTPS (ou LAN) de votre serveur Memento. Les cookies de ce navigateur gèrent la connexion."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper 0.3.1"
|
||||
"message": "Memento Web Clipper 0.3.1"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento ne peut pas accéder à cet onglet. Vérifiez les autorisations du clavier/extension de site – ou ouvrez le panneau latéral."
|
||||
"message": "Memento ne peut pas accéder à cet onglet. Vérifiez les autorisations du clavier/extension de site – ou ouvrez le panneau latéral."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Carnet sans titre"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Impossible de clipper ici — cette page bloque l'accès aux extensions."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Veuillez d'abord vous connecter à Momento dans ce navigateur."
|
||||
"message": "Veuillez d'abord vous connecter à Memento dans ce navigateur."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Impossible de charger les carnets. Essayez de vous reconnecter."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Page restreinte : clip via la barre d'outils Momento ou le panneau latéral."
|
||||
"message": "Page restreinte : clip via la barre d'outils Memento ou le panneau latéral."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Carnet de destination"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Extrait"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Enregistrer dans Momento"
|
||||
"message": "Enregistrer dans Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Retour"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Voir dans Momento"
|
||||
"message": "Voir dans Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Clipper une autre page"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Impossible de terminer"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Une erreur est survenue lors de la communication avec votre instance Momento."
|
||||
"message": "Une erreur est survenue lors de la communication avec votre instance Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Réessayer"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Impossible d'enregistrer votre note."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Problème de réseau : vérifiez votre connexion et l'URL Momento."
|
||||
"message": "Problème de réseau : vérifiez votre connexion et l'URL Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Surlignez le texte à clipper"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Web Clipper"
|
||||
"message": "Memento Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Cattura pagine web e testo evidenziato nei tuoi taccuini Momento: si connette al tuo server Momento."
|
||||
"message": "Cattura pagine web e testo evidenziato nei tuoi taccuini Memento: si connette al tuo server Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip su Momento"
|
||||
"message": "Clip su Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Tagliatore di fotoricettore"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "URL del momento"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "L'URL dell'istanza di Momento"
|
||||
"message": "L'URL dell'istanza di Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Preimpostazione di produzione · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Applicare e riconnettersi"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Momento aperto"
|
||||
"message": "Memento aperto"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Incolla l'URL HTTPS (o LAN) del tuo server Momento. I cookie in questo browser gestiscono l'accesso."
|
||||
"message": "Incolla l'URL HTTPS (o LAN) del tuo server Memento. I cookie in questo browser gestiscono l'accesso."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<VERSIONE>>>"
|
||||
"message": "Memento Web Clipper <<<VERSIONE>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento non può accedere a questa scheda. Controlla le autorizzazioni per tastiera/estensione del sito oppure apri il pannello laterale."
|
||||
"message": "Memento non può accedere a questa scheda. Controlla le autorizzazioni per tastiera/estensione del sito oppure apri il pannello laterale."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Taccuino senza titolo"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Impossibile ritagliare qui: questa pagina blocca l'accesso all'estensione."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Accedi prima a Momento in questo browser."
|
||||
"message": "Accedi prima a Memento in questo browser."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Impossibile caricare i taccuini. Prova a riconnetterti."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Pagina limitata: ritaglia tramite la barra degli strumenti Momento o il pannello laterale."
|
||||
"message": "Pagina limitata: ritaglia tramite la barra degli strumenti Memento o il pannello laterale."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Taccuino di destinazione"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Estratto"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Salva su Momento"
|
||||
"message": "Salva su Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Indietro"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Visualizza in Momento"
|
||||
"message": "Visualizza in Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Ritaglia un'altra pagina"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Impossibile completare"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Qualcosa è andato storto nel raggiungere la tua istanza Momento."
|
||||
"message": "Qualcosa è andato storto nel raggiungere la tua istanza Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Riprova"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Impossibile salvare la nota."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Problema di rete: controlla la connessione e l'URL Momento."
|
||||
"message": "Problema di rete: controlla la connessione e l'URL Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Evidenzia il testo sulla pagina o ritaglia l'intera pagina."
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"message": "モーメントウェブクリッパー"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Web ページとハイライトされたテキストを Momento ノートブックにキャプチャします。独自の Momento サーバーに接続します。"
|
||||
"message": "Web ページとハイライトされたテキストを Memento ノートブックにキャプチャします。独自の Memento サーバーに接続します。"
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "モーメントにクリップ"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "モーメントのURL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "Momento インスタンスの URL"
|
||||
"message": "Memento インスタンスの URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "プロダクションプリセット・memento-note.com"
|
||||
@@ -33,13 +33,13 @@
|
||||
"message": "モーメントを開く"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Momento サーバーの HTTPS (または LAN) URL を貼り付けます。このブラウザの Cookie がサインインを処理します。"
|
||||
"message": "Memento サーバーの HTTPS (または LAN) URL を貼り付けます。このブラウザの Cookie がサインインを処理します。"
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web クリッパー <<<バージョン>>>"
|
||||
"message": "Memento Web クリッパー <<<バージョン>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento はこのタブにアクセスできません。キーボード/サイト拡張機能の権限を確認するか、サイド パネルを開きます。"
|
||||
"message": "Memento はこのタブにアクセスできません。キーボード/サイト拡張機能の権限を確認するか、サイド パネルを開きます。"
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "無題のノート"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "ここではクリップできません — このページは拡張機能へのアクセスをブロックしています。"
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "まずこのブラウザで Momento にサインインしてください。"
|
||||
"message": "まずこのブラウザで Memento にサインインしてください。"
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "ノートブックをロードできませんでした。再接続してみてください。"
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "制限されたページ — Momento ツールバーまたはサイド パネルを介してクリップします。"
|
||||
"message": "制限されたページ — Memento ツールバーまたはサイド パネルを介してクリップします。"
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "宛先ノートブック"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "完了できませんでした"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Momento インスタンスに到達する際に問題が発生しました。"
|
||||
"message": "Memento インスタンスに到達する際に問題が発生しました。"
|
||||
},
|
||||
"retry": {
|
||||
"message": "リトライ"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "メモを保存できませんでした。"
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "ネットワークの問題 — 接続と Momento URL を確認してください。"
|
||||
"message": "ネットワークの問題 — 接続と Memento URL を確認してください。"
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "ページ上のテキストを強調表示するか、ページ全体をクリップします。"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"message": "모멘토 웹 클리퍼"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "웹 페이지와 강조 표시된 텍스트를 Momento 노트북에 캡처하여 자체 Momento 서버에 연결합니다."
|
||||
"message": "웹 페이지와 강조 표시된 텍스트를 Memento 노트북에 캡처하여 자체 Memento 서버에 연결합니다."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "순간에 클립"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "모멘토 URL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "귀하의 Momento 인스턴스 URL"
|
||||
"message": "귀하의 Memento 인스턴스 URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "프로덕션 프리셋 · memento-note.com"
|
||||
@@ -33,13 +33,13 @@
|
||||
"message": "모멘토 열기"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Momento 서버의 HTTPS(또는 LAN) URL을 붙여넣습니다. 이 브라우저의 쿠키는 로그인을 처리합니다."
|
||||
"message": "Memento 서버의 HTTPS(또는 LAN) URL을 붙여넣습니다. 이 브라우저의 쿠키는 로그인을 처리합니다."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<버전>>>"
|
||||
"message": "Memento Web Clipper <<<버전>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento는 이 탭에 접근할 수 없습니다. 키보드/사이트 확장 권한을 확인하거나 측면 패널을 엽니다."
|
||||
"message": "Memento는 이 탭에 접근할 수 없습니다. 키보드/사이트 확장 권한을 확인하거나 측면 패널을 엽니다."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "제목 없는 노트"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "여기서 클립할 수 없습니다. 이 페이지는 확장 프로그램 액세스를 차단합니다."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "먼저 이 브라우저에서 Momento에 로그인하세요."
|
||||
"message": "먼저 이 브라우저에서 Memento에 로그인하세요."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "노트북을 로드할 수 없습니다. 다시 연결해 보세요."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "제한된 페이지 — Momento 도구 모음 또는 측면 패널을 통해 클립합니다."
|
||||
"message": "제한된 페이지 — Memento 도구 모음 또는 측면 패널을 통해 클립합니다."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "대상 노트북"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Momento에서 보기"
|
||||
"message": "Memento에서 보기"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "다른 페이지 자르기"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "완료할 수 없습니다."
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Momento 인스턴스에 연결하는 데 문제가 발생했습니다."
|
||||
"message": "Memento 인스턴스에 연결하는 데 문제가 발생했습니다."
|
||||
},
|
||||
"retry": {
|
||||
"message": "다시 해 보다"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "메모를 저장할 수 없습니다."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "네트워크 문제 - 연결 및 Momento URL을 확인하세요."
|
||||
"message": "네트워크 문제 - 연결 및 Memento URL을 확인하세요."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "페이지의 텍스트를 강조 표시하거나 전체 페이지를 자릅니다."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Webclipper"
|
||||
"message": "Memento Webclipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Leg webpagina's en gemarkeerde tekst vast in uw Momento-notebooks - maakt verbinding met uw eigen Momento-server."
|
||||
"message": "Leg webpagina's en gemarkeerde tekst vast in uw Memento-notebooks - maakt verbinding met uw eigen Memento-server."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip naar Momento"
|
||||
"message": "Clip naar Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Webclipper"
|
||||
@@ -18,10 +18,10 @@
|
||||
"message": "Niet verbonden"
|
||||
},
|
||||
"instanceSettings": {
|
||||
"message": "Momento-URL"
|
||||
"message": "Memento-URL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "Uw Momento-instantie-URL"
|
||||
"message": "Uw Memento-instantie-URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Productievoorinstelling · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Toepassen en opnieuw verbinden"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Momento openen"
|
||||
"message": "Memento openen"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Plak de HTTPS (of LAN) URL van uw Momento-server. Cookies in deze browser zorgen voor het inloggen."
|
||||
"message": "Plak de HTTPS (of LAN) URL van uw Memento-server. Cookies in deze browser zorgen voor het inloggen."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<VERSIE>>>"
|
||||
"message": "Memento Web Clipper <<<VERSIE>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento heeft geen toegang tot dit tabblad. Controleer de rechten voor toetsenbord-/site-extensies — of open het zijpaneel."
|
||||
"message": "Memento heeft geen toegang tot dit tabblad. Controleer de rechten voor toetsenbord-/site-extensies — of open het zijpaneel."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Naamloos notitieboekje"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Kan hier niet knippen: deze pagina blokkeert de toegang tot extensies."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Meld u eerst aan bij Momento in deze browser."
|
||||
"message": "Meld u eerst aan bij Memento in deze browser."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Kan notitieboekjes niet laden. Probeer opnieuw verbinding te maken."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Beperkte pagina — clip via de Momento-werkbalk of het zijpaneel."
|
||||
"message": "Beperkte pagina — clip via de Memento-werkbalk of het zijpaneel."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Bestemmingsnotitieboekje"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Uittreksel"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Opslaan in Momento"
|
||||
"message": "Opslaan in Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Rug"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Bekijk in Momento"
|
||||
"message": "Bekijk in Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Knip nog een pagina uit"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Kon niet voltooien"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Er is iets misgegaan bij het bereiken van uw Momento-instantie."
|
||||
"message": "Er is iets misgegaan bij het bereiken van uw Memento-instantie."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Opnieuw proberen"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Kan uw notitie niet opslaan."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Netwerkprobleem: controleer uw verbinding en Momento-URL."
|
||||
"message": "Netwerkprobleem: controleer uw verbinding en Memento-URL."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Markeer tekst op de pagina of knip de hele pagina uit."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Narzędzie do strzyżenia sieci Momento"
|
||||
"message": "Narzędzie do strzyżenia sieci Memento"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Przechwytuj strony internetowe i zaznaczony tekst do swoich notatników Momento — łączy się z Twoim własnym serwerem Momento."
|
||||
"message": "Przechwytuj strony internetowe i zaznaczony tekst do swoich notatników Memento — łączy się z Twoim własnym serwerem Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Klip do Momento"
|
||||
"message": "Klip do Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Obcinacz sieci"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "Adres URL chwili"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "Adres URL Twojej instancji Momento"
|
||||
"message": "Adres URL Twojej instancji Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Wstępne ustawienia produkcyjne · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Zastosuj i połącz ponownie"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Otwórz Momento"
|
||||
"message": "Otwórz Memento"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Wklej adres URL HTTPS (lub LAN) swojego serwera Momento. Pliki cookie w tej przeglądarce obsługują logowanie."
|
||||
"message": "Wklej adres URL HTTPS (lub LAN) swojego serwera Memento. Pliki cookie w tej przeglądarce obsługują logowanie."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<WERSJA>>>"
|
||||
"message": "Memento Web Clipper <<<WERSJA>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento nie ma dostępu do tej karty. Sprawdź uprawnienia rozszerzenia klawiatury/witryny — lub otwórz Panel boczny."
|
||||
"message": "Memento nie ma dostępu do tej karty. Sprawdź uprawnienia rozszerzenia klawiatury/witryny — lub otwórz Panel boczny."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Notatnik bez tytułu"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Nie można tutaj przyciąć — ta strona blokuje dostęp do rozszerzenia."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Najpierw zaloguj się do Momento w tej przeglądarce."
|
||||
"message": "Najpierw zaloguj się do Memento w tej przeglądarce."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Nie udało się załadować notatników. Spróbuj połączyć się ponownie."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Strona z ograniczeniami — klip za pomocą paska narzędzi Momento lub panelu bocznego."
|
||||
"message": "Strona z ograniczeniami — klip za pomocą paska narzędzi Memento lub panelu bocznego."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Notatnik docelowy"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Fragment"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Zapisz w Momento"
|
||||
"message": "Zapisz w Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Z powrotem"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Zobacz w Momento"
|
||||
"message": "Zobacz w Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Wytnij kolejną stronę"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Nie udało się ukończyć"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Coś poszło nie tak podczas docierania do Twojej instancji Momento."
|
||||
"message": "Coś poszło nie tak podczas docierania do Twojej instancji Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Spróbować ponownie"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Nie można zapisać notatki."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Problem z siecią — sprawdź swoje połączenie i adres URL Momento."
|
||||
"message": "Problem z siecią — sprawdź swoje połączenie i adres URL Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Zaznacz tekst na stronie lub przytnij całą stronę."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Web Clipper"
|
||||
"message": "Memento Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Capture páginas da web e texto destacado em seus blocos de anotações Momento – conecte-se ao seu próprio servidor Momento."
|
||||
"message": "Capture páginas da web e texto destacado em seus blocos de anotações Memento – conecte-se ao seu próprio servidor Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clipe para Momento"
|
||||
"message": "Clipe para Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Clipper da Web"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "URL do momento"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "URL da sua instância do Momento"
|
||||
"message": "URL da sua instância do Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Predefinição de produção · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Aplicar e reconectar"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Momento aberto"
|
||||
"message": "Memento aberto"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Cole o URL HTTPS (ou LAN) do seu servidor Momento. Os cookies neste navegador controlam o login."
|
||||
"message": "Cole o URL HTTPS (ou LAN) do seu servidor Memento. Os cookies neste navegador controlam o login."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<VERSÃO>>>"
|
||||
"message": "Memento Web Clipper <<<VERSÃO>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento não pode acessar esta guia. Verifique as permissões de extensão de teclado/site – ou abra o painel lateral."
|
||||
"message": "Memento não pode acessar esta guia. Verifique as permissões de extensão de teclado/site – ou abra o painel lateral."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Caderno sem título"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Não é possível recortar aqui – esta página bloqueia o acesso à extensão."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Faça login no Momento neste navegador primeiro."
|
||||
"message": "Faça login no Memento neste navegador primeiro."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Não foi possível carregar os notebooks. Tente reconectar."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Página restrita – recorte por meio da barra de ferramentas do Momento ou do painel lateral."
|
||||
"message": "Página restrita – recorte por meio da barra de ferramentas do Memento ou do painel lateral."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Caderno de destino"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Trecho"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Salvar no Momento"
|
||||
"message": "Salvar no Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Voltar"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Ver em Momento"
|
||||
"message": "Ver em Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Recortar outra página"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Não foi possível concluir"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Algo deu errado ao chegar à sua instância do Momento."
|
||||
"message": "Algo deu errado ao chegar à sua instância do Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Tentar novamente"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Não foi possível salvar sua nota."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Problema de rede – verifique sua conexão e URL do Momento."
|
||||
"message": "Problema de rede – verifique sua conexão e URL do Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Destaque o texto na página ou recorte a página inteira."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Веб-клипер Momento"
|
||||
"message": "Веб-клипер Memento"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Сохраняйте веб-страницы и выделенный текст в свои блокноты Momento — подключайтесь к вашему собственному серверу Momento."
|
||||
"message": "Сохраняйте веб-страницы и выделенный текст в свои блокноты Memento — подключайтесь к вашему собственному серверу Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Клип на Momento"
|
||||
"message": "Клип на Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Веб-клипер"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "URL-адрес момента"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "URL-адрес вашего экземпляра Momento"
|
||||
"message": "URL-адрес вашего экземпляра Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Настройки производства · memento-note.com"
|
||||
@@ -33,13 +33,13 @@
|
||||
"message": "Открыть Моменто"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Вставьте URL-адрес HTTPS (или LAN) вашего сервера Momento. Файлы cookie в этом браузере обрабатывают вход в систему."
|
||||
"message": "Вставьте URL-адрес HTTPS (или LAN) вашего сервера Memento. Файлы cookie в этом браузере обрабатывают вход в систему."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<ВЕРСИЯ>>>"
|
||||
"message": "Memento Web Clipper <<<ВЕРСИЯ>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento не имеет доступа к этой вкладке. Проверьте разрешения для расширения клавиатуры/сайта или откройте боковую панель."
|
||||
"message": "Memento не имеет доступа к этой вкладке. Проверьте разрешения для расширения клавиатуры/сайта или откройте боковую панель."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Блокнот без названия"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Невозможно обрезать здесь — эта страница блокирует доступ к расширению."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Сначала войдите в Momento в этом браузере."
|
||||
"message": "Сначала войдите в Memento в этом браузере."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Не удалось загрузить блокноты. Попробуйте переподключиться."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Страница с ограниченным доступом — вырезайте с помощью панели инструментов Momento или боковой панели."
|
||||
"message": "Страница с ограниченным доступом — вырезайте с помощью панели инструментов Memento или боковой панели."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Блокнот назначения"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Не удалось завершить"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Что-то пошло не так при получении вашего экземпляра Momento."
|
||||
"message": "Что-то пошло не так при получении вашего экземпляра Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Повторить попытку"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Не удалось сохранить заметку."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Проблема с сетью. Проверьте подключение и URL-адрес Momento."
|
||||
"message": "Проблема с сетью. Проверьте подключение и URL-адрес Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Выделите текст на странице или вырежьте всю страницу."
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento 网页剪辑器"
|
||||
"message": "Memento 网页剪辑器"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "将网页和突出显示的文本捕获到您的 Momento 笔记本中 — 连接到您自己的 Momento 服务器。"
|
||||
"message": "将网页和突出显示的文本捕获到您的 Memento 笔记本中 — 连接到您自己的 Memento 服务器。"
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "剪辑到时刻"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "时刻网址"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "您的 Momento 实例 URL"
|
||||
"message": "您的 Memento 实例 URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "制作预设 · memento-note.com"
|
||||
@@ -33,13 +33,13 @@
|
||||
"message": "打开时刻"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "粘贴 Momento 服务器的 HTTPS(或 LAN)URL。此浏览器中的 Cookie 处理登录。"
|
||||
"message": "粘贴 Memento 服务器的 HTTPS(或 LAN)URL。此浏览器中的 Cookie 处理登录。"
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<版本>>>"
|
||||
"message": "Memento Web Clipper <<<版本>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento 无法访问此选项卡。检查键盘/站点扩展权限 - 或打开侧面板。"
|
||||
"message": "Memento 无法访问此选项卡。检查键盘/站点扩展权限 - 或打开侧面板。"
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "无标题笔记本"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "此处无法剪辑 — 此页面阻止扩展程序访问。"
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "请先在此浏览器中登录 Momento。"
|
||||
"message": "请先在此浏览器中登录 Memento。"
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "无法加载笔记本。尝试重新连接。"
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "受限页面 — 通过 Momento 工具栏或侧面板进行剪辑。"
|
||||
"message": "受限页面 — 通过 Memento 工具栏或侧面板进行剪辑。"
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "目的地笔记本"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "在 Momento 中查看"
|
||||
"message": "在 Memento 中查看"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "剪辑另一页"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "无法完成"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "您的 Momento 实例出现问题。"
|
||||
"message": "您的 Memento 实例出现问题。"
|
||||
},
|
||||
"retry": {
|
||||
"message": "重试"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "无法保存您的笔记。"
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "网络问题 — 检查您的连接和 Momento URL。"
|
||||
"message": "网络问题 — 检查您的连接和 Memento URL。"
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "突出显示页面上的文本,或剪辑整个页面。"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Content script Momento — sélection live, surlignage, communication avec le side panel.
|
||||
* Content script Memento — sélection live, surlignage, communication avec le side panel.
|
||||
* Injecté automatiquement sur http(s) ; ré-injecté à la demande si l’onglet était déjà ouvert.
|
||||
*/
|
||||
;(function initMementoClipperContent() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Script de diagnostic pour l'extension Momento
|
||||
* Script de diagnostic pour l'extension Memento
|
||||
* Vérifie tous les fichiers et identifie les problèmes potentiels
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'url'
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const extDir = __dirname
|
||||
|
||||
console.log('🔍 Diagnostic Extension Momento\n')
|
||||
console.log('🔍 Diagnostic Extension Memento\n')
|
||||
|
||||
const issues = []
|
||||
const warnings = []
|
||||
@@ -155,6 +155,6 @@ if (issues.length === 0 && warnings.length === 0) {
|
||||
console.log('\n📝 Instructions:')
|
||||
console.log('1. Rechargez l\'extension dans chrome://extensions (bouton 🔄)')
|
||||
console.log('2. Ouvrez une page web normale')
|
||||
console.log('3. Cliquez sur l\'icône Momento')
|
||||
console.log('3. Cliquez sur l\'icône Memento')
|
||||
console.log('4. Fermez le sidepanel - la bannière doit disparaître')
|
||||
console.log('5. Ouvrez la console (F12) - pas d\'erreur CSP')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Momento Web Clipper</title>
|
||||
<title>Memento Web Clipper</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Vazirmatn:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="brand">
|
||||
<div class="brand-logo">M</div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-name">Momento</span>
|
||||
<span class="brand-name">Memento</span>
|
||||
<span class="brand-sub">Web Clipper</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Extension Momento</h1>
|
||||
<h1>Test Extension Memento</h1>
|
||||
<div id="results"></div>
|
||||
|
||||
<script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Momento Web Clipper</title>
|
||||
<title>Memento Web Clipper</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Vazirmatn:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="brand">
|
||||
<div class="brand-logo">M</div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-name">Momento</span>
|
||||
<span class="brand-name">Memento</span>
|
||||
<span class="brand-sub">Web Clipper</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Extension Momento</h1>
|
||||
<h1>Test Extension Memento</h1>
|
||||
<div id="results"></div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { getChatProvider } from '../factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export interface NoteAnalysisResult {
|
||||
noteId: string
|
||||
include: boolean
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface NotebookAnalysis {
|
||||
notes: NoteAnalysisResult[]
|
||||
description: string
|
||||
}
|
||||
|
||||
export class NotebookPublishAnalyzerService {
|
||||
async analyze(
|
||||
notebookName: string,
|
||||
notes: Array<{ id: string; title: string; wordCount: number; preview: string }>,
|
||||
language: string,
|
||||
): Promise<NotebookAnalysis> {
|
||||
const lang = language || 'fr'
|
||||
const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang === 'ar' ? 'العربية' : lang === 'de' ? 'Deutsch' : lang === 'es' ? 'Español' : lang
|
||||
|
||||
const notesList = notes.map((n, i) =>
|
||||
`${i + 1}. [${n.id}] "${n.title || 'Sans titre'}" — ${n.wordCount} mots — "${n.preview}"`
|
||||
).join('\n')
|
||||
|
||||
const prompt = `Tu es un éditeur web expert. Analyse ce carnet de notes "${notebookName}" et décide quelles notes méritent d'être publiées sur un site web professionnel.
|
||||
|
||||
NOTES DU CARNET :
|
||||
${notesList}
|
||||
|
||||
CRITÈRES D'INCLUSION :
|
||||
- Note complète (>150 mots), structurée, sans fautes évidentes
|
||||
- Contenu utile pour un lecteur externe (pas juste des notes personnelles)
|
||||
- Titre clair et représentatif du contenu
|
||||
|
||||
CRITÈRES D'EXCLUSION :
|
||||
- Brouillon ou note vide (<50 mots)
|
||||
- Contenu incomplet ("à compléter", "TODO", "...")
|
||||
- Note purement personnelle ou de travail interne
|
||||
|
||||
Réponds en JSON strict :
|
||||
{
|
||||
"description": "Résumé du carnet en 1-2 phrases percutantes en ${langName}",
|
||||
"notes": [
|
||||
{ "noteId": "id_ici", "include": true, "reason": "Courte justification en ${langName}" }
|
||||
]
|
||||
}
|
||||
|
||||
Inclus toutes les notes dans le tableau "notes", même celles exclues.`
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
const raw = await provider.generateText(prompt)
|
||||
|
||||
return this.parseResponse(raw, notes)
|
||||
}
|
||||
|
||||
private parseResponse(
|
||||
raw: string,
|
||||
notes: Array<{ id: string }>,
|
||||
): NotebookAnalysis {
|
||||
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
|
||||
let jsonStr = jsonMatch ? jsonMatch[1] : raw
|
||||
const s = jsonStr.indexOf('{')
|
||||
const e = jsonStr.lastIndexOf('}')
|
||||
if (s >= 0 && e > s) jsonStr = jsonStr.slice(s, e + 1)
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr)
|
||||
const noteIds = new Set(notes.map(n => n.id))
|
||||
const analysisNotes: NoteAnalysisResult[] = (parsed.notes || [])
|
||||
.filter((n: any) => noteIds.has(n.noteId))
|
||||
.map((n: any) => ({
|
||||
noteId: String(n.noteId),
|
||||
include: Boolean(n.include),
|
||||
reason: String(n.reason || ''),
|
||||
}))
|
||||
|
||||
// Include any notes missing from AI response with include=true as default
|
||||
const analyzedIds = new Set(analysisNotes.map(n => n.noteId))
|
||||
for (const note of notes) {
|
||||
if (!analyzedIds.has(note.id)) {
|
||||
analysisNotes.push({ noteId: note.id, include: true, reason: '' })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
notes: analysisNotes,
|
||||
description: String(parsed.description || ''),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
notes: notes.map(n => ({ noteId: n.id, include: true, reason: '' })),
|
||||
description: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const notebookPublishAnalyzerService = new NotebookPublishAnalyzerService()
|
||||
@@ -29,7 +29,7 @@ export class NotebookWizardService {
|
||||
const provider = getChatProvider(config)
|
||||
const raw = await provider.generateText(prompts)
|
||||
|
||||
return this.parseResponse(raw, profile)
|
||||
return this.parseResponse(raw, profile, lang)
|
||||
}
|
||||
|
||||
private buildPrompt(
|
||||
@@ -39,8 +39,9 @@ export class NotebookWizardService {
|
||||
count: number,
|
||||
level?: string
|
||||
): string {
|
||||
const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang
|
||||
const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang === 'ar' ? 'العربية' : lang === 'de' ? 'Deutsch' : lang === 'es' ? 'Español' : lang === 'it' ? 'Italiano' : lang === 'pt' ? 'Português' : lang === 'ru' ? 'Русский' : lang === 'zh' ? '中文' : lang === 'ja' ? '日本語' : lang === 'ko' ? '한국어' : lang === 'nl' ? 'Nederlands' : lang === 'pl' ? 'Polski' : lang === 'hi' ? 'हिन्दी' : lang
|
||||
|
||||
const 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.`,
|
||||
@@ -77,8 +78,8 @@ FORMAT DE SORTIE — JSON UNIQUEMENT :
|
||||
}
|
||||
],
|
||||
"schemaProperties": [
|
||||
{ "name": "Statut", "type": "select", "options": ["À réviser", "En cours", "Maîtrisé"] },
|
||||
{ "name": "Difficulté", "type": "select", "options": ["Facile", "Moyen", "Difficile"] }
|
||||
{ "name": "${schemaLabels.status}", "type": "select", "options": ["${schemaLabels.statusTodo}", "${schemaLabels.statusInProgress}", "${schemaLabels.statusDone}"] },
|
||||
{ "name": "${schemaLabels.difficulty}", "type": "select", "options": ["${schemaLabels.easy}", "${schemaLabels.medium}", "${schemaLabels.hard}"] }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
@@ -110,7 +111,28 @@ IMPORTANT : Chaque note DOIT faire entre 800 et 1500 mots. Sois exhaustif. Déve
|
||||
Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
|
||||
}
|
||||
|
||||
private parseResponse(raw: string, profile: WizardProfile): GeneratedCarnet {
|
||||
private getSchemaLabels(lang: string) {
|
||||
const map: Record<string, { status: string; statusTodo: string; statusInProgress: string; statusDone: string; difficulty: string; easy: string; medium: string; hard: string }> = {
|
||||
fr: { status: 'Statut', statusTodo: 'À réviser', statusInProgress: 'En cours', statusDone: 'Maîtrisé', difficulty: 'Difficulté', easy: 'Facile', medium: 'Moyen', hard: 'Difficile' },
|
||||
en: { status: 'Status', statusTodo: 'To review', statusInProgress: 'In progress', statusDone: 'Mastered', difficulty: 'Difficulty', easy: 'Easy', medium: 'Medium', hard: 'Hard' },
|
||||
fa: { status: 'وضعیت', statusTodo: 'باید مرور شود', statusInProgress: 'در حال یادگیری', statusDone: 'تسلط یافته', difficulty: 'سختی', easy: 'آسان', medium: 'متوسط', hard: 'دشوار' },
|
||||
ar: { status: 'الحالة', statusTodo: 'للمراجعة', statusInProgress: 'قيد الدراسة', statusDone: 'متقن', difficulty: 'الصعوبة', easy: 'سهل', medium: 'متوسط', hard: 'صعب' },
|
||||
de: { status: 'Status', statusTodo: 'Zu wiederholen', statusInProgress: 'In Bearbeitung', statusDone: 'Beherrscht', difficulty: 'Schwierigkeit', easy: 'Einfach', medium: 'Mittel', hard: 'Schwer' },
|
||||
es: { status: 'Estado', statusTodo: 'Por repasar', statusInProgress: 'En progreso', statusDone: 'Dominado', difficulty: 'Dificultad', easy: 'Fácil', medium: 'Medio', hard: 'Difícil' },
|
||||
it: { status: 'Stato', statusTodo: 'Da ripassare', statusInProgress: 'In corso', statusDone: 'Padroneggiato', difficulty: 'Difficoltà', easy: 'Facile', medium: 'Medio', hard: 'Difficile' },
|
||||
pt: { status: 'Estado', statusTodo: 'A rever', statusInProgress: 'Em progresso', statusDone: 'Dominado', difficulty: 'Dificuldade', easy: 'Fácil', medium: 'Médio', hard: 'Difícil' },
|
||||
ru: { status: 'Статус', statusTodo: 'На повторение', statusInProgress: 'В процессе', statusDone: 'Усвоено', difficulty: 'Сложность', easy: 'Лёгкий', medium: 'Средний', hard: 'Сложный' },
|
||||
zh: { status: '状态', statusTodo: '待复习', statusInProgress: '学习中', statusDone: '已掌握', difficulty: '难度', easy: '简单', medium: '中等', hard: '困难' },
|
||||
ja: { status: 'ステータス', statusTodo: '要復習', statusInProgress: '学習中', statusDone: '習得済み', difficulty: '難易度', easy: '易しい', medium: '普通', hard: '難しい' },
|
||||
ko: { status: '상태', statusTodo: '복습 필요', statusInProgress: '학습 중', statusDone: '마스터', difficulty: '난이도', easy: '쉬움', medium: '보통', hard: '어려움' },
|
||||
nl: { status: 'Status', statusTodo: 'Te herhalen', statusInProgress: 'Bezig', statusDone: 'Beheerst', difficulty: 'Moeilijkheid', easy: 'Makkelijk', medium: 'Gemiddeld', hard: 'Moeilijk' },
|
||||
pl: { status: 'Status', statusTodo: 'Do powtórki', statusInProgress: 'W trakcie', statusDone: 'Opanowane', difficulty: 'Trudność', easy: 'Łatwy', medium: 'Średni', hard: 'Trudny' },
|
||||
hi: { status: 'स्थिति', statusTodo: 'दोहराने के लिए', statusInProgress: 'प्रगति में', statusDone: 'महारत हासिल', difficulty: 'कठिनाई', easy: 'आसान', medium: 'मध्यम', hard: 'कठिन' },
|
||||
}
|
||||
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
|
||||
|
||||
@@ -145,11 +167,12 @@ Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
|
||||
})
|
||||
}
|
||||
if (notes.length === 0) throw new Error('No notes found in response')
|
||||
const fallbackLabels = this.getSchemaLabels(lang || 'fr')
|
||||
return {
|
||||
notes,
|
||||
schemaProperties: [
|
||||
{ name: 'Statut', type: 'select', options: ['À réviser', 'En cours', 'Maîtrisé'] },
|
||||
{ name: 'Difficulté', type: 'select', options: ['Facile', 'Moyen', 'Difficile'] },
|
||||
{ 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 {
|
||||
@@ -164,9 +187,10 @@ Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
|
||||
difficulty: n.difficulty || undefined,
|
||||
}))
|
||||
|
||||
const defaultLabels = this.getSchemaLabels(lang || 'fr')
|
||||
const schemaProperties = parsed.schemaProperties || [
|
||||
{ name: 'Statut', type: 'select', options: ['À réviser', 'En cours', 'Maîtrisé'] },
|
||||
{ name: 'Difficulté', type: 'select', options: ['Facile', 'Moyen', 'Difficile'] },
|
||||
{ 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 }
|
||||
|
||||
149
memento-note/lib/ai/services/publish-enhance.service.ts
Normal file
149
memento-note/lib/ai/services/publish-enhance.service.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { getChatProvider } from '../factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { extractPublishImageUrls } from '@/lib/publish/process-note-html'
|
||||
import { transformEditorBlocksForPublish } from '@/lib/publish/transform-editor-blocks'
|
||||
import type { PublishEnhanceSpec, PublishRewriteSpec, PublishTemplateId } from '@/lib/publish/types'
|
||||
|
||||
const TEMPLATE_HINTS: Record<PublishTemplateId, string> = {
|
||||
magazine: `Chapô accrocheur + une citation mise en avant (pullQuote). Style journalistique.`,
|
||||
brief: `Résumé exécutif dense + 3 à 5 points clés (keyPoints). Ton professionnel actionnable.`,
|
||||
essay: `Épigraphe inspirante + chapô réfléchi. Ton littéraire mais clair.`,
|
||||
}
|
||||
|
||||
/* ─── MODE ÉDITORIAL (pas de réécriture) ─────────────────────────────────── */
|
||||
export class PublishEnhanceService {
|
||||
async enhance(
|
||||
title: string,
|
||||
noteContent: string,
|
||||
template: PublishTemplateId,
|
||||
language: string,
|
||||
): Promise<PublishEnhanceSpec> {
|
||||
const imageUrls = extractPublishImageUrls(noteContent)
|
||||
const plainText = noteContent
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 6000)
|
||||
|
||||
const langName = language === 'fr' ? 'français' : language === 'fa' ? 'فارسی' : 'English'
|
||||
const imageHint = imageUrls.length > 0
|
||||
? `La note contient ${imageUrls.length} image(s) — elles sont affichées automatiquement.`
|
||||
: ''
|
||||
|
||||
const prompt = `Tu es un éditeur web. Rédige UNIQUEMENT l'habillage éditorial d'une page publique.
|
||||
|
||||
TITRE : "${title || 'Sans titre'}"
|
||||
LANGUE : ${langName}
|
||||
STYLE : ${TEMPLATE_HINTS[template]}
|
||||
${imageHint}
|
||||
|
||||
CONTENU (pour contexte) :
|
||||
${plainText}
|
||||
|
||||
Réponds en JSON strict, sans markdown :
|
||||
{
|
||||
"summary": "chapô 2-3 phrases",
|
||||
"pullQuote": "phrase marquante (magazine)",
|
||||
"epigraph": "ouverture courte (essay)",
|
||||
"keyPoints": ["point 1", "point 2", "point 3"]
|
||||
}`
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
const raw = await provider.generateText(prompt)
|
||||
return this.parseEnhanceResponse(raw, plainText)
|
||||
}
|
||||
|
||||
private parseEnhanceResponse(raw: string, fallback: string): PublishEnhanceSpec {
|
||||
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
|
||||
let jsonStr = jsonMatch ? jsonMatch[1] : raw
|
||||
const s = jsonStr.indexOf('{')
|
||||
const e = jsonStr.lastIndexOf('}')
|
||||
if (s >= 0 && e > s) jsonStr = jsonStr.slice(s, e + 1)
|
||||
try {
|
||||
const p = JSON.parse(jsonStr)
|
||||
return {
|
||||
summary: String(p.summary || '').trim() || fallback.slice(0, 280),
|
||||
pullQuote: p.pullQuote ? String(p.pullQuote).trim() : undefined,
|
||||
epigraph: p.epigraph ? String(p.epigraph).trim() : undefined,
|
||||
keyPoints: Array.isArray(p.keyPoints)
|
||||
? p.keyPoints.map((k: unknown) => String(k).trim()).filter(Boolean).slice(0, 6)
|
||||
: undefined,
|
||||
}
|
||||
} catch {
|
||||
return { summary: fallback.slice(0, 280) }
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── MODE RÉÉCRITURE WEB ─────────────────────────────────────────────── */
|
||||
async rewrite(
|
||||
title: string,
|
||||
noteContent: string,
|
||||
template: PublishTemplateId,
|
||||
language: string,
|
||||
): Promise<PublishRewriteSpec> {
|
||||
const langName = language === 'fr' ? 'français' : language === 'fa' ? 'فارسی' : 'English'
|
||||
const plainText = noteContent
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 8000)
|
||||
|
||||
// Corps = transformation déterministe du HTML éditeur (exercices, toggles, callouts…)
|
||||
const structuredBody = transformEditorBlocksForPublish(noteContent)
|
||||
|
||||
const prompt = `Tu es un éditeur web. Analyse ce contenu de note et rédige UNIQUEMENT l'habillage éditorial.
|
||||
Langue : ${langName}
|
||||
Titre : "${title || 'Sans titre'}"
|
||||
Style : ${TEMPLATE_HINTS[template]}
|
||||
|
||||
Le corps HTML est déjà structuré (exercices, définitions, toggles) — tu ne réécris PAS le corps.
|
||||
|
||||
CONTENU (texte) :
|
||||
${plainText}
|
||||
|
||||
Réponds en JSON strict, sans markdown :
|
||||
{
|
||||
"contentType": "article|exercises|tutorial|reference|mixed",
|
||||
"summary": "chapô 1-2 phrases percutantes"
|
||||
}`
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
const raw = await provider.generateText(prompt)
|
||||
return this.parseRewriteResponse(raw, title, plainText, structuredBody)
|
||||
}
|
||||
|
||||
private parseRewriteResponse(
|
||||
raw: string,
|
||||
title: string,
|
||||
fallback: string,
|
||||
structuredBody: string,
|
||||
): PublishRewriteSpec {
|
||||
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
|
||||
let jsonStr = jsonMatch ? jsonMatch[1] : raw
|
||||
const s = jsonStr.indexOf('{')
|
||||
const e = jsonStr.lastIndexOf('}')
|
||||
if (s >= 0 && e > s) jsonStr = jsonStr.slice(s, e + 1)
|
||||
try {
|
||||
const p = JSON.parse(jsonStr)
|
||||
return {
|
||||
contentType: ['article', 'exercises', 'tutorial', 'reference', 'mixed'].includes(p.contentType)
|
||||
? p.contentType
|
||||
: 'article',
|
||||
summary: String(p.summary || '').trim() || '',
|
||||
body: structuredBody || `<p>${fallback.slice(0, 500)}</p>`,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
contentType: 'article',
|
||||
summary: '',
|
||||
body: structuredBody || `<h2>${title}</h2><p>${fallback.slice(0, 600)}</p>`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const publishEnhanceService = new PublishEnhanceService()
|
||||
@@ -66,13 +66,14 @@ Chaque jour doit avoir 1-4 notes à réviser avec une activité descriptive.`
|
||||
const provider = getChatProvider(config)
|
||||
const raw = await provider.generateText(prompt)
|
||||
|
||||
return this.parseResponse(raw, notes, daysUntilExam)
|
||||
return this.parseResponse(raw, notes, daysUntilExam, language)
|
||||
}
|
||||
|
||||
private parseResponse(
|
||||
raw: string,
|
||||
notes: Array<{ id: string; title: string }>,
|
||||
totalDays: number
|
||||
totalDays: number,
|
||||
language?: string
|
||||
): StudyPlan {
|
||||
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
|
||||
let jsonStr = jsonMatch ? jsonMatch[1] : raw
|
||||
@@ -101,6 +102,9 @@ Chaque jour doit avoir 1-4 notes à réviser avec une activité descriptive.`
|
||||
}
|
||||
} catch {
|
||||
// Fallback: simple distribution
|
||||
const lang = language || 'fr'
|
||||
const firstReadLabel = lang === 'fa' ? 'اولین مطالعه' : lang === 'ar' ? 'القراءة الأولى' : lang === 'de' ? 'Erste Lektüre' : lang === 'es' ? 'Primera lectura' : lang === 'it' ? 'Prima lettura' : lang === 'pt' ? 'Primeira leitura' : lang === 'ru' ? 'Первое чтение' : lang === 'zh' ? '第一次阅读' : lang === 'ja' ? '最初の読み' : lang === 'ko' ? '첫 번째 읽기' : lang === 'nl' ? 'Eerste lezing' : lang === 'pl' ? 'Pierwsze czytanie' : lang === 'hi' ? 'पहली पढ़ाई' : lang === 'fr' ? 'Première lecture' : 'First reading'
|
||||
const reviewLabel = (n: number) => lang === 'fa' ? `مرور ${n} یادداشت` : lang === 'ar' ? `مراجعة ${n} ملاحظات` : lang === 'de' ? `${n} Notizen wiederholen` : lang === 'es' ? `Repasar ${n} notas` : lang === 'it' ? `Ripassa ${n} note` : lang === 'pt' ? `Rever ${n} notas` : lang === 'ru' ? `Повторить ${n} заметок` : lang === 'zh' ? `复习 ${n} 条笔记` : lang === 'ja' ? `${n}件のノートを復習` : lang === 'ko' ? `${n}개 노트 복습` : lang === 'nl' ? `${n} notities herzien` : lang === 'pl' ? `Powtórz ${n} notatek` : lang === 'hi' ? `${n} नोट्स दोहराएं` : lang === 'fr' ? `Revoir ${n} notes` : `Review ${n} notes`
|
||||
const days: StudyDay[] = []
|
||||
const today = new Date()
|
||||
const notesPerDay = Math.max(1, Math.ceil(notes.length / Math.min(totalDays, 14)))
|
||||
@@ -113,7 +117,7 @@ Chaque jour doit avoir 1-4 notes à réviser avec une activité descriptive.`
|
||||
date: date.toISOString().slice(0, 10),
|
||||
noteIds: dayNotes.map(n => n.id),
|
||||
noteTitles: dayNotes.map(n => n.title),
|
||||
activity: i === 0 ? 'Première lecture' : `Revoir ${dayNotes.length} notes`,
|
||||
activity: i === 0 ? firstReadLabel : reviewLabel(dayNotes.length),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ function addWatermark(slide: any) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Wrap pres.addSlide so every new slide gets the Momento watermark automatically */
|
||||
/** Wrap pres.addSlide so every new slide gets the Memento watermark automatically */
|
||||
function withWatermark(pres: PptxGenJSModule): PptxGenJSModule {
|
||||
const original = pres.addSlide.bind(pres)
|
||||
;(pres as any).addSlide = (...args: any[]) => {
|
||||
@@ -1004,7 +1004,7 @@ async function buildPresentation(spec: PresentationSpec): Promise<PptxGenJSModul
|
||||
const PptxGenJS = await getPptxGenClass()
|
||||
const pres = withWatermark(new PptxGenJS())
|
||||
pres.title = spec.title
|
||||
pres.author = 'Momento'
|
||||
pres.author = 'Memento'
|
||||
pres.subject = spec.title
|
||||
pres.layout = 'LAYOUT_16x9'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
/** Ends the Momento session server-side (JWT revoked) and redirects to login. */
|
||||
/** Ends the Memento session server-side (JWT revoked) and redirects to login. */
|
||||
export async function performSignOut(callbackUrl = '/login') {
|
||||
await signOut({ redirectTo: callbackUrl });
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ async function getPptxGenClass(): Promise<new () => PptxGenJSModule> {
|
||||
return _PptxGenJS
|
||||
}
|
||||
|
||||
// ── Theme — cohérent avec l'identité visuelle Momento ───────────────────────
|
||||
// ── Theme — cohérent avec l'identité visuelle Memento ───────────────────────
|
||||
const T = {
|
||||
bg: 'F2F0E9',
|
||||
primary: '1C1C1C',
|
||||
@@ -78,7 +78,7 @@ function addWatermark(slide: any) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Wrap pres.addSlide so every new slide gets the Momento watermark automatically */
|
||||
/** Wrap pres.addSlide so every new slide gets the Memento watermark automatically */
|
||||
function withWatermark(pres: PptxGenJSModule): PptxGenJSModule {
|
||||
const original = pres.addSlide.bind(pres)
|
||||
;(pres as any).addSlide = (...args: any[]) => {
|
||||
@@ -309,7 +309,7 @@ export async function generateBrainstormPptx(session: SessionLike): Promise<{ bu
|
||||
const pres = withWatermark(new PptxGenJS())
|
||||
|
||||
pres.layout = 'LAYOUT_WIDE'
|
||||
pres.author = 'Momento'
|
||||
pres.author = 'Memento'
|
||||
pres.subject = `Brainstorm: ${session.seedIdea}`
|
||||
|
||||
const activeIdeas = session.ideas.filter(i => i.status !== 'dismissed')
|
||||
|
||||
19
memento-note/lib/editor/callout-colors.ts
Normal file
19
memento-note/lib/editor/callout-colors.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface CalloutColors {
|
||||
bg: string
|
||||
border: string
|
||||
}
|
||||
|
||||
const CALLOUT_COLOR_MAP: Record<string, CalloutColors> = {
|
||||
info: { bg: '#eff6ff', border: '#93c5fd' },
|
||||
warning: { bg: '#fffbeb', border: '#fcd34d' },
|
||||
tip: { bg: '#faf5ff', border: '#c4b5fd' },
|
||||
success: { bg: '#f0fdf4', border: '#86efac' },
|
||||
danger: { bg: '#fef2f2', border: '#fca5a5' },
|
||||
}
|
||||
|
||||
export function getCalloutColors(type: string | null | undefined): CalloutColors {
|
||||
if (type && type in CALLOUT_COLOR_MAP) {
|
||||
return CALLOUT_COLOR_MAP[type]
|
||||
}
|
||||
return CALLOUT_COLOR_MAP.info
|
||||
}
|
||||
@@ -85,6 +85,95 @@ export class QuotaExceededError extends Error {
|
||||
|
||||
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
|
||||
function getPeriodDates(): { period: string; periodStart: Date; periodEnd: Date } {
|
||||
const period = getCurrentPeriodKey();
|
||||
const periodStart = new Date(`${period}-01T00:00:00.000Z`);
|
||||
const periodEnd = new Date(Date.UTC(periodStart.getUTCFullYear(), periodStart.getUTCMonth() + 1, 1));
|
||||
return { period, periodStart, periodEnd };
|
||||
}
|
||||
|
||||
async function getDatabaseUsageCounts(
|
||||
userId: string,
|
||||
features: string[],
|
||||
): Promise<Record<string, number>> {
|
||||
if (features.length === 0) return {};
|
||||
const { periodStart } = getPeriodDates();
|
||||
const rows = await prisma.usageLog.findMany({
|
||||
where: { userId, periodStart, feature: { in: features } },
|
||||
select: { feature: true, requestsCount: true },
|
||||
});
|
||||
return Object.fromEntries(rows.map((r) => [r.feature, r.requestsCount]));
|
||||
}
|
||||
|
||||
async function mirrorUsageCountToDatabase(
|
||||
userId: string,
|
||||
feature: string,
|
||||
requestsCount: number,
|
||||
): Promise<void> {
|
||||
const { periodStart, periodEnd } = getPeriodDates();
|
||||
await prisma.usageLog.upsert({
|
||||
where: {
|
||||
userId_feature_periodStart: { userId, feature, periodStart },
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
feature,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
requestsCount,
|
||||
tokensUsed: 0,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
requestsCount,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('[entitlements] Failed to mirror usage to DB:', err);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fallback when Redis is unavailable — atomic increment in PostgreSQL. */
|
||||
async function reserveUsageInDatabase(
|
||||
userId: string,
|
||||
feature: string,
|
||||
limit: number,
|
||||
): Promise<number> {
|
||||
const { periodStart, periodEnd } = getPeriodDates();
|
||||
|
||||
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;
|
||||
|
||||
const updated = await tx.usageLog.upsert({
|
||||
where: { userId_feature_periodStart: { userId, feature, periodStart } },
|
||||
create: {
|
||||
userId,
|
||||
feature,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
requestsCount: 1,
|
||||
tokensUsed: 0,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
requestsCount: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return updated.requestsCount;
|
||||
});
|
||||
}
|
||||
|
||||
function parseReserveResult(raw: unknown): number {
|
||||
const n = typeof raw === 'number' ? raw : Number(raw);
|
||||
return Number.isFinite(n) ? n : NaN;
|
||||
}
|
||||
|
||||
function shouldFailClosedOnRedisError(): boolean {
|
||||
return process.env.NODE_ENV === 'production';
|
||||
}
|
||||
@@ -182,7 +271,9 @@ export async function canUseFeature(
|
||||
try {
|
||||
const key = getRedisKey(userId, feature);
|
||||
const currentStr = await redis.get(key);
|
||||
const current = parseRedisInt(currentStr);
|
||||
const redisCurrent = parseRedisInt(currentStr);
|
||||
const dbCounts = await getDatabaseUsageCounts(userId, [feature]);
|
||||
const current = Math.max(redisCurrent, dbCounts[feature] ?? 0);
|
||||
const allowed = current < limit;
|
||||
|
||||
if (!allowed) {
|
||||
@@ -265,13 +356,14 @@ export async function reserveUsageOrThrow(
|
||||
|
||||
try {
|
||||
const key = getRedisKey(userId, feature);
|
||||
const newCount = await redis.eval(
|
||||
const raw = await redis.eval(
|
||||
RESERVE_LUA,
|
||||
1,
|
||||
key,
|
||||
String(limit),
|
||||
String(TTL_SECONDS),
|
||||
) as number;
|
||||
);
|
||||
const newCount = parseReserveResult(raw);
|
||||
|
||||
if (newCount === -1) {
|
||||
const byokConfigured = await hasAnyActiveByok(userId);
|
||||
@@ -284,9 +376,36 @@ export async function reserveUsageOrThrow(
|
||||
{ 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 unavailable:', 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();
|
||||
}
|
||||
@@ -334,12 +453,15 @@ export async function getUserQuotas(
|
||||
|
||||
try {
|
||||
const values = await redis.mget(...keys);
|
||||
const dbCounts = await getDatabaseUsageCounts(userId, features);
|
||||
|
||||
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
|
||||
for (let i = 0; i < features.length; i++) {
|
||||
const feature = features[i];
|
||||
const limit = (await getLimitAsync(tier, feature)) ?? 0;
|
||||
const current = parseRedisInt(values[i]);
|
||||
const redisCurrent = parseRedisInt(values[i]);
|
||||
const dbCurrent = dbCounts[feature] ?? 0;
|
||||
const current = Math.max(redisCurrent, dbCurrent);
|
||||
result[feature] = {
|
||||
remaining: limit === Infinity ? Infinity : Math.max(0, limit - current),
|
||||
limit,
|
||||
|
||||
@@ -18,6 +18,7 @@ export const FALLBACK_TIER_LIMITS: Record<
|
||||
suggest_charts: 5,
|
||||
ai_flashcard: 5,
|
||||
voice_transcribe: 20,
|
||||
publish_enhance: 2,
|
||||
},
|
||||
PRO: {
|
||||
semantic_search: 200,
|
||||
@@ -33,6 +34,7 @@ export const FALLBACK_TIER_LIMITS: Record<
|
||||
excalidraw_generate: 20,
|
||||
ai_flashcard: 100,
|
||||
voice_transcribe: 500,
|
||||
publish_enhance: 15,
|
||||
},
|
||||
BUSINESS: {
|
||||
semantic_search: 1000,
|
||||
@@ -48,6 +50,7 @@ export const FALLBACK_TIER_LIMITS: Record<
|
||||
excalidraw_generate: 100,
|
||||
ai_flashcard: 'unlimited',
|
||||
voice_transcribe: 'unlimited',
|
||||
publish_enhance: 100,
|
||||
},
|
||||
ENTERPRISE: {
|
||||
semantic_search: 'unlimited',
|
||||
@@ -63,6 +66,7 @@ export const FALLBACK_TIER_LIMITS: Record<
|
||||
excalidraw_generate: 'unlimited',
|
||||
ai_flashcard: 'unlimited',
|
||||
voice_transcribe: 'unlimited',
|
||||
publish_enhance: 'unlimited',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -100,6 +104,19 @@ function buildLimitMapFromRows(
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Merge DB entitlements with FALLBACK for features added after admin seeding. */
|
||||
function mergeWithFallback(map: LimitMap): LimitMap {
|
||||
const fallback = fallbackToLimitMap();
|
||||
for (const tier of Object.keys(fallback) as SubscriptionTier[]) {
|
||||
for (const [feature, limit] of Object.entries(fallback[tier])) {
|
||||
if (map[tier][feature] === undefined) {
|
||||
map[tier][feature] = limit;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function invalidateEntitlementCache(): void {
|
||||
cachedLimits = null;
|
||||
cacheExpiresAt = 0;
|
||||
@@ -119,11 +136,11 @@ async function loadLimitMap(): Promise<LimitMap> {
|
||||
if (rows.length === 0) {
|
||||
cachedLimits = fallbackToLimitMap();
|
||||
} else {
|
||||
cachedLimits = buildLimitMapFromRows(rows as Array<{
|
||||
cachedLimits = mergeWithFallback(buildLimitMapFromRows(rows as Array<{
|
||||
tier: SubscriptionTier;
|
||||
feature: string;
|
||||
limitValue: number | null;
|
||||
}>);
|
||||
}>));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[plan-entitlements] DB load failed, using fallback:', err);
|
||||
|
||||
111
memento-note/lib/publish/process-note-html.ts
Normal file
111
memento-note/lib/publish/process-note-html.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import katex from 'katex'
|
||||
import { sanitizePublishedHtml } from '@/lib/sanitize-content'
|
||||
import { preprocessMathInHtml } from '@/lib/text/math-preprocess'
|
||||
import { transformEditorBlocksForPublish } from '@/lib/publish/transform-editor-blocks'
|
||||
|
||||
function decodeHtml(text: string): string {
|
||||
const map: Record<string, string> = { '"': '"', '&': '&', '<': '<', '>': '>', ''': "'" }
|
||||
return text.replace(/&[a-z#0-9]+;/gi, (m) => map[m] || m)
|
||||
}
|
||||
|
||||
function extractLatex(attrs: string, inner: string): string {
|
||||
const fromAttr = attrs.match(/data-latex=["']([^"']*)["']/i)?.[1]
|
||||
if (fromAttr) return decodeHtml(fromAttr)
|
||||
const text = inner.replace(/<[^>]+>/g, '').trim()
|
||||
return decodeHtml(text)
|
||||
}
|
||||
|
||||
function isAlreadyKatex(html: string): boolean {
|
||||
return /class=["'][^"']*\bkatex\b/i.test(html)
|
||||
}
|
||||
|
||||
function renderKatexDisplay(latex: string): string {
|
||||
const trimmed = latex.trim()
|
||||
if (!trimmed) return ''
|
||||
try {
|
||||
const rendered = katex.renderToString(trimmed, { displayMode: true, throwOnError: false })
|
||||
return `<div class="r-math-display">${rendered}</div>`
|
||||
} catch {
|
||||
return `<div class="r-math-display r-math-fallback">${trimmed}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function renderKatexInline(latex: string): string {
|
||||
const trimmed = latex.trim()
|
||||
if (!trimmed) return ''
|
||||
try {
|
||||
const rendered = katex.renderToString(trimmed, { displayMode: false, throwOnError: false })
|
||||
return `<span class="r-math-inline">${rendered}</span>`
|
||||
} catch {
|
||||
return `<span class="r-math-inline r-math-fallback">${trimmed}</span>`
|
||||
}
|
||||
}
|
||||
|
||||
/** Convertit nœuds éditeur + délimiteurs LaTeX en HTML KaTeX. */
|
||||
export function renderMathInHtml(html: string): string {
|
||||
if (!html?.trim()) return ''
|
||||
|
||||
let result = preprocessMathInHtml(html)
|
||||
|
||||
// Blocs : data-type="math-equation" ou .math-equation-block
|
||||
result = result.replace(
|
||||
/<div\b([^>]*(?:data-type=["']math-equation["']|class=["'][^"']*math-equation-block)[^>]*)>([\s\S]*?)<\/div>/gi,
|
||||
(full, attrs, inner) => {
|
||||
if (isAlreadyKatex(full)) return full
|
||||
const latex = extractLatex(attrs, inner)
|
||||
return latex ? renderKatexDisplay(latex) : full
|
||||
},
|
||||
)
|
||||
|
||||
// Inline : data-type="inline-math" ou .inline-math
|
||||
result = result.replace(
|
||||
/<span\b([^>]*(?:data-type=["']inline-math["']|class=["'][^"']*inline-math)[^>]*)>([\s\S]*?)<\/span>/gi,
|
||||
(full, attrs, inner) => {
|
||||
if (isAlreadyKatex(full)) return full
|
||||
const latex = extractLatex(attrs, inner)
|
||||
return latex ? renderKatexInline(latex) : full
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** Prépare le HTML de la note pour affichage public (KaTeX, callouts, nettoyage éditeur). */
|
||||
export function processNoteHtmlForPublish(html: string): string {
|
||||
if (!html?.trim()) return ''
|
||||
|
||||
let result = renderMathInHtml(html)
|
||||
|
||||
// Blocs éditeur TipTap → HTML publication (exercices, toggles, callouts…)
|
||||
result = transformEditorBlocksForPublish(result)
|
||||
|
||||
result = result.replace(/<div[^>]*class="link-preview-searchable"[^>]*>[\s\S]*?<\/div>/g, '')
|
||||
|
||||
// Figures éditeur → sémantique publication (img déjà traités ignorés)
|
||||
result = result.replace(/<img([^>]*?)>/gi, (match, attrs) => {
|
||||
if (/class=["'][^"']*pub-/i.test(match)) return match
|
||||
const altMatch = attrs.match(/\balt=["']([^"']*)["']/i)
|
||||
const alt = altMatch?.[1] || ''
|
||||
return `<figure class="pub-figure"><img${attrs} loading="lazy" class="pub-figure-img" />${alt ? `<figcaption class="pub-figure-caption">${alt}</figcaption>` : ''}</figure>`
|
||||
})
|
||||
|
||||
return sanitizePublishedHtml(result)
|
||||
}
|
||||
|
||||
export function extractPublishImageUrls(html: string): string[] {
|
||||
if (!html) return []
|
||||
const urls = new Set<string>()
|
||||
for (const match of html.matchAll(/<img[^>]+src=["']([^"'>]+)["']/gi)) {
|
||||
const src = match[1]?.trim()
|
||||
if (src && isAllowedImageSrc(src)) urls.add(src)
|
||||
}
|
||||
return Array.from(urls)
|
||||
}
|
||||
|
||||
export function isAllowedImageSrc(src: string): boolean {
|
||||
const t = src.trim()
|
||||
return t.startsWith('/uploads/')
|
||||
|| t.startsWith('https://')
|
||||
|| t.startsWith('http://')
|
||||
|| t.startsWith('/api/')
|
||||
}
|
||||
286
memento-note/lib/publish/shared-css.ts
Normal file
286
memento-note/lib/publish/shared-css.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/** CSS KaTeX commun aux pages publiées */
|
||||
export const KATEX_PUBLISH_CSS = `
|
||||
.r-math-display {
|
||||
margin: 1.75em 0;
|
||||
text-align: center;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 0.25em 0;
|
||||
}
|
||||
.r-math-inline { display: inline; }
|
||||
.r-math-inline .katex { font-size: 1.05em; }
|
||||
.r-math-fallback {
|
||||
font-family: 'SF Mono', Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.pub-rewrite-body .katex-display { margin: 0; }
|
||||
.katex { font-size: 1.1em; }
|
||||
.katex-display { margin: 0; overflow-x: auto; overflow-y: hidden; }
|
||||
`
|
||||
|
||||
export const REWRITE_SHARED_CSS = `
|
||||
${KATEX_PUBLISH_CSS}
|
||||
/* ─── Résumé introductif ─────────────────────────────── */
|
||||
.pub-rewrite-summary {
|
||||
font-size: 1.2em;
|
||||
line-height: 1.7;
|
||||
font-style: italic;
|
||||
color: var(--pub-summary-color, #444);
|
||||
margin-bottom: 2em;
|
||||
padding-bottom: 1.5em;
|
||||
border-bottom: 2px solid var(--pub-accent, #A47148);
|
||||
}
|
||||
|
||||
/* ─── Exercices ──────────────────────────────────────── */
|
||||
.pub-rewrite-body .pub-exercise {
|
||||
border: 2px solid var(--pub-exercise-border, #e0d4c3);
|
||||
border-radius: 12px;
|
||||
margin: 1.75em 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pub-rewrite-body .pub-exercise-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 18px;
|
||||
background: var(--pub-exercise-header-bg, #f6f1ea);
|
||||
border-bottom: 1px solid var(--pub-exercise-border, #e0d4c3);
|
||||
}
|
||||
.pub-rewrite-body .pub-exercise-num {
|
||||
font-weight: 700;
|
||||
font-size: 0.82em;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pub-accent, #A47148);
|
||||
}
|
||||
.pub-rewrite-body .pub-exercise-meta {
|
||||
margin-left: auto;
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
.pub-rewrite-body .pub-exercise-body {
|
||||
padding: 16px 20px;
|
||||
color: #1a1a1a;
|
||||
background: #fff;
|
||||
}
|
||||
.pub-rewrite-body .pub-exercise-body p:first-child { margin-top: 0; }
|
||||
.pub-rewrite-body .pub-exercise-body p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* Corps réécrit : texte toujours sombre sur fond clair */
|
||||
.pub-rewrite-body {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.pub-rewrite-body p,
|
||||
.pub-rewrite-body li,
|
||||
.pub-rewrite-body td,
|
||||
.pub-rewrite-body th {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Solution collapsible */
|
||||
.pub-rewrite-body .pub-solution {
|
||||
border-top: 1px dashed var(--pub-exercise-border, #e0d4c3);
|
||||
}
|
||||
.pub-rewrite-body .pub-solution > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 20px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
color: var(--pub-accent, #A47148);
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.pub-rewrite-body .pub-solution > summary::before { content: '▶'; font-size: 10px; transition: transform 0.2s; }
|
||||
.pub-rewrite-body .pub-solution[open] > summary::before { transform: rotate(90deg); }
|
||||
.pub-rewrite-body .pub-solution-body {
|
||||
padding: 14px 20px 18px;
|
||||
background: var(--pub-solution-bg, rgba(164,113,72,0.04));
|
||||
border-top: 1px solid var(--pub-exercise-border, #e0d4c3);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* ─── Étapes tutoriel ────────────────────────────────── */
|
||||
.pub-rewrite-body .pub-step {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
.pub-rewrite-body .pub-step-num {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--pub-accent, #A47148);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.pub-rewrite-body .pub-step-body p:first-child { margin-top: 0; }
|
||||
|
||||
/* ─── Définitions / Concepts ─────────────────────────── */
|
||||
.pub-rewrite-body .pub-definition {
|
||||
margin: 1.5em 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--pub-definition-border, #ddd);
|
||||
}
|
||||
.pub-rewrite-body .pub-definition-term {
|
||||
padding: 8px 16px;
|
||||
background: var(--pub-accent, #A47148);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.88em;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.pub-rewrite-body .pub-definition-body {
|
||||
padding: 12px 16px;
|
||||
background: var(--pub-definition-bg, #fafaf8);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.pub-rewrite-body .pub-definition-body p { margin: 0.4em 0; color: #1a1a1a; }
|
||||
.pub-rewrite-body .pub-definition-body p:first-child { margin-top: 0; }
|
||||
|
||||
/* ─── Toggle / Collapsible ───────────────────────────── */
|
||||
.pub-rewrite-body .pub-toggle {
|
||||
border: 1px solid var(--pub-toggle-border, #e5e5e5);
|
||||
border-radius: 10px;
|
||||
margin: 1.2em 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pub-rewrite-body .pub-toggle > .pub-toggle-trigger {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.92em;
|
||||
background: var(--pub-toggle-header-bg, #f7f7f5);
|
||||
user-select: none;
|
||||
}
|
||||
.pub-rewrite-body .pub-toggle > .pub-toggle-trigger::before {
|
||||
content: '▶';
|
||||
font-size: 10px;
|
||||
color: var(--pub-accent, #A47148);
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pub-rewrite-body .pub-toggle[open] > .pub-toggle-trigger::before { transform: rotate(90deg); }
|
||||
.pub-rewrite-body .pub-toggle-body {
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--pub-toggle-border, #e5e5e5);
|
||||
color: #1a1a1a;
|
||||
background: #fff;
|
||||
}
|
||||
.pub-rewrite-body .pub-toggle-body p:first-child { margin-top: 0; }
|
||||
|
||||
/* ─── Mise en avant ──────────────────────────────────── */
|
||||
.pub-rewrite-body .pub-highlight {
|
||||
margin: 1.5em 0;
|
||||
padding: 16px 20px;
|
||||
background: var(--pub-highlight-bg, #fffbf5);
|
||||
border-left: 4px solid var(--pub-accent, #A47148);
|
||||
border-radius: 0 10px 10px 0;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.pub-rewrite-body .pub-highlight p { margin: 0; color: #1a1a1a; }
|
||||
|
||||
/* ─── Callouts ───────────────────────────────────────── */
|
||||
.pub-rewrite-body .pub-callout {
|
||||
margin: 1.4em 0;
|
||||
padding: 14px 18px 14px 52px;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
font-size: 0.93em;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.pub-rewrite-body .pub-callout::before {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 14px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.pub-rewrite-body .pub-callout strong {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.9em;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: inherit;
|
||||
}
|
||||
.pub-rewrite-body .pub-callout p { margin: 0.3em 0; color: inherit; }
|
||||
.pub-rewrite-body .pub-callout p:first-of-type { margin-top: 0; }
|
||||
|
||||
.pub-rewrite-body .pub-callout-info {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #93c5fd;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
.pub-rewrite-body .pub-callout-info::before { content: 'ℹ️'; }
|
||||
|
||||
.pub-rewrite-body .pub-callout-tip {
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #6ee7b7;
|
||||
color: #065f46;
|
||||
}
|
||||
.pub-rewrite-body .pub-callout-tip::before { content: '💡'; }
|
||||
|
||||
.pub-rewrite-body .pub-callout-warning {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fbbf24;
|
||||
color: #78350f;
|
||||
}
|
||||
.pub-rewrite-body .pub-callout-warning::before { content: '⚠️'; }
|
||||
|
||||
/* ─── Checklist ──────────────────────────────────────── */
|
||||
.pub-rewrite-body .pub-checklist {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.pub-rewrite-body .pub-checklist li {
|
||||
padding: 8px 12px 8px 36px;
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
margin: 4px 0;
|
||||
background: var(--pub-checklist-bg, rgba(0,0,0,0.02));
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.pub-rewrite-body .pub-checklist li::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 8px;
|
||||
font-weight: 700;
|
||||
color: var(--pub-accent, #A47148);
|
||||
}
|
||||
|
||||
/* ─── Code ───────────────────────────────────────────── */
|
||||
.pub-rewrite-body .pub-code {
|
||||
margin: 1.5em 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pub-rewrite-body .pub-code code {
|
||||
display: block;
|
||||
padding: 20px 22px;
|
||||
font-family: 'SF Mono', 'Fira Code', Menlo, Consolas, monospace;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.65;
|
||||
overflow-x: auto;
|
||||
}
|
||||
`
|
||||
116
memento-note/lib/publish/template-render.ts
Normal file
116
memento-note/lib/publish/template-render.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { createHash } from 'crypto'
|
||||
import type { PublishEnhanceSpec, PublishRewriteSpec, PublishTemplateId } from './types'
|
||||
import { extractPublishImageUrls, isAllowedImageSrc, processNoteHtmlForPublish } from './process-note-html'
|
||||
|
||||
function esc(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function heroFigure(src: string): string {
|
||||
if (!isAllowedImageSrc(src)) return ''
|
||||
return `<figure class="pub-hero"><img src="${esc(src)}" alt="" class="pub-hero-img" loading="eager" decoding="async" /></figure>`
|
||||
}
|
||||
|
||||
function galleryFigures(urls: string[]): string {
|
||||
const items = urls.map((src) => {
|
||||
if (!isAllowedImageSrc(src)) return ''
|
||||
return `<figure class="pub-gallery-item"><img src="${esc(src)}" alt="" class="pub-gallery-img" loading="lazy" decoding="async" /></figure>`
|
||||
}).filter(Boolean).join('')
|
||||
if (!items) return ''
|
||||
return `<div class="pub-gallery" aria-label="Images">${items}</div>`
|
||||
}
|
||||
|
||||
function removeFirstImageFromHtml(html: string): string {
|
||||
return html.replace(/<figure[^>]*>[\s\S]*?<img[^>]*>[\s\S]*?<\/figure>|<img[^>]*>/i, '')
|
||||
}
|
||||
|
||||
/* ─── Mode éditorial (texte original + habillage) ──────────────────────── */
|
||||
export function renderPublishedTemplate(
|
||||
spec: PublishEnhanceSpec,
|
||||
template: PublishTemplateId,
|
||||
sourceHtml: string,
|
||||
): string {
|
||||
const images = extractPublishImageUrls(sourceHtml)
|
||||
const bodySource = images[0] ? removeFirstImageFromHtml(sourceHtml) : sourceHtml
|
||||
const processedBody = processNoteHtmlForPublish(bodySource)
|
||||
const hero = images[0] ? heroFigure(images[0]) : ''
|
||||
const gallery = images.length > 1 ? galleryFigures(images.slice(1)) : ''
|
||||
|
||||
const summary = esc(spec.summary)
|
||||
const pullQuote = spec.pullQuote
|
||||
? `<blockquote class="pub-pull-quote"><p>${esc(spec.pullQuote)}</p></blockquote>`
|
||||
: ''
|
||||
const epigraph = spec.epigraph
|
||||
? `<p class="pub-epigraph">${esc(spec.epigraph)}</p>`
|
||||
: ''
|
||||
const keyPoints = spec.keyPoints?.length
|
||||
? `<div class="pub-key-points">
|
||||
<p class="pub-key-points-label">Points clés</p>
|
||||
<ul>${spec.keyPoints.map((k) => `<li>${esc(k)}</li>`).join('')}</ul>
|
||||
</div>`
|
||||
: ''
|
||||
|
||||
const bodyBlock = `<div class="pub-body-source">${processedBody}</div>`
|
||||
|
||||
if (template === 'brief') {
|
||||
return `<div class="pub-tpl pub-tpl-brief">
|
||||
${hero}
|
||||
${summary ? `<div class="pub-brief-lead"><p>${summary}</p></div>` : ''}
|
||||
${keyPoints}
|
||||
${bodyBlock}
|
||||
${gallery}
|
||||
</div>`
|
||||
}
|
||||
|
||||
if (template === 'essay') {
|
||||
return `<div class="pub-tpl pub-tpl-essay">
|
||||
${hero}
|
||||
${epigraph}
|
||||
${summary ? `<p class="pub-essay-summary">${summary}</p>` : ''}
|
||||
${bodyBlock}
|
||||
${gallery}
|
||||
</div>`
|
||||
}
|
||||
|
||||
return `<div class="pub-tpl pub-tpl-magazine">
|
||||
${hero}
|
||||
${summary ? `<p class="pub-magazine-dek">${summary}</p>` : ''}
|
||||
${pullQuote}
|
||||
${bodyBlock}
|
||||
${gallery}
|
||||
</div>`
|
||||
}
|
||||
|
||||
/* ─── Mode réécriture (HTML sémantique IA) ─────────────────────────────── */
|
||||
export function renderRewrittenTemplate(
|
||||
spec: PublishRewriteSpec,
|
||||
template: PublishTemplateId,
|
||||
sourceHtml: string,
|
||||
): string {
|
||||
const images = extractPublishImageUrls(sourceHtml)
|
||||
const hero = images[0] ? heroFigure(images[0]) : ''
|
||||
const gallery = images.length > 1 ? galleryFigures(images.slice(1)) : ''
|
||||
|
||||
const summary = spec.summary
|
||||
? `<p class="pub-rewrite-summary">${esc(spec.summary)}</p>`
|
||||
: ''
|
||||
|
||||
const processedBody = processNoteHtmlForPublish(spec.body)
|
||||
|
||||
return `<div class="pub-tpl pub-tpl-${template} pub-tpl-rewrite" data-content-type="${spec.contentType}">
|
||||
${hero}
|
||||
${summary}
|
||||
<div class="pub-rewrite-body">
|
||||
${processedBody}
|
||||
</div>
|
||||
${gallery}
|
||||
</div>`
|
||||
}
|
||||
|
||||
export function computePublishedSourceHash(content: string): string {
|
||||
return createHash('sha256').update(content || '').digest('hex').slice(0, 16)
|
||||
}
|
||||
208
memento-note/lib/publish/transform-editor-blocks.ts
Normal file
208
memento-note/lib/publish/transform-editor-blocks.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { load, type Cheerio, type CheerioAPI } from 'cheerio'
|
||||
import type { AnyNode } from 'domhandler'
|
||||
|
||||
const CALLOUT_PUB_CLASS: Record<string, string> = {
|
||||
info: 'pub-callout-info',
|
||||
warning: 'pub-callout-warning',
|
||||
tip: 'pub-callout-tip',
|
||||
success: 'pub-callout-tip',
|
||||
danger: 'pub-callout-warning',
|
||||
}
|
||||
|
||||
function stripTags(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function esc(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function firstParagraphText($: CheerioAPI, $el: Cheerio<AnyNode>): string {
|
||||
const p = $el.find('p').first()
|
||||
const text = p.length ? stripTags(p.html() || '') : stripTags($el.html() || '')
|
||||
return text || 'Voir le contenu'
|
||||
}
|
||||
|
||||
function isSolutionToggle(summary: string): boolean {
|
||||
return /solution|réponse|answer|corrigé|corrige|reveal|révéler/i.test(summary)
|
||||
}
|
||||
|
||||
function transformToggleBlocks($: CheerioAPI): void {
|
||||
$('div[data-type="toggle-block"]').each((_, el) => {
|
||||
const $el = $(el)
|
||||
const opened = $el.attr('data-opened') !== 'false'
|
||||
const innerHtml = $el.html() || ''
|
||||
const $inner = load(`<div>${innerHtml}</div>`, { decodeEntities: false } as never)
|
||||
const summaryText = firstParagraphText($, $inner.root())
|
||||
|
||||
const firstP = $inner('p').first()
|
||||
if (firstP.length && /cliquer|révéler|reveal|click/i.test(stripTags(firstP.html() || ''))) {
|
||||
firstP.remove()
|
||||
}
|
||||
$inner('h3').each((__, h) => {
|
||||
if (/solution|réponse|answer/i.test(stripTags($(h).html() || ''))) $(h).remove()
|
||||
})
|
||||
|
||||
const bodyHtml = $inner.root().html() || innerHtml
|
||||
const isSolution = isSolutionToggle(summaryText)
|
||||
const summary = isSolution ? 'Voir la solution' : esc(summaryText.slice(0, 120))
|
||||
const cssClass = isSolution ? 'pub-solution' : 'pub-toggle'
|
||||
const summaryClass = isSolution ? '' : ' class="pub-toggle-trigger"'
|
||||
const bodyClass = isSolution ? 'pub-solution-body' : 'pub-toggle-body'
|
||||
const openAttr = opened ? ' open' : ''
|
||||
|
||||
$el.replaceWith(
|
||||
`<details class="${cssClass}"${openAttr}><summary${summaryClass}>${summary}</summary><div class="${bodyClass}">${bodyHtml}</div></details>`,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function transformCalloutBlocks($: CheerioAPI): void {
|
||||
$('div[data-type="callout-block"]').each((_, el) => {
|
||||
const $el = $(el)
|
||||
if ($el.closest('.pub-exercise').length) return
|
||||
const type = ($el.attr('data-callout-type') || 'info').toLowerCase()
|
||||
const pubClass = CALLOUT_PUB_CLASS[type] || 'pub-callout-info'
|
||||
const inner = $el.html() || ''
|
||||
$el.replaceWith(`<div class="pub-callout ${pubClass}">${inner}</div>`)
|
||||
})
|
||||
}
|
||||
|
||||
function extractExerciseMeta(headerHtml: string): { label: string; meta: string } {
|
||||
const text = stripTags(headerHtml)
|
||||
const match = text.match(/(exercice|exercise|تمرین)\s*(\d+)?/i)
|
||||
const label = match
|
||||
? `${match[1].charAt(0).toUpperCase()}${match[1].slice(1).toLowerCase()}${match[2] ? ` ${match[2]}` : ''}`
|
||||
: 'Exercice'
|
||||
const meta = text.replace(new RegExp(label, 'i'), '').replace(/^[\s—–-]+/, '').trim()
|
||||
return { label, meta }
|
||||
}
|
||||
|
||||
function isExerciseHeaderEl($: CheerioAPI, $el: Cheerio<AnyNode>): boolean {
|
||||
return $el.hasClass('pub-callout-warning')
|
||||
&& /exercice|exercise|تمرین/i.test(stripTags($el.html() || ''))
|
||||
}
|
||||
|
||||
function isEnonceHeadingEl($: CheerioAPI, $el: Cheerio<AnyNode>): boolean {
|
||||
if (!$el.is('h2, h3')) return false
|
||||
const text = stripTags($el.html() || '').toLowerCase()
|
||||
return /énoncé|enonce|problem|question|statement/.test(text)
|
||||
}
|
||||
|
||||
function bundleExerciseSequences($: CheerioAPI, $root: Cheerio<AnyNode>): void {
|
||||
const nodes = $root.children().toArray()
|
||||
const out: string[] = []
|
||||
let i = 0
|
||||
|
||||
while (i < nodes.length) {
|
||||
const $node = $(nodes[i])
|
||||
if (!isExerciseHeaderEl($, $node)) {
|
||||
out.push($.html(nodes[i]) || '')
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
const { label, meta } = extractExerciseMeta($node.html() || '')
|
||||
const bodyParts: string[] = []
|
||||
let solutionHtml = ''
|
||||
let j = i + 1
|
||||
|
||||
if (j < nodes.length && isEnonceHeadingEl($, $(nodes[j]))) {
|
||||
bodyParts.push($.html(nodes[j]) || '')
|
||||
j++
|
||||
}
|
||||
|
||||
while (j < nodes.length) {
|
||||
const $sib = $(nodes[j])
|
||||
if (isExerciseHeaderEl($, $sib)) break
|
||||
if ($sib.is('details')) {
|
||||
const sum = stripTags($sib.find('summary').first().text())
|
||||
$sib.removeClass('pub-toggle').addClass('pub-solution')
|
||||
$sib.find('summary').first().text('Voir la solution')
|
||||
$sib.find('.pub-toggle-body').removeClass('pub-toggle-body').addClass('pub-solution-body')
|
||||
solutionHtml = $.html(nodes[j]) || ''
|
||||
j++
|
||||
break
|
||||
}
|
||||
if ($sib.is('h2') && bodyParts.length > 0) break
|
||||
bodyParts.push($.html(nodes[j]) || '')
|
||||
j++
|
||||
}
|
||||
|
||||
const metaSpan = meta ? `<span class="pub-exercise-meta">${esc(meta)}</span>` : ''
|
||||
out.push(`<div class="pub-exercise">
|
||||
<div class="pub-exercise-header"><span class="pub-exercise-num">${esc(label)}</span>${metaSpan}</div>
|
||||
<div class="pub-exercise-body">${bodyParts.join('')}</div>
|
||||
${solutionHtml}
|
||||
</div>`)
|
||||
i = j
|
||||
}
|
||||
|
||||
$root.html(out.join(''))
|
||||
}
|
||||
|
||||
function promoteDefinitionBlocks($: CheerioAPI): void {
|
||||
const headings = $('h2, h3').toArray()
|
||||
for (const el of headings) {
|
||||
const $h = $(el)
|
||||
if ($h.closest('.pub-exercise, .pub-definition').length) continue
|
||||
|
||||
const title = stripTags($h.html() || '')
|
||||
const $next = $h.next()
|
||||
if (!title || title.length > 80 || !$next.length) continue
|
||||
|
||||
const isShortTitle = title.split(/\s+/).length <= 6
|
||||
const looksLikeDefinition = /définition|definition|concept/i.test(title)
|
||||
|| (isShortTitle && $next.is('p, ul, ol') && !/énoncé|exercice|chapitre|partie/i.test(title))
|
||||
|
||||
if (!looksLikeDefinition) continue
|
||||
|
||||
const chunks: Cheerio<AnyNode>[] = []
|
||||
let $cursor: Cheerio<AnyNode> | null = $next
|
||||
while ($cursor?.length) {
|
||||
const tag = $cursor.prop('tagName')?.toLowerCase()
|
||||
if (tag === 'h2' || tag === 'h3') break
|
||||
if ($cursor.is('p, ul, ol, blockquote')) {
|
||||
chunks.push($cursor)
|
||||
$cursor = $cursor.next()
|
||||
} else break
|
||||
}
|
||||
|
||||
if (chunks.length === 0) continue
|
||||
|
||||
const bodyHtml = chunks.map((c) => $.html(c)).join('')
|
||||
chunks.forEach((c) => c.remove())
|
||||
$h.replaceWith(`<div class="pub-definition">
|
||||
<div class="pub-definition-term">${esc(title)}</div>
|
||||
<div class="pub-definition-body">${bodyHtml}</div>
|
||||
</div>`)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanEditorArtifacts($: CheerioAPI): void {
|
||||
$('button').remove()
|
||||
$('div[data-type="outline-block"]').remove()
|
||||
$('.link-preview-searchable').remove()
|
||||
$('[contenteditable]').removeAttr('contenteditable')
|
||||
}
|
||||
|
||||
/** Convertit le HTML TipTap en blocs web (exercices, toggles, callouts…). */
|
||||
export function transformEditorBlocksForPublish(html: string): string {
|
||||
if (!html?.trim()) return ''
|
||||
|
||||
const $ = load(`<div id="pub-root">${html}</div>`, { decodeEntities: false } as never)
|
||||
const $root = $('#pub-root')
|
||||
|
||||
cleanEditorArtifacts($)
|
||||
transformToggleBlocks($)
|
||||
transformCalloutBlocks($)
|
||||
bundleExerciseSequences($, $root)
|
||||
promoteDefinitionBlocks($)
|
||||
cleanEditorArtifacts($)
|
||||
|
||||
return $root.html() || ''
|
||||
}
|
||||
21
memento-note/lib/publish/types.ts
Normal file
21
memento-note/lib/publish/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export const PUBLISH_TEMPLATES = ['magazine', 'brief', 'essay'] as const
|
||||
export type PublishTemplateId = (typeof PUBLISH_TEMPLATES)[number]
|
||||
|
||||
/** Métadonnées éditoriales IA — corps = HTML source original. */
|
||||
export interface PublishEnhanceSpec {
|
||||
summary: string
|
||||
pullQuote?: string
|
||||
epigraph?: string
|
||||
keyPoints?: string[]
|
||||
}
|
||||
|
||||
/** Réécriture IA — corps = HTML sémantique généré par l'IA. */
|
||||
export interface PublishRewriteSpec {
|
||||
contentType: 'article' | 'exercises' | 'tutorial' | 'reference' | 'mixed'
|
||||
summary: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export function isPublishTemplateId(value: string): value is PublishTemplateId {
|
||||
return (PUBLISH_TEMPLATES as readonly string[]).includes(value)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export const VALID_FEATURES = [
|
||||
'excalidraw_generate',
|
||||
'ai_flashcard',
|
||||
'voice_transcribe',
|
||||
'publish_enhance',
|
||||
] as const;
|
||||
|
||||
export type FeatureName = (typeof VALID_FEATURES)[number];
|
||||
|
||||
@@ -27,3 +27,29 @@ export function sanitizeRichHtml(html: string): string {
|
||||
if (!html) return ''
|
||||
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } })
|
||||
}
|
||||
|
||||
/** Sanitisation pages publiées — préserve le HTML généré par KaTeX (MathML + spans). */
|
||||
const KATEX_MATH_TAGS = [
|
||||
'math', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'mfrac', 'msqrt',
|
||||
'mroot', 'mtext', 'mspace', 'mstyle', 'mpadded', 'mphantom', 'menclose',
|
||||
'mover', 'munder', 'munderover', 'mtable', 'mtr', 'mtd', 'mlabeledtr',
|
||||
'annotation', 'maligngroup', 'malignmark',
|
||||
] as const
|
||||
|
||||
const KATEX_MATH_ATTR = [
|
||||
'xmlns', 'display', 'mathvariant', 'mathsize', 'mathcolor', 'dir',
|
||||
'columnalign', 'rowalign', 'columnspacing', 'rowspacing', 'stretchy',
|
||||
'symmetric', 'maxsize', 'minsize', 'largeop', 'movablelimits', 'accent',
|
||||
'accentunder', 'fence', 'separator', 'lspace', 'rspace', 'depth', 'height',
|
||||
'width', 'displaystyle', 'scriptlevel', 'class', 'style', 'aria-hidden',
|
||||
'encoding', 'data-latex',
|
||||
] as const
|
||||
|
||||
export function sanitizePublishedHtml(html: string): string {
|
||||
if (!html) return ''
|
||||
return DOMPurify.sanitize(html, {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_TAGS: [...KATEX_MATH_TAGS],
|
||||
ADD_ATTR: [...KATEX_MATH_ATTR],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,7 +88,12 @@ export interface Note {
|
||||
searchScore?: number | null;
|
||||
isPublic?: boolean;
|
||||
publicSlug?: string | null;
|
||||
publishedContent?: string | null;
|
||||
publishedTemplate?: string | null;
|
||||
publishedSourceHash?: string | null;
|
||||
publishedAt?: Date | null;
|
||||
/** Champ virtuel ajouté côté serveur — true si la note est partagée avec l'utilisateur courant */
|
||||
_isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface NoteHistoryEntry {
|
||||
|
||||
@@ -575,7 +575,7 @@
|
||||
"noNoteId": "No note ID"
|
||||
},
|
||||
"openAssistant": "فتح مساعد الذكاء الاصطناعي",
|
||||
"poweredByMomento": "مدعوم من Momento AI",
|
||||
"poweredByMomento": "مدعوم من Memento AI",
|
||||
"welcomeMsg": "مرحبًا! أنا مساعدك الذكي. كيف يمكنني مساعدتك في ملاحظاتك اليوم؟ يمكنني تحسين النبرة أو توسيع الرسائل أو تلخيص المحتوى.",
|
||||
"summaryLast5": "ملخص آخر 5 ملاحظات",
|
||||
"analyzingProgress": "جاري التحليل...",
|
||||
@@ -2184,7 +2184,30 @@
|
||||
"importMarkdown": "استيراد Markdown",
|
||||
"markdownExportSuccess": "تم تصدير الملاحظة كـ Markdown",
|
||||
"markdownExportError": "فشل تصدير الملاحظة",
|
||||
"markdownImportSuccess": "تم استيراد Markdown بنجاح"
|
||||
"markdownImportSuccess": "تم استيراد Markdown بنجاح",
|
||||
"publishTitle": "النشر العام",
|
||||
"publishDesc": "انشر هذه الملاحظة على رابط عام. يمكن لأي شخص لديه الرابط قراءتها.",
|
||||
"publish": "نشر",
|
||||
"publishSuccess": "تم نشر الملاحظة!",
|
||||
"publishLive": "مباشر",
|
||||
"unpublish": "إلغاء النشر",
|
||||
"unpublishSuccess": "تم إلغاء نشر الملاحظة",
|
||||
"publishBlocked": "تم رفض النشر",
|
||||
"publishSimple": "نشر كما هو",
|
||||
"publishSimpleHint": "مجاني — محتوى ملاحظتك كما هو",
|
||||
"publishWithAi": "النشر بالذكاء الاصطناعي",
|
||||
"publishWithAiHint": "تخطيط محسّن · {count} رصيد متبقي",
|
||||
"publishWithAiLocked": "متاح في باقة Pro",
|
||||
"publishWithAiGenerating": "جاري إنشاء التخطيط…",
|
||||
"publishTemplateMagazine": "مقال مجلات",
|
||||
"publishTemplateBrief": "إفادة خبير",
|
||||
"publishTemplateEssay": "مقال",
|
||||
"publishAiSuccess": "تم نشر الصفحة المحسّنة بالذكاء الاصطناعي!",
|
||||
"publishRewriteLabel": "إعادة الكتابة للويب",
|
||||
"publishRewriteOnHint": "ينظّم كتل المحرر (تمارين، أقسام قابلة للطي، صناديق تنبيه) للويب — الذكاء الاصطناعي يكتب المقدمة فقط",
|
||||
"publishRewriteOffHint": "يُحفظ نصك الأصلي؛ الذكاء الاصطناعي يضيف فقط الإطار التحريري",
|
||||
"copyPublicLink": "نسخ الرابط",
|
||||
"openPublicPage": "فتح الصفحة العامة"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2492,7 +2515,7 @@
|
||||
"badge": "مدعوم بالذكاء الاصطناعي",
|
||||
"title1": "عقلك الثاني،",
|
||||
"title2": "مُعزَّز أخيرًا.",
|
||||
"subtitle": "Momento ليست مجرد تطبيق للملاحظات. إنها منظومة ذكية تربط أفكارك وتحللها وتطورها في الوقت الفعلي بفضل 6 أنواع من وكلاء الذكاء الاصطناعي والبحث الدلالي المتقدم.",
|
||||
"subtitle": "Memento ليست مجرد تطبيق للملاحظات. إنها منظومة ذكية تربط أفكارك وتحللها وتطورها في الوقت الفعلي بفضل 6 أنواع من وكلاء الذكاء الاصطناعي والبحث الدلالي المتقدم.",
|
||||
"cta": "سجّل الآن",
|
||||
"secondary": "استكشف الميزات",
|
||||
"memoryEcho": "Memory Echo",
|
||||
@@ -2504,7 +2527,7 @@
|
||||
"label": "قدرات الذكاء الاصطناعي",
|
||||
"title": "ذكاء سلس،",
|
||||
"title2": "منسوج في كل كلمة.",
|
||||
"desc": "تنسّق Momento أفكارك عبر بنية متعددة المزوّدين.",
|
||||
"desc": "تنسّق Memento أفكارك عبر بنية متعددة المزوّدين.",
|
||||
"f1Title": "البحث الدلالي",
|
||||
"f1Desc": "توقّف عن البحث بالكلمات المفتاحية. ابحث بالمفهوم. محركنا الهجين Vector + FTS يفهم القصد وراء ملاحظاتك.",
|
||||
"f2Title": "دردشة RAG السياقية",
|
||||
@@ -2590,7 +2613,7 @@
|
||||
"popular": "الأكثر شعبية",
|
||||
"basic": {
|
||||
"name": "Basic",
|
||||
"desc": "اكتشف سحر Momento.",
|
||||
"desc": "اكتشف سحر Memento.",
|
||||
"cta": "ابدأ",
|
||||
"feature0": "100 ملاحظة كحد أقصى",
|
||||
"feature1": "3 دفاتر",
|
||||
@@ -2636,7 +2659,7 @@
|
||||
"byok": {
|
||||
"label": "تقنية سحابية مفتوحة",
|
||||
"title": "استراتيجية BYOK",
|
||||
"desc": "لديك مفاتيح API من OpenAI أو Anthropic أو Google؟ اربطها مباشرة بـ Momento. استخدم الذكاء الاصطناعي دون حدود ائتمان مفروضة، وادفع فقط ما تستهلكه لدى مزوّدك المفضل.",
|
||||
"desc": "لديك مفاتيح API من OpenAI أو Anthropic أو Google؟ اربطها مباشرة بـ Memento. استخدم الذكاء الاصطناعي دون حدود ائتمان مفروضة، وادفع فقط ما تستهلكه لدى مزوّدك المفضل.",
|
||||
"noLockin": "بدون قفل",
|
||||
"noLockinDesc": "بدّل المزوّد بنقرة واحدة.",
|
||||
"cost": "تكاليف محسّنة",
|
||||
@@ -2646,8 +2669,8 @@
|
||||
"cta": {
|
||||
"title1": "مستعد لإطلاق",
|
||||
"title2": "إمكاناتك الكاملة؟",
|
||||
"desc": "انضم إلى آلاف الباحثين والمصممين والمفكرين الذين يستخدمون Momento لبناء مستقبلهم.",
|
||||
"button": "تشغيل Momento"
|
||||
"desc": "انضم إلى آلاف الباحثين والمصممين والمفكرين الذين يستخدمون Memento لبناء مستقبلهم.",
|
||||
"button": "تشغيل Memento"
|
||||
},
|
||||
"footer": {
|
||||
"desc": "العقل الثاني المعزّز بالذكاء الاصطناعي. صُمم للعقول المبدعة.",
|
||||
@@ -2704,14 +2727,14 @@
|
||||
"necessaryDesc": "These cookies are required to authenticate your session, save your language and theme preferences. They cannot be turned off.",
|
||||
"alwaysOn": "Always On",
|
||||
"analyticsTitle": "Anonymous Analytics",
|
||||
"analyticsDesc": "Help us improve Momento by sending completely anonymous usage statistics. No personal data is ever tracked.",
|
||||
"analyticsDesc": "Help us improve Memento by sending completely anonymous usage statistics. No personal data is ever tracked.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Preferences",
|
||||
"saved": "Preferences updated successfully."
|
||||
},
|
||||
"ai": {
|
||||
"modalTitle": "مطلوب موافقة على معالجة الذكاء الاصطناعي",
|
||||
"modalDescription": "لتحليل ملاحظاتك أو ملفات PDF أو جلسات العصف الذهني، ينقل Momento هذه البيانات بأمان إلى واجهات ذكاء اصطناعي تابعة لجهات خارجية (OpenAI وGemini وDeepSeek). نطبّق سياسة صارمة بعدم الاحتفاظ بالبيانات. بقبولك، تفوّض هذا المعالجة.",
|
||||
"modalDescription": "لتحليل ملاحظاتك أو ملفات PDF أو جلسات العصف الذهني، ينقل Memento هذه البيانات بأمان إلى واجهات ذكاء اصطناعي تابعة لجهات خارجية (OpenAI وGemini وDeepSeek). نطبّق سياسة صارمة بعدم الاحتفاظ بالبيانات. بقبولك، تفوّض هذا المعالجة.",
|
||||
"zeroRetentionTitle": "عدم الاحتفاظ بالبيانات",
|
||||
"zeroRetentionDesc": "تتضمن جميع الطلبات الصادرة مؤشرات لمنع تدريب النماذج لحماية ملكيتك الفكرية.",
|
||||
"rememberMe": "تذكّر اختياري (عدم السؤال مجدداً)",
|
||||
@@ -2725,10 +2748,10 @@
|
||||
"statusActive": "نشط",
|
||||
"statusInactive": "غير ممنوح",
|
||||
"helpAriaLabel": "ماذا تعني موافقة الذكاء الاصطناعي؟",
|
||||
"helpTooltip": "Momento لا تبيع بياناتك. هذه الموافقة تنطبق فقط عند تشغيل إجراء ذكاء اصطناعي (العناوين، الوسوم، الدردشة، إعادة الصياغة). بدونها، يعمل بقية التطبيق بشكل طبيعي.",
|
||||
"helpTooltip": "Memento لا تبيع بياناتك. هذه الموافقة تنطبق فقط عند تشغيل إجراء ذكاء اصطناعي (العناوين، الوسوم، الدردشة، إعادة الصياغة). بدونها، يعمل بقية التطبيق بشكل طبيعي.",
|
||||
"whatItMeansTitle": "ماذا يعني هذا؟",
|
||||
"inactiveHint": "بدون موافقة، تُعطَّل ميزات الذكاء الاصطناعي فقط. يمكنك متابعة إنشاء الملاحظات وتحريرها وتنظيمها كالمعتاد.",
|
||||
"noCommercialUse": "Momento لا تبيع بياناتك الشخصية ولا تحقق منها أي عائد مالي.",
|
||||
"noCommercialUse": "Memento لا تبيع بياناتك الشخصية ولا تحقق منها أي عائد مالي.",
|
||||
"affectedFeatures": "الميزات المتأثرة: اقتراحات العناوين، الوسوم، الدردشة، إعادة الصياغة، Memory Echo، إلخ.",
|
||||
"dataPortabilityHint": "يمكنك تصدير أو حذف جميع بياناتك في أي وقت (الإعدادات → البيانات).",
|
||||
"dataPortabilityLink": "تصدير بياناتي",
|
||||
@@ -2782,7 +2805,7 @@
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "ذاكرتك المعززة بالذكاء الاصطناعي",
|
||||
"welcome_subtitle": "Momento يتذكر ما تنساه.",
|
||||
"welcome_subtitle": "Memento يتذكر ما تنساه.",
|
||||
"welcome_cta": "ابدأ",
|
||||
"skip": "تخطي",
|
||||
"step_notes_title": "ملاحظاتك",
|
||||
@@ -2794,7 +2817,7 @@
|
||||
"step_aha_title": "اعثر على ما نسيته",
|
||||
"step_aha_subtitle": "اطرح سؤالاً. اعثر على ملاحظة نسيتها.",
|
||||
"step_aha_placeholder": "ملاحظات حول الإنتاجية...",
|
||||
"step_aha_cta": "استكشف Momento",
|
||||
"step_aha_cta": "استكشف Memento",
|
||||
"progress": "{current} من {total}",
|
||||
"creating_demo_notes": "جارٍ إنشاء الملاحظات التجريبية...",
|
||||
"demo_notes_ready": "تم إنشاء 5 ملاحظات تجريبية!",
|
||||
@@ -2898,4 +2921,4 @@
|
||||
"copyRefNoNote": "Save the note before copying a block reference",
|
||||
"copyRefUnsupported": "This block type cannot be referenced yet"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,7 +575,7 @@
|
||||
"noNoteId": "Keine Notiz-ID"
|
||||
},
|
||||
"openAssistant": "KI-Assistenten öffnen",
|
||||
"poweredByMomento": "Angetrieben von Momento AI",
|
||||
"poweredByMomento": "Angetrieben von Memento AI",
|
||||
"welcomeMsg": "Hallo! Ich bin dein KI-Assistent. Wie kann ich dir heute mit deinen Notizen helfen? Ich kann den Ton verfeinern, Nachrichten erweitern oder Inhalte zusammenfassen.",
|
||||
"summaryLast5": "Zusammenfassung deiner letzten 5 Notizen",
|
||||
"analyzingProgress": "Analyse läuft...",
|
||||
@@ -2184,7 +2184,30 @@
|
||||
"importMarkdown": "Markdown importieren",
|
||||
"markdownExportSuccess": "Notiz als Markdown exportiert",
|
||||
"markdownExportError": "Export der Notiz fehlgeschlagen",
|
||||
"markdownImportSuccess": "Markdown erfolgreich importiert"
|
||||
"markdownImportSuccess": "Markdown erfolgreich importiert",
|
||||
"publishTitle": "Öffentliche Veröffentlichung",
|
||||
"publishDesc": "Veröffentliche diese Notiz auf einer öffentlichen URL. Jeder mit dem Link kann sie lesen.",
|
||||
"publish": "Veröffentlichen",
|
||||
"publishSuccess": "Notiz veröffentlicht!",
|
||||
"publishLive": "Online",
|
||||
"unpublish": "Zurückziehen",
|
||||
"unpublishSuccess": "Notiz zurückgezogen",
|
||||
"publishBlocked": "Veröffentlichung abgelehnt",
|
||||
"publishSimple": "Wie vorhanden veröffentlichen",
|
||||
"publishSimpleHint": "Kostenlos — dein Notizinhalt unverändert",
|
||||
"publishWithAi": "Mit KI veröffentlichen",
|
||||
"publishWithAiHint": "Verbessertes Layout · {count} Credits übrig",
|
||||
"publishWithAiLocked": "Im Pro-Tarif verfügbar",
|
||||
"publishWithAiGenerating": "Layout wird generiert…",
|
||||
"publishTemplateMagazine": "Magazinartikel",
|
||||
"publishTemplateBrief": "Experten-Briefing",
|
||||
"publishTemplateEssay": "Essay",
|
||||
"publishAiSuccess": "KI-optimierte Seite veröffentlicht!",
|
||||
"publishRewriteLabel": "Für das Web umschreiben",
|
||||
"publishRewriteOnHint": "Strukturiert deine Editor-Blöcke (Übungen, Ausklappbereiche, Hinweiskästen) fürs Web — KI schreibt nur die Einleitung",
|
||||
"publishRewriteOffHint": "Dein Originaltext bleibt erhalten; KI fügt nur redaktionelle Rahmentexte hinzu",
|
||||
"copyPublicLink": "Link kopieren",
|
||||
"openPublicPage": "Öffentliche Seite öffnen"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -2492,7 +2515,7 @@
|
||||
"badge": "Angetrieben von Künstlicher Intelligenz",
|
||||
"title1": "Ihr zweites Gehirn,",
|
||||
"title2": "endlich verstärkt.",
|
||||
"subtitle": "Momento ist mehr als eine Notizen-App. Es ist ein intelligentes Ökosystem, das Ihre Ideen in Echtzeit verbindet, analysiert und weiterentwickelt – mit 6 Arten von KI-Agenten und modernster semantischer Suche.",
|
||||
"subtitle": "Memento ist mehr als eine Notizen-App. Es ist ein intelligentes Ökosystem, das Ihre Ideen in Echtzeit verbindet, analysiert und weiterentwickelt – mit 6 Arten von KI-Agenten und modernster semantischer Suche.",
|
||||
"cta": "Jetzt registrieren",
|
||||
"secondary": "Funktionen ansehen",
|
||||
"memoryEcho": "Memory Echo",
|
||||
@@ -2504,7 +2527,7 @@
|
||||
"label": "KI-Fähigkeiten",
|
||||
"title": "Fließende Intelligenz,",
|
||||
"title2": "in jedes Wort eingewoben.",
|
||||
"desc": "Momento orchestriert Ihre Ideen über eine Multi-Provider-Architektur.",
|
||||
"desc": "Memento orchestriert Ihre Ideen über eine Multi-Provider-Architektur.",
|
||||
"f1Title": "Semantische Suche",
|
||||
"f1Desc": "Schluss mit der Stichwortsuche. Finden Sie nach Konzept. Unsere hybride Vector- und FTS-Engine versteht die Absicht hinter Ihren Notizen.",
|
||||
"f2Title": "Kontextueller RAG-Chat",
|
||||
@@ -2590,7 +2613,7 @@
|
||||
"popular": "Am beliebtesten",
|
||||
"basic": {
|
||||
"name": "Basic",
|
||||
"desc": "Entdecken Sie die Magie von Momento.",
|
||||
"desc": "Entdecken Sie die Magie von Memento.",
|
||||
"cta": "Loslegen",
|
||||
"feature0": "Max. 100 Notizen",
|
||||
"feature1": "3 Notizbücher",
|
||||
@@ -2636,7 +2659,7 @@
|
||||
"byok": {
|
||||
"label": "Offene Cloud-Technologie",
|
||||
"title": "Die BYOK-Strategie",
|
||||
"desc": "Sie haben bereits API-Schlüssel von OpenAI, Anthropic oder Google? Verbinden Sie sie direkt mit Momento. Nutzen Sie KI ohne erzwungene Credit-Limits und zahlen Sie nur, was Sie beim Anbieter Ihrer Wahl tatsächlich verbrauchen.",
|
||||
"desc": "Sie haben bereits API-Schlüssel von OpenAI, Anthropic oder Google? Verbinden Sie sie direkt mit Memento. Nutzen Sie KI ohne erzwungene Credit-Limits und zahlen Sie nur, was Sie beim Anbieter Ihrer Wahl tatsächlich verbrauchen.",
|
||||
"noLockin": "Kein Lock-in",
|
||||
"noLockinDesc": "Anbieter mit einem Klick wechseln.",
|
||||
"cost": "Optimierte Kosten",
|
||||
@@ -2646,8 +2669,8 @@
|
||||
"cta": {
|
||||
"title1": "Bereit, Ihr",
|
||||
"title2": "volles Potenzial freizusetzen?",
|
||||
"desc": "Schließen Sie sich Tausenden von Forschern, Designern und Denkern an, die Momento bereits nutzen, um ihre Zukunft zu gestalten.",
|
||||
"button": "Momento starten"
|
||||
"desc": "Schließen Sie sich Tausenden von Forschern, Designern und Denkern an, die Memento bereits nutzen, um ihre Zukunft zu gestalten.",
|
||||
"button": "Memento starten"
|
||||
},
|
||||
"footer": {
|
||||
"desc": "Das KI-verstärkte zweite Gehirn. Für kreative Köpfe entwickelt.",
|
||||
@@ -2704,7 +2727,7 @@
|
||||
"necessaryDesc": "These cookies are required to authenticate your session, save your language and theme preferences. They cannot be turned off.",
|
||||
"alwaysOn": "Always On",
|
||||
"analyticsTitle": "Anonymous Analytics",
|
||||
"analyticsDesc": "Help us improve Momento by sending completely anonymous usage statistics. No personal data is ever tracked.",
|
||||
"analyticsDesc": "Help us improve Memento by sending completely anonymous usage statistics. No personal data is ever tracked.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Preferences",
|
||||
"saved": "Preferences updated successfully."
|
||||
@@ -2725,10 +2748,10 @@
|
||||
"statusActive": "Aktiv",
|
||||
"statusInactive": "Nicht erteilt",
|
||||
"helpAriaLabel": "Was bedeutet die KI-Einwilligung?",
|
||||
"helpTooltip": "Momento verkauft Ihre Daten nicht. Diese Einwilligung gilt nur bei ausdrücklicher KI-Nutzung (Titel, Tags, Chat, Umformulierung). Ohne sie funktioniert der Rest der App normal.",
|
||||
"helpTooltip": "Memento verkauft Ihre Daten nicht. Diese Einwilligung gilt nur bei ausdrücklicher KI-Nutzung (Titel, Tags, Chat, Umformulierung). Ohne sie funktioniert der Rest der App normal.",
|
||||
"whatItMeansTitle": "Was bedeutet das?",
|
||||
"inactiveHint": "Ohne Einwilligung sind nur KI-Funktionen deaktiviert. Notizen erstellen, bearbeiten und organisieren funktioniert weiterhin.",
|
||||
"noCommercialUse": "Momento verkauft oder monetarisiert Ihre personenbezogenen Daten nicht.",
|
||||
"noCommercialUse": "Memento verkauft oder monetarisiert Ihre personenbezogenen Daten nicht.",
|
||||
"affectedFeatures": "Betroffene Funktionen: Titelvorschläge, Tags, KI-Chat, Umformulierung, Memory Echo usw.",
|
||||
"dataPortabilityHint": "Sie können alle Daten jederzeit exportieren oder löschen (Einstellungen → Daten).",
|
||||
"dataPortabilityLink": "Meine Daten exportieren",
|
||||
@@ -2782,7 +2805,7 @@
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "Ihr KI-erweitertes Gedächtnis",
|
||||
"welcome_subtitle": "Momento erinnert sich an das, was Sie vergessen.",
|
||||
"welcome_subtitle": "Memento erinnert sich an das, was Sie vergessen.",
|
||||
"welcome_cta": "Loslegen",
|
||||
"skip": "Überspringen",
|
||||
"step_notes_title": "Ihre Notizen",
|
||||
@@ -2794,7 +2817,7 @@
|
||||
"step_aha_title": "Finden Sie, was Sie vergessen haben",
|
||||
"step_aha_subtitle": "Stellen Sie eine Frage. Finden Sie eine vergessene Notiz.",
|
||||
"step_aha_placeholder": "Notizen zur Produktivität...",
|
||||
"step_aha_cta": "Momento erkunden",
|
||||
"step_aha_cta": "Memento erkunden",
|
||||
"progress": "{current} von {total}",
|
||||
"creating_demo_notes": "Beispielnotizen werden erstellt...",
|
||||
"demo_notes_ready": "5 Beispielnotizen erstellt!",
|
||||
@@ -2898,4 +2921,4 @@
|
||||
"copyRefNoNote": "Save the note before copying a block reference",
|
||||
"copyRefUnsupported": "This block type cannot be referenced yet"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"createYourSpaceSubtitle": "Join the new era of smart note-taking.",
|
||||
"forgot": "Forgot?",
|
||||
"backToSite": "Back to site",
|
||||
"privacyTerms": "© 2025 Momento Labs — Privacy · Terms"
|
||||
"privacyTerms": "© 2025 Memento Labs — Privacy · Terms"
|
||||
},
|
||||
"sidebar": {
|
||||
"notes": "Notes",
|
||||
@@ -623,7 +623,7 @@
|
||||
"noNoteId": "No note ID"
|
||||
},
|
||||
"openAssistant": "Open AI Note",
|
||||
"poweredByMomento": "Powered by Momento AI",
|
||||
"poweredByMomento": "Powered by Memento AI",
|
||||
"welcomeMsg": "Hello! I'm your AI assistant. How can I help you with your notes today? I can help refine tone, expand messaging, or summarize content.",
|
||||
"summaryLast5": "Summary of your last 5 notes",
|
||||
"analyzingProgress": "Analyzing...",
|
||||
@@ -808,7 +808,7 @@
|
||||
"openInEditor": "Open in editor",
|
||||
"badgeLabel": "Memory Echo",
|
||||
"affinityBadge": "{percentage}% semantic affinity",
|
||||
"intro": "Momento found another note on the same topic. Preview it, insert a citation, or merge — without leaving this one.",
|
||||
"intro": "Memento found another note on the same topic. Preview it, insert a citation, or merge — without leaving this one.",
|
||||
"detectedIn": "Passage found in: {title}",
|
||||
"helpToggle": "How does this work?",
|
||||
"helpTitle": "What can you do with this connection?",
|
||||
@@ -895,12 +895,12 @@
|
||||
"processingDetail": "Generating tags, semantic summary and embeddings.",
|
||||
"successBadge": "Success",
|
||||
"sentToNotebook": "Note saved to notebook",
|
||||
"viewInMomento": "View in Momento",
|
||||
"viewInMomento": "View in Memento",
|
||||
"clipAnother": "Clip another page",
|
||||
"captureFailed": "Capture failed",
|
||||
"genericError": "Something went wrong while sending to your instance.",
|
||||
"retry": "Retry",
|
||||
"footer": "Momento Companion v2.1.2 · Secured HTTPS TLS 1.3",
|
||||
"footer": "Memento Companion v2.1.2 · Secured HTTPS TLS 1.3",
|
||||
"quitSimulator": "Close simulator",
|
||||
"simulatorBadge": "Capture simulator",
|
||||
"extensionActive": "Extension active on this page",
|
||||
@@ -1726,6 +1726,7 @@
|
||||
"featureBrainstormCreate": "Brainstorm creations",
|
||||
"featureBrainstormSessions": "Brainstorm sessions",
|
||||
"featureCharts": "AI Charts",
|
||||
"featurePublishEnhance": "AI Publishing",
|
||||
"featureBrainstormExpand": "Brainstorm expansions",
|
||||
"featureBrainstormEnrich": "Brainstorm enrichments"
|
||||
},
|
||||
@@ -2534,7 +2535,7 @@
|
||||
"imageModalLoadFailed": "Failed to load image",
|
||||
"linkPlaceholder": "Paste or type a link...",
|
||||
"noteLinkPickerTitle": "Link to another note",
|
||||
"noteLinkPickerHint": "Pick a note. Momento inserts [[Title]] in the text and creates the link when you save.",
|
||||
"noteLinkPickerHint": "Pick a note. Memento inserts [[Title]] in the text and creates the link when you save.",
|
||||
"noteLinkPickerSearch": "Search notes…",
|
||||
"noteLinkPickerEmpty": "No notes found. Check the title or create the target note first.",
|
||||
"slashNoteLink": "Link to a note",
|
||||
@@ -2582,6 +2583,21 @@
|
||||
"unpublish": "Unpublish",
|
||||
"unpublishSuccess": "Note unpublished",
|
||||
"publishBlocked": "Publication refused",
|
||||
"publishSimple": "Publish as-is",
|
||||
"publishSimpleHint": "Free — your note content as-is",
|
||||
"publishWithAi": "Publish with AI",
|
||||
"publishWithAiHint": "Enhanced layout · {count} credits left",
|
||||
"publishWithAiLocked": "Available on Pro plan",
|
||||
"publishWithAiGenerating": "Generating layout…",
|
||||
"publishTemplateMagazine": "Magazine article",
|
||||
"publishTemplateBrief": "Expert brief",
|
||||
"publishTemplateEssay": "Essay",
|
||||
"publishAiSuccess": "AI-enhanced page published!",
|
||||
"publishRewriteLabel": "Rewrite for the web",
|
||||
"publishRewriteOnHint": "Structures your editor blocks (exercises, toggles, callouts) for the web — AI writes only the intro",
|
||||
"publishRewriteOffHint": "Your original text is kept; AI only adds editorial framing",
|
||||
"copyPublicLink": "Copy link",
|
||||
"openPublicPage": "Open public page",
|
||||
"slashSubPage": "Sub-page",
|
||||
"slashSubPageDesc": "Create a linked note inside this note",
|
||||
"slashCharts": "AI Charts",
|
||||
@@ -3204,7 +3220,7 @@
|
||||
"badge": "Powered by Artificial Intelligence",
|
||||
"title1": "Your second brain,",
|
||||
"title2": "finally amplified.",
|
||||
"subtitle": "Momento 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.",
|
||||
"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",
|
||||
@@ -3216,7 +3232,7 @@
|
||||
"label": "AI Capabilities",
|
||||
"title": "Fluid intelligence,",
|
||||
"title2": "woven into every word.",
|
||||
"desc": "Momento orchestrates your ideas through a multi-provider architecture.",
|
||||
"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",
|
||||
@@ -3302,7 +3318,7 @@
|
||||
"popular": "Most popular",
|
||||
"basic": {
|
||||
"name": "Basic",
|
||||
"desc": "Discover the magic of Momento.",
|
||||
"desc": "Discover the magic of Memento.",
|
||||
"cta": "Get started",
|
||||
"feature0": "100 Notes max",
|
||||
"feature1": "3 Notebooks",
|
||||
@@ -3348,7 +3364,7 @@
|
||||
"byok": {
|
||||
"label": "Open Cloud Technology",
|
||||
"title": "The BYOK Strategy",
|
||||
"desc": "Already have OpenAI, Anthropic or Google API keys? Connect them directly to Momento. Use AI without imposed credit limits, paying only what you actually consume from your favorite provider.",
|
||||
"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",
|
||||
@@ -3358,8 +3374,8 @@
|
||||
"cta": {
|
||||
"title1": "Ready to unlock your",
|
||||
"title2": "full potential?",
|
||||
"desc": "Join thousands of researchers, designers and thinkers already using Momento to build their future.",
|
||||
"button": "Launch Momento"
|
||||
"desc": "Join thousands of researchers, designers and thinkers already using Memento to build their future.",
|
||||
"button": "Launch Memento"
|
||||
},
|
||||
"footer": {
|
||||
"desc": "The AI-amplified second brain. Designed for creative minds.",
|
||||
@@ -3527,7 +3543,7 @@
|
||||
"necessaryDesc": "These cookies are required to authenticate your session, save your language and theme preferences. They cannot be turned off.",
|
||||
"alwaysOn": "Always On",
|
||||
"analyticsTitle": "Anonymous Analytics",
|
||||
"analyticsDesc": "Help us improve Momento by sending completely anonymous usage statistics. No personal data is ever tracked.",
|
||||
"analyticsDesc": "Help us improve Memento by sending completely anonymous usage statistics. No personal data is ever tracked.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Preferences",
|
||||
"saved": "Preferences updated successfully."
|
||||
@@ -3548,10 +3564,10 @@
|
||||
"statusActive": "Active",
|
||||
"statusInactive": "Not granted",
|
||||
"helpAriaLabel": "What does AI consent mean?",
|
||||
"helpTooltip": "Momento does not sell your data. This consent only applies when you trigger an AI action (titles, tags, chat, reformulation). Without it, the rest of the app works normally.",
|
||||
"helpTooltip": "Memento does not sell your data. This consent only applies when you trigger an AI action (titles, tags, chat, reformulation). Without it, the rest of the app works normally.",
|
||||
"whatItMeansTitle": "What does this mean?",
|
||||
"inactiveHint": "Without consent, only AI-powered features are disabled. You can still create, edit, and organize notes as usual.",
|
||||
"noCommercialUse": "Momento does not sell or monetize your personal data.",
|
||||
"noCommercialUse": "Memento does not sell or monetize your personal data.",
|
||||
"affectedFeatures": "AI features affected: title suggestions, tags, assistant chat, reformulation, Memory Echo, and similar tools.",
|
||||
"dataPortabilityHint": "You can export or delete all your data at any time (Settings → Data).",
|
||||
"dataPortabilityLink": "Export my data",
|
||||
@@ -3681,7 +3697,7 @@
|
||||
"localDbTitle": "Standalone Database",
|
||||
"echoPopoverTitle": "Semantic Resonances 🔮",
|
||||
"noEchoFound": "No semantic connections detected.",
|
||||
"echoUpgradeText": "Convert this table to a notebook to activate Momento's neural analysis.",
|
||||
"echoUpgradeText": "Convert this table to a notebook to activate Memento's neural analysis.",
|
||||
"echoLoading": "Searching for semantic connections...",
|
||||
"analyticsTitle": "Analytics & Insights",
|
||||
"analyticsNoData": "No analysis data available.",
|
||||
@@ -3737,7 +3753,7 @@
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome_title": "Your AI-augmented memory",
|
||||
"welcome_subtitle": "Momento remembers what you forget.",
|
||||
"welcome_subtitle": "Memento remembers what you forget.",
|
||||
"welcome_cta": "Get started",
|
||||
"skip": "Skip",
|
||||
"step_notes_title": "Your notes",
|
||||
@@ -3749,7 +3765,7 @@
|
||||
"step_aha_title": "Find what you forgot",
|
||||
"step_aha_subtitle": "Type a question. Find a note you forgot.",
|
||||
"step_aha_placeholder": "notes about productivity...",
|
||||
"step_aha_cta": "Explore Momento",
|
||||
"step_aha_cta": "Explore Memento",
|
||||
"progress": "{current} of {total}",
|
||||
"creating_demo_notes": "Creating example notes...",
|
||||
"demo_notes_ready": "5 example notes created!",
|
||||
@@ -3872,7 +3888,8 @@
|
||||
"studyPlanSuccess": "Plan created! Reminders have been added to your notes.",
|
||||
"daysPlanned": "days planned",
|
||||
"studyPlanReminders": "Reminders have been automatically added to your notes.",
|
||||
"organizer": "Organize with AI",
|
||||
"organizer": "Auto tags",
|
||||
"autoTags": "Auto tags",
|
||||
"organizerDesc": "AI analyzes your notes and suggests tags, groupings and duplicate detection.",
|
||||
"analyze": "Analyze notebook",
|
||||
"organizing": "Analyzing notes...",
|
||||
@@ -3884,5 +3901,25 @@
|
||||
"noSuggestions": "No suggestions — notebook looks well organized.",
|
||||
"taskA": "Task A",
|
||||
"taskB": "Task B"
|
||||
},
|
||||
"notebookSite": {
|
||||
"title": "Publish notebook as website",
|
||||
"shortTitle": "Website",
|
||||
"howItWorks": "How it works",
|
||||
"step1": "AI analyzes your notes and recommends which ones to publish",
|
||||
"step2": "You confirm or adjust the selection",
|
||||
"step3": "Your site is generated with navigation and table of contents",
|
||||
"quotaWarning": "Analysis uses 1 AI credit.",
|
||||
"analyzeBtn": "Analyze with AI",
|
||||
"analyzing": "AI is analyzing your notes...",
|
||||
"siteDescription": "Site description",
|
||||
"notesSelected": "notes selected",
|
||||
"template": "Visual style",
|
||||
"publishBtn": "Publish site",
|
||||
"publishing": "Generating site...",
|
||||
"published": "Site published!",
|
||||
"openSite": "Open site",
|
||||
"updateSite": "Update site",
|
||||
"unpublish": "Unpublish site"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user