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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
100
memento-note/app/actions/note-illustration.ts
Normal file
100
memento-note/app/actions/note-illustration.ts
Normal 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, 2–4 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 n’a 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 }
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user