chore: remove dead code — 8 components, 5 libs, 4 API routes, 4 npm packages, 30+ scripts, dead CSS, dead exports
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s

Removed unused components:
- brainstorm-canvas, brainstorm-create-dialog, invite-dialog, manual-idea-dialog
- note-inline-editor, profile-page-header, quota-paywall, label-management-dialog

Removed dead lib files:
- api-auth.ts, color-harmony-recommendation.ts, label-storage.ts, modern-color-options.ts
- hooks/use-card-size-mode.ts

Removed dead API routes:
- ai/test-chat, ai/test-embeddings, ai/test-tags, admin/randomize-labels

Removed unused npm packages:
- cmdk, novel, tippy.js, react-force-graph-2d

Cleaned dead CSS from globals.css:
- acrylic-*, win11-shadow-*, muuri-grid/item, ai-glass, ai-tab-indicator, ai-send-btn, sidebar-view-toggle, memento-sidebar-depth

Removed 29 orphan scripts and 3 root orphan files

Cleaned dead exports from 8 lib files:
- NOTE_TYPE_CONFIG, getPublishableKey, PROVIDER_DEFAULTS, useNotes/useNote/invalidateNote, etc.
This commit is contained in:
Antigravity
2026-05-16 20:34:58 +00:00
parent 8c7ca69640
commit 724474cb49
61 changed files with 16 additions and 9502 deletions

View File

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

View File

@@ -1,175 +0,0 @@
# Brainstorm Canvas — Feature Spec for OpenCode
## PROJECT CONTEXT
- **Stack**: Next.js 15 + Prisma + PostgreSQL (pgvector)
- **Location**: ~/dev/Momento/memento-note/
- **Existing models**: User, Note, Notebook, Canvas (Excalidraw), Agent, Conversation, Workflow, etc.
- **Existing API routes**: /app/api/notes, /notebooks, /ai, /canvas, /chat, /agents, etc.
- **UI lib**: Radix UI + Tailwind CSS + shadcn components
- **State**: @tanstack/react-query
- **Auth**: NextAuth (auth.ts, auth.config.ts)
## ⚠️ CRITICAL RULES
1. **DO NOT modify existing models or API routes** — only ADD new ones
2. **DO NOT touch prisma/schema.prisma without creating a migration** — use `npx prisma migrate dev --name add_brainstorm`
3. **Study the existing code patterns** before writing new code — match the project conventions
4. **Look at how existing API routes are structured** (e.g., /app/api/notes/route.ts) and follow the same pattern
5. **Look at how existing components use React Query** and follow the same pattern
6. **Use existing UI components** from /components/ui/ (shadcn) — don't install new UI libs
7. **The project uses TypeScript** — maintain strict typing
8. **Do NOT run npm build** — only dev server if needed for testing
## FEATURE: Brainstorm Canvas (Wave Brainstorming)
### Concept
A temporary workspace for brainstorming ideas using AI-generated "waves" of ideas, displayed as a radial graph. The output feeds back into the notes system.
### Data Model — ADD these 2 models to prisma/schema.prisma:
```prisma
model BrainstormSession {
id String @id @default(cuid())
seedIdea String
sourceNoteId String?
contextNoteIds String? // JSON array of note IDs
exportedNoteId String?
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ideas BrainstormIdea[]
sourceNote Note? @relation(fields: [sourceNoteId], references: [id])
exportedNote Note? @relation(fields: [exportedNoteId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, createdAt])
}
model BrainstormIdea {
id String @id @default(cuid())
sessionId String
waveNumber Int // 1, 2, or 3
title String
description String
connectionToSeed String?
noveltyScore Int?
parentIdeaId String? // for "dig deeper" sub-brainstorms
convertedToNoteId String?
relatedNoteIds String? // JSON array
status String @default("active") // active, dismissed, converted
positionX Float?
positionY Float?
createdAt DateTime @default(now())
session BrainstormSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
parentIdea BrainstormIdea? @relation("IdeaTree", fields: [parentIdeaId], references: [id])
children BrainstormIdea[] @relation("IdeaTree")
convertedNote Note? @relation(fields: [convertedToNoteId], references: [id])
@@index([sessionId])
@@index([waveNumber])
@@index([status])
@@index([parentIdeaId])
}
```
Also add relations to User model:
```
brainstormSessions BrainstormSession[]
```
And to Note model — add these two relations:
```
sourceBrainstormSessions BrainstormSession[] @relation via sourceNoteId
exportedBrainstormSessions BrainstormSession[] @relation via exportedNoteId
convertedBrainstormIdeas BrainstormIdea[] @relation via convertedToNoteId
```
### API Routes — Create /app/api/brainstorm/
1. **POST /api/brainstorm/wave** — Create new brainstorm session
- Input: { seedIdea, sourceNoteId?, contextNoteIds? }
- Uses existing AI setup (check how /api/ai/ routes call LLM)
- Generates 3 waves of ~3 ideas each (9 total)
- For each idea, does an embedding search to find related notes (check how semantic search works in existing code)
- Saves session + ideas to DB
- Returns: { sessionId, ideas: [...] }
2. **POST /api/brainstorm/[sessionId]/expand** — Dig deeper on an idea
- Input: { ideaId }
- Uses the clicked idea as new seed
- Generates 3 more waves, linked as children
- Returns new ideas with parentIdeaId set
3. **POST /api/brainstorm/[sessionId]/dismiss** — Mark idea as not relevant
- Input: { ideaId }
- Sets status = "dismissed"
4. **POST /api/brainstorm/[sessionId]/convert** — Convert idea to a real Note
- Input: { ideaId }
- Creates a Note with pre-filled content
- Auto-tags: "brainstorm", "idée"
- Links to source note if exists
- Sets idea.status = "converted", idea.convertedToNoteId = newNote.id
- Returns the created note
5. **POST /api/brainstorm/[sessionId]/export** — Export session as summary note
- Generates a Markdown summary note
- Groups by waves, shows which ideas were converted
- Links to all converted notes
- Returns the created note
6. **GET /api/brainstorm** — List user's brainstorm sessions
- Returns sessions ordered by date, with idea counts
7. **GET /api/brainstorm/[sessionId]** — Get full session with ideas
- Returns session + all ideas + their status
### Frontend — Canvas Component
Use **react-force-graph-2d** (install it: `npm install react-force-graph-2d`).
It's a React wrapper around d3-force — declarative API, same physics engine.
**Layout:**
- Center node = seed idea (large, white)
- Ring 1 (radius ~150px) = Wave 1 ideas (orange)
- Ring 2 (radius ~300px) = Wave 2 ideas (blue)
- Ring 3 (radius ~450px) = Wave 3 ideas (purple)
- Use d3.forceRadial for ring constraint
- Dismissed nodes = opacity 0.3, smaller
- Converted nodes = green border + icon
- Click node = side panel with details + 3 action buttons
**Side Panel (when clicking a node):**
- Show: title, description, connection to seed, related notes
- 3 buttons: Dig Deeper, Create Note, Dismiss
- Uses existing shadcn Sheet or Dialog component
**Sidebar Integration:**
- Add "Brainstorms" section in the existing sidebar
- List sessions with preview (seed idea + count)
- Click to reopen saved canvas state
**Entry Points:**
1. From a note: Brainstorm button in note toolbar/editor
2. From sidebar: "+ New Brainstorm" button
### AI Prompt for Wave Generation
The LLM prompt should generate 3 waves:
- **Wave 1 — Variations**: Direct variations/expansions of the seed (sous-aspects, reformulations, variations)
- **Wave 2 — Analogies**: Cross-domain analogies (autres domaines, biologie, technologie, etc.)
- **Wave 3 — Disruptions**: Inversions, provocations, ideas that challenge assumptions
Each idea should have:
- title (short)
- description (1-2 sentences)
- connectionToSeed (how it relates to the seed)
- noveltyScore (1-10)
### Implementation Order
1. Prisma schema + migration
2. API routes (start with /wave and /convert)
3. Brainstorm canvas component (react-force-graph-2d)
4. Sidebar integration
5. Note toolbar button
6. Export functionality

View File

@@ -1,36 +0,0 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { LABEL_COLORS } from '@/lib/types';
import { auth } from '@/auth';
export const dynamic = 'force-dynamic';
export async function POST() {
try {
const session = await auth()
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const labels = await prisma.label.findMany();
const colors = Object.keys(LABEL_COLORS).filter(c => c !== 'gray'); // Exclude gray to force colors
const updates = labels.map((label: any) => {
const randomColor = colors[Math.floor(Math.random() * colors.length)];
return prisma.label.update({
where: { id: label.id },
data: { color: randomColor }
});
});
await prisma.$transaction(updates);
return NextResponse.json({
success: true,
updated: updates.length,
message: "All labels have been assigned a random non-gray color."
});
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View File

@@ -1,55 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { auth } from '@/auth'
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
try {
const config = await getSystemConfig()
const provider = getChatProvider(config)
const testMessage = 'Réponds en exactement 3 mots : quel est ton nom ?'
const startTime = Date.now()
const response = await provider.generateText(testMessage)
const endTime = Date.now()
if (!response || response.trim().length === 0) {
return NextResponse.json(
{
success: false,
error: 'No response from chat provider',
model: config.AI_MODEL_CHAT || 'granite4:latest',
},
{ status: 500 }
)
}
return NextResponse.json({
success: true,
model: config.AI_MODEL_CHAT || 'granite4:latest',
chatResponse: response.trim(),
responseTime: endTime - startTime,
})
} catch (error: any) {
const config = await getSystemConfig()
return NextResponse.json(
{
success: false,
error: error.message || 'Unknown error',
model: config.AI_MODEL_CHAT || 'granite4:latest',
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,
},
{ status: 500 }
)
}
}

View File

@@ -1,99 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getEmbeddingsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { auth } from '@/auth'
function getProviderDetails(config: Record<string, string>, providerType: string) {
const provider = providerType.toLowerCase()
switch (provider) {
case 'ollama':
return {
provider: 'Ollama',
baseUrl: config.OLLAMA_BASE_URL || 'http://localhost:11434',
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'
}
case 'openai':
return {
provider: 'OpenAI',
baseUrl: 'https://api.openai.com/v1',
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'
}
case 'custom':
return {
provider: 'Custom OpenAI',
baseUrl: config.CUSTOM_OPENAI_BASE_URL || 'Not configured',
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'
}
default:
return {
provider: provider,
baseUrl: 'unknown',
model: config.AI_MODEL_EMBEDDING || 'unknown'
}
}
}
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
try {
const config = await getSystemConfig()
const provider = getEmbeddingsProvider(config)
const testText = 'test'
const startTime = Date.now()
const embeddings = await provider.getEmbeddings(testText)
const endTime = Date.now()
if (!embeddings || embeddings.length === 0) {
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
const details = getProviderDetails(config, providerType)
return NextResponse.json(
{
success: false,
error: 'No embeddings returned',
provider: providerType,
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
details
},
{ status: 500 }
)
}
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
const details = getProviderDetails(config, providerType)
return NextResponse.json({
success: true,
provider: providerType,
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
embeddingLength: embeddings.length,
firstValues: embeddings.slice(0, 5),
responseTime: endTime - startTime,
details
})
} catch (error: any) {
const config = await getSystemConfig()
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
const details = getProviderDetails(config, providerType)
return NextResponse.json(
{
success: false,
error: error.message || 'Unknown error',
provider: providerType,
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
details,
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
},
{ status: 500 }
)
}
}

View File

@@ -1,58 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getTagsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { auth } from '@/auth'
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
try {
const config = await getSystemConfig()
const provider = getTagsProvider(config)
const testContent = "This is a test note about artificial intelligence and machine learning. It contains keywords like AI, ML, neural networks, and deep learning."
const startTime = Date.now()
const tags = await provider.generateTags(testContent)
const endTime = Date.now()
if (!tags || tags.length === 0) {
return NextResponse.json(
{
success: false,
error: 'No tags generated',
provider: config.AI_PROVIDER_TAGS || 'ollama',
model: config.AI_MODEL_TAGS || 'granite4:latest'
},
{ status: 500 }
)
}
return NextResponse.json({
success: true,
provider: config.AI_PROVIDER_TAGS || 'ollama',
model: config.AI_MODEL_TAGS || 'granite4:latest',
tags: tags,
responseTime: endTime - startTime
})
} catch (error: any) {
const config = await getSystemConfig()
return NextResponse.json(
{
success: false,
error: error.message || 'Unknown error',
provider: config.AI_PROVIDER_TAGS || 'ollama',
model: config.AI_MODEL_TAGS || 'granite4:latest',
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
},
{ status: 500 }
)
}
}

View File

@@ -94,36 +94,10 @@
-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);
}
@utility editor-body {
font-size: var(--editor-body-size, 16px);
}
@@ -137,14 +111,6 @@ html.dark .memento-paper-texture {
background-color: var(--background);
}
.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);
@@ -164,12 +130,6 @@ html:not(.dark) .memento-active-nav {
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);
}
@@ -188,41 +148,6 @@ html:not(.dark) .memento-active-nav {
--ai-accent: #ACB995;
}
/* 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;
@@ -254,16 +179,6 @@ html.dark .sidebar-inbox-item.active {
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;
@@ -274,31 +189,12 @@ html.dark .sidebar-inbox-item.active {
opacity: 0.7;
}
/* Persian/Arabic: avoid uppercase + wide tracking on mixed scripts; bidi class set in TSX */
.note-date-badge.note-date-badge--locale-rtl {
text-transform: none;
letter-spacing: 0.08em;
unicode-bidi: isolate;
}
/* 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);
@@ -1125,36 +1021,6 @@ html.font-system * {
pointer-events: none !important;
}
/* ============================================
Muuri Grid Styles for Drag & Drop
============================================ */
.muuri-grid {
position: relative;
}
/* Note: Width is controlled by Tailwind classes (w-1/2, w-1/3, w-full, etc.) */
.muuri-item {
position: absolute;
/* width: 100%; REMOVED - Don't override Tailwind size classes */
}
.muuri-item.muuri-item-dragging {
z-index: 3;
}
.muuri-item.muuri-item-releasing {
z-index: 2;
}
.muuri-item.muuri-item-hidden {
z-index: 0;
}
/* Ensure note cards work properly with Muuri */
.muuri-item>* {
width: 100%;
}
/* Force URLs/links to render LTR even in RTL mode */
[dir="rtl"] .prose a {
direction: ltr;

View File

@@ -1,386 +0,0 @@
'use client'
import React, { useCallback, useMemo, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import { BrainstormSession, BrainstormIdea } from '@/types/brainstorm'
import {
useExpandIdea,
useDismissIdea,
useConvertIdea,
useExportBrainstorm,
brainstormQuotaMessageKey,
} from '@/hooks/use-brainstorm'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Sparkles, X, FileText, Download } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
ssr: false,
})
interface GraphNode {
id: string
name: string
val: number
color: string
borderColor: string
wave: number
status: string
idea: BrainstormIdea
x?: number
y?: number
__bckgDimensions?: [number, number]
}
interface GraphLink {
source: string | GraphNode
target: string | GraphNode
color: string
}
const WAVE_COLORS: Record<number, string> = {
0: '#ffffff',
1: '#f97316',
2: '#3b82f6',
3: '#a855f7',
}
const WAVE_BORDER: Record<number, string> = {
0: '#e5e5e5',
1: '#ea580c',
2: '#2563eb',
3: '#9333ea',
}
const STATUS_ALPHA: Record<string, number> = {
active: 1,
dismissed: 0.25,
converted: 0.9,
}
interface BrainstormCanvasProps {
session: BrainstormSession
}
export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
const { t } = useLanguage()
const fgRef = useRef<any>(null)
const [selectedIdea, setSelectedIdea] = useState<BrainstormIdea | null>(null)
const [isSheetOpen, setIsSheetOpen] = useState(false)
const expandIdea = useExpandIdea(session.id)
const dismissIdea = useDismissIdea(session.id)
const convertIdea = useConvertIdea(session.id)
const exportBrainstorm = useExportBrainstorm(session.id)
const { graphData } = useMemo(() => {
const nodes: GraphNode[] = []
const links: GraphLink[] = []
nodes.push({
id: 'seed',
name: session.seedIdea.length > 30 ? session.seedIdea.slice(0, 30) + '...' : session.seedIdea,
val: 25,
color: WAVE_COLORS[0],
borderColor: WAVE_BORDER[0],
wave: 0,
status: 'active',
idea: {
id: 'seed',
sessionId: session.id,
waveNumber: 0,
title: session.seedIdea,
description: t('brainstorm.originalSeedDescription'),
connectionToSeed: null,
noveltyScore: null,
parentIdeaId: null,
convertedToNoteId: null,
relatedNoteIds: null,
status: 'active',
positionX: null,
positionY: null,
createdAt: session.createdAt,
} as BrainstormIdea,
})
for (const idea of session.ideas) {
const parentNode = idea.parentIdeaId
? idea.parentIdeaId
: 'seed'
nodes.push({
id: idea.id,
name: idea.title,
val: idea.status === 'dismissed' ? 8 : 15,
color: WAVE_COLORS[idea.waveNumber] || WAVE_COLORS[3],
borderColor: idea.status === 'converted' ? '#22c55e' : (WAVE_BORDER[idea.waveNumber] || WAVE_BORDER[3]),
wave: idea.waveNumber,
status: idea.status,
idea,
})
const linkColor = WAVE_COLORS[idea.waveNumber] || WAVE_COLORS[3]
links.push({
source: parentNode,
target: idea.id,
color: idea.status === 'dismissed' ? '#444444' : linkColor,
})
}
return { graphData: { nodes, links } }
}, [session, t])
const handleNodeClick = useCallback((node: any) => {
setSelectedIdea(node.idea)
setIsSheetOpen(true)
}, [])
const handleExpand = useCallback(async () => {
if (!selectedIdea || selectedIdea.id === 'seed') return
try {
await expandIdea.mutateAsync({ ideaId: selectedIdea.id })
toast.success(t('brainstorm.toastExpandSuccess'))
} catch (err: unknown) {
const quotaKey = brainstormQuotaMessageKey(err)
toast.error(quotaKey ? t(quotaKey) : (err instanceof Error ? err.message : t('brainstorm.toastExpandFailed')))
}
}, [selectedIdea, expandIdea, t])
const handleDismiss = useCallback(async () => {
if (!selectedIdea || selectedIdea.id === 'seed') return
try {
await dismissIdea.mutateAsync(selectedIdea.id)
setIsSheetOpen(false)
setSelectedIdea(null)
toast.success(t('brainstorm.toastDismissSuccess'))
} catch (err: any) {
toast.error(err.message || t('brainstorm.toastDismissFailed'))
}
}, [selectedIdea, dismissIdea, t])
const handleConvert = useCallback(async () => {
if (!selectedIdea || selectedIdea.id === 'seed') return
try {
await convertIdea.mutateAsync(selectedIdea.id)
toast.success(t('brainstorm.toastConvertSuccess'))
} catch (err: any) {
toast.error(err.message || t('brainstorm.toastConvertFailed'))
}
}, [selectedIdea, convertIdea, t])
const handleExport = useCallback(async () => {
try {
const note = await exportBrainstorm.mutateAsync()
toast.success(t('brainstorm.toastExportNoteSuccess'))
} catch (err: any) {
toast.error(err.message || t('brainstorm.toastExportFailed'))
}
}, [exportBrainstorm, t])
const paintNode = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
const label = node.name
const fontSize = Math.max(12 / globalScale, 4)
ctx.font = `${fontSize}px Sans-Serif`
const textWidth = ctx.measureText(label).width
const nodeSize = node.val
const bgDimensions: [number, number] = [textWidth + nodeSize * 0.8, nodeSize * 1.2]
node.__bckgDimensions = bgDimensions
const alpha = STATUS_ALPHA[node.status] || 1
ctx.globalAlpha = alpha
ctx.fillStyle = node.color
ctx.strokeStyle = node.borderColor
ctx.lineWidth = node.status === 'converted' ? 3 / globalScale : 1.5 / globalScale
ctx.beginPath()
ctx.roundRect(
node.x! - bgDimensions[0] / 2,
node.y! - bgDimensions[1] / 2,
bgDimensions[0],
bgDimensions[1],
nodeSize * 0.3
)
ctx.fill()
ctx.stroke()
ctx.fillStyle = node.wave === 0 ? '#000000' : '#ffffff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(label, node.x!, node.y!)
if (node.status === 'converted') {
ctx.fillStyle = '#22c55e'
ctx.font = `${fontSize * 1.2}px Sans-Serif`
ctx.fillText('✓', node.x! + bgDimensions[0] / 2 - fontSize * 0.5, node.y! - bgDimensions[1] / 2 + fontSize * 0.5)
}
ctx.globalAlpha = 1
}, [])
const waveLegend = useMemo(
() => [
{ label: t('brainstorm.legendSeed'), color: WAVE_COLORS[0] },
{ label: t('brainstorm.legendVariations'), color: WAVE_COLORS[1] },
{ label: t('brainstorm.legendAnalogies'), color: WAVE_COLORS[2] },
{ label: t('brainstorm.legendDisruptions'), color: WAVE_COLORS[3] },
],
[t]
)
return (
<div className="relative w-full h-full bg-zinc-950 rounded-xl overflow-hidden">
<ForceGraph2D
ref={fgRef}
graphData={graphData}
nodeCanvasObject={paintNode}
nodePointerAreaPaint={(node: any, color: string, ctx: CanvasRenderingContext2D) => {
const dims = node.__bckgDimensions
if (!dims) return
ctx.fillStyle = color
ctx.beginPath()
ctx.roundRect(node.x! - dims[0] / 2, node.y! - dims[1] / 2, dims[0], dims[1], (node.val || 10) * 0.3)
ctx.fill()
}}
onNodeClick={handleNodeClick}
linkColor={(link: any) => link.color}
linkWidth={1}
linkDirectionalArrowLength={3}
linkDirectionalArrowRelPos={1}
backgroundColor="#09090b"
nodeVal={(node: any) => node.val}
cooldownTicks={100}
enableNodeDrag={true}
enableZoomInteraction={true}
enablePanInteraction={true}
warmupTicks={50}
/>
<div className="absolute top-4 left-4 flex flex-col gap-1.5">
{waveLegend.map(w => (
<div key={w.label} className="flex items-center gap-2 text-xs text-zinc-400">
<div className="w-3 h-3 rounded-sm border border-zinc-600" style={{ backgroundColor: w.color }} />
<span>{w.label}</span>
</div>
))}
</div>
<div className="absolute top-4 right-4">
<Button
size="sm"
variant="outline"
className="bg-zinc-800 border-zinc-700 text-zinc-300 hover:bg-zinc-700 hover:text-white"
onClick={handleExport}
disabled={exportBrainstorm.isPending}
>
<Download size={14} className="mr-1" />
{exportBrainstorm.isPending ? t('brainstorm.exporting') : t('brainstorm.export')}
</Button>
</div>
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetContent className="bg-zinc-900 border-zinc-800 text-white w-96">
{selectedIdea && (
<>
<SheetHeader>
<SheetTitle className="text-white text-left">
{selectedIdea.id === 'seed' ? session.seedIdea : selectedIdea.title}
</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-4">
{selectedIdea.description && selectedIdea.id !== 'seed' && (
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailDescription')}</p>
<p className="text-sm text-zinc-300">{selectedIdea.description}</p>
</div>
)}
{selectedIdea.connectionToSeed && (
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailConnection')}</p>
<p className="text-sm text-zinc-300">{selectedIdea.connectionToSeed}</p>
</div>
)}
{(selectedIdea.noveltyScore != null && selectedIdea.noveltyScore > 0) && (
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailNovelty')}</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-orange-500 via-brand-accent to-purple-500 rounded-full"
style={{ width: `${Math.min(100, Math.max(0, selectedIdea.noveltyScore * 10))}%` }}
/>
</div>
<span className="text-sm text-zinc-400">{selectedIdea.noveltyScore}/10</span>
</div>
</div>
)}
{selectedIdea.waveNumber > 0 && (
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailWave')}</p>
<span
className="inline-block px-2 py-0.5 rounded text-xs font-medium"
style={{
backgroundColor: WAVE_COLORS[selectedIdea.waveNumber] + '30',
color: WAVE_COLORS[selectedIdea.waveNumber],
}}
>
{selectedIdea.waveNumber === 1
? t('brainstorm.waveFlavorVariation')
: selectedIdea.waveNumber === 2
? t('brainstorm.waveFlavorAnalogy')
: t('brainstorm.waveFlavorDisruption')}
</span>
</div>
)}
{selectedIdea.status === 'converted' && (
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p className="text-xs text-green-400 flex items-center gap-1">
<FileText size={12} />
{t('brainstorm.convertedToNoteStatus')}
</p>
</div>
)}
{selectedIdea.id !== 'seed' && selectedIdea.status === 'active' && (
<div className="flex flex-col gap-2 pt-4 border-t border-zinc-800">
<Button
onClick={handleExpand}
disabled={expandIdea.isPending}
className="w-full bg-orange-600 hover:bg-orange-700 text-white"
>
<Sparkles size={14} className="mr-1" />
{expandIdea.isPending ? t('brainstorm.deepening') : t('brainstorm.deepen')}
</Button>
<Button
onClick={handleConvert}
disabled={convertIdea.isPending}
className="w-full bg-brand-accent hover:bg-blue-700 text-white"
>
<FileText size={14} className="mr-1" />
{convertIdea.isPending ? t('brainstorm.converting') : t('brainstorm.extract')}
</Button>
<Button
onClick={handleDismiss}
disabled={dismissIdea.isPending}
variant="outline"
className="w-full border-zinc-700 text-zinc-400 hover:text-white hover:bg-zinc-800"
>
<X size={14} className="mr-1" />
{t('brainstorm.dismiss')}
</Button>
</div>
)}
</div>
</>
)}
</SheetContent>
</Sheet>
</div>
)
}

