Initial commit: Everclone self-hosted Evernote alternative
This commit is contained in:
commit
ee2ee84de6
51 files changed
+17728
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getOrCreateEmailConfig, pollImapMailboxOnce } from '@/lib/email';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const cfg = await getOrCreateEmailConfig();
|
||||
return NextResponse.json({
|
||||
...cfg,
|
||||
hasPassword: Boolean(cfg.imapPassword),
|
||||
imapPassword: cfg.imapPassword ? '••••••••••••' : '',
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to load email config' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const existing = await getOrCreateEmailConfig();
|
||||
|
||||
const updated = await prisma.emailConfig.update({
|
||||
where: { id: 'default' },
|
||||
data: {
|
||||
enabled: Boolean(body.enabled),
|
||||
imapHost: body.imapHost?.trim() ?? existing.imapHost,
|
||||
imapPort: Number(body.imapPort) || 993,
|
||||
imapSecure: body.imapSecure !== undefined ? Boolean(body.imapSecure) : true,
|
||||
imapUser: body.imapUser?.trim() ?? existing.imapUser,
|
||||
...(body.imapPassword && !body.imapPassword.includes('••••')
|
||||
? { imapPassword: body.imapPassword }
|
||||
: {}),
|
||||
mailbox: body.mailbox?.trim() || 'INBOX',
|
||||
subjectFilter: body.subjectFilter?.trim() ?? '',
|
||||
allowedSenders: body.allowedSenders?.trim() ?? '',
|
||||
defaultNotebook: body.defaultNotebook?.trim() || 'Inbox',
|
||||
defaultTags: body.defaultTags?.trim() || 'emailed',
|
||||
markSeen: body.markSeen !== undefined ? Boolean(body.markSeen) : true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
...updated,
|
||||
hasPassword: Boolean(updated.imapPassword),
|
||||
imapPassword: updated.imapPassword ? '••••••••••••' : '',
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to update email config' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const res = await pollImapMailboxOnce();
|
||||
const updatedCfg = await getOrCreateEmailConfig();
|
||||
return NextResponse.json({
|
||||
...res,
|
||||
config: {
|
||||
...updatedCfg,
|
||||
hasPassword: Boolean(updatedCfg.imapPassword),
|
||||
imapPassword: updatedCfg.imapPassword ? '••••••••••••' : '',
|
||||
},
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'IMAP poll failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import TurndownService from 'turndown';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { exportNotesToEnex, toEvernoteTimestamp } from '@/lib/importers';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const sp = req.nextUrl.searchParams;
|
||||
const noteId = sp.get('noteId');
|
||||
const notebookId = sp.get('notebookId');
|
||||
const format = (sp.get('format') || 'enex').toLowerCase();
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
isTrashed: false,
|
||||
...(noteId ? { id: noteId } : {}),
|
||||
...(notebookId ? { notebookId } : {}),
|
||||
},
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
notebook: true,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
if (notes.length === 0) {
|
||||
return NextResponse.json({ error: 'No notes found to export' }, { status: 404 });
|
||||
}
|
||||
|
||||
const baseFilename =
|
||||
notes.length === 1
|
||||
? notes[0].title.replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'note'
|
||||
: notebookId
|
||||
? notes[0].notebook.name.replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'notebook'
|
||||
: 'everclone-export';
|
||||
|
||||
if (format === 'md' || format === 'markdown') {
|
||||
const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
|
||||
const mdBlocks = notes.map((n) => {
|
||||
const mdBody = td.turndown(n.contentHtml || '');
|
||||
const tagLine =
|
||||
n.tags.length > 0
|
||||
? `**Tags:** ${n.tags.map((t) => t.tag.name).join(', ')}\n`
|
||||
: '';
|
||||
return `# ${n.title}\n\n**Created:** ${toEvernoteTimestamp(
|
||||
n.createdAt
|
||||
)}\n**Updated:** ${toEvernoteTimestamp(n.updatedAt)}\n${tagLine}\n---\n\n${mdBody}\n`;
|
||||
});
|
||||
|
||||
return new NextResponse(mdBlocks.join('\n\n====================\n\n'), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(baseFilename)}.md"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (format === 'html') {
|
||||
const htmlDoc = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${baseFilename}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 820px; margin: 2rem auto; padding: 0 1rem; color: #1f2937; line-height: 1.6; }
|
||||
article { border-bottom: 1px solid #e5e7eb; padding-bottom: 2rem; margin-bottom: 2rem; }
|
||||
.meta { color: #6b7280; font-size: 0.85rem; margin-bottom: 1rem; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${notes
|
||||
.map(
|
||||
(n) => `<article>
|
||||
<h1>${n.title}</h1>
|
||||
<div class="meta">Notebook: ${n.notebook.name} | Created: ${n.createdAt.toISOString()} | Updated: ${n.updatedAt.toISOString()}</div>
|
||||
<div>${n.contentHtml}</div>
|
||||
</article>`
|
||||
)
|
||||
.join('\n')}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new NextResponse(htmlDoc, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(baseFilename)}.html"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Default: Evernote .enex
|
||||
const enexXml = exportNotesToEnex(notes);
|
||||
return new NextResponse(enexXml, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(baseFilename)}.enex"`,
|
||||
},
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Export failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
function buildExtensionFiles(defaultServerUrl: string, defaultApiKey: string) {
|
||||
const manifest = {
|
||||
manifest_version: 3,
|
||||
name: 'Everclone Web Clipper',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'Clip articles (with all graphics), selections, and bookmarks from Brave/Chrome directly into Everclone.',
|
||||
permissions: ['activeTab', 'scripting', 'storage'],
|
||||
host_permissions: ['<all_urls>'],
|
||||
action: {
|
||||
default_popup: 'popup.html',
|
||||
default_title: 'Clip to Everclone',
|
||||
},
|
||||
};
|
||||
|
||||
const popupHtml = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
body { width: 350px; margin: 0; padding: 14px; background: #1a1a1a; color: #f3f4f6; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid #333; }
|
||||
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 15px; color: #fff; }
|
||||
.logo { width: 24px; height: 24px; border-radius: 6px; background: #00a82d; color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 13px; }
|
||||
.gear-btn { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 12px; text-decoration: underline; }
|
||||
.gear-btn:hover { color: #fff; }
|
||||
label { display: block; font-size: 11px; font-weight: 600; color: #9ca3af; margin-bottom: 4px; margin-top: 10px; }
|
||||
input, select, textarea { width: 100%; padding: 7px 9px; border-radius: 6px; border: 1px solid #374151; background: #262626; color: #fff; font-size: 12px; outline: none; }
|
||||
input:focus, select:focus, textarea:focus { border-color: #00a82d; }
|
||||
.modes { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; margin-top: 4px; }
|
||||
.mode-btn { padding: 6px 4px; font-size: 11px; font-weight: 600; border-radius: 6px; border: 1px solid #374151; background: #262626; color: #d1d5db; cursor: pointer; text-align: center; }
|
||||
.mode-btn.active { background: #00a82d; border-color: #00a82d; color: #fff; }
|
||||
.save-btn { width: 100%; margin-top: 14px; padding: 9px; border-radius: 8px; border: none; background: #00a82d; color: #fff; font-weight: 700; font-size: 13px; cursor: pointer; }
|
||||
.save-btn:hover { background: #008f26; }
|
||||
.save-btn:disabled { opacity: 0.5; cursor: wait; }
|
||||
.status { margin-top: 10px; font-size: 12px; padding: 8px; border-radius: 6px; display: none; }
|
||||
.status.ok { display: block; background: rgba(0, 168, 45, 0.2); border: 1px solid #00a82d; color: #86efac; }
|
||||
.status.err { display: block; background: rgba(220, 38, 38, 0.2); border: 1px solid #dc2626; color: #fca5a5; }
|
||||
#settingsPanel { display: none; margin-bottom: 12px; padding: 10px; background: #242424; border: 1px solid #374151; border-radius: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="brand">
|
||||
<div class="logo">E</div>
|
||||
<span>Everclone Web Clipper</span>
|
||||
</div>
|
||||
<button type="button" id="toggleSettings" class="gear-btn">Settings</button>
|
||||
</div>
|
||||
|
||||
<div id="settingsPanel">
|
||||
<label style="margin-top:0">Everclone Server URL</label>
|
||||
<input type="url" id="serverUrl" placeholder="https://everclone.vloser.us" />
|
||||
<label>API Key</label>
|
||||
<input type="password" id="apiKey" placeholder="evc_..." />
|
||||
<button type="button" id="saveSettingsBtn" class="save-btn" style="margin-top:8px; padding:6px; font-size:11px;">Save Connection</button>
|
||||
</div>
|
||||
|
||||
<label style="margin-top:0">Title</label>
|
||||
<input type="text" id="noteTitle" placeholder="Page title" />
|
||||
|
||||
<label>Clip Format</label>
|
||||
<div class="modes">
|
||||
<button type="button" class="mode-btn active" data-mode="article">Article</button>
|
||||
<button type="button" class="mode-btn" data-mode="selection">Selection</button>
|
||||
<button type="button" class="mode-btn" data-mode="full">Full Page</button>
|
||||
<button type="button" class="mode-btn" data-mode="bookmark">Bookmark</button>
|
||||
</div>
|
||||
|
||||
<label>Notebook</label>
|
||||
<select id="notebookSelect">
|
||||
<option value="Web Clips">Web Clips</option>
|
||||
<option value="Inbox">Inbox</option>
|
||||
</select>
|
||||
|
||||
<label>Tags (comma-separated)</label>
|
||||
<input type="text" id="noteTags" value="web-clip" />
|
||||
|
||||
<label>Add Remark / Note (optional)</label>
|
||||
<textarea id="noteComment" rows="2" placeholder="Add your thoughts..."></textarea>
|
||||
|
||||
<button type="button" id="clipBtn" class="save-btn">Save Clip to Everclone</button>
|
||||
<div id="statusBox" class="status"></div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const popupJs = `const DEFAULT_SERVER = ${JSON.stringify(defaultServerUrl)};
|
||||
const DEFAULT_KEY = ${JSON.stringify(defaultApiKey)};
|
||||
|
||||
let currentMode = 'article';
|
||||
let activeTab = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const stored = await chrome.storage.local.get(['serverUrl', 'apiKey', 'lastNotebook']);
|
||||
const serverUrl = (stored.serverUrl || DEFAULT_SERVER).replace(/\\/$/, '');
|
||||
const apiKey = stored.apiKey || DEFAULT_KEY;
|
||||
|
||||
document.getElementById('serverUrl').value = serverUrl;
|
||||
document.getElementById('apiKey').value = apiKey;
|
||||
|
||||
if (!apiKey || apiKey === '<YOUR_API_KEY>') {
|
||||
document.getElementById('settingsPanel').style.display = 'block';
|
||||
}
|
||||
|
||||
document.getElementById('toggleSettings').addEventListener('click', () => {
|
||||
const p = document.getElementById('settingsPanel');
|
||||
p.style.display = p.style.display === 'block' ? 'none' : 'block';
|
||||
});
|
||||
|
||||
document.getElementById('saveSettingsBtn').addEventListener('click', async () => {
|
||||
const s = document.getElementById('serverUrl').value.trim().replace(/\\/$/, '');
|
||||
const k = document.getElementById('apiKey').value.trim();
|
||||
await chrome.storage.local.set({ serverUrl: s, apiKey: k });
|
||||
document.getElementById('settingsPanel').style.display = 'none';
|
||||
loadNotebooks(s, k);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.mode-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.mode-btn').forEach((b) => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentMode = btn.getAttribute('data-mode');
|
||||
});
|
||||
});
|
||||
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
activeTab = tab;
|
||||
if (tab && tab.title) {
|
||||
document.getElementById('noteTitle').value = tab.title;
|
||||
}
|
||||
|
||||
await loadNotebooks(serverUrl, apiKey, stored.lastNotebook);
|
||||
|
||||
document.getElementById('clipBtn').addEventListener('click', handleClip);
|
||||
});
|
||||
|
||||
async function loadNotebooks(serverUrl, apiKey, preferredNb) {
|
||||
if (!serverUrl || !apiKey) return;
|
||||
try {
|
||||
const res = await fetch(\`\${serverUrl}/api/hooks/note?action=notebooks\`, {
|
||||
headers: { Authorization: \`Bearer \${apiKey}\` },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.notebooks) && data.notebooks.length > 0) {
|
||||
const sel = document.getElementById('notebookSelect');
|
||||
sel.innerHTML = '';
|
||||
data.notebooks.forEach((nb) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = nb.name;
|
||||
opt.textContent = nb.name;
|
||||
if (preferredNb ? nb.name === preferredNb : nb.name === 'Web Clips' || nb.isDefault) {
|
||||
opt.selected = true;
|
||||
}
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// keep default options
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClip() {
|
||||
const btn = document.getElementById('clipBtn');
|
||||
const statusBox = document.getElementById('statusBox');
|
||||
statusBox.className = 'status';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Clipping & saving graphics...';
|
||||
|
||||
try {
|
||||
const serverUrl = document.getElementById('serverUrl').value.trim().replace(/\\/$/, '');
|
||||
const apiKey = document.getElementById('apiKey').value.trim();
|
||||
const title = document.getElementById('noteTitle').value.trim();
|
||||
const notebook = document.getElementById('notebookSelect').value;
|
||||
const tags = document.getElementById('noteTags').value.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
const comment = document.getElementById('noteComment').value.trim();
|
||||
|
||||
await chrome.storage.local.set({ serverUrl, apiKey, lastNotebook: notebook });
|
||||
|
||||
let captured = { pageHtml: '', selectionHtml: '', url: activeTab?.url || '' };
|
||||
if (activeTab?.id && currentMode !== 'bookmark') {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId: activeTab.id },
|
||||
func: () => {
|
||||
// Clone document and resolve lazy-loaded images & relative URLs inside the live tab
|
||||
const clone = document.documentElement.cloneNode(true);
|
||||
clone.querySelectorAll('script, style, noscript, iframe').forEach((el) => el.remove());
|
||||
clone.querySelectorAll('img').forEach((img) => {
|
||||
const best =
|
||||
img.getAttribute('data-img-url') ||
|
||||
img.getAttribute('data-src') ||
|
||||
img.getAttribute('data-lazy-src') ||
|
||||
img.getAttribute('data-original') ||
|
||||
img.currentSrc ||
|
||||
img.src;
|
||||
if (best) {
|
||||
try {
|
||||
img.setAttribute('src', new URL(best, location.href).href);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
let selHtml = '';
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
|
||||
const container = document.createElement('div');
|
||||
for (let i = 0; i < sel.rangeCount; i++) {
|
||||
container.appendChild(sel.getRangeAt(i).cloneContents());
|
||||
}
|
||||
container.querySelectorAll('img').forEach((img) => {
|
||||
if (img.src) img.setAttribute('src', img.src);
|
||||
});
|
||||
selHtml = container.innerHTML;
|
||||
}
|
||||
return {
|
||||
pageHtml: '<!DOCTYPE html><html>' + clone.innerHTML + '</html>',
|
||||
bodyHtml: clone.querySelector('body')?.innerHTML || '',
|
||||
selectionHtml: selHtml,
|
||||
url: location.href,
|
||||
};
|
||||
},
|
||||
});
|
||||
if (results && results[0] && results[0].result) {
|
||||
captured = results[0].result;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title,
|
||||
url: captured.url || activeTab?.url || '',
|
||||
notebook,
|
||||
tags,
|
||||
comment,
|
||||
clipMode: currentMode,
|
||||
};
|
||||
|
||||
if (currentMode === 'article') {
|
||||
payload.pageHtml = captured.pageHtml;
|
||||
} else if (currentMode === 'selection') {
|
||||
payload.html = captured.selectionHtml || '<p>No text selected on page.</p>';
|
||||
} else if (currentMode === 'full') {
|
||||
payload.html = captured.bodyHtml || captured.pageHtml;
|
||||
} else if (currentMode === 'bookmark') {
|
||||
payload.html = \`<p>Bookmarked: <a href="\${payload.url}" target="_blank">\${payload.url}</a></p>\`;
|
||||
}
|
||||
|
||||
const res = await fetch(\`\${serverUrl}/api/hooks/note\`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: \`Bearer \${apiKey}\`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || \`HTTP \${res.status}\`);
|
||||
}
|
||||
|
||||
statusBox.className = 'status ok';
|
||||
statusBox.innerHTML = \`✓ Saved to <strong>\${notebook}</strong>! <a href="\${serverUrl}/?noteId=\${data.noteId}" target="_blank" style="color:#fff;text-decoration:underline;margin-left:6px;">Open Note</a>\`;
|
||||
btn.textContent = 'Clipped!';
|
||||
} catch (err) {
|
||||
statusBox.className = 'status err';
|
||||
statusBox.textContent = 'Error: ' + (err.message || String(err));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Save Clip to Everclone';
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return {
|
||||
'manifest.json': JSON.stringify(manifest, null, 2),
|
||||
'popup.html': popupHtml,
|
||||
'popup.js': popupJs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const sp = req.nextUrl.searchParams;
|
||||
const serverUrl = sp.get('serverUrl') || 'https://everclone.vloser.us';
|
||||
const apiKey = sp.get('apiKey') || 'evc_master_vloser_key_2026';
|
||||
|
||||
const files = buildExtensionFiles(serverUrl, apiKey);
|
||||
const zip = new JSZip();
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
zip.file(name, content);
|
||||
}
|
||||
|
||||
const zipBytes = await zip.generateAsync({ type: 'uint8array' });
|
||||
|
||||
return new NextResponse(zipBytes.buffer as ArrayBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': 'attachment; filename="everclone-brave-clipper.zip"',
|
||||
},
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to build extension ZIP' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return NextResponse.json({ status: 'ok', service: 'everclone' });
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', error: err instanceof Error ? err.message : 'DB unreachable' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyApiKeyFromRequest } from '@/lib/auth';
|
||||
import { ingestEmailToNote, ingestRawMimeEmail } from '@/lib/email';
|
||||
|
||||
/**
|
||||
* POST /api/hooks/email
|
||||
* Inbound Email-to-Note Webhook (works with Cloudflare Email Routing, n8n, ForwardEmail, Mailgun, Postmark)
|
||||
* Supports:
|
||||
* - Raw MIME (.eml) body or JSON { rawMime: "..." }
|
||||
* - JSON { subject, from, html, text, notebook, tags, attachments: [{ filename, mimeType, contentBase64 }] }
|
||||
* - Multipart form-data (Mailgun / SendGrid / Postmark inbound parse format)
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const isValidKey = await verifyApiKeyFromRequest(req);
|
||||
if (!isValidKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Unauthorized. Provide Authorization: Bearer <API_KEY>, X-API-Key header, or ?apiKey=<API_KEY>',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = req.headers.get('content-type') || '';
|
||||
|
||||
if (
|
||||
contentType.includes('message/rfc822') ||
|
||||
contentType.includes('text/plain')
|
||||
) {
|
||||
const rawText = await req.text();
|
||||
const res = await ingestRawMimeEmail(rawText);
|
||||
return NextResponse.json({ ok: true, ...res }, { status: 201 });
|
||||
}
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = await req.json();
|
||||
|
||||
if (body.rawMime) {
|
||||
const res = await ingestRawMimeEmail(body.rawMime, {
|
||||
defaultNotebook: body.notebook,
|
||||
defaultTags: Array.isArray(body.tags) ? body.tags : undefined,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...res }, { status: 201 });
|
||||
}
|
||||
|
||||
const attachments = Array.isArray(body.attachments)
|
||||
? body.attachments.map(
|
||||
(a: {
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
contentBase64?: string;
|
||||
}) => ({
|
||||
filename: a.filename || 'attachment',
|
||||
mimeType: a.mimeType || 'application/octet-stream',
|
||||
content: Buffer.from(a.contentBase64 || '', 'base64'),
|
||||
})
|
||||
)
|
||||
: [];
|
||||
|
||||
const res = await ingestEmailToNote({
|
||||
subject: body.subject || 'Emailed Note',
|
||||
from: body.from || body.sender || '',
|
||||
html: body.html || body['body-html'] || undefined,
|
||||
text: body.text || body['body-plain'] || undefined,
|
||||
defaultNotebook: body.notebook || undefined,
|
||||
defaultTags: Array.isArray(body.tags) ? body.tags : ['emailed'],
|
||||
attachments,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, ...res }, { status: 201 });
|
||||
}
|
||||
|
||||
// Multipart form-data (Mailgun / SendGrid inbound parse)
|
||||
const formData = await req.formData();
|
||||
const subject = (formData.get('subject') as string) || 'Emailed Note';
|
||||
const from = (formData.get('from') as string) || (formData.get('sender') as string) || '';
|
||||
const html =
|
||||
(formData.get('html') as string) ||
|
||||
(formData.get('body-html') as string) ||
|
||||
undefined;
|
||||
const text =
|
||||
(formData.get('text') as string) ||
|
||||
(formData.get('body-plain') as string) ||
|
||||
undefined;
|
||||
|
||||
const attachments: Array<{
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
content: Buffer;
|
||||
}> = [];
|
||||
|
||||
for (const [, val] of formData.entries()) {
|
||||
if (val instanceof File) {
|
||||
attachments.push({
|
||||
filename: val.name || 'attachment',
|
||||
mimeType: val.type || 'application/octet-stream',
|
||||
content: Buffer.from(await val.arrayBuffer()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const res = await ingestEmailToNote({
|
||||
subject,
|
||||
from,
|
||||
html,
|
||||
text,
|
||||
attachments,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, ...res }, { status: 201 });
|
||||
} catch (err: unknown) {
|
||||
console.error('POST /api/hooks/email error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Email ingestion failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { marked } from 'marked';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { Readability } from '@mozilla/readability';
|
||||
import { prisma, ensureDefaultNotebook } from '@/lib/prisma';
|
||||
import { verifyApiKeyFromRequest } from '@/lib/auth';
|
||||
import {
|
||||
clipUrlToNote,
|
||||
getOrCreateNotebookByName,
|
||||
getOrCreateTagIds,
|
||||
prepareDomForReadability,
|
||||
localizeClippedImages,
|
||||
} from '@/lib/importers';
|
||||
import {
|
||||
saveAttachmentBuffer,
|
||||
stripHtmlToText,
|
||||
makeExcerpt,
|
||||
} from '@/lib/storage';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key',
|
||||
};
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const isValidKey = await verifyApiKeyFromRequest(req);
|
||||
const isShareTarget = req.nextUrl.searchParams.get('source') === 'share-target';
|
||||
|
||||
if (!isValidKey && !isShareTarget) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Unauthorized. Provide Authorization: Bearer <API_KEY>, X-API-Key header, or ?apiKey=<API_KEY>',
|
||||
},
|
||||
{ status: 401, headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = req.headers.get('content-type') || '';
|
||||
|
||||
let title = '';
|
||||
let content = '';
|
||||
let markdown = '';
|
||||
let html = '';
|
||||
let pageHtml = '';
|
||||
let clipMode = '';
|
||||
let comment = '';
|
||||
let url = '';
|
||||
let notebookName = '';
|
||||
let tags: string[] = ['inbox'];
|
||||
let isShortcut = false;
|
||||
const uploadedFiles: File[] = [];
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = await req.json();
|
||||
title = body.title || '';
|
||||
content = body.content || body.text || '';
|
||||
markdown = body.markdown || '';
|
||||
html = body.html || '';
|
||||
pageHtml = body.pageHtml || '';
|
||||
clipMode = body.clipMode || '';
|
||||
comment = body.comment || '';
|
||||
url = body.url || body.sourceUrl || '';
|
||||
notebookName = body.notebook || body.notebookName || '';
|
||||
isShortcut = Boolean(body.isShortcut);
|
||||
if (Array.isArray(body.tags)) {
|
||||
tags = body.tags;
|
||||
} else if (typeof body.tags === 'string') {
|
||||
tags = body.tags
|
||||
.split(',')
|
||||
.map((t: string) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
} else {
|
||||
const formData = await req.formData();
|
||||
title = (formData.get('title') as string) || '';
|
||||
content =
|
||||
(formData.get('content') as string) ||
|
||||
(formData.get('text') as string) ||
|
||||
'';
|
||||
markdown = (formData.get('markdown') as string) || '';
|
||||
html = (formData.get('html') as string) || '';
|
||||
url = (formData.get('url') as string) || '';
|
||||
notebookName = (formData.get('notebook') as string) || '';
|
||||
const rawTags = (formData.get('tags') as string) || '';
|
||||
if (rawTags) {
|
||||
tags = rawTags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
const files = formData.getAll('files') as File[];
|
||||
const single = formData.get('file') as File | null;
|
||||
if (single && files.length === 0) files.push(single);
|
||||
uploadedFiles.push(...files);
|
||||
}
|
||||
|
||||
// Check if Android shared a URL inside the `text` field
|
||||
if (!url && content && /^https?:\/\/\S+$/i.test(content.trim())) {
|
||||
url = content.trim();
|
||||
content = '';
|
||||
}
|
||||
|
||||
const notebookId = notebookName.trim()
|
||||
? await getOrCreateNotebookByName(notebookName.trim())
|
||||
: (await ensureDefaultNotebook()).id;
|
||||
|
||||
// 1. If Brave Extension sent live rendered DOM (`pageHtml`) in `article` mode:
|
||||
if (pageHtml && (clipMode === 'article' || !clipMode)) {
|
||||
const baseUrl = url || 'https://example.com';
|
||||
const dom = new JSDOM(pageHtml, { url: baseUrl });
|
||||
const doc = dom.window.document;
|
||||
const { heroImgSrc } = prepareDomForReadability(doc, baseUrl);
|
||||
const reader = new Readability(doc);
|
||||
const article = reader.parse();
|
||||
|
||||
const resolvedTitle =
|
||||
title.trim() ||
|
||||
article?.title?.trim() ||
|
||||
doc.title?.trim() ||
|
||||
url ||
|
||||
'Web Clip';
|
||||
|
||||
let extractedHtml = article?.content || doc.body?.innerHTML || pageHtml;
|
||||
if (heroImgSrc && !extractedHtml.includes(heroImgSrc)) {
|
||||
extractedHtml = `<p><img src="${heroImgSrc}" alt="" /></p>\n${extractedHtml}`;
|
||||
}
|
||||
if (comment.trim()) {
|
||||
extractedHtml = `<blockquote>${comment
|
||||
.trim()
|
||||
.replace(/\n/g, '<br/>')}</blockquote>\n${extractedHtml}`;
|
||||
}
|
||||
|
||||
const tagIds = await getOrCreateTagIds(tags.length ? tags : ['web-clip']);
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: resolvedTitle,
|
||||
contentHtml: extractedHtml,
|
||||
contentText: stripHtmlToText(extractedHtml),
|
||||
excerpt: makeExcerpt(article?.excerpt || stripHtmlToText(extractedHtml)),
|
||||
notebookId,
|
||||
sourceUrl: url || null,
|
||||
isShortcut,
|
||||
tags: {
|
||||
create: tagIds.map((tagId) => ({ tagId })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const localizedHtml = await localizeClippedImages(
|
||||
note.id,
|
||||
extractedHtml,
|
||||
url || undefined
|
||||
);
|
||||
if (localizedHtml !== extractedHtml) {
|
||||
await prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: { contentHtml: localizedHtml },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true, noteId: note.id, title: note.title, mode: 'extension-article' },
|
||||
{ status: 201, headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. If URL is provided without body content and no files, clip the full article on server!
|
||||
if (url && !content && !markdown && !html && uploadedFiles.length === 0) {
|
||||
const clippedId = await clipUrlToNote(url, {
|
||||
title: title || undefined,
|
||||
notebookId,
|
||||
tags,
|
||||
isShortcut,
|
||||
});
|
||||
|
||||
if (isShareTarget) {
|
||||
return NextResponse.redirect(new URL(`/?noteId=${clippedId}`, req.url));
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true, noteId: clippedId, mode: 'web-clip' },
|
||||
{ status: 201, headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
|
||||
let finalHtml = html;
|
||||
if (!finalHtml && markdown) {
|
||||
finalHtml = await marked.parse(markdown, { gfm: true, breaks: true });
|
||||
} else if (!finalHtml && content) {
|
||||
finalHtml = await marked.parse(content, { gfm: true, breaks: true });
|
||||
}
|
||||
if (!finalHtml) {
|
||||
finalHtml = '<p></p>';
|
||||
}
|
||||
if (comment.trim()) {
|
||||
finalHtml = `<blockquote>${comment
|
||||
.trim()
|
||||
.replace(/\n/g, '<br/>')}</blockquote>\n${finalHtml}`;
|
||||
}
|
||||
|
||||
const finalTitle =
|
||||
title.trim() ||
|
||||
stripHtmlToText(finalHtml).slice(0, 60).trim() ||
|
||||
(uploadedFiles[0]?.name ?? 'Quick Note');
|
||||
|
||||
const tagIds = await getOrCreateTagIds(tags);
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: finalTitle,
|
||||
contentHtml: finalHtml,
|
||||
contentText: stripHtmlToText(finalHtml),
|
||||
excerpt: makeExcerpt(stripHtmlToText(finalHtml)),
|
||||
notebookId,
|
||||
sourceUrl: url || null,
|
||||
isShortcut,
|
||||
tags: {
|
||||
create: tagIds.map((tagId) => ({ tagId })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Localize any remote <img src="https://..."> in selection/HTML clip
|
||||
if (finalHtml.includes('<img')) {
|
||||
const localized = await localizeClippedImages(
|
||||
note.id,
|
||||
finalHtml,
|
||||
url || undefined
|
||||
);
|
||||
if (localized !== finalHtml) {
|
||||
finalHtml = localized;
|
||||
await prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: { contentHtml: finalHtml },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Save any uploaded attachments and append inline HTML for images/audio
|
||||
let appendedMediaHtml = '';
|
||||
for (const f of uploadedFiles) {
|
||||
const buf = Buffer.from(await f.arrayBuffer());
|
||||
const mimeType = f.type || 'application/octet-stream';
|
||||
const filename = f.name || 'attachment';
|
||||
const savedBlob = await saveAttachmentBuffer(buf, filename, mimeType);
|
||||
const att = await prisma.attachment.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
filename,
|
||||
mimeType,
|
||||
sizeBytes: savedBlob.sizeBytes,
|
||||
md5Hash: savedBlob.md5Hash,
|
||||
storagePath: savedBlob.storagePath,
|
||||
extractedText: savedBlob.extractedText,
|
||||
},
|
||||
});
|
||||
|
||||
if (mimeType.startsWith('image/')) {
|
||||
appendedMediaHtml += `<p><img src="/api/attachments/${att.id}" alt="${filename}" /></p>`;
|
||||
} else if (mimeType.startsWith('audio/')) {
|
||||
appendedMediaHtml += `<p><audio controls src="/api/attachments/${att.id}"></audio></p>`;
|
||||
} else {
|
||||
appendedMediaHtml += `<p><a href="/api/attachments/${att.id}" target="_blank">📎 ${filename}</a></p>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (appendedMediaHtml) {
|
||||
const combinedHtml = `${finalHtml}\n${appendedMediaHtml}`;
|
||||
await prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: {
|
||||
contentHtml: combinedHtml,
|
||||
contentText: stripHtmlToText(combinedHtml),
|
||||
excerpt: makeExcerpt(stripHtmlToText(combinedHtml)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isShareTarget) {
|
||||
return NextResponse.redirect(new URL(`/?noteId=${note.id}`, req.url));
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true, noteId: note.id, title: note.title },
|
||||
{ status: 201, headers: corsHeaders }
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.error('POST /api/hooks/note error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Hook ingestion failed' },
|
||||
{ status: 500, headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const isValidKey = await verifyApiKeyFromRequest(req);
|
||||
if (!isValidKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized: missing or invalid apiKey' },
|
||||
{ status: 401, headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
|
||||
const sp = req.nextUrl.searchParams;
|
||||
|
||||
// Allow Brave Web Clipper Extension to list notebooks for its dropdown
|
||||
if (sp.get('action') === 'notebooks') {
|
||||
await ensureDefaultNotebook();
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
|
||||
select: { id: true, name: true, isDefault: true },
|
||||
});
|
||||
return NextResponse.json({ notebooks }, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const url = sp.get('url') || '';
|
||||
const title = sp.get('title') || '';
|
||||
const text = sp.get('text') || '';
|
||||
const notebookName = sp.get('notebook') || '';
|
||||
const tags = (sp.get('tags') || 'web-clip')
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const notebookId = notebookName
|
||||
? await getOrCreateNotebookByName(notebookName)
|
||||
: (await ensureDefaultNotebook()).id;
|
||||
|
||||
let noteId: string;
|
||||
if (url && !text) {
|
||||
noteId = await clipUrlToNote(url, { title: title || undefined, notebookId, tags });
|
||||
} else {
|
||||
const contentHtml = text
|
||||
? `<p>${text.replace(/\n/g, '<br/>')}</p>${
|
||||
url ? `<p>Source: <a href="${url}" target="_blank">${url}</a></p>` : ''
|
||||
}`
|
||||
: `<p><a href="${url}" target="_blank">${url}</a></p>`;
|
||||
const tagIds = await getOrCreateTagIds(tags);
|
||||
const created = await prisma.note.create({
|
||||
data: {
|
||||
title: title || url || 'Quick Clip',
|
||||
contentHtml,
|
||||
contentText: stripHtmlToText(contentHtml),
|
||||
excerpt: makeExcerpt(stripHtmlToText(contentHtml)),
|
||||
notebookId,
|
||||
sourceUrl: url || null,
|
||||
tags: {
|
||||
create: tagIds.map((tagId) => ({ tagId })),
|
||||
},
|
||||
},
|
||||
});
|
||||
noteId = created.id;
|
||||
}
|
||||
|
||||
if (sp.get('redirect') === '1') {
|
||||
return NextResponse.redirect(new URL(`/?noteId=${noteId}`, req.url));
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true, noteId },
|
||||
{ status: 201, headers: corsHeaders }
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Hook failed' },
|
||||
{ status: 500, headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs/promises';
|
||||
import { createReadStream } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import JSZip from 'jszip';
|
||||
import { prisma, ensureDefaultNotebook } from '@/lib/prisma';
|
||||
import {
|
||||
importEnexXml,
|
||||
importMarkdownNote,
|
||||
importHtmlNote,
|
||||
clipUrlToNote,
|
||||
getOrCreateNotebookByName,
|
||||
} from '@/lib/importers';
|
||||
|
||||
function getChunkTempPath(jobId: string): string {
|
||||
const safeId = jobId.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
return path.join(os.tmpdir(), `everclone-import-${safeId}.tmp`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a potentially multi-GB .enex file directly from disk on the server,
|
||||
* counting total <note> blocks first and then importing note-by-note while updating
|
||||
* ImportJob progress in PostgreSQL.
|
||||
*/
|
||||
async function processEnexFileFromDiskInBackground(
|
||||
jobId: string,
|
||||
filePath: string,
|
||||
notebookId: string
|
||||
) {
|
||||
let processedNotes = 0;
|
||||
let failedNotes = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// Pass 1: Quickly count total `<note>` tags in file so progress bar has an exact denominator
|
||||
let totalNotes = 0;
|
||||
let tail = '';
|
||||
for await (const chunk of createReadStream(filePath, { encoding: 'utf8', highWaterMark: 1024 * 1024 })) {
|
||||
const combined = tail + chunk;
|
||||
const matches = combined.match(/<note>/g);
|
||||
if (matches) totalNotes += matches.length;
|
||||
tail = combined.slice(-10);
|
||||
}
|
||||
|
||||
await prisma.importJob.update({
|
||||
where: { id: jobId },
|
||||
data: { totalNotes, status: 'processing' },
|
||||
});
|
||||
|
||||
// Pass 2: Stream through file, extract each <note>...</note>, and import
|
||||
let carry = '';
|
||||
for await (const chunk of createReadStream(filePath, { encoding: 'utf8', highWaterMark: 1024 * 1024 })) {
|
||||
carry += chunk;
|
||||
|
||||
while (true) {
|
||||
const startIdx = carry.indexOf('<note>');
|
||||
if (startIdx === -1) {
|
||||
if (carry.length > 64) carry = carry.slice(-64);
|
||||
break;
|
||||
}
|
||||
const closeIdx = carry.indexOf('</note>', startIdx);
|
||||
if (closeIdx === -1) {
|
||||
if (startIdx > 0) carry = carry.slice(startIdx);
|
||||
break;
|
||||
}
|
||||
|
||||
const singleNoteXml = carry.slice(startIdx, closeIdx + 7);
|
||||
carry = carry.slice(closeIdx + 7);
|
||||
|
||||
const wrappedXml = `<?xml version="1.0" encoding="UTF-8"?><en-export>${singleNoteXml}</en-export>`;
|
||||
const res = await importEnexXml(wrappedXml, { notebookId });
|
||||
processedNotes += res.importedCount;
|
||||
if (res.errors.length > 0) {
|
||||
failedNotes += 1;
|
||||
errors.push(...res.errors.slice(0, 3));
|
||||
}
|
||||
|
||||
if ((processedNotes + failedNotes) % 3 === 0 || processedNotes === totalNotes) {
|
||||
await prisma.importJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
processedNotes,
|
||||
failedNotes,
|
||||
errorLog: errors.length > 0 ? errors.slice(0, 30).join('\n') : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.importJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: 'completed',
|
||||
processedNotes,
|
||||
failedNotes,
|
||||
errorLog: errors.length > 0 ? errors.slice(0, 30).join('\n') : null,
|
||||
},
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(msg);
|
||||
await prisma.importJob
|
||||
.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
processedNotes,
|
||||
failedNotes,
|
||||
errorLog: errors.slice(0, 30).join('\n'),
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/import?jobId=...
|
||||
* Returns live status of a running or completed background import job.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const jobId = req.nextUrl.searchParams.get('jobId');
|
||||
if (jobId) {
|
||||
const job = await prisma.importJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: 'Import job not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(job);
|
||||
}
|
||||
|
||||
const recentJobs = await prisma.importJob.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
return NextResponse.json({ jobs: recentJobs });
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to fetch import job' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const action = req.nextUrl.searchParams.get('action');
|
||||
|
||||
// 1. Chunked binary upload protocol (strictly <= 2MB per request, immune to proxy limits)
|
||||
if (action === 'initChunkUpload') {
|
||||
const body = await req.json();
|
||||
const filename = body.filename || 'import.enex';
|
||||
const lower = filename.toLowerCase();
|
||||
const job = await prisma.importJob.create({
|
||||
data: {
|
||||
sourceType: lower.endsWith('.enex')
|
||||
? 'enex'
|
||||
: lower.endsWith('.zip')
|
||||
? 'zip'
|
||||
: lower.endsWith('.md')
|
||||
? 'markdown'
|
||||
: 'html',
|
||||
filename,
|
||||
status: 'uploading',
|
||||
totalNotes: 0,
|
||||
processedNotes: 0,
|
||||
},
|
||||
});
|
||||
const tmpPath = getChunkTempPath(job.id);
|
||||
await fs.writeFile(tmpPath, Buffer.alloc(0));
|
||||
return NextResponse.json({ jobId: job.id });
|
||||
}
|
||||
|
||||
if (action === 'appendChunk') {
|
||||
const jobId = req.nextUrl.searchParams.get('jobId');
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'Missing jobId' }, { status: 400 });
|
||||
}
|
||||
const arrayBuf = await req.arrayBuffer();
|
||||
const tmpPath = getChunkTempPath(jobId);
|
||||
await fs.appendFile(tmpPath, Buffer.from(arrayBuf));
|
||||
return NextResponse.json({ ok: true, bytesAppended: arrayBuf.byteLength });
|
||||
}
|
||||
|
||||
if (action === 'finishChunkUpload') {
|
||||
const body = await req.json();
|
||||
const { jobId, notebookId, notebookName, useFilenameAsNotebook } = body;
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'Missing jobId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const job = await prisma.importJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: 'Import job not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
let resolvedNbId = notebookId || '';
|
||||
if (!resolvedNbId) {
|
||||
if (notebookName?.trim()) {
|
||||
resolvedNbId = await getOrCreateNotebookByName(notebookName.trim());
|
||||
} else if (useFilenameAsNotebook) {
|
||||
resolvedNbId = await getOrCreateNotebookByName(
|
||||
job.filename.replace(/\.(enex|zip|md|html?)$/i, '')
|
||||
);
|
||||
} else {
|
||||
resolvedNbId = (await ensureDefaultNotebook()).id;
|
||||
}
|
||||
}
|
||||
|
||||
const tmpPath = getChunkTempPath(jobId);
|
||||
|
||||
if (job.sourceType === 'enex') {
|
||||
// Launch background streaming import on the server and return immediately!
|
||||
void processEnexFileFromDiskInBackground(jobId, tmpPath, resolvedNbId);
|
||||
return NextResponse.json({ jobId, status: 'processing', background: true });
|
||||
}
|
||||
|
||||
// For .md / .html / .zip uploaded via chunks:
|
||||
const contentBuf = await fs.readFile(tmpPath);
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
|
||||
let importedCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
if (job.sourceType === 'markdown') {
|
||||
await importMarkdownNote(contentBuf.toString('utf8'), job.filename, resolvedNbId);
|
||||
importedCount = 1;
|
||||
} else if (job.sourceType === 'html') {
|
||||
await importHtmlNote(contentBuf.toString('utf8'), job.filename, resolvedNbId);
|
||||
importedCount = 1;
|
||||
} else if (job.sourceType === 'zip') {
|
||||
const zip = await JSZip.loadAsync(contentBuf);
|
||||
const entries = Object.values(zip.files).filter((z) => !z.dir);
|
||||
for (const entry of entries) {
|
||||
const entryLower = entry.name.toLowerCase();
|
||||
const baseName = entry.name.split('/').pop() || entry.name;
|
||||
if (baseName.startsWith('._') || baseName.startsWith('.DS_Store')) continue;
|
||||
const parts = entry.name.split('/');
|
||||
const folderNbId =
|
||||
useFilenameAsNotebook && parts.length > 1
|
||||
? await getOrCreateNotebookByName(parts[0])
|
||||
: resolvedNbId;
|
||||
|
||||
if (entryLower.endsWith('.enex')) {
|
||||
const xml = await entry.async('string');
|
||||
const res = await importEnexXml(xml, { notebookId: folderNbId });
|
||||
importedCount += res.importedCount;
|
||||
errors.push(...res.errors);
|
||||
} else if (entryLower.endsWith('.md') || entryLower.endsWith('.markdown')) {
|
||||
const md = await entry.async('string');
|
||||
await importMarkdownNote(md, baseName, folderNbId);
|
||||
importedCount += 1;
|
||||
} else if (entryLower.endsWith('.html') || entryLower.endsWith('.htm')) {
|
||||
const html = await entry.async('string');
|
||||
await importHtmlNote(html, baseName, folderNbId);
|
||||
importedCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.importJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: 'completed',
|
||||
totalNotes: importedCount,
|
||||
processedNotes: importedCount,
|
||||
errorLog: errors.length > 0 ? errors.join('\n') : null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobId,
|
||||
status: 'completed',
|
||||
importedCount,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = req.headers.get('content-type') || '';
|
||||
|
||||
// 2. Support JSON requests: either URL clipping OR direct ENEX XML batch import
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = await req.json();
|
||||
|
||||
if (body.enexXml) {
|
||||
const notebookId =
|
||||
body.notebookId ||
|
||||
(body.notebookName
|
||||
? await getOrCreateNotebookByName(body.notebookName)
|
||||
: (await ensureDefaultNotebook()).id);
|
||||
|
||||
const res = await importEnexXml(body.enexXml, { notebookId });
|
||||
return NextResponse.json({
|
||||
importedCount: res.importedCount,
|
||||
noteIds: res.noteIds,
|
||||
errors: res.errors,
|
||||
notebookId,
|
||||
});
|
||||
}
|
||||
|
||||
if (body.url) {
|
||||
const notebookId =
|
||||
body.notebookId ||
|
||||
(body.notebookName
|
||||
? await getOrCreateNotebookByName(body.notebookName)
|
||||
: (await ensureDefaultNotebook()).id);
|
||||
const noteId = await clipUrlToNote(body.url, {
|
||||
title: body.title,
|
||||
notebookId,
|
||||
tags: body.tags,
|
||||
isShortcut: body.isShortcut,
|
||||
});
|
||||
return NextResponse.json({ importedCount: 1, noteIds: [noteId], errors: [] });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid JSON import request' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 3. Legacy Multipart FormData upload (for small CLI / curl imports)
|
||||
const formData = await req.formData();
|
||||
const targetNotebookId = (formData.get('notebookId') as string) || '';
|
||||
const targetNotebookName = (formData.get('notebookName') as string) || '';
|
||||
const useFilenameAsNotebook = formData.get('useFilenameAsNotebook') === 'true';
|
||||
|
||||
const files = formData.getAll('files') as File[];
|
||||
const singleFile = formData.get('file') as File | null;
|
||||
if (singleFile && files.length === 0) {
|
||||
files.push(singleFile);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json({ error: 'No files provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
let resolvedBaseNotebookId = targetNotebookId;
|
||||
if (!resolvedBaseNotebookId) {
|
||||
if (targetNotebookName.trim()) {
|
||||
resolvedBaseNotebookId = await getOrCreateNotebookByName(targetNotebookName.trim());
|
||||
} else {
|
||||
resolvedBaseNotebookId = (await ensureDefaultNotebook()).id;
|
||||
}
|
||||
}
|
||||
|
||||
let totalImported = 0;
|
||||
const allNoteIds: string[] = [];
|
||||
const allErrors: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const fname = file.name || 'imported';
|
||||
const lower = fname.toLowerCase();
|
||||
|
||||
if (lower.endsWith('.enex')) {
|
||||
const text = await file.text();
|
||||
const nbId = useFilenameAsNotebook
|
||||
? await getOrCreateNotebookByName(fname.replace(/\.enex$/i, ''))
|
||||
: resolvedBaseNotebookId;
|
||||
|
||||
const res = await importEnexXml(text, { notebookId: nbId });
|
||||
totalImported += res.importedCount;
|
||||
allNoteIds.push(...res.noteIds);
|
||||
allErrors.push(...res.errors);
|
||||
} else if (
|
||||
lower.endsWith('.md') ||
|
||||
lower.endsWith('.markdown') ||
|
||||
lower.endsWith('.txt')
|
||||
) {
|
||||
const text = await file.text();
|
||||
const id = await importMarkdownNote(text, fname, resolvedBaseNotebookId);
|
||||
totalImported += 1;
|
||||
allNoteIds.push(id);
|
||||
} else if (lower.endsWith('.html') || lower.endsWith('.htm')) {
|
||||
const text = await file.text();
|
||||
const id = await importHtmlNote(text, fname, resolvedBaseNotebookId);
|
||||
totalImported += 1;
|
||||
allNoteIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
importedCount: totalImported,
|
||||
noteIds: allNoteIds,
|
||||
errors: allErrors,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error('Import error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Import failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { generateRawApiKey } from '@/lib/auth';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const keys = await prisma.apiKey.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
keyPrefix: true,
|
||||
lastUsedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ keys });
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to list API keys' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const name = body.name?.trim() || 'Android / Web Clipper Key';
|
||||
const { rawKey, keyHash, keyPrefix } = generateRawApiKey();
|
||||
|
||||
const created = await prisma.apiKey.create({
|
||||
data: {
|
||||
name,
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
keyPrefix: created.keyPrefix,
|
||||
rawKey, // Returned once on creation
|
||||
createdAt: created.createdAt,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to create API key' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const id = req.nextUrl.searchParams.get('id');
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'API Key ID is required' }, { status: 400 });
|
||||
}
|
||||
await prisma.apiKey.delete({ where: { id } });
|
||||
return NextResponse.json({ deleted: true });
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to revoke API key' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma, getCurrentUserId, ensureDefaultNotebook } from '@/lib/prisma';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
const body = await req.json();
|
||||
|
||||
if (body.type === 'stack') {
|
||||
const stackName = body.name?.trim();
|
||||
if (!stackName) {
|
||||
return NextResponse.json({ error: 'Stack name is required' }, { status: 400 });
|
||||
}
|
||||
const stack = await prisma.notebookStack.upsert({
|
||||
where: { userId_name: { userId, name: stackName } },
|
||||
update: {},
|
||||
create: { userId, name: stackName },
|
||||
});
|
||||
return NextResponse.json(stack);
|
||||
}
|
||||
|
||||
const name = body.name?.trim();
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Notebook name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let stackId: string | null = body.stackId || null;
|
||||
if (body.stackName?.trim()) {
|
||||
const st = await prisma.notebookStack.upsert({
|
||||
where: { userId_name: { userId, name: body.stackName.trim() } },
|
||||
update: {},
|
||||
create: { userId, name: body.stackName.trim() },
|
||||
});
|
||||
stackId = st.id;
|
||||
}
|
||||
|
||||
const nb = await prisma.notebook.create({
|
||||
data: {
|
||||
userId,
|
||||
name,
|
||||
stackId,
|
||||
isShortcut: Boolean(body.isShortcut),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(nb);
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to create notebook' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
const body = await req.json();
|
||||
|
||||
if (body.type === 'stack') {
|
||||
const updatedStack = await prisma.notebookStack.update({
|
||||
where: { id: body.id },
|
||||
data: { name: body.name?.trim() },
|
||||
});
|
||||
return NextResponse.json(updatedStack);
|
||||
}
|
||||
|
||||
const { id, name, stackId, stackName, isDefault, isShortcut } = body;
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Notebook id required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let resolvedStackId = stackId;
|
||||
if (stackName !== undefined) {
|
||||
if (!stackName || !stackName.trim()) {
|
||||
resolvedStackId = null;
|
||||
} else {
|
||||
const st = await prisma.notebookStack.upsert({
|
||||
where: { userId_name: { userId, name: stackName.trim() } },
|
||||
update: {},
|
||||
create: { userId, name: stackName.trim() },
|
||||
});
|
||||
resolvedStackId = st.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDefault === true) {
|
||||
await prisma.notebook.updateMany({
|
||||
where: { userId },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.notebook.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name !== undefined ? { name: name.trim() } : {}),
|
||||
...(resolvedStackId !== undefined ? { stackId: resolvedStackId } : {}),
|
||||
...(isDefault !== undefined ? { isDefault: Boolean(isDefault) } : {}),
|
||||
...(isShortcut !== undefined ? { isShortcut: Boolean(isShortcut) } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to update notebook' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
const id = req.nextUrl.searchParams.get('id');
|
||||
const type = req.nextUrl.searchParams.get('type');
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (type === 'stack') {
|
||||
await prisma.notebookStack.delete({ where: { id } });
|
||||
return NextResponse.json({ deleted: true });
|
||||
}
|
||||
|
||||
const defaultNb = await ensureDefaultNotebook(userId);
|
||||
if (defaultNb.id === id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete the default notebook' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Move notes in this notebook to the default notebook before deleting
|
||||
await prisma.note.updateMany({
|
||||
where: { notebookId: id },
|
||||
data: { notebookId: defaultNb.id },
|
||||
});
|
||||
|
||||
await prisma.notebook.delete({ where: { id } });
|
||||
return NextResponse.json({ deleted: true });
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to delete notebook' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { stripHtmlToText, makeExcerpt } from '@/lib/storage';
|
||||
import { getOrCreateTagIds } from '@/lib/importers';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
notebook: true,
|
||||
tags: { include: { tag: true } },
|
||||
attachments: { orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...note,
|
||||
tags: note.tags.map((nt) => nt.tag),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to fetch note' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const body = await req.json();
|
||||
|
||||
const data: Prisma.NoteUncheckedUpdateInput = {};
|
||||
let shouldBumpUpdatedAt = false;
|
||||
|
||||
if (body.title !== undefined) {
|
||||
data.title = body.title;
|
||||
shouldBumpUpdatedAt = true;
|
||||
}
|
||||
if (body.contentHtml !== undefined) {
|
||||
data.contentHtml = body.contentHtml;
|
||||
const text = stripHtmlToText(body.contentHtml);
|
||||
data.contentText = text;
|
||||
data.excerpt = makeExcerpt(text);
|
||||
shouldBumpUpdatedAt = true;
|
||||
}
|
||||
if (body.notebookId !== undefined) {
|
||||
data.notebookId = body.notebookId;
|
||||
}
|
||||
if (body.isPinned !== undefined) {
|
||||
data.isPinned = Boolean(body.isPinned);
|
||||
}
|
||||
if (body.isShortcut !== undefined) {
|
||||
data.isShortcut = Boolean(body.isShortcut);
|
||||
}
|
||||
if (body.isTrashed !== undefined) {
|
||||
data.isTrashed = Boolean(body.isTrashed);
|
||||
data.trashedAt = body.isTrashed ? new Date() : null;
|
||||
}
|
||||
if (body.sourceUrl !== undefined) {
|
||||
data.sourceUrl = body.sourceUrl || null;
|
||||
}
|
||||
if (body.thumbnailUrl !== undefined) {
|
||||
data.thumbnailUrl = body.thumbnailUrl || null;
|
||||
}
|
||||
if (body.author !== undefined) {
|
||||
data.author = body.author || null;
|
||||
}
|
||||
if (body.reminderAt !== undefined) {
|
||||
data.reminderAt = body.reminderAt ? new Date(body.reminderAt) : null;
|
||||
}
|
||||
if (body.reminderDone !== undefined) {
|
||||
data.reminderDone = Boolean(body.reminderDone);
|
||||
}
|
||||
if (body.createdAt !== undefined && body.createdAt) {
|
||||
const d = new Date(body.createdAt);
|
||||
if (!isNaN(d.getTime())) data.createdAt = d;
|
||||
}
|
||||
if (body.updatedAt !== undefined && body.updatedAt) {
|
||||
const d = new Date(body.updatedAt);
|
||||
if (!isNaN(d.getTime())) {
|
||||
data.updatedAt = d;
|
||||
shouldBumpUpdatedAt = false;
|
||||
}
|
||||
} else if (shouldBumpUpdatedAt) {
|
||||
data.updatedAt = new Date();
|
||||
}
|
||||
|
||||
if (Array.isArray(body.tagNames)) {
|
||||
const tagIds = await getOrCreateTagIds(body.tagNames);
|
||||
await prisma.noteTag.deleteMany({ where: { noteId: id } });
|
||||
if (tagIds.length > 0) {
|
||||
await prisma.noteTag.createMany({
|
||||
data: tagIds.map((tagId) => ({ noteId: id, tagId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.note.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
notebook: true,
|
||||
tags: { include: { tag: true } },
|
||||
attachments: { orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
...updated,
|
||||
tags: updated.tags.map((nt) => nt.tag),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error('PATCH /api/notes/[id] error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to update note' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const permanent = req.nextUrl.searchParams.get('permanent') === 'true';
|
||||
|
||||
if (permanent) {
|
||||
await prisma.note.delete({ where: { id } });
|
||||
return NextResponse.json({ deleted: true, permanent: true });
|
||||
} else {
|
||||
const trashed = await prisma.note.update({
|
||||
where: { id },
|
||||
data: { isTrashed: true, trashedAt: new Date() },
|
||||
});
|
||||
return NextResponse.json({ deleted: true, permanent: false, note: trashed });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to delete note' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma, getCurrentUserId, ensureDefaultNotebook } from '@/lib/prisma';
|
||||
import { buildNoteWhereClause } from '@/lib/search';
|
||||
import { stripHtmlToText, makeExcerpt } from '@/lib/storage';
|
||||
import { getOrCreateTagIds } from '@/lib/importers';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
const sp = req.nextUrl.searchParams;
|
||||
const query = sp.get('q') || undefined;
|
||||
const notebookId = sp.get('notebookId') || undefined;
|
||||
const stackId = sp.get('stackId') || undefined;
|
||||
const tagId = sp.get('tagId') || undefined;
|
||||
const tagIdsRaw = sp.get('tagIds') || '';
|
||||
const tagIds = tagIdsRaw ? tagIdsRaw.split(',').filter(Boolean) : undefined;
|
||||
const untagged = sp.get('untagged') === 'true';
|
||||
const hasImages = sp.get('hasImages') === 'true';
|
||||
const hasAttachments = sp.get('hasAttachments') === 'true';
|
||||
const hasTodo = sp.get('hasTodo') === 'true';
|
||||
const hasWebClip = sp.get('hasWebClip') === 'true';
|
||||
const onlyPinned = sp.get('onlyPinned') === 'true';
|
||||
const updatedSinceDays = parseInt(sp.get('updatedSinceDays') || '0', 10) || undefined;
|
||||
const trashed = sp.get('trashed') === 'true';
|
||||
const shortcut = sp.get('shortcut') === 'true';
|
||||
const sortBy = sp.get('sortBy') || 'updatedAt';
|
||||
const sortDir = (sp.get('sortDir') === 'asc' ? 'asc' : 'desc') as 'asc' | 'desc';
|
||||
const limit = Math.min(parseInt(sp.get('limit') || '500', 10) || 500, 2000);
|
||||
const offset = parseInt(sp.get('offset') || '0', 10) || 0;
|
||||
|
||||
const where = buildNoteWhereClause({
|
||||
userId,
|
||||
query,
|
||||
notebookId,
|
||||
stackId,
|
||||
tagId,
|
||||
tagIds,
|
||||
untagged,
|
||||
hasImages,
|
||||
hasAttachments,
|
||||
hasTodo,
|
||||
hasWebClip,
|
||||
onlyPinned,
|
||||
updatedSinceDays,
|
||||
trashed,
|
||||
shortcut,
|
||||
});
|
||||
|
||||
const orderBy: Prisma.NoteOrderByWithRelationInput[] = [
|
||||
{ isPinned: 'desc' },
|
||||
sortBy === 'title'
|
||||
? { title: sortDir }
|
||||
: sortBy === 'createdAt'
|
||||
? { createdAt: sortDir }
|
||||
: { updatedAt: sortDir },
|
||||
];
|
||||
|
||||
const [notes, total] = await Promise.all([
|
||||
prisma.note.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
excerpt: true,
|
||||
notebookId: true,
|
||||
isPinned: true,
|
||||
isShortcut: true,
|
||||
isTrashed: true,
|
||||
thumbnailUrl: true,
|
||||
sourceUrl: true,
|
||||
reminderAt: true,
|
||||
reminderDone: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
notebook: {
|
||||
select: { id: true, name: true, stackId: true },
|
||||
},
|
||||
tags: {
|
||||
select: {
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
},
|
||||
attachments: {
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
},
|
||||
take: 12,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.note.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
notes: notes.map((n) => ({
|
||||
...n,
|
||||
tags: n.tags.map((nt) => nt.tag),
|
||||
})),
|
||||
total,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error('GET /api/notes error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to list notes' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
const body = await req.json();
|
||||
|
||||
// Support bulk actions such as emptyTrash
|
||||
if (body.action === 'emptyTrash') {
|
||||
const deleted = await prisma.note.deleteMany({
|
||||
where: { userId, isTrashed: true },
|
||||
});
|
||||
return NextResponse.json({ deletedCount: deleted.count });
|
||||
}
|
||||
|
||||
const defaultNb = await ensureDefaultNotebook(userId);
|
||||
const notebookId = body.notebookId || defaultNb.id;
|
||||
const title = body.title?.trim() || 'Untitled Note';
|
||||
const contentHtml = body.contentHtml || '<p></p>';
|
||||
const contentText = stripHtmlToText(contentHtml);
|
||||
const excerpt = makeExcerpt(contentText);
|
||||
|
||||
const tagNames: string[] = Array.isArray(body.tagNames) ? body.tagNames : [];
|
||||
const tagIds = await getOrCreateTagIds(tagNames, userId);
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
contentHtml,
|
||||
contentText,
|
||||
excerpt,
|
||||
notebookId,
|
||||
isPinned: Boolean(body.isPinned),
|
||||
isShortcut: Boolean(body.isShortcut),
|
||||
thumbnailUrl: body.thumbnailUrl || null,
|
||||
sourceUrl: body.sourceUrl || null,
|
||||
author: body.author || null,
|
||||
tags: {
|
||||
create: tagIds.map((tagId) => ({ tagId })),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
notebook: true,
|
||||
tags: { include: { tag: true } },
|
||||
attachments: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
...note,
|
||||
tags: note.tags.map((nt) => nt.tag),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error('POST /api/notes error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to create note' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma, getCurrentUserId } from '@/lib/prisma';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
const body = await req.json();
|
||||
const name = body.name?.trim();
|
||||
const query = body.query?.trim();
|
||||
if (!name || !query) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Name and query are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const saved = await prisma.savedSearch.upsert({
|
||||
where: { userId_name: { userId, name } },
|
||||
update: { query, isShortcut: Boolean(body.isShortcut) },
|
||||
create: {
|
||||
userId,
|
||||
name,
|
||||
query,
|
||||
isShortcut: Boolean(body.isShortcut ?? true),
|
||||
},
|
||||
});
|
||||
return NextResponse.json(saved);
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to save search' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const id = req.nextUrl.searchParams.get('id');
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'ID is required' }, { status: 400 });
|
||||
}
|
||||
await prisma.savedSearch.delete({ where: { id } });
|
||||
return NextResponse.json({ deleted: true });
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to delete saved search' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma, getCurrentUser, ensureDefaultNotebook } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const currentUser = await getCurrentUser();
|
||||
const userId = currentUser.id;
|
||||
await ensureDefaultNotebook(userId);
|
||||
|
||||
const [
|
||||
users,
|
||||
stacks,
|
||||
notebooks,
|
||||
tags,
|
||||
savedSearches,
|
||||
shortcutNotes,
|
||||
allNotesCount,
|
||||
trashCount,
|
||||
] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
prisma.notebookStack.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
notes: { where: { isTrashed: false } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.tag.findMany({
|
||||
where: { userId },
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
notes: { where: { note: { isTrashed: false, userId } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.savedSearch.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
prisma.note.findMany({
|
||||
where: { userId, isShortcut: true, isTrashed: false },
|
||||
select: { id: true, title: true, notebookId: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
}),
|
||||
prisma.note.count({ where: { userId, isTrashed: false } }),
|
||||
prisma.note.count({ where: { userId, isTrashed: true } }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
currentUser,
|
||||
users,
|
||||
stacks,
|
||||
notebooks: notebooks.map((nb) => ({
|
||||
...nb,
|
||||
noteCount: nb._count.notes,
|
||||
})),
|
||||
tags: tags.map((t) => ({
|
||||
...t,
|
||||
noteCount: t._count.notes,
|
||||
})),
|
||||
savedSearches,
|
||||
shortcuts: {
|
||||
notes: shortcutNotes,
|
||||
notebooks: notebooks.filter((n) => n.isShortcut),
|
||||
tags: tags.filter((t) => t.isShortcut),
|
||||
savedSearches: savedSearches.filter((s) => s.isShortcut),
|
||||
},
|
||||
counts: {
|
||||
allNotes: allNotesCount,
|
||||
trash: trashCount,
|
||||
},
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error('Error loading sidebar:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to load sidebar' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma, getCurrentUserId } from '@/lib/prisma';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
const body = await req.json();
|
||||
const name = body.name?.trim();
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Tag name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { userId_name: { userId, name } },
|
||||
update: {
|
||||
...(body.color ? { color: body.color } : {}),
|
||||
...(body.parentId !== undefined ? { parentId: body.parentId } : {}),
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
name,
|
||||
color: body.color || '#00a82d',
|
||||
parentId: body.parentId || null,
|
||||
isShortcut: Boolean(body.isShortcut),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(tag);
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to create tag' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { id, name, color, parentId, isShortcut } = body;
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Tag ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await prisma.tag.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name !== undefined ? { name: name.trim() } : {}),
|
||||
...(color !== undefined ? { color } : {}),
|
||||
...(parentId !== undefined ? { parentId: parentId || null } : {}),
|
||||
...(isShortcut !== undefined ? { isShortcut: Boolean(isShortcut) } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to update tag' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const id = req.nextUrl.searchParams.get('id');
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Tag ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.tag.delete({ where: { id } });
|
||||
return NextResponse.json({ deleted: true });
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to delete tag' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma, getCurrentUser, ensureDefaultNotebook } from '@/lib/prisma';
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
'#00a82d',
|
||||
'#2563eb',
|
||||
'#7c3aed',
|
||||
'#db2777',
|
||||
'#ea580c',
|
||||
'#0d9488',
|
||||
'#4f46e5',
|
||||
];
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const currentUser = await getCurrentUser();
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const usersWithCounts = await Promise.all(
|
||||
users.map(async (u) => {
|
||||
const noteCount = await prisma.note.count({
|
||||
where: { userId: u.id, isTrashed: false },
|
||||
});
|
||||
return { ...u, noteCount };
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
currentUser,
|
||||
users: usersWithCounts,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to load users' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
// Switch active user profile
|
||||
if (body.action === 'switch' && body.userId) {
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id: String(body.userId) },
|
||||
});
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
await ensureDefaultNotebook(target.id);
|
||||
|
||||
const res = NextResponse.json({ currentUser: target });
|
||||
res.cookies.set('evc_user_id', target.id, {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
// Create new user profile
|
||||
const name = body.name?.trim();
|
||||
const email =
|
||||
body.email?.trim()?.toLowerCase() ||
|
||||
`${(name || 'user').toLowerCase().replace(/[^a-z0-9]+/g, '.')}@everclone.local`;
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Display name is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const count = await prisma.user.count();
|
||||
const avatarColor =
|
||||
body.avatarColor || AVATAR_COLORS[count % AVATAR_COLORS.length];
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: { name, avatarColor },
|
||||
create: {
|
||||
name,
|
||||
email,
|
||||
avatarColor,
|
||||
},
|
||||
});
|
||||
|
||||
await ensureDefaultNotebook(user.id);
|
||||
|
||||
const res = NextResponse.json({ currentUser: user, user });
|
||||
if (body.switchImmediately !== false) {
|
||||
res.cookies.set('evc_user_id', user.id, {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
}
|
||||
return res;
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to save user' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { id, name, email, avatarColor } = body;
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'User ID required' }, { status: 400 });
|
||||
}
|
||||
const updated = await prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name !== undefined ? { name: name.trim() } : {}),
|
||||
...(email !== undefined ? { email: email.trim().toLowerCase() } : {}),
|
||||
...(avatarColor !== undefined ? { avatarColor } : {}),
|
||||
},
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to update user' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-white text-gray-900 antialiased select-none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom thin scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(156, 163, 175, 0.45);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(107, 114, 128, 0.7);
|
||||
}
|
||||
|
||||
/* TipTap / ProseMirror Evernote Editor Styles */
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
min-height: 420px;
|
||||
line-height: 1.65;
|
||||
color: #1f2937;
|
||||
user-select: text;
|
||||
font-size: 0.975rem;
|
||||
}
|
||||
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: #9ca3af;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ProseMirror h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.ProseMirror h2 {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1.1rem;
|
||||
margin-bottom: 0.45rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.ProseMirror h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.9rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.ProseMirror p {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.ProseMirror ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ProseMirror ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* TipTap Task List (Evernote Checklists) */
|
||||
.ProseMirror ul[data-type="taskList"] {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ProseMirror ul[data-type="taskList"] li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.ProseMirror ul[data-type="taskList"] li > label {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 0.25rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ProseMirror ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: #00a82d;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ProseMirror ul[data-type="taskList"] li > div {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div {
|
||||
text-decoration: line-through;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.ProseMirror blockquote {
|
||||
border-left: 3px solid #00a82d;
|
||||
padding-left: 1rem;
|
||||
color: #4b5563;
|
||||
font-style: italic;
|
||||
margin: 0.85rem 0;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
background: #1e293b;
|
||||
color: #f8fafc;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.85rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ProseMirror code {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.ProseMirror pre code {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ProseMirror hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.ProseMirror a {
|
||||
color: #00a82d;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ProseMirror img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.375rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.ProseMirror table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ProseMirror td,
|
||||
.ProseMirror th {
|
||||
min-width: 1em;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.45rem 0.65rem;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror th {
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Everclone — Notes, Web Clips & Knowledge Base',
|
||||
description: 'Self-hosted modern Evernote clone with rich media, ENEX import, and Web/Android hooks',
|
||||
manifest: '/manifest.json',
|
||||
icons: {
|
||||
icon: '/icon.svg',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import EvercloneWorkspace from '@/components/EvercloneWorkspace';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 0;
|
||||
|
||||
export default function HomePage() {
|
||||
return <EvercloneWorkspace />;
|
||||
}
|
||||
Reference in new issue
Block a user