perf: optimize MCP server (O(1) auth, compact JSON, trashedAt fix) + memento-note performance (lazy loading, server-side filtering, XSS fixes, dead code removal, security hardening)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m35s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m35s
MCP Server: - Fix validateApiKey: O(1) direct lookup by shortId instead of loading all keys - Add trashedAt:null filter to ALL note queries (trashed notes leaked in results) - Compact JSON output (~40% smaller responses) - Bounded session cache (Map with MAX_SESSIONS=500) to prevent memory leaks - PostgreSQL connection pooling (connection_limit=10) - Rewrite all 22 tool descriptions in clear English - Fix /sse fallback to proper 307 redirect memento-note Performance: - loading=lazy on all note images - Split notebooksRefreshKey from global refreshKey (note CRUD no longer re-fetches notebooks) - Remove searchKey from trash count deps (no re-fetch on every keystroke) - Server-side notebookId filter in getAllNotes() (biggest win) - Skip collaborator fetch for non-shared notes (eliminates N+1 API calls) - next/dynamic for MarkdownContent + 4 modals (code-split remark/rehype/KaTeX) - Memoize DOMPurify sanitize with useMemo Security: - XSS: DOMPurify sanitize in note-card and note-history-modal - Auth anti-enumeration: uniform errors in auth.ts - CRON_SECRET mandatory on cron endpoints - Rate limiting on login (5 attempts/min per email) - Centralized API auth helpers (requireAuth/requireAdmin) - randomize-labels changed GET→POST - Removed debug endpoints (/api/debug/config, /api/debug/test-chat) Cleanup: - Removed dead code: .backup-keep, settings-backup, fix-*.js, debug-theme, fix-labels route - Removed sensitive console.error in auth.ts - Ollama fetchWithTimeout (30s/60s AbortController) - i18n: full Arabic translation, Farsi missing keys - Masonry drag-and-drop fix (localOrderMap, cross-section block) - Sidebar notebook tooltip on truncation
This commit is contained in:
@@ -96,52 +96,39 @@ export async function validateApiKey(prisma, rawKey) {
|
|||||||
|
|
||||||
const keyHash = hashKey(rawKey);
|
const keyHash = hashKey(rawKey);
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cached = getCachedKey(keyHash);
|
const cached = getCachedKey(keyHash);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimized: Use startsWith to leverage index, then filter by hash
|
const shortId = rawKey.substring(7, 15);
|
||||||
// This is much faster than loading all keys
|
const configKey = `${KEY_PREFIX}${shortId}`;
|
||||||
const shortIdFromKey = rawKey.substring(7, 15); // Extract potential shortId from key
|
|
||||||
|
|
||||||
const entries = await prisma.systemConfig.findMany({
|
const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } });
|
||||||
where: {
|
if (!entry) return null;
|
||||||
key: { startsWith: KEY_PREFIX },
|
|
||||||
},
|
|
||||||
take: 100, // Limit to prevent loading too many keys
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
try {
|
||||||
try {
|
const info = JSON.parse(entry.value);
|
||||||
const info = JSON.parse(entry.value);
|
if (info.keyHash !== keyHash || !info.active) return null;
|
||||||
if (info.keyHash === keyHash && info.active) {
|
|
||||||
// Update lastUsedAt (fire and forget - don't wait)
|
|
||||||
info.lastUsedAt = new Date().toISOString();
|
|
||||||
prisma.systemConfig.update({
|
|
||||||
where: { key: entry.key },
|
|
||||||
data: { value: JSON.stringify(info) },
|
|
||||||
}).catch(() => {}); // Ignore errors
|
|
||||||
|
|
||||||
const result = {
|
info.lastUsedAt = new Date().toISOString();
|
||||||
apiKeyId: info.shortId,
|
prisma.systemConfig.update({
|
||||||
apiKeyName: info.name,
|
where: { key: configKey },
|
||||||
userId: info.userId,
|
data: { value: JSON.stringify(info) },
|
||||||
userName: info.userName,
|
}).catch(() => {});
|
||||||
};
|
|
||||||
|
|
||||||
// Cache the result
|
const result = {
|
||||||
setCachedKey(keyHash, result);
|
apiKeyId: info.shortId,
|
||||||
|
apiKeyName: info.name,
|
||||||
|
userId: info.userId,
|
||||||
|
userName: info.userName,
|
||||||
|
};
|
||||||
|
|
||||||
return result;
|
setCachedKey(keyHash, result);
|
||||||
}
|
return result;
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid JSON, skip
|
return null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,30 +1,27 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Memento MCP Server - Streamable HTTP Transport (Optimized)
|
* Memento MCP Server - Streamable HTTP Transport (Fast)
|
||||||
*
|
*
|
||||||
* Performance improvements:
|
|
||||||
* - Prisma connection pooling
|
* - Prisma connection pooling
|
||||||
* - Request timeout handling
|
* - Compact JSON output
|
||||||
* - Response compression
|
* - Bounded session cache
|
||||||
* - Connection keep-alive
|
* - Proper keep-alive & timeouts
|
||||||
* - Request batching support
|
* - O(1) API key validation
|
||||||
*
|
*
|
||||||
* Environment variables:
|
* Environment:
|
||||||
* PORT - Server port (default: 3001)
|
* PORT Server port (default: 3001)
|
||||||
* DATABASE_URL - Prisma database URL (default: ../../memento-note/prisma/dev.db)
|
* DATABASE_URL Prisma database URL
|
||||||
* USER_ID - Optional user ID to filter data
|
* USER_ID Optional user ID filter
|
||||||
* APP_BASE_URL - Optional Next.js app URL for AI features (default: http://localhost:3000)
|
* APP_BASE_URL Next.js app URL (default: http://localhost:3000)
|
||||||
* MCP_REQUIRE_AUTH - Set to 'true' to require x-api-key or x-user-id header
|
* MCP_REQUIRE_AUTH Set 'true' to require authentication
|
||||||
* MCP_API_KEY - Static API key for authentication (when MCP_REQUIRE_AUTH=true)
|
* MCP_API_KEY Static fallback API key
|
||||||
* MCP_LOG_LEVEL - Log level: debug, info, warn, error (default: info)
|
* MCP_LOG_LEVEL debug, info, warn, error (default: info)
|
||||||
* MCP_REQUEST_TIMEOUT - Request timeout in ms (default: 30000)
|
* MCP_REQUEST_TIMEOUT Timeout in ms (default: 30000)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
@@ -32,13 +29,12 @@ import { registerTools } from './tools.js';
|
|||||||
import { validateApiKey, resolveUser } from './auth.js';
|
import { validateApiKey, resolveUser } from './auth.js';
|
||||||
import { requestContext } from './request-context.js';
|
import { requestContext } from './request-context.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
|
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
|
||||||
const REQUEST_TIMEOUT = parseInt(process.env.MCP_REQUEST_TIMEOUT, 10) || 30000;
|
const REQUEST_TIMEOUT = parseInt(process.env.MCP_REQUEST_TIMEOUT, 10) || 30000;
|
||||||
|
const MAX_SESSIONS = 500;
|
||||||
|
const SESSION_TTL = 3600000;
|
||||||
|
|
||||||
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
|
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||||
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
|
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
|
||||||
|
|
||||||
@@ -48,51 +44,57 @@ function log(level, ...args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
// Middleware
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json({ limit: '10mb' }));
|
|
||||||
|
|
||||||
// Database - requires DATABASE_URL environment variable
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
console.error('ERROR: DATABASE_URL environment variable is required');
|
console.error('ERROR: DATABASE_URL is required');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMIZED: Prisma client with connection pooling
|
const isPostgres = databaseUrl.startsWith('postgresql://') || databaseUrl.startsWith('postgres://');
|
||||||
|
|
||||||
const prisma = new PrismaClient({
|
const prisma = new PrismaClient({
|
||||||
datasources: {
|
datasources: { db: { url: databaseUrl } },
|
||||||
db: { url: databaseUrl },
|
...(isPostgres ? { datasources: { db: { url: `${databaseUrl}${databaseUrl.includes('?') ? '&' : '?'}connection_limit=10&pool_timeout=10` } } } : {}),
|
||||||
},
|
|
||||||
log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
|
log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
// ── Auth Middleware ──────────────────────────────────────────────────────────
|
// ── Bounded Session Cache ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const userSessions = {};
|
const sessions = new Map();
|
||||||
const SESSION_TIMEOUT = 3600000; // 1 hour
|
|
||||||
|
|
||||||
// Cleanup old sessions periodically
|
function cleanupSessions() {
|
||||||
setInterval(() => {
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let cleaned = 0;
|
let cleaned = 0;
|
||||||
for (const [key, session] of Object.entries(userSessions)) {
|
for (const [key, s] of sessions) {
|
||||||
if (now - new Date(session.lastSeen).getTime() > SESSION_TIMEOUT) {
|
if (now - s._lastSeen > SESSION_TTL) {
|
||||||
delete userSessions[key];
|
sessions.delete(key);
|
||||||
cleaned++;
|
cleaned++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cleaned > 0) {
|
if (cleaned > 0) log('debug', `Cleaned ${cleaned} sessions`);
|
||||||
log('debug', `Cleaned up ${cleaned} expired sessions`);
|
}
|
||||||
|
|
||||||
|
function pruneIfFull() {
|
||||||
|
if (sessions.size < MAX_SESSIONS) return;
|
||||||
|
const entries = [...sessions.entries()].sort((a, b) => a[1]._lastSeen - b[1]._lastSeen);
|
||||||
|
for (let i = 0; i < Math.floor(MAX_SESSIONS / 4); i++) {
|
||||||
|
sessions.delete(entries[i][0]);
|
||||||
}
|
}
|
||||||
}, 600000); // Every 10 minutes
|
}
|
||||||
|
|
||||||
|
setInterval(cleanupSessions, 600000);
|
||||||
|
|
||||||
|
// ── Express ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
||||||
|
// ── Auth Middleware ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.use(async (req, res, next) => {
|
app.use(async (req, res, next) => {
|
||||||
// Dev mode: no auth required
|
|
||||||
if (process.env.MCP_REQUIRE_AUTH !== 'true') {
|
if (process.env.MCP_REQUIRE_AUTH !== 'true') {
|
||||||
req.userSession = { id: 'dev-user', name: 'Development User', isAuth: false };
|
req.userSession = { id: 'dev-user', name: 'Development User', isAuth: false };
|
||||||
return next();
|
return next();
|
||||||
@@ -102,189 +104,128 @@ app.use(async (req, res, next) => {
|
|||||||
const headerUserId = req.headers['x-user-id'];
|
const headerUserId = req.headers['x-user-id'];
|
||||||
|
|
||||||
if (!apiKey && !headerUserId) {
|
if (!apiKey && !headerUserId) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({ error: 'Authentication required', message: 'Provide x-api-key or x-user-id header' });
|
||||||
error: 'Authentication required',
|
|
||||||
message: 'Provide x-api-key header (recommended) or x-user-id header',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Method 1: API Key (recommended) ──────────────────────────────
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
const keyUser = await validateApiKey(prisma, apiKey);
|
const keyUser = await validateApiKey(prisma, apiKey);
|
||||||
if (keyUser) {
|
if (keyUser) {
|
||||||
const sessionKey = `key:${keyUser.apiKeyId}`;
|
req.userSession = getOrCreateSession(`key:${keyUser.apiKeyId}`, {
|
||||||
if (userSessions[sessionKey]) {
|
name: `${keyUser.userName} (${keyUser.apiKeyName})`,
|
||||||
req.userSession = userSessions[sessionKey];
|
userId: keyUser.userId,
|
||||||
req.userSession.lastSeen = new Date().toISOString();
|
userName: keyUser.userName,
|
||||||
} else {
|
apiKeyId: keyUser.apiKeyId,
|
||||||
req.userSession = {
|
authMethod: 'api-key',
|
||||||
id: randomUUID(),
|
});
|
||||||
name: `${keyUser.userName} (${keyUser.apiKeyName})`,
|
|
||||||
userId: keyUser.userId,
|
|
||||||
userName: keyUser.userName,
|
|
||||||
apiKeyId: keyUser.apiKeyId,
|
|
||||||
connectedAt: new Date().toISOString(),
|
|
||||||
lastSeen: new Date().toISOString(),
|
|
||||||
requestCount: 0,
|
|
||||||
isAuth: true,
|
|
||||||
authMethod: 'api-key',
|
|
||||||
};
|
|
||||||
userSessions[sessionKey] = req.userSession;
|
|
||||||
}
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: static env var key
|
|
||||||
if (process.env.MCP_API_KEY && apiKey === process.env.MCP_API_KEY) {
|
if (process.env.MCP_API_KEY && apiKey === process.env.MCP_API_KEY) {
|
||||||
const sessionKey = `static:${apiKey.substring(0, 8)}`;
|
req.userSession = getOrCreateSession(`static:${apiKey.substring(0, 8)}`, {
|
||||||
if (userSessions[sessionKey]) {
|
name: 'Static API Key User',
|
||||||
req.userSession = userSessions[sessionKey];
|
userId: process.env.USER_ID || null,
|
||||||
req.userSession.lastSeen = new Date().toISOString();
|
authMethod: 'static-key',
|
||||||
} else {
|
});
|
||||||
req.userSession = {
|
|
||||||
id: randomUUID(),
|
|
||||||
name: 'Static API Key User',
|
|
||||||
userId: process.env.USER_ID || null,
|
|
||||||
connectedAt: new Date().toISOString(),
|
|
||||||
lastSeen: new Date().toISOString(),
|
|
||||||
requestCount: 0,
|
|
||||||
isAuth: true,
|
|
||||||
authMethod: 'static-key',
|
|
||||||
};
|
|
||||||
userSessions[sessionKey] = req.userSession;
|
|
||||||
}
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(401).json({ error: 'Invalid API key' });
|
return res.status(401).json({ error: 'Invalid API key' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Method 2: User ID header (validate against DB) ──────────────
|
|
||||||
if (headerUserId) {
|
if (headerUserId) {
|
||||||
const user = await resolveUser(prisma, headerUserId);
|
const user = await resolveUser(prisma, headerUserId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(401).json({ error: 'User not found', message: `No user matching: ${headerUserId}` });
|
return res.status(401).json({ error: 'User not found' });
|
||||||
}
|
|
||||||
|
|
||||||
const sessionKey = `user:${user.id}`;
|
|
||||||
if (userSessions[sessionKey]) {
|
|
||||||
req.userSession = userSessions[sessionKey];
|
|
||||||
req.userSession.lastSeen = new Date().toISOString();
|
|
||||||
} else {
|
|
||||||
req.userSession = {
|
|
||||||
id: randomUUID(),
|
|
||||||
name: user.name,
|
|
||||||
userId: user.id,
|
|
||||||
userName: user.name,
|
|
||||||
userEmail: user.email,
|
|
||||||
userRole: user.role,
|
|
||||||
connectedAt: new Date().toISOString(),
|
|
||||||
lastSeen: new Date().toISOString(),
|
|
||||||
requestCount: 0,
|
|
||||||
isAuth: true,
|
|
||||||
authMethod: 'user-id',
|
|
||||||
};
|
|
||||||
userSessions[sessionKey] = req.userSession;
|
|
||||||
}
|
}
|
||||||
|
req.userSession = getOrCreateSession(`user:${user.id}`, {
|
||||||
|
name: user.name,
|
||||||
|
userId: user.id,
|
||||||
|
userName: user.name,
|
||||||
|
userEmail: user.email,
|
||||||
|
userRole: user.role,
|
||||||
|
authMethod: 'user-id',
|
||||||
|
});
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(401).json({ error: 'Authentication failed' });
|
return res.status(401).json({ error: 'Authentication failed' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Request Logging ─────────────────────────────────────────────────────────
|
function getOrCreateSession(key, base) {
|
||||||
|
const existing = sessions.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing._lastSeen = Date.now();
|
||||||
|
existing.requestCount = (existing.requestCount || 0) + 1;
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
pruneIfFull();
|
||||||
|
const s = {
|
||||||
|
id: randomUUID(),
|
||||||
|
...base,
|
||||||
|
connectedAt: new Date().toISOString(),
|
||||||
|
requestCount: 1,
|
||||||
|
isAuth: true,
|
||||||
|
_lastSeen: Date.now(),
|
||||||
|
};
|
||||||
|
sessions.set(key, s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Logging ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
if (req.userSession) {
|
|
||||||
req.userSession.requestCount = (req.userSession.requestCount || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
const duration = Date.now() - start;
|
const ms = Date.now() - start;
|
||||||
const sessionId = req.userSession?.id?.substring(0, 8) || 'anon';
|
const sid = req.userSession?.id?.substring(0, 8) || 'anon';
|
||||||
log('debug', `[${sessionId}] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`);
|
log('debug', `[${sid}] ${req.method} ${req.path} ${res.statusCode} ${ms}ms`);
|
||||||
});
|
});
|
||||||
|
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Request Timeout Middleware ──────────────────────────────────────────────
|
// ── Timeout ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
req.setTimeout(REQUEST_TIMEOUT);
|
||||||
res.setTimeout(REQUEST_TIMEOUT, () => {
|
res.setTimeout(REQUEST_TIMEOUT, () => {
|
||||||
log('warn', `Request timeout: ${req.method} ${req.path}`);
|
if (!res.headersSent) {
|
||||||
res.status(504).json({ error: 'Gateway Timeout', message: 'Request took too long' });
|
res.status(504).json({ error: 'Gateway Timeout' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── MCP Server Setup ────────────────────────────────────────────────────────
|
// ── MCP Server ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{
|
{ name: 'memento-mcp-server', version: '3.2.0' },
|
||||||
name: 'memento-mcp-server',
|
{ capabilities: { tools: {} } },
|
||||||
version: '3.1.0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
capabilities: { tools: {} },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
registerTools(server, prisma);
|
registerTools(server, prisma);
|
||||||
|
|
||||||
// ── HTTP Endpoints ──────────────────────────────────────────────────────────
|
// ── Routes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const transports = {};
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
name: 'Memento MCP Server',
|
name: 'Memento MCP Server',
|
||||||
version: '3.1.0',
|
version: '3.2.0',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
endpoints: { mcp: '/mcp', health: '/', sessions: '/sessions' },
|
endpoints: { mcp: '/mcp', health: '/', sessions: '/sessions' },
|
||||||
auth: {
|
auth: { enabled: process.env.MCP_REQUIRE_AUTH === 'true' },
|
||||||
enabled: process.env.MCP_REQUIRE_AUTH === 'true',
|
tools: 22,
|
||||||
method: 'x-api-key or x-user-id header',
|
|
||||||
},
|
|
||||||
tools: {
|
|
||||||
notes: 11,
|
|
||||||
notebooks: 6,
|
|
||||||
labels: 4,
|
|
||||||
reminders: 1,
|
|
||||||
total: 22,
|
|
||||||
},
|
|
||||||
performance: {
|
|
||||||
optimizations: [
|
|
||||||
'Connection pooling',
|
|
||||||
'Batch operations',
|
|
||||||
'API key caching',
|
|
||||||
'Request timeout handling',
|
|
||||||
'Parallel query execution',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Session status
|
|
||||||
app.get('/sessions', (req, res) => {
|
app.get('/sessions', (req, res) => {
|
||||||
const sessions = Object.values(userSessions).map((s) => ({
|
const list = [...sessions.values()].map(s => ({
|
||||||
id: s.id,
|
id: s.id, name: s.name, connectedAt: s.connectedAt,
|
||||||
name: s.name,
|
requestCount: s.requestCount || 0, authMethod: s.authMethod,
|
||||||
connectedAt: s.connectedAt,
|
|
||||||
lastSeen: s.lastSeen,
|
|
||||||
requestCount: s.requestCount || 0,
|
|
||||||
}));
|
}));
|
||||||
res.json({
|
res.json({ activeUsers: list.length, sessions: list, uptime: process.uptime() });
|
||||||
activeUsers: sessions.length,
|
|
||||||
sessions,
|
|
||||||
uptime: process.uptime(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// MCP endpoint - Streamable HTTP
|
// MCP endpoint — Streamable HTTP
|
||||||
app.all('/mcp', async (req, res) => {
|
app.all('/mcp', async (req, res) => {
|
||||||
const sessionId = req.headers['mcp-session-id'];
|
const sessionId = req.headers['mcp-session-id'];
|
||||||
let transport;
|
let transport;
|
||||||
@@ -295,15 +236,15 @@ app.all('/mcp', async (req, res) => {
|
|||||||
transport = new StreamableHTTPServerTransport({
|
transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => randomUUID(),
|
sessionIdGenerator: () => randomUUID(),
|
||||||
onsessioninitialized: (id) => {
|
onsessioninitialized: (id) => {
|
||||||
log('debug', `Session initialized: ${id}`);
|
log('debug', `Session init: ${id}`);
|
||||||
transports[id] = transport;
|
transports[id] = transport;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
const sid = transport.sessionId;
|
const sid = transport.sessionId;
|
||||||
if (sid && transports[sid]) {
|
if (sid) {
|
||||||
log('debug', `Session closed: ${sid}`);
|
log('debug', `Session close: ${sid}`);
|
||||||
delete transports[sid];
|
delete transports[sid];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -311,79 +252,45 @@ app.all('/mcp', async (req, res) => {
|
|||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass authenticated userId to tool handlers via AsyncLocalStorage
|
|
||||||
const ctx = { userId: req.userSession?.userId || null };
|
const ctx = { userId: req.userSession?.userId || null };
|
||||||
await requestContext.run(ctx, async () => {
|
await requestContext.run(ctx, async () => {
|
||||||
await transport.handleRequest(req, res, req.body);
|
await transport.handleRequest(req, res, req.body);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Legacy /sse redirect for backward compat
|
// Legacy /sse → /mcp redirect
|
||||||
app.all('/sse', async (req, res) => {
|
app.all('/sse', (req, res) => {
|
||||||
// Redirect to /mcp
|
res.redirect(307, '/mcp');
|
||||||
req.url = '/mcp';
|
|
||||||
return app._router.handle(req, res, () => {
|
|
||||||
res.status(404).json({ error: 'Not found' });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Start Server ────────────────────────────────────────────────────────────
|
const transports = {};
|
||||||
|
|
||||||
|
// ── Start ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`
|
console.log(`
|
||||||
╔═══════════════════════════════════════════════════════════════╗
|
╔═══════════════════════════════════════════════════════╗
|
||||||
║ Memento MCP Server v3.1.0 (Streamable HTTP) - Optimized ║
|
║ Memento MCP Server v3.2.0 (Streamable HTTP) ║
|
||||||
╚═══════════════════════════════════════════════════════════════╝
|
╚═══════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
Server: http://localhost:${PORT}
|
Server: http://localhost:${PORT}
|
||||||
MCP: http://localhost:${PORT}/mcp
|
MCP: http://localhost:${PORT}/mcp
|
||||||
Health: http://localhost:${PORT}/
|
Auth: ${process.env.MCP_REQUIRE_AUTH === 'true' ? 'ENABLED' : 'DISABLED (dev)'}
|
||||||
Sessions: http://localhost:${PORT}/sessions
|
Timeout: ${REQUEST_TIMEOUT}ms
|
||||||
|
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
||||||
Database: ${databaseUrl}
|
Tools: 22
|
||||||
App URL: ${appBaseUrl}
|
|
||||||
User filter: per-request (from auth)
|
|
||||||
Auth: ${process.env.MCP_REQUIRE_AUTH === 'true' ? 'ENABLED' : 'DISABLED (dev mode)'}
|
|
||||||
Timeout: ${REQUEST_TIMEOUT}ms
|
|
||||||
|
|
||||||
Performance Optimizations:
|
|
||||||
✅ Connection pooling
|
|
||||||
✅ Batch operations
|
|
||||||
✅ API key caching (60s TTL)
|
|
||||||
✅ Parallel query execution
|
|
||||||
✅ Request timeout handling
|
|
||||||
✅ Session cleanup
|
|
||||||
|
|
||||||
Tools (22 total):
|
|
||||||
Notes (11):
|
|
||||||
create_note, get_notes, get_note, update_note, delete_note,
|
|
||||||
search_notes, move_note, toggle_pin, toggle_archive,
|
|
||||||
export_notes, import_notes
|
|
||||||
|
|
||||||
Notebooks (6):
|
|
||||||
create_notebook, get_notebooks, get_notebook, update_notebook,
|
|
||||||
delete_notebook, reorder_notebooks
|
|
||||||
|
|
||||||
Labels (4):
|
|
||||||
create_label, get_labels, update_label, delete_label
|
|
||||||
|
|
||||||
Reminders (1):
|
|
||||||
get_due_reminders
|
|
||||||
|
|
||||||
N8N config: SSE endpoint http://YOUR_IP:${PORT}/mcp
|
|
||||||
Headers: x-api-key or x-user-id
|
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// ── Shutdown ─────────────────────────────────────────────────────────────────
|
||||||
process.on('SIGINT', async () => {
|
|
||||||
log('info', '\nShutting down MCP server...');
|
|
||||||
await prisma.$disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
async function shutdown() {
|
||||||
log('info', '\nShutting down MCP server...');
|
log('info', 'Shutting down...');
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('uncaughtException', (err) => log('error', 'Uncaught:', err.message));
|
||||||
|
process.on('unhandledRejection', (reason) => log('error', 'Unhandled rejection:', reason));
|
||||||
|
|||||||
@@ -1,31 +1,19 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Memento MCP Server - Stdio Transport (Optimized)
|
* Memento MCP Server - Stdio Transport
|
||||||
*
|
*
|
||||||
* Performance improvements:
|
* Environment:
|
||||||
* - Prisma connection pooling
|
* DATABASE_URL Prisma database URL
|
||||||
* - Prepared statements caching
|
* USER_ID Optional user ID filter
|
||||||
* - Optimized JSON serialization
|
* APP_BASE_URL Next.js app URL (default: http://localhost:3000)
|
||||||
* - Lazy user resolution
|
* MCP_LOG_LEVEL debug, info, warn, error (default: info)
|
||||||
*
|
|
||||||
* Environment variables:
|
|
||||||
* DATABASE_URL - Prisma database URL (default: ../../memento-note/prisma/dev.db)
|
|
||||||
* USER_ID - Optional user ID to filter data
|
|
||||||
* APP_BASE_URL - Optional Next.js app URL for AI features (default: http://localhost:3000)
|
|
||||||
* MCP_LOG_LEVEL - Log level: debug, info, warn, error (default: info)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
import { registerTools } from './tools.js';
|
import { registerTools } from './tools.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
|
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
|
||||||
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
|
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||||
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
|
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
|
||||||
@@ -36,44 +24,24 @@ function log(level, ...args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database - requires DATABASE_URL environment variable
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
console.error('ERROR: DATABASE_URL environment variable is required');
|
console.error('ERROR: DATABASE_URL is required');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMIZED: Prisma client with connection pooling and prepared statements
|
const isPostgres = databaseUrl.startsWith('postgresql://') || databaseUrl.startsWith('postgres://');
|
||||||
|
|
||||||
const prisma = new PrismaClient({
|
const prisma = new PrismaClient({
|
||||||
datasources: {
|
datasources: {
|
||||||
db: { url: databaseUrl },
|
db: { url: isPostgres ? `${databaseUrl}${databaseUrl.includes('?') ? '&' : '?'}connection_limit=10&pool_timeout=10` : databaseUrl },
|
||||||
},
|
},
|
||||||
// SQLite optimizations
|
|
||||||
log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
|
log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connection health check
|
|
||||||
let isConnected = false;
|
|
||||||
async function checkConnection() {
|
|
||||||
try {
|
|
||||||
await prisma.$queryRaw`SELECT 1`;
|
|
||||||
isConnected = true;
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
isConnected = false;
|
|
||||||
log('error', 'Database connection failed:', error.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{
|
{ name: 'memento-mcp-server', version: '3.2.0' },
|
||||||
name: 'memento-mcp-server',
|
{ capabilities: { tools: {} } },
|
||||||
version: '3.1.0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
capabilities: { tools: {} },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
||||||
@@ -84,21 +52,19 @@ registerTools(server, prisma, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Verify database connection on startup
|
try {
|
||||||
const connected = await checkConnection();
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
if (!connected) {
|
} catch (error) {
|
||||||
console.error('FATAL: Could not connect to database');
|
console.error('FATAL: Database connection failed:', error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
|
|
||||||
log('info', `Memento MCP Server v3.1.0 (stdio) - Optimized`);
|
log('info', `Memento MCP Server v3.2.0 (stdio)`);
|
||||||
log('info', `Database: ${databaseUrl}`);
|
log('info', `Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}`);
|
||||||
log('info', `App URL: ${appBaseUrl}`);
|
log('info', `User filter: ${process.env.USER_ID || 'none'}`);
|
||||||
log('info', `User filter: ${process.env.USER_ID || 'none (all data)'}`);
|
|
||||||
log('debug', 'Performance optimizations enabled: connection pooling, batch operations, caching');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
@@ -106,24 +72,13 @@ main().catch((error) => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
async function shutdown() {
|
||||||
process.on('SIGINT', async () => {
|
log('info', 'Shutting down...');
|
||||||
log('info', 'Shutting down gracefully...');
|
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
}
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
process.on('SIGINT', shutdown);
|
||||||
log('info', 'Shutting down gracefully...');
|
process.on('SIGTERM', shutdown);
|
||||||
await prisma.$disconnect();
|
process.on('uncaughtException', (err) => log('error', 'Uncaught:', err.message));
|
||||||
process.exit(0);
|
process.on('unhandledRejection', (reason) => log('error', 'Unhandled:', reason));
|
||||||
});
|
|
||||||
|
|
||||||
// Handle uncaught errors
|
|
||||||
process.on('uncaughtException', (error) => {
|
|
||||||
log('error', 'Uncaught exception:', error.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason) => {
|
|
||||||
log('error', 'Unhandled rejection:', reason);
|
|
||||||
});
|
|
||||||
|
|||||||
14
mcp-server/node_modules/.package-lock.json
generated
vendored
14
mcp-server/node_modules/.package-lock.json
generated
vendored
@@ -848,20 +848,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
|||||||
158
mcp-server/node_modules/.prisma/client/edge.js
generated
vendored
158
mcp-server/node_modules/.prisma/client/edge.js
generated
vendored
File diff suppressed because one or more lines are too long
130
mcp-server/node_modules/.prisma/client/index-browser.js
generated
vendored
130
mcp-server/node_modules/.prisma/client/index-browser.js
generated
vendored
@@ -116,40 +116,26 @@ Prisma.NullTypes = {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
||||||
|
ReadUncommitted: 'ReadUncommitted',
|
||||||
|
ReadCommitted: 'ReadCommitted',
|
||||||
|
RepeatableRead: 'RepeatableRead',
|
||||||
Serializable: 'Serializable'
|
Serializable: 'Serializable'
|
||||||
});
|
});
|
||||||
|
|
||||||
exports.Prisma.NoteScalarFieldEnum = {
|
exports.Prisma.UserScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
title: 'title',
|
name: 'name',
|
||||||
content: 'content',
|
email: 'email',
|
||||||
color: 'color',
|
emailVerified: 'emailVerified',
|
||||||
isPinned: 'isPinned',
|
password: 'password',
|
||||||
isArchived: 'isArchived',
|
role: 'role',
|
||||||
type: 'type',
|
image: 'image',
|
||||||
checkItems: 'checkItems',
|
theme: 'theme',
|
||||||
labels: 'labels',
|
cardSizeMode: 'cardSizeMode',
|
||||||
images: 'images',
|
resetToken: 'resetToken',
|
||||||
links: 'links',
|
resetTokenExpiry: 'resetTokenExpiry',
|
||||||
reminder: 'reminder',
|
|
||||||
isReminderDone: 'isReminderDone',
|
|
||||||
reminderRecurrence: 'reminderRecurrence',
|
|
||||||
reminderLocation: 'reminderLocation',
|
|
||||||
isMarkdown: 'isMarkdown',
|
|
||||||
size: 'size',
|
|
||||||
embedding: 'embedding',
|
|
||||||
sharedWith: 'sharedWith',
|
|
||||||
userId: 'userId',
|
|
||||||
order: 'order',
|
|
||||||
notebookId: 'notebookId',
|
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt'
|
||||||
autoGenerated: 'autoGenerated',
|
|
||||||
aiProvider: 'aiProvider',
|
|
||||||
aiConfidence: 'aiConfidence',
|
|
||||||
language: 'language',
|
|
||||||
languageConfidence: 'languageConfidence',
|
|
||||||
lastAiAnalysis: 'lastAiAnalysis'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.NotebookScalarFieldEnum = {
|
exports.Prisma.NotebookScalarFieldEnum = {
|
||||||
@@ -173,17 +159,57 @@ exports.Prisma.LabelScalarFieldEnum = {
|
|||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.UserScalarFieldEnum = {
|
exports.Prisma.NoteScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
name: 'name',
|
title: 'title',
|
||||||
email: 'email',
|
content: 'content',
|
||||||
emailVerified: 'emailVerified',
|
color: 'color',
|
||||||
password: 'password',
|
isPinned: 'isPinned',
|
||||||
role: 'role',
|
isArchived: 'isArchived',
|
||||||
image: 'image',
|
trashedAt: 'trashedAt',
|
||||||
theme: 'theme',
|
type: 'type',
|
||||||
resetToken: 'resetToken',
|
dismissedFromRecent: 'dismissedFromRecent',
|
||||||
resetTokenExpiry: 'resetTokenExpiry',
|
checkItems: 'checkItems',
|
||||||
|
labels: 'labels',
|
||||||
|
images: 'images',
|
||||||
|
links: 'links',
|
||||||
|
reminder: 'reminder',
|
||||||
|
isReminderDone: 'isReminderDone',
|
||||||
|
reminderRecurrence: 'reminderRecurrence',
|
||||||
|
reminderLocation: 'reminderLocation',
|
||||||
|
isMarkdown: 'isMarkdown',
|
||||||
|
size: 'size',
|
||||||
|
sharedWith: 'sharedWith',
|
||||||
|
userId: 'userId',
|
||||||
|
order: 'order',
|
||||||
|
notebookId: 'notebookId',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt',
|
||||||
|
contentUpdatedAt: 'contentUpdatedAt',
|
||||||
|
autoGenerated: 'autoGenerated',
|
||||||
|
aiProvider: 'aiProvider',
|
||||||
|
aiConfidence: 'aiConfidence',
|
||||||
|
language: 'language',
|
||||||
|
languageConfidence: 'languageConfidence',
|
||||||
|
lastAiAnalysis: 'lastAiAnalysis'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.NoteEmbeddingScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
noteId: 'noteId',
|
||||||
|
embedding: 'embedding',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.NoteShareScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
noteId: 'noteId',
|
||||||
|
userId: 'userId',
|
||||||
|
sharedBy: 'sharedBy',
|
||||||
|
status: 'status',
|
||||||
|
permission: 'permission',
|
||||||
|
notifiedAt: 'notifiedAt',
|
||||||
|
respondedAt: 'respondedAt',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
@@ -218,19 +244,6 @@ exports.Prisma.VerificationTokenScalarFieldEnum = {
|
|||||||
expires: 'expires'
|
expires: 'expires'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.NoteShareScalarFieldEnum = {
|
|
||||||
id: 'id',
|
|
||||||
noteId: 'noteId',
|
|
||||||
userId: 'userId',
|
|
||||||
sharedBy: 'sharedBy',
|
|
||||||
status: 'status',
|
|
||||||
permission: 'permission',
|
|
||||||
notifiedAt: 'notifiedAt',
|
|
||||||
respondedAt: 'respondedAt',
|
|
||||||
createdAt: 'createdAt',
|
|
||||||
updatedAt: 'updatedAt'
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.Prisma.SystemConfigScalarFieldEnum = {
|
exports.Prisma.SystemConfigScalarFieldEnum = {
|
||||||
key: 'key',
|
key: 'key',
|
||||||
value: 'value'
|
value: 'value'
|
||||||
@@ -273,6 +286,7 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
|
|||||||
fontSize: 'fontSize',
|
fontSize: 'fontSize',
|
||||||
demoMode: 'demoMode',
|
demoMode: 'demoMode',
|
||||||
showRecentNotes: 'showRecentNotes',
|
showRecentNotes: 'showRecentNotes',
|
||||||
|
notesViewMode: 'notesViewMode',
|
||||||
emailNotifications: 'emailNotifications',
|
emailNotifications: 'emailNotifications',
|
||||||
desktopNotifications: 'desktopNotifications',
|
desktopNotifications: 'desktopNotifications',
|
||||||
anonymousAnalytics: 'anonymousAnalytics'
|
anonymousAnalytics: 'anonymousAnalytics'
|
||||||
@@ -283,6 +297,11 @@ exports.Prisma.SortOrder = {
|
|||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.QueryMode = {
|
||||||
|
default: 'default',
|
||||||
|
insensitive: 'insensitive'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.NullsOrder = {
|
exports.Prisma.NullsOrder = {
|
||||||
first: 'first',
|
first: 'first',
|
||||||
last: 'last'
|
last: 'last'
|
||||||
@@ -290,14 +309,15 @@ exports.Prisma.NullsOrder = {
|
|||||||
|
|
||||||
|
|
||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
Note: 'Note',
|
User: 'User',
|
||||||
Notebook: 'Notebook',
|
Notebook: 'Notebook',
|
||||||
Label: 'Label',
|
Label: 'Label',
|
||||||
User: 'User',
|
Note: 'Note',
|
||||||
|
NoteEmbedding: 'NoteEmbedding',
|
||||||
|
NoteShare: 'NoteShare',
|
||||||
Account: 'Account',
|
Account: 'Account',
|
||||||
Session: 'Session',
|
Session: 'Session',
|
||||||
VerificationToken: 'VerificationToken',
|
VerificationToken: 'VerificationToken',
|
||||||
NoteShare: 'NoteShare',
|
|
||||||
SystemConfig: 'SystemConfig',
|
SystemConfig: 'SystemConfig',
|
||||||
AiFeedback: 'AiFeedback',
|
AiFeedback: 'AiFeedback',
|
||||||
MemoryEchoInsight: 'MemoryEchoInsight',
|
MemoryEchoInsight: 'MemoryEchoInsight',
|
||||||
|
|||||||
15128
mcp-server/node_modules/.prisma/client/index.d.ts
generated
vendored
15128
mcp-server/node_modules/.prisma/client/index.d.ts
generated
vendored
File diff suppressed because it is too large
Load Diff
162
mcp-server/node_modules/.prisma/client/index.js
generated
vendored
162
mcp-server/node_modules/.prisma/client/index.js
generated
vendored
File diff suppressed because one or more lines are too long
2
mcp-server/node_modules/.prisma/client/package.json
generated
vendored
2
mcp-server/node_modules/.prisma/client/package.json
generated
vendored
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "prisma-client-07b35a59db17a461d4c7b787cc433edb9e7b79a627ae71660fd00cce5311cf75",
|
"name": "prisma-client-8c3c28a242bf05b03713c0c3d78783f929261d76a15352bcfc52a1cfa1e7f92a",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"browser": "index-browser.js",
|
"browser": "index-browser.js",
|
||||||
|
|||||||
225
mcp-server/node_modules/.prisma/client/schema.prisma
generated
vendored
225
mcp-server/node_modules/.prisma/client/schema.prisma
generated
vendored
@@ -1,44 +1,46 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// MCP Server Schema — exact copy from memento-note (source of truth)
|
||||||
|
// Only includes models used by MCP tools.
|
||||||
|
// Do NOT modify independently — always sync with memento-note/prisma/schema.prisma
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
output = "../node_modules/.prisma/client"
|
output = "../node_modules/.prisma/client"
|
||||||
|
binaryTargets = ["linux-musl-openssl-3.0.x", "native"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "postgresql"
|
||||||
url = "file:../../memento-note/prisma/dev.db"
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Note {
|
// ── Core models (used by MCP tools) ─────────────────────────────────────────
|
||||||
id String @id @default(cuid())
|
|
||||||
title String?
|
model User {
|
||||||
content String
|
id String @id @default(cuid())
|
||||||
color String @default("default")
|
name String?
|
||||||
isPinned Boolean @default(false)
|
email String @unique
|
||||||
isArchived Boolean @default(false)
|
emailVerified DateTime?
|
||||||
type String @default("text")
|
password String?
|
||||||
checkItems String?
|
role String @default("USER")
|
||||||
labels String?
|
image String?
|
||||||
images String?
|
theme String @default("light")
|
||||||
links String?
|
cardSizeMode String @default("variable")
|
||||||
reminder DateTime?
|
resetToken String? @unique
|
||||||
isReminderDone Boolean @default(false)
|
resetTokenExpiry DateTime?
|
||||||
reminderRecurrence String?
|
createdAt DateTime @default(now())
|
||||||
reminderLocation String?
|
updatedAt DateTime @updatedAt
|
||||||
isMarkdown Boolean @default(false)
|
accounts Account[]
|
||||||
size String @default("small")
|
aiFeedback AiFeedback[]
|
||||||
embedding String?
|
labels Label[]
|
||||||
sharedWith String?
|
memoryEchoInsights MemoryEchoInsight[]
|
||||||
userId String?
|
notes Note[]
|
||||||
order Int @default(0)
|
sentShares NoteShare[] @relation("SentShares")
|
||||||
notebookId String?
|
receivedShares NoteShare[] @relation("ReceivedShares")
|
||||||
createdAt DateTime @default(now())
|
notebooks Notebook[]
|
||||||
updatedAt DateTime @updatedAt
|
sessions Session[]
|
||||||
autoGenerated Boolean?
|
aiSettings UserAISettings?
|
||||||
aiProvider String?
|
|
||||||
aiConfidence Int?
|
|
||||||
language String?
|
|
||||||
languageConfidence Float?
|
|
||||||
lastAiAnalysis DateTime?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Notebook {
|
model Notebook {
|
||||||
@@ -50,33 +52,115 @@ model Notebook {
|
|||||||
userId String
|
userId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
labels Label[]
|
||||||
|
notes Note[]
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, order])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Label {
|
model Label {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
color String @default("gray")
|
color String @default("gray")
|
||||||
notebookId String?
|
notebookId String?
|
||||||
userId String?
|
userId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: Cascade)
|
||||||
|
notes Note[] @relation("LabelToNote")
|
||||||
|
|
||||||
|
@@unique([notebookId, name])
|
||||||
|
@@index([notebookId])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model Note {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String?
|
title String?
|
||||||
email String @unique
|
content String
|
||||||
emailVerified DateTime?
|
color String @default("default")
|
||||||
password String?
|
isPinned Boolean @default(false)
|
||||||
role String @default("USER")
|
isArchived Boolean @default(false)
|
||||||
image String?
|
trashedAt DateTime?
|
||||||
theme String @default("light")
|
type String @default("text")
|
||||||
resetToken String? @unique
|
dismissedFromRecent Boolean @default(false)
|
||||||
resetTokenExpiry DateTime?
|
checkItems String?
|
||||||
createdAt DateTime @default(now())
|
labels String?
|
||||||
updatedAt DateTime @updatedAt
|
images String?
|
||||||
|
links String?
|
||||||
|
reminder DateTime?
|
||||||
|
isReminderDone Boolean @default(false)
|
||||||
|
reminderRecurrence String?
|
||||||
|
reminderLocation String?
|
||||||
|
isMarkdown Boolean @default(false)
|
||||||
|
size String @default("small")
|
||||||
|
sharedWith String?
|
||||||
|
userId String?
|
||||||
|
order Int @default(0)
|
||||||
|
notebookId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
contentUpdatedAt DateTime @default(now())
|
||||||
|
autoGenerated Boolean?
|
||||||
|
aiProvider String?
|
||||||
|
aiConfidence Int?
|
||||||
|
language String?
|
||||||
|
languageConfidence Float?
|
||||||
|
lastAiAnalysis DateTime?
|
||||||
|
aiFeedback AiFeedback[]
|
||||||
|
memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2")
|
||||||
|
memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
|
||||||
|
notebook Notebook? @relation(fields: [notebookId], references: [id])
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
shares NoteShare[]
|
||||||
|
labelRelations Label[] @relation("LabelToNote")
|
||||||
|
noteEmbedding NoteEmbedding?
|
||||||
|
|
||||||
|
@@index([isPinned])
|
||||||
|
@@index([isArchived])
|
||||||
|
@@index([trashedAt])
|
||||||
|
@@index([order])
|
||||||
|
@@index([reminder])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([userId, notebookId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model NoteEmbedding {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
noteId String @unique
|
||||||
|
embedding String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([noteId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model NoteShare {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
noteId String
|
||||||
|
userId String
|
||||||
|
sharedBy String
|
||||||
|
status String @default("pending")
|
||||||
|
permission String @default("view")
|
||||||
|
notifiedAt DateTime?
|
||||||
|
respondedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
sharer User @relation("SentShares", fields: [sharedBy], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation("ReceivedShares", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([noteId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([sharedBy])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Supporting models (used for auth, AI features, config) ──────────────────
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
userId String
|
userId String
|
||||||
type String
|
type String
|
||||||
@@ -91,6 +175,7 @@ model Account {
|
|||||||
session_state String?
|
session_state String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@id([provider, providerAccountId])
|
@@id([provider, providerAccountId])
|
||||||
}
|
}
|
||||||
@@ -101,6 +186,7 @@ model Session {
|
|||||||
expires DateTime
|
expires DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model VerificationToken {
|
model VerificationToken {
|
||||||
@@ -111,21 +197,6 @@ model VerificationToken {
|
|||||||
@@id([identifier, token])
|
@@id([identifier, token])
|
||||||
}
|
}
|
||||||
|
|
||||||
model NoteShare {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
noteId String
|
|
||||||
userId String
|
|
||||||
sharedBy String
|
|
||||||
status String @default("pending")
|
|
||||||
permission String @default("view")
|
|
||||||
notifiedAt DateTime?
|
|
||||||
respondedAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@unique([noteId, userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model SystemConfig {
|
model SystemConfig {
|
||||||
key String @id
|
key String @id
|
||||||
value String
|
value String
|
||||||
@@ -141,6 +212,12 @@ model AiFeedback {
|
|||||||
correctedContent String?
|
correctedContent String?
|
||||||
metadata String?
|
metadata String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([noteId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([feature])
|
||||||
}
|
}
|
||||||
|
|
||||||
model MemoryEchoInsight {
|
model MemoryEchoInsight {
|
||||||
@@ -154,8 +231,13 @@ model MemoryEchoInsight {
|
|||||||
viewed Boolean @default(false)
|
viewed Boolean @default(false)
|
||||||
feedback String?
|
feedback String?
|
||||||
dismissed Boolean @default(false)
|
dismissed Boolean @default(false)
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
note2 Note @relation("EchoNote2", fields: [note2Id], references: [id], onDelete: Cascade)
|
||||||
|
note1 Note @relation("EchoNote1", fields: [note1Id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([userId, insightDate])
|
@@unique([userId, insightDate])
|
||||||
|
@@index([userId, insightDate])
|
||||||
|
@@index([userId, dismissed])
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserAISettings {
|
model UserAISettings {
|
||||||
@@ -169,8 +251,15 @@ model UserAISettings {
|
|||||||
preferredLanguage String @default("auto")
|
preferredLanguage String @default("auto")
|
||||||
fontSize String @default("medium")
|
fontSize String @default("medium")
|
||||||
demoMode Boolean @default(false)
|
demoMode Boolean @default(false)
|
||||||
showRecentNotes Boolean @default(false)
|
showRecentNotes Boolean @default(true)
|
||||||
|
notesViewMode String @default("masonry")
|
||||||
emailNotifications Boolean @default(false)
|
emailNotifications Boolean @default(false)
|
||||||
desktopNotifications Boolean @default(false)
|
desktopNotifications Boolean @default(false)
|
||||||
anonymousAnalytics Boolean @default(false)
|
anonymousAnalytics Boolean @default(false)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([memoryEcho])
|
||||||
|
@@index([aiProvider])
|
||||||
|
@@index([memoryEchoFrequency])
|
||||||
|
@@index([preferredLanguage])
|
||||||
}
|
}
|
||||||
|
|||||||
130
mcp-server/node_modules/.prisma/client/wasm.js
generated
vendored
130
mcp-server/node_modules/.prisma/client/wasm.js
generated
vendored
@@ -116,40 +116,26 @@ Prisma.NullTypes = {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
||||||
|
ReadUncommitted: 'ReadUncommitted',
|
||||||
|
ReadCommitted: 'ReadCommitted',
|
||||||
|
RepeatableRead: 'RepeatableRead',
|
||||||
Serializable: 'Serializable'
|
Serializable: 'Serializable'
|
||||||
});
|
});
|
||||||
|
|
||||||
exports.Prisma.NoteScalarFieldEnum = {
|
exports.Prisma.UserScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
title: 'title',
|
name: 'name',
|
||||||
content: 'content',
|
email: 'email',
|
||||||
color: 'color',
|
emailVerified: 'emailVerified',
|
||||||
isPinned: 'isPinned',
|
password: 'password',
|
||||||
isArchived: 'isArchived',
|
role: 'role',
|
||||||
type: 'type',
|
image: 'image',
|
||||||
checkItems: 'checkItems',
|
theme: 'theme',
|
||||||
labels: 'labels',
|
cardSizeMode: 'cardSizeMode',
|
||||||
images: 'images',
|
resetToken: 'resetToken',
|
||||||
links: 'links',
|
resetTokenExpiry: 'resetTokenExpiry',
|
||||||
reminder: 'reminder',
|
|
||||||
isReminderDone: 'isReminderDone',
|
|
||||||
reminderRecurrence: 'reminderRecurrence',
|
|
||||||
reminderLocation: 'reminderLocation',
|
|
||||||
isMarkdown: 'isMarkdown',
|
|
||||||
size: 'size',
|
|
||||||
embedding: 'embedding',
|
|
||||||
sharedWith: 'sharedWith',
|
|
||||||
userId: 'userId',
|
|
||||||
order: 'order',
|
|
||||||
notebookId: 'notebookId',
|
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt'
|
||||||
autoGenerated: 'autoGenerated',
|
|
||||||
aiProvider: 'aiProvider',
|
|
||||||
aiConfidence: 'aiConfidence',
|
|
||||||
language: 'language',
|
|
||||||
languageConfidence: 'languageConfidence',
|
|
||||||
lastAiAnalysis: 'lastAiAnalysis'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.NotebookScalarFieldEnum = {
|
exports.Prisma.NotebookScalarFieldEnum = {
|
||||||
@@ -173,17 +159,57 @@ exports.Prisma.LabelScalarFieldEnum = {
|
|||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.UserScalarFieldEnum = {
|
exports.Prisma.NoteScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
name: 'name',
|
title: 'title',
|
||||||
email: 'email',
|
content: 'content',
|
||||||
emailVerified: 'emailVerified',
|
color: 'color',
|
||||||
password: 'password',
|
isPinned: 'isPinned',
|
||||||
role: 'role',
|
isArchived: 'isArchived',
|
||||||
image: 'image',
|
trashedAt: 'trashedAt',
|
||||||
theme: 'theme',
|
type: 'type',
|
||||||
resetToken: 'resetToken',
|
dismissedFromRecent: 'dismissedFromRecent',
|
||||||
resetTokenExpiry: 'resetTokenExpiry',
|
checkItems: 'checkItems',
|
||||||
|
labels: 'labels',
|
||||||
|
images: 'images',
|
||||||
|
links: 'links',
|
||||||
|
reminder: 'reminder',
|
||||||
|
isReminderDone: 'isReminderDone',
|
||||||
|
reminderRecurrence: 'reminderRecurrence',
|
||||||
|
reminderLocation: 'reminderLocation',
|
||||||
|
isMarkdown: 'isMarkdown',
|
||||||
|
size: 'size',
|
||||||
|
sharedWith: 'sharedWith',
|
||||||
|
userId: 'userId',
|
||||||
|
order: 'order',
|
||||||
|
notebookId: 'notebookId',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt',
|
||||||
|
contentUpdatedAt: 'contentUpdatedAt',
|
||||||
|
autoGenerated: 'autoGenerated',
|
||||||
|
aiProvider: 'aiProvider',
|
||||||
|
aiConfidence: 'aiConfidence',
|
||||||
|
language: 'language',
|
||||||
|
languageConfidence: 'languageConfidence',
|
||||||
|
lastAiAnalysis: 'lastAiAnalysis'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.NoteEmbeddingScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
noteId: 'noteId',
|
||||||
|
embedding: 'embedding',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.NoteShareScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
noteId: 'noteId',
|
||||||
|
userId: 'userId',
|
||||||
|
sharedBy: 'sharedBy',
|
||||||
|
status: 'status',
|
||||||
|
permission: 'permission',
|
||||||
|
notifiedAt: 'notifiedAt',
|
||||||
|
respondedAt: 'respondedAt',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
@@ -218,19 +244,6 @@ exports.Prisma.VerificationTokenScalarFieldEnum = {
|
|||||||
expires: 'expires'
|
expires: 'expires'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.NoteShareScalarFieldEnum = {
|
|
||||||
id: 'id',
|
|
||||||
noteId: 'noteId',
|
|
||||||
userId: 'userId',
|
|
||||||
sharedBy: 'sharedBy',
|
|
||||||
status: 'status',
|
|
||||||
permission: 'permission',
|
|
||||||
notifiedAt: 'notifiedAt',
|
|
||||||
respondedAt: 'respondedAt',
|
|
||||||
createdAt: 'createdAt',
|
|
||||||
updatedAt: 'updatedAt'
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.Prisma.SystemConfigScalarFieldEnum = {
|
exports.Prisma.SystemConfigScalarFieldEnum = {
|
||||||
key: 'key',
|
key: 'key',
|
||||||
value: 'value'
|
value: 'value'
|
||||||
@@ -273,6 +286,7 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
|
|||||||
fontSize: 'fontSize',
|
fontSize: 'fontSize',
|
||||||
demoMode: 'demoMode',
|
demoMode: 'demoMode',
|
||||||
showRecentNotes: 'showRecentNotes',
|
showRecentNotes: 'showRecentNotes',
|
||||||
|
notesViewMode: 'notesViewMode',
|
||||||
emailNotifications: 'emailNotifications',
|
emailNotifications: 'emailNotifications',
|
||||||
desktopNotifications: 'desktopNotifications',
|
desktopNotifications: 'desktopNotifications',
|
||||||
anonymousAnalytics: 'anonymousAnalytics'
|
anonymousAnalytics: 'anonymousAnalytics'
|
||||||
@@ -283,6 +297,11 @@ exports.Prisma.SortOrder = {
|
|||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.QueryMode = {
|
||||||
|
default: 'default',
|
||||||
|
insensitive: 'insensitive'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.NullsOrder = {
|
exports.Prisma.NullsOrder = {
|
||||||
first: 'first',
|
first: 'first',
|
||||||
last: 'last'
|
last: 'last'
|
||||||
@@ -290,14 +309,15 @@ exports.Prisma.NullsOrder = {
|
|||||||
|
|
||||||
|
|
||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
Note: 'Note',
|
User: 'User',
|
||||||
Notebook: 'Notebook',
|
Notebook: 'Notebook',
|
||||||
Label: 'Label',
|
Label: 'Label',
|
||||||
User: 'User',
|
Note: 'Note',
|
||||||
|
NoteEmbedding: 'NoteEmbedding',
|
||||||
|
NoteShare: 'NoteShare',
|
||||||
Account: 'Account',
|
Account: 'Account',
|
||||||
Session: 'Session',
|
Session: 'Session',
|
||||||
VerificationToken: 'VerificationToken',
|
VerificationToken: 'VerificationToken',
|
||||||
NoteShare: 'NoteShare',
|
|
||||||
SystemConfig: 'SystemConfig',
|
SystemConfig: 'SystemConfig',
|
||||||
AiFeedback: 'AiFeedback',
|
AiFeedback: 'AiFeedback',
|
||||||
MemoryEchoInsight: 'MemoryEchoInsight',
|
MemoryEchoInsight: 'MemoryEchoInsight',
|
||||||
|
|||||||
1
mcp-server/package-lock.json
generated
1
mcp-server/package-lock.json
generated
@@ -870,6 +870,7 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Memento MCP Server - Optimized Tool Definitions & Handlers
|
* Memento MCP Server - Tool Definitions & Handlers
|
||||||
*
|
*
|
||||||
* Performance optimizations:
|
* Fast, minimal overhead. All queries filter trashed notes.
|
||||||
* - O(1) API key lookup with caching
|
* Compact JSON output. Direct DB lookups.
|
||||||
* - Batch operations for imports
|
|
||||||
* - Parallel promise execution
|
|
||||||
* - HTTP timeout wrapper
|
|
||||||
* - N+1 query fixes
|
|
||||||
* - Connection pooling
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -18,12 +13,12 @@ import {
|
|||||||
} from '@modelcontextprotocol/sdk/types.js';
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { requestContext } from './request-context.js';
|
import { requestContext } from './request-context.js';
|
||||||
|
|
||||||
// ─── Configuration ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEFAULT_SEARCH_LIMIT = 50;
|
const DEFAULT_SEARCH_LIMIT = 50;
|
||||||
const DEFAULT_NOTES_LIMIT = 100;
|
const DEFAULT_NOTES_LIMIT = 100;
|
||||||
|
const MAX_NOTES_LIMIT = 500;
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray';
|
||||||
|
const LABEL_COLORS = 'red, orange, yellow, green, teal, blue, purple, pink, gray';
|
||||||
|
|
||||||
export function parseNote(dbNote) {
|
export function parseNote(dbNote) {
|
||||||
if (!dbNote) return null;
|
if (!dbNote) return null;
|
||||||
@@ -66,65 +61,60 @@ export function parseNoteLightweight(dbNote) {
|
|||||||
|
|
||||||
function textResult(data) {
|
function textResult(data) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
content: [{ type: 'text', text: JSON.stringify(data) }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tool Schemas ───────────────────────────────────────────────────────────
|
// ─── Tool Definitions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NOTE_COLORS = ['default', 'red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
|
|
||||||
const LABEL_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
|
|
||||||
|
|
||||||
const toolDefinitions = [
|
const toolDefinitions = [
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══ NOTES ═══
|
||||||
// NOTE TOOLS
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
{
|
{
|
||||||
name: 'create_note',
|
name: 'create_note',
|
||||||
description: 'Create a new note. Supports text and checklist types, colors, labels, images, links, reminders, markdown, and notebook assignment.',
|
description: 'Create a new note. Set content (required), optional title, color, labels, notebook assignment, reminder, or checklist items.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
title: { type: 'string', description: 'Note title (optional)' },
|
title: { type: 'string', description: 'Note title' },
|
||||||
content: { type: 'string', description: 'Note content (required)' },
|
content: { type: 'string', description: 'Note body text (required)' },
|
||||||
color: { type: 'string', description: `Note color: ${NOTE_COLORS.join(', ')}`, default: 'default' },
|
color: { type: 'string', description: `Color: ${NOTE_COLORS}`, default: 'default' },
|
||||||
type: { type: 'string', enum: ['text', 'checklist'], description: 'Note type', default: 'text' },
|
type: { type: 'string', enum: ['text', 'checklist'], description: 'Note type', default: 'text' },
|
||||||
checkItems: {
|
checkItems: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'Checklist items (when type is checklist)',
|
description: 'Checklist items (when type=list)',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string' }, text: { type: 'string' }, checked: { type: 'boolean' } },
|
properties: { id: { type: 'string' }, text: { type: 'string' }, checked: { type: 'boolean' } },
|
||||||
required: ['id', 'text', 'checked'],
|
required: ['id', 'text', 'checked'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
labels: { type: 'array', description: 'Note labels/tags', items: { type: 'string' } },
|
labels: { type: 'array', description: 'Tags/labels', items: { type: 'string' } },
|
||||||
isPinned: { type: 'boolean', description: 'Pin the note', default: false },
|
isPinned: { type: 'boolean', description: 'Pin to top', default: false },
|
||||||
isArchived: { type: 'boolean', description: 'Create as archived', default: false },
|
isArchived: { type: 'boolean', description: 'Create as archived', default: false },
|
||||||
images: { type: 'array', description: 'Image URLs or base64 strings', items: { type: 'string' } },
|
images: { type: 'array', description: 'Image URLs', items: { type: 'string' } },
|
||||||
links: { type: 'array', description: 'URLs attached to the note', items: { type: 'string' } },
|
links: { type: 'array', description: 'Attached URLs', items: { type: 'string' } },
|
||||||
reminder: { type: 'string', description: 'Reminder datetime (ISO 8601)' },
|
reminder: { type: 'string', description: 'Reminder datetime (ISO 8601)' },
|
||||||
isReminderDone: { type: 'boolean', default: false },
|
isReminderDone: { type: 'boolean', default: false },
|
||||||
reminderRecurrence: { type: 'string', description: 'Recurrence: daily, weekly, monthly, yearly' },
|
reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly' },
|
||||||
reminderLocation: { type: 'string', description: 'Location-based reminder' },
|
reminderLocation: { type: 'string', description: 'Location string' },
|
||||||
isMarkdown: { type: 'boolean', description: 'Enable markdown rendering', default: false },
|
isMarkdown: { type: 'boolean', description: 'Render as markdown', default: false },
|
||||||
size: { type: 'string', enum: ['small', 'medium', 'large'], default: 'small' },
|
size: { type: 'string', enum: ['small', 'medium', 'large'], default: 'small' },
|
||||||
notebookId: { type: 'string', description: 'Notebook to assign the note to' },
|
notebookId: { type: 'string', description: 'Assign to notebook' },
|
||||||
},
|
},
|
||||||
required: ['content'],
|
required: ['content'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_notes',
|
name: 'get_notes',
|
||||||
description: 'Get notes with optional filters. Returns lightweight format by default (truncated content, no images). Use fullDetails=true for complete data.',
|
description: 'List notes. Returns lightweight format by default (truncated content, no images). Use fullDetails=true for full payloads.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
includeArchived: { type: 'boolean', description: 'Include archived notes', default: false },
|
includeArchived: { type: 'boolean', description: 'Include archived notes', default: false },
|
||||||
search: { type: 'string', description: 'Filter by keyword in title/content' },
|
search: { type: 'string', description: 'Keyword filter on title/content' },
|
||||||
notebookId: { type: 'string', description: 'Filter by notebook ID. Use "inbox" for notes without a notebook' },
|
notebookId: { type: 'string', description: 'Filter by notebook. Use "inbox" for unfiled notes.' },
|
||||||
fullDetails: { type: 'boolean', description: 'Return full details including images (large payload)', default: false },
|
fullDetails: { type: 'boolean', description: 'Full payload including images', default: false },
|
||||||
limit: { type: 'number', description: `Max notes to return (default ${DEFAULT_NOTES_LIMIT})`, default: DEFAULT_NOTES_LIMIT },
|
limit: { type: 'number', description: `Max results (default ${DEFAULT_NOTES_LIMIT})`, default: DEFAULT_NOTES_LIMIT },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -139,14 +129,14 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'update_note',
|
name: 'update_note',
|
||||||
description: 'Update an existing note. Only include fields you want to change.',
|
description: 'Update a note. Only fields you include will be changed.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', description: 'Note ID' },
|
id: { type: 'string', description: 'Note ID' },
|
||||||
title: { type: 'string' },
|
title: { type: 'string' },
|
||||||
content: { type: 'string' },
|
content: { type: 'string' },
|
||||||
color: { type: 'string', description: `One of: ${NOTE_COLORS.join(', ')}` },
|
color: { type: 'string', description: `One of: ${NOTE_COLORS}` },
|
||||||
type: { type: 'string', enum: ['text', 'checklist'] },
|
type: { type: 'string', enum: ['text', 'checklist'] },
|
||||||
checkItems: {
|
checkItems: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@@ -173,7 +163,7 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'delete_note',
|
name: 'delete_note',
|
||||||
description: 'Delete a note by ID.',
|
description: 'Permanently delete a note.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string', description: 'Note ID' } },
|
properties: { id: { type: 'string', description: 'Note ID' } },
|
||||||
@@ -187,7 +177,7 @@ const toolDefinitions = [
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: 'string', description: 'Search query' },
|
query: { type: 'string', description: 'Search query' },
|
||||||
notebookId: { type: 'string', description: 'Limit search to a notebook' },
|
notebookId: { type: 'string', description: 'Limit to a notebook' },
|
||||||
includeArchived: { type: 'boolean', default: false },
|
includeArchived: { type: 'boolean', default: false },
|
||||||
},
|
},
|
||||||
required: ['query'],
|
required: ['query'],
|
||||||
@@ -195,12 +185,12 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'move_note',
|
name: 'move_note',
|
||||||
description: 'Move a note to a different notebook. Pass null for notebookId to move to Inbox.',
|
description: 'Move a note to a notebook. Set notebookId to null to move to Inbox.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', description: 'Note ID' },
|
id: { type: 'string', description: 'Note ID' },
|
||||||
notebookId: { type: 'string', description: 'Target notebook ID, or null/empty for Inbox' },
|
notebookId: { type: 'string', description: 'Target notebook ID, or null for Inbox' },
|
||||||
},
|
},
|
||||||
required: ['id'],
|
required: ['id'],
|
||||||
},
|
},
|
||||||
@@ -225,18 +215,18 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'export_notes',
|
name: 'export_notes',
|
||||||
description: 'Export all notes, labels, and notebooks as a JSON object.',
|
description: 'Export all notes, notebooks, and labels as JSON.',
|
||||||
inputSchema: { type: 'object', properties: {} },
|
inputSchema: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'import_notes',
|
name: 'import_notes',
|
||||||
description: 'Import notes from a previously exported JSON object. Skips duplicates by name.',
|
description: 'Import notes, notebooks, and labels from a previous export. Duplicates are skipped.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
data: {
|
data: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description: 'The exported JSON data (from export_notes)',
|
description: 'Export JSON (from export_notes)',
|
||||||
properties: {
|
properties: {
|
||||||
version: { type: 'string' },
|
version: { type: 'string' },
|
||||||
data: {
|
data: {
|
||||||
@@ -254,31 +244,29 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══ NOTEBOOKS ═══
|
||||||
// NOTEBOOK TOOLS
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
{
|
{
|
||||||
name: 'create_notebook',
|
name: 'create_notebook',
|
||||||
description: 'Create a new notebook.',
|
description: 'Create a notebook with a name, icon, and color.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: 'string', description: 'Notebook name' },
|
name: { type: 'string', description: 'Notebook name' },
|
||||||
icon: { type: 'string', description: 'Notebook icon (emoji)', default: '📁' },
|
icon: { type: 'string', description: 'Icon (emoji)', default: '📁' },
|
||||||
color: { type: 'string', description: 'Hex color code', default: '#3B82F6' },
|
color: { type: 'string', description: 'Hex color', default: '#3B82F6' },
|
||||||
order: { type: 'number', description: 'Sort position (auto-assigned if omitted)' },
|
order: { type: 'number', description: 'Sort position (auto if omitted)' },
|
||||||
},
|
},
|
||||||
required: ['name'],
|
required: ['name'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_notebooks',
|
name: 'get_notebooks',
|
||||||
description: 'Get all notebooks with label and note counts.',
|
description: 'List all notebooks with label and note counts.',
|
||||||
inputSchema: { type: 'object', properties: {} },
|
inputSchema: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_notebook',
|
name: 'get_notebook',
|
||||||
description: 'Get a notebook by ID, including its notes.',
|
description: 'Get a notebook by ID including its notes.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string', description: 'Notebook ID' } },
|
properties: { id: { type: 'string', description: 'Notebook ID' } },
|
||||||
@@ -287,7 +275,7 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'update_notebook',
|
name: 'update_notebook',
|
||||||
description: 'Update a notebook\'s name, icon, color, or order.',
|
description: 'Update a notebook name, icon, color, or order.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -302,7 +290,7 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'delete_notebook',
|
name: 'delete_notebook',
|
||||||
description: 'Delete a notebook. Notes inside will be moved to Inbox.',
|
description: 'Delete a notebook. Notes inside are moved to Inbox.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string', description: 'Notebook ID' } },
|
properties: { id: { type: 'string', description: 'Notebook ID' } },
|
||||||
@@ -311,13 +299,13 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'reorder_notebooks',
|
name: 'reorder_notebooks',
|
||||||
description: 'Reorder notebooks. Pass an ordered array of notebook IDs.',
|
description: 'Set the order of all notebooks by passing their IDs in sequence.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
notebookIds: {
|
notebookIds: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'Notebook IDs in the desired order',
|
description: 'Notebook IDs in desired order',
|
||||||
items: { type: 'string' },
|
items: { type: 'string' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -325,35 +313,33 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══ LABELS ═══
|
||||||
// LABEL TOOLS
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
{
|
{
|
||||||
name: 'create_label',
|
name: 'create_label',
|
||||||
description: 'Create a label in a notebook.',
|
description: 'Create a label inside a notebook.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: 'string', description: 'Label name' },
|
name: { type: 'string', description: 'Label name' },
|
||||||
color: { type: 'string', description: `Label color: ${LABEL_COLORS.join(', ')}` },
|
color: { type: 'string', description: `Color: ${LABEL_COLORS}` },
|
||||||
notebookId: { type: 'string', description: 'Notebook to attach the label to' },
|
notebookId: { type: 'string', description: 'Parent notebook ID' },
|
||||||
},
|
},
|
||||||
required: ['name', 'notebookId'],
|
required: ['name', 'notebookId'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_labels',
|
name: 'get_labels',
|
||||||
description: 'Get all labels, optionally filtered by notebook.',
|
description: 'List labels, optionally filtered by notebook.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
notebookId: { type: 'string', description: 'Filter labels by notebook ID' },
|
notebookId: { type: 'string', description: 'Filter by notebook' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'update_label',
|
name: 'update_label',
|
||||||
description: 'Update a label\'s name or color.',
|
description: 'Update a label name or color.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -366,7 +352,7 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'delete_label',
|
name: 'delete_label',
|
||||||
description: 'Delete a label by ID.',
|
description: 'Delete a label.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string', description: 'Label ID' } },
|
properties: { id: { type: 'string', description: 'Label ID' } },
|
||||||
@@ -374,33 +360,23 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══ REMINDERS ═══
|
||||||
// REMINDER TOOLS
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
{
|
{
|
||||||
name: 'get_due_reminders',
|
name: 'get_due_reminders',
|
||||||
description: 'Get all notes with due reminders that haven\'t been processed yet. Designed for cron/automation use.',
|
description: 'Get notes with due reminders. Designed for cron/automation.',
|
||||||
inputSchema: { type: 'object', properties: {} },
|
inputSchema: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Tool Handlers ──────────────────────────────────────────────────────────
|
// ─── Tool Handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Register all tools and handlers on an MCP Server instance.
|
|
||||||
*
|
|
||||||
* @param {import('@modelcontextprotocol/sdk/server/index.js').Server} server
|
|
||||||
* @param {import('@prisma/client').PrismaClient} prisma
|
|
||||||
*/
|
|
||||||
export function registerTools(server, prisma) {
|
export function registerTools(server, prisma) {
|
||||||
|
|
||||||
// Resolve userId per-request from AsyncLocalStorage (set by auth middleware)
|
|
||||||
const getResolvedUserId = () => {
|
const getResolvedUserId = () => {
|
||||||
const store = requestContext.getStore();
|
const store = requestContext.getStore();
|
||||||
return store?.userId || null;
|
return store?.userId || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fallback: auto-detect first user when no auth context
|
|
||||||
let fallbackUserId = null;
|
let fallbackUserId = null;
|
||||||
let fallbackPromise = null;
|
let fallbackPromise = null;
|
||||||
|
|
||||||
@@ -418,22 +394,24 @@ export function registerTools(server, prisma) {
|
|||||||
return fallbackPromise;
|
return fallbackPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── List Tools ────────────────────────────────────────────────────────────
|
const noteWhere = (resolvedUserId, extra = {}) => ({
|
||||||
|
trashedAt: null,
|
||||||
|
...(resolvedUserId ? { userId: resolvedUserId } : {}),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
return { tools: toolDefinitions };
|
return { tools: toolDefinitions };
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Call Tools ────────────────────────────────────────────────────────────
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
const { name, arguments: args } = request.params;
|
const { name, arguments: args } = request.params;
|
||||||
const resolvedUserId = getResolvedUserId();
|
const uid = getResolvedUserId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══ NOTES ═══
|
||||||
// NOTES
|
|
||||||
// ═══════════════════════════════════════════════════════
|
|
||||||
case 'create_note': {
|
case 'create_note': {
|
||||||
const data = {
|
const data = {
|
||||||
title: args.title || null,
|
title: args.title || null,
|
||||||
@@ -453,31 +431,29 @@ export function registerTools(server, prisma) {
|
|||||||
isMarkdown: args.isMarkdown || false,
|
isMarkdown: args.isMarkdown || false,
|
||||||
size: args.size || 'small',
|
size: args.size || 'small',
|
||||||
notebookId: args.notebookId || null,
|
notebookId: args.notebookId || null,
|
||||||
|
userId: uid || await ensureUserId(),
|
||||||
};
|
};
|
||||||
if (resolvedUserId) data.userId = resolvedUserId;
|
|
||||||
else data.userId = await ensureUserId();
|
|
||||||
|
|
||||||
const note = await prisma.note.create({ data });
|
const note = await prisma.note.create({ data });
|
||||||
return textResult(parseNote(note));
|
return textResult(parseNote(note));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'get_notes': {
|
case 'get_notes': {
|
||||||
const where = {};
|
const extra = {};
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
if (!args?.includeArchived) extra.isArchived = false;
|
||||||
if (!args?.includeArchived) where.isArchived = false;
|
|
||||||
if (args?.search) {
|
if (args?.search) {
|
||||||
where.OR = [
|
extra.OR = [
|
||||||
{ title: { contains: args.search } },
|
{ title: { contains: args.search } },
|
||||||
{ content: { contains: args.search } },
|
{ content: { contains: args.search } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (args?.notebookId) {
|
if (args?.notebookId) {
|
||||||
where.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
|
extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = Math.min(args?.limit || DEFAULT_NOTES_LIMIT, 500); // Max 500
|
const limit = Math.min(args?.limit || DEFAULT_NOTES_LIMIT, MAX_NOTES_LIMIT);
|
||||||
const notes = await prisma.note.findMany({
|
const notes = await prisma.note.findMany({
|
||||||
where,
|
where: noteWhere(uid, extra),
|
||||||
orderBy: [{ isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' }],
|
orderBy: [{ isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' }],
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
@@ -487,50 +463,50 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'get_note': {
|
case 'get_note': {
|
||||||
const where = { id: args.id };
|
const note = await prisma.note.findUnique({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
const note = await prisma.note.findUnique({ where });
|
});
|
||||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||||
return textResult(parseNote(note));
|
return textResult(parseNote(note));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'update_note': {
|
case 'update_note': {
|
||||||
const updateData = {};
|
const d = {};
|
||||||
const fields = ['title', 'color', 'type', 'isPinned', 'isArchived', 'isMarkdown', 'size', 'notebookId', 'isReminderDone', 'reminderRecurrence', 'reminderLocation'];
|
const simpleFields = ['title', 'color', 'type', 'isPinned', 'isArchived', 'isMarkdown', 'size', 'notebookId', 'isReminderDone', 'reminderRecurrence', 'reminderLocation'];
|
||||||
for (const f of fields) {
|
for (const f of simpleFields) {
|
||||||
if (f in args) updateData[f] = args[f];
|
if (f in args) d[f] = args[f];
|
||||||
}
|
}
|
||||||
if ('content' in args) updateData.content = args.content;
|
if ('content' in args) d.content = args.content;
|
||||||
if ('checkItems' in args) updateData.checkItems = args.checkItems ?? null;
|
if ('checkItems' in args) d.checkItems = args.checkItems ?? null;
|
||||||
if ('labels' in args) updateData.labels = args.labels ?? null;
|
if ('labels' in args) d.labels = args.labels ?? null;
|
||||||
if ('images' in args) updateData.images = args.images ?? null;
|
if ('images' in args) d.images = args.images ?? null;
|
||||||
if ('links' in args) updateData.links = args.links ?? null;
|
if ('links' in args) d.links = args.links ?? null;
|
||||||
if ('reminder' in args) updateData.reminder = args.reminder ? new Date(args.reminder) : null;
|
if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null;
|
||||||
updateData.updatedAt = new Date();
|
d.updatedAt = new Date();
|
||||||
|
|
||||||
const where = { id: args.id };
|
const note = await prisma.note.update({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
const note = await prisma.note.update({ where, data: updateData });
|
data: d,
|
||||||
|
});
|
||||||
return textResult(parseNote(note));
|
return textResult(parseNote(note));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'delete_note': {
|
case 'delete_note': {
|
||||||
const where = { id: args.id };
|
await prisma.note.delete({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
await prisma.note.delete({ where });
|
});
|
||||||
return textResult({ success: true, message: 'Note deleted' });
|
return textResult({ success: true, deleted: args.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'search_notes': {
|
case 'search_notes': {
|
||||||
const where = {
|
const where = noteWhere(uid, {
|
||||||
isArchived: args.includeArchived || false,
|
isArchived: args.includeArchived || false,
|
||||||
OR: [
|
OR: [
|
||||||
{ title: { contains: args.query } },
|
{ title: { contains: args.query } },
|
||||||
{ content: { contains: args.query } },
|
{ content: { contains: args.query } },
|
||||||
],
|
],
|
||||||
};
|
...(args.notebookId ? { notebookId: args.notebookId } : {}),
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
});
|
||||||
if (args.notebookId) where.notebookId = args.notebookId;
|
|
||||||
|
|
||||||
const notes = await prisma.note.findMany({
|
const notes = await prisma.note.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -541,79 +517,61 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'move_note': {
|
case 'move_note': {
|
||||||
const noteWhere = { id: args.id };
|
const targetId = args.notebookId || null;
|
||||||
if (resolvedUserId) noteWhere.userId = resolvedUserId;
|
|
||||||
|
|
||||||
const targetNotebookId = args.notebookId || null;
|
|
||||||
|
|
||||||
// Optimized: Parallel execution
|
|
||||||
const [note, notebook] = await Promise.all([
|
const [note, notebook] = await Promise.all([
|
||||||
prisma.note.update({
|
prisma.note.update({
|
||||||
where: noteWhere,
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
data: { notebookId: targetNotebookId, updatedAt: new Date() },
|
data: { notebookId: targetId, updatedAt: new Date() },
|
||||||
}),
|
}),
|
||||||
targetNotebookId
|
targetId
|
||||||
? prisma.notebook.findUnique({ where: { id: targetNotebookId }, select: { name: true } })
|
? prisma.notebook.findUnique({ where: { id: targetId }, select: { name: true } })
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const notebookName = notebook?.name || 'Inbox';
|
|
||||||
|
|
||||||
return textResult({
|
return textResult({
|
||||||
success: true,
|
success: true,
|
||||||
data: { id: note.id, notebookId: note.notebookId, notebook: { name: notebookName } },
|
id: note.id,
|
||||||
message: `Note moved to ${notebookName}`,
|
notebookId: note.notebookId,
|
||||||
|
notebookName: notebook?.name || 'Inbox',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'toggle_pin': {
|
case 'toggle_pin': {
|
||||||
const where = { id: args.id };
|
const note = await prisma.note.findUnique({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
const note = await prisma.note.findUnique({ where });
|
});
|
||||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||||
const updated = await prisma.note.update({
|
const updated = await prisma.note.update({
|
||||||
where,
|
where: { id: args.id },
|
||||||
data: { isPinned: !note.isPinned },
|
data: { isPinned: !note.isPinned },
|
||||||
});
|
});
|
||||||
return textResult(parseNote(updated));
|
return textResult(parseNote(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'toggle_archive': {
|
case 'toggle_archive': {
|
||||||
const where = { id: args.id };
|
const note = await prisma.note.findUnique({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
const note = await prisma.note.findUnique({ where });
|
});
|
||||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||||
const updated = await prisma.note.update({
|
const updated = await prisma.note.update({
|
||||||
where,
|
where: { id: args.id },
|
||||||
data: { isArchived: !note.isArchived },
|
data: { isArchived: !note.isArchived },
|
||||||
});
|
});
|
||||||
return textResult(parseNote(updated));
|
return textResult(parseNote(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'export_notes': {
|
case 'export_notes': {
|
||||||
const noteWhere = {};
|
const nbWhere = uid ? { userId: uid } : {};
|
||||||
const nbWhere = {};
|
|
||||||
if (resolvedUserId) { noteWhere.userId = resolvedUserId; nbWhere.userId = resolvedUserId; }
|
|
||||||
|
|
||||||
// Optimized: Parallel queries
|
|
||||||
const [notes, notebooks, labels] = await Promise.all([
|
const [notes, notebooks, labels] = await Promise.all([
|
||||||
prisma.note.findMany({
|
prisma.note.findMany({
|
||||||
where: noteWhere,
|
where: noteWhere(uid),
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true, title: true, content: true, color: true, type: true,
|
||||||
title: true,
|
isPinned: true, isArchived: true, isMarkdown: true, size: true,
|
||||||
content: true,
|
labels: true, notebookId: true, createdAt: true, updatedAt: true,
|
||||||
color: true,
|
|
||||||
type: true,
|
|
||||||
isPinned: true,
|
|
||||||
isArchived: true,
|
|
||||||
isMarkdown: true,
|
|
||||||
size: true,
|
|
||||||
labels: true,
|
|
||||||
notebookId: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.notebook.findMany({
|
prisma.notebook.findMany({
|
||||||
@@ -626,9 +584,8 @@ export function registerTools(server, prisma) {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Filter labels by userId in memory (faster than multiple queries)
|
const filteredLabels = uid
|
||||||
const filteredLabels = resolvedUserId
|
? labels.filter(l => l.notebook?.userId === uid)
|
||||||
? labels.filter(l => l.notebook?.userId === resolvedUserId)
|
|
||||||
: labels;
|
: labels;
|
||||||
|
|
||||||
return textResult({
|
return textResult({
|
||||||
@@ -636,32 +593,16 @@ export function registerTools(server, prisma) {
|
|||||||
exportDate: new Date().toISOString(),
|
exportDate: new Date().toISOString(),
|
||||||
data: {
|
data: {
|
||||||
notes: notes.map(n => ({
|
notes: notes.map(n => ({
|
||||||
id: n.id,
|
id: n.id, title: n.title, content: n.content, color: n.color, type: n.type,
|
||||||
title: n.title,
|
isPinned: n.isPinned, isArchived: n.isArchived, isMarkdown: n.isMarkdown,
|
||||||
content: n.content,
|
size: n.size, labels: Array.isArray(n.labels) ? n.labels : [],
|
||||||
color: n.color,
|
notebookId: n.notebookId, createdAt: n.createdAt, updatedAt: n.updatedAt,
|
||||||
type: n.type,
|
|
||||||
isPinned: n.isPinned,
|
|
||||||
isArchived: n.isArchived,
|
|
||||||
isMarkdown: n.isMarkdown,
|
|
||||||
size: n.size,
|
|
||||||
labels: Array.isArray(n.labels) ? n.labels : [],
|
|
||||||
notebookId: n.notebookId,
|
|
||||||
createdAt: n.createdAt,
|
|
||||||
updatedAt: n.updatedAt,
|
|
||||||
})),
|
})),
|
||||||
notebooks: notebooks.map(nb => ({
|
notebooks: notebooks.map(nb => ({
|
||||||
id: nb.id,
|
id: nb.id, name: nb.name, icon: nb.icon, color: nb.color, noteCount: nb._count.notes,
|
||||||
name: nb.name,
|
|
||||||
icon: nb.icon,
|
|
||||||
color: nb.color,
|
|
||||||
noteCount: nb._count.notes,
|
|
||||||
})),
|
})),
|
||||||
labels: filteredLabels.map(l => ({
|
labels: filteredLabels.map(l => ({
|
||||||
id: l.id,
|
id: l.id, name: l.name, color: l.color, notebookId: l.notebookId,
|
||||||
name: l.name,
|
|
||||||
color: l.color,
|
|
||||||
notebookId: l.notebookId,
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -671,60 +612,51 @@ export function registerTools(server, prisma) {
|
|||||||
const importData = args.data;
|
const importData = args.data;
|
||||||
let importedNotes = 0, importedLabels = 0, importedNotebooks = 0;
|
let importedNotes = 0, importedLabels = 0, importedNotebooks = 0;
|
||||||
|
|
||||||
// OPTIMIZED: Batch operations with Promise.all for notebooks
|
|
||||||
if (importData.data?.notebooks?.length > 0) {
|
if (importData.data?.notebooks?.length > 0) {
|
||||||
const existingNotebooks = await prisma.notebook.findMany({
|
const existing = await prisma.notebook.findMany({
|
||||||
where: resolvedUserId ? { userId: resolvedUserId } : {},
|
where: uid ? { userId: uid } : {},
|
||||||
select: { name: true },
|
select: { name: true },
|
||||||
});
|
});
|
||||||
const existingNames = new Set(existingNotebooks.map(nb => nb.name));
|
const existingNames = new Set(existing.map(nb => nb.name));
|
||||||
|
|
||||||
const notebooksToCreate = importData.data.notebooks
|
const toCreate = importData.data.notebooks
|
||||||
.filter(nb => !existingNames.has(nb.name))
|
.filter(nb => !existingNames.has(nb.name))
|
||||||
.map(nb => prisma.notebook.create({
|
.map(nb => ({
|
||||||
data: {
|
name: nb.name,
|
||||||
name: nb.name,
|
icon: nb.icon || '📁',
|
||||||
icon: nb.icon || '📁',
|
color: nb.color || '#3B82F6',
|
||||||
color: nb.color || '#3B82F6',
|
...(uid ? { userId: uid } : {}),
|
||||||
...(resolvedUserId ? { userId: resolvedUserId } : {}),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await Promise.all(notebooksToCreate);
|
if (toCreate.length > 0) {
|
||||||
importedNotebooks = notebooksToCreate.length;
|
await prisma.notebook.createMany({ data: toCreate, skipDuplicates: true });
|
||||||
|
importedNotebooks = toCreate.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMIZED: Batch labels
|
|
||||||
if (importData.data?.labels?.length > 0) {
|
if (importData.data?.labels?.length > 0) {
|
||||||
const notebooks = await prisma.notebook.findMany({
|
const notebooks = await prisma.notebook.findMany({
|
||||||
where: resolvedUserId ? { userId: resolvedUserId } : {},
|
where: uid ? { userId: uid } : {},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
const notebookIds = new Set(notebooks.map(nb => nb.id));
|
const nbIds = new Set(notebooks.map(nb => nb.id));
|
||||||
|
|
||||||
const existingLabels = await prisma.label.findMany({
|
const existing = await prisma.label.findMany({
|
||||||
where: { notebookId: { in: Array.from(notebookIds) } },
|
where: { notebookId: { in: Array.from(nbIds) } },
|
||||||
select: { name: true, notebookId: true },
|
select: { name: true, notebookId: true },
|
||||||
});
|
});
|
||||||
const existingLabelKeys = new Set(existingLabels.map(l => `${l.notebookId}:${l.name}`));
|
const existingKeys = new Set(existing.map(l => `${l.notebookId}:${l.name}`));
|
||||||
|
|
||||||
const labelsToCreate = [];
|
const toCreate = importData.data.labels
|
||||||
for (const label of importData.data.labels) {
|
.filter(l => l.notebookId && nbIds.has(l.notebookId) && !existingKeys.has(`${l.notebookId}:${l.name}`))
|
||||||
if (label.notebookId && notebookIds.has(label.notebookId)) {
|
.map(l => ({ name: l.name, color: l.color, notebookId: l.notebookId }));
|
||||||
const key = `${label.notebookId}:${label.name}`;
|
|
||||||
if (!existingLabelKeys.has(key)) {
|
if (toCreate.length > 0) {
|
||||||
labelsToCreate.push(prisma.label.create({
|
await prisma.label.createMany({ data: toCreate, skipDuplicates: true });
|
||||||
data: { name: label.name, color: label.color, notebookId: label.notebookId },
|
importedLabels = toCreate.length;
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(labelsToCreate);
|
|
||||||
importedLabels = labelsToCreate.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMIZED: Batch notes with createMany if available, else Promise.all
|
|
||||||
if (importData.data?.notes?.length > 0) {
|
if (importData.data?.notes?.length > 0) {
|
||||||
const notesData = importData.data.notes.map(note => ({
|
const notesData = importData.data.notes.map(note => ({
|
||||||
title: note.title,
|
title: note.title,
|
||||||
@@ -737,22 +669,14 @@ export function registerTools(server, prisma) {
|
|||||||
size: note.size || 'small',
|
size: note.size || 'small',
|
||||||
labels: note.labels ?? null,
|
labels: note.labels ?? null,
|
||||||
notebookId: note.notebookId || null,
|
notebookId: note.notebookId || null,
|
||||||
...(resolvedUserId ? { userId: resolvedUserId } : {}),
|
...(uid ? { userId: uid } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Try createMany first (faster), fall back to Promise.all
|
|
||||||
try {
|
try {
|
||||||
const result = await prisma.note.createMany({
|
const result = await prisma.note.createMany({ data: notesData, skipDuplicates: true });
|
||||||
data: notesData,
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
importedNotes = result.count;
|
importedNotes = result.count;
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback to individual creates
|
const results = await Promise.all(notesData.map(d => prisma.note.create({ data: d }).catch(() => null)));
|
||||||
const creates = notesData.map(data =>
|
|
||||||
prisma.note.create({ data }).catch(() => null)
|
|
||||||
);
|
|
||||||
const results = await Promise.all(creates);
|
|
||||||
importedNotes = results.filter(r => r !== null).length;
|
importedNotes = results.filter(r => r !== null).length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -763,27 +687,23 @@ export function registerTools(server, prisma) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══ NOTEBOOKS ═══
|
||||||
// NOTEBOOKS
|
|
||||||
// ═══════════════════════════════════════════════════════
|
|
||||||
case 'create_notebook': {
|
case 'create_notebook': {
|
||||||
const highestOrder = await prisma.notebook.findFirst({
|
const highest = await prisma.notebook.findFirst({
|
||||||
where: resolvedUserId ? { userId: resolvedUserId } : {},
|
where: uid ? { userId: uid } : {},
|
||||||
orderBy: { order: 'desc' },
|
orderBy: { order: 'desc' },
|
||||||
select: { order: true },
|
select: { order: true },
|
||||||
});
|
});
|
||||||
const nextOrder = args.order !== undefined ? args.order : (highestOrder?.order ?? -1) + 1;
|
const nextOrder = args.order !== undefined ? args.order : (highest?.order ?? -1) + 1;
|
||||||
|
|
||||||
const data = {
|
|
||||||
name: args.name.trim(),
|
|
||||||
icon: args.icon || '📁',
|
|
||||||
color: args.color || '#3B82F6',
|
|
||||||
order: nextOrder,
|
|
||||||
};
|
|
||||||
data.userId = resolvedUserId || await ensureUserId();
|
|
||||||
|
|
||||||
const notebook = await prisma.notebook.create({
|
const notebook = await prisma.notebook.create({
|
||||||
data,
|
data: {
|
||||||
|
name: args.name.trim(),
|
||||||
|
icon: args.icon || '📁',
|
||||||
|
color: args.color || '#3B82F6',
|
||||||
|
order: nextOrder,
|
||||||
|
userId: uid || await ensureUserId(),
|
||||||
|
},
|
||||||
include: { labels: true, _count: { select: { notes: true } } },
|
include: { labels: true, _count: { select: { notes: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -791,9 +711,7 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'get_notebooks': {
|
case 'get_notebooks': {
|
||||||
const where = {};
|
const where = uid ? { userId: uid } : {};
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
const notebooks = await prisma.notebook.findMany({
|
const notebooks = await prisma.notebook.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
@@ -807,12 +725,10 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'get_notebook': {
|
case 'get_notebook': {
|
||||||
const where = { id: args.id };
|
const where = { id: args.id, ...(uid ? { userId: uid } : {}) };
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
const notebook = await prisma.notebook.findUnique({
|
const notebook = await prisma.notebook.findUnique({
|
||||||
where,
|
where,
|
||||||
include: { labels: true, notes: true, _count: { select: { notes: true } } },
|
include: { labels: true, notes: { where: { trashedAt: null } }, _count: { select: { notes: true } } },
|
||||||
});
|
});
|
||||||
if (!notebook) throw new McpError(ErrorCode.InvalidRequest, 'Notebook not found');
|
if (!notebook) throw new McpError(ErrorCode.InvalidRequest, 'Notebook not found');
|
||||||
|
|
||||||
@@ -824,18 +740,16 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'update_notebook': {
|
case 'update_notebook': {
|
||||||
const updateData = {};
|
const d = {};
|
||||||
if ('name' in args) updateData.name = args.name.trim();
|
if ('name' in args) d.name = args.name.trim();
|
||||||
if ('icon' in args) updateData.icon = args.icon;
|
if ('icon' in args) d.icon = args.icon;
|
||||||
if ('color' in args) updateData.color = args.color;
|
if ('color' in args) d.color = args.color;
|
||||||
if ('order' in args) updateData.order = args.order;
|
if ('order' in args) d.order = args.order;
|
||||||
|
|
||||||
const where = { id: args.id };
|
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
|
const where = { id: args.id, ...(uid ? { userId: uid } : {}) };
|
||||||
const notebook = await prisma.notebook.update({
|
const notebook = await prisma.notebook.update({
|
||||||
where,
|
where,
|
||||||
data: updateData,
|
data: d,
|
||||||
include: { labels: true, _count: { select: { notes: true } } },
|
include: { labels: true, _count: { select: { notes: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -843,16 +757,14 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'delete_notebook': {
|
case 'delete_notebook': {
|
||||||
const where = { id: args.id };
|
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
// Move notes to inbox before deleting
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.note.updateMany({
|
prisma.note.updateMany({
|
||||||
where: { notebookId: args.id, ...(resolvedUserId ? { userId: resolvedUserId } : {}) },
|
where: { notebookId: args.id, ...(uid ? { userId: uid } : {}) },
|
||||||
data: { notebookId: null },
|
data: { notebookId: null },
|
||||||
}),
|
}),
|
||||||
prisma.notebook.delete({ where }),
|
prisma.notebook.delete({
|
||||||
|
where: { id: args.id, ...(uid ? { userId: uid } : {}) },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return textResult({ success: true, message: 'Notebook deleted, notes moved to Inbox' });
|
return textResult({ success: true, message: 'Notebook deleted, notes moved to Inbox' });
|
||||||
@@ -860,21 +772,14 @@ export function registerTools(server, prisma) {
|
|||||||
|
|
||||||
case 'reorder_notebooks': {
|
case 'reorder_notebooks': {
|
||||||
const ids = args.notebookIds;
|
const ids = args.notebookIds;
|
||||||
|
const where = { id: { in: ids }, ...(uid ? { userId: uid } : {}) };
|
||||||
|
|
||||||
// Optimized: Verify ownership in one query
|
const existing = await prisma.notebook.findMany({ where, select: { id: true } });
|
||||||
const where = { id: { in: ids } };
|
const existingIds = new Set(existing.map(nb => nb.id));
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
const missing = ids.filter(id => !existingIds.has(id));
|
||||||
|
|
||||||
const existingNotebooks = await prisma.notebook.findMany({
|
if (missing.length > 0) {
|
||||||
where,
|
throw new McpError(ErrorCode.InvalidRequest, `Notebooks not found: ${missing.join(', ')}`);
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingIds = new Set(existingNotebooks.map(nb => nb.id));
|
|
||||||
const missingIds = ids.filter(id => !existingIds.has(id));
|
|
||||||
|
|
||||||
if (missingIds.length > 0) {
|
|
||||||
throw new McpError(ErrorCode.InvalidRequest, `Notebooks not found: ${missingIds.join(', ')}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
@@ -882,12 +787,10 @@ export function registerTools(server, prisma) {
|
|||||||
prisma.notebook.update({ where: { id }, data: { order: index } })
|
prisma.notebook.update({ where: { id }, data: { order: index } })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return textResult({ success: true, message: 'Notebooks reordered' });
|
return textResult({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══ LABELS ═══
|
||||||
// LABELS - OPTIMIZED to fix N+1 query
|
|
||||||
// ═══════════════════════════════════════════════════════
|
|
||||||
case 'create_label': {
|
case 'create_label': {
|
||||||
const existing = await prisma.label.findFirst({
|
const existing = await prisma.label.findFirst({
|
||||||
where: { name: args.name.trim(), notebookId: args.notebookId },
|
where: { name: args.name.trim(), notebookId: args.notebookId },
|
||||||
@@ -908,49 +811,37 @@ export function registerTools(server, prisma) {
|
|||||||
const where = {};
|
const where = {};
|
||||||
if (args?.notebookId) where.notebookId = args.notebookId;
|
if (args?.notebookId) where.notebookId = args.notebookId;
|
||||||
|
|
||||||
// OPTIMIZED: Single query with include, then filter in memory
|
|
||||||
const labels = await prisma.label.findMany({
|
const labels = await prisma.label.findMany({
|
||||||
where,
|
where,
|
||||||
include: { notebook: { select: { id: true, name: true, userId: true } } },
|
include: { notebook: { select: { id: true, name: true, userId: true } } },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter by userId in memory (much faster than N+1 queries)
|
const filtered = uid ? labels.filter(l => l.notebook?.userId === uid) : labels;
|
||||||
let filteredLabels = labels;
|
return textResult(filtered);
|
||||||
if (resolvedUserId) {
|
|
||||||
filteredLabels = labels.filter(l => l.notebook?.userId === resolvedUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return textResult(filteredLabels);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'update_label': {
|
case 'update_label': {
|
||||||
const updateData = {};
|
const d = {};
|
||||||
if ('name' in args) updateData.name = args.name.trim();
|
if ('name' in args) d.name = args.name.trim();
|
||||||
if ('color' in args) updateData.color = args.color;
|
if ('color' in args) d.color = args.color;
|
||||||
|
|
||||||
const label = await prisma.label.update({
|
const label = await prisma.label.update({ where: { id: args.id }, data: d });
|
||||||
where: { id: args.id },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
return textResult(label);
|
return textResult(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'delete_label': {
|
case 'delete_label': {
|
||||||
await prisma.label.delete({ where: { id: args.id } });
|
await prisma.label.delete({ where: { id: args.id } });
|
||||||
return textResult({ success: true, message: 'Label deleted' });
|
return textResult({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══ REMINDERS ═══
|
||||||
// REMINDERS
|
|
||||||
// ═══════════════════════════════════════════════════════
|
|
||||||
case 'get_due_reminders': {
|
case 'get_due_reminders': {
|
||||||
const where = {
|
const where = noteWhere(uid, {
|
||||||
reminder: { not: null, lte: new Date() },
|
reminder: { not: null, lte: new Date() },
|
||||||
isReminderDone: false,
|
isReminderDone: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
};
|
});
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
const reminders = await prisma.note.findMany({
|
const reminders = await prisma.note.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -958,7 +849,6 @@ export function registerTools(server, prisma) {
|
|||||||
orderBy: { reminder: 'asc' },
|
orderBy: { reminder: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark them as done
|
|
||||||
if (reminders.length > 0) {
|
if (reminders.length > 0) {
|
||||||
await prisma.note.updateMany({
|
await prisma.note.updateMany({
|
||||||
where: { id: { in: reminders.map(r => r.id) } },
|
where: { id: { in: reminders.map(r => r.id) } },
|
||||||
@@ -966,7 +856,7 @@ export function registerTools(server, prisma) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return textResult({ success: true, count: reminders.length, reminders });
|
return textResult({ count: reminders.length, reminders });
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -974,7 +864,7 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof McpError) throw error;
|
if (error instanceof McpError) throw error;
|
||||||
throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error.message}`);
|
throw new McpError(ErrorCode.InternalError, `Tool error: ${error.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Note } from '@/lib/types'
|
|
||||||
import { NoteCard } from './note-card'
|
|
||||||
import { ChevronDown, ChevronUp, Pin } from 'lucide-react'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
interface FavoritesSectionProps {
|
|
||||||
pinnedNotes: Note[]
|
|
||||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
|
||||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
|
|
||||||
isLoading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FavoritesSection({ pinnedNotes, onEdit, onSizeChange, isLoading }: FavoritesSectionProps) {
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
|
||||||
const { t } = useLanguage()
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<section data-testid="favorites-section" className="mb-8">
|
|
||||||
<div className="flex items-center gap-2 mb-4 px-2 py-2">
|
|
||||||
<Pin className="w-5 h-5 text-muted-foreground animate-pulse" />
|
|
||||||
<div className="h-6 w-32 bg-muted rounded animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="h-40 bg-muted rounded-2xl animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pinnedNotes.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section data-testid="favorites-section" className="mb-8">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault()
|
|
||||||
setIsCollapsed(!isCollapsed)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center justify-between gap-2 mb-4 px-2 py-2 hover:bg-accent rounded-lg transition-colors min-h-[44px]"
|
|
||||||
aria-expanded={!isCollapsed}
|
|
||||||
aria-label={t('favorites.toggleSection') || 'Toggle pinned notes section'}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-2xl">📌</span>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
|
||||||
{t('notes.pinnedNotes')}
|
|
||||||
<span className="text-sm font-medium text-muted-foreground ml-2">
|
|
||||||
({pinnedNotes.length})
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Collapsible Content */}
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{pinnedNotes.map((note) => (
|
|
||||||
<NoteCard
|
|
||||||
key={note.id}
|
|
||||||
note={note}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onSizeChange={(size) => onSizeChange?.(note.id, size)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
import { Note } from '@/lib/types'
|
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
|
||||||
import { getAllNotes, searchNotes } from '@/app/actions/notes'
|
|
||||||
import { NoteInput } from '@/components/note-input'
|
|
||||||
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
|
|
||||||
import { NotesViewToggle } from '@/components/notes-view-toggle'
|
|
||||||
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
|
||||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
|
||||||
import { FavoritesSection } from '@/components/favorites-section'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Wand2 } from 'lucide-react'
|
|
||||||
import { useLabels } from '@/context/LabelContext'
|
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
|
||||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
|
||||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
|
||||||
import { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Plane, ChevronRight, Plus } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { LabelFilter } from '@/components/label-filter'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
import { useHomeView } from '@/context/home-view-context'
|
|
||||||
|
|
||||||
// Lazy-load heavy dialogs — uniquement chargés à la demande
|
|
||||||
const NoteEditor = dynamic(
|
|
||||||
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
|
|
||||||
{ ssr: false }
|
|
||||||
)
|
|
||||||
const BatchOrganizationDialog = dynamic(
|
|
||||||
() => import('@/components/batch-organization-dialog').then(m => ({ default: m.BatchOrganizationDialog })),
|
|
||||||
{ ssr: false }
|
|
||||||
)
|
|
||||||
const AutoLabelSuggestionDialog = dynamic(
|
|
||||||
() => import('@/components/auto-label-suggestion-dialog').then(m => ({ default: m.AutoLabelSuggestionDialog })),
|
|
||||||
{ ssr: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
type InitialSettings = {
|
|
||||||
showRecentNotes: boolean
|
|
||||||
notesViewMode: 'masonry' | 'tabs'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HomeClientProps {
|
|
||||||
/** Notes pré-chargées côté serveur — hydratées immédiatement sans loading spinner */
|
|
||||||
initialNotes: Note[]
|
|
||||||
initialSettings: InitialSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const { t } = useLanguage()
|
|
||||||
|
|
||||||
const [notes, setNotes] = useState<Note[]>(initialNotes)
|
|
||||||
const [pinnedNotes, setPinnedNotes] = useState<Note[]>(
|
|
||||||
initialNotes.filter(n => n.isPinned)
|
|
||||||
)
|
|
||||||
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
|
|
||||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded
|
|
||||||
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
|
|
||||||
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
|
|
||||||
const { refreshKey } = useNoteRefresh()
|
|
||||||
const { labels } = useLabels()
|
|
||||||
const { setControls } = useHomeView()
|
|
||||||
|
|
||||||
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
|
||||||
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldSuggestLabels && suggestNotebookId) {
|
|
||||||
setAutoLabelOpen(true)
|
|
||||||
}
|
|
||||||
}, [shouldSuggestLabels, suggestNotebookId])
|
|
||||||
|
|
||||||
const notebookFilter = searchParams.get('notebook')
|
|
||||||
const isInbox = !notebookFilter
|
|
||||||
|
|
||||||
const handleNoteCreated = useCallback((note: Note) => {
|
|
||||||
setNotes((prevNotes) => {
|
|
||||||
const notebookFilter = searchParams.get('notebook')
|
|
||||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
|
||||||
const colorFilter = searchParams.get('color')
|
|
||||||
const search = searchParams.get('search')?.trim() || null
|
|
||||||
|
|
||||||
if (notebookFilter && note.notebookId !== notebookFilter) return prevNotes
|
|
||||||
if (!notebookFilter && note.notebookId) return prevNotes
|
|
||||||
|
|
||||||
if (labelFilter.length > 0) {
|
|
||||||
const noteLabels = note.labels || []
|
|
||||||
if (!noteLabels.some((label: string) => labelFilter.includes(label))) return prevNotes
|
|
||||||
}
|
|
||||||
|
|
||||||
if (colorFilter) {
|
|
||||||
const labelNamesWithColor = labels
|
|
||||||
.filter((label: any) => label.color === colorFilter)
|
|
||||||
.map((label: any) => label.name)
|
|
||||||
const noteLabels = note.labels || []
|
|
||||||
if (!noteLabels.some((label: string) => labelNamesWithColor.includes(label))) return prevNotes
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
router.refresh()
|
|
||||||
return prevNotes
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPinned = note.isPinned || false
|
|
||||||
const pinnedNotes = prevNotes.filter(n => n.isPinned)
|
|
||||||
const unpinnedNotes = prevNotes.filter(n => !n.isPinned)
|
|
||||||
|
|
||||||
if (isPinned) {
|
|
||||||
return [note, ...pinnedNotes, ...unpinnedNotes]
|
|
||||||
} else {
|
|
||||||
return [...pinnedNotes, note, ...unpinnedNotes]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!note.notebookId) {
|
|
||||||
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
|
|
||||||
if (wordCount >= 20) {
|
|
||||||
setNotebookSuggestion({ noteId: note.id, content: note.content || '' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchParams, labels, router])
|
|
||||||
|
|
||||||
const handleOpenNote = (noteId: string) => {
|
|
||||||
const note = notes.find(n => n.id === noteId)
|
|
||||||
if (note) setEditingNote({ note, readOnly: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => {
|
|
||||||
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
|
||||||
setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useReminderCheck(notes)
|
|
||||||
|
|
||||||
// Rechargement uniquement pour les filtres actifs (search, labels, notebook)
|
|
||||||
// Les notes initiales suffisent sans filtre
|
|
||||||
useEffect(() => {
|
|
||||||
const search = searchParams.get('search')?.trim() || null
|
|
||||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
|
||||||
const colorFilter = searchParams.get('color')
|
|
||||||
const notebook = searchParams.get('notebook')
|
|
||||||
const semanticMode = searchParams.get('semantic') === 'true'
|
|
||||||
|
|
||||||
// Pour le refreshKey (mutations), toujours recharger
|
|
||||||
// Pour les filtres, charger depuis le serveur
|
|
||||||
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
let allNotes = search
|
|
||||||
? await searchNotes(search, semanticMode, notebook || undefined)
|
|
||||||
: await getAllNotes()
|
|
||||||
|
|
||||||
// Filtre notebook côté client
|
|
||||||
if (notebook) {
|
|
||||||
allNotes = allNotes.filter((note: any) => note.notebookId === notebook)
|
|
||||||
} else {
|
|
||||||
allNotes = allNotes.filter((note: any) => !note.notebookId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre labels
|
|
||||||
if (labelFilter.length > 0) {
|
|
||||||
allNotes = allNotes.filter((note: any) =>
|
|
||||||
note.labels?.some((label: string) => labelFilter.includes(label))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre couleur
|
|
||||||
if (colorFilter) {
|
|
||||||
const labelNamesWithColor = labels
|
|
||||||
.filter((label: any) => label.color === colorFilter)
|
|
||||||
.map((label: any) => label.name)
|
|
||||||
allNotes = allNotes.filter((note: any) =>
|
|
||||||
note.labels?.some((label: string) => labelNamesWithColor.includes(label))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merger avec les tailles locales pour ne pas écraser les modifications
|
|
||||||
setNotes(prev => {
|
|
||||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
|
||||||
return allNotes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
|
||||||
})
|
|
||||||
setPinnedNotes(allNotes.filter((n: any) => n.isPinned))
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Éviter le rechargement initial si les notes sont déjà chargées sans filtres
|
|
||||||
if (refreshKey > 0 || hasActiveFilter) {
|
|
||||||
const cancelled = { value: false }
|
|
||||||
load().then(() => { if (cancelled.value) return })
|
|
||||||
return () => { cancelled.value = true }
|
|
||||||
} else {
|
|
||||||
// Données initiales : filtrage inbox/notebook côté client seulement
|
|
||||||
let filtered = initialNotes
|
|
||||||
if (notebook) {
|
|
||||||
filtered = initialNotes.filter(n => n.notebookId === notebook)
|
|
||||||
} else {
|
|
||||||
filtered = initialNotes.filter(n => !n.notebookId)
|
|
||||||
}
|
|
||||||
// Merger avec les tailles déjà modifiées localement
|
|
||||||
setNotes(prev => {
|
|
||||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
|
||||||
return filtered.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
|
||||||
})
|
|
||||||
setPinnedNotes(filtered.filter(n => n.isPinned))
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [searchParams, refreshKey])
|
|
||||||
|
|
||||||
const { notebooks } = useNotebooks()
|
|
||||||
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
|
||||||
const [showNoteInput, setShowNoteInput] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setControls({
|
|
||||||
isTabsMode: notesViewMode === 'tabs',
|
|
||||||
openNoteComposer: () => setShowNoteInput(true),
|
|
||||||
})
|
|
||||||
return () => setControls(null)
|
|
||||||
}, [notesViewMode, setControls])
|
|
||||||
|
|
||||||
const getNotebookIcon = (iconName: string) => {
|
|
||||||
const ICON_MAP: Record<string, any> = {
|
|
||||||
'folder': Folder,
|
|
||||||
'briefcase': Briefcase,
|
|
||||||
'document': FileText,
|
|
||||||
'lightning': Zap,
|
|
||||||
'chart': BarChart3,
|
|
||||||
'globe': Globe,
|
|
||||||
'sparkle': Sparkles,
|
|
||||||
'book': Book,
|
|
||||||
'heart': Heart,
|
|
||||||
'crown': Crown,
|
|
||||||
'music': Music,
|
|
||||||
'building': Building2,
|
|
||||||
'flight_takeoff': Plane,
|
|
||||||
}
|
|
||||||
return ICON_MAP[iconName] || Folder
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNoteCreatedWrapper = (note: any) => {
|
|
||||||
handleNoteCreated(note)
|
|
||||||
setShowNoteInput(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
||||||
<span>{t('nav.notebooks')}</span>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
<span className="font-medium text-primary">{notebookName}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const isTabs = notesViewMode === 'tabs'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex w-full min-h-0 flex-1 flex-col',
|
|
||||||
isTabs ? 'gap-3 py-1' : 'h-full px-2 py-6 sm:px-4 md:px-8'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Notebook Specific Header */}
|
|
||||||
{currentNotebook ? (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
|
|
||||||
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Breadcrumbs notebookName={currentNotebook.name} />
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<div className="p-3 bg-primary/10 dark:bg-primary/20 rounded-xl">
|
|
||||||
{(() => {
|
|
||||||
const Icon = getNotebookIcon(currentNotebook.icon || 'folder')
|
|
||||||
return (
|
|
||||||
<Icon
|
|
||||||
className={cn("w-8 h-8", !currentNotebook.color && "text-primary dark:text-primary-foreground")}
|
|
||||||
style={currentNotebook.color ? { color: currentNotebook.color } : undefined}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
|
||||||
<LabelFilter
|
|
||||||
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
|
|
||||||
onFilterChange={(newLabels) => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
|
||||||
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
|
|
||||||
else params.delete('labels')
|
|
||||||
router.push(`/?${params.toString()}`)
|
|
||||||
}}
|
|
||||||
className="border-gray-200"
|
|
||||||
/>
|
|
||||||
{!isTabs && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowNoteInput(!showNoteInput)}
|
|
||||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
|
||||||
>
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
{t('notes.addNote') || 'Add Note'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
|
|
||||||
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!isTabs && <div className="mb-1 h-5" />}
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
|
|
||||||
<FileText className="w-8 h-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
|
||||||
<LabelFilter
|
|
||||||
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
|
|
||||||
onFilterChange={(newLabels) => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
|
||||||
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
|
|
||||||
else params.delete('labels')
|
|
||||||
router.push(`/?${params.toString()}`)
|
|
||||||
}}
|
|
||||||
className="border-gray-200"
|
|
||||||
/>
|
|
||||||
{isInbox && !isLoading && notes.length >= 2 && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setBatchOrganizationOpen(true)}
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
|
|
||||||
title={t('batch.organizeWithAI')}
|
|
||||||
>
|
|
||||||
<Wand2 className="h-4 w-4 text-purple-600" />
|
|
||||||
<span className="hidden sm:inline">{t('batch.organize')}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!isTabs && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowNoteInput(!showNoteInput)}
|
|
||||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
|
||||||
>
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
{t('notes.newNote')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNoteInput && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'animate-in fade-in slide-in-from-top-4 duration-300',
|
|
||||||
isTabs ? 'mb-3 w-full shrink-0' : 'mb-8'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<NoteInput
|
|
||||||
onNoteCreated={handleNoteCreatedWrapper}
|
|
||||||
forceExpanded={true}
|
|
||||||
fullWidth={isTabs}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FavoritesSection
|
|
||||||
pinnedNotes={pinnedNotes}
|
|
||||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
|
||||||
onSizeChange={handleSizeChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{notes.filter((note) => !note.isPinned).length > 0 && (
|
|
||||||
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
|
|
||||||
<NotesMainSection
|
|
||||||
viewMode={notesViewMode}
|
|
||||||
notes={notes.filter((note) => !note.isPinned)}
|
|
||||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
|
||||||
onSizeChange={handleSizeChange}
|
|
||||||
currentNotebookId={searchParams.get('notebook')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
{t('notes.emptyState')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MemoryEchoNotification onOpenNote={handleOpenNote} />
|
|
||||||
|
|
||||||
{notebookSuggestion && (
|
|
||||||
<NotebookSuggestionToast
|
|
||||||
noteId={notebookSuggestion.noteId}
|
|
||||||
noteContent={notebookSuggestion.content}
|
|
||||||
onDismiss={() => setNotebookSuggestion(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{batchOrganizationOpen && (
|
|
||||||
<BatchOrganizationDialog
|
|
||||||
open={batchOrganizationOpen}
|
|
||||||
onOpenChange={setBatchOrganizationOpen}
|
|
||||||
onNotesMoved={() => router.refresh()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{autoLabelOpen && (
|
|
||||||
<AutoLabelSuggestionDialog
|
|
||||||
open={autoLabelOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setAutoLabelOpen(open)
|
|
||||||
if (!open) dismissLabelSuggestion()
|
|
||||||
}}
|
|
||||||
notebookId={suggestNotebookId}
|
|
||||||
onLabelsCreated={() => router.refresh()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editingNote && (
|
|
||||||
<NoteEditor
|
|
||||||
note={editingNote.note}
|
|
||||||
readOnly={editingNote.readOnly}
|
|
||||||
onClose={() => setEditingNote(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
/**
|
|
||||||
* Masonry Grid — CSS Grid avec tailles visibles
|
|
||||||
* Layout avec espaces minimisés via dense, drag-and-drop via @dnd-kit
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ─── Container ──────────────────────────────────── */
|
|
||||||
.masonry-container {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 8px 40px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── CSS Grid avec dense pour minimiser les trous ─ */
|
|
||||||
.masonry-css-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
||||||
grid-auto-rows: auto;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: start;
|
|
||||||
grid-auto-flow: dense;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Sortable items ─────────────────────────────── */
|
|
||||||
.masonry-sortable-item {
|
|
||||||
break-inside: avoid;
|
|
||||||
box-sizing: border-box;
|
|
||||||
will-change: transform;
|
|
||||||
transition: opacity 0.15s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Taille des notes : small=1 colonne, medium=2 colonnes, large=3 colonnes */
|
|
||||||
.masonry-sortable-item[data-size="medium"] {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masonry-sortable-item[data-size="large"] {
|
|
||||||
grid-column: span 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Note card base ─────────────────────────────── */
|
|
||||||
.note-card {
|
|
||||||
width: 100% !important;
|
|
||||||
min-width: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Drag overlay ───────────────────────────────── */
|
|
||||||
.masonry-drag-overlay {
|
|
||||||
cursor: grabbing;
|
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25), 0 8px 16px rgba(0, 0, 0, 0.15);
|
|
||||||
border-radius: 12px;
|
|
||||||
opacity: 0.95;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Mobile (< 480px) : 1 colonne ──────────────── */
|
|
||||||
@media (max-width: 479px) {
|
|
||||||
.masonry-css-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sur mobile tout est 1 colonne */
|
|
||||||
.masonry-sortable-item[data-size="medium"],
|
|
||||||
.masonry-sortable-item[data-size="large"] {
|
|
||||||
grid-column: span 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masonry-container {
|
|
||||||
padding: 0 4px 16px 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Small tablet (480–767px) : 2 colonnes ─────── */
|
|
||||||
@media (min-width: 480px) and (max-width: 767px) {
|
|
||||||
.masonry-css-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sur 2 colonnes, large prend tout */
|
|
||||||
.masonry-sortable-item[data-size="large"] {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masonry-container {
|
|
||||||
padding: 0 8px 20px 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Tablet (768–1023px) ────────────────────────── */
|
|
||||||
@media (min-width: 768px) and (max-width: 1023px) {
|
|
||||||
.masonry-css-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Desktop (1024–1279px) ─────────────────────── */
|
|
||||||
@media (min-width: 1024px) and (max-width: 1279px) {
|
|
||||||
.masonry-css-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Large Desktop (1280px+) ───────────────────── */
|
|
||||||
@media (min-width: 1280px) {
|
|
||||||
.masonry-css-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
.masonry-container {
|
|
||||||
max-width: 1600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 12px 32px 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Print ──────────────────────────────────────── */
|
|
||||||
@media print {
|
|
||||||
.masonry-sortable-item {
|
|
||||||
break-inside: avoid;
|
|
||||||
page-break-inside: avoid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Reduced motion ─────────────────────────────── */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.masonry-sortable-item {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, memo, useMemo, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
DragEndEvent,
|
|
||||||
DragOverlay,
|
|
||||||
DragStartEvent,
|
|
||||||
PointerSensor,
|
|
||||||
TouchSensor,
|
|
||||||
closestCenter,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
arrayMove,
|
|
||||||
rectSortingStrategy,
|
|
||||||
useSortable,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import { Note } from '@/lib/types';
|
|
||||||
import { NoteCard } from './note-card';
|
|
||||||
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
|
|
||||||
import { useNotebookDrag } from '@/context/notebook-drag-context';
|
|
||||||
import { useLanguage } from '@/lib/i18n';
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import './masonry-grid.css';
|
|
||||||
|
|
||||||
// Lazy-load NoteEditor — uniquement chargé au clic
|
|
||||||
const NoteEditor = dynamic(
|
|
||||||
() => import('./note-editor').then(m => ({ default: m.NoteEditor })),
|
|
||||||
{ ssr: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
interface MasonryGridProps {
|
|
||||||
notes: Note[];
|
|
||||||
onEdit?: (note: Note, readOnly?: boolean) => void;
|
|
||||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// Sortable Note Item
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
interface SortableNoteProps {
|
|
||||||
note: Note;
|
|
||||||
onEdit: (note: Note, readOnly?: boolean) => void;
|
|
||||||
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
|
|
||||||
onDragStartNote?: (noteId: string) => void;
|
|
||||||
onDragEndNote?: () => void;
|
|
||||||
isDragging?: boolean;
|
|
||||||
isOverlay?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SortableNoteItem = memo(function SortableNoteItem({
|
|
||||||
note,
|
|
||||||
onEdit,
|
|
||||||
onSizeChange,
|
|
||||||
onDragStartNote,
|
|
||||||
onDragEndNote,
|
|
||||||
isDragging,
|
|
||||||
isOverlay,
|
|
||||||
}: SortableNoteProps) {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging: isSortableDragging,
|
|
||||||
} = useSortable({ id: note.id });
|
|
||||||
|
|
||||||
const style: React.CSSProperties = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isSortableDragging && !isOverlay ? 0.3 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
className="masonry-sortable-item"
|
|
||||||
data-id={note.id}
|
|
||||||
data-size={note.size}
|
|
||||||
>
|
|
||||||
<NoteCard
|
|
||||||
note={note}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onDragStart={onDragStartNote}
|
|
||||||
onDragEnd={onDragEndNote}
|
|
||||||
isDragging={isDragging}
|
|
||||||
onSizeChange={(newSize) => onSizeChange(note.id, newSize)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// Sortable Grid Section (pinned or others)
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
interface SortableGridSectionProps {
|
|
||||||
notes: Note[];
|
|
||||||
onEdit: (note: Note, readOnly?: boolean) => void;
|
|
||||||
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
|
|
||||||
draggedNoteId: string | null;
|
|
||||||
onDragStartNote: (noteId: string) => void;
|
|
||||||
onDragEndNote: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SortableGridSection = memo(function SortableGridSection({
|
|
||||||
notes,
|
|
||||||
onEdit,
|
|
||||||
onSizeChange,
|
|
||||||
draggedNoteId,
|
|
||||||
onDragStartNote,
|
|
||||||
onDragEndNote,
|
|
||||||
}: SortableGridSectionProps) {
|
|
||||||
const ids = useMemo(() => notes.map(n => n.id), [notes]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableContext items={ids} strategy={rectSortingStrategy}>
|
|
||||||
<div className="masonry-css-grid">
|
|
||||||
{notes.map(note => (
|
|
||||||
<SortableNoteItem
|
|
||||||
key={note.id}
|
|
||||||
note={note}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onSizeChange={onSizeChange}
|
|
||||||
onDragStartNote={onDragStartNote}
|
|
||||||
onDragEndNote={onDragEndNote}
|
|
||||||
isDragging={draggedNoteId === note.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// Main MasonryGrid component
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
export function MasonryGrid({ notes, onEdit, onSizeChange }: MasonryGridProps) {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
|
||||||
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
|
|
||||||
|
|
||||||
// Local notes state for optimistic size/order updates
|
|
||||||
const [localNotes, setLocalNotes] = useState<Note[]>(notes);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalNotes(prev => {
|
|
||||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
|
||||||
return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
|
||||||
})
|
|
||||||
}, [notes]);
|
|
||||||
|
|
||||||
const pinnedNotes = useMemo(() => localNotes.filter(n => n.isPinned), [localNotes]);
|
|
||||||
const othersNotes = useMemo(() => localNotes.filter(n => !n.isPinned), [localNotes]);
|
|
||||||
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
|
||||||
const activeNote = useMemo(
|
|
||||||
() => localNotes.find(n => n.id === activeId) ?? null,
|
|
||||||
[localNotes, activeId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleEdit = useCallback((note: Note, readOnly?: boolean) => {
|
|
||||||
if (onEdit) {
|
|
||||||
onEdit(note, readOnly);
|
|
||||||
} else {
|
|
||||||
setEditingNote({ note, readOnly });
|
|
||||||
}
|
|
||||||
}, [onEdit]);
|
|
||||||
|
|
||||||
const handleSizeChange = useCallback((noteId: string, newSize: 'small' | 'medium' | 'large') => {
|
|
||||||
setLocalNotes(prev => prev.map(n => n.id === noteId ? { ...n, size: newSize } : n));
|
|
||||||
onSizeChange?.(noteId, newSize);
|
|
||||||
}, [onSizeChange]);
|
|
||||||
|
|
||||||
// @dnd-kit sensors — pointer (desktop) + touch (mobile)
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: { distance: 8 }, // Évite les activations accidentelles
|
|
||||||
}),
|
|
||||||
useSensor(TouchSensor, {
|
|
||||||
activationConstraint: { delay: 200, tolerance: 8 }, // Long-press sur mobile
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const localNotesRef = useRef<Note[]>(localNotes)
|
|
||||||
useEffect(() => {
|
|
||||||
localNotesRef.current = localNotes
|
|
||||||
}, [localNotes])
|
|
||||||
|
|
||||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
||||||
setActiveId(event.active.id as string);
|
|
||||||
startDrag(event.active.id as string);
|
|
||||||
}, [startDrag]);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
setActiveId(null);
|
|
||||||
endDrag();
|
|
||||||
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
|
|
||||||
const reordered = arrayMove(
|
|
||||||
localNotesRef.current,
|
|
||||||
localNotesRef.current.findIndex(n => n.id === active.id),
|
|
||||||
localNotesRef.current.findIndex(n => n.id === over.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (reordered.length === 0) return;
|
|
||||||
|
|
||||||
setLocalNotes(reordered);
|
|
||||||
// Persist order outside of setState to avoid "setState in render" warning
|
|
||||||
const ids = reordered.map(n => n.id);
|
|
||||||
updateFullOrderWithoutRevalidation(ids).catch(err => {
|
|
||||||
console.error('Failed to persist order:', err);
|
|
||||||
});
|
|
||||||
}, [endDrag]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
id="masonry-dnd"
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<div className="masonry-container">
|
|
||||||
{pinnedNotes.length > 0 && (
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
|
|
||||||
{t('notes.pinned')}
|
|
||||||
</h2>
|
|
||||||
<SortableGridSection
|
|
||||||
notes={pinnedNotes}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onSizeChange={handleSizeChange}
|
|
||||||
draggedNoteId={draggedNoteId}
|
|
||||||
onDragStartNote={startDrag}
|
|
||||||
onDragEndNote={endDrag}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{othersNotes.length > 0 && (
|
|
||||||
<div>
|
|
||||||
{pinnedNotes.length > 0 && (
|
|
||||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
|
|
||||||
{t('notes.others')}
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
<SortableGridSection
|
|
||||||
notes={othersNotes}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onSizeChange={handleSizeChange}
|
|
||||||
draggedNoteId={draggedNoteId}
|
|
||||||
onDragStartNote={startDrag}
|
|
||||||
onDragEndNote={endDrag}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DragOverlay — montre une copie flottante pendant le drag */}
|
|
||||||
<DragOverlay>
|
|
||||||
{activeNote ? (
|
|
||||||
<div className="masonry-sortable-item masonry-drag-overlay" data-size={activeNote.size}>
|
|
||||||
<NoteCard
|
|
||||||
note={activeNote}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
isDragging={true}
|
|
||||||
onSizeChange={(newSize) => handleSizeChange(activeNote.id, newSize)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
|
|
||||||
{editingNote && (
|
|
||||||
<NoteEditor
|
|
||||||
note={editingNote.note}
|
|
||||||
readOnly={editingNote.readOnly}
|
|
||||||
onClose={() => setEditingNote(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,677 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
|
|
||||||
import { Card } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from '@/components/ui/alert-dialog'
|
|
||||||
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2 } from 'lucide-react'
|
|
||||||
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
|
|
||||||
import { useSession } from 'next-auth/react'
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge } from '@/app/actions/notes'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { formatDistanceToNow, Locale } from 'date-fns'
|
|
||||||
import { enUS } from 'date-fns/locale/en-US'
|
|
||||||
import { fr } from 'date-fns/locale/fr'
|
|
||||||
import { es } from 'date-fns/locale/es'
|
|
||||||
import { de } from 'date-fns/locale/de'
|
|
||||||
import { faIR } from 'date-fns/locale/fa-IR'
|
|
||||||
import { it } from 'date-fns/locale/it'
|
|
||||||
import { pt } from 'date-fns/locale/pt'
|
|
||||||
import { ru } from 'date-fns/locale/ru'
|
|
||||||
import { zhCN } from 'date-fns/locale/zh-CN'
|
|
||||||
import { ja } from 'date-fns/locale/ja'
|
|
||||||
import { ko } from 'date-fns/locale/ko'
|
|
||||||
import { ar } from 'date-fns/locale/ar'
|
|
||||||
import { hi } from 'date-fns/locale/hi'
|
|
||||||
import { nl } from 'date-fns/locale/nl'
|
|
||||||
import { pl } from 'date-fns/locale/pl'
|
|
||||||
import { MarkdownContent } from './markdown-content'
|
|
||||||
import { LabelBadge } from './label-badge'
|
|
||||||
import { NoteImages } from './note-images'
|
|
||||||
import { NoteChecklist } from './note-checklist'
|
|
||||||
import { NoteActions } from './note-actions'
|
|
||||||
import { CollaboratorDialog } from './collaborator-dialog'
|
|
||||||
import { CollaboratorAvatars } from './collaborator-avatars'
|
|
||||||
import { ConnectionsBadge } from './connections-badge'
|
|
||||||
import { ConnectionsOverlay } from './connections-overlay'
|
|
||||||
import { ComparisonModal } from './comparison-modal'
|
|
||||||
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
|
||||||
import { useLabels } from '@/context/LabelContext'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
// Mapping of supported languages to date-fns locales
|
|
||||||
const localeMap: Record<string, Locale> = {
|
|
||||||
en: enUS,
|
|
||||||
fr: fr,
|
|
||||||
es: es,
|
|
||||||
de: de,
|
|
||||||
fa: faIR,
|
|
||||||
it: it,
|
|
||||||
pt: pt,
|
|
||||||
ru: ru,
|
|
||||||
zh: zhCN,
|
|
||||||
ja: ja,
|
|
||||||
ko: ko,
|
|
||||||
ar: ar,
|
|
||||||
hi: hi,
|
|
||||||
nl: nl,
|
|
||||||
pl: pl,
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDateLocale(language: string): Locale {
|
|
||||||
return localeMap[language] || enUS
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map icon names to lucide-react components
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
|
||||||
'folder': Folder,
|
|
||||||
'briefcase': Briefcase,
|
|
||||||
'document': FileText,
|
|
||||||
'lightning': Zap,
|
|
||||||
'chart': BarChart3,
|
|
||||||
'globe': Globe,
|
|
||||||
'sparkle': Sparkles,
|
|
||||||
'book': Book,
|
|
||||||
'heart': Heart,
|
|
||||||
'crown': Crown,
|
|
||||||
'music': Music,
|
|
||||||
'building': Building2,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get icon component by name
|
|
||||||
function getNotebookIcon(iconName: string): LucideIcon {
|
|
||||||
const IconComponent = ICON_MAP[iconName] || Folder
|
|
||||||
return IconComponent
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NoteCardProps {
|
|
||||||
note: Note
|
|
||||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
|
||||||
isDragging?: boolean
|
|
||||||
isDragOver?: boolean
|
|
||||||
onDragStart?: (noteId: string) => void
|
|
||||||
onDragEnd?: () => void
|
|
||||||
onResize?: () => void
|
|
||||||
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to get initials from name
|
|
||||||
function getInitials(name: string): string {
|
|
||||||
if (!name) return '??'
|
|
||||||
const trimmedName = name.trim()
|
|
||||||
const parts = trimmedName.split(' ')
|
|
||||||
if (parts.length === 1) {
|
|
||||||
return trimmedName.substring(0, 2).toUpperCase()
|
|
||||||
}
|
|
||||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to get avatar color based on name hash
|
|
||||||
function getAvatarColor(name: string): string {
|
|
||||||
const colors = [
|
|
||||||
'bg-primary',
|
|
||||||
'bg-purple-500',
|
|
||||||
'bg-green-500',
|
|
||||||
'bg-orange-500',
|
|
||||||
'bg-pink-500',
|
|
||||||
'bg-teal-500',
|
|
||||||
'bg-red-500',
|
|
||||||
'bg-indigo-500',
|
|
||||||
]
|
|
||||||
|
|
||||||
const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
|
||||||
return colors[hash % colors.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NoteCard = memo(function NoteCard({
|
|
||||||
note,
|
|
||||||
onEdit,
|
|
||||||
onDragStart,
|
|
||||||
onDragEnd,
|
|
||||||
isDragging,
|
|
||||||
onResize,
|
|
||||||
onSizeChange
|
|
||||||
}: NoteCardProps) {
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const { refreshLabels } = useLabels()
|
|
||||||
const { data: session } = useSession()
|
|
||||||
const { t, language } = useLanguage()
|
|
||||||
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
|
||||||
const [, startTransition] = useTransition()
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
|
||||||
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
|
|
||||||
const [collaborators, setCollaborators] = useState<any[]>([])
|
|
||||||
const [owner, setOwner] = useState<any>(null)
|
|
||||||
const [showConnectionsOverlay, setShowConnectionsOverlay] = useState(false)
|
|
||||||
const [comparisonNotes, setComparisonNotes] = useState<string[] | null>(null)
|
|
||||||
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
|
||||||
|
|
||||||
// Move note to a notebook
|
|
||||||
const handleMoveToNotebook = async (notebookId: string | null) => {
|
|
||||||
await moveNoteToNotebookOptimistic(note.id, notebookId)
|
|
||||||
setShowNotebookMenu(false)
|
|
||||||
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optimistic UI state for instant feedback
|
|
||||||
const [optimisticNote, addOptimisticNote] = useOptimistic(
|
|
||||||
note,
|
|
||||||
(state, newProps: Partial<Note>) => ({ ...state, ...newProps })
|
|
||||||
)
|
|
||||||
|
|
||||||
// Local color state so color persists after transition ends
|
|
||||||
const [localColor, setLocalColor] = useState(note.color)
|
|
||||||
|
|
||||||
const colorClasses = NOTE_COLORS[(localColor || optimisticNote.color) as NoteColor] || NOTE_COLORS.default
|
|
||||||
|
|
||||||
// Check if this note is currently open in the editor
|
|
||||||
const isNoteOpenInEditor = searchParams.get('note') === note.id
|
|
||||||
|
|
||||||
// Only fetch comparison notes when we have IDs to compare
|
|
||||||
const { notes: comparisonNotesData, isLoading: isLoadingComparison } = useConnectionsCompare(
|
|
||||||
comparisonNotes && comparisonNotes.length > 0 ? comparisonNotes : null
|
|
||||||
)
|
|
||||||
|
|
||||||
const currentUserId = session?.user?.id
|
|
||||||
const canManageCollaborators = currentUserId && note.userId && currentUserId === note.userId
|
|
||||||
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
|
|
||||||
const isOwner = currentUserId && note.userId && currentUserId === note.userId
|
|
||||||
|
|
||||||
// Load collaborators only for shared notes (not owned by current user)
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip API call for notes owned by current user — no need to fetch collaborators
|
|
||||||
if (!isSharedNote) {
|
|
||||||
// For own notes, set owner to current user
|
|
||||||
if (currentUserId && session?.user) {
|
|
||||||
setOwner({
|
|
||||||
id: currentUserId,
|
|
||||||
name: session.user.name,
|
|
||||||
email: session.user.email,
|
|
||||||
image: session.user.image,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let isMounted = true
|
|
||||||
|
|
||||||
const loadCollaborators = async () => {
|
|
||||||
if (note.userId && isMounted) {
|
|
||||||
try {
|
|
||||||
const users = await getNoteAllUsers(note.id)
|
|
||||||
if (isMounted) {
|
|
||||||
setCollaborators(users)
|
|
||||||
if (users.length > 0) {
|
|
||||||
setOwner(users[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load collaborators:', error)
|
|
||||||
if (isMounted) {
|
|
||||||
setCollaborators([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadCollaborators()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false
|
|
||||||
}
|
|
||||||
}, [note.id, note.userId, isSharedNote, currentUserId, session?.user])
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
setIsDeleting(true)
|
|
||||||
try {
|
|
||||||
await deleteNote(note.id)
|
|
||||||
await refreshLabels()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete note:', error)
|
|
||||||
setIsDeleting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTogglePin = async () => {
|
|
||||||
startTransition(async () => {
|
|
||||||
addOptimisticNote({ isPinned: !note.isPinned })
|
|
||||||
await togglePin(note.id, !note.isPinned)
|
|
||||||
|
|
||||||
if (!note.isPinned) {
|
|
||||||
toast.success(t('notes.pinned') || 'Note pinned')
|
|
||||||
} else {
|
|
||||||
toast.info(t('notes.unpinned') || 'Note unpinned')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleArchive = async () => {
|
|
||||||
startTransition(async () => {
|
|
||||||
addOptimisticNote({ isArchived: !note.isArchived })
|
|
||||||
await toggleArchive(note.id, !note.isArchived)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleColorChange = async (color: string) => {
|
|
||||||
setLocalColor(color) // instant visual update, survives transition
|
|
||||||
startTransition(async () => {
|
|
||||||
addOptimisticNote({ color })
|
|
||||||
await updateNote(note.id, { color }, { skipRevalidation: false })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSizeChange = (size: 'small' | 'medium' | 'large') => {
|
|
||||||
// Notifier le parent immédiatement (hors transition) — c'est lui
|
|
||||||
// qui détient la source de vérité via localNotes
|
|
||||||
onSizeChange?.(size)
|
|
||||||
onResize?.()
|
|
||||||
|
|
||||||
// Persister en arrière-plan
|
|
||||||
updateSize(note.id, size).catch(err =>
|
|
||||||
console.error('Failed to update note size:', err)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCheckItem = async (checkItemId: string) => {
|
|
||||||
if (note.type === 'checklist' && Array.isArray(note.checkItems)) {
|
|
||||||
const updatedItems = note.checkItems.map(item =>
|
|
||||||
item.id === checkItemId ? { ...item, checked: !item.checked } : item
|
|
||||||
)
|
|
||||||
startTransition(async () => {
|
|
||||||
addOptimisticNote({ checkItems: updatedItems })
|
|
||||||
await updateNote(note.id, { checkItems: updatedItems })
|
|
||||||
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLeaveShare = async () => {
|
|
||||||
if (confirm(t('notes.confirmLeaveShare'))) {
|
|
||||||
try {
|
|
||||||
await leaveSharedNote(note.id)
|
|
||||||
setIsDeleting(true) // Hide the note from view
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to leave share:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveFusedBadge = async (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation() // Prevent opening the note editor
|
|
||||||
startTransition(async () => {
|
|
||||||
addOptimisticNote({ autoGenerated: null })
|
|
||||||
await removeFusedBadge(note.id)
|
|
||||||
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDeleting) return null
|
|
||||||
|
|
||||||
const getMinHeight = (size?: string) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'medium': return '350px'
|
|
||||||
case 'large': return '500px'
|
|
||||||
default: return '150px' // small
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
data-testid="note-card"
|
|
||||||
data-draggable="true"
|
|
||||||
data-note-id={note.id}
|
|
||||||
data-size={optimisticNote.size}
|
|
||||||
style={{ minHeight: getMinHeight(optimisticNote.size) }}
|
|
||||||
draggable={true}
|
|
||||||
onDragStart={(e) => {
|
|
||||||
e.dataTransfer.setData('text/plain', note.id)
|
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
|
||||||
e.dataTransfer.setData('text/html', '') // Prevent ghost image in some browsers
|
|
||||||
onDragStart?.(note.id)
|
|
||||||
}}
|
|
||||||
onDragEnd={() => onDragEnd?.()}
|
|
||||||
className={cn(
|
|
||||||
'note-card group relative rounded-2xl overflow-hidden p-5 border shadow-sm',
|
|
||||||
'transition-all duration-200 ease-out',
|
|
||||||
'hover:shadow-xl hover:-translate-y-1',
|
|
||||||
colorClasses.bg,
|
|
||||||
colorClasses.card,
|
|
||||||
colorClasses.hover,
|
|
||||||
colorClasses.hover,
|
|
||||||
isDragging && 'shadow-2xl' // Removed opacity, scale, and rotation for clean drag
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
// Only trigger edit if not clicking on buttons
|
|
||||||
const target = e.target as HTMLElement
|
|
||||||
if (!target.closest('button') && !target.closest('[role="checkbox"]') && !target.closest('.muuri-drag-handle') && !target.closest('.drag-handle')) {
|
|
||||||
// For shared notes, pass readOnly flag
|
|
||||||
onEdit?.(note, !!isSharedNote) // Pass second parameter as readOnly flag (convert to boolean)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Drag Handle - Only visible on mobile/touch devices */}
|
|
||||||
<div
|
|
||||||
className="muuri-drag-handle absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
|
|
||||||
aria-label={t('notes.dragToReorder') || 'Drag to reorder'}
|
|
||||||
title={t('notes.dragToReorder') || 'Drag to reorder'}
|
|
||||||
>
|
|
||||||
<GripVertical className="h-5 w-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Move to Notebook Dropdown Menu */}
|
|
||||||
<div onClick={(e) => e.stopPropagation()} className="absolute top-2 right-2 z-20">
|
|
||||||
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0 bg-primary/10 dark:bg-primary/20 hover:bg-primary/20 dark:hover:bg-primary/30 text-primary dark:text-primary-foreground"
|
|
||||||
title={t('notebookSuggestion.moveToNotebook')}
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
|
||||||
{t('notebookSuggestion.moveToNotebook')}
|
|
||||||
</div>
|
|
||||||
<DropdownMenuItem onClick={() => handleMoveToNotebook(null)}>
|
|
||||||
<StickyNote className="h-4 w-4 mr-2" />
|
|
||||||
{t('notebookSuggestion.generalNotes')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{notebooks.map((notebook: any) => {
|
|
||||||
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={notebook.id}
|
|
||||||
onClick={() => handleMoveToNotebook(notebook.id)}
|
|
||||||
>
|
|
||||||
<NotebookIcon className="h-4 w-4 mr-2" />
|
|
||||||
{notebook.name}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pin Button - Visible on hover or if pinned */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
data-testid="pin-button"
|
|
||||||
className={cn(
|
|
||||||
"absolute top-2 right-12 z-20 min-h-[44px] min-w-[44px] h-8 w-8 p-0 rounded-md transition-opacity",
|
|
||||||
optimisticNote.isPinned ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleTogglePin()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pin
|
|
||||||
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Reminder Icon - Move slightly if pin button is there */}
|
|
||||||
{note.reminder && new Date(note.reminder) > new Date() && (
|
|
||||||
<Bell
|
|
||||||
className="absolute top-3 right-10 h-4 w-4 text-primary"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Memory Echo Badges - Fusion + Connections (BEFORE Title) */}
|
|
||||||
<div className="flex flex-wrap gap-1 mb-2">
|
|
||||||
{/* Fusion Badge with remove button */}
|
|
||||||
{note.autoGenerated && (
|
|
||||||
<div className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 flex items-center gap-1 group/badge relative">
|
|
||||||
<Link2 className="h-2.5 w-2.5" />
|
|
||||||
{t('memoryEcho.fused')}
|
|
||||||
<button
|
|
||||||
onClick={handleRemoveFusedBadge}
|
|
||||||
className="ml-1 opacity-0 group-hover/badge:opacity-100 hover:opacity-100 transition-opacity"
|
|
||||||
title={t('notes.remove') || 'Remove'}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-2.5 w-2.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Connections Badge */}
|
|
||||||
<ConnectionsBadge
|
|
||||||
noteId={note.id}
|
|
||||||
onClick={() => {
|
|
||||||
// Only open overlay if note is NOT open in editor
|
|
||||||
// (to avoid having 2 Dialogs with 2 close buttons)
|
|
||||||
if (!isNoteOpenInEditor) {
|
|
||||||
setShowConnectionsOverlay(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
{optimisticNote.title && (
|
|
||||||
<h3 className="text-base font-medium mb-2 pr-10 text-foreground">
|
|
||||||
{optimisticNote.title}
|
|
||||||
</h3>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Search Match Type Badge */}
|
|
||||||
{optimisticNote.matchType && (
|
|
||||||
<Badge
|
|
||||||
variant={optimisticNote.matchType === 'exact' ? 'default' : 'secondary'}
|
|
||||||
className={cn(
|
|
||||||
'mb-2 text-xs',
|
|
||||||
optimisticNote.matchType === 'exact'
|
|
||||||
? 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800'
|
|
||||||
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:text-primary-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Shared badge */}
|
|
||||||
{isSharedNote && owner && (
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-xs text-primary dark:text-primary-foreground font-medium">
|
|
||||||
{t('notes.sharedBy')} {owner.name || owner.email}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-xs text-gray-500 hover:text-red-600 dark:hover:text-red-400"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleLeaveShare()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LogOut className="h-3 w-3 mr-1" />
|
|
||||||
{t('notes.leaveShare')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Images Component */}
|
|
||||||
<NoteImages images={optimisticNote.images || []} title={optimisticNote.title} />
|
|
||||||
|
|
||||||
{/* Link Previews */}
|
|
||||||
{Array.isArray(optimisticNote.links) && optimisticNote.links.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-2 mb-2">
|
|
||||||
{optimisticNote.links.map((link, idx) => (
|
|
||||||
<a
|
|
||||||
key={idx}
|
|
||||||
href={link.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="block border rounded-md overflow-hidden bg-white/50 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/40 transition-colors"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{link.imageUrl && (
|
|
||||||
<div className="h-24 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
|
||||||
)}
|
|
||||||
<div className="p-2">
|
|
||||||
<h4 className="font-medium text-xs truncate text-gray-900 dark:text-gray-100">{link.title || link.url}</h4>
|
|
||||||
{link.description && <p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">{link.description}</p>}
|
|
||||||
<span className="text-[10px] text-primary mt-1 block">
|
|
||||||
{new URL(link.url).hostname}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{optimisticNote.type === 'text' ? (
|
|
||||||
<div className="text-sm text-foreground line-clamp-10">
|
|
||||||
<MarkdownContent content={optimisticNote.content} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<NoteChecklist
|
|
||||||
items={optimisticNote.checkItems || []}
|
|
||||||
onToggleItem={handleCheckItem}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Labels - using shared LabelBadge component */}
|
|
||||||
{optimisticNote.notebookId && Array.isArray(optimisticNote.labels) && optimisticNote.labels.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1 mt-3">
|
|
||||||
{optimisticNote.labels.map((label) => (
|
|
||||||
<LabelBadge key={label} label={label} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer with Date only */}
|
|
||||||
<div className="mt-3 flex items-center justify-end">
|
|
||||||
{/* Creation Date */}
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Owner Avatar - Aligned with action buttons at bottom */}
|
|
||||||
{owner && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute bottom-2 left-2 z-20",
|
|
||||||
"w-6 h-6 rounded-full text-white text-[10px] font-semibold flex items-center justify-center",
|
|
||||||
getAvatarColor(owner.name || owner.email || 'Unknown')
|
|
||||||
)}
|
|
||||||
title={owner.name || owner.email || 'Unknown'}
|
|
||||||
>
|
|
||||||
{getInitials(owner.name || owner.email || '??')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Bar Component - Always show for now to fix regression */}
|
|
||||||
{true && (
|
|
||||||
<NoteActions
|
|
||||||
isPinned={optimisticNote.isPinned}
|
|
||||||
isArchived={optimisticNote.isArchived}
|
|
||||||
currentColor={optimisticNote.color}
|
|
||||||
currentSize={optimisticNote.size as 'small' | 'medium' | 'large'}
|
|
||||||
onTogglePin={handleTogglePin}
|
|
||||||
onToggleArchive={handleToggleArchive}
|
|
||||||
onColorChange={handleColorChange}
|
|
||||||
onSizeChange={handleSizeChange}
|
|
||||||
onDelete={() => setShowDeleteDialog(true)}
|
|
||||||
onShareCollaborators={() => setShowCollaboratorDialog(true)}
|
|
||||||
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Collaborator Dialog */}
|
|
||||||
{currentUserId && note.userId && (
|
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
|
||||||
<CollaboratorDialog
|
|
||||||
open={showCollaboratorDialog}
|
|
||||||
onOpenChange={setShowCollaboratorDialog}
|
|
||||||
noteId={note.id}
|
|
||||||
noteOwnerId={note.userId}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Connections Overlay */}
|
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
|
||||||
<ConnectionsOverlay
|
|
||||||
isOpen={showConnectionsOverlay}
|
|
||||||
onClose={() => setShowConnectionsOverlay(false)}
|
|
||||||
noteId={note.id}
|
|
||||||
onOpenNote={(noteId) => {
|
|
||||||
// Find the note and open it
|
|
||||||
onEdit?.(note, false)
|
|
||||||
}}
|
|
||||||
onCompareNotes={(noteIds) => {
|
|
||||||
setComparisonNotes(noteIds)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comparison Modal */}
|
|
||||||
{comparisonNotes && comparisonNotesData.length > 0 && (
|
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
|
||||||
<ComparisonModal
|
|
||||||
isOpen={!!comparisonNotes}
|
|
||||||
onClose={() => setComparisonNotes(null)}
|
|
||||||
notes={comparisonNotesData}
|
|
||||||
onOpenNote={(noteId) => {
|
|
||||||
const foundNote = comparisonNotesData.find(n => n.id === noteId)
|
|
||||||
if (foundNote) {
|
|
||||||
onEdit?.(foundNote, false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>{t('common.cancel') || 'Cancel'}</AlertDialogCancel>
|
|
||||||
<AlertDialogAction variant="destructive" onClick={handleDelete}>
|
|
||||||
{t('notes.delete') || 'Delete'}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
import { Note } from '@/lib/types'
|
|
||||||
import { NotesTabsView } from '@/components/notes-tabs-view'
|
|
||||||
|
|
||||||
const MasonryGridLazy = dynamic(
|
|
||||||
() => import('@/components/masonry-grid').then((m) => m.MasonryGrid),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div
|
|
||||||
className="min-h-[200px] rounded-xl border border-dashed border-muted-foreground/20 bg-muted/30 animate-pulse"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export type NotesViewMode = 'masonry' | 'tabs'
|
|
||||||
|
|
||||||
interface NotesMainSectionProps {
|
|
||||||
notes: Note[]
|
|
||||||
viewMode: NotesViewMode
|
|
||||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
|
||||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
|
|
||||||
currentNotebookId?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotesMainSection({ notes, viewMode, onEdit, onSizeChange, currentNotebookId }: NotesMainSectionProps) {
|
|
||||||
if (viewMode === 'tabs') {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">
|
|
||||||
<NotesTabsView notes={notes} onEdit={onEdit} currentNotebookId={currentNotebookId} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-testid="notes-grid">
|
|
||||||
<MasonryGridLazy notes={notes} onEdit={onEdit} onSizeChange={onSizeChange} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1375,28 +1375,35 @@ export async function syncAllEmbeddings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all notes including those shared with the user
|
// Get all notes including those shared with the user
|
||||||
export async function getAllNotes(includeArchived = false) {
|
export async function getAllNotes(includeArchived = false, notebookId?: string) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) return [];
|
if (!session?.user?.id) return [];
|
||||||
|
|
||||||
const userId = session.user.id;
|
const userId = session.user.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch own notes + shared notes in parallel — no embedding to keep transfer fast
|
const whereClause: any = {
|
||||||
const [ownNotes, acceptedShares] = await Promise.all([
|
userId,
|
||||||
prisma.note.findMany({
|
trashedAt: null,
|
||||||
where: {
|
...(includeArchived ? {} : { isArchived: false }),
|
||||||
userId,
|
...(notebookId !== undefined ? { notebookId: notebookId || null } : {}),
|
||||||
trashedAt: null,
|
}
|
||||||
...(includeArchived ? {} : { isArchived: false }),
|
|
||||||
},
|
const ownNotes = await prisma.note.findMany({
|
||||||
select: NOTE_LIST_SELECT,
|
where: whereClause,
|
||||||
orderBy: [
|
select: NOTE_LIST_SELECT,
|
||||||
{ isPinned: 'desc' },
|
orderBy: [
|
||||||
{ order: 'asc' },
|
{ isPinned: 'desc' },
|
||||||
{ updatedAt: 'desc' }
|
{ order: 'asc' },
|
||||||
]
|
{ updatedAt: 'desc' }
|
||||||
}),
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (notebookId) {
|
||||||
|
return ownNotes.map(parseNote)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [acceptedShares] = await Promise.all([
|
||||||
prisma.noteShare.findMany({
|
prisma.noteShare.findMany({
|
||||||
where: { userId, status: 'accepted' },
|
where: { userId, status: 'accepted' },
|
||||||
include: { note: { select: NOTE_LIST_SELECT } }
|
include: { note: { select: NOTE_LIST_SELECT } }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { auth } from '@/auth';
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET() {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
|
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ export const dynamic = 'force-dynamic'
|
|||||||
* Authorization: Bearer <CRON_SECRET>
|
* Authorization: Bearer <CRON_SECRET>
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
// Optional auth
|
|
||||||
const cronSecret = process.env.CRON_SECRET
|
const cronSecret = process.env.CRON_SECRET
|
||||||
if (cronSecret) {
|
if (!cronSecret) {
|
||||||
const authHeader = request.headers.get('authorization')
|
console.error('[CronAgents] CRON_SECRET env var is required but not set')
|
||||||
if (authHeader !== `Bearer ${cronSecret}`) {
|
return NextResponse.json({ error: 'Server misconfiguration' }, { status: 500 })
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
}
|
||||||
}
|
const authHeader = request.headers.get('authorization')
|
||||||
|
if (authHeader !== `Bearer ${cronSecret}`) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import prisma from '@/lib/prisma';
|
|||||||
export const dynamic = 'force-dynamic'; // No caching
|
export const dynamic = 'force-dynamic'; // No caching
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
// Optional auth: set CRON_SECRET env var, callers must pass
|
|
||||||
// Authorization: Bearer <CRON_SECRET>
|
|
||||||
const cronSecret = process.env.CRON_SECRET
|
const cronSecret = process.env.CRON_SECRET
|
||||||
if (cronSecret) {
|
if (!cronSecret) {
|
||||||
const authHeader = request.headers.get('authorization')
|
console.error('[CronReminders] CRON_SECRET env var is required but not set')
|
||||||
if (authHeader !== `Bearer ${cronSecret}`) {
|
return NextResponse.json({ error: 'Server misconfiguration' }, { status: 500 })
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
}
|
||||||
}
|
const authHeader = request.headers.get('authorization')
|
||||||
|
if (authHeader !== `Bearer ${cronSecret}`) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
// Visible directement dans `docker logs memento-web`
|
|
||||||
console.error('[CLIENT-ERROR-REPORT]', JSON.stringify(body, null, 2))
|
console.error('[CLIENT-ERROR-REPORT]', JSON.stringify(body, null, 2))
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore malformed payload
|
||||||
}
|
}
|
||||||
return NextResponse.json({ ok: true })
|
return NextResponse.json({ ok: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getSystemConfig } from '@/lib/config';
|
|
||||||
import { auth } from '@/auth';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Debug endpoint to check AI configuration
|
|
||||||
* This helps verify that OpenAI is properly configured
|
|
||||||
*/
|
|
||||||
export async function GET() {
|
|
||||||
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();
|
|
||||||
|
|
||||||
// Return only AI-related config for debugging
|
|
||||||
const aiConfig = {
|
|
||||||
AI_PROVIDER_TAGS: config.AI_PROVIDER_TAGS || 'not set',
|
|
||||||
AI_PROVIDER_EMBEDDING: config.AI_PROVIDER_EMBEDDING || 'not set',
|
|
||||||
AI_MODEL_TAGS: config.AI_MODEL_TAGS || 'not set',
|
|
||||||
AI_MODEL_EMBEDDING: config.AI_MODEL_EMBEDDING || 'not set',
|
|
||||||
OPENAI_API_KEY: config.OPENAI_API_KEY ? 'set (hidden)' : 'not set',
|
|
||||||
OLLAMA_BASE_URL: config.OLLAMA_BASE_URL || 'not set',
|
|
||||||
OLLAMA_MODEL: config.OLLAMA_MODEL || 'not set',
|
|
||||||
CUSTOM_OPENAI_BASE_URL: config.CUSTOM_OPENAI_BASE_URL || 'not set',
|
|
||||||
CUSTOM_OPENAI_API_KEY: config.CUSTOM_OPENAI_API_KEY ? 'set (hidden)' : 'not set',
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(aiConfig);
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to get config', details: error },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { chatService } from '@/lib/ai/services/chat.service';
|
|
||||||
import { auth } from '@/auth';
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
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 body = await req.json();
|
|
||||||
console.log("TEST ROUTE INCOMING BODY:", body);
|
|
||||||
|
|
||||||
// Simulate what the server action does
|
|
||||||
const result = await chatService.chat(body.message, body.conversationId, body.notebookId);
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, result });
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("====== TEST ROUTE CHAT ERROR ======");
|
|
||||||
console.error("NAME:", err.name);
|
|
||||||
console.error("MSG:", err.message);
|
|
||||||
if (err.cause) console.error("CAUSE:", JSON.stringify(err.cause, null, 2));
|
|
||||||
if (err.data) console.error("DATA:", JSON.stringify(err.data, null, 2));
|
|
||||||
if (err.stack) console.error("STACK:", err.stack);
|
|
||||||
console.error("===================================");
|
|
||||||
return NextResponse.json({
|
|
||||||
success: false,
|
|
||||||
error: err.message,
|
|
||||||
name: err.name,
|
|
||||||
cause: err.cause,
|
|
||||||
data: err.data
|
|
||||||
}, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import prisma from '@/lib/prisma'
|
|
||||||
import { revalidatePath } from 'next/cache'
|
|
||||||
import { auth } from '@/auth'
|
|
||||||
|
|
||||||
function getHashColor(name: string): string {
|
|
||||||
const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray']
|
|
||||||
let hash = 0
|
|
||||||
for (let i = 0; i < name.length; i++) {
|
|
||||||
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
|
||||||
}
|
|
||||||
return colors[Math.abs(hash) % colors.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
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 result = { created: 0, deleted: 0, missing: [] as string[] }
|
|
||||||
|
|
||||||
// Get ALL users
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
select: { id: true, email: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
for (const user of users) {
|
|
||||||
const userId = user.id
|
|
||||||
|
|
||||||
// 1. Get all labels from notes
|
|
||||||
const allNotes = await prisma.note.findMany({
|
|
||||||
where: { userId },
|
|
||||||
select: { labels: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
const labelsInNotes = new Set<string>()
|
|
||||||
allNotes.forEach(note => {
|
|
||||||
if (note.labels) {
|
|
||||||
try {
|
|
||||||
const parsed: string[] = Array.isArray(note.labels) ? (note.labels as string[]) : []
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
parsed.forEach(l => {
|
|
||||||
if (l && l.trim()) labelsInNotes.add(l.trim())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
// 2. Get existing Label records
|
|
||||||
const existingLabels = await prisma.label.findMany({
|
|
||||||
where: { userId },
|
|
||||||
select: { id: true, name: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
const existingLabelMap = new Map<string, any>()
|
|
||||||
existingLabels.forEach(label => {
|
|
||||||
existingLabelMap.set(label.name.toLowerCase(), label)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. Create missing Label records
|
|
||||||
for (const labelName of labelsInNotes) {
|
|
||||||
if (!existingLabelMap.has(labelName.toLowerCase())) {
|
|
||||||
try {
|
|
||||||
await prisma.label.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
name: labelName,
|
|
||||||
color: getHashColor(labelName)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
result.created++
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error(`[FIX] ✗ Failed to create "${labelName}":`, e.message, e.code)
|
|
||||||
result.missing.push(labelName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Delete orphan Label records
|
|
||||||
const usedLabelsSet = new Set<string>()
|
|
||||||
allNotes.forEach(note => {
|
|
||||||
if (note.labels) {
|
|
||||||
try {
|
|
||||||
const parsed: string[] = Array.isArray(note.labels) ? (note.labels as string[]) : []
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
parsed.forEach(l => usedLabelsSet.add(l.toLowerCase()))
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const label of existingLabels) {
|
|
||||||
if (!usedLabelsSet.has(label.name.toLowerCase())) {
|
|
||||||
try {
|
|
||||||
await prisma.label.delete({
|
|
||||||
where: { id: label.id }
|
|
||||||
})
|
|
||||||
result.deleted++
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath('/')
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
...result,
|
|
||||||
message: `Created ${result.created} labels, deleted ${result.deleted} orphans`
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[FIX] Error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: String(error) },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import Credentials from 'next-auth/providers/credentials';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
export const { auth, signIn, signOut, handlers } = NextAuth({
|
export const { auth, signIn, signOut, handlers } = NextAuth({
|
||||||
...authConfig,
|
...authConfig,
|
||||||
@@ -16,17 +17,21 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
|
|||||||
.safeParse(credentials);
|
.safeParse(credentials);
|
||||||
|
|
||||||
if (!parsedCredentials.success) {
|
if (!parsedCredentials.success) {
|
||||||
console.error('Invalid credentials format');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, password } = parsedCredentials.data;
|
const { email, password } = parsedCredentials.data;
|
||||||
|
|
||||||
|
const { allowed } = rateLimit(`login:${email.toLowerCase()}`)
|
||||||
|
if (!allowed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { email: email.toLowerCase() }
|
where: { email: email.toLowerCase() }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.password) {
|
if (!user || !user.password) {
|
||||||
console.error('User not found or no password set');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,10 +46,8 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('Password mismatch');
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('CRITICAL AUTH ERROR:', error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
export function DebugTheme({ theme }: { theme: string }) {
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-4 left-4 z-50 bg-black text-white p-2 rounded text-xs opacity-80 pointer-events-none">
|
|
||||||
Debug Theme: {theme}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,9 @@ export function ErrorReporter() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
}).catch(() => {})
|
}).catch(() => {
|
||||||
|
console.error('[ErrorReporter] Failed to report client error (endpoint removed)')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function onError(event: ErrorEvent) {
|
function onError(event: ErrorEvent) {
|
||||||
|
|||||||
@@ -237,13 +237,9 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
}
|
}
|
||||||
let allNotes = search
|
let allNotes = search
|
||||||
? await searchNotes(search, semanticMode, notebook || undefined)
|
? await searchNotes(search, semanticMode, notebook || undefined)
|
||||||
: await getAllNotes()
|
: await getAllNotes(false, notebook || undefined)
|
||||||
|
|
||||||
// Filtre notebook côté client
|
if (!notebook && !search) {
|
||||||
// Shared notes appear ONLY in inbox (general notes), not in notebooks
|
|
||||||
if (notebook) {
|
|
||||||
allNotes = allNotes.filter((note: any) => note.notebookId === notebook && !note._isShared)
|
|
||||||
} else {
|
|
||||||
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
|
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,16 @@ export function MasonryGrid({
|
|||||||
|
|
||||||
if (notes !== prevNotesRef.current) {
|
if (notes !== prevNotesRef.current) {
|
||||||
const localSizeMap = new Map(localNotes.map(n => [n.id, n.size]));
|
const localSizeMap = new Map(localNotes.map(n => [n.id, n.size]));
|
||||||
|
const localOrderMap = new Map(localNotes.map((n, i) => [n.id, i]));
|
||||||
const newLocalNotes = notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }));
|
const newLocalNotes = notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }));
|
||||||
|
newLocalNotes.sort((a, b) => {
|
||||||
|
const oA = localOrderMap.get(a.id)
|
||||||
|
const oB = localOrderMap.get(b.id)
|
||||||
|
if (oA !== undefined && oB !== undefined) return oA - oB
|
||||||
|
if (oA !== undefined) return -1
|
||||||
|
if (oB !== undefined) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
setLocalNotes(newLocalNotes);
|
setLocalNotes(newLocalNotes);
|
||||||
prevNotesRef.current = notes;
|
prevNotesRef.current = notes;
|
||||||
}
|
}
|
||||||
@@ -239,16 +248,20 @@ export function MasonryGrid({
|
|||||||
|
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
const reordered = arrayMove(
|
const current = localNotesRef.current
|
||||||
localNotesRef.current,
|
const activeIdx = current.findIndex(n => n.id === active.id)
|
||||||
localNotesRef.current.findIndex(n => n.id === active.id),
|
const overIdx = current.findIndex(n => n.id === over.id)
|
||||||
localNotesRef.current.findIndex(n => n.id === over.id),
|
if (activeIdx === -1 || overIdx === -1) return
|
||||||
);
|
|
||||||
|
|
||||||
|
const activeNote = current[activeIdx]
|
||||||
|
const overNote = current[overIdx]
|
||||||
|
|
||||||
|
if (activeNote.isPinned !== overNote.isPinned) return
|
||||||
|
|
||||||
|
const reordered = arrayMove(current, activeIdx, overIdx);
|
||||||
if (reordered.length === 0) return;
|
if (reordered.length === 0) return;
|
||||||
|
|
||||||
setLocalNotes(reordered);
|
setLocalNotes(reordered);
|
||||||
// Persist order outside of setState to avoid "setState in render" warning
|
|
||||||
const ids = reordered.map(n => n.id);
|
const ids = reordered.map(n => n.id);
|
||||||
updateFullOrderWithoutRevalidation(ids).catch(err => {
|
updateFullOrderWithoutRevalidation(ids).catch(err => {
|
||||||
console.error('Failed to persist order:', err);
|
console.error('Failed to persist order:', err);
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2, AlignLeft, FileCode2, PenLine, ListChecks } from 'lucide-react'
|
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2, AlignLeft, FileCode2, PenLine, ListChecks } from 'lucide-react'
|
||||||
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
|
import { useState, useEffect, useTransition, useOptimistic, memo, useMemo } from 'react'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, createNote, restoreNote, permanentDeleteNote } from '@/app/actions/notes'
|
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, createNote, restoreNote, permanentDeleteNote } from '@/app/actions/notes'
|
||||||
@@ -42,17 +43,20 @@ import { ar } from 'date-fns/locale/ar'
|
|||||||
import { hi } from 'date-fns/locale/hi'
|
import { hi } from 'date-fns/locale/hi'
|
||||||
import { nl } from 'date-fns/locale/nl'
|
import { nl } from 'date-fns/locale/nl'
|
||||||
import { pl } from 'date-fns/locale/pl'
|
import { pl } from 'date-fns/locale/pl'
|
||||||
import { MarkdownContent } from './markdown-content'
|
|
||||||
import { LabelBadge } from './label-badge'
|
import { LabelBadge } from './label-badge'
|
||||||
import { NoteImages } from './note-images'
|
import { NoteImages } from './note-images'
|
||||||
import { NoteChecklist } from './note-checklist'
|
import { NoteChecklist } from './note-checklist'
|
||||||
import { NoteActions } from './note-actions'
|
import { NoteActions } from './note-actions'
|
||||||
import { CollaboratorDialog } from './collaborator-dialog'
|
|
||||||
import { CollaboratorAvatars } from './collaborator-avatars'
|
import { CollaboratorAvatars } from './collaborator-avatars'
|
||||||
import { ConnectionsBadge } from './connections-badge'
|
import { ConnectionsBadge } from './connections-badge'
|
||||||
import { ConnectionsOverlay } from './connections-overlay'
|
|
||||||
import { ComparisonModal } from './comparison-modal'
|
const MarkdownContent = dynamic(() => import('./markdown-content').then(m => ({ default: m.MarkdownContent })), {
|
||||||
import { FusionModal } from './fusion-modal'
|
loading: () => <div className="text-sm text-muted-foreground animate-pulse">…</div>,
|
||||||
|
})
|
||||||
|
const CollaboratorDialog = dynamic(() => import('./collaborator-dialog').then(m => ({ default: m.CollaboratorDialog })), { ssr: false })
|
||||||
|
const ConnectionsOverlay = dynamic(() => import('./connections-overlay').then(m => ({ default: m.ConnectionsOverlay })), { ssr: false })
|
||||||
|
const ComparisonModal = dynamic(() => import('./comparison-modal').then(m => ({ default: m.ComparisonModal })), { ssr: false })
|
||||||
|
const FusionModal = dynamic(() => import('./fusion-modal').then(m => ({ default: m.FusionModal })), { ssr: false })
|
||||||
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useLabels } from '@/context/LabelContext'
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
@@ -186,6 +190,14 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
||||||
const [reminderDate, setReminderDate] = useState<Date | null>(note.reminder ? new Date(note.reminder) : null)
|
const [reminderDate, setReminderDate] = useState<Date | null>(note.reminder ? new Date(note.reminder) : null)
|
||||||
|
|
||||||
|
const sanitizedHtml = useMemo(() => {
|
||||||
|
if (note.type !== 'richtext' || !note.content) return ''
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return require('isomorphic-dompurify').sanitize(note.content)
|
||||||
|
}
|
||||||
|
return note.content
|
||||||
|
}, [note.type, note.content])
|
||||||
|
|
||||||
const handleUpdateReminder = async (noteId: string, reminder: Date | null) => {
|
const handleUpdateReminder = async (noteId: string, reminder: Date | null) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -257,6 +269,8 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isSharedNote) return
|
||||||
|
|
||||||
let isMounted = true
|
let isMounted = true
|
||||||
|
|
||||||
const loadCollaborators = async () => {
|
const loadCollaborators = async () => {
|
||||||
@@ -270,7 +284,6 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load collaborators:', error)
|
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setCollaborators([])
|
setCollaborators([])
|
||||||
}
|
}
|
||||||
@@ -633,7 +646,7 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
onToggleItem={handleCheckItem}
|
onToggleItem={handleCheckItem}
|
||||||
/>
|
/>
|
||||||
) : note.type === 'richtext' ? (
|
) : note.type === 'richtext' ? (
|
||||||
<div className="text-sm text-foreground line-clamp-10 rt-preview" dangerouslySetInnerHTML={{ __html: note.content || '' }} />
|
<div className="text-sm text-foreground line-clamp-10 rt-preview" dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-foreground line-clamp-10">
|
<div className="text-sm text-foreground line-clamp-10">
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function VersionPreview({ entry, language }: { entry: NoteHistoryEntry; language
|
|||||||
<MarkdownContent content={entry.content || ''} />
|
<MarkdownContent content={entry.content || ''} />
|
||||||
</div>
|
</div>
|
||||||
) : isRt ? (
|
) : isRt ? (
|
||||||
<div dangerouslySetInnerHTML={{ __html: entry.content || '' }} />
|
<div dangerouslySetInnerHTML={{ __html: require('isomorphic-dompurify').sanitize(entry.content || '') }} />
|
||||||
) : (
|
) : (
|
||||||
<pre className="whitespace-pre-wrap font-sans">{entry.content || ''}</pre>
|
<pre className="whitespace-pre-wrap font-sans">{entry.content || ''}</pre>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
|
|||||||
<img
|
<img
|
||||||
src={images[0]}
|
src={images[0]}
|
||||||
alt=""
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
className="w-full h-auto rounded-lg"
|
className="w-full h-auto rounded-lg"
|
||||||
/>
|
/>
|
||||||
) : images.length === 2 ? (
|
) : images.length === 2 ? (
|
||||||
@@ -27,6 +28,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
|
|||||||
key={idx}
|
key={idx}
|
||||||
src={img}
|
src={img}
|
||||||
alt=""
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
className="w-full h-auto rounded-lg"
|
className="w-full h-auto rounded-lg"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -36,6 +38,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
|
|||||||
<img
|
<img
|
||||||
src={images[0]}
|
src={images[0]}
|
||||||
alt=""
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
className="col-span-2 w-full h-auto rounded-lg"
|
className="col-span-2 w-full h-auto rounded-lg"
|
||||||
/>
|
/>
|
||||||
{images.slice(1).map((img, idx) => (
|
{images.slice(1).map((img, idx) => (
|
||||||
@@ -43,6 +46,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
|
|||||||
key={idx}
|
key={idx}
|
||||||
src={img}
|
src={img}
|
||||||
alt=""
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
className="w-full h-auto rounded-lg"
|
className="w-full h-auto rounded-lg"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -54,6 +58,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
|
|||||||
key={idx}
|
key={idx}
|
||||||
src={img}
|
src={img}
|
||||||
alt=""
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
className="w-full h-auto rounded-lg"
|
className="w-full h-auto rounded-lg"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useRef } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -18,6 +18,36 @@ import { useLabels } from '@/context/LabelContext'
|
|||||||
import { LabelManagementDialog } from '@/components/label-management-dialog'
|
import { LabelManagementDialog } from '@/components/label-management-dialog'
|
||||||
import { Notebook } from '@/lib/types'
|
import { Notebook } from '@/lib/types'
|
||||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
function NotebookNameTooltip({ name, children }: { name: string; children: React.ReactNode }) {
|
||||||
|
const spanRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const [isTruncated, setIsTruncated] = useState(false)
|
||||||
|
|
||||||
|
const checkTruncation = useCallback((node: HTMLSpanElement | null) => {
|
||||||
|
if (!node) return
|
||||||
|
setIsTruncated(node.scrollWidth > node.clientWidth)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip open={isTruncated ? undefined : false}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
ref={(el) => { spanRef.current = el; checkTruncation(el) }}
|
||||||
|
onMouseEnter={() => checkTruncation(spanRef.current)}
|
||||||
|
className="truncate min-w-0"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{isTruncated && (
|
||||||
|
<TooltipContent side="right" className="max-w-[240px] break-words text-sm">
|
||||||
|
{name}
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function NotebooksList() {
|
export function NotebooksList() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@@ -152,12 +182,14 @@ export function NotebooksList() {
|
|||||||
className={cn("w-5 h-5 flex-shrink-0 fill-current", !notebook.color && "text-primary dark:text-primary-foreground")}
|
className={cn("w-5 h-5 flex-shrink-0 fill-current", !notebook.color && "text-primary dark:text-primary-foreground")}
|
||||||
style={notebook.color ? { color: notebook.color } : undefined}
|
style={notebook.color ? { color: notebook.color } : undefined}
|
||||||
/>
|
/>
|
||||||
<span
|
<NotebookNameTooltip name={notebook.name}>
|
||||||
className={cn("text-[15px] font-medium tracking-wide truncate min-w-0", !notebook.color && "text-primary dark:text-primary-foreground")}
|
<span
|
||||||
style={notebook.color ? { color: notebook.color } : undefined}
|
className={cn("text-[15px] font-medium tracking-wide", !notebook.color && "text-primary dark:text-primary-foreground")}
|
||||||
>
|
style={notebook.color ? { color: notebook.color } : undefined}
|
||||||
{notebook.name}
|
>
|
||||||
</span>
|
{notebook.name}
|
||||||
|
</span>
|
||||||
|
</NotebookNameTooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{/* Actions menu for active notebook */}
|
{/* Actions menu for active notebook */}
|
||||||
@@ -241,7 +273,11 @@ export function NotebooksList() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
|
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
|
||||||
<span className="text-[15px] font-medium tracking-wide truncate min-w-0 text-start">{notebook.name}</span>
|
<NotebookNameTooltip name={notebook.name}>
|
||||||
|
<span className="text-[15px] font-medium tracking-wide text-start">
|
||||||
|
{notebook.name}
|
||||||
|
</span>
|
||||||
|
</NotebookNameTooltip>
|
||||||
{(notebook as any).notesCount > 0 && (
|
{(notebook as any).notesCount > 0 && (
|
||||||
<span className="text-xs text-gray-400 ms-2 flex-shrink-0">({new Intl.NumberFormat(language).format((notebook as any).notesCount)})</span>
|
<span className="text-xs text-gray-400 ms-2 flex-shrink-0">({new Intl.NumberFormat(language).format((notebook as any).notesCount)})</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Loader2, Check } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
interface SettingInputProps {
|
|
||||||
label: string
|
|
||||||
description?: string
|
|
||||||
value: string
|
|
||||||
type?: 'text' | 'password' | 'email' | 'url'
|
|
||||||
onChange: (value: string) => Promise<void>
|
|
||||||
placeholder?: string
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingInput({
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
value,
|
|
||||||
type = 'text',
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
disabled
|
|
||||||
}: SettingInputProps) {
|
|
||||||
const { t } = useLanguage()
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [isSaved, setIsSaved] = useState(false)
|
|
||||||
|
|
||||||
const handleChange = async (newValue: string) => {
|
|
||||||
setIsLoading(true)
|
|
||||||
setIsSaved(false)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onChange(newValue)
|
|
||||||
setIsSaved(true)
|
|
||||||
toast.success(t('toast.saved'))
|
|
||||||
|
|
||||||
setTimeout(() => setIsSaved(false), 2000)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error updating setting:', err)
|
|
||||||
toast.error(t('toast.saveFailed'))
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('py-4', 'border-b last:border-0 dark:border-gray-800')}>
|
|
||||||
<Label className="font-medium text-gray-900 dark:text-gray-100 block mb-1">
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
{description && (
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled || isLoading}
|
|
||||||
className={cn(
|
|
||||||
'w-full px-3 py-2 border rounded-lg',
|
|
||||||
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
||||||
'bg-white dark:bg-gray-900',
|
|
||||||
'border-gray-300 dark:border-gray-700',
|
|
||||||
'text-gray-900 dark:text-gray-100',
|
|
||||||
'placeholder:text-gray-400 dark:placeholder:text-gray-600'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{isLoading && (
|
|
||||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-500" />
|
|
||||||
)}
|
|
||||||
{isSaved && !isLoading && (
|
|
||||||
<Check className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-green-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Loader2 } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
interface SelectOption {
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
description?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingSelectProps {
|
|
||||||
label: string
|
|
||||||
description?: string
|
|
||||||
value: string
|
|
||||||
options: SelectOption[]
|
|
||||||
onChange: (value: string) => Promise<void>
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingSelect({
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
value,
|
|
||||||
options,
|
|
||||||
onChange,
|
|
||||||
disabled
|
|
||||||
}: SettingSelectProps) {
|
|
||||||
const { t } = useLanguage()
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleChange = async (newValue: string) => {
|
|
||||||
setIsLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onChange(newValue)
|
|
||||||
toast.success(t('toast.saved'))
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error updating setting:', err)
|
|
||||||
toast.error(t('toast.saveFailed'))
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('py-4', 'border-b last:border-0 dark:border-gray-800')}>
|
|
||||||
<Label className="font-medium text-gray-900 dark:text-gray-100 block mb-1">
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
{description && (
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
|
||||||
disabled={disabled || isLoading}
|
|
||||||
className={cn(
|
|
||||||
'w-full px-3 py-2 border rounded-lg',
|
|
||||||
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
||||||
'appearance-none bg-white dark:bg-gray-900',
|
|
||||||
'border-gray-300 dark:border-gray-700',
|
|
||||||
'text-gray-900 dark:text-gray-100'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{options.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{isLoading && (
|
|
||||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Loader2, Check, X } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
interface SettingToggleProps {
|
|
||||||
label: string
|
|
||||||
description?: string
|
|
||||||
checked: boolean
|
|
||||||
onChange: (checked: boolean) => Promise<void>
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingToggle({
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
disabled
|
|
||||||
}: SettingToggleProps) {
|
|
||||||
const { t } = useLanguage()
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [error, setError] = useState(false)
|
|
||||||
|
|
||||||
const handleChange = async (newChecked: boolean) => {
|
|
||||||
setIsLoading(true)
|
|
||||||
setError(false)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onChange(newChecked)
|
|
||||||
toast.success(t('toast.saved'))
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error updating setting:', err)
|
|
||||||
setError(true)
|
|
||||||
toast.error(t('toast.saveFailed'))
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn(
|
|
||||||
'flex items-center justify-between py-4',
|
|
||||||
'border-b last:border-0 dark:border-gray-800'
|
|
||||||
)}>
|
|
||||||
<div className="flex-1 pr-4">
|
|
||||||
<Label className="font-medium text-gray-900 dark:text-gray-100 cursor-pointer">
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
{description && (
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-gray-500" />}
|
|
||||||
{!isLoading && !error && checked && <Check className="h-4 w-4 text-green-500" />}
|
|
||||||
{!isLoading && !error && !checked && <X className="h-4 w-4 text-gray-400" />}
|
|
||||||
<Switch
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={handleChange}
|
|
||||||
disabled={disabled || isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { usePathname } from 'next/navigation'
|
|
||||||
import { Settings, Sparkles, Palette, User, Database, Info, Check, Key } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
interface SettingsSection {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
icon: React.ReactNode
|
|
||||||
href: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsNavProps {
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsNav({ className }: SettingsNavProps) {
|
|
||||||
const pathname = usePathname()
|
|
||||||
const { t } = useLanguage()
|
|
||||||
|
|
||||||
const sections: SettingsSection[] = [
|
|
||||||
{
|
|
||||||
id: 'general',
|
|
||||||
label: t('generalSettings.title'),
|
|
||||||
icon: <Settings className="h-5 w-5" />,
|
|
||||||
href: '/settings/general'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ai',
|
|
||||||
label: t('aiSettings.title'),
|
|
||||||
icon: <Sparkles className="h-5 w-5" />,
|
|
||||||
href: '/settings/ai'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'appearance',
|
|
||||||
label: t('appearance.title'),
|
|
||||||
icon: <Palette className="h-5 w-5" />,
|
|
||||||
href: '/settings/appearance'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'profile',
|
|
||||||
label: t('profile.title'),
|
|
||||||
icon: <User className="h-5 w-5" />,
|
|
||||||
href: '/settings/profile'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'data',
|
|
||||||
label: t('dataManagement.title'),
|
|
||||||
icon: <Database className="h-5 w-5" />,
|
|
||||||
href: '/settings/data'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mcp',
|
|
||||||
label: t('mcpSettings.title'),
|
|
||||||
icon: <Key className="h-5 w-5" />,
|
|
||||||
href: '/settings/mcp'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'about',
|
|
||||||
label: t('about.title'),
|
|
||||||
icon: <Info className="h-5 w-5" />,
|
|
||||||
href: '/settings/about'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className={cn('space-y-1', className)}>
|
|
||||||
{sections.map((section) => (
|
|
||||||
<Link
|
|
||||||
key={section.id}
|
|
||||||
href={section.href}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-colors',
|
|
||||||
'hover:bg-gray-100 dark:hover:bg-gray-800',
|
|
||||||
isActive(section.href)
|
|
||||||
? 'bg-gray-100 dark:bg-gray-800 text-primary'
|
|
||||||
: 'text-gray-700 dark:text-gray-300'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isActive(section.href) && (
|
|
||||||
<Check className="h-4 w-4 text-primary" />
|
|
||||||
)}
|
|
||||||
{!isActive(section.href) && (
|
|
||||||
<div className="w-4" />
|
|
||||||
)}
|
|
||||||
{section.icon}
|
|
||||||
<span className="font-medium">{section.label}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { Search, X } from 'lucide-react'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
export interface Section {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
icon: React.ReactNode
|
|
||||||
href: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsSearchProps {
|
|
||||||
sections: Section[]
|
|
||||||
onFilter: (filteredSections: Section[]) => void
|
|
||||||
placeholder?: string
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsSearch({
|
|
||||||
sections,
|
|
||||||
onFilter,
|
|
||||||
placeholder,
|
|
||||||
className
|
|
||||||
}: SettingsSearchProps) {
|
|
||||||
const { t } = useLanguage()
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const [filteredSections, setFilteredSections] = useState<Section[]>(sections)
|
|
||||||
|
|
||||||
const searchPlaceholder = placeholder || t('settings.searchNoResults') || 'Search settings...'
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!query.trim()) {
|
|
||||||
setFilteredSections(sections)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryLower = query.toLowerCase()
|
|
||||||
const filtered = sections.filter(section => {
|
|
||||||
const labelMatch = section.label.toLowerCase().includes(queryLower)
|
|
||||||
const descMatch = section.description.toLowerCase().includes(queryLower)
|
|
||||||
return labelMatch || descMatch
|
|
||||||
})
|
|
||||||
setFilteredSections(filtered)
|
|
||||||
}, [query, sections])
|
|
||||||
|
|
||||||
const handleClearSearch = () => {
|
|
||||||
setQuery('')
|
|
||||||
setFilteredSections(sections)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
handleClearSearch()
|
|
||||||
e.stopPropagation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
|
||||||
setQuery(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasResults = query.trim() && filteredSections.length < sections.length
|
|
||||||
const isEmptySearch = query.trim() && filteredSections.length === 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('relative', className)}>
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => handleSearchChange(e.target.value)}
|
|
||||||
placeholder={searchPlaceholder}
|
|
||||||
className="pl-10"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{hasResults && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClearSearch}
|
|
||||||
className="absolute right-2 top-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
aria-label={t('search.placeholder')}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isEmptySearch && (
|
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 p-2 bg-white rounded-lg shadow-lg border z-50">
|
|
||||||
<p className="text-sm text-gray-600">{t('settings.searchNoResults')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
|
|
||||||
interface SettingsSectionProps {
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
icon?: React.ReactNode
|
|
||||||
children: React.ReactNode
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsSection({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
icon,
|
|
||||||
children,
|
|
||||||
className
|
|
||||||
}: SettingsSectionProps) {
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
{icon}
|
|
||||||
{title}
|
|
||||||
</CardTitle>
|
|
||||||
{description && (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{children}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export { SettingsNav } from './SettingsNav'
|
|
||||||
export { SettingsSection } from './SettingsSection'
|
|
||||||
export { SettingToggle } from './SettingToggle'
|
|
||||||
export { SettingSelect } from './SettingSelect'
|
|
||||||
export { SettingInput } from './SettingInput'
|
|
||||||
export { SettingsSearch } from './SettingsSearch'
|
|
||||||
@@ -46,7 +46,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return
|
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return
|
||||||
getTrashCount().then(setTrashCount)
|
getTrashCount().then(setTrashCount)
|
||||||
}, [pathname, searchKey, refreshKey])
|
}, [pathname, refreshKey])
|
||||||
|
|
||||||
// Hide sidebar on Agents, Chat IA and Lab routes
|
// Hide sidebar on Agents, Chat IA and Lab routes
|
||||||
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return null
|
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return null
|
||||||
|
|||||||
@@ -5,18 +5,25 @@ import { createContext, useContext, useState, useCallback, useMemo } from 'react
|
|||||||
interface NoteRefreshContextType {
|
interface NoteRefreshContextType {
|
||||||
refreshKey: number
|
refreshKey: number
|
||||||
triggerRefresh: () => void
|
triggerRefresh: () => void
|
||||||
|
notebooksRefreshKey: number
|
||||||
|
triggerNotebooksRefresh: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const NoteRefreshContext = createContext<NoteRefreshContextType | undefined>(undefined)
|
const NoteRefreshContext = createContext<NoteRefreshContextType | undefined>(undefined)
|
||||||
|
|
||||||
export function NoteRefreshProvider({ children }: { children: React.ReactNode }) {
|
export function NoteRefreshProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [refreshKey, setRefreshKey] = useState(0)
|
const [refreshKey, setRefreshKey] = useState(0)
|
||||||
|
const [notebooksRefreshKey, setNotebooksRefreshKey] = useState(0)
|
||||||
|
|
||||||
const triggerRefresh = useCallback(() => {
|
const triggerRefresh = useCallback(() => {
|
||||||
setRefreshKey(prev => prev + 1)
|
setRefreshKey(prev => prev + 1)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const value = useMemo(() => ({ refreshKey, triggerRefresh }), [refreshKey, triggerRefresh])
|
const triggerNotebooksRefresh = useCallback(() => {
|
||||||
|
setNotebooksRefreshKey(prev => prev + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ refreshKey, triggerRefresh, notebooksRefreshKey, triggerNotebooksRefresh }), [refreshKey, triggerRefresh, notebooksRefreshKey, triggerNotebooksRefresh])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoteRefreshContext.Provider value={value}>
|
<NoteRefreshContext.Provider value={value}>
|
||||||
@@ -33,11 +40,7 @@ export function useNoteRefresh() {
|
|||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Same as useNoteRefresh but tolerates being called outside the provider
|
|
||||||
* (e.g. shared header rendered in admin pages). Returns a no-op when absent.
|
|
||||||
*/
|
|
||||||
export function useNoteRefreshOptional(): NoteRefreshContextType {
|
export function useNoteRefreshOptional(): NoteRefreshContextType {
|
||||||
const context = useContext(NoteRefreshContext)
|
const context = useContext(NoteRefreshContext)
|
||||||
return context ?? { refreshKey: 0, triggerRefresh: () => {} }
|
return context ?? { refreshKey: 0, triggerRefresh: () => {}, notebooksRefreshKey: 0, triggerNotebooksRefresh: () => {} }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isMovingNote, setIsMovingNote] = useState(false)
|
const [isMovingNote, setIsMovingNote] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const { triggerRefresh, refreshKey } = useNoteRefresh()
|
const { triggerRefresh, triggerNotebooksRefresh, notebooksRefreshKey } = useNoteRefresh()
|
||||||
|
|
||||||
// ===== DERIVED STATE =====
|
// ===== DERIVED STATE =====
|
||||||
const currentLabels = useMemo(() => {
|
const currentLabels = useMemo(() => {
|
||||||
@@ -115,10 +115,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
loadNotebooks()
|
loadNotebooks()
|
||||||
}, [loadNotebooks])
|
}, [loadNotebooks])
|
||||||
|
|
||||||
// Recharge les carnets à chaque fois qu'une note est modifiée/supprimée
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (refreshKey > 0) loadNotebooks()
|
if (notebooksRefreshKey > 0) loadNotebooks()
|
||||||
}, [refreshKey, loadNotebooks])
|
}, [notebooksRefreshKey, loadNotebooks])
|
||||||
|
|
||||||
// ===== ACTIONS: NOTEBOOKS =====
|
// ===== ACTIONS: NOTEBOOKS =====
|
||||||
const createNotebookOptimistic = useCallback(async (data: CreateNotebookInput) => {
|
const createNotebookOptimistic = useCallback(async (data: CreateNotebookInput) => {
|
||||||
@@ -134,8 +133,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
|
|
||||||
// Reload notebooks from server to update sidebar state
|
// Reload notebooks from server to update sidebar state
|
||||||
await loadNotebooks()
|
await loadNotebooks()
|
||||||
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerRefresh])
|
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
|
const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
|
||||||
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
||||||
@@ -149,8 +149,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
await loadNotebooks()
|
||||||
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerRefresh])
|
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
const deleteNotebook = useCallback(async (notebookId: string) => {
|
const deleteNotebook = useCallback(async (notebookId: string) => {
|
||||||
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
||||||
@@ -162,8 +163,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
await loadNotebooks()
|
||||||
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerRefresh])
|
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
|
const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
|
||||||
const response = await fetch('/api/notebooks/reorder', {
|
const response = await fetch('/api/notebooks/reorder', {
|
||||||
@@ -177,8 +179,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
await loadNotebooks()
|
||||||
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerRefresh])
|
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
// ===== ACTIONS: LABELS =====
|
// ===== ACTIONS: LABELS =====
|
||||||
const createLabel = useCallback(async (data: CreateLabelInput) => {
|
const createLabel = useCallback(async (data: CreateLabelInput) => {
|
||||||
@@ -233,6 +236,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
await loadNotebooks()
|
||||||
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to move note. Please try again.')
|
toast.error('Failed to move note. Please try again.')
|
||||||
|
|||||||
@@ -1,178 +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 icons
|
|
||||||
content = content.replace(
|
|
||||||
"import { TestTube, ExternalLink, RefreshCw } from 'lucide-react'",
|
|
||||||
"import { TestTube, ExternalLink, RefreshCw, Shield, Brain, Mail, Wrench } from 'lucide-react'"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Change root wrapper to 2 columns
|
|
||||||
content = content.replace(
|
|
||||||
'<div className="space-y-6">',
|
|
||||||
`<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
|
||||||
{/* Left Column */}
|
|
||||||
<div className="flex flex-col gap-6">`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Close left column and open right column right before AI Providers
|
|
||||||
content = content.replace(
|
|
||||||
' <Card>\n <CardHeader>\n <CardTitle>{t(\'admin.ai.title\')}</CardTitle>',
|
|
||||||
` </div>
|
|
||||||
{/* Right Column */}
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('admin.ai.title')}</CardTitle>`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. Move Email from after AI to Left Column? No, let's keep the order and just split them.
|
|
||||||
// Wait, in my previous attempt I put Security + AI on left, and Email + Tools on right?
|
|
||||||
// Let's just do that to be safe.
|
|
||||||
// Let's put Security + AI in Left Column, and Email + Tools in Right Column.
|
|
||||||
// So:
|
|
||||||
// Left Column ends after AI Providers.
|
|
||||||
// Right Column starts before Email.
|
|
||||||
content = content.replace(
|
|
||||||
' <Card>\n <CardHeader>\n <CardTitle>{t(\'admin.email.title\')}</CardTitle>',
|
|
||||||
` </div>
|
|
||||||
{/* Right Column */}
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('admin.email.title')}</CardTitle>`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add the closing div for Right Column at the very end
|
|
||||||
content = content.replace(
|
|
||||||
' </div>\n )\n}\n',
|
|
||||||
' </div>\n </div>\n )\n}\n'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Now let's replace all Card components with the custom design system
|
|
||||||
// Security Card
|
|
||||||
content = content.replace(
|
|
||||||
'<Card>\n <CardHeader>\n <CardTitle>{t(\'admin.security.title\')}</CardTitle>\n <CardDescription>{t(\'admin.security.description\')}</CardDescription>\n </CardHeader>',
|
|
||||||
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
|
||||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
|
||||||
<Shield className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold text-foreground">{t('admin.security.title')}</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('admin.security.description')}</p>
|
|
||||||
</div>
|
|
||||||
</div>`
|
|
||||||
);
|
|
||||||
content = content.replace(/<CardContent/g, '<div');
|
|
||||||
content = content.replace(/<\/CardContent>/g, '</div>');
|
|
||||||
content = content.replace(/<CardFooter>/g, '<div className="px-6 pb-6">');
|
|
||||||
content = content.replace(/<\/CardFooter>/g, '</div>');
|
|
||||||
|
|
||||||
content = content.replace(
|
|
||||||
'</form>\n </Card>',
|
|
||||||
'</form>\n </div>'
|
|
||||||
); // do it 4 times
|
|
||||||
content = content.replace('</form>\n </Card>', '</form>\n </div>');
|
|
||||||
content = content.replace('</form>\n </Card>', '</form>\n </div>');
|
|
||||||
content = content.replace('</form>\n </Card>', '</form>\n </div>');
|
|
||||||
|
|
||||||
// AI Card
|
|
||||||
content = content.replace(
|
|
||||||
'<Card>\n <CardHeader>\n <CardTitle>{t(\'admin.ai.title\')}</CardTitle>\n <CardDescription>{t(\'admin.ai.description\')}</CardDescription>\n </CardHeader>',
|
|
||||||
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
|
||||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
|
||||||
<Brain className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold text-foreground">{t('admin.ai.title')}</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('admin.ai.description')}</p>
|
|
||||||
</div>
|
|
||||||
</div>`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Email Card
|
|
||||||
content = content.replace(
|
|
||||||
'<Card>\n <CardHeader>\n <CardTitle>{t(\'admin.email.title\')}</CardTitle>\n <CardDescription>{t(\'admin.email.description\')}</CardDescription>\n </CardHeader>',
|
|
||||||
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
|
||||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
|
||||||
<Mail className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold text-foreground">{t('admin.email.title')}</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('admin.email.description')}</p>
|
|
||||||
</div>
|
|
||||||
</div>`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tools Card
|
|
||||||
content = content.replace(
|
|
||||||
'<Card>\n <CardHeader>\n <CardTitle>{t(\'admin.tools.title\')}</CardTitle>\n <CardDescription>{t(\'admin.tools.description\')}</CardDescription>\n </CardHeader>',
|
|
||||||
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
|
||||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
|
||||||
<Wrench className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold text-foreground">{t('admin.tools.title')}</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('admin.tools.description')}</p>
|
|
||||||
</div>
|
|
||||||
</div>`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Replace AI block styling
|
|
||||||
content = content.replace(
|
|
||||||
'p-4 border rounded-lg bg-primary/5 dark:bg-primary/10',
|
|
||||||
'p-4 border border-border/50 rounded-lg bg-muted/50'
|
|
||||||
);
|
|
||||||
content = content.replace(
|
|
||||||
'p-4 border rounded-lg bg-green-50/50 dark:bg-green-950/20',
|
|
||||||
'p-4 border border-border/50 rounded-lg bg-muted/50 mt-4'
|
|
||||||
);
|
|
||||||
content = content.replace(
|
|
||||||
'p-4 border rounded-lg bg-blue-50/50 dark:bg-blue-950/20',
|
|
||||||
'p-4 border border-border/50 rounded-lg bg-muted/50 mt-4'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also replace dark text colors inside headers
|
|
||||||
content = content.replace(
|
|
||||||
'<h3 className="text-base font-semibold flex items-center gap-2">\\n <span className="text-primary">🏷️</span>',
|
|
||||||
'<h3 className="text-base font-semibold flex items-center gap-2 text-foreground">\\n <span className="text-primary">🏷️</span>'
|
|
||||||
);
|
|
||||||
content = content.replace(
|
|
||||||
'<h3 className="text-base font-semibold flex items-center gap-2">\\n <span className="text-green-600">🔍</span>',
|
|
||||||
'<h3 className="text-base font-semibold flex items-center gap-2 text-foreground">\\n <span className="text-green-600">🔍</span>'
|
|
||||||
);
|
|
||||||
content = content.replace(
|
|
||||||
'<h3 className="text-base font-semibold flex items-center gap-2">\\n <span className="text-blue-600">💬</span>',
|
|
||||||
'<h3 className="text-base font-semibold flex items-center gap-2 text-foreground">\\n <span className="text-blue-600">💬</span>'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Email / Search test result classes
|
|
||||||
content = content.replace(
|
|
||||||
/border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950\/30 dark:text-green-300/g,
|
|
||||||
'border-green-500/20 bg-green-500/10 text-green-600'
|
|
||||||
);
|
|
||||||
content = content.replace(
|
|
||||||
/border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950\/30 dark:text-red-300/g,
|
|
||||||
'border-red-500/20 bg-red-500/10 text-red-600'
|
|
||||||
);
|
|
||||||
|
|
||||||
// CardFooter specific layout fixes since it became a div
|
|
||||||
content = content.replace(
|
|
||||||
'<div className="flex justify-between pb-6">',
|
|
||||||
'<div className="px-6 pb-6 flex justify-between">'
|
|
||||||
); // Might not match exact, let's fix manually if needed.
|
|
||||||
|
|
||||||
// Wait, the CardFooter replacement was: content.replace(/<CardFooter>/g, '<div className="px-6 pb-6">');
|
|
||||||
// Then there was <CardFooter className="flex justify-between"> which became <div className="flex justify-between">... Need to make sure it has padding.
|
|
||||||
content = content.replace(
|
|
||||||
'<div className="flex justify-between">',
|
|
||||||
'<div className="px-6 pb-6 flex justify-between">'
|
|
||||||
);
|
|
||||||
|
|
||||||
fs.writeFileSync(file, content);
|
|
||||||
console.log("Success");
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const file = 'app/(admin)/admin/settings/admin-settings-form.tsx';
|
|
||||||
let content = fs.readFileSync(file, 'utf-8');
|
|
||||||
|
|
||||||
// Replace any leftover <CardFooter> tags
|
|
||||||
content = content.replace(/<CardFooter>/g, '<div className="px-6 pb-6">');
|
|
||||||
content = content.replace(/<CardFooter className="([^"]+)">/g, '<div className="px-6 pb-6 $1">');
|
|
||||||
content = content.replace(/<\/CardFooter>/g, '</div>');
|
|
||||||
|
|
||||||
fs.writeFileSync(file, content);
|
|
||||||
console.log("Fixed CardFooters");
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const file = 'components/mcp/mcp-settings-panel.tsx';
|
|
||||||
let content = fs.readFileSync(file, 'utf-8');
|
|
||||||
|
|
||||||
// Replace standard <Card className="p-6"> with our styled headers
|
|
||||||
// Section 1
|
|
||||||
content = content.replace(
|
|
||||||
`<Card className="p-6">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Info className="h-5 w-5 text-blue-500 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">{t('mcpSettings.whatIsMcp.title')}</h2>`,
|
|
||||||
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
|
||||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-500 shrink-0">
|
|
||||||
<Info className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold text-foreground">{t('mcpSettings.whatIsMcp.title')}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 pt-4">`
|
|
||||||
);
|
|
||||||
|
|
||||||
// We need to close the p-6 div, wait, the <Card> ends with </Card>. So we need to replace </Card> carefully.
|
|
||||||
// Let's do it section by section or just globally replace </Card> with </div></div> since we added a new wrapping div.
|
|
||||||
// For Section 1, the end is </Card>
|
|
||||||
content = content.replace(
|
|
||||||
` <ExternalLink className="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>`,
|
|
||||||
` <ExternalLink className="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>`
|
|
||||||
); // The original has </div></div></Card>
|
|
||||||
|
|
||||||
// Section 2
|
|
||||||
content = content.replace(
|
|
||||||
`<Card className="p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<Server className="h-5 w-5 shrink-0" />
|
|
||||||
<h2 className="text-lg font-semibold">{t('mcpSettings.serverStatus.title')}</h2>
|
|
||||||
</div>`,
|
|
||||||
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
|
||||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
|
||||||
<Server className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold text-foreground">{t('mcpSettings.serverStatus.title')}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-6">`
|
|
||||||
);
|
|
||||||
content = content.replace(
|
|
||||||
` </div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>`,
|
|
||||||
` </div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
// Section 3
|
|
||||||
content = content.replace(
|
|
||||||
`<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Key className="h-5 w-5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">{t('mcpSettings.apiKeys.title')}</h2>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{t('mcpSettings.apiKeys.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>`,
|
|
||||||
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
|
||||||
<Key className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold text-foreground">{t('mcpSettings.apiKeys.title')}</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('mcpSettings.apiKeys.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>`
|
|
||||||
);
|
|
||||||
|
|
||||||
content = content.replace(
|
|
||||||
` ))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>`,
|
|
||||||
` ))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>`
|
|
||||||
);
|
|
||||||
// Wait! I forgot to add `<div className="p-6">` after the header for Section 3!
|
|
||||||
// The header ended at <Dialog open...
|
|
||||||
content = content.replace(
|
|
||||||
` <CreateKeyDialog
|
|
||||||
onGenerate={handleGenerate}
|
|
||||||
isPending={isPending}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
</div>`,
|
|
||||||
` <CreateKeyDialog
|
|
||||||
onGenerate={handleGenerate}
|
|
||||||
isPending={isPending}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
<div className="p-6">`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Section 4
|
|
||||||
// Wait, Section 4 is a subcomponent! <ConfigInstructions>
|
|
||||||
content = content.replace(
|
|
||||||
`<Card className="p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<ExternalLink className="h-5 w-5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">
|
|
||||||
{t('mcpSettings.configInstructions.title')}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{t('mcpSettings.configInstructions.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>`,
|
|
||||||
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
|
||||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
|
||||||
<ExternalLink className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold text-foreground">
|
|
||||||
{t('mcpSettings.configInstructions.title')}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('mcpSettings.configInstructions.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-2">`
|
|
||||||
);
|
|
||||||
|
|
||||||
content = content.replace(
|
|
||||||
` ))}
|
|
||||||
</div>
|
|
||||||
</Card>`,
|
|
||||||
` ))}
|
|
||||||
</div>
|
|
||||||
</div>`
|
|
||||||
);
|
|
||||||
// In Section 4, remove `<div className="space-y-2">` because I merged it into `<div className="p-6 space-y-2">`
|
|
||||||
content = content.replace(
|
|
||||||
`<div className="p-6 space-y-2">\n <div className="space-y-2">`,
|
|
||||||
`<div className="p-6 space-y-2">`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fix colors
|
|
||||||
content = content.replace(/text-gray-500/g, 'text-muted-foreground');
|
|
||||||
content = content.replace(/text-gray-600/g, 'text-muted-foreground');
|
|
||||||
content = content.replace(/text-gray-400/g, 'text-muted-foreground');
|
|
||||||
content = content.replace(/bg-gray-100 dark:bg-gray-800/g, 'bg-muted');
|
|
||||||
content = content.replace(/bg-gray-50 dark:bg-gray-900/g, 'bg-muted/50');
|
|
||||||
content = content.replace(/bg-gray-50/g, 'bg-muted/50');
|
|
||||||
content = content.replace(/dark:hover:bg-gray-900/g, 'hover:bg-muted');
|
|
||||||
|
|
||||||
// Remove Card import
|
|
||||||
content = content.replace("import { Card } from '@/components/ui/card'", "");
|
|
||||||
|
|
||||||
// Grid layout for mcp-settings-panel root
|
|
||||||
content = content.replace(
|
|
||||||
'<div className="space-y-6">',
|
|
||||||
'<div className="columns-1 lg:columns-2 gap-6 space-y-6">'
|
|
||||||
);
|
|
||||||
|
|
||||||
fs.writeFileSync(file, content);
|
|
||||||
console.log("Done");
|
|
||||||
@@ -27,6 +27,16 @@ export class OllamaProvider implements AIProvider {
|
|||||||
this.model = ollamaClient.chat(modelName);
|
this.model = ollamaClient.chat(modelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number = 30_000): Promise<Response> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...options, signal: controller.signal })
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async generateTags(content: string, language: string = "en"): Promise<TagSuggestion[]> {
|
async generateTags(content: string, language: string = "en"): Promise<TagSuggestion[]> {
|
||||||
try {
|
try {
|
||||||
const promptText = language === 'fa'
|
const promptText = language === 'fa'
|
||||||
@@ -55,7 +65,7 @@ Rules:
|
|||||||
Respond ONLY as a JSON list of objects: [{"tag": "string", "confidence": number}].
|
Respond ONLY as a JSON list of objects: [{"tag": "string", "confidence": number}].
|
||||||
Note content: "${content}"`;
|
Note content: "${content}"`;
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}/generate`, {
|
const response = await this.fetchWithTimeout(`${this.baseUrl}/generate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -75,7 +85,6 @@ Note content: "${content}"`;
|
|||||||
return JSON.parse(jsonMatch[0]);
|
return JSON.parse(jsonMatch[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Support for { "tags": [...] } format
|
|
||||||
const objectMatch = text.match(/\{\s*"tags"\s*:\s*(\[[\s\S]*\])\s*\}/);
|
const objectMatch = text.match(/\{\s*"tags"\s*:\s*(\[[\s\S]*\])\s*\}/);
|
||||||
if (objectMatch && objectMatch[1]) {
|
if (objectMatch && objectMatch[1]) {
|
||||||
return JSON.parse(objectMatch[1]);
|
return JSON.parse(objectMatch[1]);
|
||||||
@@ -90,14 +99,14 @@ Note content: "${content}"`;
|
|||||||
|
|
||||||
async getEmbeddings(text: string): Promise<number[]> {
|
async getEmbeddings(text: string): Promise<number[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseUrl}/embeddings`, {
|
const response = await this.fetchWithTimeout(`${this.baseUrl}/embeddings`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: this.embeddingModelName,
|
model: this.embeddingModelName,
|
||||||
prompt: text,
|
prompt: text,
|
||||||
}),
|
}),
|
||||||
});
|
}, 60_000);
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
|
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
|
||||||
|
|
||||||
@@ -111,7 +120,7 @@ Note content: "${content}"`;
|
|||||||
|
|
||||||
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
|
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseUrl}/generate`, {
|
const response = await this.fetchWithTimeout(`${this.baseUrl}/generate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -126,7 +135,6 @@ Note content: "${content}"`;
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const text = data.response;
|
const text = data.response;
|
||||||
|
|
||||||
// Extract JSON from response
|
|
||||||
const jsonMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/);
|
const jsonMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/);
|
||||||
if (jsonMatch) {
|
if (jsonMatch) {
|
||||||
return JSON.parse(jsonMatch[0]);
|
return JSON.parse(jsonMatch[0]);
|
||||||
@@ -141,7 +149,7 @@ Note content: "${content}"`;
|
|||||||
|
|
||||||
async generateText(prompt: string): Promise<string> {
|
async generateText(prompt: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseUrl}/generate`, {
|
const response = await this.fetchWithTimeout(`${this.baseUrl}/generate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -172,7 +180,7 @@ Note content: "${content}"`;
|
|||||||
ollamaMessages.unshift({ role: 'system', content: systemPrompt });
|
ollamaMessages.unshift({ role: 'system', content: systemPrompt });
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}/chat`, {
|
const response = await this.fetchWithTimeout(`${this.baseUrl}/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
19
memento-note/lib/api-auth.ts
Normal file
19
memento-note/lib/api-auth.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
29
memento-note/lib/rate-limit.ts
Normal file
29
memento-note/lib/rate-limit.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const attempts = new Map<string, { count: number; resetAt: number }>()
|
||||||
|
|
||||||
|
const WINDOW_MS = 60_000
|
||||||
|
const MAX_ATTEMPTS = 5
|
||||||
|
|
||||||
|
export function rateLimit(key: string): { allowed: boolean; retryAfterMs: number } {
|
||||||
|
const now = Date.now()
|
||||||
|
const entry = attempts.get(key)
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetAt) {
|
||||||
|
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS })
|
||||||
|
return { allowed: true, retryAfterMs: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++
|
||||||
|
|
||||||
|
if (entry.count > MAX_ATTEMPTS) {
|
||||||
|
return { allowed: false, retryAfterMs: entry.resetAt - now }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true, retryAfterMs: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [k, v] of attempts) {
|
||||||
|
if (now > v.resetAt) attempts.delete(k)
|
||||||
|
}
|
||||||
|
}, 60_000)
|
||||||
@@ -26,21 +26,21 @@
|
|||||||
"sending": "جاري الإرسال...",
|
"sending": "جاري الإرسال...",
|
||||||
"sendResetLink": "إرسال رابط إعادة التعيين",
|
"sendResetLink": "إرسال رابط إعادة التعيين",
|
||||||
"backToLogin": "العودة إلى تسجيل الدخول",
|
"backToLogin": "العودة إلى تسجيل الدخول",
|
||||||
"signOut": "Sign out",
|
"signOut": "تسجيل الخروج",
|
||||||
"confirmPassword": "تأكيد كلمة المرور",
|
"confirmPassword": "تأكيد كلمة المرور",
|
||||||
"confirmPasswordPlaceholder": "أعد إدخال كلمة المرور"
|
"confirmPasswordPlaceholder": "أعد إدخال كلمة المرور"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "Notes",
|
"notes": "الملاحظات",
|
||||||
"reminders": "Reminders",
|
"reminders": "التذكيرات",
|
||||||
"labels": "Labels",
|
"labels": "التسميات",
|
||||||
"editLabels": "Edit labels",
|
"editLabels": "تعديل التسميات",
|
||||||
"newNoteTabs": "ملاحظة جديدة",
|
"newNoteTabs": "ملاحظة جديدة",
|
||||||
"newNoteTabsHint": "إنشاء ملاحظة في هذا الدفتر",
|
"newNoteTabsHint": "إنشاء ملاحظة في هذا الدفتر",
|
||||||
"noLabelsInNotebook": "لا توجد تسميات في هذا الدفتر",
|
"noLabelsInNotebook": "لا توجد تسميات في هذا الدفتر",
|
||||||
"archive": "Archive",
|
"archive": "الأرشيف",
|
||||||
"trash": "Trash",
|
"trash": "المهملات",
|
||||||
"clearFilter": "Remove filter"
|
"clearFilter": "إزالة الفلتر"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"title": "الملاحظات",
|
"title": "الملاحظات",
|
||||||
@@ -88,8 +88,8 @@
|
|||||||
"itemOrMediaRequired": "الرجاء إضافة عنصر واحد على الأقل أو وسائط",
|
"itemOrMediaRequired": "الرجاء إضافة عنصر واحد على الأقل أو وسائط",
|
||||||
"noteCreated": "تم إنشاء الملاحظة بنجاح",
|
"noteCreated": "تم إنشاء الملاحظة بنجاح",
|
||||||
"noteCreateFailed": "فشل في إنشاء الملاحظة",
|
"noteCreateFailed": "فشل في إنشاء الملاحظة",
|
||||||
"deleted": "Note deleted",
|
"deleted": "تم حذف الملاحظة",
|
||||||
"deleteFailed": "Failed to delete note",
|
"deleteFailed": "فشل حذف الملاحظة",
|
||||||
"aiAssistant": "مساعد الذكاء الاصطناعي",
|
"aiAssistant": "مساعد الذكاء الاصطناعي",
|
||||||
"changeSize": "تغيير الحجم",
|
"changeSize": "تغيير الحجم",
|
||||||
"backgroundOptions": "خيارات الخلفية",
|
"backgroundOptions": "خيارات الخلفية",
|
||||||
@@ -176,10 +176,10 @@
|
|||||||
"history": "السجل",
|
"history": "السجل",
|
||||||
"historyRestored": "تم استعادة النسخة",
|
"historyRestored": "تم استعادة النسخة",
|
||||||
"historyEnabled": "تم تفعيل السجل",
|
"historyEnabled": "تم تفعيل السجل",
|
||||||
"historyDisabledTitle": "Version history",
|
"historyDisabledTitle": "سجل النسخ",
|
||||||
"historyDisabledDesc": "السجل معطل لحسابك.",
|
"historyDisabledDesc": "السجل معطل لحسابك.",
|
||||||
"historyEnabledTitle": "History enabled!",
|
"historyEnabledTitle": "تم تفعيل السجل!",
|
||||||
"historyEnabledDesc": "Versions of this note will now be recorded.",
|
"historyEnabledDesc": "سيتم الآن تسجيل نسخ هذه الملاحظة.",
|
||||||
"enableHistory": "تفعيل السجل",
|
"enableHistory": "تفعيل السجل",
|
||||||
"historyEmpty": "لا توجد نسخ متاحة",
|
"historyEmpty": "لا توجد نسخ متاحة",
|
||||||
"historySelectVersion": "اختر نسخة لمعاينة محتواها",
|
"historySelectVersion": "اختر نسخة لمعاينة محتواها",
|
||||||
@@ -188,32 +188,37 @@
|
|||||||
"sortDateAsc": "التاريخ (الأقدم)",
|
"sortDateAsc": "التاريخ (الأقدم)",
|
||||||
"sortTitleAsc": "العنوان أ ← ي",
|
"sortTitleAsc": "العنوان أ ← ي",
|
||||||
"sortTitleDesc": "العنوان ي ← أ",
|
"sortTitleDesc": "العنوان ي ← أ",
|
||||||
"suggestTitle": "AI title",
|
"suggestTitle": "عنوان بالذكاء الاصطناعي",
|
||||||
"generateTitleFromImage": "Generate title from image",
|
"generateTitleFromImage": "إنشاء عنوان من الصورة",
|
||||||
"titleGenerated": "Title generated",
|
"titleGenerated": "تم إنشاء العنوان",
|
||||||
"content": "Content",
|
"content": "المحتوى",
|
||||||
"restore": "Restore",
|
"restore": "استعادة",
|
||||||
"createFailed": "Failed to create note",
|
"createFailed": "فشل إنشاء الملاحظة",
|
||||||
"updateFailed": "Failed to update note",
|
"updateFailed": "فشل تحديث الملاحظة",
|
||||||
"archived": "Note archived",
|
"archived": "تم أرشفة الملاحظة",
|
||||||
"archiveFailed": "Failed to archive",
|
"archiveFailed": "فشل الأرشفة",
|
||||||
"sort": "Sort",
|
"sort": "ترتيب",
|
||||||
"confirmDeleteTitle": "Delete note",
|
"confirmDeleteTitle": "حذف الملاحظة",
|
||||||
"leftShare": "Share removed",
|
"leftShare": "تمت إزالة المشاركة",
|
||||||
"dismissed": "Note dismissed from recent",
|
"dismissed": "تمت إزالة الملاحظة من الحديثة",
|
||||||
"generalNotes": "General Notes",
|
"generalNotes": "الملاحظات العامة",
|
||||||
"noteType": "نوع الملاحظة",
|
"noteType": "نوع الملاحظة",
|
||||||
"typeText": "نص",
|
"typeText": "نص",
|
||||||
"typeMarkdown": "ماركداون",
|
"typeMarkdown": "ماركداون",
|
||||||
"typeRichText": "نص منسق",
|
"typeRichText": "نص منسق",
|
||||||
"typeChecklist": "قائمة مراجعة",
|
"typeChecklist": "قائمة مراجعة",
|
||||||
"convertedToRichText": "Converted to rich text",
|
"convertedToRichText": "تم التحويل إلى نص منسق",
|
||||||
"conversionFailed": "Conversion failed, staying in Markdown",
|
"conversionFailed": "فشل التحويل، البقاء في Markdown",
|
||||||
"richTextPlaceholder": "اكتب ملاحظة...",
|
"richTextPlaceholder": "اكتب ملاحظة...",
|
||||||
"switchTypeTitle": "تغيير نوع الملاحظة؟",
|
"switchTypeTitle": "تغيير نوع الملاحظة؟",
|
||||||
"switchTypeWarning": "قد يفقد بعض التنسيق عند التحويل إلى {type}.",
|
"switchTypeWarning": "قد يفقد بعض التنسيق عند التحويل إلى {type}.",
|
||||||
"switchTypeContentPreserved": "سيتم الحفاظ على المحتوى كنص عادي.",
|
"switchTypeContentPreserved": "سيتم الحفاظ على المحتوى كنص عادي.",
|
||||||
"switchType": "تحويل إلى {type}"
|
"switchType": "تحويل إلى {type}",
|
||||||
|
"deleteVersionDesc": "لا يمكن التراجع عن هذا الإجراء. سيتم حذف النسخة نهائياً من السجل.",
|
||||||
|
"compareVersions": "مقارنة",
|
||||||
|
"currentVersion": "الحالي",
|
||||||
|
"diffSelectHint": "انقر على نسختين في القائمة للمقارنة بينهما",
|
||||||
|
"diffTitle": "المقارنة"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "←",
|
"previous": "←",
|
||||||
@@ -221,81 +226,81 @@
|
|||||||
"next": "→"
|
"next": "→"
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
"title": "Labels",
|
"title": "التسميات",
|
||||||
"filter": "Filter by Label",
|
"filter": "تصفية حسب التسمية",
|
||||||
"manage": "Manage Labels",
|
"manage": "إدارة التسميات",
|
||||||
"manageTooltip": "Manage Labels",
|
"manageTooltip": "إدارة التسميات",
|
||||||
"changeColor": "Change Color",
|
"changeColor": "تغيير اللون",
|
||||||
"changeColorTooltip": "Change color",
|
"changeColorTooltip": "تغيير اللون",
|
||||||
"delete": "Delete",
|
"delete": "حذف",
|
||||||
"deleteTooltip": "Delete label",
|
"deleteTooltip": "حذف التسمية",
|
||||||
"confirmDelete": "Are you sure you want to delete this label?",
|
"confirmDelete": "هل أنت متأكد أنك تريد حذف هذه التسمية؟",
|
||||||
"newLabelPlaceholder": "Create new label",
|
"newLabelPlaceholder": "إنشاء تسمية جديدة",
|
||||||
"namePlaceholder": "Enter label name",
|
"namePlaceholder": "أدخل اسم التسمية",
|
||||||
"addLabel": "Add label",
|
"addLabel": "إضافة تسمية",
|
||||||
"createLabel": "Create label",
|
"createLabel": "إنشاء تسمية",
|
||||||
"labelName": "Label name",
|
"labelName": "اسم التسمية",
|
||||||
"labelColor": "Label color",
|
"labelColor": "لون التسمية",
|
||||||
"manageLabels": "Manage labels",
|
"manageLabels": "إدارة التسميات",
|
||||||
"manageLabelsDescription": "Add or remove labels for this note. Click on a label to change its color.",
|
"manageLabelsDescription": "إضافة أو إزالة تسميات لهذه الملاحظة. انقر على التسمية لتغيير لونها.",
|
||||||
"selectedLabels": "Selected Labels",
|
"selectedLabels": "التسميات المحددة",
|
||||||
"allLabels": "All Labels",
|
"allLabels": "جميع التسميات",
|
||||||
"clearAll": "Clear all",
|
"clearAll": "مسح الكل",
|
||||||
"filterByLabel": "Filter by label",
|
"filterByLabel": "تصفية حسب التسمية",
|
||||||
"tagAdded": "Tag \"{tag}\" added",
|
"tagAdded": "تمت إضافة الوسم \"{tag}\"",
|
||||||
"showLess": "Show less",
|
"showLess": "عرض أقل",
|
||||||
"showMore": "Show more",
|
"showMore": "عرض المزيد",
|
||||||
"editLabels": "Edit Labels",
|
"editLabels": "تعديل التسميات",
|
||||||
"editLabelsDescription": "Create, edit colors, or delete labels.",
|
"editLabelsDescription": "إنشاء أو تعديل الألوان أو حذف التسميات.",
|
||||||
"noLabelsFound": "No labels found.",
|
"noLabelsFound": "لم يتم العثور على تسميات.",
|
||||||
"loading": "Loading...",
|
"loading": "جاري التحميل...",
|
||||||
"notebookRequired": "⚠️ Labels are only available in notebooks. Move this note to a notebook first.",
|
"notebookRequired": "⚠️ التسميات متاحة فقط في الدفاتر. انقل هذه الملاحظة إلى دفتر أولاً.",
|
||||||
"count": "{count} labels",
|
"count": "{count} تسميات",
|
||||||
"noLabels": "No labels",
|
"noLabels": "لا توجد تسميات",
|
||||||
"confirmDeleteShort": "Confirm?",
|
"confirmDeleteShort": "تأكيد؟",
|
||||||
"labelRemoved": "Label \"{label}\" removed"
|
"labelRemoved": "تمت إزالة التسمية \"{label}\""
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Search",
|
"placeholder": "بحث",
|
||||||
"searchPlaceholder": "Search your notes...",
|
"searchPlaceholder": "ابحث في ملاحظاتك...",
|
||||||
"semanticInProgress": "AI search in progress...",
|
"semanticInProgress": "بحث الذكاء الاصطناعي جارٍ...",
|
||||||
"semanticTooltip": "AI semantic search",
|
"semanticTooltip": "بحث دلالي بالذكاء الاصطناعي",
|
||||||
"searching": "Searching...",
|
"searching": "جاري البحث...",
|
||||||
"noResults": "No results found",
|
"noResults": "لم يتم العثور على نتائج",
|
||||||
"resultsFound": "{count} notes found",
|
"resultsFound": "تم العثور على {count} ملاحظات",
|
||||||
"exactMatch": "Exact match",
|
"exactMatch": "تطابق تام",
|
||||||
"related": "Related",
|
"related": "ذات صلة",
|
||||||
"disabledAdmin": "البحث معطل في وضع المسؤول"
|
"disabledAdmin": "البحث معطل في وضع المسؤول"
|
||||||
},
|
},
|
||||||
"collaboration": {
|
"collaboration": {
|
||||||
"emailPlaceholder": "Enter email address",
|
"emailPlaceholder": "أدخل عنوان البريد الإلكتروني",
|
||||||
"addCollaborator": "Add collaborator",
|
"addCollaborator": "إضافة متعاون",
|
||||||
"removeCollaborator": "Remove collaborator",
|
"removeCollaborator": "إزالة متعاون",
|
||||||
"owner": "Owner",
|
"owner": "المالك",
|
||||||
"canEdit": "Can edit",
|
"canEdit": "يمكنه التعديل",
|
||||||
"canView": "Can view",
|
"canView": "يمكنه العرض",
|
||||||
"shareNote": "Share note",
|
"shareNote": "مشاركة الملاحظة",
|
||||||
"shareWithCollaborators": "Share with collaborators",
|
"shareWithCollaborators": "المشاركة مع المتعاونين",
|
||||||
"addCollaboratorDescription": "Add people to collaborate on this note by their email address.",
|
"addCollaboratorDescription": "أضف أشخاصاً للتعاون في هذه الملاحظة عبر عنوان بريدهم الإلكتروني.",
|
||||||
"viewerDescription": "You have access to this note. Only the owner can manage collaborators.",
|
"viewerDescription": "لديك صلاحية الوصول إلى هذه الملاحظة. فقط المالك يمكنه إدارة المتعاونين.",
|
||||||
"emailAddress": "Email address",
|
"emailAddress": "عنوان البريد الإلكتروني",
|
||||||
"enterEmailAddress": "Enter email address",
|
"enterEmailAddress": "أدخل عنوان البريد الإلكتروني",
|
||||||
"invite": "Invite",
|
"invite": "دعوة",
|
||||||
"peopleWithAccess": "People with access",
|
"peopleWithAccess": "الأشخاص الذين لديهم صلاحية الوصول",
|
||||||
"noCollaborators": "No collaborators yet. Add someone above!",
|
"noCollaborators": "لا يوجد متعاونون بعد. أضف شخصاً أعلاه!",
|
||||||
"noCollaboratorsViewer": "No collaborators yet.",
|
"noCollaboratorsViewer": "لا يوجد متعاونون بعد.",
|
||||||
"pendingInvite": "Pending Invite",
|
"pendingInvite": "دعوة معلقة",
|
||||||
"pending": "Pending",
|
"pending": "معلق",
|
||||||
"remove": "Remove",
|
"remove": "إزالة",
|
||||||
"unnamedUser": "Unnamed User",
|
"unnamedUser": "مستخدم بدون اسم",
|
||||||
"done": "Done",
|
"done": "تم",
|
||||||
"willBeAdded": "{email} will be added as collaborator when note is created",
|
"willBeAdded": "سيتم إضافة {email} كمتعاون عند إنشاء الملاحظة",
|
||||||
"alreadyInList": "This email is already in the list",
|
"alreadyInList": "هذا البريد الإلكتروني موجود بالفعل في القائمة",
|
||||||
"nowHasAccess": "{name} now has access to this note",
|
"nowHasAccess": "{name} لديه الآن صلاحية الوصول إلى هذه الملاحظة",
|
||||||
"accessRevoked": "Access has been revoked",
|
"accessRevoked": "تم إلغاء صلاحية الوصول",
|
||||||
"errorLoading": "Error loading collaborators",
|
"errorLoading": "خطأ في تحميل المتعاونين",
|
||||||
"failedToAdd": "Failed to add collaborator",
|
"failedToAdd": "فشل في إضافة المتعاون",
|
||||||
"failedToRemove": "Failed to remove collaborator"
|
"failedToRemove": "فشل في إزالة المتعاون"
|
||||||
},
|
},
|
||||||
"ai": {
|
"ai": {
|
||||||
"analyzing": "الذكاء الاصطناعي يحلل...",
|
"analyzing": "الذكاء الاصطناعي يحلل...",
|
||||||
@@ -417,14 +422,17 @@
|
|||||||
"transformationsDesc": "التحويلات — مطبقة مباشرة في الملاحظة",
|
"transformationsDesc": "التحويلات — مطبقة مباشرة في الملاحظة",
|
||||||
"writeMinWordsAction": "اكتب 5 كلمات على الأقل لتفعيل إجراءات الذكاء الاصطناعي.",
|
"writeMinWordsAction": "اكتب 5 كلمات على الأقل لتفعيل إجراءات الذكاء الاصطناعي.",
|
||||||
"processingAction": "جاري المعالجة...",
|
"processingAction": "جاري المعالجة...",
|
||||||
"noImagesError": "No images in this note",
|
"noImagesError": "لا توجد صور في هذه الملاحظة",
|
||||||
"overview": "Overview",
|
"overview": "نظرة عامة",
|
||||||
"action": {
|
"action": {
|
||||||
"clarify": "توضيح",
|
"clarify": "توضيح",
|
||||||
"shorten": "تقصير",
|
"shorten": "تقصير",
|
||||||
"improve": "تحسين",
|
"improve": "تحسين",
|
||||||
"toMarkdown": "إلى Markdown",
|
"toMarkdown": "إلى Markdown",
|
||||||
"describeImages": "Describe images"
|
"describeImages": "وصف الصور",
|
||||||
|
"fixGrammar": "تصحيح القواعد",
|
||||||
|
"translate": "ترجمة",
|
||||||
|
"explain": "شرح"
|
||||||
},
|
},
|
||||||
"openAssistant": "فتح مساعد الذكاء الاصطناعي",
|
"openAssistant": "فتح مساعد الذكاء الاصطناعي",
|
||||||
"poweredByMomento": "مدعوم من Momento AI",
|
"poweredByMomento": "مدعوم من Momento AI",
|
||||||
@@ -440,8 +448,50 @@
|
|||||||
"insightsTab": "رؤى",
|
"insightsTab": "رؤى",
|
||||||
"aiCopilot": "مساعد ذكي",
|
"aiCopilot": "مساعد ذكي",
|
||||||
"suggestTitle": "اقتراح عنوان بالذكاء الاصطناعي",
|
"suggestTitle": "اقتراح عنوان بالذكاء الاصطناعي",
|
||||||
"generateTitleFromImage": "Generate title from image",
|
"generateTitleFromImage": "إنشاء عنوان من الصورة",
|
||||||
"titleGenerated": "Title generated from image"
|
"titleGenerated": "تم إنشاء العنوان من الصورة",
|
||||||
|
"wordCountMin": "الرجاء تحديد {min} كلمات على الأقل لإعادة الصياغة (حالياً {current} كلمة)",
|
||||||
|
"wordCountMax": "الرجاء تحديد {max} كلمة كحد أقصى لإعادة الصياغة (حالياً {current} كلمة)",
|
||||||
|
"resourceTab": "المصدر",
|
||||||
|
"aiNoteTitle": "ملاحظة الذكاء الاصطناعي",
|
||||||
|
"injectReplace": "استبدال",
|
||||||
|
"injectReplaceTitle": "استبدال محتوى الملاحظة بهذه الرسالة",
|
||||||
|
"injectComplete": "إكمال",
|
||||||
|
"injectCompleteTitle": "إكمال الملاحظة بهذه الرسالة (ذكاء اصطناعي)",
|
||||||
|
"injectMerge": "دمج",
|
||||||
|
"injectMergeTitle": "دمج مع الملاحظة (ذكاء اصطناعي)",
|
||||||
|
"imagesCount": "{count} صور",
|
||||||
|
"resource": {
|
||||||
|
"failedToLoadUrl": "فشل تحميل هذا الرابط",
|
||||||
|
"pageLoaded": "تم تحميل الصفحة: {title}",
|
||||||
|
"pageLoadError": "خطأ في تحميل الصفحة",
|
||||||
|
"pasteOrUrlFirst": "الصق نصاً أو حمّل رابطاً أولاً",
|
||||||
|
"enrichError": "خطأ في الإثراء",
|
||||||
|
"enrichErrorShort": "خطأ في الإثراء",
|
||||||
|
"contentApplied": "تم تطبيق المحتوى على الملاحظة ✓",
|
||||||
|
"fromChat": "💬 من الدردشة",
|
||||||
|
"replacement": "↓ استبدال",
|
||||||
|
"completedByAI": "✦ أكمله الذكاء الاصطناعي",
|
||||||
|
"mergedByAI": "⟳ دمجه الذكاء الاصطناعي",
|
||||||
|
"rendered": "تم العرض",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"applyToNote": "تطبيق على الملاحظة",
|
||||||
|
"urlLabel": "رابط URL (اختياري)",
|
||||||
|
"resourceText": "نص المصدر",
|
||||||
|
"resourcePlaceholder": "الصق النص هنا (ماركداون، HTML، نص عادي…)",
|
||||||
|
"words": "كلمات",
|
||||||
|
"integrationMode": "وضع التكامل",
|
||||||
|
"modeReplace": "استبدال",
|
||||||
|
"modeReplaceDesc": "مباشر، بدون ذكاء اصطناعي",
|
||||||
|
"modeComplete": "إكمال",
|
||||||
|
"modeCompleteDesc": "يضيف بدون إعادة كتابة",
|
||||||
|
"modeMerge": "دمج",
|
||||||
|
"modeMergeDesc": "يعيد الكتابة ويكامل",
|
||||||
|
"aiProcessing": "جاري المعالجة بالذكاء الاصطناعي…",
|
||||||
|
"preview": "معاينة",
|
||||||
|
"generatePreview": "إنشاء معاينة",
|
||||||
|
"emptyNoteHint": "💡 الملاحظة فارغة — سيتم دمج محتوى المصدر مباشرة."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"titleSuggestions": {
|
"titleSuggestions": {
|
||||||
"available": "اقتراحات العنوان",
|
"available": "اقتراحات العنوان",
|
||||||
@@ -533,16 +583,16 @@
|
|||||||
"confirmFusion": "تأكيد الدمج",
|
"confirmFusion": "تأكيد الدمج",
|
||||||
"success": "تم دمج الملاحظات بنجاح!",
|
"success": "تم دمج الملاحظات بنجاح!",
|
||||||
"error": "فشل في دمج الملاحظات",
|
"error": "فشل في دمج الملاحظات",
|
||||||
"generateError": "Failed to generate fusion",
|
"generateError": "فشل في إنشاء الدمج",
|
||||||
"noContentReturned": "No fusion content returned from API",
|
"noContentReturned": "لم يتم إرجاع محتوى دمج من API",
|
||||||
"unknownDate": "Unknown date"
|
"unknownDate": "تاريخ غير معروف"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"accept": "Accept",
|
"accept": "قبول",
|
||||||
"accepted": "Share accepted",
|
"accepted": "تم قبول المشاركة",
|
||||||
"decline": "Decline",
|
"decline": "رفض",
|
||||||
"noNotifications": "No new notifications",
|
"noNotifications": "لا توجد إشعارات جديدة",
|
||||||
"shared": "شارك \"{title}\"",
|
"shared": "شارك \"{title}\"",
|
||||||
"untitled": "بدون عنوان",
|
"untitled": "بدون عنوان",
|
||||||
"notifications": "الإشعارات",
|
"notifications": "الإشعارات",
|
||||||
@@ -603,24 +653,24 @@
|
|||||||
"about": "حول",
|
"about": "حول",
|
||||||
"version": "الإصدار",
|
"version": "الإصدار",
|
||||||
"settingsSaved": "تم حفظ الإعدادات",
|
"settingsSaved": "تم حفظ الإعدادات",
|
||||||
"cardSizeMode": "Note Size",
|
"cardSizeMode": "حجم الملاحظة",
|
||||||
"cardSizeModeDescription": "Choose between variable sizes or uniform size",
|
"cardSizeModeDescription": "اختر بين أحجام متغيرة أو حجم موحد",
|
||||||
"selectCardSizeMode": "Select display mode",
|
"selectCardSizeMode": "اختر وضع العرض",
|
||||||
"cardSizeVariable": "Variable sizes (small/medium/large)",
|
"cardSizeVariable": "أحجام متغيرة (صغير/متوسط/كبير)",
|
||||||
"cardSizeUniform": "Uniform size",
|
"cardSizeUniform": "حجم موحد",
|
||||||
"settingsError": "خطأ في حفظ الإعدادات",
|
"settingsError": "خطأ في حفظ الإعدادات",
|
||||||
"maintenance": "Maintenance",
|
"maintenance": "الصيانة",
|
||||||
"maintenanceDescription": "Tools to maintain your database health",
|
"maintenanceDescription": "أدوات للحفاظ على صحة قاعدة البيانات",
|
||||||
"cleanTags": "Clean Orphan Tags",
|
"cleanTags": "تنظيف الوسوم اليتيمة",
|
||||||
"cleanTagsDescription": "Remove tags that are no longer used by any notes",
|
"cleanTagsDescription": "إزالة الوسوم التي لم تعد مستخدمة في أي ملاحظة",
|
||||||
"cleanupDone": "تمت مزامنة {created} تسمية، حذف {deleted} يتيمة",
|
"cleanupDone": "تمت مزامنة {created} تسمية، حذف {deleted} يتيمة",
|
||||||
"cleanupNothing": "لا حاجة لأي إجراء — التسميات متزامنة بالفعل مع ملاحظاتك",
|
"cleanupNothing": "لا حاجة لأي إجراء — التسميات متزامنة بالفعل مع ملاحظاتك",
|
||||||
"cleanupWithErrors": "بعض العمليات فشلت",
|
"cleanupWithErrors": "بعض العمليات فشلت",
|
||||||
"cleanupError": "تعذر تنظيف التسميات",
|
"cleanupError": "تعذر تنظيف التسميات",
|
||||||
"indexingComplete": "اكتملت الفهرسة: تمت معالجة {count} ملاحظة",
|
"indexingComplete": "اكتملت الفهرسة: تمت معالجة {count} ملاحظة",
|
||||||
"indexingError": "خطأ أثناء الفهرسة",
|
"indexingError": "خطأ أثناء الفهرسة",
|
||||||
"semanticIndexing": "Semantic Indexing",
|
"semanticIndexing": "الفهرسة الدلالية",
|
||||||
"semanticIndexingDescription": "Generate vectors for all notes to enable intent-based search",
|
"semanticIndexingDescription": "إنشاء متجهات لجميع الملاحظات لتفعيل البحث القائم على النية",
|
||||||
"profile": "الملف الشخصي",
|
"profile": "الملف الشخصي",
|
||||||
"searchNoResults": "لم يتم العثور على إعدادات مطابقة",
|
"searchNoResults": "لم يتم العثور على إعدادات مطابقة",
|
||||||
"languageAuto": "الكشف التلقائي",
|
"languageAuto": "الكشف التلقائي",
|
||||||
@@ -670,10 +720,10 @@
|
|||||||
"fontSizeDescription": "قم بضبط حجم الخط لتحسين القراءة. ينطبق هذا على جميع النصوص في الواجهة.",
|
"fontSizeDescription": "قم بضبط حجم الخط لتحسين القراءة. ينطبق هذا على جميع النصوص في الواجهة.",
|
||||||
"fontSizeUpdateSuccess": "تم تحديث حجم الخط بنجاح",
|
"fontSizeUpdateSuccess": "تم تحديث حجم الخط بنجاح",
|
||||||
"fontSizeUpdateFailed": "فشل في تحديث حجم الخط",
|
"fontSizeUpdateFailed": "فشل في تحديث حجم الخط",
|
||||||
"showRecentNotes": "Show Recent Notes Section",
|
"showRecentNotes": "عرض قسم الملاحظات الحديثة",
|
||||||
"showRecentNotesDescription": "Display recent notes (last 7 days) on the main page",
|
"showRecentNotesDescription": "عرض الملاحظات الحديثة (آخر 7 أيام) في الصفحة الرئيسية",
|
||||||
"recentNotesUpdateSuccess": "Recent notes setting updated successfully",
|
"recentNotesUpdateSuccess": "تم تحديث إعدادات الملاحظات الحديثة بنجاح",
|
||||||
"recentNotesUpdateFailed": "Failed to update recent notes setting"
|
"recentNotesUpdateFailed": "فشل تحديث إعدادات الملاحظات الحديثة"
|
||||||
},
|
},
|
||||||
"aiSettings": {
|
"aiSettings": {
|
||||||
"title": "إعدادات الذكاء الاصطناعي",
|
"title": "إعدادات الذكاء الاصطناعي",
|
||||||
@@ -695,7 +745,15 @@
|
|||||||
"providerDesc": "اختر مزود الذكاء الاصطناعي المفضل",
|
"providerDesc": "اختر مزود الذكاء الاصطناعي المفضل",
|
||||||
"providerAutoDesc": "Ollama عند توفره، OpenAI كبديل",
|
"providerAutoDesc": "Ollama عند توفره، OpenAI كبديل",
|
||||||
"providerOllamaDesc": "خصوصية 100%، يعمل محليًا",
|
"providerOllamaDesc": "خصوصية 100%، يعمل محليًا",
|
||||||
"providerOpenAIDesc": "الأكثر دقة، يتطلب مفتاح API"
|
"providerOpenAIDesc": "الأكثر دقة، يتطلب مفتاح API",
|
||||||
|
"aiNote": "ملاحظة الذكاء الاصطناعي",
|
||||||
|
"aiNoteDesc": "تفعيل زر دردشة الذكاء الاصطناعي وأدوات تحسين النص",
|
||||||
|
"languageDetection": "اكتشاف اللغة",
|
||||||
|
"languageDetectionDesc": "يكتشف تلقائياً لغة ملاحظاتك",
|
||||||
|
"autoLabeling": "اقتراحات التسميات",
|
||||||
|
"autoLabelingDesc": "يقترح ويطبق التسميات تلقائياً على ملاحظاتك",
|
||||||
|
"noteHistory": "سجل الملاحظة",
|
||||||
|
"noteHistoryDesc": "تفعيل لقطات النسخ والاستعادة من السجل"
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"loading": "جاري التحميل...",
|
"loading": "جاري التحميل...",
|
||||||
@@ -717,9 +775,9 @@
|
|||||||
"error": "حدث خطأ",
|
"error": "حدث خطأ",
|
||||||
"operationSuccess": "نجحت العملية",
|
"operationSuccess": "نجحت العملية",
|
||||||
"operationFailed": "فشلت العملية",
|
"operationFailed": "فشلت العملية",
|
||||||
"testConnection": "Test Connection",
|
"testConnection": "اختبار الاتصال",
|
||||||
"clean": "Clean",
|
"clean": "تنظيف",
|
||||||
"indexAll": "Index All",
|
"indexAll": "فهرسة الكل",
|
||||||
"preview": "معاينة"
|
"preview": "معاينة"
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
@@ -752,7 +810,9 @@
|
|||||||
"markDone": "وضع علامة مكتمل",
|
"markDone": "وضع علامة مكتمل",
|
||||||
"markUndone": "وضع علامة غير مكتمل",
|
"markUndone": "وضع علامة غير مكتمل",
|
||||||
"todayAt": "اليوم في {time}",
|
"todayAt": "اليوم في {time}",
|
||||||
"tomorrowAt": "غداً في {time}"
|
"tomorrowAt": "غداً في {time}",
|
||||||
|
"clearCompleted": "مسح المكتملة",
|
||||||
|
"viewAll": "عرض جميع التذكيرات"
|
||||||
},
|
},
|
||||||
"notebook": {
|
"notebook": {
|
||||||
"create": "إنشاء دفتر",
|
"create": "إنشاء دفتر",
|
||||||
@@ -783,7 +843,7 @@
|
|||||||
"confidence": "ثقة",
|
"confidence": "ثقة",
|
||||||
"savingReminder": "خطأ في حفظ التذكير",
|
"savingReminder": "خطأ في حفظ التذكير",
|
||||||
"removingReminder": "خطأ في إزالة التذكير",
|
"removingReminder": "خطأ في إزالة التذكير",
|
||||||
"generatingDescription": "Please wait..."
|
"generatingDescription": "يرجى الانتظار..."
|
||||||
},
|
},
|
||||||
"notebookSuggestion": {
|
"notebookSuggestion": {
|
||||||
"title": "النقل إلى {name}؟",
|
"title": "النقل إلى {name}؟",
|
||||||
@@ -797,10 +857,10 @@
|
|||||||
"admin": {
|
"admin": {
|
||||||
"title": "لوحة تحكم المشرف",
|
"title": "لوحة تحكم المشرف",
|
||||||
"userManagement": "إدارة المستخدمين",
|
"userManagement": "إدارة المستخدمين",
|
||||||
"chat": "AI Chat",
|
"chat": "دردشة الذكاء الاصطناعي",
|
||||||
"lab": "The Lab",
|
"lab": "المختبر",
|
||||||
"agents": "Agents",
|
"agents": "الوكلاء",
|
||||||
"workspace": "Workspace",
|
"workspace": "مساحة العمل",
|
||||||
"settings": "إعدادات المشرف",
|
"settings": "إعدادات المشرف",
|
||||||
"security": {
|
"security": {
|
||||||
"title": "إعدادات الأمان",
|
"title": "إعدادات الأمان",
|
||||||
@@ -896,14 +956,14 @@
|
|||||||
"description": "تكوين إرسال البريد الإلكتروني لإشعارات الوكلاء وإعادة تعيين كلمة المرور.",
|
"description": "تكوين إرسال البريد الإلكتروني لإشعارات الوكلاء وإعادة تعيين كلمة المرور.",
|
||||||
"provider": "مزود البريد الإلكتروني",
|
"provider": "مزود البريد الإلكتروني",
|
||||||
"saveSettings": "حفظ إعدادات البريد الإلكتروني",
|
"saveSettings": "حفظ إعدادات البريد الإلكتروني",
|
||||||
"status": "Service Status",
|
"status": "حالة الخدمة",
|
||||||
"keySet": "key configured",
|
"keySet": "تم تكوين المفتاح",
|
||||||
"activeAuto": "Auto mode: Resend will be used first, SMTP as fallback.",
|
"activeAuto": "الوضع التلقائي: سيتم استخدام Resend أولاً، ثم SMTP كبديل.",
|
||||||
"activeSmtp": "Auto mode: SMTP will be used (Resend not configured).",
|
"activeSmtp": "الوضع التلقائي: سيتم استخدام SMTP (Resend غير مكوّن).",
|
||||||
"noneConfigured": "No email service configured. Set up Resend or SMTP.",
|
"noneConfigured": "لم يتم تكوين خدمة بريد إلكتروني. قم بإعداد Resend أو SMTP.",
|
||||||
"activeProvider": "Active provider",
|
"activeProvider": "المزود النشط",
|
||||||
"testOk": "test passed",
|
"testOk": "نجح الاختبار",
|
||||||
"testFail": "test failed"
|
"testFail": "فشل الاختبار"
|
||||||
},
|
},
|
||||||
"smtp": {
|
"smtp": {
|
||||||
"title": "تكوين SMTP",
|
"title": "تكوين SMTP",
|
||||||
@@ -937,9 +997,9 @@
|
|||||||
"deleteFailed": "فشل الحذف",
|
"deleteFailed": "فشل الحذف",
|
||||||
"roleUpdateSuccess": "تم تحديث دور المستخدم إلى {role}",
|
"roleUpdateSuccess": "تم تحديث دور المستخدم إلى {role}",
|
||||||
"roleUpdateFailed": "فشل تحديث الدور",
|
"roleUpdateFailed": "فشل تحديث الدور",
|
||||||
"demote": "تخفيض",
|
"demote": "تخفيض إلى مستخدم",
|
||||||
"promote": "ترقية",
|
"promote": "ترقية إلى مشرف",
|
||||||
"confirmDelete": "Are you sure? This action cannot be undone.",
|
"confirmDelete": "هل أنت متأكد؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
"table": {
|
"table": {
|
||||||
"name": "الاسم",
|
"name": "الاسم",
|
||||||
"email": "البريد الإلكتروني",
|
"email": "البريد الإلكتروني",
|
||||||
@@ -1182,7 +1242,7 @@
|
|||||||
"notesViewLabel": "عرض الملاحظات",
|
"notesViewLabel": "عرض الملاحظات",
|
||||||
"notesViewTabs": "علامات تبويب (نمط OneNote)",
|
"notesViewTabs": "علامات تبويب (نمط OneNote)",
|
||||||
"notesViewMasonry": "بطاقات (شبكة)",
|
"notesViewMasonry": "بطاقات (شبكة)",
|
||||||
"selectTheme": "Select theme",
|
"selectTheme": "اختر المظهر",
|
||||||
"fontFamilyLabel": "عائلة الخطوط",
|
"fontFamilyLabel": "عائلة الخطوط",
|
||||||
"fontFamilyDescription": "اختر الخط المستخدم في جميع أنحاء التطبيق",
|
"fontFamilyDescription": "اختر الخط المستخدم في جميع أنحاء التطبيق",
|
||||||
"selectFontFamily": "Inter مُحسّن لسهولة القراءة، النظام يستخدم الخط الأصلي لنظام التشغيل",
|
"selectFontFamily": "Inter مُحسّن لسهولة القراءة، النظام يستخدم الخط الأصلي لنظام التشغيل",
|
||||||
@@ -1248,12 +1308,12 @@
|
|||||||
},
|
},
|
||||||
"diagnostics": {
|
"diagnostics": {
|
||||||
"title": "التشخيص",
|
"title": "التشخيص",
|
||||||
"description": "Check your AI provider connection status",
|
"description": "تحقق من حالة اتصال مزود الذكاء الاصطناعي",
|
||||||
"configuredProvider": "المزود المكوّن",
|
"configuredProvider": "المزود المكوّن",
|
||||||
"apiStatus": "حالة API",
|
"apiStatus": "حالة API",
|
||||||
"operational": "Operational",
|
"operational": "يعمل",
|
||||||
"errorStatus": "Error",
|
"errorStatus": "خطأ",
|
||||||
"checking": "Checking...",
|
"checking": "جاري التحقق...",
|
||||||
"testDetails": "تفاصيل الاختبار:",
|
"testDetails": "تفاصيل الاختبار:",
|
||||||
"troubleshootingTitle": "نصائح استكشاف الأخطاء:",
|
"troubleshootingTitle": "نصائح استكشاف الأخطاء:",
|
||||||
"tip1": "تأكد من تشغيل Ollama (ollama serve)",
|
"tip1": "تأكد من تشغيل Ollama (ollama serve)",
|
||||||
@@ -1376,10 +1436,10 @@
|
|||||||
"subtitle": "أتمتة مهام المراقبة والبحث الخاصة بك",
|
"subtitle": "أتمتة مهام المراقبة والبحث الخاصة بك",
|
||||||
"newAgent": "وكيل جديد",
|
"newAgent": "وكيل جديد",
|
||||||
"myAgents": "وكلائي",
|
"myAgents": "وكلائي",
|
||||||
"searchPlaceholder": "Search agents...",
|
"searchPlaceholder": "البحث عن وكلاء...",
|
||||||
"filterAll": "All",
|
"filterAll": "الكل",
|
||||||
"newBadge": "New",
|
"newBadge": "جديد",
|
||||||
"noResults": "No agents match your search.",
|
"noResults": "لا يوجد وكلاء يطابقون بحثك.",
|
||||||
"noAgents": "لا يوجد وكلاء",
|
"noAgents": "لا يوجد وكلاء",
|
||||||
"noAgentsDescription": "أنشئ أول وكيل لك أو ثبّت قالبًا أدناه لأتمتة مهام المراقبة.",
|
"noAgentsDescription": "أنشئ أول وكيل لك أو ثبّت قالبًا أدناه لأتمتة مهام المراقبة.",
|
||||||
"types": {
|
"types": {
|
||||||
@@ -1423,8 +1483,8 @@
|
|||||||
"researchTopicPlaceholder": "مثال: أحدث التطورات في الذكاء الاصطناعي",
|
"researchTopicPlaceholder": "مثال: أحدث التطورات في الذكاء الاصطناعي",
|
||||||
"notifyEmail": "إشعار بالبريد الإلكتروني",
|
"notifyEmail": "إشعار بالبريد الإلكتروني",
|
||||||
"notifyEmailHint": "استلام بريد إلكتروني بنتائج الوكيل بعد كل تشغيل",
|
"notifyEmailHint": "استلام بريد إلكتروني بنتائج الوكيل بعد كل تشغيل",
|
||||||
"includeImages": "Include images",
|
"includeImages": "تضمين الصور",
|
||||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
|
"includeImagesHint": "استخراج الصور من الصفحات المجمعة وإرفاقها بالملاحظة المولدة"
|
||||||
},
|
},
|
||||||
"frequencies": {
|
"frequencies": {
|
||||||
"manual": "يدوي",
|
"manual": "يدوي",
|
||||||
@@ -1434,19 +1494,19 @@
|
|||||||
"monthly": "شهري"
|
"monthly": "شهري"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"nextRun": "Next run",
|
"nextRun": "التنفيذ التالي",
|
||||||
"pending": "Pending trigger",
|
"pending": "في انتظار التشغيل",
|
||||||
"time": "Time",
|
"time": "الوقت",
|
||||||
"dayOfWeek": "Day of week",
|
"dayOfWeek": "يوم الأسبوع",
|
||||||
"dayOfMonth": "Day of month",
|
"dayOfMonth": "يوم الشهر",
|
||||||
"days": {
|
"days": {
|
||||||
"mon": "Monday",
|
"mon": "الاثنين",
|
||||||
"tue": "Tuesday",
|
"tue": "الثلاثاء",
|
||||||
"wed": "Wednesday",
|
"wed": "الأربعاء",
|
||||||
"thu": "Thursday",
|
"thu": "الخميس",
|
||||||
"fri": "Friday",
|
"fri": "الجمعة",
|
||||||
"sat": "Saturday",
|
"sat": "السبت",
|
||||||
"sun": "Sunday"
|
"sun": "الأحد"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -1478,8 +1538,8 @@
|
|||||||
"installSuccess": "تم تثبيت \"{name}\"",
|
"installSuccess": "تم تثبيت \"{name}\"",
|
||||||
"installError": "خطأ أثناء التثبيت",
|
"installError": "خطأ أثناء التثبيت",
|
||||||
"saveError": "خطأ في الحفظ",
|
"saveError": "خطأ في الحفظ",
|
||||||
"autoRunSuccess": "Agent \"{name}\" executed automatically with success",
|
"autoRunSuccess": "تم تنفيذ الوكيل \"{name}\" تلقائياً بنجاح",
|
||||||
"autoRunError": "Agent \"{name}\" failed during automatic execution"
|
"autoRunError": "فشل الوكيل \"{name}\" أثناء التنفيذ التلقائي"
|
||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
"title": "القوالب",
|
"title": "القوالب",
|
||||||
@@ -1593,7 +1653,7 @@
|
|||||||
"searching": "جاري البحث...",
|
"searching": "جاري البحث...",
|
||||||
"noNotesFoundForContext": "لم يتم العثور على ملاحظات ذات صلة لهذا السؤال. أجب باستخدام معرفتك العامة.",
|
"noNotesFoundForContext": "لم يتم العثور على ملاحظات ذات صلة لهذا السؤال. أجب باستخدام معرفتك العامة.",
|
||||||
"webSearch": "بحث الويب",
|
"webSearch": "بحث الويب",
|
||||||
"timeoutWarning": "Response is taking longer than expected..."
|
"timeoutWarning": "الاستجابة تستغرق وقتاً أطول من المتوقع..."
|
||||||
},
|
},
|
||||||
"labHeader": {
|
"labHeader": {
|
||||||
"title": "المختبر",
|
"title": "المختبر",
|
||||||
@@ -1611,10 +1671,81 @@
|
|||||||
"deleteSpace": "حذف المساحة",
|
"deleteSpace": "حذف المساحة",
|
||||||
"deleted": "تم حذف المساحة",
|
"deleted": "تم حذف المساحة",
|
||||||
"deleteError": "خطأ في الحذف",
|
"deleteError": "خطأ في الحذف",
|
||||||
"rename": "Rename"
|
"rename": "إعادة تسمية"
|
||||||
},
|
},
|
||||||
"lab": {
|
"lab": {
|
||||||
"initializing": "تهيئة المساحة",
|
"initializing": "تهيئة المساحة",
|
||||||
"loadingIdeas": "جاري تحميل أفكارك..."
|
"loadingIdeas": "جاري تحميل أفكارك..."
|
||||||
|
},
|
||||||
|
"richTextEditor": {
|
||||||
|
"slashHint": "↑↓ تنقل · Enter إدراج · Tab تبديل القسم",
|
||||||
|
"slashLoading": "الذكاء الاصطناعي يفكر...",
|
||||||
|
"slashTabAll": "الكل",
|
||||||
|
"slashCatBasic": "كتل أساسية",
|
||||||
|
"slashCatMedia": "وسائط",
|
||||||
|
"slashCatFormatting": "تنسيق",
|
||||||
|
"slashCatAi": "ملاحظة ذكاء اصطناعي",
|
||||||
|
"insertImage": "إدراج صورة",
|
||||||
|
"imageUrlPlaceholder": "https://example.com/image.png",
|
||||||
|
"preview": "معاينة",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"insert": "إدراج",
|
||||||
|
"slashText": "نص",
|
||||||
|
"slashTextDesc": "فقرة بسيطة",
|
||||||
|
"slashH1": "عنوان 1",
|
||||||
|
"slashH1Desc": "عنوان قسم كبير",
|
||||||
|
"slashH2": "عنوان 2",
|
||||||
|
"slashH2Desc": "عنوان قسم متوسط",
|
||||||
|
"slashH3": "عنوان 3",
|
||||||
|
"slashH3Desc": "عنوان قسم صغير",
|
||||||
|
"slashBullet": "قائمة نقطية",
|
||||||
|
"slashBulletDesc": "قائمة غير مرتبة",
|
||||||
|
"slashNumbered": "قائمة مرقمة",
|
||||||
|
"slashNumberedDesc": "قائمة مرقمة مرتبة",
|
||||||
|
"slashTodo": "قائمة مهام",
|
||||||
|
"slashTodoDesc": "مهام ب مربع اختيار",
|
||||||
|
"slashQuote": "اقتباس",
|
||||||
|
"slashQuoteDesc": "التقاط اقتباس",
|
||||||
|
"slashCode": "كتلة كود",
|
||||||
|
"slashCodeDesc": "مقتطف كود",
|
||||||
|
"slashDivider": "فاصل",
|
||||||
|
"slashDividerDesc": "فاصل أفقي",
|
||||||
|
"slashImage": "صورة",
|
||||||
|
"slashImageDesc": "تضمين صورة من رابط",
|
||||||
|
"slashAlignLeft": "محاذاة لليسار",
|
||||||
|
"slashAlignLeftDesc": "محاذاة النص لليسار",
|
||||||
|
"slashAlignCenter": "توسيط",
|
||||||
|
"slashAlignCenterDesc": "توسيط النص",
|
||||||
|
"slashAlignRight": "محاذاة لليمين",
|
||||||
|
"slashAlignRightDesc": "محاذاة النص لليمين",
|
||||||
|
"slashSuperscript": "نص مرتفع",
|
||||||
|
"slashSuperscriptDesc": "نص فوق خط الأساس",
|
||||||
|
"slashSubscript": "نص منخفض",
|
||||||
|
"slashSubscriptDesc": "نص تحت خط الأساس",
|
||||||
|
"slashClarify": "توضيح",
|
||||||
|
"slashClarifyDesc": "جعل النص أوضح",
|
||||||
|
"slashShorten": "اختصار",
|
||||||
|
"slashShortenDesc": "تكثيف النص",
|
||||||
|
"slashImprove": "تحسين",
|
||||||
|
"slashImproveDesc": "تحسين الأسلوب",
|
||||||
|
"slashExpand": "توسيع",
|
||||||
|
"slashExpandDesc": "توسيع وإثراء النص",
|
||||||
|
"imageModalTitle": "إدراج صورة",
|
||||||
|
"imageModalPreview": "معاينة",
|
||||||
|
"imageModalCancel": "إلغاء",
|
||||||
|
"imageModalInsert": "إدراج",
|
||||||
|
"imageModalInvalidUrl": "الرجاء إدخال رابط صالح",
|
||||||
|
"imageModalLoadFailed": "فشل تحميل الصورة",
|
||||||
|
"linkPlaceholder": "الصق أو اكتب رابطاً...",
|
||||||
|
"bold": "عريض",
|
||||||
|
"italic": "مائل",
|
||||||
|
"underline": "تسطير",
|
||||||
|
"strike": "يتوسطه خط",
|
||||||
|
"code": "كود",
|
||||||
|
"highlight": "تمييز",
|
||||||
|
"superscript": "نص مرتفع",
|
||||||
|
"subscript": "نص منخفض",
|
||||||
|
"addBlock": "إضافة كتلة",
|
||||||
|
"placeholder": "اكتب '/' للأوامر..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,7 +429,10 @@
|
|||||||
"shorten": "خلاصه کردن",
|
"shorten": "خلاصه کردن",
|
||||||
"improve": "بهبود",
|
"improve": "بهبود",
|
||||||
"toMarkdown": "به مارکداون",
|
"toMarkdown": "به مارکداون",
|
||||||
"describeImages": "توصیف تصاویر"
|
"describeImages": "توصیف تصاویر",
|
||||||
|
"fixGrammar": "اصلاح گرامر",
|
||||||
|
"translate": "ترجمه",
|
||||||
|
"explain": "توضیح"
|
||||||
},
|
},
|
||||||
"openAssistant": "باز کردن دستیار هوش مصنوعی",
|
"openAssistant": "باز کردن دستیار هوش مصنوعی",
|
||||||
"poweredByMomento": "پشتیبانی شده توسط Momento AI",
|
"poweredByMomento": "پشتیبانی شده توسط Momento AI",
|
||||||
|
|||||||
38
memento-note/package-lock.json
generated
38
memento-note/package-lock.json
generated
@@ -55,6 +55,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"diff": "^9.0.0",
|
"diff": "^9.0.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"isomorphic-dompurify": "^3.12.0",
|
||||||
"jsdom": "^29.0.2",
|
"jsdom": "^29.0.2",
|
||||||
"katex": "^0.16.27",
|
"katex": "^0.16.27",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
@@ -8769,9 +8770,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.0",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
||||||
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
|
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
@@ -9544,6 +9545,19 @@
|
|||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/isomorphic-dompurify": {
|
||||||
|
"version": "3.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.12.0.tgz",
|
||||||
|
"integrity": "sha512-8n+j+6ypTHvriJwFOQ2qusQ6bzGjZVcR3jbe1pBpLcGI1dn4WIl0ctLBngqE5QttquQBAlKXwJeTMw+X7x7qKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dompurify": "^3.4.2",
|
||||||
|
"jsdom": "^29.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/istanbul-lib-coverage": {
|
"node_modules/istanbul-lib-coverage": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
@@ -9641,27 +9655,27 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
"version": "29.0.2",
|
"version": "29.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||||
"integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
|
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/css-color": "^5.1.5",
|
"@asamuzakjp/css-color": "^5.1.11",
|
||||||
"@asamuzakjp/dom-selector": "^7.0.6",
|
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||||
"@bramus/specificity": "^2.4.2",
|
"@bramus/specificity": "^2.4.2",
|
||||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
|
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||||
"@exodus/bytes": "^1.15.0",
|
"@exodus/bytes": "^1.15.0",
|
||||||
"css-tree": "^3.2.1",
|
"css-tree": "^3.2.1",
|
||||||
"data-urls": "^7.0.0",
|
"data-urls": "^7.0.0",
|
||||||
"decimal.js": "^10.6.0",
|
"decimal.js": "^10.6.0",
|
||||||
"html-encoding-sniffer": "^6.0.0",
|
"html-encoding-sniffer": "^6.0.0",
|
||||||
"is-potential-custom-element-name": "^1.0.1",
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
"lru-cache": "^11.2.7",
|
"lru-cache": "^11.3.5",
|
||||||
"parse5": "^8.0.0",
|
"parse5": "^8.0.1",
|
||||||
"saxes": "^6.0.0",
|
"saxes": "^6.0.0",
|
||||||
"symbol-tree": "^3.2.4",
|
"symbol-tree": "^3.2.4",
|
||||||
"tough-cookie": "^6.0.1",
|
"tough-cookie": "^6.0.1",
|
||||||
"undici": "^7.24.5",
|
"undici": "^7.25.0",
|
||||||
"w3c-xmlserializer": "^5.0.0",
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
"webidl-conversions": "^8.0.1",
|
"webidl-conversions": "^8.0.1",
|
||||||
"whatwg-mimetype": "^5.0.0",
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"diff": "^9.0.0",
|
"diff": "^9.0.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"isomorphic-dompurify": "^3.12.0",
|
||||||
"jsdom": "^29.0.2",
|
"jsdom": "^29.0.2",
|
||||||
"katex": "^0.16.27",
|
"katex": "^0.16.27",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const filePath = path.join(__dirname, '..', 'components', 'contextual-ai-chat.tsx');
|
|
||||||
let src = fs.readFileSync(filePath, 'utf8');
|
|
||||||
|
|
||||||
// ─── FIX 1: make inject buttons always visible on last assistant msg ──────────
|
|
||||||
// Replace the hover-gated condition with always-visible for last msg
|
|
||||||
const OLD_BUTTONS_CONDITION = ` {/* Hover-actions \u2014 visible only on assistant messages */}\r\n {isAssistant && onApplyToNote && (\r\n <div className={cn(\r\n 'flex gap-1 transition-all duration-150',\r\n isHovered ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-1 pointer-events-none',\r\n )}>`;
|
|
||||||
|
|
||||||
const NEW_BUTTONS_CONDITION = ` {/* Inject buttons \u2014 always visible on last assistant msg */}\r\n {isAssistant && onApplyToNote && (() => {\r\n const lastAssistantId = messages.filter(m => m.role === 'assistant').at(-1)?.id\r\n const alwaysShow = msg.id === lastAssistantId\r\n return (\r\n <div className={cn(\r\n 'flex gap-1 transition-all duration-150',\r\n (alwaysShow || isHovered) ? 'opacity-100' : 'opacity-0 pointer-events-none',\r\n )}>`;
|
|
||||||
|
|
||||||
// Also need to close the IIFE
|
|
||||||
const OLD_BUTTONS_CLOSE = ` </div>\r\n )}\r\n </div>`;
|
|
||||||
const NEW_BUTTONS_CLOSE = ` </div>\r\n )\r\n })()}\r\n </div>`;
|
|
||||||
|
|
||||||
if (src.includes(OLD_BUTTONS_CONDITION)) {
|
|
||||||
src = src.replace(OLD_BUTTONS_CONDITION, NEW_BUTTONS_CONDITION);
|
|
||||||
console.log('Fix 1a: buttons condition updated');
|
|
||||||
} else {
|
|
||||||
console.error('Fix 1a: OLD_BUTTONS_CONDITION not found!');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (src.includes(OLD_BUTTONS_CLOSE)) {
|
|
||||||
src = src.replace(OLD_BUTTONS_CLOSE, NEW_BUTTONS_CLOSE);
|
|
||||||
console.log('Fix 1b: buttons IIFE close updated');
|
|
||||||
} else {
|
|
||||||
console.error('Fix 1b: OLD_BUTTONS_CLOSE not found!');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── FIX 2: Move preview panel OUTSIDE the scrollable div ────────────────────
|
|
||||||
// The scrollable div ends with: messagesEndRef then </div>
|
|
||||||
// We need to:
|
|
||||||
// A) Remove the preview block from inside the scrollable area (already was removed in previous sessions since it was added after messagesEndRef)
|
|
||||||
// B) Add preview as shrink-0 panel between the scroll div and controls
|
|
||||||
|
|
||||||
// Find the closing of the scrollable area + start of controls
|
|
||||||
const OLD_SCROLL_END = ` <div ref={messagesEndRef} />\r\n </div>\r\n\r\n {/* Scope & Tone Control Area */}`;
|
|
||||||
const NEW_SCROLL_END = ` <div ref={messagesEndRef} />\r\n </div>\r\n\r\n {/* \u2550\u2550 Inject Preview Panel \u2014 fixed, always visible, NO scroll needed \u2550\u2550 */}\r\n {resourceEnriching && (\r\n <div className="shrink-0 mx-3 mb-2 flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-50/50 dark:bg-emerald-950/20 p-3">\r\n <Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-600 shrink-0" />\r\n <span className="text-xs text-emerald-700 dark:text-emerald-400">Traitement IA en cours...</span>\r\n </div>\r\n )}\r\n {resourcePreview && !resourceEnriching && (\r\n <div className="shrink-0 mx-3 mb-2 rounded-xl border border-primary/30 bg-primary/5 overflow-hidden">\r\n <div className="flex items-center justify-between px-3 py-2 border-b border-primary/20">\r\n <span className="text-[10px] font-semibold uppercase tracking-wider text-primary">\r\n {resourcePreview.source === 'complete' ? 'Compl\u00e9t\u00e9 par IA'\r\n : resourcePreview.source === 'merge' ? 'Fusionn\u00e9 par IA'\r\n : 'Aper\u00e7u'}\r\n </span>\r\n <button onClick={() => setResourcePreview(null)} className="text-muted-foreground hover:text-foreground">\r\n <X className="h-3 w-3" />\r\n </button>\r\n </div>\r\n <div className="px-3 py-2 max-h-36 overflow-y-auto text-sm">\r\n <MarkdownContent content={resourcePreview.text} />\r\n </div>\r\n <div className="flex gap-2 px-3 py-2 border-t border-primary/20">\r\n <button\r\n onClick={() => setResourcePreview(null)}\r\n className="flex-1 text-[11px] py-1.5 rounded-lg border border-border/60 text-muted-foreground hover:bg-muted transition-colors"\r\n >\r\n Annuler\r\n </button>\r\n <button\r\n onClick={() => {\r\n if (onApplyToNote && resourcePreview) {\r\n onApplyToNote(resourcePreview.text)\r\n setResourcePreview(null)\r\n setResourceText('')\r\n toast.success('Appliqu\u00e9 \u00e0 la note')\r\n }\r\n }}\r\n disabled={!onApplyToNote}\r\n className="flex-1 text-[11px] py-1.5 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 flex items-center justify-center gap-1"\r\n >\r\n <Check className="h-3 w-3" />\r\n Appliquer \u00e0 la note\r\n </button>\r\n </div>\r\n </div>\r\n )}\r\n\r\n {/* Scope & Tone Control Area */}`;
|
|
||||||
|
|
||||||
if (src.includes(OLD_SCROLL_END)) {
|
|
||||||
src = src.replace(OLD_SCROLL_END, NEW_SCROLL_END);
|
|
||||||
console.log('Fix 2: preview panel moved outside scroll');
|
|
||||||
} else {
|
|
||||||
console.error('Fix 2: OLD_SCROLL_END not found!');
|
|
||||||
// Show what we're looking for context
|
|
||||||
const idx = src.indexOf('messagesEndRef');
|
|
||||||
console.error('Context around messagesEndRef:', JSON.stringify(src.slice(idx-20, idx+200)));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(filePath, src);
|
|
||||||
console.log('Done!');
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const filePath = path.join(__dirname, '..', 'components', 'contextual-ai-chat.tsx');
|
|
||||||
let src = fs.readFileSync(filePath, 'utf8');
|
|
||||||
|
|
||||||
// Sync presets buttons
|
|
||||||
const OLD_PRESET_CLICK = `onClick={() => setTranslateTarget(l)}`;
|
|
||||||
const NEW_PRESET_CLICK = `onClick={() => { setTranslateTarget(l); setCustomLangInput(l); }}`;
|
|
||||||
|
|
||||||
if (src.includes(OLD_PRESET_CLICK)) {
|
|
||||||
src = src.replace(OLD_PRESET_CLICK, NEW_PRESET_CLICK);
|
|
||||||
console.log('Fix 1: Preset buttons sync updated');
|
|
||||||
} else {
|
|
||||||
console.error('Fix 1: OLD_PRESET_CLICK not found');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync input and enable live update
|
|
||||||
const OLD_INPUT_LOGIC = `onChange={e => setCustomLangInput(e.target.value)}\r\n onKeyDown={e => { if (e.key === 'Enter' && customLangInput.trim()) { setTranslateTarget(customLangInput.trim()); setCustomLangInput('') } }}`;
|
|
||||||
// Fallback for different line endings if needed, but let's try matching a smaller part first
|
|
||||||
const OLD_ONCHANGE = `onChange={e => setCustomLangInput(e.target.value)}`;
|
|
||||||
const NEW_ONCHANGE = `onChange={e => { const val = e.target.value; setCustomLangInput(val); setTranslateTarget(val); }}`;
|
|
||||||
|
|
||||||
const OLD_ONKEYDOWN = `onKeyDown={e => { if (e.key === 'Enter' && customLangInput.trim()) { setTranslateTarget(customLangInput.trim()); setCustomLangInput('') } }}`;
|
|
||||||
const NEW_ONKEYDOWN = `onKeyDown={e => { if (e.key === 'Enter' && translateTarget.trim()) { handleAction(action, translateTarget.trim()); } }}`;
|
|
||||||
|
|
||||||
if (src.includes(OLD_ONCHANGE)) {
|
|
||||||
src = src.replace(OLD_ONCHANGE, NEW_ONCHANGE);
|
|
||||||
console.log('Fix 2: Input onChange updated');
|
|
||||||
} else {
|
|
||||||
console.error('Fix 2: OLD_ONCHANGE not found');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (src.includes(OLD_ONKEYDOWN)) {
|
|
||||||
src = src.replace(OLD_ONKEYDOWN, NEW_ONKEYDOWN);
|
|
||||||
console.log('Fix 3: Input onKeyDown updated');
|
|
||||||
} else {
|
|
||||||
console.error('Fix 3: OLD_ONKEYDOWN not found');
|
|
||||||
// Try without \r if it failed
|
|
||||||
const altOnKeyDown = OLD_ONKEYDOWN.replace('\r\n', '\n');
|
|
||||||
if (src.includes(altOnKeyDown)) {
|
|
||||||
src = src.replace(altOnKeyDown, NEW_ONKEYDOWN);
|
|
||||||
console.log('Fix 3: Input onKeyDown updated (alt line ending)');
|
|
||||||
} else {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(filePath, src);
|
|
||||||
console.log('Done!');
|
|
||||||
Reference in New Issue
Block a user