View File

@@ -1,84 +0,0 @@
'use client'
import React, { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Sparkles } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
interface BrainstormCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (seedIdea: string) => void
isLoading?: boolean
}
export function BrainstormCreateDialog({
open,
onOpenChange,
onSubmit,
isLoading,
}: BrainstormCreateDialogProps) {
const { t } = useLanguage()
const [seedIdea, setSeedIdea] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!seedIdea.trim()) return
onSubmit(seedIdea.trim())
setSeedIdea('')
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-white dark:bg-zinc-900 border-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles size={18} className="text-orange-500" />
{t('brainstorm.newBrainstorm')}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider mb-1 block">
{t('brainstorm.seedLabel')}
</label>
<textarea
value={seedIdea}
onChange={(e) => setSeedIdea(e.target.value)}
placeholder={t('brainstorm.ideaPromptDetailed')}
className="w-full h-28 px-3 py-2 text-sm border border-border rounded-lg bg-transparent focus:outline-none focus:ring-2 focus:ring-orange-500/30 resize-none"
autoFocus
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t('brainstorm.cancel')}
</Button>
<Button
type="submit"
disabled={!seedIdea.trim() || isLoading}
className="bg-orange-600 hover:bg-orange-700 text-white"
>
{isLoading ? (
<>
<Sparkles size={14} className="mr-1 animate-spin" />
{t('brainstorm.generating')}
</>
) : (
<>
<Sparkles size={14} className="mr-1" />
{t('brainstorm.startBrainstorm')}
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,210 +0,0 @@
'use client'
import React, { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { UserPlus, Link, Mail, Check, Copy } from 'lucide-react'
interface InviteDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onInviteByEmail: (email: string, role: 'editor' | 'viewer') => Promise<any>
onInviteByLink: () => Promise<any>
seedIdea: string
isLoading: boolean
t: (key: string) => string | undefined
}
export function InviteDialog({
open,
onOpenChange,
onInviteByEmail,
onInviteByLink,
seedIdea,
isLoading,
t,
}: InviteDialogProps) {
const [tab, setTab] = useState<'email' | 'link'>('email')
const [email, setEmail] = useState('')
const [role, setRole] = useState<'editor' | 'viewer'>('editor')
const [linkCopied, setLinkCopied] = useState(false)
const [emailSent, setEmailSent] = useState(false)
const [localLoading, setLocalLoading] = useState(false)
const handleEmailInvite = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
setLocalLoading(true)
try {
const res = await onInviteByEmail(email.trim(), role)
if (res?.invitedUser) {
setEmailSent(true)
setTimeout(() => {
setEmailSent(false)
setEmail('')
}, 2000)
}
} finally {
setLocalLoading(false)
}
}
const handleLinkCopy = async () => {
setLocalLoading(true)
try {
const res = await onInviteByLink()
if (res?.inviteUrl) {
const url = window.location.origin + res.inviteUrl
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url)
} else {
const ta = document.createElement('textarea')
ta.value = url
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
setLinkCopied(true)
setTimeout(() => setLinkCopied(false), 3000)
}
} finally {
setLocalLoading(false)
}
}
const busy = isLoading || localLoading
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-white dark:bg-[#1A1A1A] border-border rounded-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<div className="w-7 h-7 rounded-lg bg-emerald-500/10 flex items-center justify-center">
<UserPlus size={14} className="text-emerald-500" />
</div>
<span className="font-serif">{t('brainstorm.inviteTitle') || 'Invite to brainstorm'}</span>
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs italic font-serif">
{seedIdea.length > 60 ? seedIdea.substring(0, 60) + '…' : seedIdea}
</DialogDescription>
</DialogHeader>
<div className="flex gap-1 p-1 bg-foreground/5 rounded-xl mt-2">
<button
onClick={() => setTab('email')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-[10px] font-bold uppercase tracking-[0.1em] transition-all ${
tab === 'email'
? 'bg-white dark:bg-[#2A2A2A] text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Mail size={12} />
{t('brainstorm.inviteByEmail') || 'Email'}
</button>
<button
onClick={() => setTab('link')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-[10px] font-bold uppercase tracking-[0.1em] transition-all ${
tab === 'link'
? 'bg-white dark:bg-[#2A2A2A] text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Link size={12} />
{t('brainstorm.inviteByLink') || 'Link'}
</button>
</div>
{tab === 'email' ? (
<form onSubmit={handleEmailInvite} className="space-y-4 mt-2">
<div>
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
{t('brainstorm.inviteEmailLabel') || 'Email address'}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('brainstorm.inviteEmailPlaceholder') || 'colleague@email.com'}
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/40 transition-all"
autoFocus
/>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
{t('brainstorm.inviteRoleLabel') || 'Role'}
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setRole('editor')}
className={`flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] border transition-all ${
role === 'editor'
? 'border-emerald-500/40 bg-emerald-500/5 text-emerald-600'
: 'border-border text-muted-foreground hover:border-emerald-500/20'
}`}
>
{t('brainstorm.roleEditor') || 'Editor'}
</button>
<button
type="button"
onClick={() => setRole('viewer')}
className={`flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] border transition-all ${
role === 'viewer'
? 'border-brand-accent/40 bg-brand-accent/5 text-brand-accent'
: 'border-border text-muted-foreground hover:border-brand-accent/20'
}`}
>
{t('brainstorm.roleViewer') || 'Viewer'}
</button>
</div>
</div>
<button
type="submit"
disabled={!email.trim() || busy}
className="w-full py-3 bg-emerald-500 hover:bg-emerald-600 text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center justify-center gap-1.5"
>
{emailSent ? (
<>
<Check size={12} />
{t('brainstorm.inviteSent') || 'Invitation sent!'}
</>
) : (
<>
<Mail size={12} />
{busy ? '...' : (t('brainstorm.sendInvite') || 'Send invitation')}
</>
)}
</button>
</form>
) : (
<div className="space-y-4 mt-2">
<p className="text-xs text-muted-foreground leading-relaxed">
{t('brainstorm.linkDescription') || 'Anyone with this link can join the brainstorm session.'}
</p>
<button
onClick={handleLinkCopy}
disabled={busy}
className="w-full py-3 bg-foreground/5 hover:bg-foreground/10 text-foreground text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl transition-all flex items-center justify-center gap-1.5 border border-border"
>
{linkCopied ? (
<>
<Check size={12} className="text-emerald-500" />
<span className="text-emerald-500">{t('brainstorm.linkCopied') || 'Link copied!'}</span>
</>
) : (
<>
<Copy size={12} />
{busy ? '...' : (t('brainstorm.copyLink') || 'Copy invite link')}
</>
)}
</button>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -1,98 +0,0 @@
'use client'
import React, { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Lightbulb } from 'lucide-react'
interface ManualIdeaDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (title: string, description?: string) => void
isLoading?: boolean
parentIdeaTitle?: string | null
t: (key: string) => string | undefined
}
export function ManualIdeaDialog({
open,
onOpenChange,
onSubmit,
isLoading,
parentIdeaTitle,
t,
}: ManualIdeaDialogProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) return
onSubmit(title.trim(), description.trim() || undefined)
setTitle('')
setDescription('')
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-white dark:bg-[#1A1A1A] border-border rounded-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<div className="w-7 h-7 rounded-lg bg-brand-accent/10 flex items-center justify-center">
<Lightbulb size={14} className="text-brand-accent" />
</div>
<span className="font-serif">{t('brainstorm.addIdea') || 'Add an idea'}</span>
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs">
{parentIdeaTitle
? `${t('brainstorm.respondsTo') || 'Responds to'} « ${parentIdeaTitle.length > 40 ? parentIdeaTitle.substring(0, 40) + '…' : parentIdeaTitle} »`
: (t('brainstorm.manualIdeaDesc') || 'Share your idea with the brainstorm canvas')
}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
<div>
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
{t('brainstorm.manualIdeaTitle') || 'Title'}
</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('brainstorm.manualIdeaTitlePlaceholder') || 'Your idea in a few words...'}
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/40 transition-all font-serif"
autoFocus
/>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
{t('brainstorm.manualIdeaDescLabel') || 'Description (optional)'}
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('brainstorm.manualIdeaDescPlaceholder') || 'Elaborate on your idea...'}
className="w-full h-24 px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/40 resize-none transition-all"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => onOpenChange(false)}
className="px-4 py-2 text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground hover:text-foreground rounded-xl hover:bg-foreground/5 transition-all"
>
{t('brainstorm.cancel') || 'Cancel'}
</button>
<button
type="submit"
disabled={!title.trim() || isLoading}
className="px-5 py-2.5 bg-brand-accent hover:bg-brand-accent text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Lightbulb size={12} />
{isLoading ? (t('brainstorm.adding') || 'Adding...') : (t('brainstorm.addIdea') || 'Add idea')}
</button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,229 +0,0 @@
'use client'
import { useState } from 'react'
import { Button } from './ui/button'
import { Input } from './ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from './ui/dialog'
import { Settings, Plus, Palette, Trash2, Sparkles } from 'lucide-react'
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useNotebooks } from '@/context/notebooks-context'
import { useLanguage } from '@/lib/i18n'
import { useRefresh } from '@/lib/use-refresh'
export interface LabelManagementDialogProps {
open?: boolean
onOpenChange?: (open: boolean) => void
}
export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
const { open, onOpenChange } = props
const { labels, isLoading: loading, addLabel, updateLabel, deleteLabel } = useNotebooks()
const { t, language } = useLanguage()
const { refreshLabels } = useRefresh()
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
const [newLabel, setNewLabel] = useState('')
const [editingColorId, setEditingColorId] = useState<string | null>(null)
const controlled = open !== undefined && onOpenChange !== undefined
const handleAddLabel = async () => {
const trimmed = newLabel.trim()
if (trimmed) {
try {
await addLabel(trimmed, 'gray')
refreshLabels()
setNewLabel('')
} catch (error) {
console.error('Failed to add label:', error)
}
}
}
const handleDeleteLabel = async (id: string) => {
try {
const labelToDelete = labels.find(l => l.id === id)
await deleteLabel(id)
refreshLabels()
if (labelToDelete) {
window.dispatchEvent(new CustomEvent('label-deleted', { detail: { name: labelToDelete.name } }))
}
setConfirmDeleteId(null)
} catch (error) {
console.error('Failed to delete label:', error)
}
}
const handleChangeColor = async (id: string, color: LabelColorName) => {
try {
await updateLabel(id, { color })
refreshLabels()
setEditingColorId(null)
} catch (error) {
console.error('Failed to update label color:', error)
}
}
const dialogContent = (
<DialogContent
className="max-w-md"
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
onInteractOutside={(event) => {
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>{t('labels.editLabels')}</DialogTitle>
<DialogDescription>
{t('labels.editLabelsDescription')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Input
placeholder={t('labels.newLabelPlaceholder')}
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddLabel()
}
}}
/>
<Button onClick={handleAddLabel} size="icon">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="max-h-[60vh] overflow-y-auto space-y-2">
{loading ? (
<p className="text-sm text-muted-foreground">{t('labels.loading')}</p>
) : labels.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('labels.noLabelsFound')}</p>
) : (
labels.map((label) => {
const colorClasses = LABEL_COLORS[label.color]
const isEditing = editingColorId === label.id
const isAI = label.type === 'ai'
return (
<div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-accent/50 group">
<div className="flex items-center gap-3 flex-1 relative">
{isAI ? (
<Sparkles className={cn("h-4 w-4", "text-brand-accent")} />
) : (
<div className={cn("h-3 w-3 rounded-full", colorClasses.bg)} />
)}
<span className="font-medium text-sm">{label.name}</span>
{isAI && (
<span className="text-[8px] px-1.5 py-0.5 rounded-full bg-brand-accent/10 text-brand-accent font-bold uppercase">IA</span>
)}
{isEditing && (
<div className="absolute z-20 top-8 left-0 bg-popover text-popover-foreground border border-border rounded-lg shadow-xl p-3 animate-in fade-in zoom-in-95 w-48">
<div className="grid grid-cols-5 gap-2">
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
const classes = LABEL_COLORS[color]
return (
<button
key={color}
className={cn(
'h-7 w-7 rounded-full border-2 transition-all hover:scale-110',
classes.bg,
label.color === color ? 'border-foreground dark:border-foreground ring-2 ring-offset-1' : 'border-transparent'
)}
onClick={() => handleChangeColor(label.id, color)}
title={color}
/>
)
})}
</div>
</div>
)}
</div>
{confirmDeleteId === label.id ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-500 font-medium">{t('labels.confirmDeleteShort') || 'Confirmer ?'}</span>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setConfirmDeleteId(null)}>
{t('common.cancel') || 'Annuler'}
</Button>
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={() => handleDeleteLabel(label.id)}>
{t('common.delete') || 'Supprimer'}
</Button>
</div>
) : (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => setEditingColorId(isEditing ? null : label.id)}
title={t('labels.changeColor')}
>
<Palette className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => setConfirmDeleteId(label.id)}
title={t('labels.deleteTooltip')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
)
})
)}
</div>
</div>
</DialogContent>
)
if (controlled) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{dialogContent}
</Dialog>
)
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" title={t('labels.manage')}>
<Settings className="h-5 w-5" />
</Button>
</DialogTrigger>
{dialogContent}
</Dialog>
)
}

View File

