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
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:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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(/\/$/, '')
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
@@ -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() })
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
1256
memento-note/package-lock.json
generated
1256
memento-note/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
@@ -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); });
|
||||
@@ -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);
|
||||
@@ -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!');
|
||||
@@ -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.')
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
@@ -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')
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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();
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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())
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user