feat: architectural grid editor fullPage + slash commands + doc info panel + AI title

This commit is contained in:
Antigravity
2026-05-07 22:29:02 +00:00
parent 0d8252aec0
commit e458b63115
126 changed files with 7652 additions and 1110 deletions

View File

@@ -0,0 +1,60 @@
Redesign the entire UI of this application to look like Windows 11 Fluent Design. This is a UI-only redesign - do NOT change any business logic, API routes, database schema, or functionality. Only modify visual styling (CSS classes, Tailwind utilities, color values, border-radius, shadows, etc.).
Changes needed:
1. globals.css - Update theme:
- Primary color: #0078D4 (Windows 11 blue)
- Add --color-win11-accent: #0078D4 and shades (#106EBE, #005A9E, #003D6B)
- Background: light #f3f3f3, dark #202020
- Rounded corners: 8px cards, 4px small elements
- Shadows: subtle layered shadows like Win11 (0 2px 8px rgba(0,0,0,0.04), 0 8px 32px rgba(0,0,0,0.08))
- Smooth transitions: 200-300ms ease
- Add acrylic utility: .acrylic { backdrop-filter: blur(20px) saturate(180%); background: rgba(255,255,255,0.7); }
- Add .acrylic-dark for dark mode
2. app/(main)/layout.tsx - Main layout:
- Background: #f3f3f3 (light) / #202020 (dark)
- Sidebar: add bg-white/80 dark:bg-[#2d2d2d]/80 backdrop-blur-xl rounded-e-lg
- Content area: clean with subtle padding
3. components/sidebar.tsx - Windows 11 navigation:
- Semi-transparent bg with backdrop-blur-xl
- Nav items: rounded-lg hover states with subtle bg-slate-100 dark:bg-slate-800
- Active item: bg-blue-50 dark:bg-blue-900/30 text-[#0078D4] with left border-2 indicator
- Smooth collapse animation with transition-all duration-300
4. components/header.tsx - Windows 11 title bar:
- Clean minimal, height h-12
- Rounded search input (rounded-full or rounded-lg) like Win11 search
- Subtle bottom border
5. components/note-card.tsx - Win11 cards:
- rounded-lg (8px)
- border border-slate-200 dark:border-slate-700
- hover:shadow-lg hover:-translate-y-0.5 transition-all duration-200
- Clean white bg
6. components/home-client.tsx - Widget layout:
- Rounded containers (rounded-xl) for each section
- Subtle hover:shadow-md transition
7. components/note-editor.tsx & rich-text-editor.tsx - Win11 editor:
- Rounded toolbar buttons (rounded-md)
- Subtle separators between toolbar groups
- Clean focused state
8. components/ui/button.tsx - Win11 buttons:
- rounded-md (6px)
- Primary: bg-[#0078D4] hover:bg-[#106EBE] text-white
- Secondary: bg-slate-100 hover:bg-slate-200 border border-slate-300
- Subtle active states
9. components/ui/card.tsx - Win11 card:
- rounded-lg border border-slate-200/60
- hover:shadow-md transition-shadow duration-200
10. components/ui/input.tsx - Win11 input:
- rounded-md (6px)
- border-slate-300 focus:border-[#0078D4] focus:ring-1 focus:ring-[#0078D4]/30
Read each file first, understand its structure, then make surgical edits. After all changes, run npm run build to verify the build passes. Fix any build errors if any.

View File

@@ -159,6 +159,7 @@ export function AgentsPageClient({
role: formData.get('role') as string,
sourceUrls: formData.get('sourceUrls') ? JSON.parse(formData.get('sourceUrls') as string) : undefined,
sourceNotebookId: (formData.get('sourceNotebookId') as string) || undefined,
sourceNoteIds: formData.get('sourceNoteIds') ? JSON.parse(formData.get('sourceNoteIds') as string) : undefined,
targetNotebookId: (formData.get('targetNotebookId') as string) || undefined,
frequency: formData.get('frequency') as string,
tools: formData.get('tools') ? JSON.parse(formData.get('tools') as string) : undefined,
@@ -168,6 +169,8 @@ export function AgentsPageClient({
scheduledTime: (formData.get('scheduledTime') as string) || undefined,
scheduledDay: formData.get('scheduledDay') ? Number(formData.get('scheduledDay')) : undefined,
timezone: (formData.get('timezone') as string) || undefined,
slideTheme: (formData.get('slideTheme') as string) || undefined,
slideStyle: (formData.get('slideStyle') as string) || undefined,
}
if (editingAgent) {
await updateAgent(editingAgent.id, data)
@@ -196,70 +199,30 @@ export function AgentsPageClient({
const existingAgentNames = useMemo(() => agents.map(a => a.name), [agents])
return (
/* Full-bleed layout: -m-4 cancels the p-4 of the parent <main> */
<div className="flex -m-4 h-[calc(100vh-4rem)] overflow-hidden">
/* Full-bleed layout */
<div className="flex flex-col h-full overflow-hidden">
{/* ── LEFT SIDEBAR ── */}
<aside className="w-60 flex-shrink-0 flex flex-col bg-muted/30 border-r border-border/40 h-full font-display">
{/* Brand */}
<div className="flex items-center gap-3 px-5 py-5 border-b border-border/40">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4 text-primary-foreground" />
</div>
<span className="font-bold text-base tracking-tight">{t('agents.title')}</span>
</div>
{/* Nav */}
<nav className="flex-1 p-3 space-y-0.5">
<button
onClick={() => setActiveTab('dashboard')}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm font-medium rounded-lg transition-all ${
activeTab === 'dashboard'
? 'bg-primary/10 text-primary border-r-2 border-primary'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground'
}`}
>
<Bot className="w-4 h-4" />
{/* ── Top header bar — architectural grid style ── */}
<header className="flex items-center justify-between px-12 py-10 border-b border-border/40 flex-shrink-0">
<div>
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight">
{t('agents.myAgents')}
</button>
</nav>
{/* Footer: Help */}
<div className="p-3 border-t border-border/40">
<button
onClick={() => setShowHelp(true)}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-background/70 hover:text-foreground rounded-lg transition-all"
>
<HelpCircle className="w-4 h-4" />
{t('agents.help.btnLabel')}
</button>
</h1>
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-2">
{t('agents.subtitle')}
</p>
</div>
</aside>
<button
onClick={handleCreate}
className="flex items-center gap-2 px-6 py-3 text-[13px] font-medium uppercase tracking-[0.12em] border border-foreground text-foreground hover:bg-foreground hover:text-background transition-all"
>
<Plus className="w-4 h-4" />
{t('agents.newAgent')}
</button>
</header>
{/* ── MAIN CONTENT ── */}
<div className="flex-1 flex flex-col min-w-0 bg-background overflow-hidden">
{/* Top header bar */}
<header className="flex items-center justify-between px-8 py-4 border-b border-border/40 bg-background flex-shrink-0 font-display">
<div>
<h1 className="text-xl font-bold tracking-tight">
{t('agents.myAgents')}
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{t('agents.subtitle')}
</p>
</div>
<button
onClick={handleCreate}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg shadow-sm hover:shadow-md hover:shadow-primary/20 transition-all"
>
<Plus className="w-4 h-4" />
{t('agents.newAgent')}
</button>
</header>
{/* Scrollable content area */}
<main className="flex-1 overflow-y-auto p-8">
{/* ── Scrollable content area ── */}
<main className="flex-1 overflow-y-auto px-12 py-10">
{/* Dashboard tab - agents + templates */}
{activeTab === 'dashboard' && (
@@ -329,7 +292,6 @@ export function AgentsPageClient({
</>
)}
</main>
</div>
{/* Sliding panels */}
{showForm && (

View File

@@ -1,5 +1,4 @@
import { Suspense } from "react";
import { HeaderWrapper } from "@/components/header-wrapper";
import { Sidebar } from "@/components/sidebar";
import { ProvidersWrapper } from "@/components/providers-wrapper";
import { auth } from "@/auth";
@@ -7,28 +6,23 @@ import { headers } from "next/headers";
import { detectUserLanguage, parseAcceptLanguage } from "@/lib/i18n/detect-user-language";
import { loadTranslations } from "@/lib/i18n/load-translations";
import { getAISettings } from "@/app/actions/ai-settings";
import { AIChat } from "@/components/ai-chat";
import { AIChatLayoutBridge } from "@/components/ai-chat-layout-bridge";
export default async function MainLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
// Read browser language hint from Accept-Language header
const headersList = await headers();
const browserLang = parseAcceptLanguage(headersList.get("accept-language"));
// Run auth + language detection + translation loading in parallel
const [session, initialLanguage] = await Promise.all([
auth(),
detectUserLanguage(browserLang),
]);
// Load initial translations server-side to prevent hydration mismatch
const initialTranslations = await loadTranslations(initialLanguage);
// Load AI settings to conditionally render AI features
const aiSettings = session?.user?.id
? await getAISettings(session.user.id)
: null;
@@ -36,25 +30,17 @@ export default async function MainLayout({
return (
<ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
<div className="bg-background-light dark:bg-background-dark font-display text-slate-900 dark:text-white overflow-hidden h-screen flex flex-col">
{/* Top Navigation - Style Keep */}
<HeaderWrapper user={session?.user} />
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
<div className="flex h-screen overflow-hidden bg-[#E5E2D9]">
<Suspense fallback={<div className="hidden w-80 shrink-0 md:block" />}>
<Sidebar user={session?.user} />
</Suspense>
{/* Main Layout */}
<div className="flex flex-1 overflow-hidden relative">
{/* Sidebar Navigation - Style Keep */}
<Suspense fallback={<div className="w-64 flex-none hidden md:flex" />}>
<Sidebar className="w-64 flex-none flex-col bg-white dark:bg-[#1e2128] border-e border-slate-200 dark:border-slate-800 overflow-y-auto hidden md:flex" user={session?.user} />
</Suspense>
<main className="memento-paper-texture flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth">
{children}
</main>
{/* Main Content Area */}
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-background-light dark:bg-background-dark p-4 scroll-smooth">
{children}
</main>
{/* AI Chat Drawer — only shown if user has Assistant IA enabled */}
{showAIAssistant && <AIChat />}
</div>
{showAIAssistant && <AIChatLayoutBridge />}
</div>
</ProvidersWrapper>
);

View File

@@ -10,10 +10,12 @@ export default async function HomePage() {
const notesViewMode =
settings?.notesViewMode === 'masonry'
? 'masonry' as const
: settings?.notesViewMode === 'tabs' || settings?.notesViewMode === 'list'
? 'tabs' as const
: 'masonry' as const
? ('masonry' as const)
: settings?.notesViewMode === 'tabs'
? ('tabs' as const)
: settings?.notesViewMode === 'list'
? ('list' as const)
: ('masonry' as const)
return (
<HomeClient

View File

@@ -1,132 +0,0 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { SettingsSection, SettingSelect } from '@/components/settings'
import { updateAISettings as updateAI } from '@/app/actions/ai-settings'
import { updateUserSettings as updateUser } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
interface AppearanceSettingsFormProps {
initialTheme: string
initialFontSize: string
initialCardSizeMode?: string
}
export function AppearanceSettingsForm({ initialTheme, initialFontSize, initialCardSizeMode = 'variable' }: AppearanceSettingsFormProps) {
const router = useRouter()
const [theme, setTheme] = useState(initialTheme)
const [fontSize, setFontSize] = useState(initialFontSize)
const [cardSizeMode, setCardSizeMode] = useState(initialCardSizeMode)
const { t } = useLanguage()
const handleThemeChange = async (value: string) => {
setTheme(value)
localStorage.setItem('theme-preference', value)
// Instant visual update
const root = document.documentElement
root.removeAttribute('data-theme')
root.classList.remove('dark')
if (value === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
} else if (value === 'dark') {
root.classList.add('dark')
} else if (value === 'light') {
root.setAttribute('data-theme', 'light')
} else {
root.setAttribute('data-theme', value)
if (['midnight', 'blue', 'sepia'].includes(value)) root.classList.add('dark')
}
// Save to DB (no need for router.refresh - localStorage handles immediate visuals)
await updateUser({ theme: value as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue' })
}
const handleFontSizeChange = async (value: string) => {
setFontSize(value)
// Instant visual update
const fontSizeMap: Record<string, string> = {
'small': '14px', 'medium': '16px', 'large': '18px', 'extra-large': '20px'
}
const root = document.documentElement
root.style.setProperty('--user-font-size', fontSizeMap[value] || '16px')
await updateAI({ fontSize: value as any })
}
const handleCardSizeModeChange = async (value: string) => {
setCardSizeMode(value)
localStorage.setItem('card-size-mode', value)
await updateUser({ cardSizeMode: value as 'variable' | 'uniform' })
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('appearance.description')}
</p>
</div>
<SettingsSection
title={t('settings.theme')}
icon={<span className="text-2xl">🎨</span>}
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
>
<SettingSelect
label={t('settings.theme')}
description={t('settings.selectLanguage')}
value={theme}
options={[
{ value: 'slate', label: t('settings.themeLight') },
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'sepia', label: 'Sepia' },
{ value: 'midnight', label: 'Midnight' },
{ value: 'blue', label: 'Blue' },
{ value: 'auto', label: t('settings.themeSystem') },
]}
onChange={handleThemeChange}
/>
</SettingsSection>
<SettingsSection
title={t('profile.fontSize')}
icon={<span className="text-2xl">📝</span>}
description={t('profile.fontSizeDescription')}
>
<SettingSelect
label={t('profile.fontSize')}
description={t('profile.selectFontSize')}
value={fontSize}
options={[
{ value: 'small', label: t('profile.fontSizeSmall') },
{ value: 'medium', label: t('profile.fontSizeMedium') },
{ value: 'large', label: t('profile.fontSizeLarge') },
]}
onChange={handleFontSizeChange}
/>
</SettingsSection>
<SettingsSection
title={t('settings.cardSizeMode')}
icon={<span className="text-2xl">📐</span>}
description={t('settings.cardSizeModeDescription')}
>
<SettingSelect
label={t('settings.cardSizeMode')}
description={t('settings.selectCardSizeMode')}
value={cardSizeMode}
options={[
{ value: 'variable', label: t('settings.cardSizeVariable') },
{ value: 'uniform', label: t('settings.cardSizeUniform') },
]}
onChange={handleCardSizeModeChange}
/>
</SettingsSection>
</div>
)
}

View File

@@ -6,11 +6,12 @@ import { updateUserSettings } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { Palette, Type, LayoutGrid, Maximize2 } from 'lucide-react'
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
interface AppearanceSettingsClientProps {
initialFontSize: string
initialTheme: string
initialNotesViewMode: 'masonry' | 'tabs'
initialNotesViewMode: 'masonry' | 'tabs' | 'list'
initialCardSizeMode?: 'variable' | 'uniform'
initialFontFamily?: string
}
@@ -23,27 +24,18 @@ export function AppearanceSettingsClient({
initialFontFamily = 'inter',
}: AppearanceSettingsClientProps) {
const { t } = useLanguage()
const [theme, setTheme] = useState(initialTheme || 'light')
const [theme, setTheme] = useState<ThemeId>(normalizeThemeId(initialTheme || 'light'))
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs'>(initialNotesViewMode)
const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs' | 'list'>(initialNotesViewMode)
const [cardSizeMode, setCardSizeMode] = useState<'variable' | 'uniform'>(initialCardSizeMode)
const [fontFamily, setFontFamily] = useState(initialFontFamily)
const handleThemeChange = async (value: string) => {
setTheme(value)
localStorage.setItem('theme-preference', value)
const root = document.documentElement
root.removeAttribute('data-theme')
root.classList.remove('dark')
if (value === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
} else if (value === 'dark') {
root.classList.add('dark')
} else {
root.setAttribute('data-theme', value)
if (['midnight'].includes(value)) root.classList.add('dark')
}
await updateUserSettings({ theme: value as any })
const next = normalizeThemeId(value)
setTheme(next)
localStorage.setItem('theme-preference', next)
applyDocumentTheme(next)
await updateUserSettings({ theme: next })
toast.success(t('settings.settingsSaved') || 'Saved')
}
@@ -56,7 +48,7 @@ export function AppearanceSettingsClient({
}
const handleNotesViewChange = async (value: string) => {
const mode = value === 'tabs' ? 'tabs' : 'masonry'
const mode = value === 'tabs' ? 'tabs' : value === 'list' ? 'list' : 'masonry'
setNotesViewMode(mode)
await updateAISettings({ notesViewMode: mode })
toast.success(t('settings.settingsSaved') || 'Saved')
@@ -87,12 +79,14 @@ export function AppearanceSettingsClient({
value,
options,
onChange,
optionGroups,
}: {
icon: React.ElementType
title: string
description: string
value: string
options: { value: string; label: string }[]
options?: { value: string; label: string }[]
optionGroups?: { label: string; options: { value: string; label: string }[] }[]
onChange: (v: string) => void
}) => (
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
@@ -111,17 +105,56 @@ export function AppearanceSettingsClient({
onChange={(e) => onChange(e.target.value)}
className="w-full h-11 px-4 bg-muted border border-border rounded-lg text-foreground text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none appearance-none cursor-pointer transition-colors"
>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
{optionGroups
? optionGroups.map((g) => (
<optgroup key={g.label} label={g.label}>
{g.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</optgroup>
))
: options?.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
)
const themeOptionGroups = [
{
label: t('settings.themeBaseGroup'),
options: [
{ value: 'light', label: t('settings.themeLight') },
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'auto', label: t('settings.themeSystem') },
],
},
{
label: t('settings.themePalettesGroup'),
options: [
{ value: 'sepia', label: t('settings.themeSepia') },
{ value: 'midnight', label: t('settings.themeMidnight') },
{ value: 'rose', label: t('settings.themeRose') },
{ value: 'green', label: t('settings.themeGreen') },
{ value: 'lavender', label: t('settings.themeLavender') },
{ value: 'sand', label: t('settings.themeSand') },
{ value: 'ocean', label: t('settings.themeOcean') },
{ value: 'sunset', label: t('settings.themeSunset') },
{ value: 'blue', label: t('settings.themeBlue') },
],
},
]
return (
<div className="space-y-8">
<div>
@@ -135,19 +168,7 @@ export function AppearanceSettingsClient({
title={t('settings.theme')}
description={t('appearance.selectTheme')}
value={theme}
options={[
{ value: 'light', label: t('settings.themeLight') },
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'sepia', label: 'Sepia' },
{ value: 'midnight', label: 'Midnight' },
{ value: 'rose', label: 'Rose' },
{ value: 'green', label: 'Green' },
{ value: 'lavender', label: 'Lavender' },
{ value: 'sand', label: 'Sand' },
{ value: 'ocean', label: 'Ocean' },
{ value: 'sunset', label: 'Sunset' },
{ value: 'auto', label: t('settings.themeSystem') },
]}
optionGroups={themeOptionGroups}
onChange={handleThemeChange}
/>
@@ -168,7 +189,7 @@ export function AppearanceSettingsClient({
<SelectCard
icon={Type}
title={t('appearance.fontFamilyLabel') || 'Police'}
description={t('appearance.fontFamilyDescription') || 'Choisissez la police de l\'application'}
description={t('appearance.fontFamilyDescription') || "Choisissez la police de l'application"}
value={fontFamily}
options={[
{ value: 'inter', label: 'Inter' },
@@ -184,6 +205,7 @@ export function AppearanceSettingsClient({
value={notesViewMode}
options={[
{ value: 'masonry', label: t('appearance.notesViewMasonry') },
{ value: 'list', label: t('appearance.notesViewList') },
{ value: 'tabs', label: t('appearance.notesViewTabs') },
]}
onChange={handleNotesViewChange}

View File

@@ -19,7 +19,13 @@ export default async function AppearanceSettingsPage() {
<AppearanceSettingsClient
initialFontSize={aiSettings.fontSize}
initialTheme={userSettings.theme}
initialNotesViewMode={aiSettings.notesViewMode === 'masonry' ? 'masonry' : 'tabs'}
initialNotesViewMode={
aiSettings.notesViewMode === 'masonry'
? 'masonry'
: aiSettings.notesViewMode === 'list'
? 'list'
: 'tabs'
}
initialCardSizeMode={userSettings.cardSizeMode}
initialFontFamily={aiSettings.fontFamily || 'inter'}
/>

View File

@@ -59,13 +59,11 @@ function pickUserAISettingsForDb(input: UserAISettingsData): Partial<Record<User
out[key] = v
}
}
if (out.notesViewMode === 'list') {
out.notesViewMode = 'tabs'
}
if (
out.notesViewMode != null &&
out.notesViewMode !== 'masonry' &&
out.notesViewMode !== 'tabs'
out.notesViewMode !== 'tabs' &&
out.notesViewMode !== 'list'
) {
delete out.notesViewMode
}
@@ -167,9 +165,11 @@ const getCachedAISettings = unstable_cache(
const viewMode =
raw === 'masonry'
? ('masonry' as const)
: raw === 'list' || raw === 'tabs'
: raw === 'tabs'
? ('tabs' as const)
: ('masonry' as const)
: raw === 'list'
? ('list' as const)
: ('masonry' as const)
return {
titleSuggestions: settings.titleSuggestions,

View File

@@ -5,6 +5,7 @@ import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { normalizeThemeId } from '@/lib/apply-document-theme'
const ProfileSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
@@ -88,10 +89,12 @@ export async function updateTheme(theme: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
const normalized = normalizeThemeId(theme)
try {
await prisma.user.update({
where: { id: session.user.id },
data: { theme },
data: { theme: normalized },
})
revalidatePath('/')
revalidatePath('/settings/profile')

View File

@@ -3,9 +3,11 @@
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath, updateTag } from 'next/cache'
import { unstable_cache } from 'next/cache'
import { normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
export type UserSettingsData = {
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
theme?: ThemeId
cardSizeMode?: 'variable' | 'uniform'
}
@@ -13,7 +15,6 @@ export type UserSettingsData = {
* Update user settings (theme, etc.)
*/
export async function updateUserSettings(settings: UserSettingsData) {
const session = await auth()
if (!session?.user?.id) {
@@ -22,11 +23,14 @@ export async function updateUserSettings(settings: UserSettingsData) {
}
try {
const result = await prisma.user.update({
where: { id: session.user.id },
data: settings
})
const data: { theme?: string; cardSizeMode?: 'variable' | 'uniform' } = {}
if (settings.theme !== undefined) data.theme = normalizeThemeId(settings.theme)
if (settings.cardSizeMode !== undefined) data.cardSizeMode = settings.cardSizeMode
await prisma.user.update({
where: { id: session.user.id },
data,
})
revalidatePath('/', 'layout')
updateTag('user-settings')
@@ -38,28 +42,23 @@ export async function updateUserSettings(settings: UserSettingsData) {
}
}
/**
* Get user settings for current user (Cached)
*/
import { unstable_cache } from 'next/cache'
// Internal cached function
const getCachedUserSettings = unstable_cache(
async (userId: string) => {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { theme: true, cardSizeMode: true }
select: { theme: true, cardSizeMode: true },
})
return {
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue',
cardSizeMode: (user?.cardSizeMode || 'variable') as 'variable' | 'uniform'
theme: normalizeThemeId(user?.theme || 'light'),
cardSizeMode: (user?.cardSizeMode || 'variable') as 'variable' | 'uniform',
}
} catch (error) {
console.error('Error getting user settings:', error)
return {
theme: 'light' as const
theme: 'light' as const satisfies ThemeId,
cardSizeMode: 'variable' as const,
}
}
},
@@ -77,8 +76,8 @@ export async function getUserSettings(userId?: string) {
if (!id) {
return {
theme: 'light' as const,
cardSizeMode: 'variable' as const
theme: 'light' as const satisfies ThemeId,
cardSizeMode: 'variable' as const,
}
}

View File

@@ -1,7 +1,26 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
export async function GET(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const id = req.nextUrl.searchParams.get('id')
if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 })
}
const canvas = await prisma.canvas.findFirst({
where: { id, userId: session.user.id },
select: { id: true, name: true, data: true, updatedAt: true },
})
if (!canvas) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json({ canvas })
}
export async function POST(req: Request) {
try {

View File

@@ -17,6 +17,8 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const includeArchived = searchParams.get('archived') === 'true'
const search = searchParams.get('search')
const notebookId = searchParams.get('notebookId')
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined
let where: any = {
userId: session.user.id,
@@ -27,6 +29,10 @@ export async function GET(request: NextRequest) {
where.isArchived = false
}
if (notebookId) {
where.notebookId = notebookId
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
@@ -40,7 +46,8 @@ export async function GET(request: NextRequest) {
{ isPinned: 'desc' },
{ order: 'asc' },
{ updatedAt: 'desc' }
]
],
...(limit ? { take: limit } : {}),
})
return NextResponse.json({

View File

@@ -12,22 +12,32 @@
--breakpoint-large-desktop: 1440px;
--breakpoint-ultra-wide: 1920px;
/* Custom colors matching Keep design */
--color-primary: #64748b;
--color-background-light: #f7f7f8;
--color-background-dark: #1a1d23;
/* Memento — Architectural Grid (réf. architectural-grid1) */
--color-memento-desk: #E5E2D9;
--color-memento-paper: #F2F0E9;
--color-memento-ink: #1C1C1C;
--color-primary: #1C1C1C;
--color-memento-accent: #1C1C1C;
--color-memento-paper-elevated: #faf9f5;
--color-background-light: var(--color-memento-paper);
--color-background-dark: #202020;
/* Stitch Design Tokens */
--font-sans: var(--font-inter);
--font-heading: var(--font-manrope);
--shadow-level-1: 0px 4px 20px rgba(15, 23, 42, 0.05);
--shadow-level-2: 0px 10px 30px rgba(15, 23, 42, 0.08);
--font-heading: var(--font-memento-serif), ui-serif, Georgia, "Times New Roman", serif;
--shadow-level-1: 0 2px 4px rgba(0,0,0,0.04), 0 4px 8px rgba(0,0,0,0.06);
--shadow-level-2: 0 2px 8px rgba(0,0,0,0.06), 0 8px 16px rgba(0,0,0,0.08);
--shadow-level-3: 0 4px 8px rgba(0,0,0,0.04), 0 16px 32px rgba(0,0,0,0.12);
--shadow-acrylic: 0 8px 32px rgba(0,0,0,0.08);
--shadow-card-rest: 0 1px 2px rgba(0,0,0,0.06), 0 2px 4px rgba(0,0,0,0.04);
--shadow-card-hover: 0 2px 8px rgba(0,0,0,0.08), 0 8px 16px rgba(0,0,0,0.06);
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Custom scrollbar for better aesthetics */
/* Custom scrollbar for better aesthetics - Architectural Minimalist */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 3px;
height: 3px;
}
::-webkit-scrollbar-track {
@@ -35,16 +45,211 @@
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
background: rgba(28, 28, 28, 0.05);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
background: rgba(28, 28, 28, 0.15);
}
.dark ::-webkit-scrollbar-thumb {
background: #475569;
background: rgba(255, 255, 255, 0.08);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.18);
}
/* Win11 Acrylic / Mica blur utilities */
@utility acrylic {
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
}
@utility acrylic-heavy {
backdrop-filter: blur(40px) saturate(200%);
-webkit-backdrop-filter: blur(40px) saturate(200%);
}
@utility acrylic-light {
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(24px) saturate(1.35);
-webkit-backdrop-filter: blur(24px) saturate(1.35);
}
@utility font-memento-serif {
font-family: var(--font-memento-serif), ui-serif, Georgia, "Times New Roman", serif;
}
@utility acrylic-dark {
background: rgba(32, 32, 32, 0.75);
backdrop-filter: blur(20px) saturate(1.5);
-webkit-backdrop-filter: blur(20px) saturate(1.5);
}
@utility win11-shadow {
box-shadow: var(--shadow-card-rest);
transition: box-shadow var(--transition-normal);
}
@utility win11-shadow-hover {
box-shadow: var(--shadow-card-hover);
}
/* Architectural Grid — texture & navigation (réf. architectural-grid1) */
.memento-paper-texture {
background-color: var(--background);
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
background-size: auto;
}
html.dark .memento-paper-texture {
background-image: none;
}
.memento-sidebar-depth {
box-shadow: 1px 0 10px rgba(0, 0, 0, 0.05);
}
html.dark .memento-sidebar-depth {
box-shadow: 4px 0 24px -8px rgba(0, 0, 0, 0.4);
}
html:not(.dark) .memento-active-nav {
background: linear-gradient(to right, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.4));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(255, 255, 255, 0.5);
}
.custom-scrollbar::-webkit-scrollbar {
width: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.08);
border-radius: 10px;
}
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.2);
}
.ai-glass {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
.sidebar-shadow {
box-shadow: 1px 0 10px rgba(0, 0, 0, 0.05);
}
/* ──────────────────────────────────────────────
ARCHITECTURAL GRID — Design Tokens Aliases
Compatible with shadcn token system
────────────────────────────────────────────── */
/* CSS aliases: ink = foreground, paper = background */
:root {
--ink: var(--foreground);
--paper: var(--background);
--muted-ink: var(--muted-foreground);
--ai-accent: #75B2D6;
}
/* Sidebar toggle view button (Notebooks / Agents) */
.sidebar-view-toggle {
display: flex;
background: rgba(255, 255, 255, 0.5);
padding: 2px;
border-radius: 999px;
border: 1px solid var(--border);
}
.sidebar-view-toggle-btn {
padding: 6px;
border-radius: 999px;
transition: all 150ms;
color: var(--muted-foreground);
}
.sidebar-view-toggle-btn:hover {
color: var(--foreground);
}
.sidebar-view-toggle-btn.active {
background: var(--foreground);
color: var(--background);
}
/* Dark mode toggle */
html.dark .sidebar-view-toggle {
background: rgba(255, 255, 255, 0.08);
}
html.dark .sidebar-view-toggle-btn.active {
background: var(--primary);
color: var(--primary-foreground);
}
/* Inbox section separator */
.sidebar-inbox-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: 12px;
transition: all 200ms;
width: 100%;
text-align: left;
}
.sidebar-inbox-item:hover {
background: rgba(255, 255, 255, 0.4);
}
.sidebar-inbox-item.active {
background: linear-gradient(to right, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.4));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(255, 255, 255, 0.5);
}
html.dark .sidebar-inbox-item:hover {
background: rgba(255, 255, 255, 0.06);
}
html.dark .sidebar-inbox-item.active {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.12);
}
/* Animated tab indicator for AI panel */
.ai-tab-indicator {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--foreground);
}
/* Note date in editorial view */
.note-date-badge {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.2em;
font-weight: 700;
color: var(--muted-foreground);
opacity: 0.7;
}
/* AI send button accent */
.ai-send-btn {
background: var(--ai-accent);
color: white;
border-radius: 8px;
padding: 8px;
transition: transform 100ms, opacity 100ms;
}
.ai-send-btn:hover {
opacity: 0.9;
transform: scale(1.05);
}
.ai-send-btn:active {
transform: scale(0.95);
}
/* Dark mode active nav */
html.dark .memento-active-nav {
background: rgba(255, 255, 255, 0.09);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Custom Prose overrides for compact notes */
@@ -105,133 +310,139 @@
:root {
--radius: 0.5rem;
--background: #f8fafc; /* Sub-surface off-white */
--foreground: oklch(0.2 0.02 230); /* Gris-bleu foncé */
--card: oklch(1 0 0); /* Blanc pur */
--card-foreground: oklch(0.2 0.02 230);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.02 230);
--primary: #0284c7; /* Sky Blue */
--primary-foreground: #ffffff; /* Blanc */
--secondary: #e2e8f0; /* Gris-bleu très pâle */
--secondary-foreground: #1e293b;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #f1f5f9;
--accent-foreground: #0284c7;
--destructive: oklch(0.6 0.18 25); /* Rouge */
--border: #cbd5e1; /* Gris-bleu visible */
--input: #cbd5e1; /* Bordure visible pour inputs/checkbox */
--ring: #0284c7;
--memento-desk: #E5E2D9;
--background: #F2F0E9;
--foreground: #1C1C1C;
--card: #faf9f5;
--card-foreground: #1C1C1C;
--popover: #faf9f5;
--popover-foreground: #1C1C1C;
--primary: #1C1C1C;
--primary-foreground: #F2F0E9;
--secondary: #e8e6df;
--secondary-foreground: #1C1C1C;
--muted: #e3e1d8;
--muted-foreground: rgba(28, 28, 28, 0.58);
--accent: #ebe9e2;
--accent-foreground: #1C1C1C;
--destructive: #c42b1c;
--border: rgba(28, 28, 28, 0.1);
--input: rgba(28, 28, 28, 0.12);
--ring: rgba(28, 28, 28, 0.35);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.97 0.004 230);
--sidebar-foreground: oklch(0.2 0.02 230);
--sidebar-primary: oklch(0.45 0.08 230);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.94 0.005 230);
--sidebar-accent-foreground: oklch(0.2 0.02 230);
--sidebar-border: oklch(0.9 0.008 230);
--sidebar-ring: oklch(0.7 0.005 230);
--sidebar: color-mix(in oklab, #ffffff 65%, #F2F0E9);
--sidebar-foreground: #1C1C1C;
--sidebar-primary: #1C1C1C;
--sidebar-primary-foreground: #F2F0E9;
--sidebar-accent: rgba(255, 255, 255, 0.45);
--sidebar-accent-foreground: #1C1C1C;
--sidebar-border: rgba(28, 28, 28, 0.1);
--sidebar-ring: rgba(28, 28, 28, 0.35);
}
html:not(.dark) {
--memento-sidebar-glow: color-mix(in oklab, #ffffff 72%, #F2F0E9 28%);
}
html.dark {
--memento-sidebar-glow: color-mix(in oklab, var(--primary) 22%, transparent);
}
[data-theme='light'] {
--background: oklch(0.985 0.003 230); /* Blanc grisâtre */
--foreground: oklch(0.2 0.02 230); /* Gris-bleu foncé */
--card: oklch(1 0 0); /* Blanc pur */
--card-foreground: oklch(0.2 0.02 230);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.02 230);
--primary: oklch(0.45 0.08 230); /* Gris-bleu doux */
--primary-foreground: oklch(0.99 0 0); /* Blanc */
--secondary: oklch(0.94 0.005 230); /* Gris-bleu très pâle */
--secondary-foreground: oklch(0.2 0.02 230);
--muted: oklch(0.92 0.005 230);
--muted-foreground: oklch(0.6 0.01 230);
--accent: oklch(0.92 0.005 230);
--accent-foreground: oklch(0.2 0.02 230);
--destructive: oklch(0.6 0.18 25); /* Rouge */
--border: oklch(0.85 0.008 230); /* Gris-bleu visible */
--input: oklch(0.85 0.008 230); /* Bordure visible */
--ring: oklch(0.7 0.005 230);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.02 230);
--sidebar: oklch(0.97 0.004 230);
--sidebar-foreground: oklch(0.2 0.02 230);
--sidebar-primary: oklch(0.45 0.08 230);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.94 0.005 230);
--sidebar-accent-foreground: oklch(0.2 0.02 230);
--sidebar-border: oklch(0.85 0.008 230);
--sidebar-ring: oklch(0.7 0.005 230);
--memento-desk: #E5E2D9;
--background: #F2F0E9;
--foreground: #1C1C1C;
--card: #faf9f5;
--card-foreground: #1C1C1C;
--popover: #faf9f5;
--popover-foreground: #1C1C1C;
--primary: #1C1C1C;
--primary-foreground: #F2F0E9;
--secondary: #e8e6df;
--secondary-foreground: #1C1C1C;
--muted: #e3e1d8;
--muted-foreground: rgba(28, 28, 28, 0.58);
--accent: #ebe9e2;
--accent-foreground: #1C1C1C;
--destructive: #c42b1c;
--border: rgba(28, 28, 28, 0.1);
--input: rgba(28, 28, 28, 0.12);
--ring: rgba(28, 28, 28, 0.35);
--sidebar: color-mix(in oklab, #ffffff 65%, #F2F0E9);
--sidebar-foreground: #1C1C1C;
--sidebar-primary: #1C1C1C;
--sidebar-primary-foreground: #F2F0E9;
--sidebar-accent: rgba(255, 255, 255, 0.45);
--sidebar-accent-foreground: #1C1C1C;
--sidebar-border: rgba(28, 28, 28, 0.1);
--sidebar-ring: rgba(28, 28, 28, 0.35);
}
[data-theme='light'].dark {
--background: oklch(0.14 0.005 230); /* Noir grisâtre */
--foreground: oklch(0.97 0.003 230); /* Blanc grisâtre */
--card: oklch(0.18 0.006 230); /* Gris-bleu foncé */
--card-foreground: oklch(0.97 0.003 230);
--popover: oklch(0.18 0.006 230);
--popover-foreground: oklch(0.97 0.003 230);
--primary: oklch(0.55 0.08 230); /* Gris-bleu plus clair */
--primary-foreground: oklch(0.1 0 0); /* Noir */
--secondary: oklch(0.24 0.006 230);
--secondary-foreground: oklch(0.97 0.003 230);
--muted: oklch(0.22 0.006 230);
--muted-foreground: oklch(0.55 0.01 230);
--accent: oklch(0.26 0.008 230);
--accent-foreground: oklch(0.97 0.003 230);
--background: oklch(0.16 0.006 75);
--foreground: oklch(0.96 0.004 85);
--card: oklch(0.2 0.008 75);
--card-foreground: oklch(0.96 0.004 85);
--popover: oklch(0.2 0.008 75);
--popover-foreground: oklch(0.96 0.004 85);
--primary: oklch(0.78 0.03 75);
--primary-foreground: oklch(0.18 0.02 60);
--secondary: oklch(0.26 0.008 75);
--secondary-foreground: oklch(0.96 0.004 85);
--muted: oklch(0.24 0.008 75);
--muted-foreground: oklch(0.62 0.012 75);
--accent: oklch(0.28 0.01 75);
--accent-foreground: oklch(0.96 0.004 85);
--destructive: oklch(0.65 0.18 25);
--border: oklch(0.33 0.01 230);
--input: oklch(0.33 0.01 230);
--ring: oklch(0.6 0.01 230);
--popover: oklch(0.18 0.006 230);
--popover-foreground: oklch(0.97 0.003 230);
--sidebar: oklch(0.12 0.005 230);
--sidebar-foreground: oklch(0.97 0.003 230);
--sidebar-primary: oklch(0.55 0.08 230);
--sidebar-primary-foreground: oklch(0.1 0 0);
--sidebar-accent: oklch(0.24 0.006 230);
--sidebar-accent-foreground: oklch(0.97 0.003 230);
--sidebar-border: oklch(0.28 0.01 230);
--sidebar-ring: oklch(0.6 0.01 230);
--border: oklch(0.33 0.012 75);
--input: oklch(0.33 0.012 75);
--ring: oklch(0.58 0.02 75);
--sidebar: oklch(0.14 0.006 75);
--sidebar-foreground: oklch(0.96 0.004 85);
--sidebar-primary: oklch(0.78 0.03 75);
--sidebar-primary-foreground: oklch(0.18 0.02 60);
--sidebar-accent: oklch(0.26 0.01 75);
--sidebar-accent-foreground: oklch(0.96 0.004 85);
--sidebar-border: oklch(0.3 0.012 75);
--sidebar-ring: oklch(0.58 0.02 75);
}
.dark {
--background: oklch(0.14 0.005 230); /* Noir grisâtre */
--foreground: oklch(0.97 0.003 230); /* Blanc grisâtre */
--card: oklch(0.18 0.006 230); /* Gris-bleu foncé */
--card-foreground: oklch(0.97 0.003 230);
--popover: oklch(0.18 0.006 230);
--popover-foreground: oklch(0.97 0.003 230);
--primary: oklch(0.55 0.08 230); /* Gris-bleu plus clair */
--primary-foreground: oklch(0.1 0 0); /* Noir */
--secondary: oklch(0.24 0.006 230);
--secondary-foreground: oklch(0.97 0.003 230);
--muted: oklch(0.22 0.006 230);
--muted-foreground: oklch(0.55 0.01 230);
--accent: oklch(0.26 0.008 230);
--accent-foreground: oklch(0.97 0.003 230);
--destructive: oklch(0.65 0.18 25);
--border: oklch(0.33 0.01 230);
--input: oklch(0.33 0.01 230);
--ring: oklch(0.6 0.01 230);
--background: #202020;
--foreground: #ffffff;
--card: #2d2d2d;
--card-foreground: #ffffff;
--popover: #2d2d2d;
--popover-foreground: #ffffff;
--primary: #d6d3d1;
--primary-foreground: #1c1917;
--secondary: #2d2d2d;
--secondary-foreground: #ffffff;
--muted: #2d2d2d;
--muted-foreground: #9e9e9e;
--accent: #383838;
--accent-foreground: #ffffff;
--destructive: #ff6b6b;
--border: #3d3d3d;
--input: #3d3d3d;
--ring: #a8a29e;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--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: oklch(0.12 0.005 230);
--sidebar-foreground: oklch(0.97 0.003 230);
--sidebar-primary: oklch(0.55 0.08 230);
--sidebar-primary-foreground: oklch(0.1 0 0);
--sidebar-accent: oklch(0.24 0.006 230);
--sidebar-accent-foreground: oklch(0.97 0.003 230);
--sidebar-border: oklch(0.28 0.01 230);
--sidebar-ring: oklch(0.6 0.01 230);
--sidebar: rgba(32, 32, 32, 0.75);
--sidebar-foreground: #ffffff;
--sidebar-primary: #d6d3d1;
--sidebar-primary-foreground: #1c1917;
--sidebar-accent: #383838;
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #3d3d3d;
--sidebar-ring: #a8a29e;
}
[data-theme='midnight'] {
@@ -781,8 +992,12 @@ html.font-system * {
}
body {
@apply bg-background text-foreground;
/* Body inherits from html, can be adjusted per language */
@apply text-foreground;
background-color: var(--memento-desk);
}
html.dark body {
background-color: var(--background);
}
/* Latin languages use default (inherits from html) */
@@ -1073,7 +1288,7 @@ html.font-system * {
background: var(--popover);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);
box-shadow: 0 2px 8px rgba(0,0,0,0.08), 0 8px 16px rgba(0,0,0,0.06);
padding: 2px;
display: flex;
align-items: center;
@@ -1081,7 +1296,7 @@ html.font-system * {
z-index: 100;
}
.dark .notion-bubble-menu {
box-shadow: 0 4px 16px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.06);
box-shadow: 0 2px 8px rgba(0,0,0,0.3), 0 8px 24px rgba(0,0,0,0.4);
}
.notion-bubble-btn {
@@ -1095,7 +1310,10 @@ html.font-system * {
background: transparent;
color: var(--foreground);
cursor: pointer;
transition: all 0.12s ease;
transition: all 0.15s ease;
}
.notion-bubble-btn:hover {
background: var(--accent);
}
.notion-bubble-btn:hover {
background: var(--accent);
@@ -1114,7 +1332,7 @@ html.font-system * {
background: var(--popover);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
box-shadow: 0 2px 8px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.08);
padding: 4px;
min-width: 150px;
z-index: 110;
@@ -1134,7 +1352,7 @@ html.font-system * {
cursor: pointer;
font-size: 0.8125rem;
color: var(--foreground);
transition: background 0.1s ease;
transition: background 0.15s ease;
}
.notion-ai-subitem:hover {
background: var(--accent);
@@ -1171,8 +1389,8 @@ html.font-system * {
.notion-ai-result-modal {
background: var(--popover);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04), 0 16px 48px rgba(0,0,0,0.12);
padding: 20px;
width: min(520px, 90vw);
max-height: 80vh;
@@ -1226,8 +1444,8 @@ html.font-system * {
.notion-image-modal {
background: var(--popover);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04), 0 16px 48px rgba(0,0,0,0.08);
padding: 20px;
width: 400px;
max-width: 90vw;
@@ -1239,15 +1457,16 @@ html.font-system * {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 4px;
background: var(--background);
color: var(--foreground);
font-size: 0.8125rem;
outline: none;
transition: border-color 0.15s ease;
transition: border-color var(--transition-fast);
}
.notion-modal-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
}
.notion-modal-input::placeholder {
color: var(--muted-foreground);
@@ -1255,13 +1474,13 @@ html.font-system * {
}
.notion-modal-btn {
padding: 6px 14px;
border-radius: 6px;
border-radius: 4px;
font-size: 0.8125rem;
border: 1px solid var(--border);
background: transparent;
color: var(--foreground);
cursor: pointer;
transition: background 0.1s ease;
transition: background var(--transition-fast);
}
.notion-modal-btn:hover {
background: var(--accent);
@@ -1284,22 +1503,22 @@ html.font-system * {
z-index: 9999;
background: var(--popover);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04), 0 8px 32px rgba(0,0,0,0.08);
padding: 6px;
min-width: 320px;
max-height: 420px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
animation: slash-enter 0.12s ease;
animation: slash-enter 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slash-enter {
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.dark .notion-slash-menu {
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 2px 8px rgba(0,0,0,0.3);
box-shadow: 0 2px 8px rgba(0,0,0,0.3), 0 8px 32px rgba(0,0,0,0.5);
}
/* Header hint */
@@ -1331,14 +1550,14 @@ html.font-system * {
align-items: center;
gap: 4px;
padding: 3px 9px;
border-radius: 20px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
border: 1px solid var(--border);
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
transition: all 0.12s ease;
transition: all 0.15s ease;
white-space: nowrap;
}
.notion-slash-tab:hover {
@@ -1415,11 +1634,11 @@ html.font-system * {
padding: 6px 8px;
border: none;
background: transparent;
border-radius: 7px;
border-radius: 6px;
cursor: pointer;
text-align: left;
color: var(--foreground);
transition: background 0.1s ease, transform 0.08s ease;
transition: background 0.15s ease, transform 0.08s ease;
}
[dir="rtl"] .notion-slash-item {
text-align: right;
@@ -1439,11 +1658,11 @@ html.font-system * {
width: 30px;
height: 30px;
border: 1px solid var(--border);
border-radius: 7px;
border-radius: 6px;
background: var(--background);
color: var(--foreground);
flex-shrink: 0;
transition: background 0.1s ease;
transition: background 0.15s ease;
}
.notion-slash-item-selected .notion-slash-icon,
.notion-slash-item:hover .notion-slash-icon {

View File

@@ -9,8 +9,10 @@ import { DirectionInitializer } from "@/components/direction-initializer";
import { ErrorReporter } from "@/components/error-reporter";
import { auth } from "@/auth";
import Script from "next/script";
import { getThemeScript } from "@/lib/theme-script";
import { normalizeThemeId } from "@/lib/apply-document-theme";
import { Inter, Manrope } from "next/font/google";
import { Inter, Manrope, Playfair_Display } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
@@ -22,6 +24,12 @@ const manrope = Manrope({
variable: "--font-manrope",
});
const playfair = Playfair_Display({
subsets: ["latin"],
variable: "--font-memento-serif",
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "Memento - Your Digital Notepad",
description: "A beautiful note-taking app built with Next.js 16",
@@ -38,13 +46,18 @@ export const metadata: Metadata = {
};
export const viewport: Viewport = {
themeColor: "#3A7CA5",
themeColor: "#1C1C1C",
};
function getHtmlClass(theme?: string): string {
if (theme === 'dark') return 'dark';
if (theme === 'midnight') return 'dark';
return '';
function serverHtmlThemeState(theme?: string | null): { className?: string; dataTheme?: string } {
const t = normalizeThemeId(theme || 'light')
if (t === 'auto') return {}
if (t === 'dark') return { className: 'dark' }
if (t === 'light') return {}
if (t === 'midnight') return { className: 'dark', dataTheme: 'midnight' }
const named = ['sepia', 'rose', 'green', 'lavender', 'sand', 'ocean', 'sunset', 'blue'] as const
if ((named as readonly string[]).includes(t)) return { dataTheme: t }
return {}
}
/**
@@ -77,10 +90,21 @@ export default async function RootLayout({
getUserSettings(userId),
])
const htmlTheme = serverHtmlThemeState(userSettings.theme)
return (
<html suppressHydrationWarning className={getHtmlClass(userSettings.theme)}>
<html
suppressHydrationWarning
className={htmlTheme.className}
data-theme={htmlTheme.dataTheme}
>
<head />
<body className={`${inter.className} ${inter.variable} ${manrope.variable}`}>
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable}`}>
<Script
id="theme-early"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: getThemeScript(userSettings.theme) }}
/>
<Script
id="sw-cleanup"
strategy="afterInteractive"

View File

@@ -21,6 +21,7 @@ import {
XCircle,
Clock,
Pencil,
Presentation,
} from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
@@ -51,11 +52,17 @@ interface AgentCardProps {
// --- Config ---
/** Icône par type — tons neutres alignés sur le thème (encre / papier). */
const ICON_BOX = 'bg-primary/10 dark:bg-primary/15'
const ICON_MARK = 'text-primary'
const typeConfig: Record<string, { icon: typeof Globe; color: string; bgColor: string }> = {
scraper: { icon: Globe, color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-50 dark:bg-blue-950' },
researcher: { icon: Search, color: 'text-violet-600 dark:text-violet-400', bgColor: 'bg-violet-50 dark:bg-violet-950' },
monitor: { icon: Eye, color: 'text-amber-600 dark:text-amber-400', bgColor: 'bg-amber-50 dark:bg-amber-950' },
custom: { icon: Settings, color: 'text-emerald-600 dark:text-emerald-400', bgColor: 'bg-emerald-50 dark:bg-emerald-950' },
scraper: { icon: Globe, color: ICON_MARK, bgColor: ICON_BOX },
researcher: { icon: Search, color: ICON_MARK, bgColor: ICON_BOX },
monitor: { icon: Eye, color: ICON_MARK, bgColor: ICON_BOX },
custom: { icon: Settings, color: ICON_MARK, bgColor: ICON_BOX },
'slide-generator': { icon: Presentation, color: ICON_MARK, bgColor: ICON_BOX },
'excalidraw-generator': { icon: Pencil, color: ICON_MARK, bgColor: ICON_BOX },
}
const frequencyKeys: Record<string, string> = {
@@ -177,7 +184,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
<div className={`
font-display group flex flex-col bg-card rounded-lg border transition-all duration-200
${agent.isEnabled
? 'border-border/40 hover:border-primary/30 hover:shadow-[0_2px_12px_rgba(0,91,193,0.08)]'
? 'border-border/40 hover:border-primary/25 hover:shadow-[0_2px_12px_color-mix(in_oklab,var(--foreground)_7%,transparent)]'
: 'border-border/30 opacity-60'
}
`}>
@@ -194,12 +201,12 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
<div className="flex items-center gap-1.5">
<h3 className="font-semibold text-sm text-card-foreground truncate leading-tight">{agent.name}</h3>
{mounted && isNew && (
<span className="flex-shrink-0 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300 rounded">
<span className="flex-shrink-0 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider bg-muted text-muted-foreground rounded border border-border/60">
{t('agents.newBadge')}
</span>
)}
</div>
<span className={`text-[11px] font-bold uppercase tracking-wider ${config.color}`}>
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
{t(`agents.types.${agent.type || 'custom'}`)}
</span>
</div>
@@ -263,8 +270,8 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
<p className="text-[10px] text-muted-foreground font-medium mb-0.5">{t('agents.status.lastStatus')}</p>
{lastAction ? (
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${
lastAction.status === 'success' ? 'text-emerald-600 dark:text-emerald-400' :
lastAction.status === 'failure' ? 'text-red-600 dark:text-red-400' :
lastAction.status === 'success' ? 'text-primary' :
lastAction.status === 'failure' ? 'text-destructive' :
lastAction.status === 'running' ? 'text-primary' :
'text-muted-foreground'
}`}>
@@ -302,7 +309,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
<button
onClick={handleDelete}
disabled={isDeleting}
className="p-1.5 text-red-500 bg-red-50 dark:bg-red-950/20 rounded-md hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors disabled:opacity-40"
className="p-1.5 text-destructive bg-destructive/10 rounded-md hover:bg-destructive/20 transition-colors disabled:opacity-40"
title={t('agents.actions.delete')}
>
{isDeleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}

View File

@@ -358,7 +358,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
<button
type="button"
onClick={() => removeUrl(i)}
className="p-2 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950 rounded-lg transition-colors"
className="p-2 text-destructive/80 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
@@ -721,7 +721,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{t(at.labelKey)}</span>
{at.external && !isSelected && (
<span className="ml-auto text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950 px-1.5 py-0.5 rounded-full">{t('agents.tools.configNeeded')}</span>
<span className="ml-auto text-[10px] text-muted-foreground bg-muted border border-border/60 px-1.5 py-0.5 rounded-full">{t('agents.tools.configNeeded')}</span>
)}
</button>
)

View File

@@ -32,16 +32,16 @@ export function AgentHelp({ onClose }: AgentHelpProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-2xl w-full max-w-3xl max-h-[85vh] flex flex-col mx-4">
<div className="bg-card rounded-2xl shadow-2xl w-full max-w-3xl max-h-[85vh] flex flex-col mx-4 border border-border">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<div className="flex items-center gap-2.5">
<LifeBuoy className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t('agents.help.title')}</h2>
<h2 className="text-lg font-semibold text-foreground">{t('agents.help.title')}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-5 h-5" />
</button>
@@ -53,25 +53,25 @@ export function AgentHelp({ onClose }: AgentHelpProps) {
<details
key={section.key}
open={section.defaultOpen}
className="group border-b border-slate-100 dark:border-slate-800 last:border-b-0"
className="group border-b border-border/60 last:border-b-0"
>
<summary className="flex items-center gap-2 cursor-pointer py-3 font-medium text-slate-800 dark:text-slate-200 select-none hover:text-primary transition-colors text-sm">
<summary className="flex items-center gap-2 cursor-pointer py-3 font-medium text-foreground select-none hover:text-primary transition-colors text-sm">
<span className="text-primary text-xs transition-transform group-open:rotate-90">&#9656;</span>
{t(`agents.help.${section.key}`)}
</summary>
<div className="pb-4 pl-5 prose prose-slate dark:prose-invert prose-sm max-w-none
prose-headings:font-semibold prose-headings:text-slate-800 dark:prose-headings:text-slate-200
<div className="pb-4 pl-5 prose prose-sm max-w-none dark:prose-invert
prose-headings:font-semibold prose-headings:text-foreground
prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1
prose-p:leading-relaxed prose-p:text-slate-600 dark:prose-p:text-slate-400 prose-p:my-1.5
prose-li:text-slate-600 dark:prose-li:text-slate-400 prose-li:my-0.5
prose-strong:text-slate-700 dark:prose-strong:text-slate-300
prose-p:leading-relaxed prose-p:text-muted-foreground prose-p:my-1.5
prose-li:text-muted-foreground prose-li:my-0.5
prose-strong:text-foreground
prose-code:text-primary prose-code:bg-primary/5 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
prose-ul:my-2 prose-ol:my-2
prose-hr:border-slate-200 dark:prose-hr:border-slate-700
prose-hr:border-border
prose-table:text-xs
prose-th:text-left prose-th:font-medium prose-th:text-slate-700 dark:prose-th:text-slate-300 prose-th:py-1 prose-th:pr-3
prose-td:text-slate-600 dark:prose-td:text-slate-400 prose-td:py-1 prose-td:pr-3
prose-blockquote:border-primary/30 prose-blockquote:text-slate-500 dark:prose-blockquote:text-slate-400
prose-th:text-left prose-th:font-medium prose-th:text-foreground prose-th:py-1 prose-th:pr-3
prose-td:text-muted-foreground prose-td:py-1 prose-td:pr-3
prose-blockquote:border-primary/30 prose-blockquote:text-muted-foreground
">
<Markdown remarkPlugins={[remarkGfm]}>
{t(`agents.help.${section.key}Content`)}
@@ -82,10 +82,10 @@ export function AgentHelp({ onClose }: AgentHelpProps) {
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 shrink-0">
<div className="px-6 py-4 border-t border-border shrink-0">
<button
onClick={onClose}
className="w-full px-4 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
className="w-full px-4 py-2.5 text-sm font-medium text-primary-foreground bg-primary rounded-lg hover:bg-primary/90 transition-colors"
>
{t('agents.help.close')}
</button>

View File

@@ -106,17 +106,17 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
key={action.id}
className={`
p-3 rounded-lg border
${action.status === 'success' ? 'bg-green-50/50 dark:bg-green-950/50 border-green-100 dark:border-green-900' : ''}
${action.status === 'failure' ? 'bg-red-50/50 dark:bg-red-950/50 border-red-100 dark:border-red-900' : ''}
${action.status === 'running' ? 'bg-blue-50/50 dark:bg-blue-950/50 border-blue-100 dark:border-blue-900' : ''}
${action.status === 'success' ? 'bg-muted/40 border-border' : ''}
${action.status === 'failure' ? 'bg-destructive/5 border-destructive/25' : ''}
${action.status === 'running' ? 'bg-primary/5 border-primary/25' : ''}
${action.status === 'pending' ? 'bg-muted border-border' : ''}
`}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
{action.status === 'success' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
{action.status === 'failure' && <XCircle className="w-4 h-4 text-red-500" />}
{action.status === 'running' && <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />}
{action.status === 'success' && <CheckCircle2 className="w-4 h-4 text-primary" />}
{action.status === 'failure' && <XCircle className="w-4 h-4 text-destructive" />}
{action.status === 'running' && <Loader2 className="w-4 h-4 text-primary animate-spin" />}
{action.status === 'pending' && <Clock className="w-4 h-4 text-muted-foreground" />}
</div>
<div className="flex-1 min-w-0">

View File

@@ -13,6 +13,8 @@ import {
Settings,
Plus,
Loader2,
Presentation,
Pencil,
} from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
@@ -43,6 +45,8 @@ const templateConfig = [
], frequency: 'weekly' },
{ id: 'surveillant', type: 'monitor', roleKey: 'agents.defaultRoles.monitor', urls: [], frequency: 'weekly' },
{ id: 'chercheur', type: 'researcher', roleKey: 'agents.defaultRoles.researcher', urls: [], frequency: 'manual' },
{ id: 'slideGenerator', type: 'slide-generator', roleKey: 'agents.defaultRoles.slideGenerator', urls: [], frequency: 'manual' },
{ id: 'excalidrawGenerator', type: 'excalidraw-generator', roleKey: 'agents.defaultRoles.excalidrawGenerator', urls: [], frequency: 'manual' },
] as const
const typeIcons: Record<string, typeof Globe> = {
@@ -50,14 +54,11 @@ const typeIcons: Record<string, typeof Globe> = {
researcher: Search,
monitor: Eye,
custom: Settings,
'slide-generator': Presentation,
'excalidraw-generator': Pencil,
}
const typeColors: Record<string, string> = {
scraper: 'text-blue-600 bg-blue-50',
researcher: 'text-purple-600 bg-purple-50',
monitor: 'text-amber-600 bg-amber-50',
custom: 'text-green-600 bg-green-50',
}
const templateIconBox = 'bg-primary/10 text-primary dark:bg-primary/15'
export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplatesProps) {
const { t } = useLanguage()
@@ -89,7 +90,11 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
? ['web_search', 'web_scrape', 'note_search', 'note_create']
: tpl.type === 'monitor'
? ['note_search', 'note_read', 'note_create']
: [],
: tpl.type === 'slide-generator'
? ['note_search', 'note_read', 'generate_pptx']
: tpl.type === 'excalidraw-generator'
? ['note_search', 'note_read', 'generate_excalidraw']
: [],
})
toast.success(t('agents.toasts.installSuccess', { name: resolvedName }))
onInstalled()
@@ -115,10 +120,10 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
return (
<div
key={tpl.id}
className="border-2 border-dashed border-slate-200 dark:border-slate-700 rounded-xl p-4 hover:border-primary/30 hover:bg-primary/[0.02] transition-all group"
className="border-2 border-dashed border-border/70 rounded-xl p-4 hover:border-primary/35 hover:bg-primary/[0.03] transition-all group"
>
<div className="flex items-center gap-2.5 mb-2">
<div className={`p-1.5 rounded-lg ${typeColors[tpl.type]}`}>
<div className={`p-1.5 rounded-lg ${templateIconBox}`}>
<Icon className="w-4 h-4" />
</div>
<h4 className="font-medium text-sm text-foreground">{t(nameKey)}</h4>

View File

@@ -0,0 +1,12 @@
'use client'
import { AIChat } from '@/components/ai-chat'
/**
* Always render AIChat — on desktop the floating trigger button is shown
* unless a note editor already has the contextual panel open.
* The sidebar dispatches 'toggle-ai-chat' event to open it programmatically.
*/
export function AIChatLayoutBridge() {
return <AIChat showFloatingTrigger={true} />
}

View File

@@ -30,7 +30,7 @@ const TONES = [
{ id: 'casual', label: 'Casual', icon: Coffee },
]
export function AIChat() {
export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: boolean } = {}) {
const { t, language } = useLanguage()
const webSearchAvailable = useWebSearchAvailable()
const { notebooks } = useNotebooks()
@@ -143,6 +143,7 @@ export function AIChat() {
if (!isOpen) {
if (isContextualAIVisible) return null
if (!showFloatingTrigger) return null
return (
<Button
onClick={() => setIsOpen(true)}

View File

@@ -13,8 +13,9 @@ import {
Globe, BookOpen, FileText, RotateCcw, Check,
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
GitMerge, PlusCircle, Eye, Code, Languages,
Presentation, PenTool, ExternalLink,
Presentation, PenTool, ExternalLink, ImagePlus,
} from 'lucide-react'
import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image'
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
import { toast } from 'sonner'
@@ -94,6 +95,8 @@ interface ContextualAIChatProps {
notebooks?: Array<{ id: string; name: string }>
/** Extra classes forwarded to the aside root element */
className?: string
/** How to embed generated diagram images (markdown vs rich text HTML) */
diagramInsertFormat?: 'markdown' | 'html'
}
// ── Component ─────────────────────────────────────────────────────────────────
@@ -109,6 +112,7 @@ export function ContextualAIChat({
lastActionApplied = false,
notebooks = [],
className,
diagramInsertFormat = 'markdown',
}: ContextualAIChatProps) {
const { t, language } = useLanguage()
const webSearchAvailable = useWebSearchAvailable()
@@ -135,6 +139,7 @@ export function ContextualAIChat({
const [slideStyle, setSlideStyle] = useState('soft')
const [diagramType, setDiagramType] = useState('auto')
const [diagramStyle, setDiagramStyle] = useState('default')
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
// Resource tab state
const [resourceUrl, setResourceUrl] = useState('')
@@ -329,9 +334,8 @@ export function ContextualAIChat({
})
} else if (type === 'diagram' && poll.canvasId) {
toast.success(t('ai.generate.diagramReady') || 'Diagramme généré !', {
id: toastId, duration: 10000,
description: t('ai.generate.toastSuccessDiagram') || 'Votre diagramme est disponible dans le Lab.',
action: { label: t('ai.generate.openDiagram') || 'Ouvrir', onClick: () => { window.location.href = `/lab?id=${poll.canvasId}` } },
id: toastId, duration: 8000,
description: t('ai.generate.diagramReadyHint') || '',
})
} else {
toast.success(type === 'slides'
@@ -365,6 +369,53 @@ export function ContextualAIChat({
}
}, [])
const buildDiagramImageSnippet = (imageUrl: string, alt: string) => {
if (diagramInsertFormat === 'html') {
const safeAlt = alt.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
return `\n<p><img src="${imageUrl}" alt="${safeAlt}" loading="lazy" style="max-width:100%;height:auto;border-radius:8px;" /></p>\n`
}
const safeMdAlt = alt.replace(/[\[\]]/g, '')
return `\n\n![${safeMdAlt}](${imageUrl})\n\n`
}
const handleEmbedDiagramInNote = async (canvasId: string) => {
if (!onApplyToNote) {
toast.error(t('ai.generate.insertNeedEditor'))
return
}
setDiagramEmbedLoading(true)
try {
const res = await fetch(`/api/canvas?id=${encodeURIComponent(canvasId)}`)
const data = await res.json()
if (!res.ok || !data.canvas?.data) {
toast.error(data.error || t('ai.generate.insertFetchError'))
return
}
const blob = await exportExcalidrawSceneToPngBlob(data.canvas.data)
if (!blob) {
toast.error(t('ai.generate.insertExportError'))
return
}
const fd = new FormData()
fd.append('file', blob, `diagram-${canvasId.slice(-8)}.png`)
const up = await fetch('/api/upload', { method: 'POST', body: fd })
const upJson = await up.json()
if (!up.ok || !upJson.url) {
toast.error(upJson.error || t('ai.generate.insertUploadError'))
return
}
const alt = t('ai.generate.diagramImageAlt')
const snippet = buildDiagramImageSnippet(upJson.url as string, alt)
onApplyToNote(`${noteContent ?? ''}${snippet}`)
toast.success(t('ai.generate.insertedInNote'))
} catch (e) {
console.error('[embed diagram]', e)
toast.error(t('ai.generate.insertExportError'))
} finally {
setDiagramEmbedLoading(false)
}
}
// ── Resource tab handlers ────────────────────────────────────────────────────
const handleScrapeUrl = async () => {
@@ -467,46 +518,47 @@ export function ContextualAIChat({
className,
)}>
{/* ── Header ───────────────────────────────────────────────── */}
<div className="px-4 py-3 border-b border-border/40 flex items-center justify-between shrink-0">
<div className="min-w-0">
<h2 className="text-base font-semibold text-foreground flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary shrink-0" />
{t('ai.aiNoteTitle')}
</h2>
<p className="text-xs text-muted-foreground truncate">
{noteTitle ? `"${noteTitle}"` : t('ai.currentNote')}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
{lastActionApplied && onUndoLastAction && (
{/* ── Header ─────────────────────────────────────────── */}
<div className="px-5 pt-5 pb-4 border-b border-border/40 shrink-0">
<div className="flex items-start justify-between">
<div className="min-w-0">
<h2 className="font-memento-serif text-xl font-medium text-foreground flex items-center gap-2 leading-tight">
<Sparkles className="h-4 w-4 text-amber-500 shrink-0" />
{t('ai.aiNoteTitle')}
</h2>
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.15em] mt-1 truncate font-medium">
{noteTitle ? noteTitle : t('ai.currentNote')}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0 -mt-1">
{lastActionApplied && onUndoLastAction && (
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/30"
onClick={onUndoLastAction}
title={t('ai.undoLastAction')}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/30"
onClick={onUndoLastAction}
title={t('ai.undoLastAction')}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setExpanded(e => !e)}
title={expanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
>
<RotateCcw className="h-3.5 w-3.5" />
{expanded
? <Minimize2 className="h-3.5 w-3.5" />
: <Maximize2 className="h-3.5 w-3.5" />}
</Button>
)}
{/* Expand / Shrink */}
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setExpanded(e => !e)}
title={expanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
>
{expanded
? <Minimize2 className="h-3.5 w-3.5" />
: <Maximize2 className="h-3.5 w-3.5" />}
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7 text-muted-foreground hover:text-foreground">
<X className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7 text-muted-foreground hover:text-foreground">
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
{/* ── Tabs ─────────────────────────────────────────────────── */}
{/* ── Tabs ────────────────────────────────────────────── */}
<div className="flex border-b border-border/40 shrink-0">
{([
{ id: 'chat', icon: Bot, label: t('ai.chatTab') },
@@ -517,14 +569,17 @@ export function ContextualAIChat({
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={cn(
'flex-1 py-2.5 border-b-2 text-xs font-semibold flex items-center justify-center gap-1.5 transition-all',
'relative flex-1 py-3 text-[10px] font-bold uppercase tracking-[0.15em] flex items-center justify-center gap-1.5 transition-all',
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
<tab.icon className="h-3 w-3" />
{tab.label}
{activeTab === tab.id && (
<span className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground" />
)}
</button>
))}
</div>
@@ -1089,11 +1144,24 @@ export function ContextualAIChat({
{/* Diagram result */}
{generateResult?.type === 'diagram' && generateResult.canvasId && (
<a href={`/lab?id=${generateResult.canvasId}`}
className="mt-2 w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all">
<ExternalLink className="h-4 w-4 shrink-0" />
{t('ai.generate.openDiagram') || 'Ouvrir dans le Lab'}
</a>
<div className="mt-2 flex flex-col gap-2">
<a href={`/lab?id=${generateResult.canvasId}`}
className="w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all">
<PenTool className="h-4 w-4 shrink-0" />
{t('ai.generate.openInExcalidraw')}
</a>
<button
type="button"
disabled={diagramEmbedLoading}
onClick={() => void handleEmbedDiagramInNote(generateResult.canvasId!)}
className="w-full flex items-center gap-2 rounded-xl border border-cyan-200 bg-cyan-50 dark:border-cyan-800 dark:bg-cyan-950/30 px-4 py-2.5 text-sm font-medium text-cyan-700 dark:text-cyan-300 hover:bg-cyan-100 dark:hover:bg-cyan-900/40 transition-all disabled:opacity-60 text-left"
>
{diagramEmbedLoading
? <Loader2 className="h-4 w-4 shrink-0 animate-spin" />
: <ImagePlus className="h-4 w-4 shrink-0" />}
{t('ai.generate.insertDiagramInNote')}
</button>
</div>
)}
</div>
)}

View File

@@ -40,7 +40,7 @@ export function FavoritesSection({ pinnedNotes, onEdit, onSizeChange, isLoading
}
return (
<section data-testid="favorites-section" className="mb-8">
<section data-testid="favorites-section" id="memento-pinned" className="mb-8 scroll-mt-28">
<button
onClick={() => setIsCollapsed(!isCollapsed)}
onKeyDown={(e) => {
@@ -55,7 +55,7 @@ export function FavoritesSection({ pinnedNotes, onEdit, onSizeChange, isLoading
>
<div className="flex items-center gap-2">
<span className="text-2xl">📌</span>
<h2 className="text-lg font-semibold text-foreground">
<h2 className="font-memento-serif text-xl font-normal tracking-tight text-foreground md:text-2xl">
{t('notes.pinnedNotes')}
<span className="text-sm font-medium text-muted-foreground ml-2">
({pinnedNotes.length})

View File

@@ -25,6 +25,7 @@ import { useLabels } from '@/context/LabelContext'
import { LabelFilter } from './label-filter'
import { NotificationPanel } from './notification-panel'
import { updateTheme } from '@/app/actions/profile'
import { applyDocumentTheme, normalizeThemeId } from '@/lib/apply-document-theme'
import { useDebounce } from '@/hooks/use-debounce'
import { useLanguage } from '@/lib/i18n'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
@@ -46,7 +47,7 @@ export function Header({
user
}: HeaderProps = {}) {
const [searchQuery, setSearchQuery] = useState('')
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const [theme, setTheme] = useState<string>('light')
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [isSemanticSearching, setIsSemanticSearching] = useState(false)
const pathname = usePathname()
@@ -167,24 +168,13 @@ export function Header({
}, [currentUser])
const applyTheme = async (newTheme: string, persist = true) => {
setTheme(newTheme as any)
localStorage.setItem('theme-preference', newTheme)
// Remove all theme classes first
document.documentElement.classList.remove('dark')
document.documentElement.removeAttribute('data-theme')
if (newTheme === 'dark') {
document.documentElement.classList.add('dark')
} else if (newTheme !== 'light') {
document.documentElement.setAttribute('data-theme', newTheme)
if (newTheme === 'midnight') {
document.documentElement.classList.add('dark')
}
}
const normalized = normalizeThemeId(newTheme)
setTheme(normalized)
localStorage.setItem('theme-preference', normalized)
applyDocumentTheme(normalized)
if (persist && currentUser) {
await updateTheme(newTheme)
await updateTheme(normalized)
}
}
@@ -265,10 +255,10 @@ export function Header({
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2 text-left",
"w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200 mr-2 text-left",
active
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
: "hover:bg-gray-100 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300"
)}
style={{ minHeight: '44px' }}
aria-pressed={active}
@@ -283,10 +273,10 @@ export function Header({
href={href}
onClick={() => setIsSidebarOpen(false)}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2",
"flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200 mr-2",
active
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
: "hover:bg-gray-100 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300"
)}
style={{ minHeight: '44px' }}
aria-current={active ? 'page' : undefined}
@@ -301,26 +291,27 @@ export function Header({
return (
<>
{/* Top Navigation - Style Keep */}
<header className="flex-none flex items-center justify-between whitespace-nowrap border-b border-solid border-slate-200 dark:border-slate-800 bg-white dark:bg-[#1e2128] px-6 py-3 z-20">
<div className="flex items-center gap-8">
<header className="flex-none flex items-center justify-between whitespace-nowrap border-b border-border/50 bg-card/85 backdrop-blur-xl px-5 py-3 z-20 shadow-sm md:px-8">
<div className="flex items-center gap-6 md:gap-10">
{/* Logo MEMENTO */}
<div className="flex items-center gap-3 text-slate-900 dark:text-white cursor-pointer group" onClick={() => router.push('/')}>
<div className="size-8 bg-primary rounded-lg flex items-center justify-center text-primary-foreground shadow-sm group-hover:shadow-md transition-all">
<StickyNote className="w-5 h-5" />
{/* Wordmark */}
<div className="flex cursor-pointer items-center gap-3 text-foreground group" onClick={() => router.push('/')}>
<div className="flex size-9 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-md shadow-primary/20 transition-all duration-200 group-hover:shadow-lg group-hover:shadow-primary/25">
<StickyNote className="w-[1.15rem] h-[1.15rem]" strokeWidth={2} />
</div>
<h2 className="text-xl font-bold leading-tight tracking-tight">MEMENTO</h2>
<h2 className="font-memento-serif text-[1.35rem] font-normal tracking-tight leading-none md:text-2xl">Memento</h2>
</div>
{/* Search Bar */}
<label className="hidden md:flex flex-col min-w-40 w-96 !h-10">
<div className="flex w-full flex-1 items-stretch rounded-full h-full bg-slate-100 dark:bg-slate-800 border border-transparent focus-within:bg-white dark:focus-within:bg-slate-700 focus-within:border-primary/30 focus-within:shadow-md transition-all duration-200">
<div className="text-slate-400 dark:text-slate-400 flex items-center justify-center pl-4 focus-within:text-primary transition-colors">
<div className="flex w-full flex-1 items-stretch rounded-xl h-full bg-muted/50 border border-transparent focus-within:bg-card focus-within:border-primary/35 focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_18%,transparent)] transition-all duration-200">
<div className="text-muted-foreground flex items-center justify-center pl-4 focus-within:text-primary transition-colors">
<Search className="w-4 h-4" />
</div>
<input
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden bg-transparent border-none text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 px-3 text-sm focus:ring-0 focus:outline-none"
id="memento-global-search"
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden bg-transparent border-none text-foreground placeholder:text-muted-foreground px-3 text-sm focus:ring-0 focus:outline-none"
placeholder={t('search.placeholder') }
type="text"
value={searchQuery}
@@ -333,15 +324,15 @@ export function Header({
<div className="flex flex-1 justify-end gap-2 items-center">
{/* Quick nav: Notes (hidden-sidebar only), Chat, Agents, Lab */}
<div className="hidden md:flex items-center gap-1 bg-slate-100 dark:bg-slate-800/60 rounded-full px-1.5 py-1">
<div className="hidden md:flex items-center gap-1 rounded-xl bg-muted/60 px-1.5 py-1 ring-1 ring-border/50">
{noSidebarMode && (
<Link
href="/"
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200",
pathname === '/'
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
? "bg-card text-primary shadow-sm ring-1 ring-primary/15"
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
)}
>
<StickyNote className="h-3.5 w-3.5" />
@@ -351,10 +342,10 @@ export function Header({
<Link
href="/agents"
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200",
pathname === '/agents'
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
? "bg-card text-primary shadow-sm ring-1 ring-primary/15"
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
)}
>
<Bot className="h-3.5 w-3.5" />
@@ -363,10 +354,10 @@ export function Header({
<Link
href="/lab"
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
pathname === '/lab'
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200",
pathname === '/lab'
? "bg-card text-primary shadow-sm ring-1 ring-primary/15"
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
)}
>
<FlaskConical className="h-3.5 w-3.5" />
@@ -380,7 +371,7 @@ export function Header({
{/* Settings Button */}
<Link
href="/settings"
className="flex items-center justify-center size-10 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 transition-colors"
className="flex items-center justify-center size-10 rounded-xl text-muted-foreground hover:bg-muted/80 hover:text-foreground transition-colors duration-200"
>
<Settings className="w-5 h-5" />
</Link>
@@ -388,7 +379,7 @@ export function Header({
{/* User Avatar Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex items-center justify-center bg-center bg-no-repeat bg-cover rounded-full size-10 ring-2 ring-white dark:ring-slate-700 cursor-pointer shadow-sm hover:shadow-md transition-shadow bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground"
<div className="flex items-center justify-center bg-center bg-no-repeat bg-cover rounded-xl size-10 ring-2 ring-border/60 cursor-pointer shadow-sm hover:shadow-md transition-shadow duration-200 bg-primary/10 text-primary"
style={currentUser?.image ? { backgroundImage: `url(${currentUser?.image})` } : undefined}>
{!currentUser?.image && (
<span className="text-sm font-semibold">

View File

@@ -1,31 +1,31 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useRef, useTransition, useMemo } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById } from '@/app/actions/notes'
import { NoteInput } from '@/components/note-input'
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote } from '@/app/actions/notes'
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
import { NotesViewToggle } from '@/components/notes-view-toggle'
import { NotesEditorialView } from '@/components/notes-editorial-view'
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { FavoritesSection } from '@/components/favorites-section'
import { Button } from '@/components/ui/button'
import { Wand2, ChevronRight, Plus, FileText } from 'lucide-react'
import { Plus, ArrowUpDown } from 'lucide-react'
import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useReminderCheck } from '@/hooks/use-reminder-check'
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
import { useNotebooks } from '@/context/notebooks-context'
import { getNotebookIcon } from '@/lib/notebook-icon'
import { cn } from '@/lib/utils'
import { LabelFilter } from '@/components/label-filter'
import { useLanguage } from '@/lib/i18n'
import { useHomeView } from '@/context/home-view-context'
import { NoteHistoryModal } from '@/components/note-history-modal'
import { toast } from 'sonner'
import { AnimatePresence, motion } from 'motion/react'
type SortOrder = 'newest' | 'oldest' | 'alpha'
// Lazy-load heavy dialogs — uniquement chargés à la demande
const NoteEditor = dynamic(
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
{ ssr: false }
@@ -41,7 +41,7 @@ const AutoLabelSuggestionDialog = dynamic(
type InitialSettings = {
showRecentNotes: boolean
notesViewMode: 'masonry' | 'tabs'
notesViewMode: 'masonry' | 'tabs' | 'list'
noteHistory: boolean
noteHistoryMode: 'manual' | 'auto'
}
@@ -63,11 +63,14 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
const [noteHistoryMode] = useState<'manual' | 'auto'>(initialSettings.noteHistoryMode)
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
const [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded
const [isLoading, setIsLoading] = useState(false)
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [historyNote, setHistoryNote] = useState<Note | null>(null)
const [isCreating, startCreating] = useTransition()
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
const [showSortMenu, setShowSortMenu] = useState(false)
const { refreshKey, triggerRefresh } = useNoteRefresh()
const { labels } = useLabels()
const { setControls } = useHomeView()
@@ -81,9 +84,20 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
}
}, [shouldSuggestLabels, suggestNotebookId])
const notebookFilter = searchParams.get('notebook')
const isInbox = !notebookFilter
// BUG FIX: forceList param from sidebar carnet click → reset to editorial view
useEffect(() => {
const forceList = searchParams.get('forceList')
if (forceList === '1') {
setNotesViewMode(prev => (prev === 'tabs' ? 'masonry' : prev))
const params = new URLSearchParams(searchParams.toString())
params.delete('forceList')
const newUrl = params.toString() ? `/?${params.toString()}` : '/'
router.replace(newUrl, { scroll: false })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams])
const notebookFilter = searchParams.get('notebook')
const handleNoteCreated = useCallback((note: Note) => {
setNotes((prevNotes) => {
const notebookFilter = searchParams.get('notebook')
@@ -123,10 +137,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
}
})
// NOTE: No triggerRefresh() — note is added optimistically above.
// triggerRefresh() → getAllNotes() can return stale Next.js cache (note
// created with skipRevalidation:true) and overwrite the freshly-added note.
if (!note.notebookId) {
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
if (wordCount >= 20) {
@@ -140,6 +150,25 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
if (note) setEditingNote({ note, readOnly: false })
}
const handleAddNote = () => {
startCreating(async () => {
try {
const newNote = await createNote({
content: '',
type: 'richtext',
title: undefined,
notebookId: notebookFilter || undefined,
skipRevalidation: true
})
if (!newNote) return
handleNoteCreated(newNote)
setEditingNote({ note: newNote, readOnly: false })
} catch {
toast.error(t('notes.createFailed') || 'Failed to create note')
}
})
}
const handleOpenHistory = useCallback((note: Note) => {
setHistoryNote(note)
setHistoryOpen(true)
@@ -147,7 +176,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const handleEnableHistory = useCallback(async (noteId: string) => {
await enableNoteHistory(noteId)
// Update the specific note in state
setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, historyEnabled: true } : n)))
setPinnedNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, historyEnabled: true } : n)))
setEditingNote((prev) => (prev?.note.id === noteId ? { ...prev, note: { ...prev.note, historyEnabled: true } } : prev))
@@ -168,24 +196,20 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
useReminderCheck(notes)
// Handle ?openNote=ID — open a note from a notification click
useEffect(() => {
const openNoteId = searchParams.get('openNote')
if (!openNoteId) return
const openNote = async () => {
// Try to find the note in current state first
const existing = notes.find(n => n.id === openNoteId)
if (existing) {
setEditingNote({ note: existing, readOnly: false })
} else {
// Fetch from server
const fetched = await getNoteById(openNoteId)
if (fetched) {
setEditingNote({ note: fetched, readOnly: false })
}
}
// Clean URL — remove openNote param
const params = new URLSearchParams(searchParams.toString())
params.delete('openNote')
router.replace(params.toString() ? `/?${params.toString()}` : '/', { scroll: false })
@@ -195,7 +219,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams])
// Listen for global label deletion and immediately update local state
useEffect(() => {
const handler = (e: Event) => {
const { name } = (e as CustomEvent).detail
@@ -215,8 +238,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const prevRefreshKey = useRef(refreshKey)
// Rechargement uniquement pour les filtres actifs (search, labels, notebook)
// Les notes initiales suffisent sans filtre
useEffect(() => {
const search = searchParams.get('search')?.trim() || null
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
@@ -227,8 +248,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const isBackgroundRefresh = refreshKey > prevRefreshKey.current
prevRefreshKey.current = refreshKey
// Pour le refreshKey (mutations), toujours recharger
// Pour les filtres, charger depuis le serveur
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
const load = async () => {
@@ -243,14 +262,12 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
}
// Filtre labels
if (labelFilter.length > 0) {
allNotes = allNotes.filter((note: any) =>
note.labels?.some((label: string) => labelFilter.includes(label))
)
}
// Filtre couleur
if (colorFilter) {
const labelNamesWithColor = labels
.filter((label: any) => label.color === colorFilter)
@@ -260,7 +277,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
)
}
// Merger avec les tailles locales pour ne pas écraser les modifications
setNotes(prev => {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return allNotes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
@@ -269,20 +285,17 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
setIsLoading(false)
}
// Éviter le rechargement initial si les notes sont déjà chargées sans filtres
if (refreshKey > 0 || hasActiveFilter) {
const cancelled = { value: false }
load().then(() => { if (cancelled.value) return })
return () => { cancelled.value = true }
} else {
// Données initiales : filtrage inbox/notebook côté client seulement
let filtered = initialNotes
if (notebook) {
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
} else {
filtered = initialNotes.filter((n: any) => !n.notebookId || n._isShared)
}
// Merger avec les tailles déjà modifiées localement
setNotes(prev => {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return filtered.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
@@ -298,148 +311,120 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
useEffect(() => {
setControls({
isTabsMode: notesViewMode === 'tabs',
openNoteComposer: () => {},
openNoteComposer: () => handleAddNote(),
})
return () => setControls(null)
}, [notesViewMode, setControls])
const handleNoteCreatedWrapper = (note: any) => {
handleNoteCreated(note)
// Apply sort order to notes
const sortedNotes = useMemo(() => {
const sorted = [...notes]
if (sortOrder === 'newest') sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
if (sortOrder === 'oldest') sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
if (sortOrder === 'alpha') sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''))
return sorted
}, [notes, sortOrder])
const sortedPinnedNotes = useMemo(() => {
return sortedNotes.filter(n => n.isPinned)
}, [sortedNotes])
const sortLabels: Record<SortOrder, string> = {
newest: t('sidebar.sortNewest') || 'Plus récentes',
oldest: t('sidebar.sortOldest') || 'Plus anciennes',
alpha: t('sidebar.sortAlpha') || 'A → Z',
}
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<span>{t('nav.notebooks')}</span>
<ChevronRight className="w-4 h-4" />
<span className="font-medium text-primary">{notebookName}</span>
</div>
)
const isTabs = notesViewMode === 'tabs'
const isEditorialMode = !isTabs
const handleEditorClose = useCallback(() => {
setEditingNote(null)
triggerRefresh()
}, [triggerRefresh])
return (
<div
className={cn(
'flex w-full min-h-0 flex-1 flex-col',
isTabs ? 'gap-3 py-1' : 'h-full px-2 py-6 sm:px-4 md:px-8'
isTabs ? 'gap-3 py-1' : 'h-full'
)}
>
{/* Notebook Specific Header */}
{currentNotebook ? (
<div
className={cn(
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
)}
>
<Breadcrumbs notebookName={currentNotebook.name} />
<div className="flex items-start justify-between">
<div className="flex items-center gap-5">
<div className="p-3 bg-primary/10 dark:bg-primary/20 rounded-xl">
{(() => {
const Icon = getNotebookIcon(currentNotebook.icon || 'folder')
return (
<Icon
className={cn("w-8 h-8", !currentNotebook.color && "text-primary dark:text-primary-foreground")}
style={currentNotebook.color ? { color: currentNotebook.color } : undefined}
/>
)
})()}
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
<span className="text-sm font-medium text-muted-foreground mt-2">({notes.length})</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
<LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => {
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
else params.delete('labels')
router.push(`/?${params.toString()}`)
}}
className="border-gray-200"
/>
</div>
</div>
</div>
) : (
<div
className={cn(
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
)}
>
{!isTabs && <div className="mb-1 h-5" />}
<div className="flex items-start justify-between">
<div className="flex items-center gap-5">
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
<FileText className="w-8 h-8 text-primary" />
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
<span className="text-sm font-medium text-muted-foreground mt-2">{notes.length} {notes.length === 1 ? (t('notes.note') || 'note') : (t('notes.notes') || 'notes')}</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
<LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => {
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
else params.delete('labels')
router.push(`/?${params.toString()}`)
}}
className="border-gray-200"
/>
{isInbox && !isLoading && notes.length >= 2 && (
<Button
onClick={() => setBatchOrganizationOpen(true)}
variant="outline"
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
title={t('batch.organizeWithAI')}
>
<Wand2 className="h-4 w-4 text-purple-600" />
<span className="hidden sm:inline">{t('batch.organize')}</span>
</Button>
)}
</div>
</div>
</div>
)}
{!isTabs && (
<div
className={cn(
'animate-in fade-in slide-in-from-top-4 duration-300',
isTabs ? 'mb-3 w-full shrink-0' : 'mb-8'
)}
>
<NoteInput
onNoteCreated={handleNoteCreatedWrapper}
fullWidth={isTabs}
/>
</div>
)}
{isLoading ? (
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
{editingNote ? (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={handleEditorClose}
fullPage
/>
) : (
<>
{!isTabs && (
<FavoritesSection
pinnedNotes={pinnedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
/>
)}
<div className={cn(
'px-12 pt-12 pb-8 flex flex-col gap-6',
isEditorialMode ? 'sticky top-0 bg-background/80 backdrop-blur-md z-30' : ''
)}>
<div className="flex justify-between items-start">
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight pr-12">
{currentNotebook ? currentNotebook.name : t('notes.title')}
</h1>
</div>
{(notes.filter((note) => !note.isPinned).length > 0 || isTabs) && (
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
<div className="flex items-center justify-between border-b border-foreground/5 pb-4">
<button
onClick={handleAddNote}
disabled={isCreating}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
>
<Plus size={16} />
<span>{t('notes.newNote') || 'Add Note'}</span>
</button>
{/* Sort order */}
<div className="relative">
<button
onClick={() => setShowSortMenu(s => !s)}
className="flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground font-medium transition-opacity"
title={t('sidebar.sortOrder') || 'Sort order'}
>
<ArrowUpDown size={14} />
<span className="hidden sm:inline text-[11px] uppercase tracking-wider font-bold">{sortLabels[sortOrder]}</span>
</button>
<AnimatePresence>
{showSortMenu && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -4 }}
className="absolute right-0 top-full mt-2 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
>
{(['newest', 'oldest', 'alpha'] as SortOrder[]).map(order => (
<button
key={order}
onClick={() => { setSortOrder(order); setShowSortMenu(false) }}
className={cn(
'w-full text-left px-4 py-2 text-[12px] transition-colors',
sortOrder === order
? 'font-bold text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
{sortLabels[order]}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
<div className="px-12 flex-1 pb-20">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
) : isTabs ? (
<NotesMainSection
viewMode={notesViewMode}
notes={isTabs ? notes : notes.filter((note) => !note.isPinned)}
notes={sortedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
currentNotebookId={searchParams.get('notebook')}
@@ -448,14 +433,54 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
onEnableHistory={handleEnableHistory}
onNoteCreated={handleNoteCreated}
/>
</div>
)}
) : (
<div className="max-w-3xl space-y-16">
{sortedPinnedNotes.length > 0 && (
<div className="mb-8">
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-6 px-2">
{t('notes.pinned')}
</h2>
<NotesEditorialView
notes={sortedPinnedNotes}
onOpen={(note: Note, readOnly?: boolean) => setEditingNote({ note, readOnly: readOnly ?? false })}
notebookName={currentNotebook?.name}
onOpenHistory={handleOpenHistory}
/>
</div>
)}
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && !isTabs && (
<div className="text-center py-8 text-gray-500">
{t('notes.emptyState')}
</div>
)}
{sortedNotes.filter((note) => !note.isPinned).length > 0 && (
<NotesEditorialView
notes={sortedNotes.filter((note) => !note.isPinned)}
onOpen={(note, readOnly) => setEditingNote({ note, readOnly })}
notebookName={currentNotebook?.name}
onOpenHistory={handleOpenHistory}
/>
)}
{notes.length === 0 && (
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
<p className="font-memento-serif text-xl italic text-muted-foreground">
{t('notes.emptyState') || 'This notebook is waiting for its first vision.'}
</p>
<button
onClick={handleAddNote}
disabled={isCreating}
className="px-6 py-2 border border-foreground text-[13px] uppercase tracking-[0.2em] hover:bg-foreground hover:text-background transition-all"
>
{t('notes.createFirst') || 'Begin Drawing'}
</button>
</div>
)}
</div>
)}
</div>
<footer className="px-12 py-6 border-t border-foreground/5 text-center mt-auto">
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-medium">
Memento &mdash; {new Date().getFullYear()}
</p>
</footer>
</>
)}
@@ -489,14 +514,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
/>
)}
{editingNote && (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
<NoteHistoryModal
open={historyOpen}
onOpenChange={setHistoryOpen}

View File

@@ -37,7 +37,7 @@ export function LabelBadge({
variant === 'filter' && 'cursor-pointer hover:opacity-80',
variant === 'clickable' && 'cursor-pointer',
isDisabled && 'opacity-50',
isSelected && 'ring-2 ring-blue-500'
isSelected && 'ring-2 ring-primary'
)}
onClick={onClick}
>

View File

@@ -0,0 +1,224 @@
'use client'
/**
* MarkdownSlashCommands
* Detects "/" typed in a textarea and shows a floating command palette.
* Supports keyboard navigation (↑↓ Enter Esc) and replaces the "/cmd" text
* with the appropriate markdown syntax.
*/
import { useEffect, useRef, useState, useCallback } from 'react'
import { createPortal } from 'react-dom'
import {
Heading1, Heading2, Heading3, List, ListOrdered,
CheckSquare, Quote, Code, Minus, Bold, Italic,
Pilcrow, Link as LinkIcon,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
type SlashCmd = {
title: string
desc: string
icon: typeof Heading1
keywords: string[]
insert: string
cursorOffset?: number // chars from end where cursor lands
}
const COMMANDS: SlashCmd[] = [
{ title: 'Texte', desc: 'Paragraphe normal', icon: Pilcrow, keywords: ['text', 'texte', 'p'], insert: '' },
{ title: 'Titre 1', desc: 'Grand titre de section', icon: Heading1, keywords: ['h1', '1', 'heading', 'titre'], insert: '# ' },
{ title: 'Titre 2', desc: 'Titre de niveau 2', icon: Heading2, keywords: ['h2', '2', 'titre'], insert: '## ' },
{ title: 'Titre 3', desc: 'Titre de niveau 3', icon: Heading3, keywords: ['h3', '3', 'titre'], insert: '### ' },
{ title: 'Liste à puces', desc: 'Liste non ordonnée', icon: List, keywords: ['list', 'liste', 'ul', 'bullet', '-'], insert: '- ' },
{ title: 'Liste numérotée', desc: 'Liste ordonnée', icon: ListOrdered, keywords: ['ol', 'numbered', 'numéro', '1.'], insert: '1. ' },
{ title: 'Tâche (to-do)', desc: 'Case à cocher', icon: CheckSquare, keywords: ['todo', 'tache', 'task', 'checkbox', '[]'], insert: '- [ ] ' },
{ title: 'Citation', desc: 'Bloc de citation', icon: Quote, keywords: ['quote', 'citation', 'blockquote', '>'], insert: '> ' },
{ title: 'Bloc de code', desc: 'Snippet de code', icon: Code, keywords: ['code', 'block', '```'], insert: '```\n', cursorOffset: 4 },
{ title: 'Séparateur', desc: 'Ligne horizontale', icon: Minus, keywords: ['hr', 'divider', 'separator', '---'], insert: '---\n' },
{ title: 'Gras', desc: '**texte en gras**', icon: Bold, keywords: ['bold', 'gras', '**'], insert: '****', cursorOffset: 2 },
{ title: 'Italique', desc: '*texte en italique*', icon: Italic, keywords: ['italic', 'italique', '*'], insert: '**', cursorOffset: 1 },
{ title: 'Lien', desc: '[texte](url)', icon: LinkIcon, keywords: ['link', 'lien', 'url'], insert: '[]()', cursorOffset: 3 },
]
interface MarkdownSlashCommandsProps {
textareaRef: React.RefObject<HTMLTextAreaElement>
value: string
onChange: (value: string) => void
}
export function MarkdownSlashCommands({ textareaRef, value, onChange }: MarkdownSlashCommandsProps) {
const { t } = useLanguage()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const [coords, setCoords] = useState({ top: 0, left: 0 })
const slashPosRef = useRef<number>(-1)
const menuRef = useRef<HTMLDivElement>(null)
const filtered = COMMANDS.filter(cmd => {
if (!query) return true
const q = query.toLowerCase()
return cmd.keywords.some(k => k.includes(q)) || cmd.title.toLowerCase().includes(q)
})
const close = useCallback(() => {
setOpen(false)
setQuery('')
setSelectedIndex(0)
slashPosRef.current = -1
}, [])
const getCaretCoords = useCallback(() => {
const el = textareaRef.current
if (!el) return { top: 0, left: 0 }
const rect = el.getBoundingClientRect()
// Approximate caret position using a mirror div
const mirror = document.createElement('div')
const style = window.getComputedStyle(el)
;['font', 'fontSize', 'fontFamily', 'fontWeight', 'lineHeight',
'paddingTop', 'paddingLeft', 'paddingRight', 'borderTop', 'borderLeft',
'whiteSpace', 'wordWrap', 'overflowWrap'].forEach(p => {
(mirror.style as any)[p] = (style as any)[p]
})
mirror.style.position = 'absolute'
mirror.style.visibility = 'hidden'
mirror.style.width = `${el.offsetWidth}px`
mirror.style.top = '0'
mirror.style.left = '0'
document.body.appendChild(mirror)
const textBefore = el.value.substring(0, el.selectionStart ?? 0)
mirror.textContent = textBefore
const span = document.createElement('span')
span.textContent = '|'
mirror.appendChild(span)
const spanRect = span.getBoundingClientRect()
document.body.removeChild(mirror)
return {
top: rect.top + el.scrollTop + spanRect.top - mirror.getBoundingClientRect().top + span.offsetHeight + 8,
left: Math.min(rect.left + spanRect.left - mirror.getBoundingClientRect().left, rect.right - 240),
}
}, [textareaRef])
const applyCommand = useCallback((cmd: SlashCmd) => {
const el = textareaRef.current
if (!el || slashPosRef.current < 0) { close(); return }
const cursorPos = el.selectionStart ?? 0
const before = value.substring(0, slashPosRef.current)
const after = value.substring(cursorPos)
if (cmd.insert === '') {
// "Text" — just remove the slash
const newVal = before + after
onChange(newVal)
setTimeout(() => {
el.focus()
const pos = slashPosRef.current
el.setSelectionRange(pos, pos)
}, 0)
} else {
const newVal = before + cmd.insert + after
onChange(newVal)
setTimeout(() => {
el.focus()
const offset = cmd.cursorOffset ?? 0
const pos = slashPosRef.current + cmd.insert.length - offset
el.setSelectionRange(pos, pos)
}, 0)
}
close()
}, [value, onChange, close, textareaRef])
// Monitor textarea input
useEffect(() => {
const el = textareaRef.current
if (!el) return
const handleInput = () => {
const pos = el.selectionStart ?? 0
const text = el.value
// Look for "/" at start of word
const lineStart = text.lastIndexOf('\n', pos - 1) + 1
const lineText = text.substring(lineStart, pos)
const slashMatch = lineText.match(/\/(\S*)$/)
if (slashMatch) {
slashPosRef.current = pos - slashMatch[0].length
setQuery(slashMatch[1])
setSelectedIndex(0)
setOpen(true)
const c = getCaretCoords()
setCoords(c)
} else {
if (open) close()
}
}
el.addEventListener('input', handleInput)
return () => el.removeEventListener('input', handleInput)
}, [textareaRef, open, close, getCaretCoords])
// Keyboard navigation
useEffect(() => {
if (!open) return
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => (i + 1) % filtered.length) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length) }
else if (e.key === 'Enter') { e.preventDefault(); if (filtered[selectedIndex]) applyCommand(filtered[selectedIndex]) }
else if (e.key === 'Escape') { e.preventDefault(); close() }
}
document.addEventListener('keydown', handleKey, true)
return () => document.removeEventListener('keydown', handleKey, true)
}, [open, filtered, selectedIndex, applyCommand, close])
// Close on outside click
useEffect(() => {
if (!open) return
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) close()
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open, close])
if (!open || filtered.length === 0) return null
if (typeof document === 'undefined') return null
return createPortal(
<div
ref={menuRef}
style={{ position: 'fixed', top: coords.top, left: coords.left, zIndex: 9999 }}
className="w-60 bg-card border border-border rounded-xl shadow-xl overflow-hidden"
>
<div className="px-3 py-2 border-b border-border/40">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">
{t('richTextEditor.slashHint') || 'Commandes Markdown'}
</p>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{filtered.map((cmd, idx) => (
<button
key={cmd.title}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors',
idx === selectedIndex ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
onMouseDown={e => { e.preventDefault(); applyCommand(cmd) }}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="w-7 h-7 rounded-lg border border-border bg-background flex items-center justify-center shrink-0">
<cmd.icon className="w-3.5 h-3.5" />
</div>
<div className="min-w-0">
<p className="text-[12px] font-medium leading-none truncate">{cmd.title}</p>
<p className="text-[10px] text-muted-foreground/70 mt-0.5 truncate">{cmd.desc}</p>
</div>
</button>
))}
</div>
</div>,
document.body
)
}

View File

@@ -150,7 +150,7 @@ function getAvatarColor(name: string): string {
'bg-amber-600',
'bg-pink-600',
'bg-teal-600',
'bg-blue-600',
'bg-stone-600',
'bg-indigo-600',
]
@@ -456,9 +456,9 @@ export const NoteCard = memo(function NoteCard({
}}
onDragEnd={() => onDragEnd?.()}
className={cn(
'note-card group relative rounded-lg overflow-hidden p-6 border-transparent shadow-sm',
'note-card group relative rounded-lg overflow-hidden p-6 border border-transparent shadow-[0_2px_4px_rgba(0,0,0,0.04),0_4px_12px_rgba(0,0,0,0.04)]',
'transition-all duration-200 ease-out',
'hover:shadow-md hover:border-border/50 hover:-translate-y-0.5',
'hover:shadow-[0_4px_8px_rgba(0,0,0,0.06),0_8px_24px_rgba(0,0,0,0.08)] hover:border-border/40 hover:-translate-y-0.5',
colorClasses.bg,
colorClasses.card,
colorClasses.hover,

View File

@@ -0,0 +1,243 @@
'use client'
import { useState, useMemo } from 'react'
import { Note } from '@/lib/types'
import { format, formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { LabelBadge } from './label-badge'
import { NoteHistoryModal } from './note-history-modal'
import { enableNoteHistory } from '@/app/actions/notes'
type Tab = 'info' | 'versions'
interface NoteDocumentInfoPanelProps {
note: Note
content: string
onClose: () => void
onNoteRestored?: (note: Note) => void
}
function getLocale(lang: string) {
return lang === 'fr' ? fr : enUS
}
function wordCount(text: string) {
return text.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).filter(Boolean).length
}
function charCount(text: string) {
return text.replace(/<[^>]+>/g, '').length
}
const noteTypeLabel: Record<string, string> = {
richtext: 'Rich Text',
markdown: 'Markdown',
text: 'Texte',
checklist: 'Liste de tâches',
}
export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }: NoteDocumentInfoPanelProps) {
const { t, language } = useLanguage()
const { notebooks } = useNotebooks()
const [activeTab, setActiveTab] = useState<Tab>('info')
const [showHistory, setShowHistory] = useState(false)
const [historyEnabled, setHistoryEnabled] = useState(note.historyEnabled ?? false)
const locale = getLocale(language)
const notebook = useMemo(
() => notebooks.find(nb => nb.id === note.notebookId),
[notebooks, note.notebookId]
)
const words = useMemo(() => wordCount(content), [content])
const chars = useMemo(() => charCount(content), [content])
const createdAt = note.createdAt ? new Date(note.createdAt as unknown as string) : null
const updatedAt = note.contentUpdatedAt ? new Date(note.contentUpdatedAt as unknown as string) : null
return (
<>
<div className="flex w-72 shrink-0 flex-col border-l border-border/60 bg-card overflow-hidden">
{/* Header tabs */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/60">
<div className="flex gap-1">
{(['info', 'versions'] as Tab[]).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
activeTab === tab
? 'bg-foreground text-background'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
{tab === 'info' && <Info className="h-3 w-3" />}
{tab === 'versions' && <Clock className="h-3 w-3" />}
{tab === 'info' ? 'Info' : 'Versions'}
</button>
))}
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto">
{/* ── INFO TAB ── */}
{activeTab === 'info' && (
<div className="space-y-0">
{/* Stats */}
<div className="grid grid-cols-2 border-b border-border/40">
<div className="flex flex-col items-center gap-0.5 py-4 border-r border-border/40">
<span className="text-2xl font-bold font-memento-serif tabular-nums">{words}</span>
<span className="text-[10px] uppercase tracking-widest text-muted-foreground">mots</span>
</div>
<div className="flex flex-col items-center gap-0.5 py-4">
<span className="text-2xl font-bold font-memento-serif tabular-nums">{chars}</span>
<span className="text-[10px] uppercase tracking-widest text-muted-foreground">caractères</span>
</div>
</div>
<div className="divide-y divide-border/30">
{notebook && (
<div className="flex items-start gap-3 px-4 py-3">
<Book className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">Carnet</p>
<p className="text-sm font-medium">{notebook.name}</p>
</div>
</div>
)}
<div className="flex items-start gap-3 px-4 py-3">
<FileText className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">Type</p>
<p className="text-sm font-medium">{noteTypeLabel[note.type] || note.type}</p>
</div>
</div>
{createdAt && (
<div className="flex items-start gap-3 px-4 py-3">
<Calendar className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">Créée le</p>
<p className="text-sm font-medium">{format(createdAt, 'd MMM yyyy', { locale })}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
{formatDistanceToNow(createdAt, { addSuffix: true, locale })}
</p>
</div>
</div>
)}
{updatedAt && (
<div className="flex items-start gap-3 px-4 py-3">
<Clock className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">Modifiée</p>
<p className="text-sm font-medium">{format(updatedAt, 'd MMM yyyy · HH:mm', { locale })}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
{formatDistanceToNow(updatedAt, { addSuffix: true, locale })}
</p>
</div>
</div>
)}
{(note.labels ?? []).length > 0 && (
<div className="flex items-start gap-3 px-4 py-3">
<Tag className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-1.5">Labels</p>
<div className="flex flex-wrap gap-1">
{(note.labels ?? []).map(label => (
<LabelBadge key={label} label={label} />
))}
</div>
</div>
</div>
)}
<div className="flex items-start gap-3 px-4 py-3">
<Hash className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">ID</p>
<p className="text-[11px] text-muted-foreground font-mono truncate">{note.id}</p>
</div>
</div>
</div>
</div>
)}
{/* ── VERSIONS TAB ── */}
{activeTab === 'versions' && (
<div className="p-4 space-y-4">
{!historyEnabled ? (
<div className="text-center py-6 space-y-3">
<Clock className="h-8 w-8 text-muted-foreground/30 mx-auto" />
<p className="text-sm text-muted-foreground">L'historique n'est pas activé pour cette note.</p>
<button
className="text-xs px-4 py-2 rounded-lg bg-foreground text-background font-medium hover:opacity-80 transition-opacity"
onClick={async () => {
await enableNoteHistory(note.id)
setHistoryEnabled(true)
setShowHistory(true)
}}
>
Activer l'historique
</button>
</div>
) : (
<div className="space-y-2">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-3">Versions sauvegardées</p>
<button
className="w-full flex items-center justify-between p-3 rounded-xl border border-border hover:bg-muted transition-colors text-left"
onClick={() => setShowHistory(true)}
>
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" />
<div>
<p className="text-sm font-medium">Voir l'historique</p>
<p className="text-[11px] text-muted-foreground">Comparer et restaurer des versions</p>
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</button>
</div>
)}
</div>
)}
</div>
</div>
{/* NoteHistoryModal with correct props */}
{showHistory && (
<NoteHistoryModal
open={showHistory}
onOpenChange={(v) => setShowHistory(v)}
note={note}
enabled={historyEnabled}
onEnableHistory={async () => {
await enableNoteHistory(note.id)
setHistoryEnabled(true)
}}
onRestored={(restored) => {
setShowHistory(false)
onNoteRestored?.(restored)
}}
/>
)}
</>
)
}

View File

@@ -25,8 +25,12 @@ import {
} from '@/components/ui/dropdown-menu'
import { NoteTypeSelector } from '@/components/note-type-selector'
import { RichTextEditor } from '@/components/rich-text-editor'
import { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut } from 'lucide-react'
import { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut, ArrowLeft, Info, Check, Loader2 } from 'lucide-react'
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote } from '@/app/actions/notes'
import { format } from 'date-fns'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
import { MarkdownSlashCommands } from './markdown-slash-commands'
import { NoteDocumentInfoPanel } from './note-document-info-panel'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { cn, extractImagesFromHTML } from '@/lib/utils'
import { toast } from 'sonner'
@@ -57,9 +61,10 @@ interface NoteEditorProps {
note: Note
readOnly?: boolean
onClose: () => void
fullPage?: boolean
}
export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) {
export function NoteEditor({ note, readOnly = false, onClose, fullPage = false }: NoteEditorProps) {
const { data: session } = useSession()
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
@@ -147,6 +152,17 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
// AI processing state for ActionBar
const [isProcessingAI, setIsProcessingAI] = useState(false)
const [aiOpen, setAiOpen] = useState(false)
const [infoOpen, setInfoOpen] = useState(false)
const [isDirty, setIsDirty] = useState(false)
// fullPage — auto title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const { suggestions: autoTitleSuggestions } = useTitleSuggestions({
content,
enabled: fullPage && !title && !dismissedTitleSuggestions,
})
// Track previous content for copilot action undo
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
@@ -642,6 +658,179 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
}
}
// ── fullPage mode: early return with editorial layout ──
if (fullPage) {
return (
<>
<div className="h-full flex flex-col overflow-hidden bg-background">
<div className="flex-1 flex overflow-hidden">
{/* main scrollable column */}
<div className="flex-1 flex flex-col overflow-y-auto">
{/* sticky toolbar */}
<div className="px-6 py-3 flex items-center justify-between sticky top-0 bg-background/90 backdrop-blur-sm z-40 border-b border-border gap-4">
<button onClick={onClose} className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity shrink-0">
<ArrowLeft size={16} />
<span className="text-sm font-medium hidden sm:inline">{t('notes.backToCollection') || 'Retour'}</span>
</button>
<div className="flex-1 flex justify-center">
<NoteTypeSelector
value={noteType}
onChange={(newType) => { setNoteType(newType); setShowMarkdownPreview(newType === 'markdown'); setIsDirty(true) }}
compact
/>
</div>
<div className="flex items-center gap-1 shrink-0">
<span className="hidden sm:flex items-center gap-1 text-[11px] text-muted-foreground/50 mr-2 select-none">
{isSaving
? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving') || 'Saving...'}</span></>
: isDirty
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>{t('notes.dirtyStatus') || 'Modifié'}</span></>
: <><Check className="h-3 w-3 text-emerald-500" /><span>{t('notes.savedStatus') || 'Enregistré'}</span></>}
</span>
<button
onClick={() => { setAiOpen(v => !v); setInfoOpen(false) }}
className={cn('flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all',
aiOpen ? 'bg-foreground text-background border-foreground' : 'border-border text-muted-foreground hover:text-foreground hover:bg-muted')}
>
<Sparkles size={13} /><span className="hidden sm:inline">IA</span>
</button>
<button
onClick={() => { setInfoOpen(v => !v); setAiOpen(false) }}
className={cn('flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all',
infoOpen ? 'bg-foreground text-background border-foreground' : 'border-border text-muted-foreground hover:text-foreground hover:bg-muted')}
>
<Info size={13} /><span className="hidden sm:inline">Info</span>
</button>
</div>
</div>
{/* body */}
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
{/* meta */}
<div className="text-[12px] text-muted-foreground uppercase tracking-[.25em] font-bold" suppressHydrationWarning>
{format(new Date(note.contentUpdatedAt), 'MMM d, yyyy')}
</div>
{/* title + AI */}
<div className="space-y-4">
<div className="group relative">
<input
dir="auto" type="text"
placeholder={t('notes.titlePlaceholder')}
value={title}
onChange={(e) => { setTitle(e.target.value); setIsDirty(true); setDismissedTitleSuggestions(true) }}
disabled={readOnly}
className="w-full text-5xl md:text-6xl font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground leading-tight placeholder:text-muted-foreground/30 pr-14"
/>
{!title && !readOnly && (
<button
type="button"
onClick={async () => {
const plain = content.replace(/<[^>]+>/g, ' ').trim()
if (plain.split(/\s+/).filter(Boolean).length < 3) return
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/title-suggestions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: plain }) })
if (res.ok) {
const data = await res.json()
const s = data.title || data.suggestedTitle || (data.suggestions?.[0]?.title ?? '')
if (s) { setTitle(s); setIsDirty(true) }
}
} catch {} finally { setIsProcessingAI(false) }
}}
disabled={isProcessingAI}
className="absolute right-0 top-1/2 -translate-y-1/2 opacity-30 hover:opacity-100 transition-opacity rounded-lg p-2 text-muted-foreground hover:bg-muted hover:text-primary"
title={t('ai.suggestTitle') || 'Générer un titre IA'}
>
{isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
</button>
)}
</div>
{!title && !dismissedTitleSuggestions && autoTitleSuggestions.length > 0 && (
<TitleSuggestions
suggestions={autoTitleSuggestions}
onSelect={(s) => { setTitle(s); setDismissedTitleSuggestions(true); setIsDirty(true) }}
onDismiss={() => setDismissedTitleSuggestions(true)}
/>
)}
</div>
{/* editor */}
<div className="max-w-2xl mx-auto pb-32">
{noteType === 'richtext' ? (
<RichTextEditor
content={content}
onChange={(v) => { setContent(v); setIsDirty(true) }}
className="min-h-[200px] text-lg font-light leading-relaxed"
onImageUpload={uploadImageFile}
/>
) : noteType === 'markdown' && showMarkdownPreview ? (
<div
className="min-h-[200px] cursor-text prose prose-sm dark:prose-invert max-w-none text-base leading-relaxed"
onClick={() => setShowMarkdownPreview(false)}
>
<MarkdownContent content={content} />
<p className="text-[11px] text-muted-foreground/40 mt-4 select-none">Cliquez pour éditer</p>
</div>
) : (
<div className="relative">
<textarea
ref={textareaRef}
dir="auto"
placeholder={t('notes.takeNote') || "Tapez '/' pour les commandes..."}
value={content}
onFocus={() => setShowMarkdownPreview(false)}
onChange={(e) => { setContent(e.target.value); setIsDirty(true) }}
disabled={readOnly}
className="w-full min-h-[400px] border-0 outline-none px-0 bg-transparent text-lg leading-relaxed font-light resize-none placeholder:text-muted-foreground/30"
/>
{noteType === 'markdown' && content && !readOnly && (
<button type="button" onClick={() => setShowMarkdownPreview(true)}
className="mt-2 text-[11px] text-muted-foreground/50 hover:text-foreground flex items-center gap-1 transition-colors">
<Eye className="h-3 w-3" /> Prévisualiser le Markdown
</button>
)}
{!readOnly && (
<MarkdownSlashCommands
textareaRef={textareaRef as React.RefObject<HTMLTextAreaElement>}
value={content}
onChange={(v) => { setContent(v); setIsDirty(true) }}
/>
)}
</div>
)}
</div>
</div>
</div>
{/* side panels */}
{aiOpen && (
<ContextualAIChat
onClose={() => setAiOpen(false)}
noteTitle={title} noteContent={content} noteImages={allImages} noteId={note.id}
onApplyToNote={(nc) => { setPreviousContentForCopilot(content); setContent(nc); setIsDirty(true); if (noteType === 'markdown') setShowMarkdownPreview(true) }}
onUndoLastAction={previousContentForCopilot !== null ? () => { setContent(previousContentForCopilot!); setPreviousContentForCopilot(null) } : undefined}
lastActionApplied={previousContentForCopilot !== null}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
diagramInsertFormat={noteType === 'richtext' ? 'html' : 'markdown'}
/>
)}
{infoOpen && (
<NoteDocumentInfoPanel
note={note} content={content}
onClose={() => setInfoOpen(false)}
onNoteRestored={(r) => { setContent(r.content || ''); setTitle(r.title || ''); setIsDirty(false) }}
/>
)}
</div>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
</div>
<ReminderDialog open={showReminderDialog} onOpenChange={setShowReminderDialog} currentReminder={currentReminder} onSave={handleReminderSave} onRemove={handleRemoveReminder} />
</>
)
}
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent

View File

@@ -932,6 +932,7 @@ export function NoteInlineEditor({
} : undefined}
lastActionApplied={previousContent !== null}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
diagramInsertFormat={noteType === 'richtext' ? 'html' : 'markdown'}
/>
)}
</div>

View File

@@ -733,7 +733,9 @@ export function NoteInput({
: 'max-w-2xl mx-auto'
return (
<div className={cn(
<div
id="memento-note-composer"
className={cn(
'mb-8 flex flex-row items-stretch transition-all duration-300',
(aiOpen || isExpandedFull) ? 'max-h-[calc(100vh-180px)]' : '',
widthClass
@@ -1079,6 +1081,7 @@ export function NoteInput({
}}
lastActionApplied={false}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
diagramInsertFormat={type === 'richtext' ? 'html' : 'markdown'}
className="border border-border border-l-0 rounded-r-xl overflow-hidden shadow-sm"
/>
)}

View File

@@ -169,7 +169,7 @@ export function NotebooksList() {
<LabelManagementDialog open={labelsDialogOpen} onOpenChange={setLabelsDialogOpen} />
<div className="flex flex-col pt-1">
{/* Header with Add Button */}
<div className="flex items-center justify-between px-6 py-2 mt-2 group cursor-pointer text-gray-500 hover:text-gray-800 dark:hover:text-gray-300">
<div className="group mt-1 flex cursor-pointer items-center justify-between px-3 py-2 text-muted-foreground hover:text-foreground">
<span className="text-xs font-semibold uppercase tracking-wider">{t('nav.notebooks') || 'NOTEBOOKS'}</span>
<button
onClick={() => setIsCreateDialogOpen(true)}
@@ -218,7 +218,7 @@ export function NotebooksList() {
style={notebook.color ? { backgroundColor: `${notebook.color}20` } : undefined}
>
{/* Header */}
<div className="pointer-events-auto flex items-center justify-between px-6 py-3">
<div className="pointer-events-auto flex items-center justify-between px-3 py-3">
<div className="flex items-center gap-4 min-w-0 flex-1">
<NotebookIcon
className={cn("w-5 h-5 flex-shrink-0 fill-current", !notebook.color && "text-primary dark:text-primary-foreground")}
@@ -304,7 +304,7 @@ export function NotebooksList() {
onDragLeave={handleDragLeave}
className={cn(
"flex items-center relative",
isDragOver && "ring-2 ring-blue-500 ring-dashed rounded-e-full me-2"
isDragOver && "ring-2 ring-primary ring-dashed rounded-e-full me-2"
)}
>
<TooltipProvider delayDuration={600}>

View File

@@ -0,0 +1,192 @@
'use client'
import { useState, useTransition } from 'react'
import type { Note } from '@/lib/types'
import { getNoteFeedImage, getNotePlainExcerpt, getNoteDisplayTitle } from '@/lib/note-preview'
import { useLanguage } from '@/lib/i18n'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { motion, AnimatePresence } from 'motion/react'
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { deleteNote, toggleArchive, togglePin } from '@/app/actions/notes'
import { toast } from 'sonner'
type NotesEditorialViewProps = {
notes: Note[]
onOpen: (note: Note, readOnly?: boolean) => void
notebookName?: string
onOpenHistory?: (note: Note) => void
}
function formatNoteDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).toUpperCase()
}
function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
note: Note
onOpen: (note: Note) => void
onOpenHistory?: (note: Note) => void
}) {
const { t } = useLanguage()
const { triggerRefresh } = useNoteRefresh()
const [, startTransition] = useTransition()
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation()
startTransition(async () => {
try {
await deleteNote(note.id)
triggerRefresh()
toast.success(t('notes.deleted') || 'Note supprimée')
} catch {
toast.error(t('general.error'))
}
})
}
const handleArchive = (e: React.MouseEvent) => {
e.stopPropagation()
startTransition(async () => {
try {
await toggleArchive(note.id, !note.isArchived)
triggerRefresh()
toast.success(note.isArchived ? (t('notes.unarchived') || 'Désarchivée') : (t('notes.archived') || 'Archivée'))
} catch {
toast.error(t('general.error'))
}
})
}
const handlePin = (e: React.MouseEvent) => {
e.stopPropagation()
startTransition(async () => {
try {
await togglePin(note.id, !note.isPinned)
triggerRefresh()
} catch {
toast.error(t('general.error'))
}
})
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={e => e.stopPropagation()}>
<button className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground">
<MoreHorizontal size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={e => { e.stopPropagation(); onOpen(note) }}>
<Pencil className="h-4 w-4 mr-2" />
{t('notes.open') || 'Ouvrir'}
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePin}>
<Pin className="h-4 w-4 mr-2" />
{note.isPinned ? (t('notes.unpin') || 'Désépingler') : (t('notes.pin') || 'Épingler')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleArchive}>
<Archive className="h-4 w-4 mr-2" />
{note.isArchived ? (t('notes.unarchive') || 'Désarchiver') : (t('notes.archive') || 'Archiver')}
</DropdownMenuItem>
{onOpenHistory && (
<DropdownMenuItem onClick={e => { e.stopPropagation(); onOpenHistory(note) }}>
<History className="h-4 w-4 mr-2" />
{t('notes.history') || 'Historique'}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDelete} className="text-red-600 dark:text-red-400 focus:text-red-600">
<Trash2 className="h-4 w-4 mr-2" />
{t('notes.delete') || 'Supprimer'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function NotesEditorialView({
notes,
onOpen,
notebookName,
onOpenHistory,
}: NotesEditorialViewProps) {
const { t } = useLanguage()
return (
<div className="mx-auto w-full max-w-3xl space-y-16">
<AnimatePresence>
{notes.map((note: Note, index: number) => {
const title = getNoteDisplayTitle(note, t('notes.untitled') || 'Untitled')
const img = getNoteFeedImage(note)
const excerpt = getNotePlainExcerpt(note)
const dateStr = formatNoteDate(note.createdAt)
return (
<motion.article
key={note.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 * index, duration: 0.6 }}
className="space-y-4 group cursor-pointer relative border-b border-border/20 pb-16"
onClick={() => onOpen(note)}
>
{/* Date / breadcrumb + actions menu */}
<div className="flex items-center justify-between">
<div className="note-date-badge">
{notebookName ? `${notebookName}${dateStr}` : dateStr}
</div>
<EditorialNoteMenu note={note} onOpen={onOpen} onOpenHistory={onOpenHistory} />
</div>
<h2 className="font-memento-serif text-2xl font-medium text-foreground flex items-center justify-between">
{title}
<span className="opacity-0 group-hover:opacity-30 transition-opacity shrink-0">
<ChevronRight size={20} />
</span>
</h2>
<div className="flex flex-col md:flex-row gap-8 items-start">
<div className="w-full md:w-56 aspect-[4/3] bg-white/50 border border-border overflow-hidden rounded shadow-sm flex-shrink-0">
{img ? (
<img
src={img}
alt=""
className="w-full h-full object-cover mix-blend-multiply opacity-80 grayscale contrast-125 hover:grayscale-0 hover:opacity-100 transition-all duration-500"
/>
) : (
<div className="h-full w-full bg-gradient-to-br from-muted/40 to-muted/10" aria-hidden />
)}
</div>
<div className="space-y-3 flex-1">
{excerpt ? (
<p className="text-[14px] leading-relaxed text-foreground/80 font-light max-w-lg line-clamp-4">
{excerpt}
</p>
) : (
<p className="text-sm italic text-muted-foreground/50">{t('notes.noContent')}</p>
)}
<span className="text-[11px] text-muted-foreground uppercase tracking-widest font-medium inline-flex items-center gap-1 group-hover:gap-2 transition-all">
{t('notes.readMore') || 'Read more'}
<ChevronRight size={10} />
</span>
</div>
</div>
</motion.article>
)
})}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,166 @@
'use client'
import { useMemo } from 'react'
import { Note } from '@/lib/types'
import { cn } from '@/lib/utils'
import { formatDistanceToNow, Locale } from 'date-fns'
import { enUS } from 'date-fns/locale/en-US'
import { fr } from 'date-fns/locale/fr'
import { useLanguage } from '@/lib/i18n'
import { Users, FileText, ImageIcon } from 'lucide-react'
import { useSession } from 'next-auth/react'
const localeMap: Record<string, Locale> = {
en: enUS,
fr,
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
}
function firstImageFromContent(html: string): string | null {
const m = html.match(/<img[^>]+src=["']([^"']+)["']/i)
return m ? m[1] : null
}
function previewText(note: Note): string {
if (note.type === 'richtext') {
return stripHtml(note.content || '').slice(0, 280)
}
if (note.type === 'markdown') {
return (note.content || '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/[*`_~]/g, '')
.replace(/\[(.*?)\]\([^)]*\)/g, '$1')
.slice(0, 280)
}
if (note.type === 'checklist') {
const items = (note.checkItems || []).map((i) => i.text).join(' · ')
return items.slice(0, 280)
}
return (note.content || '').slice(0, 280)
}
function thumbUrl(note: Note): string | null {
if (note.images?.length) return note.images[0]!
if (note.type === 'richtext' && note.content) {
return firstImageFromContent(note.content)
}
return null
}
interface NotesListViewProps {
notes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
}
export function NotesListView({
notes,
onEdit,
}: NotesListViewProps) {
const { t, language } = useLanguage()
const { data: session } = useSession()
const currentUserId = session?.user?.id
const locale = localeMap[language] || enUS
const sorted = useMemo(
() =>
[...notes].sort(
(a, b) =>
new Date(b.contentUpdatedAt || b.updatedAt).getTime() -
new Date(a.contentUpdatedAt || a.updatedAt).getTime()
),
[notes]
)
if (sorted.length === 0) {
return (
<p className="py-12 text-center text-sm text-muted-foreground" data-testid="notes-list-empty">
{t('notes.emptyState')}
</p>
)
}
return (
<div className="flex flex-col gap-3" data-testid="notes-list-view">
{sorted.map((note) => {
const thumb = thumbUrl(note)
const preview = previewText(note)
const title = note.title?.trim() || t('notes.untitled')
const edited = new Date(note.contentUpdatedAt || note.updatedAt)
const sharedCount = note.sharedWith?.length ?? 0
const isShared = sharedCount > 0 || (note as any)._isShared
const isSharedNote = !!(currentUserId && note.userId && currentUserId !== note.userId)
return (
<button
key={note.id}
type="button"
onClick={() => onEdit?.(note, !!isSharedNote)}
className={cn(
'group flex w-full gap-5 rounded-2xl border border-border/50 bg-card/90 p-4 text-start shadow-sm transition-all duration-200',
'hover:border-primary/25 hover:shadow-[0_8px_30px_-12px_color-mix(in_oklab, var(--foreground) 14%, transparent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30',
'md:gap-6 md:p-5'
)}
>
<div
className="relative size-[4.5rem] shrink-0 overflow-hidden rounded-xl border border-border/40 bg-muted/40 md:size-24"
aria-hidden
>
{thumb ? (
<img src={thumb} alt="" className="h-full w-full object-cover" loading="lazy" />
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground/50">
<FileText className="size-8 stroke-[1.25]" />
</div>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex flex-wrap items-baseline justify-between gap-2 gap-y-1">
<h3 className="font-memento-serif text-lg font-normal leading-snug tracking-tight text-foreground md:text-xl">
{title}
</h3>
<time
dateTime={edited.toISOString()}
className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-muted-foreground tabular-nums"
>
{formatDistanceToNow(edited, { addSuffix: true, locale })}
</time>
</div>
{preview ? (
<p className="line-clamp-2 text-sm leading-relaxed text-muted-foreground md:line-clamp-3">{preview}</p>
) : null}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
{(note.labels?.length ?? 0) > 0 && (
<div className="flex flex-wrap gap-1.5">
{note.labels!.slice(0, 6).map((label) => (
<span
key={label}
className="rounded-md border border-border/60 bg-muted/30 px-2 py-0.5 text-[11px] font-medium text-foreground/80"
>
{label}
</span>
))}
{note.labels!.length > 6 && (
<span className="text-[11px] text-muted-foreground">+{note.labels!.length - 6}</span>
)}
</div>
)}
{isShared && (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-primary">
<Users className="size-3.5 opacity-80" />
{sharedCount > 0 ? `${sharedCount}` : t('notes.sharedShort') || 'Partagé'}
</span>
)}
</div>
</div>
</button>
)
})}
</div>
)
}

View File

@@ -3,6 +3,7 @@
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { NotesTabsView } from '@/components/notes-tabs-view'
import { NotesListView } from '@/components/notes-list-view'
const MasonryGridLazy = dynamic(
() => import('@/components/masonry-grid').then((m) => m.MasonryGrid),
@@ -17,7 +18,7 @@ const MasonryGridLazy = dynamic(
}
)
export type NotesViewMode = 'masonry' | 'tabs'
export type NotesViewMode = 'masonry' | 'tabs' | 'list'
interface NotesMainSectionProps {
notes: Note[]
@@ -42,6 +43,14 @@ export function NotesMainSection({
onEnableHistory,
onNoteCreated,
}: NotesMainSectionProps) {
if (viewMode === 'list') {
return (
<div data-testid="notes-list">
<NotesListView notes={notes} onEdit={onEdit} />
</div>
)
}
if (viewMode === 'tabs') {
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">

View File

@@ -0,0 +1,69 @@
'use client'
import type { Note } from '@/lib/types'
import { getNoteFeedImage, getNotePlainExcerpt } from '@/lib/note-preview'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
type NotesNotebookFeedProps = {
notes: Note[]
onOpen: (note: Note, readOnly?: boolean) => void
}
export function NotesNotebookFeed({ notes, onOpen }: NotesNotebookFeedProps) {
const { t } = useLanguage()
return (
<div className="mx-auto w-full max-w-3xl">
{notes.map((note) => {
const title = note.title?.trim() || t('notes.untitled')
const img = getNoteFeedImage(note)
const excerpt = getNotePlainExcerpt(note)
return (
<article
key={note.id}
className="mb-16 border-b border-foreground/10 pb-16 last:mb-0 last:border-0 last:pb-0"
>
<h2 className="font-memento-serif text-2xl font-normal tracking-tight text-foreground md:text-[1.65rem]">
{title}
</h2>
<button
type="button"
className="mt-7 w-full text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm"
onClick={() => onOpen(note)}
>
<div className="flex flex-col gap-8 md:flex-row md:items-start">
<div
className={cn(
'relative w-full shrink-0 overflow-hidden rounded-lg bg-muted/40 md:w-[clamp(11rem,32%,15rem)]',
'aspect-[4/3]'
)}
>
{img ? (
// eslint-disable-next-line @next/next/no-img-element -- note content may be any uploaded or external URL
<img src={img} alt="" className="h-full w-full object-cover" />
) : (
<div className="h-full w-full bg-muted/30" aria-hidden />
)}
</div>
<div className="min-w-0 flex-1 space-y-4 pt-0 md:pt-1">
{excerpt ? (
<p className="text-sm leading-relaxed text-foreground/85 line-clamp-5">
{excerpt}
</p>
) : (
<p className="text-sm italic text-muted-foreground">{t('notes.noContent')}</p>
)}
<span className="inline-block text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
{t('notes.readMore')}
</span>
</div>
</div>
</button>
</article>
)
})}
</div>
)
}

View File

@@ -104,7 +104,7 @@ const COLOR_ACCENT: Record<NoteColor, string> = {
yellow: 'bg-amber-400',
green: 'bg-emerald-400',
teal: 'bg-teal-400',
blue: 'bg-sky-400',
blue: 'bg-slate-400',
purple: 'bg-violet-400',
pink: 'bg-fuchsia-400',
gray: 'bg-gray-400',
@@ -118,7 +118,7 @@ const COLOR_PANEL_BG: Record<NoteColor, string> = {
yellow: 'from-amber-50/60 dark:from-amber-950/20 to-background',
green: 'from-emerald-50/60 dark:from-emerald-950/20 to-background',
teal: 'from-teal-50/60 dark:from-teal-950/20 to-background',
blue: 'from-sky-50/60 dark:from-sky-950/20 to-background',
blue: 'from-slate-50/60 dark:from-slate-950/20 to-background',
purple: 'from-violet-50/60 dark:from-violet-950/20 to-background',
pink: 'from-fuchsia-50/60 dark:from-fuchsia-950/20 to-background',
gray: 'from-gray-50/60 dark:from-gray-900/20 to-background',
@@ -131,7 +131,7 @@ const COLOR_ICON: Record<NoteColor, string> = {
yellow: 'text-amber-500',
green: 'text-emerald-500',
teal: 'text-teal-500',
blue: 'text-sky-500',
blue: 'text-slate-600',
purple: 'text-violet-500',
pink: 'text-fuchsia-500',
gray: 'text-gray-500',
@@ -368,13 +368,13 @@ function SidebarActionBtn({
"group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium transition-all duration-150",
disabled
? "cursor-not-allowed text-muted-foreground/60 opacity-70"
: "text-foreground/70 hover:bg-sky-50 hover:text-sky-700"
: "text-foreground/70 hover:bg-stone-100/80 hover:text-stone-800 dark:hover:bg-white/5 dark:hover:text-stone-100"
)}
>
<span
className={cn(
"transition-colors",
disabled ? "text-muted-foreground/60" : "text-muted-foreground group-hover:text-sky-600"
disabled ? "text-muted-foreground/60" : "text-muted-foreground group-hover:text-stone-600 dark:group-hover:text-stone-300"
)}
>
{icon}
@@ -519,9 +519,9 @@ function NoteMetaSidebar({
<PopoverTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium text-foreground/70 hover:bg-sky-50 hover:text-sky-700 transition-all duration-150"
className="group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium text-foreground/70 hover:bg-stone-100/80 hover:text-stone-800 dark:hover:bg-white/5 dark:hover:text-stone-100 transition-all duration-150"
>
<span className="text-muted-foreground group-hover:text-sky-600 transition-colors">
<span className="text-muted-foreground group-hover:text-stone-600 dark:group-hover:text-stone-300 transition-colors">
{isMoving
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <FolderInput className="h-3.5 w-3.5" />}

View File

@@ -1,7 +1,7 @@
'use client'
import { useTransition } from 'react'
import { LayoutGrid, PanelsTopLeft } from 'lucide-react'
import { LayoutGrid, PanelsTopLeft, List } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
@@ -63,6 +63,26 @@ export function NotesViewToggle({ mode, onModeChange, className }: NotesViewTogg
</TooltipTrigger>
<TooltipContent side="bottom">{t('notes.viewCardsTooltip')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
disabled={pending}
className={cn(
'h-9 rounded-full px-3 gap-1.5',
mode === 'list' && 'bg-background shadow-sm text-foreground'
)}
onClick={() => setMode('list')}
aria-pressed={mode === 'list'}
>
<List className="h-4 w-4" aria-hidden />
<span className="hidden sm:inline text-xs font-medium">{t('notes.viewList')}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('notes.viewListTooltip')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@@ -389,25 +389,25 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
return (
<div className="flex items-center gap-0.5 px-1 py-0.5 relative">
{marks.map((m, i) => (
<button key={i} onClick={m.action} title={m.title} className={cn('notion-bubble-btn', m.active && 'notion-bubble-btn-active')}>
<button key={i} onClick={m.action} title={m.title} className={cn('notion-bubble-btn rounded-md', m.active && 'notion-bubble-btn-active')}>
<m.icon className="w-3.5 h-3.5" />
</button>
))}
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
<button onClick={openLinkEditor} className={cn('notion-bubble-btn', editor.isActive('link') && 'notion-bubble-btn-active')}><LinkIcon className="w-3.5 h-3.5" /></button>
<button onClick={() => setAiOpen(!aiOpen)} className={cn('notion-bubble-btn', aiLoading && 'animate-pulse')}><Sparkles className="w-3.5 h-3.5" /></button>
<button onClick={openLinkEditor} className={cn('notion-bubble-btn rounded-md', editor.isActive('link') && 'notion-bubble-btn-active')}><LinkIcon className="w-3.5 h-3.5" /></button>
<button onClick={() => setAiOpen(!aiOpen)} className={cn('notion-bubble-btn rounded-md', aiLoading && 'animate-pulse')}><Sparkles className="w-3.5 h-3.5" /></button>
{editor.isActive('image') && (
<>
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()} className="notion-bubble-btn text-xs font-medium px-1">25%</button>
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()} className="notion-bubble-btn text-xs font-medium px-1">50%</button>
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()} className="notion-bubble-btn text-xs font-medium px-1">100%</button>
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">25%</button>
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">50%</button>
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">100%</button>
</>
)}
{aiOpen && (
<div className="notion-ai-submenu">
<button className="notion-ai-subitem" onClick={() => handleAI('clarify')}><Lightbulb className="w-3.5 h-3.5 text-amber-500" /><span>{t('richTextEditor.slashClarify')}</span></button>
<button className="notion-ai-subitem" onClick={() => handleAI('shorten')}><Scissors className="w-3.5 h-3.5 text-blue-500" /><span>{t('richTextEditor.slashShorten')}</span></button>
<button className="notion-ai-subitem" onClick={() => handleAI('shorten')}><Scissors className="w-3.5 h-3.5 text-primary" /><span>{t('richTextEditor.slashShorten')}</span></button>
<button className="notion-ai-subitem" onClick={() => handleAI('improve')}><Wand2 className="w-3.5 h-3.5 text-purple-500" /><span>{t('richTextEditor.slashImprove')}</span></button>
<button className="notion-ai-subitem" onClick={() => handleAI('fix_grammar')}><SpellCheck className="w-3.5 h-3.5 text-green-500" /><span>{t('ai.action.fixGrammar')}</span></button>
<button className="notion-ai-subitem" onClick={() => setTranslateOpen(v => !v)}><Languages className="w-3.5 h-3.5 text-indigo-500" /><span>{t('ai.action.translate')}</span></button>

View File

@@ -4,208 +4,458 @@ import Link from 'next/link'
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
import { cn } from '@/lib/utils'
import {
FileText,
Bell,
Archive,
Trash2,
Settings,
Plus,
ChevronRight,
Lock,
BookOpen,
Bot,
Inbox,
FlaskConical,
ArrowUpDown,
Archive,
MessageSquare,
Sparkles,
X,
Tag,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useLanguage } from '@/lib/i18n'
import { NotebooksList } from './notebooks-list'
import { useHomeViewOptional } from '@/context/home-view-context'
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
import { useEffect, useState } from 'react'
import { getTrashCount } from '@/app/actions/notes'
import { getAllNotes } from '@/app/actions/notes'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useNotebooks } from '@/context/notebooks-context'
import { Notebook, Note } from '@/lib/types'
import { motion, AnimatePresence } from 'motion/react'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import { CreateNotebookDialog } from './create-notebook-dialog'
import { NotificationPanel } from './notification-panel'
const HIDDEN_ROUTES = ['/agents', '/chat', '/lab', '/admin']
type NavigationView = 'notebooks' | 'agents'
type SortOrder = 'newest' | 'oldest' | 'alpha'
export function Sidebar({ className, user }: { className?: string, user?: any }) {
function NoteLink({
title,
isActive,
onClick,
}: {
title: string
isActive: boolean
onClick: () => void
}) {
return (
<motion.button
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
onClick={onClick}
className={cn(
'w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg',
isActive ? 'bg-white/50 text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-white/30'
)}
>
<div className={cn(
'w-1.5 h-1.5 rounded-full shrink-0',
isActive ? 'bg-foreground' : 'bg-transparent border border-muted-foreground/30'
)} />
<span className="truncate">{title}</span>
</motion.button>
)
}
function SidebarCarnetItem({
carnet,
isActive,
notes,
activeNoteId,
onCarnetClick,
onNoteClick,
}: {
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
isActive: boolean
notes: { id: string; title: string }[]
activeNoteId: string | null
onCarnetClick: () => void
onNoteClick: (noteId: string) => void
}) {
return (
<div className="space-y-1">
<motion.button
whileHover={{ x: 4 }}
onClick={onCarnetClick}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
isActive ? 'memento-active-nav' : 'hover:bg-white/40'
)}
>
<motion.div
animate={{ rotate: isActive ? 90 : 0 }}
className="text-muted-foreground"
>
<ChevronRight size={14} />
</motion.div>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
isActive
? 'bg-foreground text-background border-foreground'
: 'bg-white/60 text-foreground border-border'
)}>
{carnet.initial}
</div>
<div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
'text-[13px] font-medium transition-colors truncate',
isActive ? 'text-foreground' : 'text-muted-foreground'
)}>
{carnet.name}
</span>
{carnet.isPrivate && <Lock size={10} className="text-muted-foreground shrink-0" />}
</div>
</div>
</motion.button>
<AnimatePresence>
{isActive && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden space-y-0.5"
>
{notes.map(note => (
<NoteLink
key={note.id}
title={note.title}
isActive={activeNoteId === note.id}
onClick={() => onNoteClick(note.id)}
/>
))}
{notes.length === 0 && (
<p className="pl-12 text-[11px] text-muted-foreground/50 py-2 italic font-light">No notes yet</p>
)}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export function Sidebar({ className, user }: { className?: string; user?: any }) {
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
const homeBridge = useHomeViewOptional()
const { refreshKey } = useNoteRefreshOptional()
const [trashCount, setTrashCount] = useState(0)
const { notebooks } = useNotebooks()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string }[]>>({})
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
const [showSortMenu, setShowSortMenu] = useState(false)
const searchKey = searchParams.toString()
const currentNotebookId = searchParams.get('notebook')
const currentNoteId = searchParams.get('openNote')
// Fetch trash count — skip for hidden/admin routes to avoid dispatching a
// Server Action during an ongoing App Router navigation transition, which
// would increment React's nested-update counter and trigger Error #310.
// Determine if inbox is active (no notebook filter, on home page)
const isInboxActive =
pathname === '/' &&
!searchParams.get('notebook') &&
!searchParams.get('label') &&
!searchParams.get('archived') &&
!searchParams.get('trashed')
// Sync activeView with current route
useEffect(() => {
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return
getTrashCount().then(setTrashCount)
}, [pathname, refreshKey])
if (pathname.startsWith('/agents') || pathname.startsWith('/lab')) {
setActiveView('agents')
}
}, [pathname])
// Hide sidebar on Agents, Chat IA and Lab routes
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return null
const displayName = user?.name || user?.email || ''
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
// Active label filter
const activeLabel = searchParams.get('label')
const activeLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
useEffect(() => {
if (!currentNotebookId) return
if (notebookNotes[currentNotebookId]) return
const clearLabelFilter = () => {
const params = new URLSearchParams(searchParams)
params.delete('label')
getAllNotes(false, currentNotebookId).then(notes => {
const mapped = notes.map((n: Note) => ({
id: n.id,
title: getNoteDisplayTitle(n, t('notes.untitled') || 'Untitled'),
}))
setNotebookNotes(prev => ({ ...prev, [currentNotebookId!]: mapped }))
})
}, [currentNotebookId, refreshKey])
// BUG FIX: clicking a carnet always forces list (editorial) view
const handleCarnetClick = (notebookId: string) => {
const params = new URLSearchParams()
params.set('notebook', notebookId)
// forceList resets to editorial view in home-client
params.set('forceList', '1')
router.push(`/?${params.toString()}`)
}
const clearLabelsFilter = (labelToRemove?: string) => {
const params = new URLSearchParams(searchParams)
if (labelToRemove) {
const remaining = activeLabels.filter(l => l !== labelToRemove)
if (remaining.length > 0) {
params.set('labels', remaining.join(','))
} else {
params.delete('labels')
}
} else {
params.delete('labels')
}
const handleInboxClick = () => {
router.push('/?forceList=1')
}
const handleNoteClick = (noteId: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set('openNote', noteId)
params.delete('forceList')
router.push(`/?${params.toString()}`)
}
// Helper to determine if a link is active
const isActive = (href: string, exact = false) => {
if (href === '/') {
// Home is active only if no special filters are applied
return pathname === '/' &&
!searchParams.get('label') &&
!searchParams.get('archived') &&
!searchParams.get('trashed') &&
!searchParams.get('notebook')
}
// Sort notebooks
const sortedNotebooks = [...notebooks].sort((a: Notebook, b: Notebook) => {
if (sortOrder === 'alpha') return a.name.localeCompare(b.name)
if (sortOrder === 'newest') return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
if (sortOrder === 'oldest') return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
return 0
})
// For labels
if (href.startsWith('/?label=')) {
const labelParam = searchParams.get('label')
// Extract label from href
const labelFromHref = href.split('=')[1]
return labelParam === labelFromHref
}
// For other routes
return pathname === href
const sortLabels: Record<SortOrder, string> = {
newest: t('sidebar.sortNewest') || 'Newest first',
oldest: t('sidebar.sortOldest') || 'Oldest first',
alpha: t('sidebar.sortAlpha') || 'A → Z',
}
const NavItem = ({ href, icon: Icon, label, active, badge }: any) => (
<Link
href={href}
className={cn(
"flex items-center gap-4 px-6 py-3 rounded-e-full me-2 transition-colors",
"text-[15px] font-medium tracking-wide",
active
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
: "text-muted-foreground hover:bg-muted/50 dark:hover:bg-muted/30"
)}
>
<Icon className={cn("w-5 h-5", active ? "fill-current" : "")} />
<span className="truncate">{label}</span>
{badge > 0 && (
<span className={cn(
"ms-auto text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[20px] text-center",
active
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
)}>
{badge}
</span>
)}
</Link>
)
return (
<aside className={cn(
"w-[280px] flex-none flex-col bg-white dark:bg-[#1e2128] overflow-y-auto hidden md:flex py-2",
className
)}>
{/* Main Navigation */}
<div className="flex flex-col gap-1 px-3">
<NavItem
href="/"
icon={FileText}
label={t('sidebar.notes') || 'Notes'}
active={isActive('/')}
/>
</div>
{/* Notebooks Section */}
<div className="flex flex-col mt-2">
<NotebooksList />
</div>
{/* Active Label Filter Chips */}
{pathname === '/' && (activeLabel || activeLabels.length > 0) && (
<div className="px-4 pt-2 flex flex-col gap-1">
{activeLabel && (
<div className="flex items-center gap-2 ps-2 pe-1 py-1.5 rounded-e-full me-2 bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground">
<Tag className="w-3.5 h-3.5 shrink-0" />
<span className="text-xs font-medium truncate flex-1">{activeLabel}</span>
<button
type="button"
onClick={clearLabelFilter}
className="shrink-0 p-0.5 rounded-full hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors"
title={t('sidebar.clearFilter') || 'Remove filter'}
>
<X className="w-3 h-3" />
</button>
<>
<aside
className={cn(
'hidden h-full min-h-0 w-80 shrink-0 flex-col md:flex',
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-border/30 dark:bg-sidebar/90',
className
)}
>
{/* ── Top: Avatar + View Toggle ── */}
<div className="p-6 flex items-center justify-between mb-4">
{/* Avatar → profile */}
<Link href="/settings/profile" className="shrink-0">
<div className="w-10 h-10 rounded-full bg-slate-200 border border-border flex items-center justify-center text-foreground font-memento-serif text-lg shadow-sm hover:ring-2 hover:ring-primary/30 transition-all">
{user?.image ? (
<Avatar className="size-10 ring-1 ring-border/60">
<AvatarImage src={user.image} alt="" />
<AvatarFallback className="bg-primary/10 text-sm font-semibold text-primary">{initial}</AvatarFallback>
</Avatar>
) : (
<span>{initial}</span>
)}
</div>
)}
{activeLabels.map((label) => (
<div
key={label}
className="flex items-center gap-2 ps-2 pe-1 py-1.5 rounded-e-full me-2 bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground"
</Link>
{/* Notebooks / Agents toggle */}
<div className="sidebar-view-toggle">
<button
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
className={cn('sidebar-view-toggle-btn', activeView === 'notebooks' && 'active')}
title={t('nav.notebooks') || 'Notebooks'}
>
<Tag className="w-3.5 h-3.5 shrink-0" />
<span className="text-xs font-medium truncate flex-1">{label}</span>
<button
type="button"
onClick={() => clearLabelsFilter(label)}
className="shrink-0 p-0.5 rounded-full hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors"
title={t('sidebar.clearFilter') || 'Remove filter'}
>
<X className="w-3 h-3" />
</button>
</div>
))}
<BookOpen size={14} />
</button>
<button
onClick={() => { setActiveView('agents'); router.push('/agents') }}
className={cn('sidebar-view-toggle-btn', activeView === 'agents' && 'active')}
title={t('nav.agents') || 'Agents'}
>
<Bot size={14} />
</button>
</div>
</div>
)}
{/* Archive & Trash */}
<div className="flex flex-col mt-auto pb-4 border-t border-transparent">
<NavItem
href="/reminders"
icon={Bell}
label={t('sidebar.reminders') || 'Rappels'}
active={isActive('/reminders')}
/>
<NavItem
href="/archive"
icon={Archive}
label={t('sidebar.archive') || 'Archives'}
active={pathname === '/archive'}
/>
<NavItem
href="/trash"
icon={Trash2}
label={t('sidebar.trash') || 'Corbeille'}
active={pathname === '/trash'}
badge={trashCount}
/>
</div>
{/* ── Scrollable content ── */}
<div className="flex-1 overflow-y-auto space-y-6 -mx-2 px-2 custom-scrollbar pb-4">
</aside>
<AnimatePresence mode="wait">
{activeView === 'notebooks' ? (
<motion.div
key="notebooks"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{/* Section header with sort button */}
<div className="flex items-center justify-between px-4 mb-3">
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase">
{t('nav.notebooks') || 'Notebooks'}
</p>
<div className="relative">
<button
onClick={() => setShowSortMenu(s => !s)}
className="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
title={t('sidebar.sortOrder') || 'Sort order'}
>
<ArrowUpDown size={12} />
</button>
<AnimatePresence>
{showSortMenu && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -4 }}
className="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
>
{(['newest', 'oldest', 'alpha'] as SortOrder[]).map(order => (
<button
key={order}
onClick={() => { setSortOrder(order); setShowSortMenu(false) }}
className={cn(
'w-full text-left px-4 py-2 text-[12px] transition-colors',
sortOrder === order
? 'font-bold text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
{sortLabels[order]}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Inbox — Notes without notebook */}
<button
onClick={handleInboxClick}
className={cn('sidebar-inbox-item', isInboxActive && 'active')}
>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
isInboxActive
? 'bg-foreground text-background border-foreground'
: 'bg-white/60 text-foreground border-border'
)}>
<Inbox size={14} />
</div>
<span className={cn(
'text-[13px] font-medium truncate',
isInboxActive ? 'text-foreground' : 'text-muted-foreground'
)}>
{t('sidebar.inbox') || 'Inbox'}
</span>
</button>
{/* Divider */}
<div className="mx-4 my-3 h-px bg-border/40" />
{/* Notebooks list */}
<div className="space-y-1">
{sortedNotebooks.map((notebook: Notebook) => {
const isActive = currentNotebookId === notebook.id
const notes = notebookNotes[notebook.id] || []
return (
<SidebarCarnetItem
key={notebook.id}
carnet={{
id: notebook.id,
name: notebook.name,
initial: notebook.name.charAt(0).toUpperCase(),
}}
isActive={isActive}
notes={isActive ? notes : []}
activeNoteId={currentNoteId}
onCarnetClick={() => handleCarnetClick(notebook.id)}
onNoteClick={handleNoteClick}
/>
)
})}
<button
onClick={() => setIsCreateDialogOpen(true)}
className="w-full mt-4 flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/40"
>
<Plus size={16} />
<span>{t('notebooks.create') || 'New Carnet'}</span>
</button>
</div>
</motion.div>
) : (
<motion.div
key="agents"
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.2 }}
>
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-4">
{t('agents.intelligenceOS') || 'Intelligence OS'}
</p>
<div className="space-y-1">
{[
{ id: 'agents', href: '/agents', label: t('agents.myAgents') || 'Mes Agents', icon: Bot },
{ id: 'lab', href: '/lab', label: t('nav.lab') || 'Le Lab AI', icon: FlaskConical },
{ id: 'chat', href: '/chat', label: t('nav.chat') || 'Conversations', icon: MessageSquare },
].map(item => {
const isActive = pathname.startsWith(item.href)
return (
<Link
key={item.id}
href={item.href}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
isActive ? 'memento-active-nav' : 'text-muted-foreground hover:bg-white/40 hover:text-foreground'
)}
>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0',
isActive
? 'bg-foreground text-background border-foreground'
: 'bg-white/60 border-border group-hover:border-foreground/20'
)}>
<item.icon size={16} />
</div>
<span className="text-[13px] font-medium">{item.label}</span>
</Link>
)
})}
{/* General Chat button (opens floating panel) */}
<button
onClick={() => window.dispatchEvent(new Event('toggle-ai-chat'))}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group text-muted-foreground hover:bg-white/40 hover:text-foreground"
>
<div className="w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0 bg-white/60 border-border group-hover:border-foreground/20">
<Sparkles size={16} />
</div>
<span className="text-[13px] font-medium">{t('ai.openAssistant') || 'Assistant IA'}</span>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* ── Footer ── */}
<div className="pt-4 p-5 border-t border-border space-y-1">
{/* Notification bell */}
<div className="flex items-center gap-3 px-4 py-2">
<NotificationPanel />
<span className="text-[13px] text-muted-foreground font-medium">{t('notification.notifications') || 'Notifications'}</span>
</div>
<Link
href="/archive"
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
>
<Archive size={16} />
<span>{t('sidebar.archive') || 'Archive'}</span>
</Link>
<Link
href="/settings"
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
>
<Settings size={16} />
<span>{t('nav.settings') || 'Settings'}</span>
</Link>
</div>
</aside>
<CreateNotebookDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
/>
</>
)
}

View File

@@ -1,97 +1,61 @@
'use client'
import { useEffect } from 'react'
import { applyDocumentTheme, normalizeThemeId } from '@/lib/apply-document-theme'
interface ThemeInitializerProps {
theme?: string
fontSize?: string
fontFamily?: string
theme?: string
fontSize?: string
fontFamily?: string
}
export function ThemeInitializer({ theme, fontSize, fontFamily }: ThemeInitializerProps) {
useEffect(() => {
useEffect(() => {
const applyFontSize = (s?: string) => {
const size = s || 'medium'
// Helper to apply theme
const applyTheme = (t?: string) => {
const fontSizeMap: Record<string, string> = {
small: '14px',
medium: '16px',
large: '18px',
'extra-large': '20px',
}
if (!t) return
const fontSizeFactorMap: Record<string, number> = {
small: 0.95,
medium: 1.0,
large: 1.1,
'extra-large': 1.25,
}
const root = document.documentElement
const root = document.documentElement
root.style.setProperty('--user-font-size', fontSizeMap[size] || '16px')
root.style.setProperty('--user-font-size-factor', (fontSizeFactorMap[size] || 1).toString())
}
// Reset
root.removeAttribute('data-theme')
root.classList.remove('dark')
const localTheme = localStorage.getItem('theme-preference')
const effectiveTheme = localTheme || theme || 'light'
if (t === 'auto') {
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (systemDark) root.classList.add('dark')
} else if (t === 'dark') {
root.classList.add('dark')
} else if (t === 'light') {
// Default, nothing needed usually if light is default, but ensuring no 'dark' class
} else {
// Named theme
root.setAttribute('data-theme', t)
// Check if theme implies dark mode (e.g. midnight)
if (['midnight'].includes(t)) {
root.classList.add('dark')
}
}
}
applyDocumentTheme(effectiveTheme)
// Helper to apply font size
const applyFontSize = (s?: string) => {
const size = s || 'medium'
if (!localTheme && theme) {
localStorage.setItem('theme-preference', normalizeThemeId(theme))
}
const fontSizeMap: Record<string, string> = {
'small': '14px',
'medium': '16px',
'large': '18px',
'extra-large': '20px'
}
applyFontSize(fontSize)
const fontSizeFactorMap: Record<string, number> = {
'small': 0.95,
'medium': 1.0,
'large': 1.1,
'extra-large': 1.25
}
const localFontFamily = localStorage.getItem('font-family')
const effectiveFontFamily = localFontFamily || fontFamily || 'inter'
const root = document.documentElement
if (effectiveFontFamily === 'system') {
root.classList.add('font-system')
} else {
root.classList.remove('font-system')
}
if (!localFontFamily && fontFamily) {
localStorage.setItem('font-family', fontFamily)
}
}, [theme, fontSize, fontFamily])
const root = document.documentElement
root.style.setProperty('--user-font-size', fontSizeMap[size] || '16px')
root.style.setProperty('--user-font-size-factor', (fontSizeFactorMap[size] || 1).toString())
}
// CRITICAL: Use localStorage as the source of truth (it's always fresh)
// Server prop may be stale due to caching.
const localTheme = localStorage.getItem('theme-preference')
const effectiveTheme = localTheme || theme
applyTheme(effectiveTheme)
// Only sync to localStorage if it was empty (first visit after login)
// NEVER overwrite with server value if localStorage already has a value
if (!localTheme && theme) {
localStorage.setItem('theme-preference', theme)
}
applyFontSize(fontSize)
// Apply font family
const localFontFamily = localStorage.getItem('font-family')
const effectiveFontFamily = localFontFamily || fontFamily || 'inter'
const root = document.documentElement
if (effectiveFontFamily === 'system') {
root.classList.add('font-system')
} else {
root.classList.remove('font-system')
}
if (!localFontFamily && fontFamily) {
localStorage.setItem('font-family', fontFamily)
}
}, [theme, fontSize, fontFamily])
return null
return null
}

View File

@@ -15,16 +15,16 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
if (suggestions.length === 0) return null
return (
<div className="mt-2 p-3 bg-sky-50 dark:bg-sky-950/50 border border-sky-200 dark:border-sky-800 rounded-lg animate-in fade-in slide-in-from-top-2 duration-300">
<div className="mt-2 p-3 bg-stone-100/90 dark:bg-stone-900/50 border border-stone-200 dark:border-stone-700 rounded-lg animate-in fade-in slide-in-from-top-2 duration-300">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm font-medium text-sky-900 dark:text-sky-100">
<div className="flex items-center gap-2 text-sm font-medium text-stone-800 dark:text-stone-100">
<Sparkles className="w-4 h-4" />
<span>{t('titleSuggestions.title')}</span>
</div>
<button
type="button"
onClick={onDismiss}
className="text-sky-500 hover:text-sky-900 dark:text-sky-400 dark:hover:text-sky-200 transition-colors"
className="text-stone-500 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200 transition-colors"
>
<X className="w-4 h-4" />
</button>
@@ -38,19 +38,19 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
onClick={() => onSelect(suggestion.title)}
className={cn(
"w-full text-left px-3 py-2 rounded-md transition-all",
"hover:bg-sky-100 dark:hover:bg-sky-900/50",
"text-sm text-sky-900 dark:text-sky-100",
"border border-transparent hover:border-sky-300 dark:hover:border-sky-700"
"hover:bg-stone-200/80 dark:hover:bg-stone-800/50",
"text-sm text-stone-800 dark:text-stone-100",
"border border-transparent hover:border-stone-300 dark:hover:border-stone-600"
)}
>
<div className="flex items-start justify-between gap-2">
<span className="font-medium">{suggestion.title}</span>
<span className="text-xs text-sky-500 dark:text-sky-400 whitespace-nowrap">
<span className="text-xs text-stone-500 dark:text-stone-400 whitespace-nowrap">
{suggestion.confidence}%
</span>
</div>
{suggestion.reasoning && (
<p className="text-xs text-sky-600 dark:text-sky-300 mt-1">
<p className="text-xs text-stone-600 dark:text-stone-300 mt-1">
{suggestion.reasoning}
</p>
)}

View File

@@ -5,13 +5,13 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-normal transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-normal transition-all duration-200 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-primary text-primary-foreground shadow-[0_1px_2px_rgba(0,0,0,0.06),0_2px_4px_rgba(0,0,0,0.04)] hover:bg-primary/90 hover:shadow-[0_2px_8px_rgba(0,0,0,0.08),0_4px_8px_rgba(0,0,0,0.06)]",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
"bg-destructive text-white shadow-[0_1px_2px_rgba(0,0,0,0.06)] hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-lg border border-border/40 py-6 shadow-[0_1px_2px_rgba(0,0,0,0.06),0_2px_4px_rgba(0,0,0,0.04)]",
className
)}
{...props}

View File

@@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/50 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/50 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow,border-color] duration-200 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-primary/40 focus-visible:ring-primary/20 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}

View File

@@ -5,24 +5,34 @@ import { Toaster as SonnerToaster, toast as sonnerToast } from 'sonner'
// Re-export toast functions from Sonner
export const toast = sonnerToast
// Toaster component with custom styles
// Toaster component — styled for Memento paper/ink design system
export function Toaster() {
return (
<SonnerToaster
position="bottom-right"
expand={false}
richColors
richColors={false}
closeButton
duration={3000}
className="toaster"
duration={3500}
className="toaster font-sans"
toastOptions={{
classNames: {
toast: 'toast pointer-events-auto',
description: 'toast-description',
actionButton: 'toast-action-button',
closeButton: 'toast-close-button',
toast: [
'toast pointer-events-auto',
'!bg-[#1C1C1C] !text-[#F2F0E9] !border !border-white/10',
'!rounded-xl !shadow-xl !shadow-black/30',
'!text-[13px] !font-medium !py-3 !px-4',
].join(' '),
description: '!text-[#F2F0E9]/70 !text-[12px]',
actionButton: '!bg-[#F2F0E9] !text-[#1C1C1C] !text-[11px] !font-bold !rounded-lg !px-3 !py-1',
closeButton: '!bg-white/10 !text-[#F2F0E9]/70 !border-white/10 hover:!bg-white/20',
success: '!border-l-4 !border-l-emerald-400/70',
error: '!border-l-4 !border-l-red-400/70',
warning: '!border-l-4 !border-l-amber-400/70',
info: '!border-l-4 !border-l-sky-400/70',
},
}}
/>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -0,0 +1,83 @@
/**
* Valeurs persistées pour User.theme / localStorage `theme-preference`.
* Une seule source de vérité pour le DOM : évite les écarts entre header, settings et hydratation.
*/
export const THEME_IDS = [
'light',
'dark',
'auto',
'sepia',
'midnight',
'rose',
'green',
'lavender',
'sand',
'ocean',
'sunset',
'blue',
] as const
export type ThemeId = (typeof THEME_IDS)[number]
const NAMED_PALETTE_IDS: ThemeId[] = [
'sepia',
'midnight',
'rose',
'green',
'lavender',
'sand',
'ocean',
'sunset',
'blue',
]
export function isThemeId(value: string | null | undefined): value is ThemeId {
return value !== undefined && value !== null && (THEME_IDS as readonly string[]).includes(value)
}
/** Corrige les anciennes valeurs (ex. formulaire « slate ») vers un thème supporté. */
export function normalizeThemeId(raw: string | null | undefined): ThemeId {
if (!raw) return 'light'
if (raw === 'slate') return 'light'
if (isThemeId(raw)) return raw
return 'light'
}
/**
* Applique le thème sur `<html>` : `class="dark"` et/ou `data-theme`.
* - `light` → papier par défaut (`:root`), pas de `data-theme`
* - `dark` → sombre global (`.dark`)
* - `auto` → `.dark` si prefers-color-scheme: dark
* - palettes nommées → `data-theme="<id>"` ; `midnight` force aussi `.dark` (variante sombre du thème)
*/
export function applyDocumentTheme(theme: string): void {
if (typeof document === 'undefined') return
const t = normalizeThemeId(theme)
const root = document.documentElement
root.classList.remove('dark')
root.removeAttribute('data-theme')
if (t === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
root.classList.add('dark')
}
return
}
if (t === 'dark') {
root.classList.add('dark')
return
}
if (t === 'light') {
return
}
if ((NAMED_PALETTE_IDS as readonly string[]).includes(t)) {
root.setAttribute('data-theme', t)
if (t === 'midnight') {
root.classList.add('dark')
}
}
}

View File

@@ -0,0 +1,59 @@
'use client'
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
import type { BinaryFiles } from '@excalidraw/excalidraw/types'
/**
* Parses canvas JSON from DB — either a bare elements array or { elements, files }.
* Returns null for PPTX placeholders or invalid JSON.
*/
export function parseExcalidrawSceneFromCanvasData(
dataStr: string
): { elements: readonly ExcalidrawElement[]; files: BinaryFiles | null } | null {
if (!dataStr?.trim()) return null
try {
const parsed = JSON.parse(dataStr)
if (parsed && parsed.type === 'pptx') return null
if (Array.isArray(parsed)) {
return { elements: parsed as ExcalidrawElement[], files: null }
}
if (parsed?.elements && Array.isArray(parsed.elements)) {
const files =
parsed.files && typeof parsed.files === 'object' ? (parsed.files as BinaryFiles) : null
return { elements: parsed.elements as readonly ExcalidrawElement[], files }
}
} catch {
/* ignore */
}
return null
}
/**
* Renders Excali scene to PNG in the browser (uses Excalidraw export helpers).
*/
export async function exportExcalidrawSceneToPngBlob(dataStr: string): Promise<Blob | null> {
const scene = parseExcalidrawSceneFromCanvasData(dataStr)
if (!scene || scene.elements.length === 0) return null
const { exportToBlob, getNonDeletedElements, MIME_TYPES } = await import('@excalidraw/excalidraw')
const elements = getNonDeletedElements(scene.elements as ExcalidrawElement[])
if (!elements.length) return null
try {
return await exportToBlob({
elements,
files: scene.files,
mimeType: MIME_TYPES.png,
exportPadding: 24,
maxWidthOrHeight: 2400,
appState: {
exportBackground: true,
exportWithDarkMode: false,
viewBackgroundColor: '#ffffff',
},
})
} catch (e) {
console.error('[excalidraw-export-image] exportToBlob failed:', e)
return null
}
}

View File

@@ -1,3 +1,8 @@
import type { Note } from '@/lib/types'
const MD_IMG = /!\[[^\]]*\]\(([^)\s]+)\)/g
const HTML_IMG = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi
/**
* Plain-text preview for list view (light markdown stripping).
*/
@@ -31,3 +36,27 @@ export function getNoteDisplayTitle(note: { title: string | null; content: strin
const preview = stripMarkdownPreview(note.content || '', 100)
return preview || untitled
}
export function getNoteFeedImage(note: Note): string | null {
const first = note.images?.[0]?.trim()
if (first) return first
const content = note.content || ''
MD_IMG.lastIndex = 0
const md = MD_IMG.exec(content)
if (md?.[1]) return md[1]
HTML_IMG.lastIndex = 0
const html = HTML_IMG.exec(content)
if (html?.[1]) return html[1]
return null
}
/** Plain excerpt for collection cards (HTML + markdown noise stripped). */
export function getNotePlainExcerpt(note: Note, maxLen = 240): string {
let raw = note.content || ''
raw = raw.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
raw = raw.replace(/<[^>]+>/g, ' ')
return stripMarkdownPreview(raw, maxLen)
}

View File

@@ -1,31 +1,35 @@
export function getThemeScript(theme: string = 'light') {
return `
(function() {
try {
var localTheme = localStorage.getItem('theme-preference');
var theme = localTheme || '${theme}';
var root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
/**
* Script inline exécuté avant lhydratation : même logique que `applyDocumentTheme`.
*/
import { normalizeThemeId } from './apply-document-theme'
if (theme === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
root.classList.add('dark');
}
} else if (theme === 'dark') {
root.classList.add('dark');
} else if (theme === 'light') {
// do nothing
} else {
root.setAttribute('data-theme', theme);
if (theme === 'midnight') {
root.classList.add('dark');
}
}
} catch (e) {
console.error('Theme script error', e);
}
})();
`
export function getThemeScript(serverTheme: string = 'light') {
const fallback = normalizeThemeId(serverTheme)
return `
(function() {
try {
var fallback = ${JSON.stringify(fallback)};
var stored = localStorage.getItem('theme-preference');
var raw = stored || fallback;
if (raw === 'slate') raw = 'light';
var allowed = { light:1, dark:1, auto:1, sepia:1, midnight:1, rose:1, green:1, lavender:1, sand:1, ocean:1, sunset:1, blue:1 };
var theme = allowed[raw] ? raw : 'light';
var root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
if (theme === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark');
} else if (theme === 'dark') {
root.classList.add('dark');
} else if (theme === 'light') {
/* :root papier */
} else {
root.setAttribute('data-theme', theme);
if (theme === 'midnight') root.classList.add('dark');
}
} catch (e) {
console.error('Theme script error', e);
}
})();
`.trim()
}

View File

@@ -154,10 +154,10 @@ export const LABEL_COLORS = {
icon: 'text-teal-500 dark:text-teal-400'
},
blue: {
bg: 'bg-sky-100 dark:bg-sky-900/40',
text: 'text-sky-700 dark:text-sky-300',
border: 'border-sky-200 dark:border-sky-800',
icon: 'text-sky-500 dark:text-sky-400'
bg: 'bg-slate-100 dark:bg-slate-900/40',
text: 'text-slate-700 dark:text-slate-300',
border: 'border-slate-200 dark:border-slate-700',
icon: 'text-slate-600 dark:text-slate-400'
},
purple: {
bg: 'bg-violet-100 dark:bg-violet-900/40',
@@ -207,9 +207,9 @@ export const NOTE_COLORS = {
card: 'bg-teal-50 dark:bg-teal-950/30 border-teal-100 dark:border-teal-900/50'
},
blue: {
bg: 'bg-blue-50 dark:bg-blue-950/30',
hover: 'hover:bg-blue-100 dark:hover:bg-blue-950/50',
card: 'bg-blue-50 dark:bg-blue-950/30 border-blue-100 dark:border-blue-900/50'
bg: 'bg-slate-50 dark:bg-slate-950/30',
hover: 'hover:bg-slate-100 dark:hover:bg-slate-950/50',
card: 'bg-slate-50 dark:bg-slate-950/30 border-slate-200 dark:border-slate-800/50'
},
purple: {
bg: 'bg-purple-50 dark:bg-purple-950/30',

View File

@@ -131,6 +131,7 @@
"pinnedNotes": "الملاحظات المثبتة",
"recent": "الحديثة",
"addNote": "إضافة ملاحظة",
"readMore": "اقرأ المزيد",
"remove": "إزالة",
"dragToReorder": "اسحب لإعادة الترتيب",
"more": "المزيد",
@@ -678,7 +679,18 @@
"emailNotificationsDesc": "تلقي إشعارات مهمة عبر البريد الإلكتروني",
"desktopNotifications": "إشعارات سطح المكتب",
"desktopNotificationsDesc": "تلقي إشعارات في المتصفح",
"notificationsDesc": "إدارة تفضيلات الإشعارات"
"notificationsDesc": "إدارة تفضيلات الإشعارات",
"themeBaseGroup": "Base",
"themePalettesGroup": "Color palettes",
"themeSepia": "Sepia",
"themeMidnight": "Midnight",
"themeRose": "Rose",
"themeGreen": "Green",
"themeLavender": "Lavender",
"themeSand": "Sand",
"themeOcean": "Ocean",
"themeSunset": "Sunset",
"themeBlue": "Blue"
},
"profile": {
"title": "الملف الشخصي",
@@ -1748,4 +1760,4 @@
"addBlock": "إضافة كتلة",
"placeholder": "اكتب '/' للأوامر..."
}
}
}

View File

@@ -131,6 +131,7 @@
"pinnedNotes": "Angeheftete Notizen",
"recent": "Aktuell",
"addNote": "Notiz hinzufügen",
"readMore": "Weiterlesen",
"remove": "Entfernen",
"dragToReorder": "Ziehen zum Neuanordnen",
"more": "Mehr",
@@ -628,7 +629,18 @@
"emailNotificationsDesc": "Wichtige Benachrichtigungen per E-Mail erhalten",
"desktopNotifications": "Desktop-Benachrichtigungen",
"desktopNotificationsDesc": "Benachrichtigungen im Browser erhalten",
"notificationsDesc": "Verwalten Sie Ihre Benachrichtigungseinstellungen"
"notificationsDesc": "Verwalten Sie Ihre Benachrichtigungseinstellungen",
"themeBaseGroup": "Base",
"themePalettesGroup": "Color palettes",
"themeSepia": "Sepia",
"themeMidnight": "Midnight",
"themeRose": "Rose",
"themeGreen": "Green",
"themeLavender": "Lavender",
"themeSand": "Sand",
"themeOcean": "Ocean",
"themeSunset": "Sunset",
"themeBlue": "Blue"
},
"profile": {
"title": "Profil",

View File

@@ -32,6 +32,8 @@
},
"sidebar": {
"notes": "Notes",
"recent": "Recent",
"quickNav": "Quick navigation",
"reminders": "Reminders",
"labels": "Labels",
"editLabels": "Edit labels",
@@ -58,6 +60,7 @@
"confirmDelete": "Are you sure you want to delete this note?",
"confirmLeaveShare": "Are you sure you want to leave this shared note?",
"sharedBy": "Shared by",
"sharedShort": "Shared",
"leaveShare": "Leave",
"delete": "Delete",
"archive": "Archive",
@@ -131,10 +134,13 @@
"pinnedNotes": "Pinned Notes",
"recent": "Recent",
"addNote": "Add Note",
"readMore": "Read more",
"remove": "Remove",
"dragToReorder": "Drag to reorder",
"more": "More options",
"emptyState": "No notes yet. Create your first note!",
"metadataPanel": "Details",
"metadataNotebook": "Notebook",
"emptyStateTabs": "No notes here yet. Use \"New note\" in the sidebar to add one (AI title suggestions appear in the composer).",
"inNotebook": "In notebook",
"moveFailed": "Failed to move note. Please try again.",
@@ -148,7 +154,9 @@
"undoShortcut": "Undo (Ctrl+Z)",
"viewCards": "Cards View",
"viewCardsTooltip": "Card grid with drag-and-drop reorder",
"viewTabs": "List View",
"viewList": "List",
"viewListTooltip": "Scannable list with preview, dates, and labels",
"viewTabs": "Tabs",
"viewTabsTooltip": "Tabs on top, note below — drag tabs to reorder",
"viewModeGroup": "Notes display mode",
"reorderTabs": "Reorder tab",
@@ -201,6 +209,7 @@
"createFailed": "Failed to create note",
"updateFailed": "Failed to update note",
"archived": "Note archived",
"unarchivedSuccess": "Note removed from archive",
"archiveFailed": "Failed to archive",
"sort": "Sort",
"confirmDeleteTitle": "Delete note",
@@ -464,7 +473,16 @@
},
"toastLoadingDesc": "You can navigate freely, a notification will appear.",
"toastSuccessSlides": "Click Download in the AI panel.",
"toastSuccessDiagram": "Your diagram is available in the Lab."
"toastSuccessDiagram": "Your diagram is available in the Lab.",
"diagramReadyHint": "Use the panel below: Excalidraw or insert into the note.",
"openInExcalidraw": "Open in Excalidraw (Lab)",
"insertDiagramInNote": "Insert as image into note",
"insertNeedEditor": "Can't insert here — open a note with the assistant or use the Lab.",
"insertFetchError": "Couldn't load the diagram.",
"insertExportError": "Failed to export the diagram as an image.",
"insertUploadError": "Failed to upload the image.",
"diagramImageAlt": "Generated diagram",
"insertedInNote": "Diagram added to the note"
},
"openAssistant": "Open AI Assistant",
"poweredByMomento": "Powered by Momento AI",
@@ -681,6 +699,17 @@
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"themeBaseGroup": "Base",
"themePalettesGroup": "Color palettes",
"themeSepia": "Sepia",
"themeMidnight": "Midnight",
"themeRose": "Rose",
"themeGreen": "Green",
"themeLavender": "Lavender",
"themeSand": "Sand",
"themeOcean": "Ocean",
"themeSunset": "Sunset",
"themeBlue": "Blue",
"notifications": "Notifications",
"language": "Language",
"selectLanguage": "Select language",
@@ -1277,6 +1306,7 @@
"notesViewLabel": "Notes layout",
"notesViewTabs": "Tabs (OneNote-style)",
"notesViewMasonry": "Cards (grid)",
"notesViewList": "List (magazine)",
"selectTheme": "Select theme",
"fontFamilyLabel": "Font Family",
"fontFamilyDescription": "Choose the font used throughout the app",

View File

@@ -131,6 +131,7 @@
"pinnedNotes": "Notas fijadas",
"recent": "Recientes",
"addNote": "Agregar nota",
"readMore": "Leer más",
"remove": "Remove",
"dragToReorder": "Arrastra para reordenar",
"more": "Más",
@@ -628,7 +629,18 @@
"emailNotificationsDesc": "Recibir notificaciones importantes por correo",
"desktopNotifications": "Notificaciones de escritorio",
"desktopNotificationsDesc": "Recibir notificaciones en el navegador",
"notificationsDesc": "Gestiona tus preferencias de notificaciones"
"notificationsDesc": "Gestiona tus preferencias de notificaciones",
"themeBaseGroup": "Base",
"themePalettesGroup": "Color palettes",
"themeSepia": "Sepia",
"themeMidnight": "Midnight",
"themeRose": "Rose",
"themeGreen": "Green",
"themeLavender": "Lavender",
"themeSand": "Sand",
"themeOcean": "Ocean",
"themeSunset": "Sunset",
"themeBlue": "Blue"
},
"profile": {
"title": "Perfil",

View File

@@ -131,6 +131,7 @@
"pinnedNotes": "یادداشت‌های سنجاق شده",
"recent": "اخیر",
"addNote": "افزودن یادداشت",
"readMore": "ادامه مطلب",
"remove": "حذف",
"dragToReorder": "بکشید تا مرتب کنید",
"more": "بیشتر",
@@ -678,7 +679,18 @@
"emailNotificationsDesc": "دریافت اعلان‌های مهم از طریق ایمیل",
"desktopNotifications": "اعلان‌های مرورگر",
"desktopNotificationsDesc": "دریافت اعلان‌ها در مرورگر",
"notificationsDesc": "مدیریت تنظیمات اعلان"
"notificationsDesc": "مدیریت تنظیمات اعلان",
"themeBaseGroup": "Base",
"themePalettesGroup": "Color palettes",
"themeSepia": "Sepia",
"themeMidnight": "Midnight",
"themeRose": "Rose",
"themeGreen": "Green",
"themeLavender": "Lavender",
"themeSand": "Sand",
"themeOcean": "Ocean",
"themeSunset": "Sunset",
"themeBlue": "Blue"
},
"profile": {
"title": "پروفایل",

View File

@@ -32,6 +32,8 @@
},
"sidebar": {
"notes": "Notes",
"recent": "Récent",
"quickNav": "Navigation rapide",
"reminders": "Rappels",
"labels": "Étiquettes",
"editLabels": "Modifier les étiquettes",
@@ -58,6 +60,7 @@
"confirmDelete": "Êtes-vous sûr de vouloir supprimer cette note ?",
"confirmLeaveShare": "Êtes-vous sûr de vouloir quitter cette note partagée ?",
"sharedBy": "Partagé par",
"sharedShort": "Partagé",
"leaveShare": "Quitter",
"delete": "Supprimer",
"archive": "Archiver",
@@ -131,10 +134,13 @@
"pinnedNotes": "Notes épinglées",
"recent": "Récent",
"addNote": "Ajouter une note",
"readMore": "Lire la suite",
"remove": "Supprimer",
"dragToReorder": "Glisser pour réorganiser",
"more": "Plus d'options",
"emptyState": "Aucune note encore. Créez votre première note !",
"metadataPanel": "Métadonnées",
"metadataNotebook": "Carnet",
"emptyStateTabs": "Aucune note dans cette vue. Utilisez « Nouvelle note » dans la barre latérale pour en ajouter une (titres suggérés par lIA disponibles dans le compositeur).",
"inNotebook": "Dans le carnet",
"moveFailed": "Échec du déplacement de la note. Veuillez réessayer.",
@@ -148,7 +154,9 @@
"undoShortcut": "Annuler (Ctrl+Z)",
"viewCards": "Vue par cartes",
"viewCardsTooltip": "Grille de cartes et réorganisation par glisser-déposer",
"viewTabs": "Vue en liste",
"viewList": "Liste",
"viewListTooltip": "Liste avec aperçu, dates et étiquettes (style magazine)",
"viewTabs": "Onglets",
"viewTabsTooltip": "Onglets en haut, contenu dessous — glisser les onglets pour réordonner",
"viewModeGroup": "Mode d'affichage des notes",
"reorderTabs": "Réordonner l'onglet",
@@ -201,6 +209,7 @@
"createFailed": "Impossible de créer la note",
"updateFailed": "Mise à jour échouée",
"archived": "Note archivée",
"unarchivedSuccess": "Note désarchivée",
"archiveFailed": "Échec de l'archivage",
"sort": "Trier",
"confirmDeleteTitle": "Supprimer la note",
@@ -464,7 +473,16 @@
},
"toastLoadingDesc": "Vous pouvez naviguer librement, une notification apparaîtra.",
"toastSuccessSlides": "Cliquez sur Télécharger dans le panneau IA.",
"toastSuccessDiagram": "Votre diagramme est disponible dans le Lab."
"toastSuccessDiagram": "Votre diagramme est disponible dans le Lab.",
"diagramReadyHint": "Utilisez le panneau ci-dessous : Excalidraw ou insertion dans la note.",
"openInExcalidraw": "Ouvrir dans Excalidraw (Lab)",
"insertDiagramInNote": "Insérer comme image dans la note",
"insertNeedEditor": "Impossible dinsérer ici — ouvrez une note avec lassistant ou ouvrez le Lab.",
"insertFetchError": "Impossible de récupérer le diagramme.",
"insertExportError": "Erreur lors de lexport du diagramme en image.",
"insertUploadError": "Erreur lors du téléchargement de limage.",
"diagramImageAlt": "Diagramme généré",
"insertedInNote": "Diagramme ajouté à la note"
},
"openAssistant": "Ouvrir IA Note",
"poweredByMomento": "Propulsé par Momento AI",
@@ -752,6 +770,17 @@
"themeLight": "Clair",
"themeDark": "Sombre",
"themeSystem": "Système",
"themeBaseGroup": "Affichage",
"themePalettesGroup": "Palettes de couleur",
"themeSepia": "Sépia",
"themeMidnight": "Minuit",
"themeRose": "Rose",
"themeGreen": "Vert",
"themeLavender": "Lavande",
"themeSand": "Sable",
"themeOcean": "Océan",
"themeSunset": "Coucher de soleil",
"themeBlue": "Bleu",
"notifications": "Notifications",
"language": "Langue",
"selectLanguage": "Sélectionner une langue",
@@ -1355,6 +1384,7 @@
"notesViewLabel": "Affichage des notes",
"notesViewTabs": "Onglets (type OneNote)",
"notesViewMasonry": "Cartes (grille)",
"notesViewList": "Liste (magazine)",
"selectTheme": "Sélectionner le thème",
"fontFamilyLabel": "Famille de polices",
"fontFamilyDescription": "Choisissez la police utilisée dans toute l'application",

View File

@@ -131,6 +131,7 @@
"pinnedNotes": "पिन किए गए नोट्स",
"recent": "हालिया",
"addNote": "नोट जोड़ें",
"readMore": "और पढ़ें",
"remove": "हटाएं",
"dragToReorder": "पुनर्व्यवस्थित करने के लिए खींचें",
"more": "अधिक",
@@ -628,7 +629,18 @@
"emailNotificationsDesc": "ईमेल द्वारा महत्वपूर्ण सूचनाएं प्राप्त करें",
"desktopNotifications": "डेस्कटॉप सूचनाएं",
"desktopNotificationsDesc": "ब्राउज़र में सूचनाएं प्राप्त करें",
"notificationsDesc": "अपनी सूचना वरीयताएं प्रबंधित करें"
"notificationsDesc": "अपनी सूचना वरीयताएं प्रबंधित करें",
"themeBaseGroup": "Base",
"themePalettesGroup": "Color palettes",
"themeSepia": "Sepia",
"themeMidnight": "Midnight",
"themeRose": "Rose",
"themeGreen": "Green",
"themeLavender": "Lavender",
"themeSand": "Sand",
"themeOcean": "Ocean",
"themeSunset": "Sunset",
"themeBlue": "Blue"
},
"profile": {
"title": "प्रोफ़ाइल",

View File

@@ -131,6 +131,7 @@
"pinnedNotes": "Note fissate",
"recent": "Recenti",
"addNote": "Aggiungi nota",
"readMore": "Leggi di più",
"remove": "Remove",
"dragToReorder": "Trascina per riordinare",
"more": "Altro",
@@ -628,7 +629,18 @@
"emailNotificationsDesc": "Ricevi notifiche importanti via email",
"desktopNotifications": "Notifiche desktop",
"desktopNotificationsDesc": "Ricevi notifiche nel browser",
"notificationsDesc": "Gestisci le preferenze di notifica"
"notificationsDesc": "Gestisci le preferenze di notifica",
"themeBaseGroup": "Base",
"themePalettesGroup": "Color palettes",
"themeSepia": "Sepia",
"themeMidnight": "Midnight",
"themeRose": "Rose",
"themeGreen": "Green",
"themeLavender": "Lavender",
"themeSand": "Sand",
"themeOcean": "Ocean",
"themeSunset": "Sunset",
"themeBlue": "Blue"
},
"profile": {
"title": "Profilo",

View File

@@ -131,6 +131,7 @@
"pinnedNotes": "ピン留めされたノート",
"recent": "最近",
"addNote": "ノートを追加",
"readMore": "続きを読む",
"remove": "削除",
"dragToReorder": "ドラッグして並べ替え",
"more": "もっと見る",
@@ -628,7 +629,18 @@
"emailNotificationsDesc": "重要な通知をメールで受け取ります",
"desktopNotifications": "デスクトップ通知",
"desktopNotificationsDesc": "ブラウザで通知を受け取ります",
"notificationsDesc": "通知設定を管理します"
"notificationsDesc": "通知設定を管理します",
"themeBaseGroup": "Base",
"themePalettesGroup": "Color palettes",
"themeSepia": "Sepia",
"themeMidnight": "Midnight",
"themeRose": "Rose",
"themeGreen": "Green",
"themeLavender": "Lavender",
"themeSand": "Sand",
"themeOcean": "Ocean",
"themeSunset": "Sunset",
"themeBlue": "Blue"
},
"profile": {
"title": "プロフィール",

View File

@@ -131,6 +131,7 @@
"pinnedNotes": "고정된 메모",
"recent": "최근",
"addNote": "메모 추가",
"readMore": "더 읽기",
"remove": "제거",
"dragToReorder": "드래그하여 재정렬",
"more": "더 보기",
@@ -628,7 +629,18 @@
"emailNotificationsDesc": "이메일로 중요한 알림을 받습니다",
"desktopNotifications": "데스크톱 알림",
"desktopNotificationsDesc": "브라우저에서 알림을 받습니다",
"notificationsDesc": "알림 환경설정을 관리합니다"
"notificationsDesc": "알림 환경설정을 관리합니다",
"themeBaseGroup": "Base",
"themePalettesGroup": "Color palettes",
"themeSepia": "Sepia",
"themeMidnight": "Midnight",
"themeRose": "Rose",
"themeGreen": "Green",
"themeLavender": "Lavender",
"themeSand": "Sand",
"themeOcean": "Ocean",
"themeSunset": "Sunset",
"themeBlue": "Blue"
},
"profile": {
"title": "프로필",

View File

@@ -131,6 +131,7 @@
"pinnedNotes": "Vastgezette notities",
"recent": "Recent",
"addNote": "Notitie toevoegen",
"readMore": "Lees meer",
"remove": "Verwijderen",
"dragToReorder": "Sleep om te herschikken",
"more": "Meer",
@@ -628,7 +629,18 @@
"emailNotificationsDesc": "Ontvang belangrijke meldingen per e-mail",
"desktopNotifications": "Bureaubladmeldingen",
"desktopNotificationsDesc": "Ontvang meldingen in uw browser",
"notificationsDesc": "Beheer uw meldingsvoorkeuren"
"notificationsDesc": "Beheer uw meldingsvoorkeuren",
"themeBaseGroup": "Base",
"themePalettesGroup": "Color palettes",
"themeSepia": "Sepia",
"themeMidnight": "Midnight",
"themeRose": "Rose",
"themeGreen": "Green",
"themeLavender": "Lavender",
"themeSand": "Sand",
"themeOcean": "Ocean",
"themeSunset": "Sunset",
"themeBlue": "Blue"
},
"profile": {
"title": "Profiel",

View File

@@ -131,6 +131,7 @@
"pinnedNotes": "Przypięte notatki",
"recent": "Ostatnie",
"addNote": "Dodaj notatkę",
"readMore": "Czytaj więcej",
"remove": "Usuń",
"dragToReorder": "Przeciągnij, aby zmienić kolejność",
"more": "Więcej",
@@ -628,7 +629,18 @@
"emailNotificationsDesc": "Otrzymuj ważne powiadomienia przez e-mail",
"desktopNotifications": "Powiadomienia na pulpicie",
"desktopNotificationsDesc": "Otrzymuj powiadomienia w przeglądarce",
"notificationsDesc": "Zarządzaj swoimi preferencjami powiadomień"
"notificationsDesc": "Zarządzaj swoimi preferencjami powiadomień",
"themeBaseGroup": "Base",
"themePalettesGroup": "Color palettes",
"themeSepia": "Sepia",
"themeMidnight": "Midnight",
"themeRose": "Rose",
"themeGreen": "Green",
"themeLavender": "Lavender",
"themeSand": "Sand",
"themeOcean": "Ocean",
"themeSunset": "Sunset",
"themeBlue": "Blue"
},
"profile": {
"title": "Profil",

View File

@@ -131,6 +131,7 @@
"pinnedNotes": "Notas fixadas",
"recent": "Recentes",
"addNote": "Adicionar nota",
"readMore": "Ler mais",
"remove": "Remover",
"dragToReorder": "Arraste para reordenar",
"more": "Mais",
@@ -628,7 +629,18 @@
"emailNotificationsDesc": "Receba notificações importantes por e-mail",
"desktopNotifications": "Notificações na área de trabalho",
"desktopNotificationsDesc": "Receba notificações no seu navegador",
"notificationsDesc": "Gerencie suas preferências de notificação"
"notificationsDesc": "Gerencie suas preferências de notificação",
"themeBaseGroup": "Base",
"themePalettesGroup": "Color palettes",
"themeSepia": "Sepia",
"themeMidnight": "Midnight",
"themeRose": "Rose",
"themeGreen": "Green",
"themeLavender": "Lavender",
"themeSand": "Sand",
"themeOcean": "Ocean",
"themeSunset": "Sunset",
"themeBlue": "Blue"
},
"profile": {
"title": "Perfil",

View File

@@ -131,6 +131,7 @@
"pinnedNotes": "Закреплённые заметки",
"recent": "Недавние",
"addNote": "Добавить заметку",
"readMore": "Читать далее",
"remove": "Удалить",
"dragToReorder": "Перетащите для изменения порядка",
"more": "Ещё",
@@ -628,7 +629,18 @@
"emailNotificationsDesc": "Получать важные уведомления по email",
"desktopNotifications": "Уведомления на рабочем столе",
"desktopNotificationsDesc": "Получать уведомления в браузере",
"notificationsDesc": "Управление настройками уведомлений"
"notificationsDesc": "Управление настройками уведомлений",
"themeBaseGroup": "Base",
"themePalettesGroup": "Color palettes",
"themeSepia": "Sepia",
"themeMidnight": "Midnight",
"themeRose": "Rose",
"themeGreen": "Green",
"themeLavender": "Lavender",
"themeSand": "Sand",
"themeOcean": "Ocean",
"themeSunset": "Sunset",
"themeBlue": "Blue"
},
"profile": {
"title": "Профиль",

View File

@@ -131,6 +131,7 @@
"pinnedNotes": "已置顶笔记",
"recent": "最近",
"addNote": "添加笔记",
"readMore": "阅读全文",
"remove": "移除",
"dragToReorder": "拖动以重新排序",
"more": "更多",
@@ -628,7 +629,18 @@
"emailNotificationsDesc": "通过电子邮件接收重要通知",
"desktopNotifications": "桌面通知",
"desktopNotificationsDesc": "在浏览器中接收通知",
"notificationsDesc": "管理您的通知偏好"
"notificationsDesc": "管理您的通知偏好",
"themeBaseGroup": "Base",
"themePalettesGroup": "Color palettes",
"themeSepia": "Sepia",
"themeMidnight": "Midnight",
"themeRose": "Rose",
"themeGreen": "Green",
"themeLavender": "Lavender",
"themeSand": "Sand",
"themeOcean": "Ocean",
"themeSunset": "Sunset",
"themeBlue": "Blue"
},
"profile": {
"title": "个人资料",

View File

@@ -61,6 +61,7 @@
"jsdom": "^29.0.2",
"katex": "^0.16.27",
"lucide-react": "^0.562.0",
"motion": "^12.38.0",
"next": "^16.1.6",
"next-auth": "^5.0.0-beta.30",
"nodemailer": "^8.0.4",
@@ -9061,6 +9062,33 @@
"node": "^14.13.1 || >=16.0.0"
}
},
"node_modules/framer-motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.38.0",
"motion-utils": "^12.36.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/framework-utils": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/framework-utils/-/framework-utils-1.1.0.tgz",
@@ -11363,6 +11391,47 @@
"ufo": "^1.6.3"
}
},
"node_modules/motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
"integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.38.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.36.0"
}
},
"node_modules/motion-utils": {
"version": "12.36.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@@ -78,6 +78,7 @@
"jsdom": "^29.0.2",
"katex": "^0.16.27",
"lucide-react": "^0.562.0",
"motion": "^12.38.0",
"next": "^16.1.6",
"next-auth": "^5.0.0-beta.30",
"nodemailer": "^8.0.4",

View File

@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<rect width="192" height="192" fill="#3A7CA5" rx="40"/>
<rect width="192" height="192" fill="#1C1C1C" rx="40"/>
<g transform="translate(96,96)">
<rect x="-34" y="-34" width="68" height="68" rx="5" fill="white"/>
<path d="M34,34 L34,11 L11,34 Z" fill="#3A7CA5" opacity="0.3"/>
<path d="M34,34 L34,11 L11,34 Z" fill="#1C1C1C" opacity="0.28"/>
<line x1="-23" y1="-17" x2="23" y2="-17" stroke="#e5e7eb" stroke-width="3" stroke-linecap="round"/>
<line x1="-23" y1="-4" x2="23" y2="-4" stroke="#e5e7eb" stroke-width="3" stroke-linecap="round"/>
<line x1="-23" y1="9" x2="11" y2="9" stroke="#e5e7eb" stroke-width="3" stroke-linecap="round"/>

Before

Width:  |  Height:  |  Size: 615 B

After

Width:  |  Height:  |  Size: 616 B

View File

@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#3A7CA5" rx="100"/>
<rect width="512" height="512" fill="#1C1C1C" rx="100"/>
<g transform="translate(256,256)">
<rect x="-90" y="-90" width="180" height="180" rx="12" fill="white"/>
<path d="M90,90 L90,30 L30,90 Z" fill="#3A7CA5" opacity="0.3"/>
<path d="M90,90 L90,30 L30,90 Z" fill="#1C1C1C" opacity="0.28"/>
<line x1="-60" y1="-45" x2="60" y2="-45" stroke="#e5e7eb" stroke-width="8" stroke-linecap="round"/>
<line x1="-60" y1="-10" x2="60" y2="-10" stroke="#e5e7eb" stroke-width="8" stroke-linecap="round"/>
<line x1="-60" y1="25" x2="30" y2="25" stroke="#e5e7eb" stroke-width="8" stroke-linecap="round"/>

Before

Width:  |  Height:  |  Size: 625 B

After

Width:  |  Height:  |  Size: 626 B

View File

@@ -4,8 +4,8 @@
"description": "A smart, local-first note taking app with AI capabilities.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3A7CA5",
"background_color": "#F2F0E9",
"theme_color": "#1C1C1C",
"icons": [
{
"src": "/icons/icon-192.svg",

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

8
memento-note/run-test.ts Normal file
View File

@@ -0,0 +1,8 @@
import { executeAgent } from "./lib/ai/services/agent-executor.service.js";
async function main() {
const result = await executeAgent("cmory42lf001920drcqc6nmt8", "cmooqpcnb00001yvn3tzwgf1i");
console.log(JSON.stringify(result, null, 2));
}
main().catch(e => { console.error("ERROR:", e.message); process.exit(1); });

View File

@@ -258,10 +258,10 @@ test.describe('Note Editor - Reminder Dialog', () => {
await page.waitForTimeout(500);
// Bell button should have active styling (text-blue-600)
// Bell button should have active styling (accent = primary)
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
const className = await bellButton.getAttribute('class');
expect(className).toContain('text-blue-600');
expect(className).toContain('text-primary');
});
test('should allow editing existing reminder', async ({ page }) => {
@@ -327,7 +327,7 @@ test.describe('Note Editor - Reminder Dialog', () => {
await page.waitForTimeout(300);
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
const className = await bellButton.getAttribute('class');
expect(className).not.toContain('text-blue-600');
expect(className).not.toContain('text-primary');
});
test('should persist reminder after saving note', async ({ page }) => {
@@ -355,7 +355,7 @@ test.describe('Note Editor - Reminder Dialog', () => {
// Bell button should still be active
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
const className = await bellButton.getAttribute('class');
expect(className).toContain('text-blue-600');
expect(className).toContain('text-primary');
// Open reminder dialog to verify values
await bellButton.click();