feat: architectural grid editor fullPage + slash commands + doc info panel + AI title
60
memento-note/.win11-prompt.txt
Normal 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.
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">▸</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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
memento-note/components/ai-chat-layout-bridge.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
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\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>
|
||||
)}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 — {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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
224
memento-note/components/markdown-slash-commands.tsx
Normal 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
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
243
memento-note/components/note-document-info-panel.tsx
Normal 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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
192
memento-note/components/notes-editorial-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
166
memento-note/components/notes-list-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
69
memento-note/components/notes-notebook-feed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 85 KiB |
83
memento-note/lib/apply-document-theme.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
59
memento-note/lib/client/excalidraw-export-image.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 l’hydratation : 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()
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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": "اكتب '/' للأوامر..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "پروفایل",
|
||||
|
||||
@@ -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 l’IA 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 d’insérer ici — ouvrez une note avec l’assistant ou ouvrez le Lab.",
|
||||
"insertFetchError": "Impossible de récupérer le diagramme.",
|
||||
"insertExportError": "Erreur lors de l’export du diagramme en image.",
|
||||
"insertUploadError": "Erreur lors du téléchargement de l’image.",
|
||||
"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",
|
||||
|
||||
@@ -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": "प्रोफ़ाइल",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "プロフィール",
|
||||
|
||||
@@ -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": "프로필",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Профиль",
|
||||
|
||||
@@ -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": "个人资料",
|
||||
|
||||
69
memento-note/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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",
|
||||
|
||||
BIN
memento-note/public/textures/natural-paper.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
8
memento-note/run-test.ts
Normal 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); });
|
||||
@@ -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();
|
||||
|
||||