@@ -1,992 +0,0 @@
'use client'
import { useState, useEffect, useRef, useCallback, useTransition, useMemo } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor, NoteType } from '@/lib/types'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { LabelBadge } from '@/components/label-badge'
import { EditorConnectionsSection } from '@/components/editor-connections-section'
import { FusionModal } from '@/components/fusion-modal'
import { ComparisonModal } from '@/components/comparison-modal'
import { NoteTypeSelector } from '@/components/note-type-selector'
import { RichTextEditor } from '@/components/rich-text-editor'
import { useLanguage } from '@/lib/i18n'
import { cn, extractImagesFromHTML } from '@/lib/utils'
import {
updateNote,
toggleArchive,
deleteNote,
createNote,
commitNoteHistory,
} from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import {
Pin,
Palette,
Archive,
ArchiveRestore,
Trash2,
ImageIcon,
Link as LinkIcon,
X,
Plus,
CheckSquare,
Eye,
Sparkles,
Loader2,
Check,
RotateCcw,
History,
GitCommitHorizontal,
} from 'lucide-react'
import { toast } from 'sonner'
import { MarkdownContent } from '@/components/markdown-content'
import { EditorImages } from '@/components/editor-images'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { GhostTags } from '@/components/ghost-tags'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
import { TitleSuggestions } from '@/components/title-suggestions'
import { useNotebooks } from '@/context/notebooks-context'
import { useRefresh } from '@/lib/use-refresh'
import { ContextualAIChat } from '@/components/contextual-ai-chat'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { useSession } from 'next-auth/react'
import { getAISettings } from '@/app/actions/ai-settings'
interface NoteInlineEditorProps {
note: Note
onDelete?: (noteId: string) => void
onArchive?: (noteId: string) => void
onChange?: (noteId: string, fields: Partial<Note>) => void
onOpenHistory?: (note: Note) => void
onEnableHistory?: (noteId: string) => Promise<void>
noteHistoryMode?: 'manual' | 'auto'
colorKey: NoteColor
/** If true and the note is a Markdown note, open directly in preview mode */
defaultPreviewMode?: boolean
}
function getDateLocale(language: string) {
if (language === 'fr') return fr;
if (language === 'fa') return require('date-fns/locale').faIR;
return enUS;
}
/** Save content via REST API (not Server Action) to avoid Next.js implicit router re-renders */
async function saveInline(
id: string,
data: { title?: string | null; content?: string; checkItems?: CheckItem[]; isMarkdown?: boolean; type?: NoteType }
) {
await fetch(`/api/notes/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
export function NoteInlineEditor({
note,
onDelete,
onArchive,
onChange,
onOpenHistory,
onEnableHistory,
noteHistoryMode = 'manual',
colorKey,
defaultPreviewMode = false,
}: NoteInlineEditorProps) {
const { t, language } = useLanguage()
const { data: session } = useSession()
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
useEffect(() => {
if (session?.user?.id) {
getAISettings(session.user.id).then((settings) => {
setAiAssistantEnabled(settings.paragraphRefactor !== false)
setAutoLabelingEnabled(settings.autoLabeling !== false)
setAutoSaveEnabled(settings.autoSave !== false)
}).catch(err => console.error("Failed to fetch AI settings", err))
}
}, [session?.user?.id])
const { labels: globalLabels, addLabel } = useNotebooks()
const [, startTransition] = useTransition()
const { refreshNotes } = useRefresh()
// ── Local edit state ──────────────────────────────────────────────────────
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content || '')
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
const [noteType, setNoteType] = useState<NoteType>(note.type)
const isMarkdown = noteType === 'markdown'
const allImages = useMemo(() => {
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
return Array.from(new Set([...(note.images || []), ...extracted]));
}, [note.images, content, noteType]);
const [showMarkdownPreview, setShowMarkdownPreview] = useState(
defaultPreviewMode && (note.isMarkdown || false)
)
const [isDirty, setIsDirty] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [dismissedTags, setDismissedTags] = useState<string[]>([])
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
const changeTitle = (t: string) => { setTitle(t); onChange?.(note.id, { title: t }) }
const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) }
const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) }
// Link dialog
const [linkUrl, setLinkUrl] = useState('')
const [showLinkInput, setShowLinkInput] = useState(false)
const [isAddingLink, setIsAddingLink] = useState(false)
// AI side panel
const [aiOpen, setAiOpen] = useState(false)
const [isProcessingAI, setIsProcessingAI] = useState(false)
// Undo after AI copilot applies content
const [previousContent, setPreviousContent] = useState<string | null>(null)
// Notebooks list (for copilot chat scope)
const { notebooks } = useNotebooks()
const fileInputRef = useRef<HTMLInputElement>(null)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const pendingRef = useRef({ title, content, checkItems, isMarkdown, noteType })
const noteIdRef = useRef(note.id)
// Title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
content: noteType !== 'checklist' ? content : '',
enabled: noteType !== 'checklist' && !title
})
// Keep pending ref in sync for unmount save
useEffect(() => {
pendingRef.current = { title, content, checkItems, isMarkdown, noteType }
}, [title, content, checkItems, isMarkdown, noteType])
// ── Sync when selected note switches ─────────────────────────────────────
useEffect(() => {
// Flush unsaved changes for the PREVIOUS note before switching
if (isDirty && noteIdRef.current !== note.id) {
const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: nt === 'checklist' ? ci : undefined,
type: nt,
isMarkdown: nt === 'markdown',
}).catch(() => {})
}
noteIdRef.current = note.id
setTitle(note.title || '')
setContent(note.content || '')
setCheckItems(note.checkItems || [])
setNoteType(note.type)
setShowMarkdownPreview(defaultPreviewMode && (note.type === 'markdown'))
setIsDirty(false)
setDismissedTitleSuggestions(false)
clearTimeout(saveTimerRef.current)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [note.id])
// ── Auto-save (1.5 s debounce, skipContentTimestamp) ─────────────────────
const scheduleSave = useCallback(() => {
if (!autoSaveEnabled) {
setIsDirty(true)
return
}
setIsDirty(true)
clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(async () => {
const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current
setIsSaving(true)
try {
await saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: nt === 'checklist' ? ci : undefined,
type: nt,
isMarkdown: nt === 'markdown',
})
setIsDirty(false)
} catch {
// silent — retry on next keystroke
} finally {
setIsSaving(false)
}
}, 1500)
}, [noteType])
// Flush on unmount
useEffect(() => {
return () => {
clearTimeout(saveTimerRef.current)
const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: nt === 'checklist' ? ci : undefined,
type: nt,
isMarkdown: nt === 'markdown',
}).catch(() => {})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ── Auto-tagging ──────────────────────────────────────────────────────────
const { suggestions, isAnalyzing } = useAutoTagging({
content: noteType !== 'checklist' ? content : '',
notebookId: note.notebookId,
enabled: noteType !== 'checklist' && autoLabelingEnabled,
})
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter(
(s) => s?.tag && !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
)
const handleSelectGhostTag = async (tag: string) => {
const exists = (note.labels || []).some((l) => l.toLowerCase() === tag.toLowerCase())
if (!exists) {
const newLabels = [...(note.labels || []), tag]
// Optimistic UI — update sidebar immediately, no page refresh needed
onChange?.(note.id, { labels: newLabels })
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase())
if (!globalExists) {
try { await addLabel(tag) } catch {}
}
toast.success(t('ai.tagAdded', { tag }))
}
}
const fetchNotesByIds = async (noteIds: string[]) => {
const fetched = await Promise.all(noteIds.map(async (id) => {
try {
const res = await fetch(`/api/notes/${id}`)
if (!res.ok) return null
const data = await res.json()
return data.success && data.data ? data.data : null
} catch { return null }
}))
return fetched.filter((n: any) => n !== null) as Array<Partial<Note>>
}
const handleMergeNotes = async (noteIds: string[]) => {
setFusionNotes(await fetchNotesByIds(noteIds))
}
const handleCompareNotes = async (noteIds: string[]) => {
setComparisonNotes(await fetchNotesByIds(noteIds))
}
const handleConfirmFusion = async ({ title, content }: { title: string; content: string }, options: { archiveOriginals: boolean; keepAllTags: boolean; useLatestTitle: boolean; createBacklinks: boolean }) => {
await createNote({
title,
content,
labels: options.keepAllTags
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
: fusionNotes[0].labels || [],
color: fusionNotes[0].color,
type: 'markdown',
isMarkdown: true,
autoGenerated: true,
aiProvider: 'fusion',
notebookId: fusionNotes[0].notebookId ?? undefined
})
if (options.archiveOriginals) {
for (const n of fusionNotes) {
if (n.id) await updateNote(n.id, { isArchived: true })
}
}
toast.success(t('toast.notesFusionSuccess'))
setFusionNotes([])
refreshNotes(note?.notebookId)
}
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
const handleTogglePin = () => {
const prev = note.isPinned
startTransition(async () => {
onChange?.(note.id, { isPinned: !prev })
try {
await updateNote(note.id, { isPinned: !prev }, { skipRevalidation: true })
toast.success(prev ? t('notes.unpinned') : t('notes.pinned') )
} catch {
onChange?.(note.id, { isPinned: prev })
toast.error(t('general.error'))
}
})
}
const handleToggleArchive = () => {
startTransition(async () => {
onArchive?.(note.id)
try {
await toggleArchive(note.id, !note.isArchived)
refreshNotes(note?.notebookId)
} catch {
// Cannot easily revert since onArchive removes from list
toast.error(t('general.error'))
}
})
}
const handleColorChange = (color: string) => {
const prev = color
startTransition(async () => {
onChange?.(note.id, { color })
try {
await updateNote(note.id, { color }, { skipRevalidation: true })
} catch {
onChange?.(note.id, { color: prev })
toast.error(t('general.error'))
}
})
}
const handleDelete = () => {
if (!confirm(t('notes.confirmDelete'))) return
startTransition(async () => {
await deleteNote(note.id)
onDelete?.(note.id)
refreshNotes(note?.notebookId)
})
}
// ── Image upload ──────────────────────────────────────────────────────────
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
for (const file of Array.from(files)) {
try {
const url = await uploadImageFile(file)
const newImages = [...(note.images || []), url]
onChange?.(note.id, { images: newImages })
await updateNote(note.id, { images: newImages })
} catch {
toast.error(t('notes.uploadFailed', { filename: file.name }))
}
}
if (fileInputRef.current) fileInputRef.current.value = ''
}
const uploadImageFile = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/upload', { method: 'POST', body: formData })
if (!res.ok) throw new Error('Upload failed')
const data = await res.json()
return data.url
}
// Paste handler: upload clipboard images
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
if (noteType === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
if (!file) continue
try {
const url = await uploadImageFile(file)
const newImages = [...(note.images || []), url]
onChange?.(note.id, { images: newImages })
await updateNote(note.id, { images: newImages })
} catch {
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
}
}
}
}
document.addEventListener('paste', handlePaste, { capture: true })
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
}, [note.id, note.images, onChange, t])
const handleRemoveImage = async (index: number) => {
const newImages = (note.images || []).filter((_, i) => i !== index)
onChange?.(note.id, { images: newImages })
await updateNote(note.id, { images: newImages })
}
// ── Link ──────────────────────────────────────────────────────────────────
const handleAddLink = async () => {
if (!linkUrl) return
setIsAddingLink(true)
try {
const metadata = await fetchLinkMetadata(linkUrl)
const newLink = metadata || { url: linkUrl, title: linkUrl }
const newLinks = [...(note.links || []), newLink]
onChange?.(note.id, { links: newLinks })
await updateNote(note.id, { links: newLinks })
toast.success(t('notes.linkAdded'))
} catch {
toast.error(t('notes.linkAddFailed'))
} finally {
setLinkUrl('')
setShowLinkInput(false)
setIsAddingLink(false)
}
}
const handleRemoveLink = async (index: number) => {
const newLinks = (note.links || []).filter((_, i) => i !== index)
onChange?.(note.id, { links: newLinks })
await updateNote(note.id, { links: newLinks })
}
// ── Checklist helpers ─────────────────────────────────────────────────────
const handleToggleCheckItem = (id: string) => {
const updated = checkItems.map((ci) =>
ci.id === id ? { ...ci, checked: !ci.checked } : ci
)
setCheckItems(updated)
scheduleSave()
}
const handleUpdateCheckText = (id: string, text: string) => {
const updated = checkItems.map((ci) => (ci.id === id ? { ...ci, text } : ci))
setCheckItems(updated)
scheduleSave()
}
const handleAddCheckItem = () => {
const updated = [...checkItems, { id: Date.now().toString(), text: '', checked: false }]
setCheckItems(updated)
scheduleSave()
}
const handleRemoveCheckItem = (id: string) => {
const updated = checkItems.filter((ci) => ci.id !== id)
setCheckItems(updated)
scheduleSave()
}
const dateLocale = getDateLocale(language)
return (
<div className="flex h-full w-full overflow-hidden">
<div className="flex flex-1 min-w-0 flex-col overflow-hidden transition-all duration-300">
{/* ── Toolbar ───────────────────────────────────────────────── */}
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-1.5 gap-2">
{/* Left group: content tools */}
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon" className="h-8 w-8"
title={t('notes.addImage') }
onClick={() => fileInputRef.current?.click()}>
<ImageIcon className="h-4 w-4" />
</Button>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
<Button variant="ghost" size="icon" className="h-8 w-8"
title={t('notes.addLink') }
onClick={() => setShowLinkInput(!showLinkInput)}>
<LinkIcon className="h-4 w-4" />
</Button>
<NoteTypeSelector
value={noteType}
onChange={async (newType) => {
const oldType = noteType
// markdown → richtext: convert content to HTML first
if (oldType === 'markdown' && newType === 'richtext') {
try {
const res = await fetch('/api/ai/convert-markdown', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
})
if (res.ok) {
const { html } = await res.json()
setNoteType('richtext')
setShowMarkdownPreview(false)
setContent(html)
saveInline(note.id, {
type: 'richtext',
isMarkdown: false,
content: html,
}).catch(() => {})
onChange?.(note.id, { type: 'richtext', isMarkdown: false, content: html })
toast.success(t('notes.convertedToRichText') || 'Converted to rich text')
return
}
} catch {}
// Conversion failed — abort the type change
toast.error(t('notes.conversionFailed') || 'Conversion failed, staying in Markdown')
return
}
setNoteType(newType)
if (newType === 'markdown') setShowMarkdownPreview(true)
else setShowMarkdownPreview(false)
// Persist both type and isMarkdown immediately
saveInline(note.id, {
type: newType,
isMarkdown: newType === 'markdown',
}).catch(() => {})
onChange?.(note.id, { type: newType, isMarkdown: newType === 'markdown' })
}}
compact
/>
{noteType === 'markdown' && (
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? (t('notes.edit')) : (t('notes.preview'))}>
<Eye className="h-4 w-4" />
</Button>
)}
{noteType !== 'checklist' && aiAssistantEnabled && (
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!aiOpen)}
title={t('ai.aiNoteTitle')}>
{isProcessingAI
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Sparkles className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{t('ai.aiNoteTitle')}</span>
</Button>
)}
{previousContent !== null && (
<Button variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30 font-medium"
title={t('ai.undoAI') }
onClick={() => { changeContent(previousContent); setPreviousContent(null); scheduleSave(); toast.info(t('ai.undoApplied') ) }}>
<RotateCcw className="h-3.5 w-3.5" />
<span className="text-[11px]">{t('general.undo') || 'Annuler'}</span>
</Button>
)}
</div>
{/* Right group: meta actions + save indicator */}
<div className="flex items-center gap-1">
{note.historyEnabled && noteHistoryMode === 'manual' && (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-primary/70 hover:text-primary"
title={t('notes.commitVersion')}
onClick={() => {
startTransition(async () => {
try {
await commitNoteHistory(note.id)
toast.success(t('notes.versionSaved'))
} catch {
toast.error(t('general.error'))
}
})
}}
>
<GitCommitHorizontal className="h-3.5 w-3.5" />
</Button>
)}
<span className="me-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
{isSaving ? (
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
) : isDirty ? (
!autoSaveEnabled ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[11px] text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30"
onClick={() => {
setIsSaving(true)
saveInline(note.id, { title, content, checkItems, type: noteType, isMarkdown: showMarkdownPreview && noteType === 'markdown' })
.then(() => {
setIsSaving(false)
setIsDirty(false)
toast.success(t('notes.savedStatus'))
})
.catch(() => {
setIsSaving(false)
toast.error(t('general.error'))
})
}}
>
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 me-1.5" />
{t('notes.saveNow') || 'Enregistrer'}
</Button>
) : (
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> {t('notes.dirtyStatus')}</>
)
) : (
<><Check className="h-3 w-3 text-emerald-500" /> {t('notes.savedStatus')}</>
)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([name, cls]) => (
<button type="button" key={name}
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', cls.bg,
note.color === name ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
onClick={() => handleColorChange(name)} title={name} />
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.moreOptions')}>
<span className="text-base leading-none text-muted-foreground"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleToggleArchive}>
{note.isArchived
? <><ArchiveRestore className="h-4 w-4 me-2" />{t('notes.unarchive')}</>
: <><Archive className="h-4 w-4 me-2" />{t('notes.archive')}</>}
</DropdownMenuItem>
{onOpenHistory && (
<DropdownMenuItem
onClick={() => {
if (note.historyEnabled) {
onOpenHistory(note)
} else if (onEnableHistory) {
onEnableHistory(note.id).then(() => onOpenHistory({ ...note, historyEnabled: true }))
}
}}
>
<History className="h-4 w-4 me-2" />
{note.historyEnabled
? (t('notes.history') || 'Historique')
: (t('notes.enableHistory') || "Activer l'historique")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600 dark:text-red-400" onClick={handleDelete}>
<Trash2 className="h-4 w-4 me-2" />{t('notes.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* ── Link input bar (inline) ───────────────────────────────────────── */}
{showLinkInput && (
<div className="flex shrink-0 items-center gap-2 border-b border-border/30 bg-muted/30 px-4 py-2">
<input
type="url"
className="flex-1 rounded-md border border-border/60 bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
placeholder="https://..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddLink() }}
autoFocus
/>
<Button size="sm" disabled={!linkUrl || isAddingLink} onClick={handleAddLink}>
{isAddingLink ? <Loader2 className="h-4 w-4 animate-spin" /> : t('notes.add')}
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => { setShowLinkInput(false); setLinkUrl('') }}>
<X className="h-4 w-4" />
</Button>
</div>
)}
{/* ── Labels strip + AI suggestions — always visible outside scroll area ─ */}
{((note.labels?.length ?? 0) > 0 || filteredSuggestions.length > 0 || isAnalyzing) && (
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/20 px-8 py-2">
{/* Existing labels */}
{(note.labels ?? []).map((label) => (
<LabelBadge key={label} label={label} />
))}
{/* AI-suggested tags inline with labels */}
<GhostTags
suggestions={filteredSuggestions}
addedTags={note.labels || []}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={(tag) => setDismissedTags((p) => [...p, tag])}
/>
</div>
)}
{/* ── Scrollable editing area ── */}
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-5">
{/* Title */}
<div className="group relative flex items-start gap-2 shrink-0 mb-1">
<textarea
dir="auto"
rows={1}
className="flex-1 bg-transparent text-xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40 resize-none overflow-hidden min-h-[1.5em]"
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
value={title}
onChange={(e) => {
changeTitle(e.target.value);
scheduleSave();
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
}}
onFocus={(e) => {
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
}}
/>
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
<button type="button"
onClick={async (e) => {
e.preventDefault()
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
})
if (res.ok) {
const data = await res.json()
const suggested = data.title || data.suggestedTitle || ''
if (suggested) { changeTitle(suggested); scheduleSave() }
}
} catch { } finally { setIsProcessingAI(false) }
}}
disabled={isProcessingAI}
className="mt-1 shrink-0 rounded-md p-1 text-muted-foreground/40 opacity-0 transition-all hover:bg-muted hover:text-primary group-hover:opacity-100"
title={t('ai.suggestTitle')}
>
{isProcessingAI ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
</button>
)}
</div>
{/* Title Suggestions Dropdown / Inline list */}
{!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
<div className="mt-2 text-sm shrink-0">
<TitleSuggestions
suggestions={titleSuggestions}
onSelect={(selectedTitle) => { changeTitle(selectedTitle); scheduleSave() }}
onDismiss={() => setDismissedTitleSuggestions(true)}
/>
</div>
)}
{/* Images */}
{Array.isArray(note.images) && note.images.length > 0 && (
<div className="mt-4">
<EditorImages images={note.images} onRemove={handleRemoveImage} />
</div>
)}
{/* Link previews */}
{Array.isArray(note.links) && note.links.length > 0 && (
<div className="mt-4 flex flex-col gap-2">
{note.links.map((link, idx) => (
<div key={idx} className="group relative flex overflow-hidden rounded-xl border border-border/60 bg-background/60">
{link.imageUrl && (
<div className="h-auto w-24 shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="flex min-w-0 flex-col justify-center gap-0.5 p-3">
<p className="truncate text-sm font-medium">{link.title || link.url}</p>
{link.description && <p className="line-clamp-1 text-xs text-muted-foreground">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-[11px] text-primary hover:underline">
{(() => { try { return new URL(link.url).hostname } catch { return link.url } })()}
</a>
</div>
<button type="button"
className="absolute end-2 top-2 rounded-full bg-background/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/10"
onClick={() => handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{/* ── Text / Checklist content ───────────────────────────────────── */}
<div className="mt-4 flex flex-1 flex-col">
{noteType === 'richtext' ? (
<RichTextEditor
content={content}
onChange={setContent}
className="min-h-[200px]"
/>
) : noteType === 'text' || noteType === 'markdown' ? (
<div className="flex flex-1 flex-col">
{showMarkdownPreview && isMarkdown ? (
<div className="prose prose-sm dark:prose-invert max-w-none flex-1 rounded-lg border border-border/40 bg-muted/20 p-4">
<MarkdownContent content={content || ''} />
</div>
) : (
<textarea
dir="auto"
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={isMarkdown
? t('notes.takeNoteMarkdown')
: t('notes.takeNote')
}
value={content}
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
style={{ minHeight: '200px' }}
/>
)}
{/* Ghost tag suggestions are now shown in the top labels strip */}
</div>
) : (
/* Checklist */
<div className="space-y-1">
{checkItems.filter((ci) => !ci.checked).map((ci, index) => (
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 transition-colors hover:bg-muted/30">
<button type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/60 transition-colors hover:border-primary"
onClick={() => handleToggleCheckItem(ci.id)}
/>
<input
dir="auto"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/40"
value={ci.text}
placeholder={t('notes.listItem') }
onChange={(e) => handleUpdateCheckText(ci.id, e.target.value)}
/>
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
<X className="h-3.5 w-3.5 text-muted-foreground/60" />
</button>
</div>
))}
<button type="button"
className="flex items-center gap-2 px-2 py-1 text-sm text-muted-foreground/60 hover:text-foreground"
onClick={handleAddCheckItem}
>
<Plus className="h-4 w-4" />
{t('notes.addItem') }
</button>
{checkItems.filter((ci) => ci.checked).length > 0 && (
<div className="mt-3">
<p className="mb-1 px-2 text-xs text-muted-foreground/40 uppercase tracking-wider">
{t('notes.completedLabel')} ({checkItems.filter((ci) => ci.checked).length})
</p>
{checkItems.filter((ci) => ci.checked).map((ci) => (
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 text-muted-foreground transition-colors hover:bg-muted/20">
<button type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/40 bg-muted/40"
onClick={() => handleToggleCheckItem(ci.id)}
>
<CheckSquare className="h-3 w-3 opacity-60" />
</button>
<span dir="auto" className="flex-1 text-sm line-through">{ci.text}</span>
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
{/* ── Memory Echo Connections Section ── */}
<EditorConnectionsSection
noteId={note.id}
onOpenNote={(connNoteId) => {
window.open(`/?note=${connNoteId}`, '_blank')
}}
onCompareNotes={handleCompareNotes}
onMergeNotes={handleMergeNotes}
/>
{/* ── Footer ───────────────────────────────────────────────────────────── */}
<div className="shrink-0 border-t border-border/20 px-8 py-2">
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">
<span suppressHydrationWarning>{t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
<span>·</span>
<span suppressHydrationWarning>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
</div>
</div>
{/* Fusion Modal */}
{fusionNotes.length > 0 && (
<FusionModal
isOpen={fusionNotes.length > 0}
onClose={() => setFusionNotes([])}
notes={fusionNotes}
onConfirmFusion={handleConfirmFusion}
/>
)}
{/* Comparison Modal */}
{comparisonNotes.length > 0 && (
<ComparisonModal
isOpen={comparisonNotes.length > 0}
onClose={() => setComparisonNotes([])}
notes={comparisonNotes}
/>
)}
</div>
{/* ── AI Copilot Side Panel ── */}
{aiOpen && (
<ContextualAIChat
onClose={() => setAiOpen(false)}
noteTitle={title}
noteContent={content}
noteImages={allImages}
noteId={note.id}
onApplyToNote={(newContent) => {
const current = content
setPreviousContent(current)
changeContent(newContent)
scheduleSave()
toast.success(t('ai.appliedToNote') || 'Applied to note', {
action: {
label: t('general.undo') || 'Undo',
onClick: () => {
changeContent(current)
setPreviousContent(null)
scheduleSave()
}
}
})
}}
onUndoLastAction={previousContent !== null ? () => {
changeContent(previousContent)
setPreviousContent(null)
scheduleSave()
} : undefined}
lastActionApplied={previousContent !== null}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name, parentId: nb.parentId, trashedAt: nb.trashedAt }))}
diagramInsertFormat={noteType === 'richtext' ? 'html' : 'markdown'}
/>
)}
</div>
)
}

View File

@@ -1,28 +0,0 @@
'use client'
import { useLanguage } from '@/lib/i18n'
export function ProfilePageHeader() {
const { t } = useLanguage()
return (
<h1 className="text-3xl font-bold mb-8">{t('nav.accountSettings')}</h1>
)
}
export function AISettingsCard() {
const { t } = useLanguage()
return (
<>
<div className="flex items-center gap-2">
<span className="text-2xl"></span>
{t('nav.aiSettings')}
</div>
<p className="text-sm text-muted-foreground">
{t('nav.configureAI')}
</p>
<span>{t('nav.manageAISettings')}</span>
</>
)
}

View File

@@ -1,49 +0,0 @@
'use client';
import { Zap } from 'lucide-react';
import Link from 'next/link';
import { useLanguage } from '@/lib/i18n';
interface QuotaPaywallProps {
feature?: string;
onDismiss?: () => void;
}
export function QuotaPaywall({ feature: _feature, onDismiss }: QuotaPaywallProps) {
const { t } = useLanguage();
return (
<div className="rounded-xl border border-[#D4A373]/40 bg-[#D4A373]/5 p-5 space-y-3">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[#D4A373]/20">
<Zap className="h-4 w-4 text-[#D4A373]" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">
{t('billing.upgradeToPro')}
</p>
<p className="text-xs text-muted-foreground">
{t('billing.proDescription')}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Link
href="/settings/billing"
className="px-4 py-1.5 text-xs font-medium rounded-lg bg-[#D4A373] text-white hover:bg-[#C49363] transition-colors"
>
{t('billing.startCheckout')}
</Link>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{t('common.later') ?? 'Later'}
</button>
)}
</div>
</div>
);
}

View File

@@ -1,37 +0,0 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
type CardSizeMode = 'variable' | 'uniform'
export function useCardSizeMode(): CardSizeMode {
const [mode, setMode] = useState<CardSizeMode>('variable')
useEffect(() => {
// Check localStorage first (for immediate UI response)
const stored = localStorage.getItem('card-size-mode') as CardSizeMode | null
if (stored && (stored === 'variable' || stored === 'uniform')) {
setMode(stored)
}
// Listen for storage changes (when user changes setting in another tab)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'card-size-mode') {
const newMode = e.newValue as CardSizeMode | null
if (newMode && (newMode === 'variable' || newMode === 'uniform')) {
setMode(newMode)
}
}
}
window.addEventListener('storage', handleStorageChange)
return () => window.removeEventListener('storage', handleStorageChange)
}, [])
return mode
}
export function useIsUniformSize(): boolean {
const mode = useCardSizeMode()
return mode === 'uniform'
}

View File

@@ -32,7 +32,7 @@ async function readLocalImage(relativePath: string): Promise<Buffer | null> {
* Replaces local image references with cid: placeholders for inline attachments.
* Returns the HTML and a list of attachments to include.
*/
export async function markdownToEmailHtml(md: string, appUrl: string): Promise<{ html: string; attachments: EmailAttachment[] }> {
async function markdownToEmailHtml(md: string, appUrl: string): Promise<{ html: string; attachments: EmailAttachment[] }> {
let html = md
const attachments: EmailAttachment[] = []
const baseUrl = appUrl.replace(/\/$/, '')

View File

@@ -274,4 +274,4 @@ export function getChatProvider(config?: Record<string, string>): AIProvider {
}
// Export for use by admin settings form and deploy scripts
export { PROVIDER_DEFAULTS, getProviderConfigKeys };
export { getProviderConfigKeys };

View File

@@ -174,20 +174,6 @@ function getSecondaryProvider(lane: AiFeatureLane, config: Record<string, string
}
}
export function shouldLogAiFallback(): boolean {
return process.env.NODE_ENV !== 'production' || process.env.MEMENTO_AI_ROUTE_DEBUG === '1'
}
export function formatAiFallbackDebug(meta: {
lane: AiFeatureLane
primaryProvider: string
secondaryProvider: string
primaryStatus?: number
fallbackMs: number
}): string {
return JSON.stringify(meta)
}
function logFallbackSuccess(meta: {
lane: AiFeatureLane
primaryProvider: string
@@ -195,12 +181,13 @@ function logFallbackSuccess(meta: {
primaryStatus?: number
fallbackMs: number
}): void {
if (!shouldLogAiFallback()) return
console.debug('[ai-fallback]', formatAiFallbackDebug(meta))
if (meta.fallbackMs > FALLBACK_BUDGET_MS) {
console.warn(
`[ai-fallback] NFR-R1 budget exceeded: ${meta.fallbackMs.toFixed(1)}ms > ${FALLBACK_BUDGET_MS}ms`
)
if (process.env.NODE_ENV !== 'production' || process.env.MEMENTO_AI_ROUTE_DEBUG === '1') {
console.debug('[ai-fallback]', JSON.stringify(meta))
if (meta.fallbackMs > FALLBACK_BUDGET_MS) {
console.warn(
`[ai-fallback] NFR-R1 budget exceeded: ${meta.fallbackMs.toFixed(1)}ms > ${FALLBACK_BUDGET_MS}ms`
)
}
}
}

View File

@@ -47,21 +47,21 @@ async function resolveProviderForLane(
return { provider, usedByok, route };
}
export async function getChatProviderForBillingUser(
async function getChatProviderForBillingUser(
config: Record<string, string>,
billingUserId?: string,
): Promise<ProviderForUserResult> {
return resolveProviderForLane('chat', config, billingUserId);
}
export async function getTagsProviderForBillingUser(
async function getTagsProviderForBillingUser(
config: Record<string, string>,
billingUserId?: string,
): Promise<ProviderForUserResult> {
return resolveProviderForLane('tags', config, billingUserId);
}
export async function getEmbeddingsProviderForBillingUser(
async function getEmbeddingsProviderForBillingUser(
config: Record<string, string>,
billingUserId?: string,
): Promise<ProviderForUserResult> {

View File

@@ -1,19 +0,0 @@
import { auth } from '@/auth'
import { NextResponse } from 'next/server'
export async function requireAuth() {
const session = await auth()
if (!session?.user?.id) {
return { session: null, error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
}
return { session, error: null }
}
export async function requireAdmin() {
const result = await requireAuth()
if (result.error) return result
if ((result.session!.user as any).role !== 'ADMIN') {
return { session: null, error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }
}
return result
}

View File

@@ -2,7 +2,7 @@
* 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 = [
const THEME_IDS = [
'light',
'dark',
'auto',

View File

@@ -1,310 +0,0 @@
/**
* PROPOSITION D'HARMONIE DE COULEURS
* ===================================
*
* Recommandations pour un système de couleurs unifié et moderne
* Approche contemporaine des couleurs de notes
*
*/
// =============================================================================
// 1. PALETTE PRINCIPALE DU THÈME (OKLCH)
// =============================================================================
/**
* Proposition de palette principale avec meilleure cohérence
* Utilise OKLCH pour une meilleure accessibilité et cohérence perceptive
*/
export const RECOMMENDED_THEME_COLORS = {
/* ============ THEME LIGHT ============ */
light: {
// Backgrounds
background: 'oklch(0.99 0.002 250)', // Blanc très légèrement bleuté
card: 'oklch(1 0 0)', // Blanc pur
sidebar: 'oklch(0.96 0.005 250)', // Gris-bleu très pâle
input: 'oklch(0.97 0.003 250)', // Gris-bleu pâle
// Textes
foreground: 'oklch(0.18 0.01 250)', // Gris-bleu foncé (meilleur contraste)
'foreground-secondary': 'oklch(0.45 0.01 250)', // Gris moyen
'foreground-muted': 'oklch(0.6 0.005 250)', // Gris clair
// Primary Actions
primary: 'oklch(0.55 0.2 250)', // Bleu Keep (plus vibrant)
'primary-hover': 'oklch(0.5 0.22 250)', // Bleu Keep foncé
'primary-foreground': 'oklch(0.99 0 0)', // Blanc pur
// Accents
accent: 'oklch(0.92 0.015 250)', // Bleu très pâle
'accent-foreground': 'oklch(0.18 0.01 250)',
// Borders
border: 'oklch(0.88 0.01 250)', // Gris-bleu très clair
'border-hover': 'oklch(0.8 0.015 250)',
// Functional
success: 'oklch(0.65 0.15 145)', // Vert
warning: 'oklch(0.75 0.12 70)', // Jaune/orange
destructive: 'oklch(0.6 0.2 25)', // Rouge
},
/* ============ THEME DARK ============ */
dark: {
// Backgrounds
background: 'oklch(0.12 0.01 250)', // Noir légèrement bleuté
card: 'oklch(0.16 0.01 250)', // Gris-bleu foncé
sidebar: 'oklch(0.1 0.01 250)', // Noir bleuté
input: 'oklch(0.18 0.01 250)',
// Textes
foreground: 'oklch(0.96 0.002 250)', // Blanc légèrement bleuté
'foreground-secondary': 'oklch(0.75 0.005 250)',
'foreground-muted': 'oklch(0.55 0.01 250)',
// Primary Actions
primary: 'oklch(0.65 0.2 250)', // Bleu plus clair
'primary-hover': 'oklch(0.7 0.2 250)',
'primary-foreground': 'oklch(0.1 0 0)',
// Accents
accent: 'oklch(0.22 0.01 250)',
'accent-foreground': 'oklch(0.96 0.002 250)',
// Borders
border: 'oklch(0.25 0.015 250)',
'border-hover': 'oklch(0.35 0.015 250)',
// Functional
success: 'oklch(0.7 0.15 145)',
warning: 'oklch(0.8 0.12 70)',
destructive: 'oklch(0.65 0.2 25)',
},
} as const;
// =============================================================================
// 2. PALETTE DES COULEURS DE NOTES (UNIFIÉE)
// =============================================================================
/**
* Nouvelle palette de couleurs pour les notes
* Harmonisée avec le thème principal
* Meilleure accessibilité WCAG AA (contraste minimum 4.5:1)
*/
export const RECOMMENDED_NOTE_COLORS = {
default: {
// Light theme
bg: 'bg-white',
'bg-dark': 'dark:bg-neutral-900',
border: 'border-neutral-200',
'border-dark': 'dark:border-neutral-800',
hover: 'hover:bg-neutral-50',
'hover-dark': 'dark:hover:bg-neutral-800',
// Pour texte sombre
text: 'text-neutral-900',
'text-dark': 'dark:text-neutral-100',
},
red: {
bg: 'bg-red-50',
'bg-dark': 'dark:bg-red-950/40',
border: 'border-red-100',
'border-dark': 'dark:border-red-900/50',
hover: 'hover:bg-red-100',
'hover-dark': 'dark:hover:bg-red-950/60',
text: 'text-red-950',
'text-dark': 'dark:text-red-100',
},
orange: {
bg: 'bg-orange-50',
'bg-dark': 'dark:bg-orange-950/40',
border: 'border-orange-100',
'border-dark': 'dark:border-orange-900/50',
hover: 'hover:bg-orange-100',
'hover-dark': 'dark:hover:bg-orange-950/60',
text: 'text-orange-950',
'text-dark': 'dark:text-orange-100',
},
yellow: {
bg: 'bg-yellow-50',
'bg-dark': 'dark:bg-yellow-950/40',
border: 'border-yellow-100',
'border-dark': 'dark:border-yellow-900/50',
hover: 'hover:bg-yellow-100',
'hover-dark': 'dark:hover:bg-yellow-950/60',
text: 'text-yellow-950',
'text-dark': 'dark:text-yellow-100',
},
green: {
bg: 'bg-emerald-50',
'bg-dark': 'dark:bg-emerald-950/40',
border: 'border-emerald-100',
'border-dark': 'dark:border-emerald-900/50',
hover: 'hover:bg-emerald-100',
'hover-dark': 'dark:hover:bg-emerald-950/60',
text: 'text-emerald-950',
'text-dark': 'dark:text-emerald-100',
},
teal: {
bg: 'bg-teal-50',
'bg-dark': 'dark:bg-teal-950/40',
border: 'border-teal-100',
'border-dark': 'dark:border-teal-900/50',
hover: 'hover:bg-teal-100',
'hover-dark': 'dark:hover:bg-teal-950/60',
text: 'text-teal-950',
'text-dark': 'dark:text-teal-100',
},
blue: {
bg: 'bg-blue-50',
'bg-dark': 'dark:bg-blue-950/40',
border: 'border-blue-100',
'border-dark': 'dark:border-blue-900/50',
hover: 'hover:bg-blue-100',
'hover-dark': 'dark:hover:bg-blue-950/60',
text: 'text-blue-950',
'text-dark': 'dark:text-blue-100',
},
indigo: {
bg: 'bg-indigo-50',
'bg-dark': 'dark:bg-indigo-950/40',
border: 'border-indigo-100',
'border-dark': 'dark:border-indigo-900/50',
hover: 'hover:bg-indigo-100',
'hover-dark': 'dark:hover:bg-indigo-950/60',
text: 'text-indigo-950',
'text-dark': 'dark:text-indigo-100',
},
violet: {
bg: 'bg-violet-50',
'bg-dark': 'dark:bg-violet-950/40',
border: 'border-violet-100',
'border-dark': 'dark:border-violet-900/50',
hover: 'hover:bg-violet-100',
'hover-dark': 'dark:hover:bg-violet-950/60',
text: 'text-violet-950',
'text-dark': 'dark:text-violet-100',
},
purple: {
bg: 'bg-purple-50',
'bg-dark': 'dark:bg-purple-950/40',
border: 'border-purple-100',
'border-dark': 'dark:border-purple-900/50',
hover: 'hover:bg-purple-100',
'hover-dark': 'dark:hover:bg-purple-950/60',
text: 'text-purple-950',
'text-dark': 'dark:text-purple-100',
},
pink: {
bg: 'bg-pink-50',
'bg-dark': 'dark:bg-pink-950/40',
border: 'border-pink-100',
'border-dark': 'dark:border-pink-900/50',
hover: 'hover:bg-pink-100',
'hover-dark': 'dark:hover:bg-pink-950/60',
text: 'text-pink-950',
'text-dark': 'dark:text-pink-100',
},
rose: {
bg: 'bg-rose-50',
'bg-dark': 'dark:bg-rose-950/40',
border: 'border-rose-100',
'border-dark': 'dark:border-rose-900/50',
hover: 'hover:bg-rose-100',
'hover-dark': 'dark:hover:bg-rose-950/60',
text: 'text-rose-950',
'text-dark': 'dark:text-rose-100',
},
gray: {
bg: 'bg-neutral-100',
'bg-dark': 'dark:bg-neutral-800',
border: 'border-neutral-200',
'border-dark': 'dark:border-neutral-700',
hover: 'hover:bg-neutral-200',
'hover-dark': 'dark:hover:bg-neutral-700',
text: 'text-neutral-900',
'text-dark': 'dark:text-neutral-100',
},
} as const;
// =============================================================================
// 3. RÈGLES DE DESIGN
// =============================================================================
/**
* Principes de design couleur :
*
* 1. HARMONIE : Toutes les couleurs partagent la même teinte de base (bleu 250°)
* - Crée une cohérence visuelle
* - Réduit la fatigue visuelle
* - Améliore la perception de marque
*
* 2. CONTRASTE : Respecte WCAG AA (4.5:1 minimum)
* - Texte sur fond : toujours ≥ 4.5:1
* - Éléments interactifs : ≥ 3:1
* - Utilise OKLCH pour une mesure plus précise
*
* 3. ACCESSIBILITÉ :
* - Ne pas utiliser la couleur seule pour véhiculer l'information
* - Inclure des icônes ou des symboles
* - Supporter le mode de contraste élevé
*
* 4. ADAPTABILITÉ :
* - Mode light/dark automatique
* - Variations de couleur par note
* - Thèmes alternatifs (midnight, blue, sepia)
*
* 5. PERFORMANCE PERCEPTIVE :
* - OKLCH pour une échelle perceptuelle uniforme
* - Même légèreté perçue entre light et dark
* - Transition fluide entre thèmes
*/
// =============================================================================
// 4. EXEMPLE D'IMPLEMENTATION
// =============================================================================
/**
* Comment utiliser ces couleurs dans les composants :
*
* ```tsx
* import { RECOMMENDED_NOTE_COLORS } from '@/lib/color-harmony-recommendation'
*
* // Pour une carte de note
* <div className={`
* ${noteColors.bg}
* dark:${noteColors['bg-dark']}
* ${noteColors.border}
* dark:${noteColors['border-dark']}
* ${noteColors.text}
* dark:${noteColors['text-dark']}
* hover:${noteColors.hover}
* dark:hover:${noteColors['hover-dark']}
* `}>
* {note.content}
* </div>
*
* // Pour le thème global (globals.css)
* :root {
* --background: oklch(0.99 0.002 250);
* --foreground: oklch(0.18 0.01 250);
* --primary: oklch(0.55 0.2 250);
* // ... autres couleurs
* }
* ```
*/
// =============================================================================
// 5. AVANTAGES DE CETTE APPROCHE
// =============================================================================
/**
* ✅ Cohérence visuelle accrue
* ✅ Meilleure accessibilité (WCAG AA+)
* ✅ Adaptation native aux modes light/dark
* ✅ Palette extensible (facile à ajouter de nouvelles couleurs)
* ✅ Performance OKLCH (perception humaine)
* ✅ Compatible avec Tailwind CSS
* ✅ Maintenance simplifiée
* ✅ Professionalisme et modernité
*/
export type RecommendedNoteColor = keyof typeof RECOMMENDED_NOTE_COLORS;

View File

@@ -1,57 +0,0 @@
import { LabelColorName } from './types'
const STORAGE_KEY = 'memento-label-colors'
// Store label colors in localStorage
export function getLabelColor(label: string): LabelColorName {
if (typeof window === 'undefined') return 'gray'
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored) return 'gray'
const colors = JSON.parse(stored) as Record<string, LabelColorName>
return colors[label] || 'gray'
} catch {
return 'gray'
}
}
export function setLabelColor(label: string, color: LabelColorName) {
if (typeof window === 'undefined') return
try {
const stored = localStorage.getItem(STORAGE_KEY)
const colors = stored ? JSON.parse(stored) as Record<string, LabelColorName> : {}
colors[label] = color
localStorage.setItem(STORAGE_KEY, JSON.stringify(colors))
} catch (error) {
console.error('Failed to save label color:', error)
}
}
export function getAllLabelColors(): Record<string, LabelColorName> {
if (typeof window === 'undefined') return {}
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) : {}
} catch {
return {}
}
}
export function deleteLabelColor(label: string) {
if (typeof window === 'undefined') return
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored) return
const colors = JSON.parse(stored) as Record<string, LabelColorName>
delete colors[label]
localStorage.setItem(STORAGE_KEY, JSON.stringify(colors))
} catch (error) {
console.error('Failed to delete label color:', error)
}
}

View File

@@ -1,307 +0,0 @@
/**
* OPTIONS DE COULEURS MODERNES - PAS DE DÉGRADÉS
* =================================================
*
* Alternatives au bleu traditionnel pour un design contemporain
*/
// =============================================================================
// OPTION 1: GRIS-BLEU (SLATE) - RECOMMANDÉE ✅
// =============================================================================
/**
* Gris-bleu moderne et professionnel
* Inspiré par Linear, Vercel, GitHub
* Très élégant, discret et apaisant pour les yeux
*/
export const SLATE_THEME = {
name: 'Gris-Bleu (Slate)',
description: 'Moderne, professionnel et apaisant - comme Linear/Vercel',
light: {
// Backgrounds
background: 'oklch(0.985 0.003 230)', // Blanc grisâtre très léger
card: 'oklch(1 0 0)', // Blanc pur
sidebar: 'oklch(0.97 0.004 230)', // Gris-bleu très pâle
input: 'oklch(0.98 0.003 230)', // Gris-bleu pâle
// Textes
foreground: 'oklch(0.2 0.02 230)', // Gris-bleu foncé
'foreground-secondary': 'oklch(0.45 0.015 230)', // Gris-bleu moyen
'foreground-muted': 'oklch(0.6 0.01 230)', // Gris-bleu clair
// Primary (le point coloré de l'interface)
primary: 'oklch(0.45 0.08 230)', // Gris-bleu doux
'primary-hover': 'oklch(0.4 0.09 230)', // Gris-bleu plus foncé
'primary-foreground': 'oklch(0.99 0 0)', // Blanc
// Accents
accent: 'oklch(0.94 0.005 230)', // Gris-bleu très pâle
'accent-foreground': 'oklch(0.2 0.02 230)',
// Borders
border: 'oklch(0.9 0.008 230)', // Gris-bleu très clair
'border-hover': 'oklch(0.82 0.01 230)',
// Functional
success: 'oklch(0.65 0.15 145)', // Vert emerald
warning: 'oklch(0.75 0.12 70)', // Jaune/orange
destructive: 'oklch(0.6 0.18 25)', // Rouge
},
dark: {
// Backgrounds
background: 'oklch(0.14 0.005 230)', // Noir grisâtre léger
card: 'oklch(0.18 0.006 230)', // Gris-bleu foncé
sidebar: 'oklch(0.12 0.005 230)', // Noir grisâtre
input: 'oklch(0.2 0.006 230)',
// Textes
foreground: 'oklch(0.97 0.003 230)', // Blanc grisâtre
'foreground-secondary': 'oklch(0.75 0.008 230)',
'foreground-muted': 'oklch(0.55 0.01 230)',
// Primary
primary: 'oklch(0.55 0.08 230)', // Gris-bleu plus clair
'primary-hover': 'oklch(0.6 0.09 230)',
'primary-foreground': 'oklch(0.1 0 0)',
// Accents
accent: 'oklch(0.24 0.006 230)',
'accent-foreground': 'oklch(0.97 0.003 230)',
// Borders
border: 'oklch(0.28 0.01 230)',
'border-hover': 'oklch(0.38 0.012 230)',
// Functional
success: 'oklch(0.7 0.15 145)',
warning: 'oklch(0.8 0.12 70)',
destructive: 'oklch(0.65 0.18 25)',
},
} as const;
// =============================================================================
// OPTION 2: MONOCHROME GRIS (MINIMALISTE)
// =============================================================================
/**
* Noir et blanc avec subtilités de gris
* Style minimaliste ultra-moderne (Linear, Stripe, Apple)
* Absolument pas de couleur sauf pour les fonctionnalités
*/
export const MONOCHROME_THEME = {
name: 'Monochrome Gris',
description: 'Minimaliste et élégant - style Linear/Apple',
light: {
background: 'oklch(0.99 0 0)', // Blanc pur
card: 'oklch(1 0 0)', // Blanc pur
sidebar: 'oklch(0.96 0 0)', // Gris très pâle
input: 'oklch(0.98 0 0)',
foreground: 'oklch(0.15 0 0)', // Noir pur
'foreground-secondary': 'oklch(0.45 0 0)', // Gris moyen
'foreground-muted': 'oklch(0.6 0 0)', // Gris clair
primary: 'oklch(0.2 0 0)', // Gris foncé
'primary-hover': 'oklch(0.15 0 0)', // Noir
'primary-foreground': 'oklch(1 0 0)', // Blanc
accent: 'oklch(0.95 0 0)', // Gris très pâle
'accent-foreground': 'oklch(0.2 0 0)',
border: 'oklch(0.89 0 0)', // Gris très clair
'border-hover': 'oklch(0.75 0 0)',
success: 'oklch(0.6 0.15 145)', // Vert subtil
warning: 'oklch(0.7 0.12 70)', // Jaune subtil
destructive: 'oklch(0.55 0.18 25)', // Rouge subtil
},
dark: {
background: 'oklch(0.1 0 0)', // Noir pur
card: 'oklch(0.14 0 0)', // Gris très foncé
sidebar: 'oklch(0.08 0 0)', // Noir pur
input: 'oklch(0.16 0 0)',
foreground: 'oklch(0.98 0 0)', // Blanc pur
'foreground-secondary': 'oklch(0.7 0 0)', // Gris moyen
'foreground-muted': 'oklch(0.5 0 0)', // Gris foncé
primary: 'oklch(0.85 0 0)', // Gris clair
'primary-hover': 'oklch(0.9 0 0)', // Blanc
'primary-foreground': 'oklch(0.1 0 0)', // Noir
accent: 'oklch(0.2 0 0)', // Gris foncé
'accent-foreground': 'oklch(0.98 0 0)',
border: 'oklch(0.25 0 0)', // Gris foncé
'border-hover': 'oklch(0.35 0 0)',
success: 'oklch(0.65 0.15 145)',
warning: 'oklch(0.75 0.12 70)',
destructive: 'oklch(0.6 0.18 25)',
},
} as const;
// =============================================================================
// OPTION 3: VIOLET PROFOND (INDIGO)
// =============================================================================
/**
* Violet profond élégant et moderne
* Entre le bleu et le violet
* Très professionnel (Discord, Notion, Figma)
*/
export const INDIGO_THEME = {
name: 'Violet Profond',
description: 'Élégant et moderne - style Discord/Notion',
light: {
background: 'oklch(0.99 0.005 260)', // Blanc légèrement violacé
card: 'oklch(1 0 0)',
sidebar: 'oklch(0.96 0.006 260)',
input: 'oklch(0.97 0.005 260)',
foreground: 'oklch(0.2 0.015 260)', // Gris-violet foncé
'foreground-secondary': 'oklch(0.45 0.012 260)',
'foreground-muted': 'oklch(0.6 0.008 260)',
primary: 'oklch(0.55 0.18 260)', // Violet profond
'primary-hover': 'oklch(0.5 0.2 260)', // Violet plus foncé
'primary-foreground': 'oklch(0.99 0 0)',
accent: 'oklch(0.94 0.008 260)',
'accent-foreground': 'oklch(0.2 0.015 260)',
border: 'oklch(0.9 0.01 260)',
'border-hover': 'oklch(0.82 0.012 260)',
success: 'oklch(0.65 0.15 145)',
warning: 'oklch(0.75 0.12 70)',
destructive: 'oklch(0.6 0.18 25)',
},
dark: {
background: 'oklch(0.14 0.008 260)', // Noir légèrement violacé
card: 'oklch(0.18 0.01 260)', // Gris-violet foncé
sidebar: 'oklch(0.12 0.008 260)',
input: 'oklch(0.2 0.01 260)',
foreground: 'oklch(0.97 0.005 260)', // Blanc légèrement violacé
'foreground-secondary': 'oklch(0.75 0.008 260)',
'foreground-muted': 'oklch(0.55 0.01 260)',
primary: 'oklch(0.65 0.18 260)', // Violet plus clair
'primary-hover': 'oklch(0.7 0.2 260)',
'primary-foreground': 'oklch(0.1 0 0)',
accent: 'oklch(0.24 0.01 260)',
'accent-foreground': 'oklch(0.97 0.005 260)',
border: 'oklch(0.28 0.012 260)',
'border-hover': 'oklch(0.38 0.015 260)',
success: 'oklch(0.7 0.15 145)',
warning: 'oklch(0.8 0.12 70)',
destructive: 'oklch(0.65 0.18 25)',
},
} as const;
// =============================================================================
// OPTION 4: TEAL (TURQUOISE)
// =============================================================================
/**
* Turquoise/teal moderne et rafraîchissante
* Entre le bleu et le vert
* Très appréciée dans le design moderne (Linear, Atlassian)
*/
export const TEAL_THEME = {
name: 'Teal (Turquoise)',
description: 'Moderne et rafraîchissant - style Atlassian/Linear',
light: {
background: 'oklch(0.99 0.003 195)', // Blanc légèrement teinté
card: 'oklch(1 0 0)',
sidebar: 'oklch(0.96 0.004 195)',
input: 'oklch(0.97 0.003 195)',
foreground: 'oklch(0.2 0.015 195)', // Gris-teal foncé
'foreground-secondary': 'oklch(0.45 0.012 195)',
'foreground-muted': 'oklch(0.6 0.008 195)',
primary: 'oklch(0.55 0.14 195)', // Teal moderne
'primary-hover': 'oklch(0.5 0.16 195)', // Teal plus foncé
'primary-foreground': 'oklch(0.99 0 0)',
accent: 'oklch(0.94 0.005 195)',
'accent-foreground': 'oklch(0.2 0.015 195)',
border: 'oklch(0.9 0.008 195)',
'border-hover': 'oklch(0.82 0.01 195)',
success: 'oklch(0.65 0.15 145)',
warning: 'oklch(0.75 0.12 70)',
destructive: 'oklch(0.6 0.18 25)',
},
dark: {
background: 'oklch(0.14 0.005 195)', // Noir légèrement teinté
card: 'oklch(0.18 0.006 195)', // Gris-teal foncé
sidebar: 'oklch(0.12 0.005 195)',
input: 'oklch(0.2 0.006 195)',
foreground: 'oklch(0.97 0.003 195)', // Blanc légèrement teinté
'foreground-secondary': 'oklch(0.75 0.006 195)',
'foreground-muted': 'oklch(0.55 0.008 195)',
primary: 'oklch(0.65 0.14 195)', // Teal plus clair
'primary-hover': 'oklch(0.7 0.16 195)',
'primary-foreground': 'oklch(0.1 0 0)',
accent: 'oklch(0.24 0.006 195)',
'accent-foreground': 'oklch(0.97 0.003 195)',
border: 'oklch(0.28 0.01 195)',
'border-hover': 'oklch(0.38 0.012 195)',
success: 'oklch(0.7 0.15 145)',
warning: 'oklch(0.8 0.12 70)',
destructive: 'oklch(0.65 0.18 25)',
},
} as const;
// =============================================================================
// RÉSUMÉ DES OPTIONS
// =============================================================================
/**
* Comparaison des options :
*
* | Option | Modernité | Professionnalisme | Fatigue oculaire | Unicité |
* |--------|-----------|-------------------|------------------|----------|
* | Slate (Gris-Bleu) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
* | Monochrome | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
* | Indigo | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
* | Teal | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
*
* Recommandation : Slate (Gris-Bleu)
* - Le plus professionnel
* - Fatigue oculaire minimale
* - Très moderne et tendance
* - Différent du bleu traditionnel
* - Cohérent avec votre suggestion
*/
export type ThemeOption = 'slate' | 'monochrome' | 'indigo' | 'teal';
/**
* Pour choisir le thème :
*
* function getTheme(option: ThemeOption) {
* switch(option) {
* case 'slate': return SLATE_THEME;
* case 'monochrome': return MONOCHROME_THEME;
* case 'indigo': return INDIGO_THEME;
* case 'teal': return TEAL_THEME;
* }
* }
*/

View File

@@ -1,38 +1,12 @@
'use client'
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query'
import { useQueryClient, useQuery } from '@tanstack/react-query'
import { queryKeys } from './query-keys'
import type { Note, Notebook, Label } from '@/lib/types'
import type { Notebook, Label } from '@/lib/types'
// Re-export query keys
export { queryKeys }
// ===== useNotes =====
export function useNotes(notebookId?: string | null) {
return useQuery({
queryKey: queryKeys.notes(notebookId),
queryFn: async (): Promise<Note[]> => {
const url = notebookId ? `/api/notes?notebookId=${notebookId}` : '/api/notes'
const res = await fetch(url, { cache: 'no-store', credentials: 'include' })
const data = await res.json()
return data.notes || []
},
})
}
// ===== useNote =====
export function useNote(noteId: string) {
return useQuery({
queryKey: queryKeys.note(noteId),
queryFn: async (): Promise<Note> => {
const res = await fetch(`/api/notes/${noteId}`, { cache: 'no-store', credentials: 'include' })
const data = await res.json()
return data.note || data
},
enabled: !!noteId,
})
}
// ===== useNotebooks =====
export function useNotebooksQuery() {
return useQuery({
@@ -64,10 +38,6 @@ export function invalidateNotes(queryClient: ReturnType<typeof useQueryClient>,
queryClient.invalidateQueries({ queryKey: queryKeys.notes(notebookId) })
}
export function invalidateNote(queryClient: ReturnType<typeof useQueryClient>, noteId: string) {
queryClient.invalidateQueries({ queryKey: queryKeys.note(noteId) })
}
export function invalidateNotebooks(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
}

View File

@@ -11,8 +11,4 @@ export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? 'sk_test_place
typescript: true,
});
export function getPublishableKey(): string {
const key = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
if (!key) throw new Error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set');
return key;
}

View File

@@ -50,13 +50,6 @@ export interface LinkMetadata {
export type NoteType = 'text' | 'markdown' | 'richtext' | 'checklist'
export const NOTE_TYPE_CONFIG: Record<NoteType, { icon: string; label: string }> = {
text: { icon: 'AlignLeft', label: 'Text' },
markdown: { icon: 'FileCode2', label: 'Markdown' },
richtext: { icon: 'PenLine', label: 'Rich Text' },
checklist: { icon: 'ListChecks', label: 'Checklist' },
}
export interface Note {
id: string;
title: string | null;

File diff suppressed because it is too large Load Diff

View File

@@ -81,7 +81,6 @@
"cheerio": "^1.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3": "^7.9.0",
"dagre": "^0.8.5",
"date-fns": "^4.1.0",
@@ -99,14 +98,12 @@
"next": "^16.1.6",
"next-auth": "^5.0.0-beta.30",
"nodemailer": "^8.0.4",
"novel": "^1.0.2",
"pdf-parse": "^2.4.5",
"postcss": "^8.5.6",
"pptxgenjs": "^4.0.1",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-force-graph-2d": "^1.29.1",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
@@ -121,7 +118,6 @@
"stripe": "^22.1.1",
"tailwind-merge": "^3.4.0",
"tinyld": "^1.3.4",
"tippy.js": "^6.3.7",
"vazirmatn": "^33.0.3",
"zod": "^4.3.5"
},

View File

@@ -1,71 +0,0 @@
const fs = require('fs');
const file = 'app/(admin)/admin/settings/admin-settings-form.tsx';
let content = fs.readFileSync(file, 'utf-8');
// 1. Add state variable
content = content.replace(
"const { t } = useLanguage()",
"const { t } = useLanguage()\n const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags')"
);
// 2. Add Tab Switcher UI and wrap each block
// Find the start of AI form
const aiFormStart = `<div className="p-6 space-y-6">
{/* Tags Generation Provider */}
<div className="space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50">`;
const newAiFormStart = `<div className="px-6 pt-6">
<div className="flex border-b border-border/50 overflow-x-auto">
<button type="button" onClick={() => setActiveAiTab('tags')} className={\`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap \${activeAiTab === 'tags' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}\`}>🏷️ Tags</button>
<button type="button" onClick={() => setActiveAiTab('embeddings')} className={\`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap \${activeAiTab === 'embeddings' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}\`}>🔍 Embeddings</button>
<button type="button" onClick={() => setActiveAiTab('chat')} className={\`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap \${activeAiTab === 'chat' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}\`}>💬 Chat</button>
</div>
</div>
<div className="p-6 space-y-6">
{/* Tags Generation Provider */}
<div className={\`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 \${activeAiTab === 'tags' ? 'block' : 'hidden'}\`}>`;
content = content.replace(aiFormStart, newAiFormStart);
// Now find Embeddings and Chat and wrap them in hidden condition
const embeddingsStart = `{/* Embeddings Provider */}
<div className="space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 mt-4">`;
const newEmbeddingsStart = `{/* Embeddings Provider */}
<div className={\`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 \${activeAiTab === 'embeddings' ? 'block' : 'hidden'}\`}>`;
content = content.replace(embeddingsStart, newEmbeddingsStart);
const chatStart = `{/* Chat Provider */}
<div className="space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 mt-4">`;
const newChatStart = `{/* Chat Provider */}
<div className={\`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 \${activeAiTab === 'chat' ? 'block' : 'hidden'}\`}>`;
content = content.replace(chatStart, newChatStart);
// 3. Fix button overflows everywhere.
// Find: <div className="px-6 pb-6 flex justify-between pt-6"> (AI)
content = content.replace(
'<div className="px-6 pb-6 flex justify-between pt-6">',
'<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">'
);
// Find: <div className="px-6 pb-6 flex justify-between"> (Email, Tools, Security?)
// Tools:
content = content.replace(
'<div className="px-6 pb-6 flex justify-between">',
'<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between">'
);
content = content.replace(
'<div className="px-6 pb-6 flex justify-between">',
'<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between">'
);
// Email:
content = content.replace(
'<div className="px-6 pb-6 flex justify-between pt-6">',
'<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">'
);
// Security doesn't have justify-between, it just has <div className="px-6 pb-6">
// Write back
fs.writeFileSync(file, content);
console.log("Done");

View File

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

View File

@@ -1,37 +0,0 @@
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '..', 'components', 'notes-tabs-view.tsx');
let lines = fs.readFileSync(filePath, 'utf8').split('\r\n');
// Line 977 (index 976) closes the scrollable div
// Line 978 (index 977) closes the left panel div
// We need to:
// 1. After the scrollable </div> (index 976), add )} to close {listOpen && (...
// 2. Add expand button JSX
// 3. Then close left panel div
const expandButton = [
' )}',
' {/* Expand button shown in collapsed state */}',
' {!listOpen && (',
' <div className="flex flex-col items-center pt-3">',
' <Button',
' variant="ghost"',
' size="sm"',
' className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"',
' onClick={() => setListOpen(true)}',
' title="Afficher la liste"',
' >',
' <PanelLeftOpen className="h-3.5 w-3.5" />',
' </Button>',
' </div>',
' )}',
];
// Insert after index 976 (which is ' </div>' - the scrollable div close)
// and remove the original ' </div>' at index 977 (left panel close)
// then re-add left panel close
lines.splice(977, 1, ...expandButton, ' </div>');
fs.writeFileSync(filePath, lines.join('\r\n'));
console.log('Done, total lines:', lines.length);

View File

@@ -1,60 +0,0 @@
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '..', 'components', 'notes-tabs-view.tsx');
let src = fs.readFileSync(filePath, 'utf8');
const insertCode = `
// Resizable left panel
const [listPanelWidth, setListPanelWidth] = useState(256)
const isDraggingRef = useRef(false)
const handleResizeStart = (e) => {
e.preventDefault()
isDraggingRef.current = true
const startX = e.clientX
const startW = listPanelWidth
const onMove = (ev) => {
if (!isDraggingRef.current) return
setListPanelWidth(Math.min(420, Math.max(180, startW + (ev.clientX - startX))))
}
const onUp = () => {
isDraggingRef.current = false
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
`;
// Insert before the return statement
const MARKER = ' return (\r\n <div\r\n className="flex min-h-0 flex-1 gap-0';
const ALT_MARKER = ' return (\n <div\n className="flex min-h-0 flex-1 gap-0';
if (src.includes(MARKER) && !src.includes('listPanelWidth')) {
src = src.replace(MARKER, insertCode + MARKER);
console.log('Inserted using CRLF marker');
} else if (src.includes(ALT_MARKER) && !src.includes('listPanelWidth')) {
src = src.replace(ALT_MARKER, insertCode + ALT_MARKER);
console.log('Inserted using LF marker');
} else if (src.includes('listPanelWidth')) {
console.log('State already exists, skipping insert');
} else {
console.error('Marker not found!');
process.exit(1);
}
// Fix left panel width - CRLF version
const OLD_CRLF = 'className="flex w-80 shrink-0 flex-col border-r border-border/60 bg-background">';
const NEW = 'className="flex shrink-0 flex-col border-r border-border/60 bg-background" style={{ width: listPanelWidth }}>';
if (src.includes(OLD_CRLF)) {
src = src.replace(OLD_CRLF, NEW);
console.log('Replaced panel width');
} else {
console.log('Panel width already updated or marker not found');
}
fs.writeFileSync(filePath, src);
console.log('Done!');

View File

@@ -1,292 +0,0 @@
#!/usr/bin/env node
/**
* Merges missing i18n keys (admin fallback, brainstorm quota, landing page)
* into locale files. Contextual translations — not word-for-word.
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const localesDir = path.join(__dirname, '../locales')
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
const sv = source[key]
if (sv && typeof sv === 'object' && !Array.isArray(sv)) {
if (!target[key] || typeof target[key] !== 'object') target[key] = {}
deepMerge(target[key], sv)
} else {
target[key] = sv
}
}
return target
}
function flatten(obj, prefix = '') {
const result = {}
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k
if (v && typeof v === 'object' && !Array.isArray(v)) Object.assign(result, flatten(v, key))
else result[key] = v
}
return result
}
const adminAiFallback = {
de: {
fallbackSectionTitle: 'Ausweich-Anbieter (optional)',
fallbackSectionDescription:
'Wird bei Anbieterfehlern automatisch genutzt (429, 5xx). Ein erneuter Versuch innerhalb von 1,5 s.',
fallbackProvider: 'Ausweich-Anbieter',
fallbackModel: 'Ausweich-Modell',
fallbackNone: 'Keiner (deaktiviert)',
fallbackModelPlaceholder: 'z. B. gpt-4o-mini',
},
es: {
fallbackSectionTitle: 'Proveedor de respaldo (opcional)',
fallbackSectionDescription:
'Se usa automáticamente ante errores del proveedor (429, 5xx). Un reintento en 1,5 s.',
fallbackProvider: 'Proveedor de respaldo',
fallbackModel: 'Modelo de respaldo',
fallbackNone: 'Ninguno (desactivado)',
fallbackModelPlaceholder: 'p. ej. gpt-4o-mini',
},
it: {
fallbackSectionTitle: 'Provider di riserva (opzionale)',
fallbackSectionDescription:
'Usato automaticamente in caso di errori del provider (429, 5xx). Un solo nuovo tentativo entro 1,5 s.',
fallbackProvider: 'Provider di riserva',
fallbackModel: 'Modello di riserva',
fallbackNone: 'Nessuno (disattivato)',
fallbackModelPlaceholder: 'es. gpt-4o-mini',
},
pt: {
fallbackSectionTitle: 'Provedor de contingência (opcional)',
fallbackSectionDescription:
'Usado automaticamente em erros do provedor (429, 5xx). Uma nova tentativa em 1,5 s.',
fallbackProvider: 'Provedor de contingência',
fallbackModel: 'Modelo de contingência',
fallbackNone: 'Nenhum (desativado)',
fallbackModelPlaceholder: 'ex.: gpt-4o-mini',
},
nl: {
fallbackSectionTitle: 'Fallback-provider (optioneel)',
fallbackSectionDescription:
'Wordt automatisch gebruikt bij providerfouten (429, 5xx). Eén nieuwe poging binnen 1,5 s.',
fallbackProvider: 'Fallback-provider',
fallbackModel: 'Fallback-model',
fallbackNone: 'Geen (uitgeschakeld)',
fallbackModelPlaceholder: 'bijv. gpt-4o-mini',
},
pl: {
fallbackSectionTitle: 'Zapasowy dostawca (opcjonalnie)',
fallbackSectionDescription:
'Używany automatycznie przy błędach dostawcy (429, 5xx). Jedna ponowna próba w 1,5 s.',
fallbackProvider: 'Zapasowy dostawca',
fallbackModel: 'Zapasowy model',
fallbackNone: 'Brak (wyłączone)',
fallbackModelPlaceholder: 'np. gpt-4o-mini',
},
ru: {
fallbackSectionTitle: 'Резервный провайдер (необязательно)',
fallbackSectionDescription:
'Используется автоматически при ошибках провайдера (429, 5xx). Одна повторная попытка за 1,5 с.',
fallbackProvider: 'Резервный провайдер',
fallbackModel: 'Резервная модель',
fallbackNone: 'Нет (отключено)',
fallbackModelPlaceholder: 'напр. gpt-4o-mini',
},
ar: {
fallbackSectionTitle: 'مزود احتياطي (اختياري)',
fallbackSectionDescription:
'يُستخدم تلقائياً عند أخطاء المزود (429، 5xx). محاولة واحدة خلال 1,5 ثانية.',
fallbackProvider: 'مزود احتياطي',
fallbackModel: 'نموذج احتياطي',
fallbackNone: 'لا شيء (معطّل)',
fallbackModelPlaceholder: 'مثال: gpt-4o-mini',
},
fa: {
fallbackSectionTitle: 'ارائه‌دهنده پشتیبان (اختیاری)',
fallbackSectionDescription:
'در صورت خطای ارائه‌دهنده (429، 5xx) به‌صورت خودکار استفاده می‌شود. یک تلاش مجدد در ۱,۵ ثانیه.',
fallbackProvider: 'ارائه‌دهنده پشتیبان',
fallbackModel: 'مدل پشتیبان',
fallbackNone: 'هیچ (غیرفعال)',
fallbackModelPlaceholder: 'مثلاً gpt-4o-mini',
},
hi: {
fallbackSectionTitle: 'फ़ॉलबैक प्रदाता (वैकल्पिक)',
fallbackSectionDescription:
'प्रदाता त्रुटियों (429, 5xx) पर स्वतः उपयोग। 1.5 सेकंड में एक पुनः प्रयास।',
fallbackProvider: 'फ़ॉलबैक प्रदाता',
fallbackModel: 'फ़ॉलबैक मॉडल',
fallbackNone: 'कोई नहीं (अक्षम)',
fallbackModelPlaceholder: 'उदा. gpt-4o-mini',
},
ja: {
fallbackSectionTitle: 'フォールバックプロバイダー(任意)',
fallbackSectionDescription:
'プロバイダーエラー時429、5xxに自動使用。1.5秒以内に1回再試行。',
fallbackProvider: 'フォールバックプロバイダー',
fallbackModel: 'フォールバックモデル',
fallbackNone: 'なし(無効)',
fallbackModelPlaceholder: '例: gpt-4o-mini',
},
ko: {
fallbackSectionTitle: '대체 제공업체(선택)',
fallbackSectionDescription:
'제공업체 오류(429, 5xx) 시 자동 사용. 1.5초 이내 1회 재시도.',
fallbackProvider: '대체 제공업체',
fallbackModel: '대체 모델',
fallbackNone: '없음(비활성화)',
fallbackModelPlaceholder: '예: gpt-4o-mini',
},
zh: {
fallbackSectionTitle: '备用提供商(可选)',
fallbackSectionDescription: '提供商出错时429、5xx自动启用。1.5 秒内重试一次。',
fallbackProvider: '备用提供商',
fallbackModel: '备用模型',
fallbackNone: '无(已禁用)',
fallbackModelPlaceholder: '例如 gpt-4o-mini',
},
}
const brainstormQuota = {
de: {
quotaGuest:
'Der Gastgeber der Sitzung hat sein KI-Kontingent aufgebraucht. Bitte ihn, seinen Tarif zu erweitern.',
quotaHost:
'Sie haben Ihr KI-Kontingent für dieses Brainstorming erreicht. Wechseln Sie den Tarif, um fortzufahren.',
},
es: {
quotaGuest:
'El anfitrión de la sesión ha alcanzado su límite de IA. Pídele que mejore su plan.',
quotaHost:
'Has alcanzado tu límite de IA para este brainstorm. Mejora tu plan para continuar.',
},
it: {
quotaGuest:
"L'host della sessione ha raggiunto il limite IA. Chiedigli di aggiornare il piano.",
quotaHost:
'Hai raggiunto il limite IA per questo brainstorm. Passa a un piano superiore per continuare.',
},
pt: {
quotaGuest:
'O anfitrião da sessão atingiu o limite de IA. Peça-lhe para atualizar o plano.',
quotaHost:
'Atingiu o limite de IA deste brainstorm. Atualize o plano para continuar.',
},
nl: {
quotaGuest:
'De sessiehost heeft zijn AI-limiet bereikt. Vraag om een upgrade van het abonnement.',
quotaHost:
'Je hebt je AI-limiet voor deze brainstorm bereikt. Upgrade je abonnement om door te gaan.',
},
pl: {
quotaGuest:
'Gospodarz sesji wyczerpał limit AI. Poproś go o ulepszenie planu.',
quotaHost:
'Osiągnąłeś limit AI dla tego brainstormu. Ulepsz plan, aby kontynuować.',
},
ru: {
quotaGuest:
'Организатор сессии исчерпал лимит ИИ. Попросите его обновить тариф.',
quotaHost:
'Вы исчерпали лимит ИИ для этого мозгового штурма. Обновите тариф, чтобы продолжить.',
},
ar: {
quotaGuest:
'استنفد مضيف الجلسة حدّ الذكاء الاصطناعي. اطلب منه ترقية خطته.',
quotaHost: 'لقد وصلت إلى حدّ الذكاء الاصطناعي لهذه الجلسة. رقِّ خطتك للمتابعة.',
},
fa: {
quotaGuest:
'میزبان جلسه به سقف هوش مصنوعی رسیده. از او بخواهید طرحش را ارتقا دهد.',
quotaHost:
'به سقف هوش مصنوعی این طوفان فکری رسیدید. برای ادامه، طرح خود را ارتقا دهید.',
},
hi: {
quotaGuest:
'सत्र के होस्ट की AI सीमा समाप्त हो गई है। उनसे अपना प्लान अपग्रेड करने को कहें।',
quotaHost:
'इस ब्रेनस्टॉर्म के लिए आपकी AI सीमा समाप्त हो गई है। जारी रखने के लिए प्लान अपग्रेड करें।',
},
ja: {
quotaGuest:
'セッションのホストがAI利用上限に達しました。プランのアップグレードを依頼してください。',
quotaHost:
'このブレインストームのAI上限に達しました。続けるにはプランをアップグレードしてください。',
},
ko: {
quotaGuest: '세션 호스트의 AI 한도에 도달했습니다. 플랜 업그레이드를 요청하세요.',
quotaHost:
'이 브레인스토밍의 AI 한도에 도달했습니다. 계속하려면 플랜을 업그레이드하세요.',
},
zh: {
quotaGuest: '会话主持人已达到 AI 额度上限。请让对方升级套餐。',
quotaHost: '您已达到此头脑风暴的 AI 额度上限。升级套餐以继续。',
},
}
// Landing blocks — load from generated JSON (keeps this script maintainable)
const landingPatchesPath = path.join(__dirname, 'i18n-landing-patches.json')
if (!fs.existsSync(landingPatchesPath)) {
console.error('Missing i18n-landing-patches.json — run generate-landing-patches first')
process.exit(1)
}
const landingPatches = JSON.parse(fs.readFileSync(landingPatchesPath, 'utf8'))
const TARGET_LANGS = [
'ar',
'de',
'es',
'fa',
'hi',
'it',
'ja',
'ko',
'nl',
'pl',
'pt',
'ru',
'zh',
]
for (const lang of TARGET_LANGS) {
const filePath = path.join(localesDir, `${lang}.json`)
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'))
if (adminAiFallback[lang]) {
if (!data.admin) data.admin = {}
if (!data.admin.ai) data.admin.ai = {}
Object.assign(data.admin.ai, adminAiFallback[lang])
}
if (brainstormQuota[lang]) {
if (!data.brainstorm) data.brainstorm = {}
Object.assign(data.brainstorm, brainstormQuota[lang])
}
if (landingPatches[lang]) {
if (!data.landing) data.landing = {}
deepMerge(data.landing, landingPatches[lang])
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8')
console.log(`${lang}.json updated`)
}
// Verify
const en = flatten(JSON.parse(fs.readFileSync(path.join(localesDir, 'en.json'), 'utf8')))
const enKeys = Object.keys(en)
let ok = true
for (const lang of TARGET_LANGS) {
const loc = flatten(JSON.parse(fs.readFileSync(path.join(localesDir, `${lang}.json`), 'utf8')))
const missing = enKeys.filter((k) => !(k in loc))
if (missing.length) {
console.error(`${lang}: still ${missing.length} missing keys`)
ok = false
}
}
console.log(ok ? '\nAll locales complete.' : '\nSome keys still missing.')

View File

@@ -1,22 +0,0 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const configs = await prisma.systemConfig.findMany()
console.log('--- System Config ---')
configs.forEach(c => {
if (c.key.startsWith('AI_') || c.key.startsWith('OLLAMA_')) {
console.log(`${c.key}: ${c.value}`)
}
})
}
main()
.catch(e => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -1,76 +0,0 @@
// Import directly from the generated client
const { PrismaClient } = require('./node_modules/@prisma/client')
const prisma = new PrismaClient()
async function checkLabels() {
try {
console.log('\n=== TOUS LES LABELS DANS LA TABLE Label ===')
const allLabels = await prisma.label.findMany({
select: { id: true, name: true, userId: true }
})
console.log(`Total: ${allLabels.length} labels`)
allLabels.forEach(l => {
console.log(` - ${l.name} (userId: ${l.userId}, id: ${l.id})`)
})
console.log('\n=== TOUS LES LABELS DANS LES NOTES (Note.labels) ===')
const allNotes = await prisma.note.findMany({
select: { id: true, labels: true, userId: true }
})
const labelsInNotes = new Set()
allNotes.forEach(note => {
if (note.labels) {
try {
const parsed = Array.isArray(note.labels) ? note.labels : []
if (Array.isArray(parsed)) {
parsed.forEach(l => labelsInNotes.add(l))
}
} catch (e) {}
}
})
console.log(`Total unique: ${labelsInNotes.size} labels`)
labelsInNotes.forEach(l => console.log(` - ${l}`))
console.log('\n=== LABELS ORPHELINS (dans Label table MAIS PAS dans les notes) ===')
const orphanLabels = []
allLabels.forEach(label => {
let found = false
labelsInNotes.forEach(noteLabel => {
if (noteLabel.toLowerCase() === label.name.toLowerCase()) {
found = true
}
})
if (!found) {
orphanLabels.push(label)
}
})
console.log(`Total orphans: ${orphanLabels.length}`)
orphanLabels.forEach(l => {
console.log(` - ${l.name} (userId: ${l.userId})`)
})
console.log('\n=== LABELS MANQUANTS (dans notes MAIS PAS dans Label table) ===')
const missingLabels = []
const existingLabelNames = new Set()
allLabels.forEach(l => existingLabelNames.add(l.name.toLowerCase()))
labelsInNotes.forEach(noteLabel => {
if (!existingLabelNames.has(noteLabel.toLowerCase())) {
missingLabels.push(noteLabel)
}
})
console.log(`Total missing: ${missingLabels.length}`)
missingLabels.forEach(l => console.log(` - ${l}`))
} catch (error) {
console.error('Error:', error)
} finally {
await prisma.$disconnect()
}
}
checkLabels()

View File

@@ -1,35 +0,0 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const users = await prisma.user.findMany({
include: {
aiSettings: true,
notes: {
take: 5,
orderBy: { updatedAt: 'desc' }
}
}
})
console.log('Total Users:', users.length)
for (const user of users) {
console.log(`User: ${user.email} (${user.id})`)
console.log(` AI Settings:`, user.aiSettings)
console.log(` Recent 5 Notes:`)
for (const note of user.notes) {
console.log(` ID: ${note.id}, Title: ${note.title}, Updated: ${note.updatedAt}, Dismissed: ${JSON.stringify(note)}`)
}
}
}
main()
.catch(e => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -1,25 +0,0 @@
import { prisma } from '../lib/prisma'
async function main() {
console.log('🔍 Checking users in database...')
console.log('Database URL used:', process.env.DATABASE_URL || "file:./dev.db")
const users = await prisma.user.findMany()
if (users.length === 0) {
console.log('❌ No users found in database!')
} else {
console.log(`✅ Found ${users.length} users:`)
console.table(users.map(u => ({
email: u.email,
role: u.role,
id: u.id,
hasPassword: !!u.password
})))
}
}
main()
.catch(e => console.error(e))
.finally(async () => await prisma.$disconnect())

View File

@@ -1,41 +0,0 @@
import { siteConfig } from '../config/site'
import { PrismaClient } from '@prisma/client'
async function main() {
console.log('🕵️‍♀️ Comparing Databases...')
// 1. Check Root DB
console.log('--- ROOT DB (./dev.db) ---')
const prismaRoot = new PrismaClient({
datasources: { db: { url: 'file:./dev.db' } }
})
try {
const countRoot = await prismaRoot.note.count()
console.log(`📦 Note Count: ${countRoot}`)
const usersRoot = await prismaRoot.user.count()
console.log(`👥 User Count: ${usersRoot}`)
} catch (e) {
console.log('❌ Failed to connect to Root DB', e)
} finally {
await prismaRoot.$disconnect()
}
// 2. Check Prisma Folder DB
console.log('\n--- PRISMA DB (./prisma/dev.db) ---')
const prismaPrisma = new PrismaClient({
datasources: { db: { url: 'file:./prisma/dev.db' } }
})
try {
const countPrisma = await prismaPrisma.note.count()
console.log(`📦 Note Count: ${countPrisma}`)
const usersPrisma = await prismaPrisma.user.count()
console.log(`👥 User Count: ${usersPrisma}`)
} catch (e) {
console.log('❌ Failed to connect to Prisma DB', e)
} finally {
await prismaPrisma.$disconnect()
}
}
main().catch(console.error)

View File

@@ -1,31 +0,0 @@
import { PrismaClient } from '../prisma/client-generated'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
const email = 'test@example.com'
const password = 'password123'
const hashedPassword = await bcrypt.hash(password, 10)
const user = await prisma.user.upsert({
where: { email },
update: { password: hashedPassword },
create: {
email,
name: 'Test User',
password: hashedPassword,
aiSettings: {
create: {
showRecentNotes: true // Ensure this is true!
}
}
}
})
console.log(`User created/updated: ${user.email}`)
}
main()
.catch(e => console.error(e))
.finally(async () => await prisma.$disconnect())

View File

@@ -1,41 +0,0 @@
import prisma from '../lib/prisma'
async function debugConfig() {
console.log('=== System Configuration Debug ===\n')
const configs = await prisma.systemConfig.findMany()
console.log(`Total configs in DB: ${configs.length}\n`)
// Group by category
const aiConfigs = configs.filter(c => c.key.startsWith('AI_'))
const ollamaConfigs = configs.filter(c => c.key.includes('OLLAMA'))
const openaiConfigs = configs.filter(c => c.key.includes('OPENAI'))
console.log('=== AI Provider Configs ===')
aiConfigs.forEach(c => {
console.log(`${c.key}: "${c.value}"`)
})
console.log('\n=== Ollama Configs ===')
ollamaConfigs.forEach(c => {
console.log(`${c.key}: "${c.value}"`)
})
console.log('\n=== OpenAI Configs ===')
openaiConfigs.forEach(c => {
console.log(`${c.key}: "${c.value}"`)
})
console.log('\n=== All Configs ===')
configs.forEach(c => {
console.log(`${c.key}: "${c.value}"`)
})
}
debugConfig()
.then(() => process.exit(0))
.catch((err) => {
console.error(err)
process.exit(1)
})

View File

@@ -1,36 +0,0 @@
import { getAllNotes } from '../app/actions/notes'
import { prisma } from '../lib/prisma'
async function main() {
console.log('🕵️‍♀️ Debugging getAllNotes...')
// 1. Get raw DB data for a sample note
const rawNote = await prisma.note.findFirst({
where: { size: { not: 'small' } }
})
if (rawNote) {
console.log('📊 Raw DB Note (should be large/medium):', {
id: rawNote.id,
size: rawNote.size
})
} else {
console.log('⚠️ No notes with size != small found in DB directly.')
}
// 2. Mock auth/session if needed (actions check session)
// Since we can't easily mock next-auth in this script environment without setup,
// we might need to rely on the direct DB check above or check if getAllNotes extracts userId safely.
// getAllNotes checks `auth()`. In this script context, `auth()` will arguably return null.
// So we can't easily run `getAllNotes` directly if it guards auth.
// Let's modify the plan: We will check the DB directly to confirm PERMANENCE.
// Then we will manually simulate `parseNote` logic.
const notes = await prisma.note.findMany({ take: 5 })
console.log('📋 Checking first 5 notes sizes in DB:')
notes.forEach(n => console.log(`- ${n.id}: ${n.size}`))
}
main().catch(console.error).finally(() => prisma.$disconnect())

View File

@@ -1,60 +0,0 @@
import { PrismaClient } from '../prisma/client-generated'
const prisma = new PrismaClient()
async function main() {
// 1. Get a user
const user = await prisma.user.findFirst()
if (!user) {
console.log('No user found in database.')
return
}
console.log(`Checking for User ID: ${user.id} (${user.email})`)
// 2. Simulate logic from app/actions/notes.ts
const sevenDaysAgo = new Date()
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
sevenDaysAgo.setHours(0, 0, 0, 0)
console.log(`Filtering for notes updated after: ${sevenDaysAgo.toISOString()}`)
// 3. Query Raw
const allNotes = await prisma.note.findMany({
where: { userId: user.id },
select: { id: true, title: true, contentUpdatedAt: true, updatedAt: true, dismissedFromRecent: true, isArchived: true }
})
console.log(`\nTotal Notes for User: ${allNotes.length}`)
// 4. Check "Recent" candidates
const recentCandidates = allNotes.filter(n => {
const noteDate = new Date(n.contentUpdatedAt)
return noteDate >= sevenDaysAgo
})
console.log(`Notes passing date filter: ${recentCandidates.length}`)
recentCandidates.forEach(n => {
console.log(` - [${n.title}] Updated: ${n.contentUpdatedAt.toISOString()} | Dismissed: ${n.dismissedFromRecent} | Archived: ${n.isArchived}`)
})
// 5. Check what the actual query returns
const actualQuery = await prisma.note.findMany({
where: {
userId: user.id,
contentUpdatedAt: { gte: sevenDaysAgo },
isArchived: false,
dismissedFromRecent: false
},
orderBy: { contentUpdatedAt: 'desc' },
take: 3
})
console.log(`\nActual Query Returns: ${actualQuery.length} notes`)
actualQuery.forEach(n => {
console.log(` -> [${n.title}]`)
})
}
main()
.catch(e => console.error(e))
.finally(async () => await prisma.$disconnect())

View File

@@ -1,39 +0,0 @@
import { PrismaClient } from '../prisma/client-generated'
const prisma = new PrismaClient()
async function main() {
console.log('Updating user settings to show recent notes...')
const updateResult = await prisma.userAISettings.updateMany({
data: {
showRecentNotes: true
}
})
console.log(`Updated ${updateResult.count} user settings.`)
// Verify and Create missing
const users = await prisma.user.findMany({
include: { aiSettings: true }
})
for (const u of users) {
if (!u.aiSettings) {
console.log(`User ${u.id} has no settings. Creating default...`)
await prisma.userAISettings.create({
data: {
userId: u.id,
showRecentNotes: true
}
})
console.log(`Created settings for ${u.id}`)
} else {
console.log(`User ${u.id}: showRecentNotes = ${u.aiSettings.showRecentNotes}`)
}
}
}
main()
.catch(e => console.error(e))
.finally(async () => await prisma.$disconnect())

View File

@@ -1,145 +0,0 @@
#!/usr/bin/env python3
"""Generate flat i18n-overrides/*.json for keys still equal to en while fr differs."""
from __future__ import annotations
import json
import re
import sys
import time
from pathlib import Path
from deep_translator import GoogleTranslator
ROOT = Path(__file__).resolve().parents[1]
LOCALES = ROOT / "locales"
OUT_DIR = Path(__file__).resolve().parent / "i18n-overrides"
LANG_TARGETS = {
"ja": "ja",
"ko": "ko",
"zh": "zh-CN",
"hi": "hi",
"fa": "fa",
}
MISSING_11 = [
"ai.featureLocked",
"ai.quotaExceeded",
"profile.tab",
"about.tab",
"appearance.tab",
"usageMeter.featureReformulate",
"usageMeter.featureChat",
"usageMeter.featureBrainstormCreate",
"usageMeter.featureBrainstormExpand",
"usageMeter.featureBrainstormEnrich",
"billing.tab",
]
PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹"
def flatten_leaves(obj: dict, prefix: str = "") -> dict[str, str]:
out: dict[str, str] = {}
for k, v in obj.items():
path = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
out.update(flatten_leaves(v, path))
elif isinstance(v, str):
out[path] = v
return out
def to_persian_digits(text: str) -> str:
return re.sub(r"\d", lambda m: PERSIAN_DIGITS[int(m.group())], text)
def collect_override_keys(en: dict[str, str], fr: dict[str, str], loc: dict[str, str], lang: str) -> list[str]:
fr_translated = [k for k in en if k in fr and fr[k] != en[k]]
keys = {k for k in fr_translated if loc.get(k, en.get(k)) == en[k]}
if lang != "fa":
keys.update(MISSING_11)
return sorted(keys)
def translate_unique(texts: list[str], target_code: str, batch_size: int = 30) -> dict[str, str]:
translator = GoogleTranslator(source="en", target=target_code)
mapping: dict[str, str] = {}
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
try:
outs = translator.translate_batch(batch)
except Exception as e:
print(f" batch error ({e}), per-item…", flush=True)
outs = []
for t in batch:
try:
outs.append(translator.translate(t))
except Exception:
outs.append(t)
time.sleep(0.2)
if not isinstance(outs, list) or len(outs) != len(batch):
outs = batch
for src, dst in zip(batch, outs):
mapping[src] = dst if isinstance(dst, str) and dst.strip() else src
time.sleep(0.5)
print(f"{min(i + batch_size, len(texts))}/{len(texts)}", flush=True)
return mapping
def post_process(lang: str, key: str, en_val: str, translated: str) -> str:
if lang == "fa" and re.search(r"\d", translated):
translated = to_persian_digits(translated)
# Momento note-taking: keep common product tokens
if key.endswith(".technology.ai") or key == "about.technology.ai":
if lang == "ja":
return "AI"
if lang == "zh":
return "AI"
if lang == "ko":
return "AI"
if lang == "hi":
return "AI"
if lang == "fa":
return "هوش مصنوعی"
if key == "about.technology.ui":
ui_map = {"ja": "UI", "ko": "UI", "zh": "界面", "hi": "UI", "fa": "رابط کاربری"}
return ui_map.get(lang, translated)
return translated
def main() -> int:
en = flatten_leaves(json.loads((LOCALES / "en.json").read_text(encoding="utf-8")))
fr = flatten_leaves(json.loads((LOCALES / "fr.json").read_text(encoding="utf-8")))
OUT_DIR.mkdir(parents=True, exist_ok=True)
for lang, google_target in LANG_TARGETS.items():
loc = flatten_leaves(json.loads((LOCALES / f"{lang}.json").read_text(encoding="utf-8")))
keys = collect_override_keys(en, fr, loc, lang)
print(f"\n=== {lang}.json — {len(keys)} override keys", flush=True)
text_to_keys: dict[str, list[str]] = {}
for k in keys:
text_to_keys.setdefault(en[k], []).append(k)
unique = list(text_to_keys.keys())
trans_map = translate_unique(unique, google_target)
overrides: dict[str, str] = {}
for src, key_list in text_to_keys.items():
tr = trans_map.get(src, src)
for k in key_list:
overrides[k] = post_process(lang, k, en[k], tr)
out_path = OUT_DIR / f"{lang}.json"
out_path.write_text(
json.dumps(overrides, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f" Wrote {out_path} ({len(overrides)} keys)", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,24 +0,0 @@
import { prisma } from '../lib/prisma'
async function main() {
console.log('👑 Granting ADMIN access to ALL users...')
try {
const result = await prisma.user.updateMany({
data: {
role: 'ADMIN'
}
})
console.log(`✅ Success! Updated ${result.count} users to ADMIN role.`)
} catch (error) {
console.error('❌ Error updating users:', error)
process.exit(1)
}
}
main()
.catch(e => console.error(e))
.finally(async () => await prisma.$disconnect())

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +0,0 @@
/**
* Ajoute récursivement les clés présentes dans locales/en.json mais absentes
* des autres fichiers (valeur = texte anglais, à retraduire si besoin).
* Usage: node scripts/merge-missing-locale-keys.mjs
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const localesDir = path.join(__dirname, '..', 'locales')
const enPath = path.join(localesDir, 'en.json')
function mergeMissing(target, source) {
for (const [k, v] of Object.entries(source)) {
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
if (!target[k] || typeof target[k] !== 'object' || Array.isArray(target[k])) {
target[k] = {}
}
mergeMissing(target[k], v)
} else {
if (!(k in target)) target[k] = v
}
}
}
const en = JSON.parse(fs.readFileSync(enPath, 'utf8'))
for (const name of fs.readdirSync(localesDir)) {
if (!name.endsWith('.json') || name === 'en.json') continue
const p = path.join(localesDir, name)
const loc = JSON.parse(fs.readFileSync(p, 'utf8'))
mergeMissing(loc, en)
fs.writeFileSync(p, JSON.stringify(loc, null, 2) + '\n')
}
console.log('merge-missing-locale-keys: OK')

View File

@@ -1,69 +0,0 @@
// scripts/migrate-embeddings.ts
// Re-indexes all notes that lack a NoteEmbedding row using pgvector format.
// Run with: npx tsx scripts/migrate-embeddings.ts
const { PrismaClient } = require('../node_modules/.prisma/client')
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL
}
}
})
async function main() {
console.log('Fetching notes without embeddings...')
const notes = await prisma.note.findMany({
where: {
trashedAt: null,
noteEmbedding: { is: null }
},
select: {
id: true,
content: true,
title: true
}
})
console.log(`Found ${notes.length} notes without an embedding.`)
if (notes.length === 0) {
console.log('Nothing to migrate.')
return
}
let count = 0
let failed = 0
for (const note of notes) {
if (!note.content) continue
try {
// Embedding will be generated by the indexNote method which handles pgvector format
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, '[0]'::vector(1536), now(), now())
ON CONFLICT ("noteId") DO NOTHING`,
note.id
)
count++
if (count % 10 === 0) {
console.log(`Placeholder for ${count}/${notes.length}...`)
}
} catch (e) {
failed++
console.error(`Failed for note ${note.id}:`, e.message)
}
}
console.log(`Created ${count} embedding placeholders (${failed} failed).`)
console.log('Run /api/notes/reindex to populate with real embeddings.')
}
main()
.catch((e) => {
console.error('Migration failed:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -1,387 +0,0 @@
/**
* One-shot migration script: SQLite → PostgreSQL
*
* Reads data from the SQLite backup (prisma/dev.db) via better-sqlite3,
* connects to PostgreSQL via Prisma, and inserts all rows while converting
* JSON string fields to native objects (for Prisma Json type).
*
* Usage:
* DATABASE_URL="postgresql://keepnotes:keepnotes@localhost:5432/keepnotes" \
* npx tsx scripts/migrate-sqlite-to-postgres.ts
*
* Prerequisites:
* - PostgreSQL running and accessible via DATABASE_URL
* - prisma migrate deploy already run (schema exists)
* - better-sqlite3 still installed (temporary)
*/
import Database from 'better-sqlite3'
import { PrismaClient } from '../prisma/client-generated'
import * as path from 'path'
const SQLITE_PATH = path.join(__dirname, '..', 'prisma', 'dev.db')
// Parse a JSON string field, returning null if empty/invalid
function parseJsonField(raw: any): any {
if (raw === null || raw === undefined) return null
if (typeof raw !== 'string') return raw
if (raw === '' || raw === 'null') return null
try {
return JSON.parse(raw)
} catch {
return null
}
}
// Parse labels specifically — always return array or null
function parseLabels(raw: any): string[] | null {
const parsed = parseJsonField(raw)
if (Array.isArray(parsed)) return parsed
return null
}
// Parse embedding — always return number[] or null
function parseEmbedding(raw: any): number[] | null {
const parsed = parseJsonField(raw)
if (Array.isArray(parsed)) return parsed
return null
}
async function main() {
console.log('╔══════════════════════════════════════════════════════════╗')
console.log('║ SQLite → PostgreSQL Migration ║')
console.log('╚══════════════════════════════════════════════════════════╝')
console.log()
// 1. Open SQLite
let sqlite: Database.Database
try {
sqlite = new Database(SQLITE_PATH, { readonly: true })
console.log(`✓ SQLite opened: ${SQLITE_PATH}`)
} catch (e) {
console.error(`✗ Cannot open SQLite at ${SQLITE_PATH}: ${e}`)
process.exit(1)
}
// 2. Connect to PostgreSQL via Prisma
const prisma = new PrismaClient()
console.log(`✓ PostgreSQL connected via Prisma`)
console.log()
// Helper to read all rows from SQLite
function allRows(sql: string): any[] {
return sqlite.prepare(sql).all() as any[]
}
let totalInserted = 0
// ── User ──────────────────────────────────────────────────
console.log('Migrating User...')
const users = allRows('SELECT * FROM User')
for (const u of users) {
await prisma.user.upsert({
where: { id: u.id },
update: {},
create: {
id: u.id,
name: u.name,
email: u.email,
emailVerified: u.emailVerified ? new Date(u.emailVerified) : null,
password: u.password,
role: u.role || 'USER',
image: u.image,
theme: u.theme || 'light',
resetToken: u.resetToken,
resetTokenExpiry: u.resetTokenExpiry ? new Date(u.resetTokenExpiry) : null,
createdAt: u.createdAt ? new Date(u.createdAt) : new Date(),
updatedAt: u.updatedAt ? new Date(u.updatedAt) : new Date(),
}
})
}
console.log(`${users.length} users`)
totalInserted += users.length
// ── Account ───────────────────────────────────────────────
console.log('Migrating Account...')
const accounts = allRows('SELECT * FROM Account')
for (const a of accounts) {
await prisma.account.create({
data: {
userId: a.userId,
type: a.type,
provider: a.provider,
providerAccountId: a.providerAccountId,
refresh_token: a.refresh_token,
access_token: a.access_token,
expires_at: a.expires_at,
token_type: a.token_type,
scope: a.scope,
id_token: a.id_token,
session_state: a.session_state,
createdAt: a.createdAt ? new Date(a.createdAt) : new Date(),
updatedAt: a.updatedAt ? new Date(a.updatedAt) : new Date(),
}
}).catch(() => {}) // skip duplicates
}
console.log(`${accounts.length} accounts`)
totalInserted += accounts.length
// ── Session ───────────────────────────────────────────────
console.log('Migrating Session...')
const sessions = allRows('SELECT * FROM Session')
for (const s of sessions) {
await prisma.session.create({
data: {
sessionToken: s.sessionToken,
userId: s.userId,
expires: s.expires ? new Date(s.expires) : new Date(),
createdAt: s.createdAt ? new Date(s.createdAt) : new Date(),
updatedAt: s.updatedAt ? new Date(s.updatedAt) : new Date(),
}
}).catch(() => {})
}
console.log(`${sessions.length} sessions`)
totalInserted += sessions.length
// ── Notebook ──────────────────────────────────────────────
console.log('Migrating Notebook...')
const notebooks = allRows('SELECT * FROM Notebook')
for (const nb of notebooks) {
await prisma.notebook.create({
data: {
id: nb.id,
name: nb.name,
icon: nb.icon,
color: nb.color,
order: nb.order ?? 0,
userId: nb.userId,
createdAt: nb.createdAt ? new Date(nb.createdAt) : new Date(),
updatedAt: nb.updatedAt ? new Date(nb.updatedAt) : new Date(),
}
}).catch(() => {})
}
console.log(`${notebooks.length} notebooks`)
totalInserted += notebooks.length
// ── Label ─────────────────────────────────────────────────
console.log('Migrating Label...')
const labels = allRows('SELECT * FROM Label')
for (const l of labels) {
await prisma.label.create({
data: {
id: l.id,
name: l.name,
color: l.color || 'gray',
notebookId: l.notebookId,
userId: l.userId,
createdAt: l.createdAt ? new Date(l.createdAt) : new Date(),
updatedAt: l.updatedAt ? new Date(l.updatedAt) : new Date(),
}
}).catch(() => {})
}
console.log(`${labels.length} labels`)
totalInserted += labels.length
// ── Note ──────────────────────────────────────────────────
console.log('Migrating Note...')
const notes = allRows('SELECT * FROM Note')
let noteCount = 0
for (const n of notes) {
await prisma.note.create({
data: {
id: n.id,
title: n.title,
content: n.content || '',
color: n.color || 'default',
isPinned: n.isPinned === 1 || n.isPinned === true,
isArchived: n.isArchived === 1 || n.isArchived === true,
type: n.type || 'text',
dismissedFromRecent: n.dismissedFromRecent === 1 || n.dismissedFromRecent === true,
checkItems: parseJsonField(n.checkItems),
labels: parseLabels(n.labels),
images: parseJsonField(n.images),
links: parseJsonField(n.links),
reminder: n.reminder ? new Date(n.reminder) : null,
isReminderDone: n.isReminderDone === 1 || n.isReminderDone === true,
reminderRecurrence: n.reminderRecurrence,
reminderLocation: n.reminderLocation,
isMarkdown: n.isMarkdown === 1 || n.isMarkdown === true,
size: n.size || 'small',
embedding: parseEmbedding(n.embedding),
sharedWith: parseJsonField(n.sharedWith),
userId: n.userId,
order: n.order ?? 0,
notebookId: n.notebookId,
createdAt: n.createdAt ? new Date(n.createdAt) : new Date(),
updatedAt: n.updatedAt ? new Date(n.updatedAt) : new Date(),
contentUpdatedAt: n.contentUpdatedAt ? new Date(n.contentUpdatedAt) : new Date(),
autoGenerated: n.autoGenerated === 1 ? true : n.autoGenerated === 0 ? false : null,
aiProvider: n.aiProvider,
aiConfidence: n.aiConfidence,
language: n.language,
languageConfidence: n.languageConfidence,
lastAiAnalysis: n.lastAiAnalysis ? new Date(n.lastAiAnalysis) : null,
}
}).catch((e) => {
console.error(` Failed note ${n.id}: ${e.message}`)
})
noteCount++
}
console.log(`${noteCount} notes`)
totalInserted += noteCount
// ── NoteShare ─────────────────────────────────────────────
console.log('Migrating NoteShare...')
const noteShares = allRows('SELECT * FROM NoteShare')
for (const ns of noteShares) {
await prisma.noteShare.create({
data: {
id: ns.id,
noteId: ns.noteId,
userId: ns.userId,
sharedBy: ns.sharedBy,
status: ns.status || 'pending',
permission: ns.permission || 'view',
notifiedAt: ns.notifiedAt ? new Date(ns.notifiedAt) : null,
respondedAt: ns.respondedAt ? new Date(ns.respondedAt) : null,
createdAt: ns.createdAt ? new Date(ns.createdAt) : new Date(),
updatedAt: ns.updatedAt ? new Date(ns.updatedAt) : new Date(),
}
}).catch(() => {})
}
console.log(`${noteShares.length} note shares`)
totalInserted += noteShares.length
// ── AiFeedback ────────────────────────────────────────────
console.log('Migrating AiFeedback...')
const aiFeedbacks = allRows('SELECT * FROM AiFeedback')
for (const af of aiFeedbacks) {
await prisma.aiFeedback.create({
data: {
id: af.id,
noteId: af.noteId,
userId: af.userId,
feedbackType: af.feedbackType,
feature: af.feature,
originalContent: af.originalContent || '',
correctedContent: af.correctedContent,
metadata: parseJsonField(af.metadata),
createdAt: af.createdAt ? new Date(af.createdAt) : new Date(),
}
}).catch(() => {})
}
console.log(`${aiFeedbacks.length} ai feedbacks`)
totalInserted += aiFeedbacks.length
// ── MemoryEchoInsight ─────────────────────────────────────
console.log('Migrating MemoryEchoInsight...')
const insights = allRows('SELECT * FROM MemoryEchoInsight')
for (const mi of insights) {
await prisma.memoryEchoInsight.create({
data: {
id: mi.id,
userId: mi.userId,
note1Id: mi.note1Id,
note2Id: mi.note2Id,
similarityScore: mi.similarityScore ?? 0,
insight: mi.insight || '',
insightDate: mi.insightDate ? new Date(mi.insightDate) : new Date(),
viewed: mi.viewed === 1 || mi.viewed === true,
feedback: mi.feedback,
dismissed: mi.dismissed === 1 || mi.dismissed === true,
}
}).catch(() => {})
}
console.log(`${insights.length} memory echo insights`)
totalInserted += insights.length
// ── UserAISettings ────────────────────────────────────────
console.log('Migrating UserAISettings...')
const aiSettings = allRows('SELECT * FROM UserAISettings')
for (const s of aiSettings) {
await prisma.userAISettings.create({
data: {
userId: s.userId,
titleSuggestions: s.titleSuggestions === 1 || s.titleSuggestions === true,
semanticSearch: s.semanticSearch === 1 || s.semanticSearch === true,
paragraphRefactor: s.paragraphRefactor === 1 || s.paragraphRefactor === true,
memoryEcho: s.memoryEcho === 1 || s.memoryEcho === true,
memoryEchoFrequency: s.memoryEchoFrequency || 'daily',
aiProvider: s.aiProvider || 'auto',
preferredLanguage: s.preferredLanguage || 'auto',
fontSize: s.fontSize || 'medium',
demoMode: s.demoMode === 1 || s.demoMode === true,
showRecentNotes: s.showRecentNotes === 1 || s.showRecentNotes === true,
emailNotifications: s.emailNotifications === 1 || s.emailNotifications === true,
desktopNotifications: s.desktopNotifications === 1 || s.desktopNotifications === true,
anonymousAnalytics: s.anonymousAnalytics === 1 || s.anonymousAnalytics === true,
}
}).catch(() => {})
}
console.log(`${aiSettings.length} user AI settings`)
totalInserted += aiSettings.length
// ── SystemConfig ──────────────────────────────────────────
console.log('Migrating SystemConfig...')
const configs = allRows('SELECT * FROM SystemConfig')
for (const c of configs) {
await prisma.systemConfig.create({
data: {
key: c.key,
value: c.value,
}
}).catch(() => {})
}
console.log(`${configs.length} system configs`)
totalInserted += configs.length
// ── _LabelToNote (many-to-many relations) ─────────────────
console.log('Migrating Label-Note relations...')
let relationCount = 0
try {
const relations = allRows('SELECT * FROM _LabelToNote')
for (const r of relations) {
await prisma.note.update({
where: { id: r.B },
data: {
labelRelations: { connect: { id: r.A } }
}
}).catch(() => {})
relationCount++
}
} catch {
// Table may not exist in older SQLite databases
console.log(' → _LabelToNote table not found, skipping')
}
console.log(`${relationCount} label-note relations`)
totalInserted += relationCount
// ── VerificationToken ─────────────────────────────────────
console.log('Migrating VerificationToken...')
const tokens = allRows('SELECT * FROM VerificationToken')
for (const t of tokens) {
await prisma.verificationToken.create({
data: {
identifier: t.identifier,
token: t.token,
expires: t.expires ? new Date(t.expires) : new Date(),
}
}).catch(() => {})
}
console.log(`${tokens.length} verification tokens`)
totalInserted += tokens.length
// Cleanup
sqlite.close()
await prisma.$disconnect()
console.log()
console.log('╔══════════════════════════════════════════════════════════╗')
console.log(`║ Migration complete: ${totalInserted} total rows inserted ║`)
console.log('╚══════════════════════════════════════════════════════════╝')
}
main().catch((e) => {
console.error('Migration failed:', e)
process.exit(1)
})

View File

@@ -1,35 +0,0 @@
const { PrismaClient } = require('../prisma/client-generated');
const prisma = new PrismaClient();
async function promoteAdmin() {
const email = process.argv[2];
try {
let user;
if (email) {
user = await prisma.user.findUnique({ where: { email } });
} else {
console.log("Aucun email fourni, promotion du premier utilisateur trouvé...");
user = await prisma.user.findFirst();
}
if (!user) {
console.error("Aucun utilisateur trouvé.");
return;
}
await prisma.user.update({
where: { id: user.id },
data: { role: 'ADMIN' }
});
console.log(`Succès : L'utilisateur ${user.email} (${user.name}) est maintenant ADMIN.`);
} catch (e) {
console.error("Erreur :", e);
} finally {
await prisma.$disconnect();
}
}
promoteAdmin();

View File

@@ -1,67 +0,0 @@
import { PrismaClient } from '../prisma/client-generated'
import { getAIProvider } from '../lib/ai/factory'
import { getSystemConfig } from '../lib/config'
const prisma = new PrismaClient()
async function regenerateAllEmbeddings() {
console.log('🔄 Starting embedding regeneration...\n')
// Get all notes
const notes = await prisma.note.findMany({
select: {
id: true,
title: true,
content: true
}
})
console.log(`📝 Found ${notes.length} notes to process\n`)
// Get AI provider
const config = await getSystemConfig()
const provider = getAIProvider(config)
console.log(`🤖 Using AI provider...`)
let successCount = 0
let errorCount = 0
for (const note of notes) {
try {
const title = note.title || '(no title)'
process.stdout.write(`\r⏳ Processing ${successCount + 1}/${notes.length}: ${title.substring(0, 40)}...`)
// Generate new embedding
const embedding = await provider.getEmbeddings(note.content)
if (embedding) {
// Update note with new embedding
await prisma.note.update({
where: { id: note.id },
data: {
embedding
}
})
successCount++
} else {
errorCount++
console.log(`\n❌ Failed: ${title} (no embedding generated)`)
}
} catch (error) {
errorCount++
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
console.log(`\n❌ Error: ${note.title || '(no title)'} - ${errorMessage}`)
}
}
console.log(`\n\n📊 Summary:`)
console.log(` ✅ Success: ${successCount}/${notes.length}`)
console.log(` ❌ Errors: ${errorCount}/${notes.length}`)
console.log('\n✨ Embeddings regenerated successfully!')
await prisma.$disconnect()
}
regenerateAllEmbeddings().catch(console.error)

View File

@@ -1,47 +0,0 @@
import { prisma } from '../lib/prisma'
import bcrypt from 'bcryptjs'
async function main() {
const email = process.argv[2]
const newPassword = process.argv[3]
if (!email || !newPassword) {
console.error('Usage: npx tsx scripts/reset-password-auto.ts <email> <new-password>')
console.error('Example: npx tsx scripts/reset-password-auto.ts user@example.com mynewpassword')
process.exit(1)
}
if (newPassword.length < 6) {
console.error('Password must be at least 6 characters')
process.exit(1)
}
console.log(`Resetting password for ${email}...`)
const hashedPassword = await bcrypt.hash(newPassword, 10)
try {
const user = await prisma.user.update({
where: { email },
data: {
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null
},
})
console.log(`Password successfully reset for ${user.email}`)
} catch (error) {
console.error('Error resetting password:', error)
process.exit(1)
}
}
main()
.catch(e => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -1,22 +0,0 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
const hashedPassword = await bcrypt.hash('password123', 10)
const user = await prisma.user.upsert({
where: { email: 'test@example.com' },
update: {},
create: {
email: 'test@example.com',
name: 'Test User',
password: hashedPassword,
},
})
console.log('User created:', user)
}
main()
.catch(e => console.error(e))
.finally(async () => await prisma.$disconnect())

View File

@@ -1,48 +0,0 @@
import prisma from '../lib/prisma'
/**
* Setup OpenAI as default AI provider in database
* Run this to ensure OpenAI is properly configured
*/
async function setupOpenAI() {
console.log('🔧 Setting up OpenAI as default AI provider...\n')
const configs = [
{ key: 'AI_PROVIDER_TAGS', value: 'openai' },
{ key: 'AI_PROVIDER_EMBEDDING', value: 'openai' },
{ key: 'AI_MODEL_TAGS', value: 'gpt-4o-mini' },
{ key: 'AI_MODEL_EMBEDDING', value: 'text-embedding-3-small' },
]
try {
for (const config of configs) {
await prisma.systemConfig.upsert({
where: { key: config.key },
update: { value: config.value },
create: { key: config.key, value: config.value }
})
console.log(`✅ Set ${config.key} = ${config.value}`)
}
console.log('\n✨ OpenAI configuration complete!')
console.log('\nNext steps:')
console.log('1. Add your OPENAI_API_KEY in admin settings: http://localhost:3000/admin/settings')
console.log('2. Or add it to .env.docker: OPENAI_API_KEY=sk-...')
console.log('3. Restart the application')
// Verify
const verify = await prisma.systemConfig.findMany({
where: { key: { in: configs.map(c => c.key) } }
})
console.log('\n✅ Verification:')
verify.forEach(c => console.log(` ${c.key}: ${c.value}`))
} catch (error) {
console.error('❌ Error:', error)
process.exit(1)
}
}
setupOpenAI()
.then(() => process.exit(0))
.catch(() => process.exit(1))

View File

@@ -1,163 +0,0 @@
#!/usr/bin/env python3
"""
Synchronise locales/*.json avec locales/en.json (référence des clés).
- Garde les chaînes déjà présentes et non vides dans la locale cible.
- Traduit les clés manquantes (Google via deep-translator).
Usage : depuis memento-note/ avec le venv :
.venv-i18n/bin/python scripts/sync_locales_from_en.py
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
from deep_translator import GoogleTranslator
ROOT = Path(__file__).resolve().parents[1]
LOCALES = ROOT / "locales"
# Codes Google Translate pour deep-translator (source=en)
LANG_TARGETS = {
"fr": "fr",
"es": "es",
"de": "de",
"fa": "fa",
"it": "it",
"pt": "pt",
"ru": "ru",
"zh": "zh-CN",
"ja": "ja",
"ko": "ko",
"ar": "ar",
"hi": "hi",
"nl": "nl",
"pl": "pl",
}
def flatten_leaves(obj: dict, prefix: str = "") -> dict[str, str]:
out: dict[str, str] = {}
for k, v in obj.items():
path = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
out.update(flatten_leaves(v, path))
elif isinstance(v, str):
out[path] = v
else:
raise TypeError(f"Valeur non supportée à {path}: {type(v)}")
return out
def unflatten_leaves(flat: dict[str, str]) -> dict:
root: dict = {}
for path, val in flat.items():
parts = path.split(".")
cur = root
for p in parts[:-1]:
cur = cur.setdefault(p, {})
cur[parts[-1]] = val
return root
def translate_unique(texts: list[str], target_code: str, batch_size: int = 35) -> dict[str, str]:
translator = GoogleTranslator(source="en", target=target_code)
mapping: dict[str, str] = {}
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
try:
outs = translator.translate_batch(batch)
except Exception as e:
print(f" batch erreur ({e}), traduction unitaire…", flush=True)
outs = []
for t in batch:
try:
outs.append(translator.translate(t))
except Exception:
outs.append(t)
time.sleep(0.15)
if len(outs) != len(batch):
outs = batch # fallback
for src, dst in zip(batch, outs):
mapping[src] = dst if isinstance(dst, str) else src
time.sleep(0.6)
print(f"{min(i + batch_size, len(texts))}/{len(texts)}", flush=True)
return mapping
def merge_locale(en_flat: dict[str, str], loc_flat: dict[str, str], target_code: str) -> dict[str, str]:
text_to_keys: dict[str, list[str]] = {}
result: dict[str, str] = {}
for key, en_val in en_flat.items():
loc_val = loc_flat.get(key)
if isinstance(loc_val, str) and loc_val.strip():
result[key] = loc_val
else:
text_to_keys.setdefault(en_val, []).append(key)
if not text_to_keys:
return result
unique = list(text_to_keys.keys())
print(f" {len(unique)} textes uniques à traduire ({sum(len(v) for v in text_to_keys.values())} clés)", flush=True)
trans_map = translate_unique(unique, target_code)
for src, keys in text_to_keys.items():
tr = trans_map.get(src, src)
for k in keys:
result[k] = tr
return result
def main() -> int:
en_path = LOCALES / "en.json"
if not en_path.exists():
print("locales/en.json introuvable", file=sys.stderr)
return 1
en_obj = json.loads(en_path.read_text(encoding="utf-8"))
en_flat = flatten_leaves(en_obj)
print(f"Référence en.json : {len(en_flat)} clés feuilles", flush=True)
skip = {"en"}
for code, google_target in LANG_TARGETS.items():
if code in skip:
continue
path = LOCALES / f"{code}.json"
if not path.exists():
print(f"Absence de {path.name}, ignoré", flush=True)
continue
loc_obj = json.loads(path.read_text(encoding="utf-8"))
loc_flat = flatten_leaves(loc_obj)
before_missing = sum(1 for k in en_flat if k not in loc_flat or not str(loc_flat.get(k, "")).strip())
if before_missing == 0:
print(f"\n=== {code}.json — déjà complet ({len(en_flat)} clés), rien à faire", flush=True)
continue
print(f"\n=== {code}.json ({google_target}) — manquantes avant: {before_missing}", flush=True)
merged_flat = merge_locale(en_flat, loc_flat, google_target)
# Couverture complète des clés en
out_flat = dict(en_flat)
out_flat.update(merged_flat)
missing_after = sum(1 for k in en_flat if k not in out_flat or not str(out_flat[k]).strip())
if missing_after:
print(f"ERREUR: encore {missing_after} clés vides pour {code}", file=sys.stderr)
return 1
tree = unflatten_leaves(out_flat)
path.write_text(json.dumps(tree, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f" Écrit {path.name}", flush=True)
print("\nTerminé.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,40 +0,0 @@
import { prisma } from '../lib/prisma'
function parseNote(dbNote: any) {
return {
...dbNote,
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
images: dbNote.images ? JSON.parse(dbNote.images) : null,
links: dbNote.links ? JSON.parse(dbNote.links) : null,
sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [],
size: dbNote.size || 'small',
}
}
async function main() {
console.log('Testing parseNote logic...')
const rawNote = await prisma.note.findFirst({
where: { size: 'large' }
})
if (!rawNote) {
console.error('No large note found in DB.')
return
}
console.log('Raw Note from DB:', { id: rawNote.id, size: rawNote.size })
const parsed = parseNote(rawNote)
console.log('Parsed Note:', { id: parsed.id, size: parsed.size })
if (parsed.size === 'large') {
console.log('parseNote preserves size correctly.')
} else {
console.error('parseNote returned wrong size:', parsed.size)
}
}
main().catch(console.error).finally(() => prisma.$disconnect())

View File

@@ -1,41 +0,0 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const OLD_COLOR = '#75B2D6'
const NEW_COLOR = '#A47148'
console.log(`Updating colors from ${OLD_COLOR} to ${NEW_COLOR}...`)
// Update Notebooks
const updatedNotebooks = await prisma.notebook.updateMany({
where: { color: OLD_COLOR },
data: { color: NEW_COLOR }
})
console.log(`Updated ${updatedNotebooks.count} notebooks.`)
// Update Labels (if any use this color)
const updatedLabels = await prisma.label.updateMany({
where: { color: OLD_COLOR },
data: { color: NEW_COLOR }
})
console.log(`Updated ${updatedLabels.count} labels.`)
// Update Notes (some notes might have this as a string color in metadata or field)
// Note.color is usually "default", but let's check
const updatedNotes = await prisma.note.updateMany({
where: { color: OLD_COLOR },
data: { color: NEW_COLOR }
})
console.log(`Updated ${updatedNotes.count} notes.`)
}
main()
.catch(e => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -1,56 +0,0 @@
import { prisma } from '../lib/prisma'
import { updateSize } from '../app/actions/notes'
async function main() {
console.log('🧪 Starting Note Size Persistence Verification...')
// 1. Create a test note
const note = await prisma.note.create({
data: {
content: 'Size Test Note',
userId: (await prisma.user.findFirst())?.id || '',
size: 'small', // Start small
}
})
console.log(`📝 Created test note (${note.id}) with size: ${note.size}`)
if (!note.userId) {
console.error('❌ No user found to create note. Aborting.')
return
}
try {
// 2. Update size to LARGE
console.log('🔄 Updating size to LARGE...')
// We mock the session for the action or call prisma directly if action fails (actions usually need auth context)
// Since we're running as script, we'll use prisma update directly to simulate what the action does at DB level
// OR we can try to invoke the action if we can mock auth.
// Let's test the DB interaction first which is the critical "persistence" part.
await prisma.note.update({
where: { id: note.id },
data: { size: 'large' }
})
// 3. Fetch back
const updatedNote = await prisma.note.findUnique({ where: { id: note.id } })
console.log(`🔍 Fetched note after update. Size is: ${updatedNote?.size}`)
if (updatedNote?.size === 'large') {
console.log('✅ BACKEND PERSISTENCE: PASSED')
} else {
console.error('❌ BACKEND PERSISTENCE: FAILED (Size reverted or did not update)')
}
} catch (error) {
console.error('❌ Error during test:', error)
} finally {
// Cleanup
await prisma.note.delete({ where: { id: note.id } })
console.log('🧹 Cleaned up test note')
await prisma.$disconnect()
}
}
main().catch(console.error)

View File

@@ -1,81 +0,0 @@
components/contextual-ai-chat.tsx:
components/contextual-ai-chat.tsx:ai.actionError
components/contextual-ai-chat.tsx:ai.allMyNotes
components/contextual-ai-chat.tsx:ai.appliedToNote
components/contextual-ai-chat.tsx:ai.applyToNote
components/contextual-ai-chat.tsx:ai.askAboutThisNote
components/contextual-ai-chat.tsx:ai.askAboutYourNotes
components/contextual-ai-chat.tsx:ai.askToStart
components/contextual-ai-chat.tsx:ai.assistantTitle
components/contextual-ai-chat.tsx:ai.chatTab
components/contextual-ai-chat.tsx:ai.contextLabel
components/contextual-ai-chat.tsx:ai.currentNote
components/contextual-ai-chat.tsx:ai.discardAction
components/contextual-ai-chat.tsx:ai.expandPanel
components/contextual-ai-chat.tsx:ai.genericError
components/contextual-ai-chat.tsx:ai.minWordsError
components/contextual-ai-chat.tsx:ai.newLineHint
components/contextual-ai-chat.tsx:ai.noImagesError
components/contextual-ai-chat.tsx:ai.noteActions
components/contextual-ai-chat.tsx:ai.notebookGeneric
components/contextual-ai-chat.tsx:ai.overview
components/contextual-ai-chat.tsx:ai.processingAction
components/contextual-ai-chat.tsx:ai.resultLabel
components/contextual-ai-chat.tsx:ai.selectContext
components/contextual-ai-chat.tsx:ai.shrinkPanel
components/contextual-ai-chat.tsx:ai.thisNote
components/contextual-ai-chat.tsx:ai.transformationsDesc
components/contextual-ai-chat.tsx:ai.undoLastAction
components/contextual-ai-chat.tsx:ai.webSearchLabel
components/contextual-ai-chat.tsx:ai.writeMinWordsAction
components/contextual-ai-chat.tsx:ai.writingTone
components/note-input.tsx:
components/note-input.tsx:@/app/actions/ai-settings
components/note-input.tsx:T
components/note-input.tsx:ai.genericError
components/note-input.tsx:ai.reformulationApplied
components/note-input.tsx:ai.reformulationFailed
components/note-input.tsx:ai.reformulationMaxWords
components/note-input.tsx:ai.transformError
components/note-input.tsx:ai.transformSuccess
components/note-input.tsx:general.add
components/note-input.tsx:general.cancel
components/note-input.tsx:general.close
components/note-input.tsx:general.edit
components/note-input.tsx:general.preview
components/note-input.tsx:notebook
components/note-input.tsx:notes.add
components/note-input.tsx:notes.addCollaborators
components/note-input.tsx:notes.addImage
components/note-input.tsx:notes.addLink
components/note-input.tsx:notes.addListItem
components/note-input.tsx:notes.adding
components/note-input.tsx:notes.archive
components/note-input.tsx:notes.backgroundOptions
components/note-input.tsx:notes.contentOrMediaRequired
components/note-input.tsx:notes.date
components/note-input.tsx:notes.generateTitleFromImage
components/note-input.tsx:notes.invalidDateTime
components/note-input.tsx:notes.itemOrMediaRequired
components/note-input.tsx:notes.linkAddFailed
components/note-input.tsx:notes.linkAdded
components/note-input.tsx:notes.linkMetadataFailed
components/note-input.tsx:notes.listItem
components/note-input.tsx:notes.markdown
components/note-input.tsx:notes.markdownPlaceholder
components/note-input.tsx:notes.newChecklist
components/note-input.tsx:notes.noteCreateFailed
components/note-input.tsx:notes.noteCreated
components/note-input.tsx:notes.placeholder
components/note-input.tsx:notes.redoShortcut
components/note-input.tsx:notes.remindMe
components/note-input.tsx:notes.reminderDateTimeRequired
components/note-input.tsx:notes.reminderMustBeFuture
components/note-input.tsx:notes.setReminder
components/note-input.tsx:notes.setReminderButton
components/note-input.tsx:notes.suggestTitle
components/note-input.tsx:notes.time
components/note-input.tsx:notes.titlePlaceholder
components/note-input.tsx:notes.unarchive
components/note-input.tsx:notes.undoShortcut
components/title-suggestions.tsx:titleSuggestions.title