Initial commit: Everclone self-hosted Evernote alternative

This commit is contained in:
chrisf committed 2026-10-22 17:13:30 -06:00
commit ee2ee84de6
51 files changed
+17728

No files matched your search

+77
View File
@@ -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 }
);
}
}
+109
View File
@@ -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 }
);
}
}
+312
View File
@@ -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 }
);
}
}
+14
View File
@@ -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 }
);
}
}
+120
View File
@@ -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 }
);
}
}
+378
View File
@@ -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 }
);
}
}
+394
View File
@@ -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 }
);
}
}
+69
View File
@@ -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 }
);
}
}
+149
View File
@@ -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 }
);
}
}
+160
View File
@@ -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 }
);
}
}
+175
View File
@@ -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 }
);
}
}
+50
View File
@@ -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 }
);
}
}
+93
View File
@@ -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 }
);
}
}
+79
View File
@@ -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 }
);
}
}
+133
View File
@@ -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 }
);
}
}
+195
View File
@@ -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;
}
+23
View File
@@ -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>
);
}
+8
View File
@@ -0,0 +1,8 @@
import EvercloneWorkspace from '@/components/EvercloneWorkspace';
export const dynamic = 'force-dynamic';
export const revalidate = 0;
export default function HomePage() {
return <EvercloneWorkspace />;
}