feat: icon-only toolbar, versioning fixes, history modal, PanelRight repositioning

- Toolbar: remove text labels from all icon buttons (AI, Save, Preview, Convert)
  all buttons now icon-only with title tooltip for accessibility
- Toolbar: reposition PanelRight (info panel toggle) to far right after three-dot menu
- Versioning: decouple getNoteHistory/restoreNoteVersion from global userAISettings.noteHistory
  now checks note.historyEnabled directly — unblocks manual per-note history
- Versioning: add 'Sauvegarder cette version' button in Versions tab of info panel
  calls commitNoteHistory with visual feedback (spinner → success state)
- note-document-info-panel: import commitNoteHistory, add isSavingVersion state
- notes.ts: fix double guard that silently blocked all history operations
This commit is contained in:
Antigravity
2026-05-09 07:28:03 +00:00
parent 574c8b3166
commit 97b08e5d0b
65 changed files with 2991 additions and 2296 deletions

View File

@@ -1,29 +1,21 @@
import { AdminHeader } from '@/components/admin-header'
import { AdminNav } from '@/components/admin-nav'
import { AdminSidebar } from '@/components/admin-sidebar'
// Auth is enforced solely by middleware (auth.config.ts → authorized callback).
// All cross-group navigation (admin ↔ main) uses <a> tags (full page reload)
// to avoid React Error #310 caused by Next.js 16.x route-group transition bug.
// Navigation admin ↔ app en <a> (rechargement complet) pour éviter React Error #310
// sur les transitions entre route groups (Next.js 16 / React #33580).
export default function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="bg-background flex flex-col min-h-screen">
<AdminHeader />
{/* Horizontal Tab Navigation */}
<div className="flex items-center gap-1 px-8 bg-background border-b border-border shrink-0">
<AdminNav />
</div>
{/* Page Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-5xl mx-auto px-8 py-8 space-y-8">
<div className="flex h-screen overflow-hidden bg-[#E5E2D9] dark:bg-background">
<AdminSidebar />
<main className="memento-paper-texture flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth">
<div className="mx-auto w-full max-w-6xl space-y-8 px-4 py-6 sm:px-6 sm:py-8 lg:px-10">
{children}
</div>
</div>
</main>
</div>
)
}

View File

@@ -12,12 +12,33 @@ import { useState, useEffect, useCallback } from 'react'
import { TestTube, ExternalLink, RefreshCw, Shield, Brain, Mail, Wrench } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
type AIProvider = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter' | 'mistral' | 'zai' | 'lmstudio'
type AIProvider =
| 'ollama'
| 'openai'
| 'anthropic'
| 'anthropic_custom'
| 'custom'
| 'deepseek'
| 'openrouter'
| 'mistral'
| 'zai'
| 'lmstudio'
/** Providers that cannot be used for embeddings in Memento (no embedding API wired). */
const PROVIDERS_WITHOUT_EMBEDDINGS: AIProvider[] = ['anthropic', 'anthropic_custom']
// Provider config metadata
const PROVIDER_META: Record<AIProvider, { apiKeyLabel: string; baseUrlLabel: string; hasApiKey: boolean; hasBaseUrl: boolean; isLocal: boolean }> = {
ollama: { apiKeyLabel: '', baseUrlLabel: 'admin.ai.baseUrl', hasApiKey: false, hasBaseUrl: true, isLocal: true },
openai: { apiKeyLabel: 'OPENAI_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
anthropic: { apiKeyLabel: 'ANTHROPIC_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
anthropic_custom: {
apiKeyLabel: 'ANTHROPIC_CUSTOM_API_KEY',
baseUrlLabel: 'admin.ai.baseUrl',
hasApiKey: true,
hasBaseUrl: true,
isLocal: false,
},
deepseek: { apiKeyLabel: 'DEEPSEEK_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
openrouter:{ apiKeyLabel: 'OPENROUTER_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
mistral: { apiKeyLabel: 'MISTRAL_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
@@ -30,6 +51,8 @@ const PROVIDER_META: Record<AIProvider, { apiKeyLabel: string; baseUrlLabel: str
const API_KEY_CONFIG: Record<AIProvider, string> = {
ollama: '',
openai: 'OPENAI_API_KEY',
anthropic: 'ANTHROPIC_API_KEY',
anthropic_custom: 'ANTHROPIC_CUSTOM_API_KEY',
deepseek: 'DEEPSEEK_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
mistral: 'MISTRAL_API_KEY',
@@ -41,6 +64,8 @@ const API_KEY_CONFIG: Record<AIProvider, string> = {
const BASE_URL_CONFIG: Record<AIProvider, string> = {
ollama: 'OLLAMA_BASE_URL',
openai: '',
anthropic: '',
anthropic_custom: 'ANTHROPIC_CUSTOM_BASE_URL',
deepseek: '',
openrouter: '',
mistral: '',
@@ -52,6 +77,8 @@ const BASE_URL_CONFIG: Record<AIProvider, string> = {
const DEFAULT_BASE_URLS: Record<AIProvider, string> = {
ollama: 'http://localhost:11434',
openai: '',
anthropic: '',
anthropic_custom: '',
deepseek: 'https://api.deepseek.com/v1',
openrouter: 'https://openrouter.ai/api/v1',
mistral: 'https://api.mistral.ai/v1',
@@ -63,6 +90,24 @@ const DEFAULT_BASE_URLS: Record<AIProvider, string> = {
// Suggested models per provider (shown as hints in Combobox - user can always type a custom name)
const SUGGESTED_MODELS: Record<string, string[]> = {
openai: ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'gpt-4o', 'gpt-4o-mini', 'o3-mini', 'o4-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
anthropic: [
'claude-sonnet-4-20250514',
'claude-sonnet-4-5',
'claude-opus-4-20250514',
'claude-opus-4-5',
'claude-haiku-4-5',
'claude-3-haiku-20240307',
],
anthropic_custom: [
'MiniMax-M2.7',
'MiniMax-M2.7-highspeed',
'MiniMax-M2.5',
'MiniMax-M2.5-highspeed',
'MiniMax-M2.1',
'MiniMax-M2.1-highspeed',
'MiniMax-M2',
'claude-sonnet-4-20250514',
],
openrouter: ['openai/gpt-4o-mini', 'openai/gpt-4.1-mini', 'anthropic/claude-sonnet-4', 'google/gemini-2.5-flash-preview', 'google/gemma-4-26b-a4b-it', 'meta-llama/llama-4-maverick', 'deepseek/deepseek-chat-v3-0324'],
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest', 'codestral-latest', 'mistral-embed'],
@@ -100,7 +145,10 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
// AI Provider state - separated for tags, embeddings, and chat
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>(() => {
const v = (config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama'
return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v
})
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
// Selected Models State
@@ -170,7 +218,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
await fetchModels('tags', 'ollama', config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434')
} else if (tagsProvider === 'lmstudio') {
await fetchModels('tags', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
} else if (PROVIDER_META[tagsProvider]?.hasApiKey) {
} else if (PROVIDER_META[tagsProvider]?.hasApiKey && tagsProvider !== 'anthropic_custom') {
const url = DEFAULT_BASE_URLS[tagsProvider]
const key = config[API_KEY_CONFIG[tagsProvider]] || ''
if (url && key) await fetchModels('tags', tagsProvider, url, key)
@@ -180,7 +228,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
await fetchModels('embeddings', 'ollama', config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434')
} else if (embeddingsProvider === 'lmstudio') {
await fetchModels('embeddings', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
} else if (PROVIDER_META[embeddingsProvider]?.hasApiKey) {
} else if (PROVIDER_META[embeddingsProvider]?.hasApiKey && embeddingsProvider !== 'anthropic_custom') {
const url = DEFAULT_BASE_URLS[embeddingsProvider]
const key = config[API_KEY_CONFIG[embeddingsProvider]] || ''
if (url && key) await fetchModels('embeddings', embeddingsProvider, url, key)
@@ -190,7 +238,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
await fetchModels('chat', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
} else if (chatProvider === 'lmstudio') {
await fetchModels('chat', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
} else if (PROVIDER_META[chatProvider]?.hasApiKey) {
} else if (PROVIDER_META[chatProvider]?.hasApiKey && chatProvider !== 'anthropic_custom') {
const url = DEFAULT_BASE_URLS[chatProvider]
const key = config[API_KEY_CONFIG[chatProvider]] || ''
if (url && key) await fetchModels('chat', chatProvider, url, key)
@@ -459,13 +507,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
? (config.LMSTUDIO_BASE_URL || DEFAULT_BASE_URLS.lmstudio)
: (config[BASE_URL_CONFIG[provider]] || DEFAULT_BASE_URLS[provider] || '')
}
placeholder={DEFAULT_BASE_URLS[provider] || t('admin.ai.baseUrl')}
placeholder={
provider === 'anthropic_custom'
? 'https://api.minimax.io/anthropic'
: DEFAULT_BASE_URLS[provider] || t('admin.ai.baseUrl')
}
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
if (provider === 'anthropic_custom') {
toast.info(t('admin.ai.anthropicCustomNoModelList'))
return
}
const urlInput = document.getElementById(`BASE_URL_${provider}_${purpose}`) as HTMLInputElement
const keyInput = meta.hasApiKey
? document.getElementById(`API_KEY_${provider}_${purpose}`) as HTMLInputElement
@@ -474,7 +530,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const key = keyInput?.value || (meta.hasApiKey ? config[API_KEY_CONFIG[provider]] : undefined)
if (url) fetchModels(purpose, provider, url, key)
}}
disabled={loading}
disabled={loading || provider === 'anthropic_custom'}
title={t('admin.ai.refreshModels')}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
@@ -500,7 +556,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const key = keyInput?.value || config[API_KEY_CONFIG[provider]] || ''
if (url && key) fetchModels(purpose, provider, url, key)
}}
disabled={loading}
disabled={loading || provider === 'anthropic'}
title={t('admin.ai.refreshModels')}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
@@ -525,9 +581,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
? t('admin.ai.fetchingModels')
: dynamicModels[purpose].length > 0
? t('admin.ai.modelsAvailable', { count: dynamicModels[purpose].length })
: provider === 'ollama' || provider === 'lmstudio'
? t('admin.ai.selectOllamaModel')
: t('admin.ai.enterUrlToLoad')}
: provider === 'anthropic'
? t('admin.ai.anthropicModelHint')
: provider === 'anthropic_custom'
? t('admin.ai.anthropicCustomModelHint')
: provider === 'ollama' || provider === 'lmstudio'
? t('admin.ai.selectOllamaModel')
: t('admin.ai.enterUrlToLoad')}
</p>
</div>
</div>
@@ -538,6 +598,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const providerOptions = [
{ value: 'ollama', label: t('admin.ai.providerOllamaOption') },
{ value: 'openai', label: t('admin.ai.providerOpenAIOption') },
{ value: 'anthropic', label: t('admin.ai.providerAnthropicOption') },
{ value: 'anthropic_custom', label: t('admin.ai.providerAnthropicCustomOption') },
{ value: 'deepseek', label: t('admin.ai.providerDeepSeekOption') },
{ value: 'openrouter', label: t('admin.ai.providerOpenRouterOption') },
{ value: 'mistral', label: t('admin.ai.providerMistralOption') },
@@ -546,6 +608,10 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
{ value: 'custom', label: t('admin.ai.providerCustomOption') },
]
const embeddingsProviderOptions = providerOptions.filter(
(opt) => !PROVIDERS_WITHOUT_EMBEDDINGS.includes(opt.value as AIProvider)
)
return (
<div className="columns-1 lg:columns-2 gap-6">
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
@@ -657,7 +723,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
}}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{providerOptions.map(opt => (
{embeddingsProviderOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>

View File

@@ -63,12 +63,18 @@ export function AppearanceSettingsClient({
}
const handleFontFamilyChange = async (value: string) => {
const font = value === 'system' ? 'system' : 'inter'
const font = value === 'system' ? 'system'
: value === 'playfair' ? 'playfair'
: value === 'jetbrains' ? 'jetbrains'
: 'inter'
setFontFamily(font)
localStorage.setItem('font-family', font)
const root = document.documentElement
font === 'system' ? root.classList.add('font-system') : root.classList.remove('font-system')
await updateAISettings({ fontFamily: font })
root.classList.remove('font-system', 'font-playfair', 'font-jetbrains')
if (font === 'system') root.classList.add('font-system')
if (font === 'playfair') root.classList.add('font-playfair')
if (font === 'jetbrains') root.classList.add('font-jetbrains')
await updateAISettings({ fontFamily: font as 'inter' | 'playfair' | 'jetbrains' | 'system' })
toast.success(t('settings.settingsSaved') || 'Saved')
}
@@ -192,7 +198,9 @@ export function AppearanceSettingsClient({
description={t('appearance.fontFamilyDescription') || "Choisissez la police de l'application"}
value={fontFamily}
options={[
{ value: 'inter', label: 'Inter' },
{ value: 'inter', label: 'Inter (défaut)' },
{ value: 'playfair', label: 'Playfair Display' },
{ value: 'jetbrains', label: 'JetBrains Mono' },
{ value: 'system', label: t('appearance.fontSystem') || 'Système' },
]}
onChange={handleFontFamilyChange}

View File

@@ -23,7 +23,7 @@ export type UserAISettingsData = {
autoLabeling?: boolean
noteHistory?: boolean
noteHistoryMode?: 'manual' | 'auto'
fontFamily?: 'inter' | 'system'
fontFamily?: 'inter' | 'playfair' | 'jetbrains' | 'system'
}
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
@@ -191,7 +191,7 @@ const getCachedAISettings = unstable_cache(
autoLabeling: settings.autoLabeling ?? true,
noteHistory: settings.noteHistory ?? false,
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'system',
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'playfair' | 'jetbrains' | 'system',
}
} catch (error) {
console.error('Error getting AI settings:', error)

View File

@@ -0,0 +1,100 @@
'use server'
import DOMPurify from 'isomorphic-dompurify'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { getAIProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { getAISettings } from '@/app/actions/ai-settings'
import { revalidatePath } from 'next/cache'
function extractSvgSnippet(raw: string): string | null {
const trimmed = raw.trim()
const fenced = trimmed.match(/```(?:svg)?\s*([\s\S]*?)```/i)
const candidate = (fenced ? fenced[1] : trimmed).trim()
const start = candidate.indexOf('<svg')
const end = candidate.lastIndexOf('</svg>')
if (start === -1 || end === -1 || end <= start) return null
return candidate.slice(start, end + 6)
}
function sanitizeSvgMarkup(svg: string): string {
return DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['use'],
ADD_ATTR: ['viewBox', 'xmlns', 'preserveAspectRatio'],
})
}
/**
* Génère une miniature SVG abstraite pour le flux éditorial (via modèle chat configuré).
* Respecte les préférences utilisateur (assistant IA activé) et nettoie le SVG.
*/
export async function generateNoteIllustrationSvg(noteId: string): Promise<{ ok: true } | { ok: false; error: string }> {
const session = await auth()
if (!session?.user?.id) return { ok: false, error: 'Non autorisé' }
try {
const settings = await getAISettings(session.user.id)
if (settings.paragraphRefactor === false) {
return { ok: false, error: 'Assistant IA désactivé dans vos paramètres.' }
}
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true, title: true, content: true },
})
if (!note) return { ok: false, error: 'Note introuvable' }
const plainTitle = (note.title || '').slice(0, 200)
const plainBody = note.content
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 1200)
if (!plainBody && !plainTitle) {
return { ok: false, error: 'Ajoutez du contenu avant de générer une illustration.' }
}
const config = await getSystemConfig()
const provider = getAIProvider(config)
const prompt = `Tu es un designer minimaliste. Produis UN SEUL document SVG valide pour une vignette de carte note.
Contraintes strictes:
- viewBox="0 0 224 168" (rapport 4:3), pas de width/height fixes en px sur la racine ou width="100%" height="100%"
- Style architectural / papier, 24 formes géométriques ou lignes, palette sobre (noir/gris/une couleur douce), pas de texte lisible
- AUCUN script, AUCUNE balise foreignObject, AUCUN lien externe, AUCUN attribut on*
- Réponds UNIQUEMENT avec le fragment SVG (commence par <svg ...> et finit par </svg>), sans markdown ni commentaire.
Thème à suggérer visuellement (abstrait, pas littéral):
Titre: ${plainTitle || '(sans titre)'}
Extrait: ${plainBody.slice(0, 400)}`
const raw = await provider.generateText(prompt)
const extracted = extractSvgSnippet(raw)
if (!extracted) {
return { ok: false, error: 'Le modèle na pas renvoyé un SVG valide. Réessayez.' }
}
const safe = sanitizeSvgMarkup(extracted)
if (!safe.includes('<svg')) {
return { ok: false, error: 'SVG rejeté après sécurisation.' }
}
await prisma.note.update({
where: { id: noteId, userId: session.user.id },
data: {
illustrationSvg: safe,
lastAiAnalysis: new Date(),
},
})
revalidatePath('/')
return { ok: true }
} catch (e) {
console.error('[note-illustration]', e)
const msg = e instanceof Error ? e.message : 'Erreur inconnue'
return { ok: false, error: msg.includes('required') ? 'Configurez un fournisseur IA (admin ou paramètres système).' : msg }
}
}

View File

@@ -372,16 +372,14 @@ export async function getNoteHistory(noteId: string, limit = 30) {
const session = await auth()
if (!session?.user?.id) return []
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
if (!enabled) return []
const clampedLimit = Math.min(Math.max(limit, 1), 100)
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true },
select: { id: true, historyEnabled: true },
})
if (!note) return []
// History not found or not enabled on this note
if (!note || !note.historyEnabled) return []
const entries = await prisma.noteHistory.findMany({
where: { noteId: note.id, userId: session.user.id },
@@ -396,13 +394,10 @@ export async function restoreNoteVersion(noteId: string, historyEntryId: string)
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
if (!enabled) throw new Error('History is disabled')
const [note, historyEntry] = await Promise.all([
prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true, notebookId: true },
select: { id: true, notebookId: true, historyEnabled: true },
}),
prisma.noteHistory.findFirst({
where: {
@@ -413,9 +408,8 @@ export async function restoreNoteVersion(noteId: string, historyEntryId: string)
}),
])
if (!note || !historyEntry) {
throw new Error('History entry not found')
}
if (!note || !note.historyEnabled) throw new Error('History is disabled for this note')
if (!historyEntry) throw new Error('History entry not found')
const userId = session.user.id
@@ -861,10 +855,18 @@ export async function updateNote(id: string, data: {
updateData.contentUpdatedAt = new Date()
}
const note = await prisma.note.update({
where: { id, userId: session.user.id },
data: updateData
})
console.log('[updateNote] Attempting update, id:', id, 'userId:', session.user.id)
let note
try {
note = await prisma.note.update({
where: { id, userId: session.user.id },
data: updateData
})
console.log('[updateNote] Succeeded, note id:', note?.id)
} catch (dbError: any) {
console.error('[updateNote] FAILED:', dbError.code, dbError.message)
throw dbError
}
// Sync labels (JSON + labelRelations + Label rows)
const notebookMoved =
@@ -908,9 +910,15 @@ export async function updateNote(id: string, data: {
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
const isStructuralChange = structuralFields.some(field => field in data)
if (isStructuralChange && !options?.skipRevalidation) {
revalidatePath('/')
console.log('[updateNote] Structural check — data fields:', Object.keys(data), '| isStructural:', isStructuralChange)
if (!options?.skipRevalidation) {
// Always revalidate note individual page on content changes so UI reflects saved data
revalidatePath(`/note/${id}`)
revalidatePath('/')
}
if (isStructuralChange) {
if (data.isArchived !== undefined) {
revalidatePath('/archive')
}

View File

@@ -34,6 +34,19 @@
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Font family overrides — toggled on <html> by ThemeInitializer */
.font-system {
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.font-playfair {
--font-sans: var(--font-memento-serif), ui-serif, Georgia, 'Times New Roman', serif;
}
.font-jetbrains {
--font-sans: var(--font-jetbrains-mono), 'JetBrains Mono', ui-monospace, monospace;
}
/* Custom scrollbar for better aesthetics - Architectural Minimalist */
::-webkit-scrollbar {
width: 3px;
@@ -93,6 +106,9 @@
@utility win11-shadow-hover {
box-shadow: var(--shadow-card-hover);
}
@utility editor-body {
font-size: var(--editor-body-size, 16px);
}
/* Architectural Grid — texture & navigation (réf. architectural-grid1) */
.memento-paper-texture {
@@ -269,7 +285,7 @@ html.dark .memento-active-nav {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-inter);
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, Consolas, monospace;
--font-mono: var(--font-jetbrains-mono), 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, Consolas, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -423,7 +439,7 @@ html.dark {
--secondary: #2d2d2d;
--secondary-foreground: #ffffff;
--muted: #2d2d2d;
--muted-foreground: #9e9e9e;
--muted-foreground: #a8a8a8;
--accent: #383838;
--accent-foreground: #ffffff;
--destructive: #ff6b6b;
@@ -435,7 +451,7 @@ html.dark {
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: rgba(32, 32, 32, 0.75);
--sidebar: #252525;
--sidebar-foreground: #ffffff;
--sidebar-primary: #d6d3d1;
--sidebar-primary-foreground: #1c1917;
@@ -991,6 +1007,11 @@ html.font-system * {
font-size: var(--user-font-size, 16px);
}
/* Editor body size — used for textarea and ProseMirror content */
:root {
--editor-body-size: var(--user-font-size, 16px);
}
body {
@apply text-foreground;
background-color: var(--memento-desk);
@@ -1778,3 +1799,25 @@ html.font-system * {
font-size: 1.125rem;
opacity: 0.4;
}
/* ─── OVERRIDE FINAL: Force editorial body text in fullPage using CSS var ──────── */
/* Sets font-size on the container so TipTap inherits --editor-body-size */
.fullpage-editor {
font-size: var(--editor-body-size, 16px) !important;
line-height: 1.85 !important;
}
/* Also target TipTap's actual editable div directly */
.fullpage-editor .tiptap,
.fullpage-editor .tiptap p,
.fullpage-editor .ProseMirror,
.fullpage-editor .ProseMirror > p {
font-size: var(--editor-body-size, 16px) !important;
line-height: 1.85 !important;
}
/* Keep headings at their correct relative sizes */
.fullpage-editor .tiptap h1,
.fullpage-editor .ProseMirror h1 { font-size: 2.25rem !important; line-height: 1.25 !important; }
.fullpage-editor .tiptap h2,
.fullpage-editor .ProseMirror h2 { font-size: 1.75rem !important; line-height: 1.3 !important; }
.fullpage-editor .tiptap h3,
.fullpage-editor .ProseMirror h3 { font-size: 1.375rem !important; line-height: 1.4 !important; }

View File

@@ -12,7 +12,7 @@ import Script from "next/script";
import { getThemeScript } from "@/lib/theme-script";
import { normalizeThemeId } from "@/lib/apply-document-theme";
import { Inter, Manrope, Playfair_Display } from "next/font/google";
import { Inter, Manrope, Playfair_Display, JetBrains_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
@@ -30,6 +30,12 @@ const playfair = Playfair_Display({
weight: ["400", "500", "600", "700"],
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
weight: ["400", "500"],
});
export const metadata: Metadata = {
title: "Memento - Your Digital Notepad",
description: "A beautiful note-taking app built with Next.js 16",
@@ -99,7 +105,7 @@ export default async function RootLayout({
data-theme={htmlTheme.dataTheme}
>
<head />
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable}`}>
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable}`}>
<Script
id="theme-early"
strategy="beforeInteractive"