From ee2ee84de64de649b70c6e73e982cbe9dc9e6ae2 Mon Sep 17 00:00:00 2001
From: Chris Fuller
Date: Thu, 22 Oct 2026 17:13:30 -0600
Subject: [PATCH] Initial commit: Everclone self-hosted Evernote alternative
---
.dockerignore | 5 +
.gitignore | 38 +
Dockerfile | 33 +
brave-extension/manifest.json | 18 +
brave-extension/popup.html | 73 +
brave-extension/popup.js | 183 +
docker-compose.yml | 42 +
entrypoint.sh | 8 +
next.config.mjs | 17 +
package-lock.json | 4954 +++++++++++++++++++++++
package.json | 64 +
postcss.config.mjs | 9 +
prisma/schema.prisma | 187 +
public/icon.svg | 8 +
public/manifest.json | 33 +
src/app/api/email/route.ts | 77 +
src/app/api/export/route.ts | 109 +
src/app/api/extension/route.ts | 312 ++
src/app/api/health/route.ts | 14 +
src/app/api/hooks/email/route.ts | 120 +
src/app/api/hooks/note/route.ts | 378 ++
src/app/api/import/route.ts | 394 ++
src/app/api/keys/route.ts | 69 +
src/app/api/notebooks/route.ts | 149 +
src/app/api/notes/[id]/route.ts | 160 +
src/app/api/notes/route.ts | 175 +
src/app/api/saved-searches/route.ts | 50 +
src/app/api/sidebar/route.ts | 93 +
src/app/api/tags/route.ts | 79 +
src/app/api/users/route.ts | 133 +
src/app/globals.css | 195 +
src/app/layout.tsx | 23 +
src/app/page.tsx | 8 +
src/components/EvercloneWorkspace.tsx | 3008 ++++++++++++++
src/components/HooksModal.tsx | 379 ++
src/components/ImportModal.tsx | 625 +++
src/components/PdfNoteComponents.tsx | 250 ++
src/components/RichNoteEditor.tsx | 571 +++
src/components/SettingsView.tsx | 1443 +++++++
src/components/ThumbnailEditorModal.tsx | 1122 +++++
src/lib/auth.ts | 57 +
src/lib/email.ts | 331 ++
src/lib/importers.ts | 849 ++++
src/lib/pdfClient.ts | 130 +
src/lib/prisma.ts | 156 +
src/lib/search.ts | 298 ++
src/lib/storage.ts | 107 +
src/types/everclone.ts | 110 +
tailwind.config.ts | 26 +
traefik-rule-example.yml | 29 +
tsconfig.json | 27 +
51 files changed, 17728 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .gitignore
create mode 100644 Dockerfile
create mode 100644 brave-extension/manifest.json
create mode 100644 brave-extension/popup.html
create mode 100644 brave-extension/popup.js
create mode 100644 docker-compose.yml
create mode 100644 entrypoint.sh
create mode 100644 next.config.mjs
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 postcss.config.mjs
create mode 100644 prisma/schema.prisma
create mode 100644 public/icon.svg
create mode 100644 public/manifest.json
create mode 100644 src/app/api/email/route.ts
create mode 100644 src/app/api/export/route.ts
create mode 100644 src/app/api/extension/route.ts
create mode 100644 src/app/api/health/route.ts
create mode 100644 src/app/api/hooks/email/route.ts
create mode 100644 src/app/api/hooks/note/route.ts
create mode 100644 src/app/api/import/route.ts
create mode 100644 src/app/api/keys/route.ts
create mode 100644 src/app/api/notebooks/route.ts
create mode 100644 src/app/api/notes/[id]/route.ts
create mode 100644 src/app/api/notes/route.ts
create mode 100644 src/app/api/saved-searches/route.ts
create mode 100644 src/app/api/sidebar/route.ts
create mode 100644 src/app/api/tags/route.ts
create mode 100644 src/app/api/users/route.ts
create mode 100644 src/app/globals.css
create mode 100644 src/app/layout.tsx
create mode 100644 src/app/page.tsx
create mode 100644 src/components/EvercloneWorkspace.tsx
create mode 100644 src/components/HooksModal.tsx
create mode 100644 src/components/ImportModal.tsx
create mode 100644 src/components/PdfNoteComponents.tsx
create mode 100644 src/components/RichNoteEditor.tsx
create mode 100644 src/components/SettingsView.tsx
create mode 100644 src/components/ThumbnailEditorModal.tsx
create mode 100644 src/lib/auth.ts
create mode 100644 src/lib/email.ts
create mode 100644 src/lib/importers.ts
create mode 100644 src/lib/pdfClient.ts
create mode 100644 src/lib/prisma.ts
create mode 100644 src/lib/search.ts
create mode 100644 src/lib/storage.ts
create mode 100644 src/types/everclone.ts
create mode 100644 tailwind.config.ts
create mode 100644 traefik-rule-example.yml
create mode 100644 tsconfig.json
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..e9995a6
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,5 @@
+node_modules
+.next
+.git
+data
+*.log
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2200de2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,38 @@
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# next.js build output
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug logs
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+.env
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+# Everclone data & uploads
+data/
+attachments/
+uploads/
+*.enex
+*.zip
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..c39180a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,33 @@
+FROM node:22-alpine AS base
+RUN apk add --no-cache libc6-compat openssl
+
+FROM base AS deps
+WORKDIR /app
+COPY package.json ./
+RUN npm install --legacy-peer-deps
+
+FROM base AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+ENV NEXT_TELEMETRY_DISABLED=1
+RUN npx prisma generate
+RUN npm run build
+
+FROM base AS runner
+WORKDIR /app
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/prisma ./prisma
+COPY --from=builder /app/.next/standalone ./
+COPY --from=builder /app/.next/static ./.next/static
+COPY entrypoint.sh ./entrypoint.sh
+RUN sed -i 's/\r$//' ./entrypoint.sh && chmod +x ./entrypoint.sh && mkdir -p /data/attachments
+
+EXPOSE 3000
+ENTRYPOINT ["./entrypoint.sh"]
diff --git a/brave-extension/manifest.json b/brave-extension/manifest.json
new file mode 100644
index 0000000..7e03d79
--- /dev/null
+++ b/brave-extension/manifest.json
@@ -0,0 +1,18 @@
+{
+ "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": [
+ ""
+ ],
+ "action": {
+ "default_popup": "popup.html",
+ "default_title": "Clip to Everclone"
+ }
+}
\ No newline at end of file
diff --git a/brave-extension/popup.html b/brave-extension/popup.html
new file mode 100644
index 0000000..c142d46
--- /dev/null
+++ b/brave-extension/popup.html
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+ Everclone Server URL
+
+ API Key
+
+ Save Connection
+
+
+ Title
+
+
+ Clip Format
+
+ Article
+ Selection
+ Full Page
+ Bookmark
+
+
+ Notebook
+
+ Web Clips
+ Inbox
+
+
+ Tags (comma-separated)
+
+
+ Add Remark / Note (optional)
+
+
+ Save Clip to Everclone
+
+
+
+
+
\ No newline at end of file
diff --git a/brave-extension/popup.js b/brave-extension/popup.js
new file mode 100644
index 0000000..d7205ad
--- /dev/null
+++ b/brave-extension/popup.js
@@ -0,0 +1,183 @@
+const DEFAULT_SERVER = "https://everclone.vloser.us";
+const DEFAULT_KEY = "evc_master_vloser_key_2026";
+
+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 === '') {
+ 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: '' + clone.innerHTML + '',
+ 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 || 'No text selected on page.
';
+ } else if (currentMode === 'full') {
+ payload.html = captured.bodyHtml || captured.pageHtml;
+ } else if (currentMode === 'bookmark') {
+ payload.html = `Bookmarked: ${payload.url}
`;
+ }
+
+ 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 ${notebook} ! Open Note `;
+ 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';
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..49a4176
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,42 @@
+services:
+ everclone-db:
+ image: postgres:17-alpine
+ container_name: everclone-db
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: everclone
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-everclone_secret_pw}
+ POSTGRES_DB: everclone
+ volumes:
+ - ./data/postgres:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U everclone -d everclone"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+ networks:
+ - default
+
+ everclone:
+ build: .
+ image: everclone:latest
+ container_name: everclone
+ restart: unless-stopped
+ depends_on:
+ everclone-db:
+ condition: service_healthy
+ ports:
+ - "127.0.0.1:3088:3000"
+ environment:
+ DATABASE_URL: postgresql://everclone:${POSTGRES_PASSWORD:-everclone_secret_pw}@everclone-db:5432/everclone?schema=public
+ ATTACHMENTS_DIR: /data/attachments
+ EVERCLONE_MASTER_API_KEY: ${EVERCLONE_MASTER_API_KEY:-evc_master_vloser_key_2026}
+ volumes:
+ - ./data/attachments:/data/attachments
+ networks:
+ - default
+ - t3_proxy
+
+networks:
+ t3_proxy:
+ external: true
diff --git a/entrypoint.sh b/entrypoint.sh
new file mode 100644
index 0000000..a9db91d
--- /dev/null
+++ b/entrypoint.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -e
+
+echo "Applying Prisma schema to PostgreSQL..."
+node ./node_modules/prisma/build/index.js db push --skip-generate --accept-data-loss
+
+echo "Starting Everclone server on port 3000..."
+exec node server.js
diff --git a/next.config.mjs b/next.config.mjs
new file mode 100644
index 0000000..71ead4d
--- /dev/null
+++ b/next.config.mjs
@@ -0,0 +1,17 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ output: 'standalone',
+ experimental: {
+ serverActions: {
+ bodySizeLimit: '500mb',
+ },
+ },
+ eslint: {
+ ignoreDuringBuilds: true,
+ },
+ typescript: {
+ ignoreBuildErrors: false,
+ },
+};
+
+export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..9d7a2c4
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,4954 @@
+{
+ "name": "everclone",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "everclone",
+ "version": "1.0.0",
+ "dependencies": {
+ "@mozilla/readability": "^0.5.0",
+ "@prisma/client": "^6.4.1",
+ "@tanstack/react-virtual": "^3.13.0",
+ "@tiptap/extension-color": "^2.11.5",
+ "@tiptap/extension-highlight": "^2.11.5",
+ "@tiptap/extension-image": "^2.11.5",
+ "@tiptap/extension-link": "^2.11.5",
+ "@tiptap/extension-placeholder": "^2.11.5",
+ "@tiptap/extension-subscript": "^2.11.5",
+ "@tiptap/extension-superscript": "^2.11.5",
+ "@tiptap/extension-table": "^2.11.5",
+ "@tiptap/extension-table-cell": "^2.11.5",
+ "@tiptap/extension-table-header": "^2.11.5",
+ "@tiptap/extension-table-row": "^2.11.5",
+ "@tiptap/extension-task-item": "^2.11.5",
+ "@tiptap/extension-task-list": "^2.11.5",
+ "@tiptap/extension-text-align": "^2.11.5",
+ "@tiptap/extension-text-style": "^2.11.5",
+ "@tiptap/extension-typography": "^2.11.5",
+ "@tiptap/extension-underline": "^2.11.5",
+ "@tiptap/pm": "^2.11.5",
+ "@tiptap/react": "^2.11.5",
+ "@tiptap/starter-kit": "^2.11.5",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
+ "imapflow": "^1.0.171",
+ "jsdom": "^26.0.0",
+ "jszip": "^3.10.1",
+ "lucide-react": "^0.475.0",
+ "mailparser": "^3.7.2",
+ "marked": "^15.0.7",
+ "next": "^15.2.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "sax": "^1.4.1",
+ "turndown": "^7.2.0"
+ },
+ "devDependencies": {
+ "@types/jsdom": "^21.1.7",
+ "@types/mailparser": "^3.4.6",
+ "@types/node": "^22.13.5",
+ "@types/react": "^19.0.10",
+ "@types/react-dom": "^19.0.4",
+ "@types/sax": "^1.2.7",
+ "@types/turndown": "^5.0.5",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.5.3",
+ "prisma": "^6.4.1",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.3"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz",
+ "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
+ "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
+ "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
+ "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
+ "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
+ "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
+ "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
+ "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
+ "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
+ "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
+ "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
+ "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
+ "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
+ "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
+ "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
+ "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
+ "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
+ "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
+ "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
+ "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
+ "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
+ "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
+ "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
+ "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
+ "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
+ "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
+ "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@mixmark-io/domino": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
+ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/@mozilla/readability": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.5.0.tgz",
+ "integrity": "sha512-Z+CZ3QaosfFaTqvhQsIktyGrjFjSC0Fa4EMph4mqKnWhmyoGICsV/8QK+8HpXut6zV7zwfWwqDmEjtk1Qf6EgQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "15.5.28",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.28.tgz",
+ "integrity": "sha512-xxW5EBI8fMPuVTPBW7LL980IRbuDXlbkUAwp9DDhtb4Lnich3nJ13uYpvrX4VE74hWPf6AvHHAMmGCidcHjbfg==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "15.5.28",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.28.tgz",
+ "integrity": "sha512-dPoQYXcfSIqx5o+TOXVXO7kONK5qbtNRDeiTfII0bT3puGIT6VF5e0QwmxNZfEZ5Zety5f3m9P/p4xqwfiLlPg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "15.5.28",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.28.tgz",
+ "integrity": "sha512-RgCJ7buBivTH+6sddqaVm+/v8kwMHlFkobvdt3DfETutws5HrxnToMIWSN9X3MZ3aAi7BbKrGZZWUDQLEycXqw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "15.5.28",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.28.tgz",
+ "integrity": "sha512-OHXQe13olE59aKe++zHavGcJPh5SLHXi6Anret3BBeq1+qhSuOJDNAli/EDDfxs6UTqA4Qddo4uyYry2slyjiQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "15.5.28",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.28.tgz",
+ "integrity": "sha512-nTB7bB6vfn4ezCJNYP43ukt2o8L3+WxKF/sTcTxepdXsJh3SM9RhlU7wrZYbH7L4HO5M87dqDTwm8587vAkCiw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "15.5.28",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.28.tgz",
+ "integrity": "sha512-WXCfVI32rW3xCusoUX1RpZKtyChWU2Zwe27JUDmM3KrXwcDNc9vV4zEq0GKFAURnuQ/Ouo/XqPvB0yh445XZCg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "15.5.28",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.28.tgz",
+ "integrity": "sha512-Miq+NDb/HWsXuxseYxMM6fuEOQXgTziarkKIRpVKCn8W69cEfhQaQr+90pO+u4q2CbJR2wzXmUuCFyZaJs/Nng==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "15.5.28",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.28.tgz",
+ "integrity": "sha512-5LR79lemgDlgxvG7MQJ+zHCkVcrUoQuO7CiIvq3I58vNW4goSOE2b+19Z6FujRWMouvni4gvudYFuFuNxolGWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "15.5.28",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.28.tgz",
+ "integrity": "sha512-GNipvXUYROSTf4PXEyH95FrWImIB5zdaK6uEOg0RP1/X2drnvoMwiBkJ0VWdzX7w3iiBm3y5g2TxkKDGVTOQcg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@pinojs/redact": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
+ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
+ "license": "MIT"
+ },
+ "node_modules/@popperjs/core": {
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
+ "node_modules/@prisma/client": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
+ "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "peerDependencies": {
+ "prisma": "*",
+ "typescript": ">=5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "prisma": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@prisma/config": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
+ "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "c12": "3.1.0",
+ "deepmerge-ts": "7.1.5",
+ "effect": "3.21.0",
+ "empathic": "2.0.0"
+ }
+ },
+ "node_modules/@prisma/debug": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
+ "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/engines": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
+ "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.3",
+ "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "@prisma/fetch-engine": "6.19.3",
+ "@prisma/get-platform": "6.19.3"
+ }
+ },
+ "node_modules/@prisma/engines-version": {
+ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
+ "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/fetch-engine": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
+ "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.3",
+ "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "@prisma/get-platform": "6.19.3"
+ }
+ },
+ "node_modules/@prisma/get-platform": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
+ "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.3"
+ }
+ },
+ "node_modules/@remirror/core-constants": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
+ "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
+ "license": "MIT"
+ },
+ "node_modules/@selderee/plugin-htmlparser2": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
+ "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "~2.3.0",
+ "domhandler": "~5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ },
+ "peerDependencies": {
+ "selderee": "~0.12.0"
+ }
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.14.22",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.22.tgz",
+ "integrity": "sha512-zNiB9IF3prySTfAUrj9tPHjD/lonBYkfxrPP4v4ITUx+qwHajJR5cY/xirjvwc+KWkur10GX3TpsbKh7JkD8Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.19.7"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.19.7",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.19.7.tgz",
+ "integrity": "sha512-gxy5SvH0mFRJ0YujtrflB/1HAu9Wn1KGvL0bjZfQkYVVMsIpQiwcf1mR06/ViN4NjllUb4HNCjqE9FGwgTvMCQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tiptap/core": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.3.tgz",
+ "integrity": "sha512-a5LfRbLpfaGI3hbL/LPHUYHI0I+FQHdSHsy8L4YnVIuu3hXcm3QZgkWbpEGf8hCz8krk6zEiu0+iFOjTySU2FA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/pm": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-blockquote": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.3.tgz",
+ "integrity": "sha512-NwK7FUFF0CKGJt/qNDR7plL/9vkJQBy8ziiyn8lk3j/j6tAFlCpgBMj420A1T26ItDpwOCyyaq+PMWHML+E4VQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-bold": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.3.tgz",
+ "integrity": "sha512-d5tQLAl5nHNrHNkBEgHJ0GYQ52iAsq83fUiKnDxPWuF6leKMOtkUBt9rw918p/L6MPg/MHROZ3Qt4Q+lmVYbfQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-bubble-menu": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.3.tgz",
+ "integrity": "sha512-08dt5pG9j3NTID1BnKT+FlfVgf16BPXEYtDCmPMLQq/jKHlOP49SRBAGhvYHZt8K7xkj0+JYhcgcJfytukTcKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tippy.js": "^6.3.7"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0",
+ "@tiptap/pm": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-bullet-list": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.3.tgz",
+ "integrity": "sha512-LEYkcuCCHYDm6NHWZRIl3Lpac1jXFhABZZpEj+V/ypPgPwKQSIzUMCySHXXNCUQOK3ipF0Z+O18kFnekag8ZLQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-code": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.3.tgz",
+ "integrity": "sha512-gETwHmS1NsQsBEeSOtd/erAQpiRRPyd2dg2HCrIASdiSApVbOfOnSfoHPrHDUp66S8WI5aR9j+d7e4Zz/gmorQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-code-block": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.3.tgz",
+ "integrity": "sha512-vzIt0orLs/59WlMOR8f9ULmwRLlOE1YB+zQuPdBVSRCOxVUYRUwtf7m1lOPNKKdorHadDpvWsT0hFMeNoyqQpQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0",
+ "@tiptap/pm": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-color": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-2.27.3.tgz",
+ "integrity": "sha512-+xHveJ8YfneusZIp87/8UW7VfdtCVgE5iN3JkTudC1TdYCnMqxbaIwQaGtsBH+UxinXhJSU9tcsD1P6QgKJSqg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0",
+ "@tiptap/extension-text-style": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-document": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.3.tgz",
+ "integrity": "sha512-U10TnvBa6WTjBu64U4gn/HaxkBs/96q0U6mKH0PO9Ab0n3Bf/iN4HMtix2TEa3qWlJ8peVk9/RBwjyxNVNub4w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-dropcursor": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.3.tgz",
+ "integrity": "sha512-dIBb5AdfoNx2bCnwP3W1e3qez5s/XxrBBT3agtDt+VYOdx0Mi9MgT6GpGF+zfE2nnB7I0bYPfe2Z8cBqP9CU1w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0",
+ "@tiptap/pm": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-floating-menu": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.3.tgz",
+ "integrity": "sha512-4Be2efRPxLqZT0QB/IStVdVR1wP+FVPQxw/7qJ+xRk+m7EX45LVO0hvRdMK9aMO8Yd8JK2Wh9V3DbCN6cZ89vw==",
+ "license": "MIT",
+ "dependencies": {
+ "tippy.js": "^6.3.7"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0",
+ "@tiptap/pm": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-gapcursor": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.3.tgz",
+ "integrity": "sha512-GhK8Xl0jlJJkeJhdMfItCyselxGDt44UmaYTB3mSpH3dbvjT/NvYx7ABrPlRffogvRJ6eIi4ebU5FRuGYDmAuA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0",
+ "@tiptap/pm": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-hard-break": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.3.tgz",
+ "integrity": "sha512-lvjELj0ZOgbgVNkUb3tQ0t96PLXybDTjL5nUyobkgIpkSUZTnuuXU9imiH7O/+frDiTCQ+Xp//VUB1XaMylfdg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-heading": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.3.tgz",
+ "integrity": "sha512-VWcj9b5VAhMJSAeG8xZyzCaVCcwmBGJL2k77lE2ZQaCS/jSvKUTN5ntd/1vwLrmaduciB7FmdVgOmWFcODyX1Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-highlight": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.27.3.tgz",
+ "integrity": "sha512-NalKhoLGfXelD1P+2wThcpiorINDqeNz05brko28Tg+Ue9skOKO54Uh0XCoe93Mig/MvP/KQbi578LCYz8mqmw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-history": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.3.tgz",
+ "integrity": "sha512-btT7Teg9xtWbv9q6uc38aIQbZsbMhgNu1NGLf7nLf7Vqzm+GRCpRF5EzlF9RpwEPtC2j1bxrDzpy427Su1qF7w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0",
+ "@tiptap/pm": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-horizontal-rule": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.3.tgz",
+ "integrity": "sha512-G9ENe55ykj1dt5FCNAySQUh17ev5wvvGreSt3vvaCOHBPWaEdMARTGISn30X2fSPUVntTeYSB+oPP3XClunLEw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0",
+ "@tiptap/pm": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-image": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.3.tgz",
+ "integrity": "sha512-YCOxC+UOFisHVpowPc3UmUtJDV9tjKJLeHKef3DZ6h1ixOnp2LStuhy2ohcSjISg1mZlYrx+/GoYottjwj7pww==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-italic": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.3.tgz",
+ "integrity": "sha512-ycgP6h7QQ4WXojOlH7gQEWwzNEzkIkVzdfH6jAZtZ2nw71L5KO1t5kt6Iu382CmQa60skDTuErbfiBUx7a3e2A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-link": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.3.tgz",
+ "integrity": "sha512-0KF+iweOlegWjsRDJO7EZL1bmmcghNmjPbcSvvyeF19Lh7Nit44dYV0w1JCDvweRRAXECYFXpxZTcIvSf0fEng==",
+ "license": "MIT",
+ "dependencies": {
+ "linkifyjs": "^4.3.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0",
+ "@tiptap/pm": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-list-item": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.3.tgz",
+ "integrity": "sha512-jWh5tZdNiZDx8X3jKV80EM6zMfUeRD3HKNVGcj5izqNncgH+/jJtF3hDmpP0nbFmhren8BQFo6W5f9L/GqtAVA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-ordered-list": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.3.tgz",
+ "integrity": "sha512-RvxSnE8rpiMSosD7ANCsyA+7XPEz0R6mDOFlORMqAf+NnPfCnH8dguVuKBGOdodPj69NcTSlA/6VBCS+3J0CLw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-paragraph": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.3.tgz",
+ "integrity": "sha512-Nbevu3wZk212NDpp1FaN05UbTHA4NHP2B5bAmkP8z3I+/ITvEgp7p9vFqJxZhe7fmqCSFlzhKj8ibmBc+Iu4CQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-placeholder": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.3.tgz",
+ "integrity": "sha512-t71nSCWVLVnS4QxNmZsdaqgerypVxcYVSwBDCqNvPuGLgmj665gy6sOJ+MGjGgvAaFvKb+QiDS6Lb5GoP8iM3w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0",
+ "@tiptap/pm": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-strike": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.3.tgz",
+ "integrity": "sha512-9Ax1UIRDOdPk/bGnHbKM39Bw+Zb0PV8IInlT6hr8K1or0wWZuiyE4zlKP3bhAB6IcDr4mJLJQ6TEmxKihivC5A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-subscript": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-subscript/-/extension-subscript-2.27.3.tgz",
+ "integrity": "sha512-x5Q1UVHLVP24wteh0xMSnwQ59WDKwNDOH0HzgbJ/UrDnN4f9H/haflQ4yZL0xdT2cYpvzJGPBugi2iKLcpeZjg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-superscript": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-superscript/-/extension-superscript-2.27.3.tgz",
+ "integrity": "sha512-dLZ9FBG6w5xlEeEsDR8T0nmI3ZDAYgLdkb/+bj8yOkzIUAbf9eDsE3HgqUrHPIm/7h9arJydnrdgOaxVDVlldg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-table": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.27.3.tgz",
+ "integrity": "sha512-xBDaT/ixkmrHKuCUlJNbzRolxjf0fFkHxybyeqfcGNDbVwOb6pOGZly6mGjBG1TXrx3U37y0jmFpwW6WU1zokA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0",
+ "@tiptap/pm": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-table-cell": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.27.3.tgz",
+ "integrity": "sha512-f697x9RDiva8EFZvLBxEp6Nx6sY5KRbHdNMhbCnqC1PrkRKNCTOib9ddWpqIERzs3HhmqZWL7XCPzvE/t8C+IQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-table-header": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.27.3.tgz",
+ "integrity": "sha512-x//GuYJlTzM3GavsDxBRDjGHMY9bbnmglQ0EAPVwQM5QHXpjwKzZN7wvDMM/RwxmxzYWd6Z+Fp7Zk8RYckpzhg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-table-row": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.27.3.tgz",
+ "integrity": "sha512-0ekXiw6aAESlcrKFmI90ar/j7XJmfdxeJEDeGZuMwOAQs+qq5RAB7ug5WPCZjG0Kqa+Kyxzb1jMw9pSkuTYp+w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-task-item": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.27.3.tgz",
+ "integrity": "sha512-7ES+qMoMgjb2iXI8iRzgegMeM1pE4zQAekT/5lQyuTv6oEyZw5s3TUZWeTYCq5TI/MezYGu3X2sV5PQnfBAsdA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0",
+ "@tiptap/pm": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-task-list": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.27.3.tgz",
+ "integrity": "sha512-T8Y3S95ZzYlh82KGd0H81QOsJZVWweUVps6l7wkiOReNsJxhkb4qW/smNE0TeNhvbU307emiPL0CH5e+eHxjEg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-text": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.3.tgz",
+ "integrity": "sha512-9VnSK7qXUuZevNzE0FIymbc8BbY5X+0k4NxxO2dtPBOX4IoNHcWzmpLcuCggd3uN6XYLAXhDi/w2hkmBtig8Mw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-text-align": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.3.tgz",
+ "integrity": "sha512-1gUN+rdCkuYDePfY8/AixiftkXZDqDN1czZS+e5QIZCNcYnFPIIVu4qurE9kaE0NYo46/RPhPERFBQAbK+dWFw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-text-style": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.3.tgz",
+ "integrity": "sha512-Z4ZKju7vA2vUCeWVgEWERHvnln6XtgEMcb29xW8i25dSrw5Qefn/b+ym7JPkaMvjWbbQ+5VtECiaEYdL3rRH6Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-typography": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-2.27.3.tgz",
+ "integrity": "sha512-j86lW8JM8ZdgXMhmoEDUQEh/DYOuc65fz4zM9vcr/ih6TqsYbbrTX5QBrEwvj1vFXwzY6niKYn8QMVGV2vSycw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/extension-underline": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.27.3.tgz",
+ "integrity": "sha512-9qc6WtfF7j+gwlbz7ChmwjPqDRF8T2vbHMrZLoWERAez2pOi9VfRG7lj437sc1v9LPT31FlWyIkaTDTGqYMPWA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0"
+ }
+ },
+ "node_modules/@tiptap/pm": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.3.tgz",
+ "integrity": "sha512-E9mBCSwe8YdWXvpjRMqddx4Yd5lEj+EprNM+4J+MtBXPGFZCUAa90rkOKU4m+Pn7rd1N5s7wRsExqFco79twbg==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-changeset": "^2.3.0",
+ "prosemirror-collab": "^1.3.1",
+ "prosemirror-commands": "^1.6.2",
+ "prosemirror-dropcursor": "^1.8.1",
+ "prosemirror-gapcursor": "^1.3.2",
+ "prosemirror-history": "^1.4.1",
+ "prosemirror-inputrules": "^1.4.0",
+ "prosemirror-keymap": "^1.2.2",
+ "prosemirror-markdown": "^1.13.1",
+ "prosemirror-menu": "^1.2.4",
+ "prosemirror-model": "^1.23.0",
+ "prosemirror-schema-basic": "^1.2.3",
+ "prosemirror-schema-list": "^1.4.1",
+ "prosemirror-state": "^1.4.3",
+ "prosemirror-tables": "^1.6.4",
+ "prosemirror-trailing-node": "^3.0.0",
+ "prosemirror-transform": "^1.10.2",
+ "prosemirror-view": "^1.42.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@tiptap/react": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.3.tgz",
+ "integrity": "sha512-7kqQXcRv56QQjB6nnv4P3Lc0D5ChFxzvnWmyQ70eYXZ56pxPfAlgxdViVAb17X89bZ/ypWHbpWZRrd3gUydMwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tiptap/extension-bubble-menu": "^2.27.3",
+ "@tiptap/extension-floating-menu": "^2.27.3",
+ "@types/use-sync-external-store": "^0.0.6",
+ "fast-deep-equal": "^3",
+ "use-sync-external-store": "^1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0",
+ "@tiptap/pm": "^2.7.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tiptap/starter-kit": {
+ "version": "2.27.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.3.tgz",
+ "integrity": "sha512-xqglSBavS4PDCWVFVruQPPRbmisWatXu7PZvzZSbR6HrLEX4ccRC3fNayRy9dUgDhuh2iLtEaXkVmy1Sd8DjBw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tiptap/core": "^2.27.3",
+ "@tiptap/extension-blockquote": "^2.27.3",
+ "@tiptap/extension-bold": "^2.27.3",
+ "@tiptap/extension-bullet-list": "^2.27.3",
+ "@tiptap/extension-code": "^2.27.3",
+ "@tiptap/extension-code-block": "^2.27.3",
+ "@tiptap/extension-document": "^2.27.3",
+ "@tiptap/extension-dropcursor": "^2.27.3",
+ "@tiptap/extension-gapcursor": "^2.27.3",
+ "@tiptap/extension-hard-break": "^2.27.3",
+ "@tiptap/extension-heading": "^2.27.3",
+ "@tiptap/extension-history": "^2.27.3",
+ "@tiptap/extension-horizontal-rule": "^2.27.3",
+ "@tiptap/extension-italic": "^2.27.3",
+ "@tiptap/extension-list-item": "^2.27.3",
+ "@tiptap/extension-ordered-list": "^2.27.3",
+ "@tiptap/extension-paragraph": "^2.27.3",
+ "@tiptap/extension-strike": "^2.27.3",
+ "@tiptap/extension-text": "^2.27.3",
+ "@tiptap/extension-text-style": "^2.27.3",
+ "@tiptap/pm": "^2.27.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@types/jsdom": {
+ "version": "21.1.7",
+ "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz",
+ "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/tough-cookie": "*",
+ "parse5": "^7.0.0"
+ }
+ },
+ "node_modules/@types/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/mailparser": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.9.0.tgz",
+ "integrity": "sha512-YnhrRiRVDiEAphFvd72hKa1n8y2b9Q3rgSQiONZQyhQz67h5g4MZHNApET3XWXVEFdInWtHqDHKEwnyjxU3QVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "iconv-lite": "^0.6.3"
+ }
+ },
+ "node_modules/@types/markdown-it": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz",
+ "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/linkify-it": "^5",
+ "@types/mdurl": "^2"
+ }
+ },
+ "node_modules/@types/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.20.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",
+ "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@types/sax": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
+ "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/tough-cookie": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
+ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/turndown": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
+ "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
+ "node_modules/@zone-eu/mailsplit": {
+ "version": "5.4.16",
+ "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.16.tgz",
+ "integrity": "sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==",
+ "license": "(MIT OR EUPL-1.1+)",
+ "dependencies": {
+ "libbase64": "1.3.0",
+ "libmime": "5.4.3",
+ "libqp": "2.1.1"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
+ "node_modules/atomic-sleep": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
+ "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.6.1",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz",
+ "integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.9",
+ "caniuse-lite": "^1.0.30001810",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.31",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.31.tgz",
+ "integrity": "sha512-b0k7wXlWjpuL/Uxb/pYlpjY0ej5BjAmurtPUk3eRGuloI4yjK32m8TiKUQ81l1ptcLjKS/KH2+uqIolZti1x4Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.29.4",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz",
+ "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.29",
+ "caniuse-lite": "^1.0.30001816",
+ "electron-to-chromium": "^1.5.459",
+ "node-releases": "^2.0.59",
+ "update-browserslist-db": "^1.3.4"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/c12": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
+ "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^4.0.3",
+ "confbox": "^0.2.2",
+ "defu": "^6.1.4",
+ "dotenv": "^16.6.1",
+ "exsolve": "^1.0.7",
+ "giget": "^2.0.0",
+ "jiti": "^2.4.2",
+ "ohash": "^2.0.11",
+ "pathe": "^2.0.3",
+ "perfect-debounce": "^1.0.0",
+ "pkg-types": "^2.2.0",
+ "rc9": "^2.1.2"
+ },
+ "peerDependencies": {
+ "magicast": "^0.3.5"
+ },
+ "peerDependenciesMeta": {
+ "magicast": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001819",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz",
+ "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/citty": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
+ "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "consola": "^3.2.3"
+ }
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/confbox": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
+ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/consola": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
+ "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.18.0 || >=16.10.0"
+ }
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
+ "node_modules/crelt": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
+ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
+ "license": "MIT"
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/date-fns": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
+ "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "license": "MIT"
+ },
+ "node_modules/deepmerge-ts": {
+ "version": "7.1.5",
+ "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
+ "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/defu": {
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
+ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/destr": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
+ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/effect": {
+ "version": "3.21.0",
+ "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
+ "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "fast-check": "^3.23.1"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.463",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.463.tgz",
+ "integrity": "sha512-nVWNU6ltFjopvOdD44J4zoBkgO0M+WZiFKKFV7EjebOH9z0KNphk6bCEjplXtJofL21ARIuuwWOCsFkBVklm6w==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/empathic": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
+ "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/encoding-japanese": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.3.0.tgz",
+ "integrity": "sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/exsolve": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz",
+ "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-check": {
+ "version": "3.23.2",
+ "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
+ "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "pure-rand": "^6.1.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.3",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
+ "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/giget": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
+ "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.1.6",
+ "consola": "^3.4.0",
+ "defu": "^6.1.4",
+ "node-fetch-native": "^1.6.6",
+ "nypm": "^0.6.0",
+ "pathe": "^2.0.3"
+ },
+ "bin": {
+ "giget": "dist/cli.mjs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/html-to-text": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.1.tgz",
+ "integrity": "sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "@selderee/plugin-htmlparser2": "~0.12.0",
+ "deepmerge-ts": "^8.0.1",
+ "dom-serializer": "^2.0.0",
+ "htmlparser2": "^10.1.0",
+ "selderee": "~0.12.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
+ "node_modules/html-to-text/node_modules/deepmerge-ts": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",
+ "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",
+ "funding": [
+ {
+ "type": "ko-fi",
+ "url": "https://ko-fi.com/rebeccastevens"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
+ }
+ ],
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/imapflow": {
+ "version": "1.7.8",
+ "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.8.tgz",
+ "integrity": "sha512-dJoCIdZOJh26Rn2PdwEzwj0bRDgGBxxX38pio534FagIHVuR2l0SAfLr6sJo32YfuJHiSs/U2ntNDHnMg3/Hlg==",
+ "license": "MIT",
+ "dependencies": {
+ "@zone-eu/mailsplit": "5.4.16",
+ "encoding-japanese": "2.3.0",
+ "iconv-lite": "0.7.3",
+ "libbase64": "1.3.0",
+ "libmime": "5.4.3",
+ "libqp": "2.1.1",
+ "pino": "10.3.1",
+ "socks": "2.8.9"
+ }
+ },
+ "node_modules/imapflow/node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ip-address": {
+ "version": "10.7.4",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
+ "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.17.0",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz",
+ "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "license": "MIT"
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jszip": {
+ "version": "3.10.2",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
+ "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/leac": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz",
+ "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
+ "node_modules/libbase64": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
+ "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
+ "license": "MIT"
+ },
+ "node_modules/libmime": {
+ "version": "5.4.3",
+ "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.3.tgz",
+ "integrity": "sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==",
+ "license": "MIT",
+ "dependencies": {
+ "encoding-japanese": "2.3.0",
+ "iconv-lite": "0.7.3",
+ "libbase64": "1.3.0",
+ "libqp": "2.1.1"
+ }
+ },
+ "node_modules/libmime/node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/libqp": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
+ "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
+ "license": "MIT"
+ },
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz",
+ "integrity": "sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/linkify-it": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
+ "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/markdown-it"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "uc.micro": "^2.0.0"
+ }
+ },
+ "node_modules/linkifyjs": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
+ "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC"
+ },
+ "node_modules/lucide-react": {
+ "version": "0.475.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz",
+ "integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/mailparser": {
+ "version": "3.12.4",
+ "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.12.4.tgz",
+ "integrity": "sha512-FdoMF+S89/AQaJHY7StwTfABPHZ8Vey+CDyUno5j+TO0jF0UxM+la4Pe9MeqKqBO4Y9ZMB2eomdyg5i+jzpfZw==",
+ "license": "MIT",
+ "dependencies": {
+ "@zone-eu/mailsplit": "5.4.29",
+ "he": "1.2.0",
+ "html-to-text": "10.0.1",
+ "libmime": "5.6.3",
+ "linkify-it": "5.0.2",
+ "nodemailer": "10.2.1",
+ "punycode.js": "2.3.1",
+ "tlds": "1.261.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/mailparser/node_modules/@zone-eu/mailsplit": {
+ "version": "5.4.29",
+ "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.29.tgz",
+ "integrity": "sha512-CCUNycmUjrV5M0wNf23waWAt9eOHczhjLbUQS7k0FNJvR0py9VSqHFt+tSl/D0+XI73VLG0U+PwYtq9R0zsm9Q==",
+ "license": "(MIT OR EUPL-1.1+)",
+ "dependencies": {
+ "libbase64": "1.4.1",
+ "libmime": "5.6.3",
+ "libqp": "3.0.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/mailparser/node_modules/encoding-japanese": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.4.0.tgz",
+ "integrity": "sha512-PE0KjlsDy9JSsoU4C2Q8NXb1kC4c+bZRac9ZvHXUE4q1Ht63gScKMdEy9jst9+Fi3M4eDkqzkEhBZvix4blP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/mailparser/node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/mailparser/node_modules/libbase64": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.4.1.tgz",
+ "integrity": "sha512-Nyh3H90B3gRtrcxIe56Ayy7635J0jmh6AbxbQgQae8wrSPQMH5wFqJChiMTB13yKG512L3YspbA5DzumhFTITA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/mailparser/node_modules/libmime": {
+ "version": "5.6.3",
+ "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.6.3.tgz",
+ "integrity": "sha512-cuTUW/Jybvh5O4/U9p61gyutHOjDtZyGQYuY99+bfnaFwqcDf2Nb+R6hMHY6DDen2H9wLKQSMpofWUgn2TI5JQ==",
+ "license": "MIT",
+ "dependencies": {
+ "encoding-japanese": "2.4.0",
+ "iconv-lite": "0.7.3",
+ "libbase64": "1.4.1",
+ "libqp": "3.0.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/mailparser/node_modules/libqp": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/libqp/-/libqp-3.0.1.tgz",
+ "integrity": "sha512-7QZxpPX25pga1WiMueduWxmrHMVBFrZPH6ZUaozGYa/H98JH6NVBSET3qVw+4NxzoraIW7rJ81YxSU9IalvCpg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/markdown-it": {
+ "version": "14.3.3",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz",
+ "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/markdown-it"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1",
+ "entities": "^4.5.0",
+ "linkify-it": "^5.0.2",
+ "mdurl": "^2.0.0",
+ "punycode.js": "^2.3.1",
+ "uc.micro": "^2.1.0"
+ },
+ "bin": {
+ "markdown-it": "bin/markdown-it.mjs"
+ }
+ },
+ "node_modules/markdown-it/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/marked": {
+ "version": "15.0.12",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
+ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/mdurl": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
+ "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
+ "license": "MIT"
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "15.5.28",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.5.28.tgz",
+ "integrity": "sha512-JjhLBBY1PNnuwPtcZ7t8yxVeJVYvD2Jyp633J+gRCNbzSt7N0E+xqCf6uQiff3+3qmUkwcF/ksfSsK1xsZ76Tw==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "15.5.28",
+ "@swc/helpers": "0.5.15",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "15.5.28",
+ "@next/swc-darwin-x64": "15.5.28",
+ "@next/swc-linux-arm64-gnu": "15.5.28",
+ "@next/swc-linux-arm64-musl": "15.5.28",
+ "@next/swc-linux-x64-gnu": "15.5.28",
+ "@next/swc-linux-x64-musl": "15.5.28",
+ "@next/swc-win32-arm64-msvc": "15.5.28",
+ "@next/swc-win32-x64-msvc": "15.5.28",
+ "sharp": "^0.34.3 || ^0.35.5"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/node-fetch-native": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
+ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.60",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz",
+ "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/nodemailer": {
+ "version": "10.2.1",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.1.tgz",
+ "integrity": "sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/nwsapi": {
+ "version": "2.2.28",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz",
+ "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==",
+ "license": "MIT"
+ },
+ "node_modules/nypm": {
+ "version": "0.6.11",
+ "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.11.tgz",
+ "integrity": "sha512-wrVBTLWDu7nTUKGWplQebWOXWnhaIwyKQhBxobH/CGxJHoVw27r9vG5ba7kdZnxG+f4rC6dZd6m2oHmpkOcO7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.2.2",
+ "pathe": "^2.0.3",
+ "tinyexec": "^1.3.1"
+ },
+ "bin": {
+ "nypm": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/nypm/node_modules/citty": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
+ "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/ohash": {
+ "version": "2.0.12",
+ "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz",
+ "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/on-exit-leak-free": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
+ "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/orderedmap": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
+ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
+ "license": "MIT"
+ },
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parseley": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",
+ "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==",
+ "license": "MIT",
+ "dependencies": {
+ "leac": "^0.7.0",
+ "peberminta": "^0.10.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/peberminta": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz",
+ "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
+ "node_modules/perfect-debounce": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+ "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pino": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
+ "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
+ "license": "MIT",
+ "dependencies": {
+ "@pinojs/redact": "^0.4.0",
+ "atomic-sleep": "^1.0.0",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^3.0.0",
+ "pino-std-serializers": "^7.0.0",
+ "process-warning": "^5.0.0",
+ "quick-format-unescaped": "^4.0.3",
+ "real-require": "^0.2.0",
+ "safe-stable-stringify": "^2.3.1",
+ "sonic-boom": "^4.0.1",
+ "thread-stream": "^4.0.0"
+ },
+ "bin": {
+ "pino": "bin.js"
+ }
+ },
+ "node_modules/pino-abstract-transport": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
+ "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.0.0"
+ }
+ },
+ "node_modules/pino-std-serializers": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
+ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
+ "license": "MIT"
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/pkg-types": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.4.0.tgz",
+ "integrity": "sha512-07Fys7iVx0X6TcviMOgogM1Lk33ZyqOezMnU79S+kffWqwolG/5W9V8EgQSeYCIfFTYVgssRbnH630Oa2+soBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.3.1",
+ "exsolve": "^1.1.3",
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/pkg-types/node_modules/confbox": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz",
+ "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
+ "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.20",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.5",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz",
+ "integrity": "sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prisma": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
+ "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/config": "6.19.3",
+ "@prisma/engines": "6.19.3"
+ },
+ "bin": {
+ "prisma": "build/index.js"
+ },
+ "engines": {
+ "node": ">=18.18"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
+ "node_modules/process-warning": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz",
+ "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/prosemirror-changeset": {
+ "version": "2.4.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
+ "integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-collab": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
+ "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-commands": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
+ "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.10.2"
+ }
+ },
+ "node_modules/prosemirror-dropcursor": {
+ "version": "1.8.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
+ "integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0",
+ "prosemirror-view": "^1.1.0"
+ }
+ },
+ "node_modules/prosemirror-gapcursor": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
+ "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.0.0",
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-view": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-history": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
+ "integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.2.2",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.31.0",
+ "rope-sequence": "^1.3.0"
+ }
+ },
+ "node_modules/prosemirror-inputrules": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
+ "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-keymap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+ "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "w3c-keyname": "^2.2.0"
+ }
+ },
+ "node_modules/prosemirror-markdown": {
+ "version": "1.13.9",
+ "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.9.tgz",
+ "integrity": "sha512-qAI3I9YdtjWHjTCBErc08ivr24KSrUjcTh5dYncCxFM+JS02kT0iHeWYjO35xeTPwt5t8rhFrelewXhXz16WZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/markdown-it": "^14.0.0",
+ "markdown-it": "^14.0.0",
+ "prosemirror-model": "^1.25.0"
+ }
+ },
+ "node_modules/prosemirror-menu": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz",
+ "integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==",
+ "license": "MIT",
+ "dependencies": {
+ "crelt": "^1.0.0",
+ "prosemirror-commands": "^1.0.0",
+ "prosemirror-history": "^1.0.0",
+ "prosemirror-state": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-model": {
+ "version": "1.25.12",
+ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
+ "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
+ "license": "MIT",
+ "dependencies": {
+ "orderedmap": "^2.0.0"
+ }
+ },
+ "node_modules/prosemirror-schema-basic": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.5.tgz",
+ "integrity": "sha512-McBWhfgOfThyu3Nltbs8SOUbM4t/h0pxteibaaHd1QR4S+F1t0u7M1ETaCE6/cveFoF+2fowTIBNkJOyeZQjNg==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.25.0"
+ }
+ },
+ "node_modules/prosemirror-schema-list": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+ "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.7.3"
+ }
+ },
+ "node_modules/prosemirror-state": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
+ "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.27.0"
+ }
+ },
+ "node_modules/prosemirror-tables": {
+ "version": "1.8.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
+ "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.2.3",
+ "prosemirror-model": "^1.25.4",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-transform": "^1.10.5",
+ "prosemirror-view": "^1.41.4"
+ }
+ },
+ "node_modules/prosemirror-trailing-node": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
+ "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@remirror/core-constants": "3.0.0",
+ "escape-string-regexp": "^4.0.0"
+ },
+ "peerDependencies": {
+ "prosemirror-model": "^1.22.1",
+ "prosemirror-state": "^1.4.2",
+ "prosemirror-view": "^1.33.8"
+ }
+ },
+ "node_modules/prosemirror-transform": {
+ "version": "1.12.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.4.tgz",
+ "integrity": "sha512-2/WuPum3pn+O/Vi2vzpi8210ICUe2kIPXdLYjQ09Pv5DtT5bcKWMJhAKPBwvpPjpp5iOYUVwVEr0gOM1BY1Mgw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.21.0"
+ }
+ },
+ "node_modules/prosemirror-view": {
+ "version": "1.42.7",
+ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.7.tgz",
+ "integrity": "sha512-aTIAUN4zdD+VS7gsv9Po9Sty81B6yl6D3HggCosry9DLYTmVo4omjcqICDIOPnPJmskRUsqLInxBjaH7I+BQ4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.25.12",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/pure-rand": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
+ "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/quick-format-unescaped": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
+ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
+ "license": "MIT"
+ },
+ "node_modules/rc9": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
+ "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "defu": "^6.1.4",
+ "destr": "^2.0.3"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
+ "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
+ "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.1"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz",
+ "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/real-require": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
+ "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.13",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz",
+ "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.17.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rope-sequence": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
+ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
+ "license": "MIT"
+ },
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "license": "MIT"
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/sax": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
+ "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/selderee": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
+ "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
+ "license": "MIT",
+ "dependencies": {
+ "parseley": "~0.13.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
+ "node_modules/sharp": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
+ "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.5",
+ "@img/sharp-darwin-x64": "0.35.5",
+ "@img/sharp-freebsd-wasm32": "0.35.5",
+ "@img/sharp-libvips-darwin-arm64": "1.3.4",
+ "@img/sharp-libvips-darwin-x64": "1.3.4",
+ "@img/sharp-libvips-linux-arm": "1.3.4",
+ "@img/sharp-libvips-linux-arm64": "1.3.4",
+ "@img/sharp-libvips-linux-ppc64": "1.3.4",
+ "@img/sharp-libvips-linux-riscv64": "1.3.4",
+ "@img/sharp-libvips-linux-s390x": "1.3.4",
+ "@img/sharp-libvips-linux-x64": "1.3.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.4",
+ "@img/sharp-linux-arm": "0.35.5",
+ "@img/sharp-linux-arm64": "0.35.5",
+ "@img/sharp-linux-ppc64": "0.35.5",
+ "@img/sharp-linux-riscv64": "0.35.5",
+ "@img/sharp-linux-s390x": "0.35.5",
+ "@img/sharp-linux-x64": "0.35.5",
+ "@img/sharp-linuxmusl-arm64": "0.35.5",
+ "@img/sharp-linuxmusl-x64": "0.35.5",
+ "@img/sharp-webcontainers-wasm32": "0.35.5",
+ "@img/sharp-win32-arm64": "0.35.5",
+ "@img/sharp-win32-ia32": "0.35.5",
+ "@img/sharp-win32-x64": "0.35.5"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.9",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
+ "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.1.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/sonic-boom": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
+ "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "atomic-sleep": "^1.0.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "license": "MIT"
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tailwindcss/node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/tailwindcss/node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/tailwindcss/node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/thread-stream": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
+ "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "real-require": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/thread-stream/node_modules/real-require": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
+ "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/tippy.js": {
+ "version": "6.3.7",
+ "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
+ "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@popperjs/core": "^2.9.0"
+ }
+ },
+ "node_modules/tlds": {
+ "version": "1.261.0",
+ "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
+ "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
+ "license": "MIT",
+ "bin": {
+ "tlds": "bin.js"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "license": "MIT"
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/turndown": {
+ "version": "7.2.4",
+ "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
+ "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@mixmark-io/domino": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=18",
+ "npm": ">=9"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
+ "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
+ "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..5d75889
--- /dev/null
+++ b/package.json
@@ -0,0 +1,64 @@
+{
+ "name": "everclone",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev -p 3000",
+ "build": "prisma generate && next build",
+ "start": "next start -p 3000",
+ "db:push": "prisma db push",
+ "db:generate": "prisma generate"
+ },
+ "dependencies": {
+ "@mozilla/readability": "^0.5.0",
+ "@prisma/client": "^6.4.1",
+ "@tanstack/react-virtual": "^3.13.0",
+ "@tiptap/extension-color": "^2.11.5",
+ "@tiptap/extension-highlight": "^2.11.5",
+ "@tiptap/extension-image": "^2.11.5",
+ "@tiptap/extension-link": "^2.11.5",
+ "@tiptap/extension-placeholder": "^2.11.5",
+ "@tiptap/extension-subscript": "^2.11.5",
+ "@tiptap/extension-superscript": "^2.11.5",
+ "@tiptap/extension-table": "^2.11.5",
+ "@tiptap/extension-table-cell": "^2.11.5",
+ "@tiptap/extension-table-header": "^2.11.5",
+ "@tiptap/extension-table-row": "^2.11.5",
+ "@tiptap/extension-task-item": "^2.11.5",
+ "@tiptap/extension-task-list": "^2.11.5",
+ "@tiptap/extension-text-align": "^2.11.5",
+ "@tiptap/extension-text-style": "^2.11.5",
+ "@tiptap/extension-typography": "^2.11.5",
+ "@tiptap/extension-underline": "^2.11.5",
+ "@tiptap/pm": "^2.11.5",
+ "@tiptap/react": "^2.11.5",
+ "@tiptap/starter-kit": "^2.11.5",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
+ "imapflow": "^1.0.171",
+ "jsdom": "^26.0.0",
+ "jszip": "^3.10.1",
+ "lucide-react": "^0.475.0",
+ "mailparser": "^3.7.2",
+ "marked": "^15.0.7",
+ "next": "^15.2.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "sax": "^1.4.1",
+ "turndown": "^7.2.0"
+ },
+ "devDependencies": {
+ "@types/jsdom": "^21.1.7",
+ "@types/mailparser": "^3.4.6",
+ "@types/node": "^22.13.5",
+ "@types/react": "^19.0.10",
+ "@types/react-dom": "^19.0.4",
+ "@types/sax": "^1.2.7",
+ "@types/turndown": "^5.0.5",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.5.3",
+ "prisma": "^6.4.1",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.3"
+ }
+}
diff --git a/postcss.config.mjs b/postcss.config.mjs
new file mode 100644
index 0000000..2ef30fc
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,9 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
+
+export default config;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
new file mode 100644
index 0000000..422813d
--- /dev/null
+++ b/prisma/schema.prisma
@@ -0,0 +1,187 @@
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+model User {
+ id String @id @default(cuid())
+ email String @unique
+ name String
+ avatarColor String @default("#00a82d")
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+model NotebookStack {
+ id String @id @default(cuid())
+ userId String @default("default")
+ name String
+ sortOrder Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ notebooks Notebook[]
+
+ @@unique([userId, name])
+ @@index([userId])
+}
+
+model Notebook {
+ id String @id @default(cuid())
+ userId String @default("default")
+ name String
+ stackId String?
+ stack NotebookStack? @relation(fields: [stackId], references: [id], onDelete: SetNull)
+ isDefault Boolean @default(false)
+ isShortcut Boolean @default(false)
+ sortOrder Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ notes Note[]
+
+ @@unique([userId, name])
+ @@index([userId])
+ @@index([stackId])
+}
+
+model Note {
+ id String @id @default(cuid())
+ userId String @default("default")
+ title String @default("Untitled Note")
+ contentHtml String @default("")
+ contentText String @default("")
+ excerpt String @default("")
+ notebookId String
+ notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
+ isPinned Boolean @default(false)
+ isShortcut Boolean @default(false)
+ isTrashed Boolean @default(false)
+ trashedAt DateTime?
+ thumbnailUrl String?
+ sourceUrl String?
+ author String?
+ latitude Float?
+ longitude Float?
+ reminderAt DateTime?
+ reminderDone Boolean @default(false)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now())
+ tags NoteTag[]
+ attachments Attachment[]
+
+ @@index([userId, isTrashed, isPinned, updatedAt(sort: Desc)])
+ @@index([notebookId, isTrashed, updatedAt(sort: Desc)])
+ @@index([isTrashed, isPinned, updatedAt(sort: Desc)])
+ @@index([isShortcut, isTrashed])
+ @@index([createdAt(sort: Desc)])
+ @@index([title])
+}
+
+model Tag {
+ id String @id @default(cuid())
+ userId String @default("default")
+ name String
+ color String @default("#00a82d")
+ parentId String?
+ parent Tag? @relation("TagHierarchy", fields: [parentId], references: [id], onDelete: SetNull)
+ children Tag[] @relation("TagHierarchy")
+ isShortcut Boolean @default(false)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ notes NoteTag[]
+
+ @@unique([userId, name])
+ @@index([userId])
+ @@index([parentId])
+}
+
+model NoteTag {
+ noteId String
+ tagId String
+ note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
+ tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
+ assignedAt DateTime @default(now())
+
+ @@id([noteId, tagId])
+ @@index([tagId])
+}
+
+model Attachment {
+ id String @id @default(cuid())
+ noteId String
+ note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
+ filename String
+ mimeType String
+ sizeBytes Int
+ md5Hash String
+ storagePath String
+ width Int?
+ height Int?
+ extractedText String?
+ createdAt DateTime @default(now())
+
+ @@index([noteId])
+ @@index([md5Hash])
+}
+
+model SavedSearch {
+ id String @id @default(cuid())
+ userId String @default("default")
+ name String
+ query String
+ isShortcut Boolean @default(false)
+ sortOrder Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([userId, name])
+ @@index([userId])
+}
+
+model ApiKey {
+ id String @id @default(cuid())
+ userId String @default("default")
+ name String
+ keyHash String @unique
+ keyPrefix String
+ lastUsedAt DateTime?
+ createdAt DateTime @default(now())
+}
+
+model ImportJob {
+ id String @id @default(cuid())
+ userId String @default("default")
+ sourceType String
+ filename String
+ status String @default("running")
+ totalNotes Int @default(0)
+ processedNotes Int @default(0)
+ failedNotes Int @default(0)
+ errorLog String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+model EmailConfig {
+ id String @id @default("default")
+ userId String @default("default")
+ enabled Boolean @default(false)
+ imapHost String @default("imap.gmail.com")
+ imapPort Int @default(993)
+ imapSecure Boolean @default(true)
+ imapUser String @default("")
+ imapPassword String @default("")
+ mailbox String @default("INBOX")
+ subjectFilter String @default("")
+ allowedSenders String @default("")
+ defaultNotebook String @default("Inbox")
+ defaultTags String @default("emailed")
+ markSeen Boolean @default(true)
+ lastCheckedAt DateTime?
+ lastStatus String?
+ emailsImported Int @default(0)
+ updatedAt DateTime @updatedAt
+}
diff --git a/public/icon.svg b/public/icon.svg
new file mode 100644
index 0000000..39a5990
--- /dev/null
+++ b/public/icon.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..942bb83
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,33 @@
+{
+ "name": "Everclone",
+ "short_name": "Everclone",
+ "description": "Self-hosted Evernote clone for notes, web clips, and attachments",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#1a1a1a",
+ "theme_color": "#00a82d",
+ "icons": [
+ {
+ "src": "/icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ],
+ "share_target": {
+ "action": "/api/hooks/note?source=share-target",
+ "method": "POST",
+ "enctype": "multipart/form-data",
+ "params": {
+ "title": "title",
+ "text": "text",
+ "url": "url",
+ "files": [
+ {
+ "name": "files",
+ "accept": ["image/*", "audio/*", "application/pdf", "text/*"]
+ }
+ ]
+ }
+ }
+}
diff --git a/src/app/api/email/route.ts b/src/app/api/email/route.ts
new file mode 100644
index 0000000..bf9cd15
--- /dev/null
+++ b/src/app/api/email/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts
new file mode 100644
index 0000000..785e484
--- /dev/null
+++ b/src/app/api/export/route.ts
@@ -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 = `
+
+
+
+ ${baseFilename}
+
+
+
+${notes
+ .map(
+ (n) => `
+ ${n.title}
+ Notebook: ${n.notebook.name} | Created: ${n.createdAt.toISOString()} | Updated: ${n.updatedAt.toISOString()}
+ ${n.contentHtml}
+ `
+ )
+ .join('\n')}
+
+`;
+
+ 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 }
+ );
+ }
+}
diff --git a/src/app/api/extension/route.ts b/src/app/api/extension/route.ts
new file mode 100644
index 0000000..e6fc66d
--- /dev/null
+++ b/src/app/api/extension/route.ts
@@ -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: [''],
+ action: {
+ default_popup: 'popup.html',
+ default_title: 'Clip to Everclone',
+ },
+ };
+
+ const popupHtml = `
+
+
+
+
+
+
+
+
+
+ Everclone Server URL
+
+ API Key
+
+ Save Connection
+
+
+ Title
+
+
+ Clip Format
+
+ Article
+ Selection
+ Full Page
+ Bookmark
+
+
+ Notebook
+
+ Web Clips
+ Inbox
+
+
+ Tags (comma-separated)
+
+
+ Add Remark / Note (optional)
+
+
+ Save Clip to Everclone
+
+
+
+
+`;
+
+ 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 === '') {
+ 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: '' + clone.innerHTML + '',
+ 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 || 'No text selected on page.
';
+ } else if (currentMode === 'full') {
+ payload.html = captured.bodyHtml || captured.pageHtml;
+ } else if (currentMode === 'bookmark') {
+ payload.html = \`Bookmarked: \${payload.url}
\`;
+ }
+
+ 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 \${notebook} ! Open Note \`;
+ 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 }
+ );
+ }
+}
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
new file mode 100644
index 0000000..2714b83
--- /dev/null
+++ b/src/app/api/health/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/hooks/email/route.ts b/src/app/api/hooks/email/route.ts
new file mode 100644
index 0000000..3dbccb8
--- /dev/null
+++ b/src/app/api/hooks/email/route.ts
@@ -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 , X-API-Key header, or ?apiKey=',
+ },
+ { 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 }
+ );
+ }
+}
diff --git a/src/app/api/hooks/note/route.ts b/src/app/api/hooks/note/route.ts
new file mode 100644
index 0000000..57ce89d
--- /dev/null
+++ b/src/app/api/hooks/note/route.ts
@@ -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 , X-API-Key header, or ?apiKey=',
+ },
+ { 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 = `
\n${extractedHtml}`;
+ }
+ if (comment.trim()) {
+ extractedHtml = `${comment
+ .trim()
+ .replace(/\n/g, ' ')} \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 = '
';
+ }
+ if (comment.trim()) {
+ finalHtml = `${comment
+ .trim()
+ .replace(/\n/g, ' ')} \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 in selection/HTML clip
+ if (finalHtml.includes('
`;
+ } else if (mimeType.startsWith('audio/')) {
+ appendedMediaHtml += `
`;
+ } else {
+ appendedMediaHtml += `π ${filename}
`;
+ }
+ }
+
+ 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
+ ? `${text.replace(/\n/g, ' ')}
${
+ url ? `Source: ${url}
` : ''
+ }`
+ : `${url}
`;
+ 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 }
+ );
+ }
+}
diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts
new file mode 100644
index 0000000..ba4fd2d
--- /dev/null
+++ b/src/app/api/import/route.ts
@@ -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 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 `` 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(//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 ... , and import
+ let carry = '';
+ for await (const chunk of createReadStream(filePath, { encoding: 'utf8', highWaterMark: 1024 * 1024 })) {
+ carry += chunk;
+
+ while (true) {
+ const startIdx = carry.indexOf('');
+ if (startIdx === -1) {
+ if (carry.length > 64) carry = carry.slice(-64);
+ break;
+ }
+ const closeIdx = carry.indexOf(' ', 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 = `${singleNoteXml} `;
+ 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 }
+ );
+ }
+}
diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts
new file mode 100644
index 0000000..bb65e5a
--- /dev/null
+++ b/src/app/api/keys/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/notebooks/route.ts b/src/app/api/notebooks/route.ts
new file mode 100644
index 0000000..6250e67
--- /dev/null
+++ b/src/app/api/notebooks/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/notes/[id]/route.ts b/src/app/api/notes/[id]/route.ts
new file mode 100644
index 0000000..69c65e4
--- /dev/null
+++ b/src/app/api/notes/[id]/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts
new file mode 100644
index 0000000..b9c3faf
--- /dev/null
+++ b/src/app/api/notes/route.ts
@@ -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 || '
';
+ 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 }
+ );
+ }
+}
diff --git a/src/app/api/saved-searches/route.ts b/src/app/api/saved-searches/route.ts
new file mode 100644
index 0000000..e7e058e
--- /dev/null
+++ b/src/app/api/saved-searches/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/sidebar/route.ts b/src/app/api/sidebar/route.ts
new file mode 100644
index 0000000..ccd3a1c
--- /dev/null
+++ b/src/app/api/sidebar/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts
new file mode 100644
index 0000000..ba0b5a9
--- /dev/null
+++ b/src/app/api/tags/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts
new file mode 100644
index 0000000..8a51fe2
--- /dev/null
+++ b/src/app/api/users/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..53c6845
--- /dev/null
+++ b/src/app/globals.css
@@ -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;
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..9a11ea6
--- /dev/null
+++ b/src/app/layout.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
new file mode 100644
index 0000000..cacfb16
--- /dev/null
+++ b/src/app/page.tsx
@@ -0,0 +1,8 @@
+import EvercloneWorkspace from '@/components/EvercloneWorkspace';
+
+export const dynamic = 'force-dynamic';
+export const revalidate = 0;
+
+export default function HomePage() {
+ return ;
+}
diff --git a/src/components/EvercloneWorkspace.tsx b/src/components/EvercloneWorkspace.tsx
new file mode 100644
index 0000000..d77a8ab
--- /dev/null
+++ b/src/components/EvercloneWorkspace.tsx
@@ -0,0 +1,3008 @@
+'use client';
+
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { formatDistanceToNow, format } from 'date-fns';
+import {
+ Plus,
+ Search,
+ Star,
+ FileText,
+ Book,
+ Folder,
+ Tag as TagIcon,
+ Bookmark,
+ Trash2,
+ Upload,
+ Smartphone,
+ Pin,
+ Info,
+ Download,
+ ChevronRight,
+ ChevronDown,
+ MoreHorizontal,
+ ExternalLink,
+ Paperclip,
+ RotateCcw,
+ Clock,
+ Check,
+ X,
+ PanelLeftClose,
+ PanelLeftOpen,
+ LayoutList,
+ LayoutGrid,
+ AlignJustify,
+ Sparkles,
+ Settings,
+ Image as ImageIcon,
+ Crop,
+ Filter,
+ CheckSquare,
+ Globe,
+ Menu,
+ ArrowLeft,
+} from 'lucide-react';
+import {
+ SidebarData,
+ NoteSummary,
+ NoteDetail,
+ NotebookItem,
+} from '@/types/everclone';
+import RichNoteEditor from './RichNoteEditor';
+import SettingsView from './SettingsView';
+import ThumbnailEditorModal from './ThumbnailEditorModal';
+import { PdfAutoThumbnail, InlinePdfViewerCard } from './PdfNoteComponents';
+
+export default function EvercloneWorkspace() {
+ const [sidebar, setSidebar] = useState({
+ stacks: [],
+ notebooks: [],
+ tags: [],
+ savedSearches: [],
+ shortcuts: { notes: [], notebooks: [], tags: [], savedSearches: [] },
+ counts: { allNotes: 0, trash: 0 },
+ });
+
+ // Navigation filter state
+ const [activeView, setActiveView] = useState<
+ 'all' | 'shortcuts' | 'notebook' | 'stack' | 'tag' | 'trash' | 'settings'
+ >('all');
+ const [selectedNotebookId, setSelectedNotebookId] = useState('');
+ const [selectedStackId, setSelectedStackId] = useState('');
+ const [selectedTagId, setSelectedTagId] = useState('');
+ const [searchQuery, setSearchQuery] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+ const [sortBy, setSortBy] = useState<'updatedAt' | 'createdAt' | 'title'>('updatedAt');
+ const [sortDir, setSortDir] = useState<'desc' | 'asc'>('desc');
+ const [listMode, setListMode] = useState<'snippets' | 'cards' | 'compact'>('snippets');
+
+ // Interactive Filter Bar state
+ const [showFilterPanel, setShowFilterPanel] = useState(false);
+ const [tagFilterDropdownOpen, setTagFilterDropdownOpen] = useState(false);
+ const [tagFilterSearch, setTagFilterSearch] = useState('');
+ const [sidebarTagSearch, setSidebarTagSearch] = useState('');
+ const [filterNotebookId, setFilterNotebookId] = useState('');
+ const [filterTagIds, setFilterTagIds] = useState([]);
+ const [filterUntagged, setFilterUntagged] = useState(false);
+ const [filterHasImages, setFilterHasImages] = useState(false);
+ const [filterHasAttachments, setFilterHasAttachments] = useState(false);
+ const [filterHasTodo, setFilterHasTodo] = useState(false);
+ const [filterHasWebClip, setFilterHasWebClip] = useState(false);
+ const [filterOnlyPinned, setFilterOnlyPinned] = useState(false);
+ const [filterUpdatedDays, setFilterUpdatedDays] = useState(0);
+
+ // Multi-user profile switcher state
+ const [userMenuOpen, setUserMenuOpen] = useState(false);
+ const [creatingUser, setCreatingUser] = useState(false);
+ const [newUserName, setNewUserName] = useState('');
+ const [newUserEmail, setNewUserEmail] = useState('');
+
+ const activeFilterCount =
+ (filterNotebookId ? 1 : 0) +
+ filterTagIds.length +
+ (filterUntagged ? 1 : 0) +
+ (filterHasImages ? 1 : 0) +
+ (filterHasAttachments ? 1 : 0) +
+ (filterHasTodo ? 1 : 0) +
+ (filterHasWebClip ? 1 : 0) +
+ (filterOnlyPinned ? 1 : 0) +
+ (filterUpdatedDays > 0 ? 1 : 0);
+
+ const clearAllFilters = () => {
+ setFilterNotebookId('');
+ setFilterTagIds([]);
+ setFilterUntagged(false);
+ setFilterHasImages(false);
+ setFilterHasAttachments(false);
+ setFilterHasTodo(false);
+ setFilterHasWebClip(false);
+ setFilterOnlyPinned(false);
+ setFilterUpdatedDays(0);
+ setSearchQuery('');
+ };
+
+ // UI collapse & modals
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+ const [mobilePane, setMobilePane] = useState<'list' | 'editor'>('list');
+ const [expandedSections, setExpandedSections] = useState>({
+ shortcuts: true,
+ notebooks: true,
+ tags: false,
+ savedSearches: false,
+ });
+ const [expandedStacks, setExpandedStacks] = useState>({});
+ const [showNoteInfo, setShowNoteInfo] = useState(false);
+ const [showExportMenu, setShowExportMenu] = useState(false);
+ const [attachmentsExpanded, setAttachmentsExpanded] = useState(false);
+ const [thumbnailEditorOpen, setThumbnailEditorOpen] = useState(false);
+ const [thumbnailInitialUrl, setThumbnailInitialUrl] = useState(null);
+
+ // Notes list & active note detail
+ const [notes, setNotes] = useState([]);
+ const [totalNotes, setTotalNotes] = useState(0);
+ const [loadingList, setLoadingList] = useState(true);
+ const [selectedNoteId, setSelectedNoteId] = useState('');
+ const [activeNote, setActiveNote] = useState(null);
+ const [saveState, setSaveState] = useState<'saved' | 'saving' | 'dirty'>('saved');
+
+ // Tag input in bottom bar
+ const [tagInput, setTagInput] = useState('');
+
+ // Quick creation dialogs in sidebar
+ const [creatingNotebook, setCreatingNotebook] = useState(false);
+ const [newNotebookName, setNewNotebookName] = useState('');
+ const [newNotebookStack, setNewNotebookStack] = useState('');
+ const [creatingTag, setCreatingTag] = useState(false);
+ const [newTagName, setNewTagName] = useState('');
+
+ const searchInputRef = useRef(null);
+ const saveTimerRef = useRef(null);
+ const listParentRef = useRef(null);
+
+ // Debounce search query
+ useEffect(() => {
+ const t = setTimeout(() => setDebouncedSearch(searchQuery), 220);
+ return () => clearTimeout(t);
+ }, [searchQuery]);
+
+ // Keyboard shortcut Ctrl+K for search, Alt+N for new note
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
+ e.preventDefault();
+ searchInputRef.current?.focus();
+ }
+ };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, []);
+
+ const fetchSidebar = useCallback(async () => {
+ try {
+ const res = await fetch('/api/sidebar');
+ if (!res.ok) return;
+ const data: SidebarData = await res.json();
+ setSidebar(data);
+ } catch (e) {
+ console.error('Failed to fetch sidebar:', e);
+ }
+ }, []);
+
+ const fetchNotesList = useCallback(
+ async (preferSelectNoteId?: string) => {
+ setLoadingList(true);
+ try {
+ const sp = new URLSearchParams();
+ if (debouncedSearch.trim()) sp.set('q', debouncedSearch.trim());
+ if (activeView === 'notebook' && selectedNotebookId) {
+ sp.set('notebookId', selectedNotebookId);
+ } else if (filterNotebookId) {
+ sp.set('notebookId', filterNotebookId);
+ }
+ if (activeView === 'stack' && selectedStackId) {
+ sp.set('stackId', selectedStackId);
+ }
+ if (activeView === 'tag' && selectedTagId) {
+ sp.set('tagId', selectedTagId);
+ }
+ if (filterTagIds.length > 0) {
+ sp.set('tagIds', filterTagIds.join(','));
+ }
+ if (filterUntagged) sp.set('untagged', 'true');
+ if (filterHasImages) sp.set('hasImages', 'true');
+ if (filterHasAttachments) sp.set('hasAttachments', 'true');
+ if (filterHasTodo) sp.set('hasTodo', 'true');
+ if (filterHasWebClip) sp.set('hasWebClip', 'true');
+ if (filterOnlyPinned) sp.set('onlyPinned', 'true');
+ if (filterUpdatedDays > 0) sp.set('updatedSinceDays', String(filterUpdatedDays));
+ if (activeView === 'trash') {
+ sp.set('trashed', 'true');
+ } else if (activeView === 'shortcuts') {
+ sp.set('shortcut', 'true');
+ }
+ sp.set('sortBy', sortBy);
+ sp.set('sortDir', sortDir);
+ sp.set('limit', '1000');
+
+ const res = await fetch(`/api/notes?${sp.toString()}`);
+ if (!res.ok) return;
+ const data = await res.json();
+ const loadedNotes: NoteSummary[] = data.notes || [];
+ setNotes(loadedNotes);
+ setTotalNotes(data.total || 0);
+
+ if (preferSelectNoteId) {
+ setSelectedNoteId(preferSelectNoteId);
+ } else if (loadedNotes.length > 0) {
+ setSelectedNoteId((prev) =>
+ prev && loadedNotes.some((n) => n.id === prev) ? prev : loadedNotes[0].id
+ );
+ } else {
+ setSelectedNoteId('');
+ setActiveNote(null);
+ }
+ } catch (e) {
+ console.error('Failed to fetch notes:', e);
+ } finally {
+ setLoadingList(false);
+ }
+ },
+ [
+ debouncedSearch,
+ activeView,
+ selectedNotebookId,
+ selectedStackId,
+ selectedTagId,
+ filterNotebookId,
+ filterTagIds,
+ filterUntagged,
+ filterHasImages,
+ filterHasAttachments,
+ filterHasTodo,
+ filterHasWebClip,
+ filterOnlyPinned,
+ filterUpdatedDays,
+ sortBy,
+ sortDir,
+ ]
+ );
+
+ useEffect(() => {
+ fetchSidebar();
+ }, [fetchSidebar]);
+
+ useEffect(() => {
+ fetchNotesList();
+ }, [fetchNotesList]);
+
+ // Fetch full note details when selectedNoteId changes
+ useEffect(() => {
+ if (!selectedNoteId) {
+ setActiveNote(null);
+ return;
+ }
+ let cancelled = false;
+ (async () => {
+ try {
+ const res = await fetch(`/api/notes/${selectedNoteId}`);
+ if (!res.ok) return;
+ const data: NoteDetail = await res.json();
+ if (!cancelled) {
+ setActiveNote(data);
+ setSaveState('saved');
+ }
+ } catch (e) {
+ console.error('Error loading note detail:', e);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [selectedNoteId]);
+
+ // Auto-save note edits with debounce
+ const scheduleNoteSave = useCallback(
+ (noteId: string, patch: Record) => {
+ setSaveState('dirty');
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+
+ saveTimerRef.current = setTimeout(async () => {
+ setSaveState('saving');
+ try {
+ const res = await fetch(`/api/notes/${noteId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(patch),
+ });
+ if (res.ok) {
+ const updated: NoteDetail = await res.json();
+ setNotes((prev) =>
+ prev.map((n) =>
+ n.id === noteId
+ ? {
+ ...n,
+ title: updated.title,
+ excerpt: updated.excerpt,
+ notebookId: updated.notebookId,
+ notebook: updated.notebook,
+ isPinned: updated.isPinned,
+ isShortcut: updated.isShortcut,
+ thumbnailUrl: updated.thumbnailUrl,
+ sourceUrl: updated.sourceUrl,
+ updatedAt: updated.updatedAt,
+ tags: updated.tags,
+ }
+ : n
+ )
+ );
+ setSaveState('saved');
+ }
+ } catch (e) {
+ console.error('Auto-save failed:', e);
+ }
+ }, 550);
+ },
+ []
+ );
+
+ const handleCreateNote = async () => {
+ const targetNbId =
+ activeView === 'notebook' && selectedNotebookId
+ ? selectedNotebookId
+ : sidebar.notebooks.find((n) => n.isDefault)?.id || sidebar.notebooks[0]?.id;
+
+ const activeTag =
+ activeView === 'tag' && selectedTagId
+ ? sidebar.tags.find((t) => t.id === selectedTagId)?.name
+ : undefined;
+
+ const res = await fetch('/api/notes', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: 'Untitled Note',
+ contentHtml: '
',
+ notebookId: targetNbId,
+ tagNames: activeTag ? [activeTag] : [],
+ }),
+ });
+
+ if (res.ok) {
+ const created: NoteDetail = await res.json();
+ if (activeView === 'trash' || activeView === 'settings') setActiveView('all');
+ setMobileMenuOpen(false);
+ openNoteInEditor(created.id);
+ await fetchSidebar();
+ await fetchNotesList(created.id);
+ setActiveNote(created);
+ }
+ };
+
+ const handleImmediatePatch = async (patch: Record) => {
+ if (!activeNote) return;
+ const res = await fetch(`/api/notes/${activeNote.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(patch),
+ });
+ if (res.ok) {
+ const updated: NoteDetail = await res.json();
+ setActiveNote(updated);
+ await fetchSidebar();
+ await fetchNotesList(updated.id);
+ }
+ };
+
+ const handleDeleteOrTrashNote = async (permanent = false) => {
+ if (!activeNote) return;
+ if (permanent && !window.confirm('Permanently delete this note? This cannot be undone.')) {
+ return;
+ }
+ await fetch(`/api/notes/${activeNote.id}?permanent=${permanent ? 'true' : 'false'}`, {
+ method: 'DELETE',
+ });
+ await fetchSidebar();
+ await fetchNotesList();
+ };
+
+ const handleEmptyTrash = async () => {
+ if (!window.confirm('Permanently delete all notes in Trash?')) return;
+ await fetch('/api/notes', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'emptyTrash' }),
+ });
+ await fetchSidebar();
+ await fetchNotesList();
+ };
+
+ const handleUploadAttachments = async (files: File[]) => {
+ if (!activeNote) return [];
+ const formData = new FormData();
+ formData.append('noteId', activeNote.id);
+ for (const f of files) {
+ formData.append('files', f);
+ }
+ const res = await fetch('/api/attachments', {
+ method: 'POST',
+ body: formData,
+ });
+ if (!res.ok) return [];
+ const data = await res.json();
+ const newAttachments = data.attachments || [];
+ setActiveNote((prev) =>
+ prev
+ ? {
+ ...prev,
+ attachments: [...prev.attachments, ...newAttachments],
+ }
+ : prev
+ );
+ return newAttachments;
+ };
+
+ const handleDeleteAttachment = async (attId: string) => {
+ await fetch(`/api/attachments/${attId}`, { method: 'DELETE' });
+ setActiveNote((prev) =>
+ prev
+ ? {
+ ...prev,
+ attachments: prev.attachments.filter((a) => a.id !== attId),
+ }
+ : prev
+ );
+ };
+
+ const handleAddTagToNote = async (rawTagName: string) => {
+ const clean = rawTagName.trim();
+ if (!clean || !activeNote) return;
+ const existingNames = activeNote.tags.map((t) => t.name);
+ if (existingNames.some((n) => n.toLowerCase() === clean.toLowerCase())) {
+ setTagInput('');
+ return;
+ }
+ const updatedNames = [...existingNames, clean];
+ setTagInput('');
+ await handleImmediatePatch({ tagNames: updatedNames });
+ };
+
+ const handleRemoveTagFromNote = async (tagName: string) => {
+ if (!activeNote) return;
+ const updatedNames = activeNote.tags
+ .map((t) => t.name)
+ .filter((n) => n !== tagName);
+ await handleImmediatePatch({ tagNames: updatedNames });
+ };
+
+ const handleCreateNotebook = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!newNotebookName.trim()) return;
+ await fetch('/api/notebooks', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: newNotebookName.trim(),
+ stackName: newNotebookStack.trim() || undefined,
+ }),
+ });
+ setNewNotebookName('');
+ setNewNotebookStack('');
+ setCreatingNotebook(false);
+ await fetchSidebar();
+ };
+
+ const handleCreateTag = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!newTagName.trim()) return;
+ await fetch('/api/tags', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: newTagName.trim() }),
+ });
+ setNewTagName('');
+ setCreatingTag(false);
+ await fetchSidebar();
+ };
+
+ // Android / Browser Back Button support when zoomed into a note or drawer on mobile
+ const openNoteInEditor = useCallback(
+ (noteId: string) => {
+ setSelectedNoteId(noteId);
+ if (typeof window !== 'undefined' && window.innerWidth < 768 && mobilePane !== 'editor') {
+ window.history.pushState({ evcMobilePane: 'editor', noteId }, '');
+ }
+ setMobilePane('editor');
+ },
+ [mobilePane]
+ );
+
+ useEffect(() => {
+ const onPopState = () => {
+ if (mobileMenuOpen) {
+ setMobileMenuOpen(false);
+ } else if (activeView === 'settings') {
+ setActiveView('all');
+ } else if (mobilePane === 'editor') {
+ setMobilePane('list');
+ }
+ };
+ window.addEventListener('popstate', onPopState);
+ return () => window.removeEventListener('popstate', onPopState);
+ }, [mobileMenuOpen, activeView, mobilePane]);
+
+ const handleSaveCurrentSearch = async () => {
+ if (!searchQuery.trim()) return;
+ const name = window.prompt('Name for this Saved Search:', searchQuery.trim());
+ if (!name?.trim()) return;
+ await fetch('/api/saved-searches', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: name.trim(),
+ query: searchQuery.trim(),
+ isShortcut: true,
+ }),
+ });
+ await fetchSidebar();
+ };
+
+ const handleMoveNoteToNotebook = async (noteId: string, notebookId: string) => {
+ if (!noteId || !notebookId) return;
+ const res = await fetch(`/api/notes/${noteId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ notebookId }),
+ });
+ if (res.ok) {
+ const updated: NoteDetail = await res.json();
+ if (activeNote?.id === noteId) {
+ setActiveNote(updated);
+ }
+ await fetchSidebar();
+ await fetchNotesList(selectedNoteId);
+ }
+ };
+
+ const handleSwitchUser = async (userId: string) => {
+ setUserMenuOpen(false);
+ const res = await fetch('/api/users', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'switch', userId }),
+ });
+ if (res.ok) {
+ setSelectedNoteId('');
+ setActiveNote(null);
+ setActiveView('all');
+ clearAllFilters();
+ await fetchSidebar();
+ await fetchNotesList();
+ }
+ };
+
+ const handleCreateUser = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!newUserName.trim()) return;
+ const res = await fetch('/api/users', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: newUserName.trim(),
+ email: newUserEmail.trim() || undefined,
+ switchImmediately: true,
+ }),
+ });
+ if (res.ok) {
+ setNewUserName('');
+ setNewUserEmail('');
+ setCreatingUser(false);
+ setUserMenuOpen(false);
+ setSelectedNoteId('');
+ setActiveNote(null);
+ setActiveView('all');
+ clearAllFilters();
+ await fetchSidebar();
+ await fetchNotesList();
+ }
+ };
+
+ // Virtualizer for middle note list pane
+ const rowVirtualizer = useVirtualizer({
+ count: notes.length,
+ getScrollElement: () => listParentRef.current,
+ estimateSize: () =>
+ listMode === 'compact' ? 52 : listMode === 'cards' ? 148 : 112,
+ overscan: 10,
+ });
+
+ // Compute current view title
+ const currentViewTitle = (() => {
+ if (activeView === 'trash') return 'Trash';
+ if (activeView === 'shortcuts') return 'Shortcuts';
+ if (activeView === 'notebook') {
+ return (
+ sidebar.notebooks.find((n) => n.id === selectedNotebookId)?.name || 'Notebook'
+ );
+ }
+ if (activeView === 'stack') {
+ return (
+ sidebar.stacks.find((s) => s.id === selectedStackId)?.name || 'Notebook Stack'
+ );
+ }
+ if (activeView === 'tag') {
+ return `#${sidebar.tags.find((t) => t.id === selectedTagId)?.name || 'Tag'}`;
+ }
+ return 'All Notes';
+ })();
+
+ const notebooksByStack = sidebar.stacks.map((st) => ({
+ stack: st,
+ notebooks: sidebar.notebooks.filter((nb) => nb.stackId === st.id),
+ }));
+ const unstackedNotebooks = sidebar.notebooks.filter((nb) => !nb.stackId);
+
+ const wordCount = activeNote?.contentText
+ ? activeNote.contentText.trim().split(/\s+/).filter(Boolean).length
+ : 0;
+
+ return (
+
+ {/* Mobile Sidebar Backdrop */}
+ {mobileMenuOpen && (
+
setMobileMenuOpen(false)}
+ className="fixed inset-0 bg-black/55 z-40 md:hidden backdrop-blur-xs"
+ />
+ )}
+
+ {/* 1. LEFT SIDEBAR (Classic Evernote Dark Theme, Slide-Over Drawer on Mobile) */}
+
+ {/* Brand Header */}
+
+ {!sidebarCollapsed && (
+
+
+ E
+
+
+
+ Everclone
+
+
+ Personal Knowledge Base
+
+
+
+ )}
+
+
setMobileMenuOpen(false)}
+ title="Close Menu"
+ className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-neutral-800 md:hidden"
+ >
+
+
+
setSidebarCollapsed((c) => !c)}
+ title={sidebarCollapsed ? 'Expand Sidebar' : 'Collapse Sidebar'}
+ className="hidden md:inline-flex p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-neutral-800"
+ >
+ {sidebarCollapsed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Search & New Note Buttons */}
+
+ {!sidebarCollapsed && (
+
+
+
setSearchQuery(e.target.value)}
+ placeholder="Search notes (tag:, intitle:)β¦"
+ className="w-full pl-8 pr-14 py-1.5 bg-neutral-800 text-xs text-white placeholder-gray-400 rounded-lg border border-neutral-700 focus:outline-none focus:border-evernote-green"
+ />
+ {searchQuery && (
+
+
+
+
+ setSearchQuery('')}
+ title="Clear search"
+ className="p-1 text-gray-400 hover:text-white"
+ >
+
+
+
+ )}
+
+ )}
+
+
+
+ {!sidebarCollapsed && New Note }
+
+
+
{
+ setActiveView('all');
+ setSelectedNotebookId('');
+ setSelectedStackId('');
+ setSelectedTagId('');
+ clearAllFilters();
+ setMobileMenuOpen(false);
+ setMobilePane('list');
+ }}
+ title="All Notes"
+ className={`w-full flex items-center justify-between py-2 px-3 rounded-xl text-xs font-semibold border transition ${
+ activeView === 'all' && activeFilterCount === 0 && !searchQuery
+ ? 'bg-emerald-950/80 border-evernote-green text-white shadow-xs'
+ : 'bg-neutral-800 hover:bg-neutral-700 border-neutral-700 text-gray-200'
+ }`}
+ >
+
+
+ {!sidebarCollapsed && All Notes }
+
+ {!sidebarCollapsed && (
+
+ {sidebar.counts.allNotes}
+
+ )}
+
+
+
+ {/* Navigation Tree */}
+
+ {/* All Notes */}
+
{
+ setActiveView('all');
+ setSelectedNotebookId('');
+ setSelectedStackId('');
+ setSelectedTagId('');
+ setMobileMenuOpen(false);
+ setMobilePane('list');
+ }}
+ className={`w-full flex items-center justify-between px-2.5 py-2 rounded-lg transition ${
+ activeView === 'all'
+ ? 'bg-evernote-sidebarActive text-white font-medium'
+ : 'hover:bg-evernote-sidebarHover text-gray-300'
+ }`}
+ >
+
+
+ {!sidebarCollapsed && All Notes }
+
+ {!sidebarCollapsed && (
+ {sidebar.counts.allNotes}
+ )}
+
+
+ {/* Shortcuts Section */}
+
+
+ sidebarCollapsed
+ ? (setActiveView('shortcuts'), setMobileMenuOpen(false), setMobilePane('list'))
+ : setExpandedSections((s) => ({ ...s, shortcuts: !s.shortcuts }))
+ }
+ className={`w-full flex items-center justify-between px-2.5 py-2 rounded-lg transition ${
+ activeView === 'shortcuts'
+ ? 'bg-evernote-sidebarActive text-white font-medium'
+ : 'hover:bg-evernote-sidebarHover text-gray-300'
+ }`}
+ >
+
+
+ {!sidebarCollapsed && Shortcuts }
+
+ {!sidebarCollapsed &&
+ (expandedSections.shortcuts ? (
+
+ ) : (
+
+ ))}
+
+
+ {!sidebarCollapsed && expandedSections.shortcuts && (
+
+ {sidebar.shortcuts.notebooks.map((nb) => (
+
{
+ setActiveView('notebook');
+ setSelectedNotebookId(nb.id);
+ setMobileMenuOpen(false);
+ setMobilePane('list');
+ }}
+ className="w-full flex items-center gap-2 px-2 py-1 rounded hover:bg-evernote-sidebarHover text-gray-300 truncate"
+ >
+
+ {nb.name}
+
+ ))}
+ {sidebar.shortcuts.notes.map((sn) => (
+
{
+ setActiveView('all');
+ openNoteInEditor(sn.id);
+ setMobileMenuOpen(false);
+ }}
+ className="w-full flex items-center gap-2 px-2 py-1 rounded hover:bg-evernote-sidebarHover text-gray-300 truncate"
+ >
+
+ {sn.title}
+
+ ))}
+ {sidebar.shortcuts.savedSearches.map((ss) => (
+
{
+ setActiveView('all');
+ setSearchQuery(ss.query);
+ }}
+ className="w-full flex items-center gap-2 px-2 py-1 rounded hover:bg-evernote-sidebarHover text-gray-300 truncate"
+ >
+
+ {ss.name}
+
+ ))}
+ {sidebar.shortcuts.notebooks.length === 0 &&
+ sidebar.shortcuts.notes.length === 0 &&
+ sidebar.shortcuts.savedSearches.length === 0 && (
+
+ Star any note or notebook
+
+ )}
+
+ )}
+
+
+ {/* Notebooks & Stacks Section */}
+
+
+
+ setExpandedSections((s) => ({ ...s, notebooks: !s.notebooks }))
+ }
+ className="flex items-center gap-2.5 flex-1 text-left"
+ >
+
+ {!sidebarCollapsed && Notebooks }
+
+ {!sidebarCollapsed && (
+
+
setCreatingNotebook((v) => !v)}
+ title="New Notebook or Stack"
+ className="p-0.5 text-gray-400 hover:text-white rounded"
+ >
+
+
+
+ setExpandedSections((s) => ({ ...s, notebooks: !s.notebooks }))
+ }
+ className="text-gray-500"
+ >
+ {expandedSections.notebooks ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+ {!sidebarCollapsed && creatingNotebook && (
+
+ )}
+
+ {!sidebarCollapsed && expandedSections.notebooks && (
+
+ {/* Notebook Stacks */}
+ {notebooksByStack.map(({ stack, notebooks: stackNbs }) => {
+ const isExpanded = expandedStacks[stack.id] ?? true;
+ return (
+
+
+ {
+ setActiveView('stack');
+ setSelectedStackId(stack.id);
+ setMobileMenuOpen(false);
+ setMobilePane('list');
+ }}
+ className="flex items-center gap-2 flex-1 truncate text-left"
+ >
+
+ {stack.name}
+
+
+ setExpandedStacks((prev) => ({
+ ...prev,
+ [stack.id]: !isExpanded,
+ }))
+ }
+ className="p-0.5 text-gray-500 hover:text-white"
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isExpanded && (
+
+ {stackNbs.map((nb) => (
+ {
+ setActiveView('notebook');
+ setSelectedNotebookId(nb.id);
+ setMobileMenuOpen(false);
+ setMobilePane('list');
+ }}
+ onDropNote={(noteId) => handleMoveNoteToNotebook(noteId, nb.id)}
+ onRefresh={fetchSidebar}
+ />
+ ))}
+
+ )}
+
+ );
+ })}
+
+ {/* Unstacked Notebooks */}
+ {unstackedNotebooks.map((nb) => (
+
{
+ setActiveView('notebook');
+ setSelectedNotebookId(nb.id);
+ setMobileMenuOpen(false);
+ setMobilePane('list');
+ }}
+ onDropNote={(noteId) => handleMoveNoteToNotebook(noteId, nb.id)}
+ onRefresh={fetchSidebar}
+ />
+ ))}
+
+ )}
+
+
+ {/* Tags Section (Collapsible with Dropdown Arrow & Search) */}
+
+
+
setExpandedSections((s) => ({ ...s, tags: !s.tags }))}
+ className="flex items-center gap-2.5 flex-1 text-left"
+ >
+
+ {!sidebarCollapsed && (
+
+ Tags
+ ({sidebar.tags.length})
+
+ )}
+
+ {!sidebarCollapsed && (
+
+
setCreatingTag((v) => !v)}
+ title="New Tag"
+ className="p-0.5 text-gray-400 hover:text-white rounded"
+ >
+
+
+
setExpandedSections((s) => ({ ...s, tags: !s.tags }))}
+ className="text-gray-400 hover:text-white p-0.5"
+ title={expandedSections.tags ? 'Collapse Tags' : 'Expand Tags'}
+ >
+ {expandedSections.tags ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+ {!sidebarCollapsed && creatingTag && (
+
+ )}
+
+ {!sidebarCollapsed && expandedSections.tags && (
+
+ {sidebar.tags.length > 6 && (
+
+ setSidebarTagSearch(e.target.value)}
+ placeholder="Filter tagsβ¦"
+ className="w-full px-2 py-1 bg-neutral-800 text-white rounded text-[11px] border border-neutral-700 placeholder-gray-500 focus:outline-none focus:border-evernote-green"
+ />
+
+ )}
+
+ {sidebar.tags
+ .filter(
+ (t) =>
+ !sidebarTagSearch.trim() ||
+ t.name.toLowerCase().includes(sidebarTagSearch.trim().toLowerCase())
+ )
+ .map((t) => (
+
+
{
+ setActiveView('tag');
+ setSelectedTagId(t.id);
+ setMobileMenuOpen(false);
+ setMobilePane('list');
+ }}
+ className="flex items-center gap-2 flex-1 truncate text-left"
+ >
+
+ {t.name}
+
+
+ {t.noteCount ?? 0}
+ {
+ e.stopPropagation();
+ if (window.confirm(`Delete tag "${t.name}"?`)) {
+ await fetch(`/api/tags?id=${t.id}`, { method: 'DELETE' });
+ fetchSidebar();
+ }
+ }}
+ className="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-red-400"
+ title="Delete tag"
+ >
+
+
+
+
+ ))}
+ {sidebar.tags.length === 0 && (
+
+ No tags yet
+
+ )}
+
+
+ )}
+
+
+ {/* Trash */}
+
{
+ setActiveView('trash');
+ setMobileMenuOpen(false);
+ setMobilePane('list');
+ }}
+ className={`w-full flex items-center justify-between px-2.5 py-2 rounded-lg transition ${
+ activeView === 'trash'
+ ? 'bg-evernote-sidebarActive text-white font-medium'
+ : 'hover:bg-evernote-sidebarHover text-gray-300'
+ }`}
+ >
+
+
+ {!sidebarCollapsed && Trash }
+
+ {!sidebarCollapsed && sidebar.counts.trash > 0 && (
+ {sidebar.counts.trash}
+ )}
+
+
+ {/* Divider + Settings */}
+
+ {
+ setActiveView('settings');
+ setMobileMenuOpen(false);
+ }}
+ title="Settings, Import, Web Clipper, Email & API"
+ className={`w-full flex items-center justify-between px-2.5 py-2 rounded-lg transition ${
+ activeView === 'settings'
+ ? 'bg-evernote-sidebarActive text-white font-medium'
+ : 'hover:bg-evernote-sidebarHover text-gray-300'
+ }`}
+ >
+
+
+ {!sidebarCollapsed && Settings & Integrations }
+
+
+
+
+
+ {/* Multi-User Profile Switcher & Footer */}
+ {!sidebarCollapsed && (
+
+
setUserMenuOpen((o) => !o)}
+ className="w-full flex items-center justify-between px-2.5 py-2 rounded-lg bg-neutral-800/80 hover:bg-neutral-800 text-left transition border border-neutral-700/70"
+ title="Switch User Profile or Add Account"
+ >
+
+
+ {(sidebar.currentUser?.name || 'U').charAt(0).toUpperCase()}
+
+
+
+ {sidebar.currentUser?.name || 'Primary User'}
+
+
+ {sidebar.currentUser?.email || 'Personal Workspace'}
+
+
+
+
+
+
+ {userMenuOpen && (
+
+
+ Switch Workspace User
+
+
+ {(sidebar.users || []).map((u) => {
+ const isCurrent = u.id === sidebar.currentUser?.id;
+ return (
+
handleSwitchUser(u.id)}
+ className={`w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-left transition ${
+ isCurrent
+ ? 'bg-emerald-950/90 border border-evernote-green text-white'
+ : 'hover:bg-neutral-800 text-gray-300'
+ }`}
+ >
+
+
+ {u.name.charAt(0).toUpperCase()}
+
+
+
{u.name}
+
+ {u.email}
+
+
+
+ {isCurrent && (
+
+ )}
+
+ );
+ })}
+
+
+ {!creatingUser ? (
+
setCreatingUser(true)}
+ className="w-full flex items-center justify-center gap-1.5 py-1.5 px-2 rounded-lg bg-neutral-800 hover:bg-neutral-700 text-emerald-400 font-semibold text-[11px] border border-neutral-700 transition"
+ >
+
+ Add User Profile
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ Everclone v1.2
+ {
+ setActiveView('settings');
+ setMobileMenuOpen(false);
+ }}
+ className="hover:text-gray-300 underline"
+ title="Open Import, Export, Clipper & Email Settings"
+ >
+ Settings
+
+
+
+ )}
+
+
+ {activeView === 'settings' ? (
+
setActiveView('all')}
+ onDataChanged={async (firstNoteId) => {
+ await fetchSidebar();
+ await fetchNotesList(firstNoteId);
+ }}
+ />
+ ) : (
+ <>
+ {/* 2. MIDDLE NOTE LIST PANE */}
+
+ {/* List Header */}
+
+
+
+ {
+ if (typeof window !== 'undefined' && window.innerWidth < 768) {
+ window.history.pushState({ evcMobileMenu: true }, '');
+ }
+ setMobileMenuOpen(true);
+ }}
+ title="Open Notebooks & Tags Menu"
+ className="p-1.5 -ml-1 rounded-lg text-gray-700 hover:bg-gray-100 md:hidden shrink-0"
+ >
+
+
+
+ {currentViewTitle}
+
+
+
+ {activeView !== 'all' && (
+
{
+ setActiveView('all');
+ setSelectedNotebookId('');
+ setSelectedStackId('');
+ setSelectedTagId('');
+ }}
+ title="Switch back to All Notes"
+ className="flex items-center gap-1 px-2 py-1 rounded-lg bg-emerald-50 hover:bg-emerald-100 text-evernote-green border border-emerald-200 text-[11px] font-semibold transition shrink-0"
+ >
+
+ All Notes
+
+ )}
+
+
+ New
+
+ {activeView === 'notebook' && selectedNotebookId && (
+
+ window.open(
+ `/api/export?notebookId=${selectedNotebookId}&format=enex`,
+ '_blank'
+ )
+ }
+ title="Export this Notebook (.enex)"
+ className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
+ >
+
+
+ )}
+ {activeView === 'trash' && notes.length > 0 && (
+
+ Empty Trash
+
+ )}
+
+
+
+
+
+ {totalNotes} {totalNotes === 1 ? 'note' : 'notes'}
+ {debouncedSearch ? ` Β· "${debouncedSearch}"` : ''}
+
+
+
+ {/* Filter Drawer Toggle Button */}
+
setShowFilterPanel((v) => !v)}
+ title="Filter notes by tag, notebook, content type, or date"
+ className={`flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition ${
+ showFilterPanel || activeFilterCount > 0
+ ? 'bg-emerald-50 border-evernote-green text-evernote-green'
+ : 'bg-gray-100 border-transparent text-gray-600 hover:bg-gray-200/70'
+ }`}
+ >
+
+ Filter
+ {activeFilterCount > 0 && (
+
+ {activeFilterCount}
+
+ )}
+
+
+ {/* Sort selector */}
+
{
+ const [sb, sd] = e.target.value.split(':') as [
+ 'updatedAt' | 'createdAt' | 'title',
+ 'desc' | 'asc'
+ ];
+ setSortBy(sb);
+ setSortDir(sd);
+ }}
+ className="bg-gray-100 px-1.5 py-1 rounded-lg text-[11px] text-gray-600 font-medium focus:outline-none cursor-pointer max-w-[112px] sm:max-w-none truncate"
+ >
+ Updated β
+ Updated β
+ Created β
+ Created β
+ Title AβZ
+ Title ZβA
+
+
+ {/* View mode switcher */}
+
+
setListMode('snippets')}
+ title="Snippet View"
+ className={`p-1 rounded ${
+ listMode === 'snippets' ? 'bg-white shadow-sm text-gray-900' : 'text-gray-500'
+ }`}
+ >
+
+
+
setListMode('cards')}
+ title="Card Grid View"
+ className={`p-1 rounded ${
+ listMode === 'cards' ? 'bg-white shadow-sm text-gray-900' : 'text-gray-500'
+ }`}
+ >
+
+
+
setListMode('compact')}
+ title="Compact List View"
+ className={`p-1 rounded ${
+ listMode === 'compact' ? 'bg-white shadow-sm text-gray-900' : 'text-gray-500'
+ }`}
+ >
+
+
+
+
+
+
+ {/* Collapsible Filter Panel */}
+ {showFilterPanel && (
+
+ {/* Quick Keyword Filter Input */}
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Filter by keyword, intitle:, tag:, source:β¦"
+ className="w-full pl-8 pr-7 py-1.5 bg-gray-50 border border-gray-300 rounded-lg text-xs text-gray-800 placeholder-gray-400 focus:outline-none focus:border-evernote-green"
+ />
+ {searchQuery && (
+ setSearchQuery('')}
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-700"
+ >
+
+
+ )}
+
+
+ {/* Content Attribute Chips */}
+
+
+ Contains / Attributes
+
+
+
setFilterHasImages((v) => !v)}
+ className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition ${
+ filterHasImages
+ ? 'bg-emerald-50 border-evernote-green text-emerald-800'
+ : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
+ }`}
+ >
+
+ Images
+
+
+
setFilterHasAttachments((v) => !v)}
+ className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition ${
+ filterHasAttachments
+ ? 'bg-emerald-50 border-evernote-green text-emerald-800'
+ : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
+ }`}
+ >
+
+ Files
+
+
+
setFilterHasTodo((v) => !v)}
+ className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition ${
+ filterHasTodo
+ ? 'bg-emerald-50 border-evernote-green text-emerald-800'
+ : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
+ }`}
+ >
+
+ Checklists
+
+
+
setFilterHasWebClip((v) => !v)}
+ className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition ${
+ filterHasWebClip
+ ? 'bg-emerald-50 border-evernote-green text-emerald-800'
+ : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
+ }`}
+ >
+
+ Web Clips
+
+
+
setFilterOnlyPinned((v) => !v)}
+ className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition ${
+ filterOnlyPinned
+ ? 'bg-emerald-50 border-evernote-green text-emerald-800'
+ : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
+ }`}
+ >
+
+ Pinned
+
+
+
{
+ setFilterUntagged((v) => {
+ if (!v) setFilterTagIds([]);
+ return !v;
+ });
+ }}
+ className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition ${
+ filterUntagged
+ ? 'bg-emerald-50 border-evernote-green text-emerald-800'
+ : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
+ }`}
+ >
+
+ Untagged
+
+
+
+
+ {/* Notebook & Updated Date Dropdowns */}
+
+ {activeView !== 'notebook' && (
+
+
+ Notebook
+
+ setFilterNotebookId(e.target.value)}
+ className="w-full px-2 py-1.5 bg-gray-50 border border-gray-300 rounded-lg text-xs text-gray-700 focus:outline-none focus:border-evernote-green"
+ >
+ All Notebooks
+ {sidebar.notebooks.map((nb) => (
+
+ {nb.name} ({nb.noteCount})
+
+ ))}
+
+
+ )}
+
+
+
+ Updated Within
+
+ setFilterUpdatedDays(parseInt(e.target.value, 10) || 0)}
+ className="w-full px-2 py-1.5 bg-gray-50 border border-gray-300 rounded-lg text-xs text-gray-700 focus:outline-none focus:border-evernote-green"
+ >
+ Any time
+ Last 24 hours
+ Last 7 days
+ Last 30 days
+ Last 90 days
+ Last year
+
+
+
+
+ {/* Collapsible Dropdown Multi-Tag Selector */}
+ {sidebar.tags.length > 0 && (
+
+
setTagFilterDropdownOpen((v) => !v)}
+ className="w-full flex items-center justify-between px-2.5 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-300 rounded-lg text-xs text-gray-700 font-medium transition"
+ >
+
+
+
+ Filter by Tags
+ {filterTagIds.length > 0
+ ? ` (${filterTagIds.length} selected)`
+ : ` (${sidebar.tags.length})`}
+
+
+ {tagFilterDropdownOpen ? (
+
+ ) : (
+
+ )}
+
+
+ {tagFilterDropdownOpen && (
+
+
setTagFilterSearch(e.target.value)}
+ placeholder="Search tagsβ¦"
+ className="w-full px-2.5 py-1 bg-white border border-gray-300 rounded text-xs text-gray-800 placeholder-gray-400 focus:outline-none focus:border-evernote-green"
+ />
+
+ {sidebar.tags
+ .filter(
+ (t) =>
+ !tagFilterSearch.trim() ||
+ t.name
+ .toLowerCase()
+ .includes(tagFilterSearch.trim().toLowerCase())
+ )
+ .map((t) => {
+ const active = filterTagIds.includes(t.id);
+ return (
+ {
+ setFilterUntagged(false);
+ setFilterTagIds((prev) =>
+ prev.includes(t.id)
+ ? prev.filter((id) => id !== t.id)
+ : [...prev, t.id]
+ );
+ }}
+ className={`px-2 py-0.5 rounded-full text-[11px] font-medium border transition ${
+ active
+ ? 'bg-evernote-green text-white border-evernote-green'
+ : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-100'
+ }`}
+ >
+ #{t.name}
+
+ );
+ })}
+
+
+ )}
+
+ )}
+
+ {(activeFilterCount > 0 || searchQuery) && (
+
+
+ Clear All Filters
+
+ setShowFilterPanel(false)}
+ className="text-[11px] font-semibold text-evernote-green hover:underline"
+ >
+ Done
+
+
+ )}
+
+ )}
+
+ {/* Active Filter Pills Strip (shown even when drawer is closed) */}
+ {!showFilterPanel && (activeFilterCount > 0 || debouncedSearch) && (
+
+ {debouncedSearch && (
+
+ “{debouncedSearch}”
+ setSearchQuery('')}>
+
+
+
+ )}
+ {filterNotebookId && (
+
+
+ π {sidebar.notebooks.find((n) => n.id === filterNotebookId)?.name}
+
+ setFilterNotebookId('')}>
+
+
+
+ )}
+ {filterHasImages && (
+
+ Has Images
+ setFilterHasImages(false)}>
+
+
+
+ )}
+ {filterHasAttachments && (
+
+ Has Files
+ setFilterHasAttachments(false)}>
+
+
+
+ )}
+ {filterHasTodo && (
+
+ Checklists
+ setFilterHasTodo(false)}>
+
+
+
+ )}
+ {filterHasWebClip && (
+
+ Web Clips
+ setFilterHasWebClip(false)}>
+
+
+
+ )}
+ {filterOnlyPinned && (
+
+ Pinned
+ setFilterOnlyPinned(false)}>
+
+
+
+ )}
+ {filterUntagged && (
+
+ Untagged
+ setFilterUntagged(false)}>
+
+
+
+ )}
+ {filterUpdatedDays > 0 && (
+
+ Last {filterUpdatedDays}d
+ setFilterUpdatedDays(0)}>
+
+
+
+ )}
+ {filterTagIds.map((tid) => {
+ const t = sidebar.tags.find((x) => x.id === tid);
+ if (!t) return null;
+ return (
+
+ #{t.name}
+
+ setFilterTagIds((prev) => prev.filter((id) => id !== tid))
+ }
+ >
+
+
+
+ );
+ })}
+
+ Reset
+
+
+ )}
+
+
+ {/* Note List Body: Card Grid vs Virtualized Snippet / Compact List */}
+
+ {loadingList && notes.length === 0 ? (
+
Loading notesβ¦
+ ) : notes.length === 0 ? (
+
+
+
No notes found
+
+ {activeFilterCount > 0 || debouncedSearch
+ ? 'No notes match your current filters.'
+ : 'Create a new note or import your Evernote .enex / Markdown files.'}
+
+ {activeFilterCount > 0 || debouncedSearch ? (
+
+ Clear All Filters
+
+ ) : (
+ activeView !== 'trash' && (
+
+
+ + New Note
+
+ setActiveView('settings')}
+ className="px-3 py-1.5 bg-white border border-gray-300 text-gray-700 text-xs font-medium rounded-lg hover:bg-gray-50"
+ >
+ Import .enex / .md
+
+
+ )
+ )}
+
+ ) : listMode === 'cards' ? (
+ /* 2-COLUMN VISUAL CARD GRID VIEW */
+
+ {notes.map((n) => {
+ const isSelected = n.id === selectedNoteId;
+ const firstImg = n.attachments.find((a) =>
+ a.mimeType.startsWith('image/')
+ );
+ const firstPdf = n.attachments.find(
+ (a) =>
+ a.mimeType === 'application/pdf' ||
+ a.filename.toLowerCase().endsWith('.pdf')
+ );
+ const effectiveThumbUrl =
+ n.thumbnailUrl === 'none'
+ ? null
+ : n.thumbnailUrl || (firstImg ? `/api/attachments/${firstImg.id}` : null);
+
+ return (
+
{
+ e.dataTransfer.setData('text/everclone-note-id', n.id);
+ }}
+ onClick={() => openNoteInEditor(n.id)}
+ className={`group/card cursor-pointer rounded-xl border transition flex flex-col overflow-hidden shadow-xs ${
+ isSelected
+ ? 'bg-emerald-50/60 border-evernote-green ring-2 ring-evernote-green/30'
+ : 'bg-white border-gray-200/90 hover:border-gray-300 hover:shadow-md'
+ }`}
+ >
+ {/* Top Hero Cover Image Banner (if present) */}
+ {effectiveThumbUrl ? (
+
+
+ {firstPdf && (
+
+ PDF
+
+ )}
+
{
+ e.stopPropagation();
+ setSelectedNoteId(n.id);
+ setThumbnailInitialUrl(effectiveThumbUrl);
+ setThumbnailEditorOpen(true);
+ }}
+ title="Edit or Crop Thumbnail"
+ className="opacity-0 group-hover/thumb:opacity-100 transition absolute top-2 right-2 p-1.5 bg-black/65 hover:bg-black/85 rounded-lg text-white shadow"
+ >
+
+
+
+ ) : (
+ n.thumbnailUrl !== 'none' &&
+ firstPdf && (
+
{
+ setSelectedNoteId(n.id);
+ setThumbnailInitialUrl(dataUrl);
+ setThumbnailEditorOpen(true);
+ }}
+ onCachedThumbnail={(nid, dataUrl) => {
+ setNotes((prev) =>
+ prev.map((item) =>
+ item.id === nid ? { ...item, thumbnailUrl: dataUrl } : item
+ )
+ );
+ }}
+ />
+ )
+ )}
+
+ {/* Card Content */}
+
+
+ {/* Notebook Mover & Pin/Star Row */}
+
+
e.stopPropagation()}
+ className="inline-flex items-center gap-1 min-w-0 max-w-[140px] px-1.5 py-0.5 rounded bg-gray-100 hover:bg-emerald-50 text-gray-600 hover:text-evernote-green transition"
+ title="Move note to another notebook"
+ >
+
+
+ handleMoveNoteToNotebook(n.id, e.target.value)
+ }
+ className="bg-transparent font-medium truncate focus:outline-none cursor-pointer w-full text-[10px]"
+ >
+ {sidebar.notebooks.map((nb) => (
+
+ {nb.name}
+
+ ))}
+
+
+
+ {n.isPinned && (
+
+ )}
+ {n.isShortcut && (
+
+ )}
+
+
+
+
+ {n.title || 'Untitled Note'}
+
+
+
+ {n.excerpt || 'No additional text'}
+
+
+
+ {/* Card Footer */}
+
+
+ {format(new Date(n.updatedAt), 'MMM d')}
+
+
+
+ {n.attachments.length > 0 && (
+
+
+ {n.attachments.length}
+
+ )}
+ {n.tags.slice(0, 1).map((t) => (
+
+ #{t.name}
+
+ ))}
+
+
+
+
+ );
+ })}
+
+ ) : (
+ /* VIRTUALIZED SNIPPET & COMPACT LIST VIEWS */
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => {
+ const n = notes[virtualRow.index];
+ const isSelected = n.id === selectedNoteId;
+ const firstImg = n.attachments.find((a) =>
+ a.mimeType.startsWith('image/')
+ );
+ const firstPdf = n.attachments.find(
+ (a) =>
+ a.mimeType === 'application/pdf' ||
+ a.filename.toLowerCase().endsWith('.pdf')
+ );
+ const effectiveThumbUrl =
+ n.thumbnailUrl === 'none'
+ ? null
+ : n.thumbnailUrl || (firstImg ? `/api/attachments/${firstImg.id}` : null);
+
+ return (
+
{
+ e.dataTransfer.setData('text/everclone-note-id', n.id);
+ }}
+ onClick={() => openNoteInEditor(n.id)}
+ style={{
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: '100%',
+ transform: `translateY(${virtualRow.start}px)`,
+ }}
+ className={`cursor-pointer border-b border-gray-200/80 transition px-4 ${
+ listMode === 'compact' ? 'py-2' : 'py-3.5'
+ } ${
+ isSelected
+ ? 'bg-emerald-50/75 border-l-4 border-l-evernote-green'
+ : 'bg-white hover:bg-gray-50'
+ }`}
+ >
+ {listMode === 'compact' ? (
+ /* COMPACT SINGLE-LINE VIEW */
+
+
+ {n.isPinned && (
+
+ )}
+ {n.isShortcut && (
+
+ )}
+
+ {n.title || 'Untitled Note'}
+
+
+
+ {n.tags[0] && (
+
+ #{n.tags[0].name}
+
+ )}
+ {format(new Date(n.updatedAt), 'MMM d')}
+
+
+ ) : (
+ /* SNIPPET VIEW */
+
+
+
+ {n.isPinned && (
+
+ )}
+ {n.isShortcut && (
+
+ )}
+
+ {n.title || 'Untitled Note'}
+
+
+
+
+ {n.excerpt || 'No additional text'}
+
+
+
+
e.stopPropagation()}
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-gray-100 hover:bg-emerald-50 text-[10px] text-gray-600 hover:text-evernote-green font-medium transition max-w-[130px]"
+ title="Move note to another notebook"
+ >
+
+
+ handleMoveNoteToNotebook(n.id, e.target.value)
+ }
+ className="bg-transparent truncate focus:outline-none cursor-pointer w-full"
+ >
+ {sidebar.notebooks.map((nb) => (
+
+ {nb.name}
+
+ ))}
+
+
+
+
+ {formatDistanceToNow(new Date(n.updatedAt), {
+ addSuffix: true,
+ })}
+
+
+ {n.attachments.length > 0 && (
+
+
+ {n.attachments.length}
+
+ )}
+
+ {n.tags.slice(0, 3).map((t) => (
+
+ {t.name}
+
+ ))}
+
+
+
+ {effectiveThumbUrl ? (
+
+
+ {firstPdf && (
+
+ PDF
+
+ )}
+
{
+ e.stopPropagation();
+ setSelectedNoteId(n.id);
+ setThumbnailInitialUrl(effectiveThumbUrl);
+ setThumbnailEditorOpen(true);
+ }}
+ title="Edit or Crop Thumbnail"
+ className="opacity-0 group-hover/thumb:opacity-100 transition absolute inset-0 bg-black/50 rounded-lg flex items-center justify-center text-white"
+ >
+
+
+
+ ) : (
+ n.thumbnailUrl !== 'none' &&
+ firstPdf && (
+
{
+ setSelectedNoteId(n.id);
+ setThumbnailInitialUrl(dataUrl);
+ setThumbnailEditorOpen(true);
+ }}
+ onCachedThumbnail={(nid, dataUrl) => {
+ setNotes((prev) =>
+ prev.map((item) =>
+ item.id === nid ? { ...item, thumbnailUrl: dataUrl } : item
+ )
+ );
+ }}
+ />
+ )
+ )}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+ {/* 3. RIGHT NOTE EDITOR PANE */}
+
+ {!activeNote ? (
+
+
+
+ Select a note or create a new one
+
+
+ ) : (
+ <>
+ {/* Top Note Action Bar */}
+
+
+ {/* Mobile Back to Note List Button */}
+
{
+ if (
+ typeof window !== 'undefined' &&
+ window.history.state?.evcMobilePane === 'editor'
+ ) {
+ window.history.back();
+ } else {
+ setMobilePane('list');
+ }
+ }}
+ className="md:hidden inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 text-xs font-semibold shrink-0"
+ >
+
+ Notes
+
+
+ {/* Notebook Mover Dropdown */}
+
+
+
+ handleImmediatePatch({ notebookId: e.target.value })
+ }
+ className="bg-transparent font-semibold text-gray-800 focus:outline-none cursor-pointer truncate w-full text-xs"
+ >
+ {sidebar.notebooks.map((nb) => (
+
+ {nb.name}
+
+ ))}
+
+
+
+ {activeNote.sourceUrl && (
+
+
+ Source URL
+
+ )}
+
+
+
+
+ {saveState === 'saving'
+ ? 'Savingβ¦'
+ : saveState === 'dirty'
+ ? 'Unsaved edits'
+ : 'All changes saved'}
+
+
+
+
+ {/* Right Note Actions */}
+
+ {activeNote.isTrashed ? (
+ <>
+
handleImmediatePatch({ isTrashed: false })}
+ className="flex items-center gap-1 px-3 py-1.5 bg-evernote-green text-white rounded-lg text-xs font-medium hover:bg-evernote-greenHover"
+ >
+
+ Restore Note
+
+
handleDeleteOrTrashNote(true)}
+ className="flex items-center gap-1 px-3 py-1.5 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700 ml-1"
+ >
+
+ Delete Permanently
+
+ >
+ ) : (
+ <>
+
+ handleImmediatePatch({ isPinned: !activeNote.isPinned })
+ }
+ title={activeNote.isPinned ? 'Unpin Note' : 'Pin Note to Top'}
+ className={`p-1.5 rounded-lg hover:bg-gray-100 ${
+ activeNote.isPinned
+ ? 'text-evernote-green bg-emerald-50'
+ : 'text-gray-500'
+ }`}
+ >
+
+
+
+
+ handleImmediatePatch({ isShortcut: !activeNote.isShortcut })
+ }
+ title={
+ activeNote.isShortcut
+ ? 'Remove from Shortcuts'
+ : 'Add to Shortcuts'
+ }
+ className={`p-1.5 rounded-lg hover:bg-gray-100 ${
+ activeNote.isShortcut
+ ? 'text-amber-500 bg-amber-50'
+ : 'text-gray-500'
+ }`}
+ >
+
+
+
+
{
+ setThumbnailInitialUrl(null);
+ setThumbnailEditorOpen(true);
+ }}
+ title="Edit Note Thumbnail (Crop, Pick Image, or Create Cover Badge)"
+ className={`p-1.5 rounded-lg hover:bg-gray-100 ${
+ activeNote.thumbnailUrl && activeNote.thumbnailUrl !== 'none'
+ ? 'text-evernote-green bg-emerald-50'
+ : 'text-gray-500'
+ }`}
+ >
+
+
+
+
setShowNoteInfo((v) => !v)}
+ title="Note Info & Metadata (Timestamps, URL, Author)"
+ className={`p-1.5 rounded-lg hover:bg-gray-100 ${
+ showNoteInfo
+ ? 'text-evernote-green bg-emerald-50'
+ : 'text-gray-500'
+ }`}
+ >
+
+
+
+ {/* Export Menu */}
+
+
setShowExportMenu((v) => !v)}
+ title="Export Note"
+ className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-500"
+ >
+
+
+ {showExportMenu && (
+
+ )}
+
+
+
handleDeleteOrTrashNote(false)}
+ title="Move to Trash"
+ className="p-1.5 rounded-lg hover:bg-red-50 text-gray-500 hover:text-red-600"
+ >
+
+
+ >
+ )}
+
+
+
+ {/* Collapsible Note Info & Metadata Drawer */}
+ {showNoteInfo && (
+
+
+
+ Source URL
+
+ {
+ const val = e.target.value;
+ setActiveNote({ ...activeNote, sourceUrl: val });
+ scheduleNoteSave(activeNote.id, { sourceUrl: val });
+ }}
+ placeholder="https://β¦"
+ className="w-full px-2.5 py-1 bg-white border border-gray-300 rounded text-xs"
+ />
+
+
+
+ Created Date
+
+ {
+ const d = new Date(e.target.value);
+ if (!isNaN(d.getTime())) {
+ handleImmediatePatch({ createdAt: d.toISOString() });
+ }
+ }}
+ className="w-full px-2.5 py-1 bg-white border border-gray-300 rounded text-xs"
+ />
+
+
+
+ Statistics & Author
+
+
+ {wordCount} words Β· {activeNote.attachments.length} attachment
+ {activeNote.attachments.length === 1 ? '' : 's'} Β· Updated{' '}
+ {format(new Date(activeNote.updatedAt), 'MMM d, yyyy HH:mm')}
+
+
+
+ )}
+
+ {/* Note Title Input */}
+
+ {
+ const newTitle = e.target.value;
+ setActiveNote({ ...activeNote, title: newTitle });
+ scheduleNoteSave(activeNote.id, { title: newTitle });
+ }}
+ placeholder="Note Title"
+ className="w-full text-2xl font-bold text-gray-900 placeholder-gray-300 focus:outline-none"
+ />
+
+
+ {/* Rich Text Editor + Inline PDF Viewers */}
+ {
+ setActiveNote((prev) =>
+ prev ? { ...prev, contentHtml: newHtml } : prev
+ );
+ scheduleNoteSave(activeNote.id, { contentHtml: newHtml });
+ }}
+ onUploadFiles={handleUploadAttachments}
+ onSetThumbnail={(src) => handleImmediatePatch({ thumbnailUrl: src })}
+ onOpenThumbnailEditor={(src) => {
+ setThumbnailInitialUrl(src);
+ setThumbnailEditorOpen(true);
+ }}
+ >
+ {activeNote.attachments
+ .filter(
+ (att) =>
+ att.mimeType === 'application/pdf' ||
+ att.filename.toLowerCase().endsWith('.pdf')
+ )
+ .map((pdfAtt) => (
+
+ handleImmediatePatch({ thumbnailUrl: dataUrl })
+ }
+ onOpenThumbnailStudio={(dataUrl) => {
+ setThumbnailInitialUrl(dataUrl);
+ setThumbnailEditorOpen(true);
+ }}
+ />
+ ))}
+
+
+ {/* Attachments Strip (Collapsed by Default) */}
+ {activeNote.attachments.length > 0 && (
+
+
+
setAttachmentsExpanded((v) => !v)}
+ className="flex items-center gap-1.5 hover:text-gray-900 py-0.5"
+ >
+ {attachmentsExpanded ? (
+
+ ) : (
+
+ )}
+
+ Attachments ({activeNote.attachments.length})
+
+ {!activeNote.isTrashed && (
+
{
+ setThumbnailInitialUrl(null);
+ setThumbnailEditorOpen(true);
+ }}
+ className="text-evernote-green hover:underline flex items-center gap-1"
+ >
+
+ Thumbnail Studio
+
+ )}
+
+
+ {attachmentsExpanded && (
+
+ {activeNote.attachments.map((att) => {
+ const attUrl = `/api/attachments/${att.id}`;
+ const isImg = att.mimeType.startsWith('image/');
+ const isCurrentThumb = activeNote.thumbnailUrl === attUrl;
+ return (
+
+
+ {att.filename}
+
+
+ {(att.sizeBytes / 1024).toFixed(1)} KB
+
+ {isImg && !activeNote.isTrashed && (
+
handleImmediatePatch({ thumbnailUrl: attUrl })}
+ className={`${
+ isCurrentThumb
+ ? 'text-evernote-green font-semibold'
+ : 'text-gray-400 hover:text-evernote-green'
+ }`}
+ title={
+ isCurrentThumb
+ ? 'Active Note Thumbnail'
+ : 'Set as Note Thumbnail'
+ }
+ >
+
+
+ )}
+
+
+
+ {!activeNote.isTrashed && (
+
handleDeleteAttachment(att.id)}
+ className="text-gray-400 hover:text-red-600"
+ title="Remove attachment"
+ >
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ {/* Bottom Tag Bar */}
+
+
+ {activeNote.tags.map((t) => (
+
+ {t.name}
+ {!activeNote.isTrashed && (
+ handleRemoveTagFromNote(t.name)}
+ className="hover:text-red-600"
+ >
+
+
+ )}
+
+ ))}
+
+ {!activeNote.isTrashed && (
+
+ )}
+
+ >
+ )}
+
+
+ {
+ setThumbnailEditorOpen(false);
+ setThumbnailInitialUrl(null);
+ }}
+ onSaveThumbnail={async (thumbnailUrl) => {
+ await handleImmediatePatch({ thumbnailUrl });
+ }}
+ />
+ >
+ )}
+
+ );
+}
+
+function NotebookRow({
+ nb,
+ active,
+ onSelect,
+ onDropNote,
+ onRefresh,
+}: {
+ nb: NotebookItem;
+ active: boolean;
+ onSelect: () => void;
+ onDropNote?: (noteId: string) => void;
+ onRefresh: () => void;
+}) {
+ const [menuOpen, setMenuOpen] = useState(false);
+ const [dragOver, setDragOver] = useState(false);
+
+ return (
+
{
+ if (onDropNote) {
+ e.preventDefault();
+ setDragOver(true);
+ }
+ }}
+ onDragLeave={() => setDragOver(false)}
+ onDrop={(e) => {
+ setDragOver(false);
+ const droppedNoteId = e.dataTransfer.getData('text/everclone-note-id');
+ if (droppedNoteId && onDropNote) {
+ e.preventDefault();
+ onDropNote(droppedNoteId);
+ }
+ }}
+ className={`group relative flex items-center justify-between px-2 py-1.5 rounded cursor-pointer transition ${
+ dragOver
+ ? 'bg-emerald-900/70 ring-1 ring-evernote-green text-white'
+ : active
+ ? 'bg-evernote-sidebarActive text-white font-medium'
+ : 'hover:bg-evernote-sidebarHover text-gray-300'
+ }`}
+ >
+
+
+ {nb.name}
+ {nb.isDefault && (
+
+ Default
+
+ )}
+
+
+
+ {nb.noteCount}
+ {
+ e.stopPropagation();
+ setMenuOpen((o) => !o);
+ }}
+ className="opacity-0 group-hover:opacity-100 p-0.5 text-gray-400 hover:text-white rounded"
+ >
+
+
+
+
+ {menuOpen && (
+
setMenuOpen(false)}
+ className="absolute right-2 top-7 w-44 bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 z-30 text-xs text-gray-200"
+ >
+ {
+ setMenuOpen(false);
+ const newName = window.prompt('Rename notebook:', nb.name);
+ if (newName?.trim()) {
+ await fetch('/api/notebooks', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: nb.id, name: newName.trim() }),
+ });
+ onRefresh();
+ }
+ }}
+ className="w-full text-left px-3 py-1.5 hover:bg-neutral-800"
+ >
+ Rename Notebook
+
+ {
+ setMenuOpen(false);
+ const stackName = window.prompt(
+ 'Enter Stack name (leave blank to remove from stack):',
+ ''
+ );
+ if (stackName !== null) {
+ await fetch('/api/notebooks', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: nb.id, stackName }),
+ });
+ onRefresh();
+ }
+ }}
+ className="w-full text-left px-3 py-1.5 hover:bg-neutral-800"
+ >
+ Move to Stackβ¦
+
+ {
+ setMenuOpen(false);
+ await fetch('/api/notebooks', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: nb.id, isShortcut: !nb.isShortcut }),
+ });
+ onRefresh();
+ }}
+ className="w-full text-left px-3 py-1.5 hover:bg-neutral-800"
+ >
+ {nb.isShortcut ? 'Remove from Shortcuts' : 'Add to Shortcuts'}
+
+ {
+ setMenuOpen(false);
+ await fetch('/api/notebooks', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: nb.id, isDefault: true }),
+ });
+ onRefresh();
+ }}
+ className="w-full text-left px-3 py-1.5 hover:bg-neutral-800"
+ >
+ Set as Default Notebook
+
+ {!nb.isDefault && (
+ {
+ setMenuOpen(false);
+ if (
+ window.confirm(
+ `Delete notebook "${nb.name}"? Notes inside will be moved to your default notebook.`
+ )
+ ) {
+ await fetch(`/api/notebooks?id=${nb.id}`, { method: 'DELETE' });
+ onRefresh();
+ }
+ }}
+ className="w-full text-left px-3 py-1.5 hover:bg-neutral-800 text-red-400"
+ >
+ Delete Notebook
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/HooksModal.tsx b/src/components/HooksModal.tsx
new file mode 100644
index 0000000..87ccf79
--- /dev/null
+++ b/src/components/HooksModal.tsx
@@ -0,0 +1,379 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import {
+ Key,
+ Smartphone,
+ Bookmark,
+ Terminal,
+ Plus,
+ Trash2,
+ Copy,
+ Check,
+ Download,
+ X,
+} from 'lucide-react';
+
+interface ApiKeyRecord {
+ id: string;
+ name: string;
+ keyPrefix: string;
+ lastUsedAt: string | null;
+ createdAt: string;
+}
+
+interface HooksModalProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export default function HooksModal({ open, onClose }: HooksModalProps) {
+ const [keys, setKeys] = useState
([]);
+ const [newKeyName, setNewKeyName] = useState('Android & Web Clipper');
+ const [generatedKey, setGeneratedKey] = useState('');
+ const [copied, setCopied] = useState('');
+
+ const origin =
+ typeof window !== 'undefined' ? window.location.origin : 'https://everclone.vloser.us';
+
+ const loadKeys = async () => {
+ try {
+ const res = await fetch('/api/keys');
+ const data = await res.json();
+ if (data.keys) setKeys(data.keys);
+ } catch {
+ // ignore
+ }
+ };
+
+ useEffect(() => {
+ if (open) {
+ loadKeys();
+ }
+ }, [open]);
+
+ if (!open) return null;
+
+ const handleCreateKey = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const res = await fetch('/api/keys', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: newKeyName }),
+ });
+ const data = await res.json();
+ if (data.rawKey) {
+ setGeneratedKey(data.rawKey);
+ loadKeys();
+ }
+ };
+
+ const handleDeleteKey = async (id: string) => {
+ await fetch(`/api/keys?id=${id}`, { method: 'DELETE' });
+ loadKeys();
+ };
+
+ const copyText = (text: string, label: string) => {
+ navigator.clipboard.writeText(text);
+ setCopied(label);
+ setTimeout(() => setCopied(''), 2000);
+ };
+
+ const activeToken = generatedKey || '';
+
+ const downloadAndroidHttpShortcuts = () => {
+ const shortcutsJson = {
+ version: 60,
+ categories: [
+ {
+ id: 'everclone-cat-1',
+ name: 'Everclone',
+ layoutType: 'linear_list',
+ shortcuts: [
+ {
+ id: 'everclone-share-url',
+ name: 'Clip Link / Text to Everclone',
+ description: 'Shares any URL or text selection from Android Share sheet to Everclone',
+ method: 'POST',
+ url: `${origin}/api/hooks/note`,
+ headers: [
+ { key: 'Authorization', value: `Bearer ${activeToken}` },
+ { key: 'Content-Type', value: 'application/json' },
+ ],
+ bodyContent: JSON.stringify({
+ title: '{share_title}',
+ content: '{share_text}',
+ tags: ['android'],
+ }),
+ requestBodyType: 'custom_text',
+ launcherShortcut: true,
+ quickSettingsTileShortcut: true,
+ },
+ {
+ id: 'everclone-quick-note',
+ name: 'Quick Note to Everclone',
+ description: 'Prompts for a quick note and saves it to Everclone Inbox',
+ method: 'POST',
+ url: `${origin}/api/hooks/note`,
+ headers: [
+ { key: 'Authorization', value: `Bearer ${activeToken}` },
+ { key: 'Content-Type', value: 'application/json' },
+ ],
+ bodyContent: JSON.stringify({
+ title: '{note_title}',
+ markdown: '{note_body}',
+ tags: ['quick-note', 'android'],
+ }),
+ requestBodyType: 'custom_text',
+ launcherShortcut: true,
+ },
+ ],
+ },
+ ],
+ variables: [
+ {
+ id: 'var-share-title',
+ key: 'share_title',
+ type: 'constant',
+ value: '',
+ isShareTitle: true,
+ },
+ {
+ id: 'var-share-text',
+ key: 'share_text',
+ type: 'constant',
+ value: '',
+ isShareText: true,
+ },
+ {
+ id: 'var-note-title',
+ key: 'note_title',
+ type: 'text',
+ title: 'Note Title',
+ Flags: 0,
+ },
+ {
+ id: 'var-note-body',
+ key: 'note_body',
+ type: 'text',
+ title: 'Note Content (Markdown)',
+ isMultiline: true,
+ },
+ ],
+ };
+
+ const blob = new Blob([JSON.stringify(shortcutsJson, null, 2)], {
+ type: 'application/json',
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'everclone-android-http-shortcuts.json';
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const bookmarkletHref = `javascript:(function(){var s=window.getSelection?window.getSelection().toString():'';var u=encodeURIComponent(location.href);var t=encodeURIComponent(document.title);var x=encodeURIComponent(s);window.open('${origin}/api/hooks/note?redirect=1&apiKey=${encodeURIComponent(
+ activeToken
+ )}&url='+u+'&title='+t+'&text='+x,'_blank');})();`;
+
+ const curlSnippet = `curl -X POST "${origin}/api/hooks/note" \\
+ -H "Authorization: Bearer ${activeToken}" \\
+ -H "Content-Type: application/json" \\
+ -d '{"title":"Idea from Android","markdown":"- [ ] Check this out","notebook":"Inbox","tags":["mobile","idea"]}'`;
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ Web & Android Note Ingestion Hooks
+
+
+
+
+
+
+
+
+ {/* 1. API Keys Section */}
+
+
+
+ Step 1: Generate an API Key for Hooks
+
+
+
+
+ {generatedKey && (
+
+
+
+ New API Key Created (automatically embedded in snippets below):
+
+
+ {generatedKey}
+
+
+
copyText(generatedKey, 'key')}
+ className="flex items-center gap-1 px-3 py-1.5 bg-white border border-emerald-300 rounded text-xs font-medium text-emerald-800 hover:bg-emerald-100 shrink-0"
+ >
+ {copied === 'key' ? : }
+ {copied === 'key' ? 'Copied' : 'Copy'}
+
+
+ )}
+
+ {keys.length > 0 && (
+
+ {keys.map((k) => (
+
+
+ {k.name}
+ {k.keyPrefix}
+
+
+
+ {k.lastUsedAt
+ ? `Last used ${new Date(k.lastUsedAt).toLocaleDateString()}`
+ : 'Never used'}
+
+ handleDeleteKey(k.id)}
+ className="text-red-500 hover:text-red-700 p-1"
+ title="Revoke Key"
+ >
+
+
+
+
+ ))}
+
+ )}
+
+
+ {/* 2. Android Integration */}
+
+
+
+ Step 2: Add Notes from Android (Share Sheet, HTTP Shortcuts & PWA)
+
+
+ You can send links, articles, text, voice notes, or photos from Android in two ways:
+
+
+
+
+ Option A: Android HTTP Shortcuts / Tasker
+
+
+ Import this file into the free HTTP Shortcuts Android app to add{' '}
+ “Clip to Everclone” to your Android Share menu & home screen widgets.
+
+
+
+ Download Android Shortcuts (.json)
+
+
+
+
+
+ Option B: Install PWA Share Target
+
+
+ Open {origin} in Chrome on Android and tap{' '}
+ “Add to Home screen / Install app” . Everclone will appear directly inside Android's native Share sheet!
+
+
+
+
+
+ {/* 3. Browser Web Clipper Bookmarklet */}
+
+
+
+ Step 3: Web Clipper Bookmarklet
+
+
+ Drag the green button below to your browser's Bookmarks Bar. Click it on any web page to clip the full readable article (or highlighted text) directly into Everclone:
+
+
+
+
+ {/* 4. REST API / cURL Reference */}
+
+
+
+
+ REST API Webhook Reference (POST /api/hooks/note)
+
+
copyText(curlSnippet, 'curl')}
+ className="flex items-center gap-1 text-xs text-evernote-green hover:underline font-medium"
+ >
+ {copied === 'curl' ? : }
+ {copied === 'curl' ? 'Copied!' : 'Copy cURL'}
+
+
+
+ {curlSnippet}
+
+
+
+
+
+ );
+}
diff --git a/src/components/ImportModal.tsx b/src/components/ImportModal.tsx
new file mode 100644
index 0000000..3ce0006
--- /dev/null
+++ b/src/components/ImportModal.tsx
@@ -0,0 +1,625 @@
+'use client';
+
+import React, { useState, useRef } from 'react';
+import {
+ Upload,
+ Globe,
+ X,
+ FileArchive,
+ CheckCircle2,
+ AlertCircle,
+ Loader2,
+} from 'lucide-react';
+import { NotebookItem } from '@/types/everclone';
+
+interface ImportModalProps {
+ open: boolean;
+ onClose: () => void;
+ notebooks: NotebookItem[];
+ onImported: (firstNoteId?: string) => void;
+}
+
+export default function ImportModal({
+ open,
+ onClose,
+ notebooks,
+ onImported,
+}: ImportModalProps) {
+ const [tab, setTab] = useState<'files' | 'url'>('files');
+ const [selectedFiles, setSelectedFiles] = useState([]);
+ const [notebookId, setNotebookId] = useState('');
+ const [newNotebookName, setNewNotebookName] = useState('');
+ const [useFilenameAsNotebook, setUseFilenameAsNotebook] = useState(true);
+
+ const [clipUrl, setClipUrl] = useState('');
+ const [clipTitle, setClipTitle] = useState('');
+ const [clipTags, setClipTags] = useState('web-clip');
+
+ const [loading, setLoading] = useState(false);
+ const [progressInfo, setProgressInfo] = useState<{
+ phase: 'uploading' | 'processing';
+ fileName: string;
+ bytesUploaded: number;
+ totalBytes: number;
+ processedNotes: number;
+ totalNotes: number;
+ } | null>(null);
+
+ const [result, setResult] = useState<{
+ importedCount: number;
+ errors: string[];
+ } | null>(null);
+
+ const abortRef = useRef(false);
+ const fileInputRef = useRef(null);
+
+ if (!open) return null;
+
+ /**
+ * Uploads any file in strict 1.5 MB raw binary chunks to /api/import?action=appendChunk,
+ * then triggers server-side background streaming import and polls job progress every 1s.
+ */
+ const uploadAndProcessFileInChunks = async (
+ file: File
+ ): Promise<{ importedCount: number; errors: string[] }> => {
+ const CHUNK_SIZE = 1536 * 1024; // 1.5 MB binary chunks (never trips proxy or OAuth limits)
+
+ // 1. Initialize chunk upload job
+ const initRes = await fetch('/api/import?action=initChunkUpload', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ filename: file.name }),
+ });
+ if (!initRes.ok) {
+ throw new Error(`Failed to initialize upload (${initRes.status})`);
+ }
+ const { jobId } = await initRes.json();
+
+ // 2. Upload binary slices sequentially with live progress bar
+ let offset = 0;
+ while (offset < file.size) {
+ if (abortRef.current) {
+ throw new Error('Upload stopped by user.');
+ }
+ const end = Math.min(offset + CHUNK_SIZE, file.size);
+ const chunkBlob = file.slice(offset, end);
+
+ const appendRes = await fetch(
+ `/api/import?action=appendChunk&jobId=${encodeURIComponent(jobId)}`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/octet-stream' },
+ body: chunkBlob,
+ }
+ );
+
+ if (!appendRes.ok) {
+ throw new Error(`Chunk upload failed at ${(offset / 1048576).toFixed(1)} MB (HTTP ${appendRes.status})`);
+ }
+
+ offset = end;
+ setProgressInfo({
+ phase: 'uploading',
+ fileName: file.name,
+ bytesUploaded: offset,
+ totalBytes: file.size,
+ processedNotes: 0,
+ totalNotes: 0,
+ });
+ }
+
+ // 3. Finalize upload and kick off server-side processing
+ setProgressInfo({
+ phase: 'processing',
+ fileName: file.name,
+ bytesUploaded: file.size,
+ totalBytes: file.size,
+ processedNotes: 0,
+ totalNotes: 0,
+ });
+
+ const finishRes = await fetch('/api/import?action=finishChunkUpload', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ jobId,
+ notebookId: notebookId || undefined,
+ notebookName: newNotebookName.trim() || undefined,
+ useFilenameAsNotebook:
+ useFilenameAsNotebook && !notebookId && !newNotebookName.trim(),
+ }),
+ });
+
+ if (!finishRes.ok) {
+ throw new Error(`Failed to start server import (HTTP ${finishRes.status})`);
+ }
+
+ const finishData = await finishRes.json();
+ if (!finishData.background) {
+ return {
+ importedCount: finishData.importedCount || 0,
+ errors: finishData.errors || [],
+ };
+ }
+
+ // 4. Poll background job status every 900ms until completed or failed
+ let lastCount = 0;
+ while (!abortRef.current) {
+ await new Promise((r) => setTimeout(r, 900));
+ const statusRes = await fetch(
+ `/api/import?jobId=${encodeURIComponent(jobId)}`
+ );
+ if (!statusRes.ok) continue;
+ const job = await statusRes.json();
+
+ setProgressInfo({
+ phase: 'processing',
+ fileName: file.name,
+ bytesUploaded: file.size,
+ totalBytes: file.size,
+ processedNotes: job.processedNotes || 0,
+ totalNotes: job.totalNotes || 0,
+ });
+
+ if ((job.processedNotes || 0) > lastCount) {
+ lastCount = job.processedNotes;
+ onImported();
+ }
+
+ if (job.status === 'completed' || job.status === 'failed') {
+ const errs = job.errorLog ? String(job.errorLog).split('\n').filter(Boolean) : [];
+ return {
+ importedCount: job.processedNotes || 0,
+ errors: errs,
+ };
+ }
+ }
+
+ return { importedCount: lastCount, errors: ['Stopped polling in UI (server job continues in background).'] };
+ };
+
+ const handleFileImport = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (selectedFiles.length === 0) return;
+
+ setLoading(true);
+ setResult(null);
+ abortRef.current = false;
+
+ let grandTotalImported = 0;
+ const allErrors: string[] = [];
+
+ try {
+ for (const file of selectedFiles) {
+ if (abortRef.current) break;
+ setProgressInfo({
+ phase: 'uploading',
+ fileName: file.name,
+ bytesUploaded: 0,
+ totalBytes: file.size,
+ processedNotes: 0,
+ totalNotes: 0,
+ });
+
+ const res = await uploadAndProcessFileInChunks(file);
+ grandTotalImported += res.importedCount;
+ allErrors.push(...res.errors);
+ onImported();
+ }
+
+ setResult({
+ importedCount: grandTotalImported,
+ errors: allErrors,
+ });
+ setSelectedFiles([]);
+ } catch (err: unknown) {
+ setResult({
+ importedCount: grandTotalImported,
+ errors: [
+ ...allErrors,
+ err instanceof Error ? err.message : 'Import failed',
+ ],
+ });
+ } finally {
+ setLoading(false);
+ setProgressInfo(null);
+ }
+ };
+
+ const handleUrlClip = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!clipUrl.trim()) return;
+
+ setLoading(true);
+ setResult(null);
+
+ try {
+ const res = await fetch('/api/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ url: clipUrl.trim(),
+ title: clipTitle.trim() || undefined,
+ notebookId: notebookId || undefined,
+ notebookName: newNotebookName.trim() || undefined,
+ tags: clipTags
+ .split(',')
+ .map((t) => t.trim())
+ .filter(Boolean),
+ }),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ throw new Error(data.error || 'URL clip failed');
+ }
+ setResult({
+ importedCount: data.importedCount || 1,
+ errors: data.errors || [],
+ });
+ setClipUrl('');
+ setClipTitle('');
+ onImported(data.noteIds?.[0]);
+ } catch (err: unknown) {
+ setResult({
+ importedCount: 0,
+ errors: [err instanceof Error ? err.message : 'URL clip failed'],
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const pct = (() => {
+ if (!progressInfo) return 0;
+ if (progressInfo.phase === 'uploading') {
+ return progressInfo.totalBytes > 0
+ ? Math.min(
+ 100,
+ Math.round(
+ (progressInfo.bytesUploaded / progressInfo.totalBytes) * 100
+ )
+ )
+ : 0;
+ }
+ return progressInfo.totalNotes > 0
+ ? Math.min(
+ 100,
+ Math.round(
+ (progressInfo.processedNotes / progressInfo.totalNotes) * 100
+ )
+ )
+ : 50;
+ })();
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ Import Notes & Web Clips
+
+
+
+
+
+
+
+ {/* Mode Tabs */}
+
+ {
+ setTab('files');
+ setResult(null);
+ }}
+ className={`px-4 py-2 text-sm font-medium rounded-t-lg border-b-2 transition ${
+ tab === 'files'
+ ? 'bg-white border-evernote-green text-evernote-green'
+ : 'border-transparent text-gray-600 hover:text-gray-900'
+ }`}
+ >
+
+
+ Files (.enex, .md, .html, .zip)
+
+
+ {
+ setTab('url');
+ setResult(null);
+ }}
+ className={`px-4 py-2 text-sm font-medium rounded-t-lg border-b-2 transition ${
+ tab === 'url'
+ ? 'bg-white border-evernote-green text-evernote-green'
+ : 'border-transparent text-gray-600 hover:text-gray-900'
+ }`}
+ >
+
+
+ Clip Web URL
+
+
+
+
+ {/* Content */}
+
+ {tab === 'files' ? (
+
+ ) : (
+
+ )}
+
+ {result && (
+
0 && result.importedCount === 0
+ ? 'bg-red-50 border-red-200 text-red-800'
+ : 'bg-emerald-50 border-emerald-200 text-emerald-900'
+ }`}
+ >
+
+ {result.importedCount > 0 ? (
+
+ ) : (
+
+ )}
+
+ Imported {result.importedCount} note
+ {result.importedCount === 1 ? '' : 's'}!
+
+
+ {result.errors.length > 0 && (
+
+ {result.errors.slice(0, 20).map((err, i) => (
+ {err}
+ ))}
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/PdfNoteComponents.tsx b/src/components/PdfNoteComponents.tsx
new file mode 100644
index 0000000..85e2e7e
--- /dev/null
+++ b/src/components/PdfNoteComponents.tsx
@@ -0,0 +1,250 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import {
+ FileText,
+ ChevronDown,
+ ChevronRight,
+ ExternalLink,
+ Download,
+ Image as ImageIcon,
+ Crop,
+} from 'lucide-react';
+import { renderPdfPageToDataUrl } from '@/lib/pdfClient';
+import { AttachmentItem } from '@/types/everclone';
+
+/**
+ * Automatically extracts Page 1 of a PDF attachment as a crisp thumbnail,
+ * displays it in Snippet / Card View, and caches it to the note's thumbnailUrl in DB.
+ */
+export function PdfAutoThumbnail({
+ noteId,
+ pdfAttachmentId,
+ mode,
+ onEditThumbnail,
+ onCachedThumbnail,
+}: {
+ noteId: string;
+ pdfAttachmentId: string;
+ mode: 'snippets' | 'cards';
+ onEditThumbnail: (dataUrl: string) => void;
+ onCachedThumbnail?: (noteId: string, dataUrl: string) => void;
+}) {
+ const [dataUrl, setDataUrl] = useState(null);
+ const pdfUrl = `/api/attachments/${pdfAttachmentId}`;
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ const rendered = await renderPdfPageToDataUrl(pdfUrl, 1, 360);
+ if (!cancelled && rendered) {
+ setDataUrl(rendered);
+ if (onCachedThumbnail) {
+ onCachedThumbnail(noteId, rendered);
+ }
+ // Persist the generated Page-1 thumbnail on the note so future list loads are instant
+ try {
+ await fetch(`/api/notes/${noteId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ thumbnailUrl: rendered }),
+ });
+ } catch {
+ // ignore background cache error
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [noteId, pdfUrl, onCachedThumbnail]);
+
+ if (mode === 'cards') {
+ return (
+
+ {dataUrl ? (
+ <>
+
+
+ PDF
+
+
{
+ e.stopPropagation();
+ onEditThumbnail(dataUrl);
+ }}
+ title="Edit or Crop PDF Thumbnail"
+ className="opacity-0 group-hover/thumb:opacity-100 transition absolute top-2 right-2 p-1.5 bg-black/65 hover:bg-black/85 rounded-lg text-white shadow"
+ >
+
+
+ >
+ ) : (
+
+
+ Rendering PDFβ¦
+
+ )}
+
+ );
+ }
+
+ return (
+
+ {dataUrl ? (
+ <>
+
+
+ PDF
+
+
{
+ e.stopPropagation();
+ onEditThumbnail(dataUrl);
+ }}
+ title="Edit or Crop PDF Thumbnail"
+ className="opacity-0 group-hover/thumb:opacity-100 transition absolute inset-0 bg-black/50 flex items-center justify-center text-white"
+ >
+
+
+ >
+ ) : (
+
+ PDF
+
+ )}
+
+ );
+}
+
+/**
+ * Evernote-style inline PDF viewer rendered directly inside the Note Editor pane.
+ */
+export function InlinePdfViewerCard({
+ attachment,
+ readOnly,
+ onSetThumbnail,
+ onOpenThumbnailStudio,
+}: {
+ attachment: AttachmentItem;
+ readOnly?: boolean;
+ onSetThumbnail: (dataUrl: string) => void;
+ onOpenThumbnailStudio: (dataUrl: string) => void;
+}) {
+ const [expanded, setExpanded] = useState(true);
+ const [extractingThumb, setExtractingThumb] = useState(false);
+ const pdfUrl = `/api/attachments/${attachment.id}`;
+
+ const handleUsePage1AsThumb = async (openStudio = false) => {
+ setExtractingThumb(true);
+ try {
+ const dataUrl = await renderPdfPageToDataUrl(pdfUrl, 1, 480);
+ if (dataUrl) {
+ if (openStudio) {
+ onOpenThumbnailStudio(dataUrl);
+ } else {
+ onSetThumbnail(dataUrl);
+ }
+ }
+ } finally {
+ setExtractingThumb(false);
+ }
+ };
+
+ return (
+
+ {/* PDF Header Bar */}
+
+
setExpanded((e) => !e)}
+ className="flex items-center gap-2 font-semibold text-gray-800 hover:text-evernote-green min-w-0"
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+ PDF
+
+
+ {attachment.filename}
+
+
+ ({(attachment.sizeBytes / 1024).toFixed(1)} KB)
+
+
+
+
+ {!readOnly && (
+ <>
+
handleUsePage1AsThumb(false)}
+ title="Set Page 1 of this PDF as the Note Thumbnail"
+ className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-white hover:bg-emerald-50 text-gray-700 hover:text-evernote-green border border-gray-200 text-[11px] font-medium transition"
+ >
+
+
+ {extractingThumb ? 'Renderingβ¦' : 'Set Thumbnail'}
+
+
+
+
handleUsePage1AsThumb(true)}
+ title="Crop a page from this PDF in Thumbnail Studio"
+ className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-white hover:bg-emerald-50 text-gray-700 hover:text-evernote-green border border-gray-200 text-[11px] font-medium transition"
+ >
+
+ Crop
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Embedded PDF Document Pane */}
+ {expanded && (
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/RichNoteEditor.tsx b/src/components/RichNoteEditor.tsx
new file mode 100644
index 0000000..832395c
--- /dev/null
+++ b/src/components/RichNoteEditor.tsx
@@ -0,0 +1,571 @@
+'use client';
+
+import React, { useEffect, useRef, useState } from 'react';
+import { useEditor, EditorContent } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import Underline from '@tiptap/extension-underline';
+import Highlight from '@tiptap/extension-highlight';
+import TextStyle from '@tiptap/extension-text-style';
+import Color from '@tiptap/extension-color';
+import TextAlign from '@tiptap/extension-text-align';
+import Link from '@tiptap/extension-link';
+import Image from '@tiptap/extension-image';
+import TaskList from '@tiptap/extension-task-list';
+import TaskItem from '@tiptap/extension-task-item';
+import Table from '@tiptap/extension-table';
+import TableRow from '@tiptap/extension-table-row';
+import TableCell from '@tiptap/extension-table-cell';
+import TableHeader from '@tiptap/extension-table-header';
+import Placeholder from '@tiptap/extension-placeholder';
+import Typography from '@tiptap/extension-typography';
+import {
+ Bold,
+ Italic,
+ Underline as UnderlineIcon,
+ Strikethrough,
+ Code,
+ Highlighter,
+ List,
+ ListOrdered,
+ CheckSquare,
+ AlignLeft,
+ AlignCenter,
+ AlignRight,
+ Quote,
+ Table as TableIcon,
+ Link as LinkIcon,
+ Paperclip,
+ Mic,
+ Square,
+ Undo2,
+ Redo2,
+ Minus,
+ Plus,
+ Trash2,
+ Image as ImageIcon,
+ Crop,
+} from 'lucide-react';
+
+interface RichNoteEditorProps {
+ noteId: string;
+ contentHtml: string;
+ onChange: (html: string) => void;
+ onUploadFiles: (files: File[]) => Promise>;
+ onSetThumbnail?: (src: string) => void;
+ onOpenThumbnailEditor?: (src: string) => void;
+ readOnly?: boolean;
+ children?: React.ReactNode;
+}
+
+export default function RichNoteEditor({
+ noteId,
+ contentHtml,
+ onChange,
+ onUploadFiles,
+ onSetThumbnail,
+ onOpenThumbnailEditor,
+ readOnly = false,
+ children,
+}: RichNoteEditorProps) {
+ const fileInputRef = useRef(null);
+ const currentNoteIdRef = useRef(noteId);
+ const [isRecording, setIsRecording] = useState(false);
+ const [recordingSeconds, setRecordingSeconds] = useState(0);
+ const mediaRecorderRef = useRef(null);
+ const timerRef = useRef(null);
+
+ const editor = useEditor({
+ immediatelyRender: false,
+ editable: !readOnly,
+ extensions: [
+ StarterKit.configure({
+ heading: { levels: [1, 2, 3] },
+ }),
+ Underline,
+ TextStyle,
+ Color,
+ Highlight.configure({ multicolor: true }),
+ TextAlign.configure({ types: ['heading', 'paragraph'] }),
+ Link.configure({
+ openOnClick: true,
+ autolink: true,
+ }),
+ Image.configure({
+ inline: false,
+ allowBase64: true,
+ }),
+ TaskList,
+ TaskItem.configure({
+ nested: true,
+ }),
+ Table.configure({
+ resizable: true,
+ }),
+ TableRow,
+ TableHeader,
+ TableCell,
+ Typography,
+ Placeholder.configure({
+ placeholder: 'Start writing, drag files here, or type / for formattingβ¦',
+ }),
+ ],
+ content: contentHtml || '
',
+ onUpdate: ({ editor: ed }) => {
+ onChange(ed.getHTML());
+ },
+ });
+
+ // Sync content when switching between different notes
+ useEffect(() => {
+ if (!editor) return;
+ if (currentNoteIdRef.current !== noteId) {
+ currentNoteIdRef.current = noteId;
+ editor.commands.setContent(contentHtml || '
', false);
+ }
+ }, [noteId, contentHtml, editor]);
+
+ useEffect(() => {
+ if (!editor) return;
+ editor.setEditable(!readOnly);
+ }, [readOnly, editor]);
+
+ const handleInsertFiles = async (files: File[]) => {
+ if (!files.length || !editor) return;
+ const uploaded = await onUploadFiles(files);
+ for (const att of uploaded) {
+ const url = `/api/attachments/${att.id}`;
+ if (att.mimeType.startsWith('image/')) {
+ editor.chain().focus().setImage({ src: url, alt: att.filename }).run();
+ } else {
+ editor
+ .chain()
+ .focus()
+ .insertContent(
+ `π ${att.filename}
`
+ )
+ .run();
+ }
+ }
+ };
+
+ const handleSetLink = () => {
+ if (!editor) return;
+ const prev = editor.getAttributes('link').href || '';
+ const url = window.prompt('Enter link URL:', prev);
+ if (url === null) return;
+ if (url === '') {
+ editor.chain().focus().extendMarkRange('link').unsetLink().run();
+ return;
+ }
+ editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
+ };
+
+ const toggleAudioRecording = async () => {
+ if (isRecording) {
+ mediaRecorderRef.current?.stop();
+ if (timerRef.current) clearInterval(timerRef.current);
+ setIsRecording(false);
+ setRecordingSeconds(0);
+ return;
+ }
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const recorder = new MediaRecorder(stream);
+ const chunks: BlobPart[] = [];
+
+ recorder.ondataavailable = (e) => {
+ if (e.data.size > 0) chunks.push(e.data);
+ };
+
+ recorder.onstop = async () => {
+ stream.getTracks().forEach((t) => t.stop());
+ const blob = new Blob(chunks, { type: 'audio/webm' });
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
+ const file = new File([blob], `voice-note-${timestamp}.webm`, {
+ type: 'audio/webm',
+ });
+ await handleInsertFiles([file]);
+ };
+
+ mediaRecorderRef.current = recorder;
+ recorder.start();
+ setIsRecording(true);
+ setRecordingSeconds(0);
+ timerRef.current = setInterval(() => {
+ setRecordingSeconds((s) => s + 1);
+ }, 1000);
+ } catch {
+ alert('Microphone access is not available or was denied.');
+ }
+ };
+
+ if (!editor) return null;
+
+ return (
+ e.preventDefault()}
+ onDrop={async (e) => {
+ if (e.dataTransfer?.files?.length) {
+ e.preventDefault();
+ await handleInsertFiles(Array.from(e.dataTransfer.files));
+ }
+ }}
+ >
+ {/* Formatting Toolbar */}
+ {!readOnly && (
+
+
editor.chain().focus().undo().run()}
+ disabled={!editor.can().undo()}
+ title="Undo (Ctrl+Z)"
+ className="p-1.5 rounded hover:bg-gray-200 disabled:opacity-35"
+ >
+
+
+
editor.chain().focus().redo().run()}
+ disabled={!editor.can().redo()}
+ title="Redo (Ctrl+Y)"
+ className="p-1.5 rounded hover:bg-gray-200 disabled:opacity-35"
+ >
+
+
+
+
+
+ {/* Heading Dropdown */}
+
{
+ const val = e.target.value;
+ if (val === 'p') editor.chain().focus().setParagraph().run();
+ else if (val === 'h1') editor.chain().focus().toggleHeading({ level: 1 }).run();
+ else if (val === 'h2') editor.chain().focus().toggleHeading({ level: 2 }).run();
+ else if (val === 'h3') editor.chain().focus().toggleHeading({ level: 3 }).run();
+ }}
+ className="px-2 py-1 text-xs font-medium bg-white border border-gray-300 rounded hover:border-gray-400 focus:outline-none"
+ >
+ Normal text
+ Large Header (H1)
+ Medium Header (H2)
+ Small Header (H3)
+
+
+
+
+
editor.chain().focus().toggleBold().run()}
+ title="Bold (Ctrl+B)"
+ className={`p-1.5 rounded hover:bg-gray-200 ${
+ editor.isActive('bold') ? 'bg-gray-200 text-evernote-green font-bold' : ''
+ }`}
+ >
+
+
+
editor.chain().focus().toggleItalic().run()}
+ title="Italic (Ctrl+I)"
+ className={`p-1.5 rounded hover:bg-gray-200 ${
+ editor.isActive('italic') ? 'bg-gray-200 text-evernote-green' : ''
+ }`}
+ >
+
+
+
editor.chain().focus().toggleUnderline().run()}
+ title="Underline (Ctrl+U)"
+ className={`p-1.5 rounded hover:bg-gray-200 ${
+ editor.isActive('underline') ? 'bg-gray-200 text-evernote-green' : ''
+ }`}
+ >
+
+
+
editor.chain().focus().toggleStrike().run()}
+ title="Strikethrough"
+ className={`p-1.5 rounded hover:bg-gray-200 ${
+ editor.isActive('strike') ? 'bg-gray-200 text-evernote-green' : ''
+ }`}
+ >
+
+
+
editor.chain().focus().toggleHighlight({ color: '#fef08a' }).run()}
+ title="Highlight"
+ className={`p-1.5 rounded hover:bg-gray-200 ${
+ editor.isActive('highlight') ? 'bg-yellow-200 text-gray-900' : ''
+ }`}
+ >
+
+
+
+ {/* Text Color Picker */}
+
+ editor.chain().focus().setColor(e.target.value).run()}
+ className="w-4 h-4 border-0 p-0 bg-transparent cursor-pointer"
+ />
+
+
+
+
+ {/* Lists & Checklists */}
+
editor.chain().focus().toggleTaskList().run()}
+ title="Checklist / To-do"
+ className={`p-1.5 rounded hover:bg-gray-200 ${
+ editor.isActive('taskList') ? 'bg-gray-200 text-evernote-green' : ''
+ }`}
+ >
+
+
+
editor.chain().focus().toggleBulletList().run()}
+ title="Bullet List"
+ className={`p-1.5 rounded hover:bg-gray-200 ${
+ editor.isActive('bulletList') ? 'bg-gray-200 text-evernote-green' : ''
+ }`}
+ >
+
+
+
editor.chain().focus().toggleOrderedList().run()}
+ title="Numbered List"
+ className={`p-1.5 rounded hover:bg-gray-200 ${
+ editor.isActive('orderedList') ? 'bg-gray-200 text-evernote-green' : ''
+ }`}
+ >
+
+
+
+
+
+ {/* Alignment */}
+
editor.chain().focus().setTextAlign('left').run()}
+ title="Align Left"
+ className="p-1.5 rounded hover:bg-gray-200"
+ >
+
+
+
editor.chain().focus().setTextAlign('center').run()}
+ title="Align Center"
+ className="p-1.5 rounded hover:bg-gray-200"
+ >
+
+
+
editor.chain().focus().setTextAlign('right').run()}
+ title="Align Right"
+ className="p-1.5 rounded hover:bg-gray-200"
+ >
+
+
+
+
+
+ {/* Code, Quote, Divider, Link, Table */}
+
editor.chain().focus().toggleCodeBlock().run()}
+ title="Code Block"
+ className={`p-1.5 rounded hover:bg-gray-200 ${
+ editor.isActive('codeBlock') ? 'bg-gray-200 text-evernote-green' : ''
+ }`}
+ >
+
+
+
editor.chain().focus().toggleBlockquote().run()}
+ title="Quote"
+ className={`p-1.5 rounded hover:bg-gray-200 ${
+ editor.isActive('blockquote') ? 'bg-gray-200 text-evernote-green' : ''
+ }`}
+ >
+
+
+
editor.chain().focus().setHorizontalRule().run()}
+ title="Horizontal Divider"
+ className="p-1.5 rounded hover:bg-gray-200"
+ >
+
+
+
+
+
+
+ editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
+ }
+ title="Insert 3x3 Table"
+ className="p-1.5 rounded hover:bg-gray-200"
+ >
+
+
+
+ {editor.isActive('table') && (
+
+
editor.chain().focus().addRowAfter().run()}
+ className="hover:text-evernote-green flex items-center gap-0.5"
+ title="Add Row"
+ >
+ Row
+
+
editor.chain().focus().addColumnAfter().run()}
+ className="hover:text-evernote-green flex items-center gap-0.5 ml-1"
+ title="Add Column"
+ >
+ Col
+
+
editor.chain().focus().deleteTable().run()}
+ className="text-red-600 hover:text-red-700 ml-1"
+ title="Delete Table"
+ >
+
+
+
+ )}
+
+ {editor.isActive('image') && (
+
+ {
+ const src = editor.getAttributes('image').src;
+ if (src && onSetThumbnail) onSetThumbnail(src);
+ }}
+ className="text-emerald-800 hover:text-evernote-green font-medium flex items-center gap-1"
+ title="Set this image as the Note Thumbnail"
+ >
+
+ Set as Thumbnail
+
+ |
+ {
+ const src = editor.getAttributes('image').src;
+ if (src && onOpenThumbnailEditor) onOpenThumbnailEditor(src);
+ }}
+ className="text-emerald-800 hover:text-evernote-green font-medium flex items-center gap-1"
+ title="Crop & Adjust this image as the Note Thumbnail"
+ >
+
+ Crop Thumbnail
+
+ |
+ editor.chain().focus().deleteSelection().run()}
+ className="text-red-600 hover:text-red-700 flex items-center"
+ title="Remove Image from Note"
+ >
+
+
+
+ )}
+
+
+
+ {/* Attach File / Image */}
+
fileInputRef.current?.click()}
+ title="Attach File, Image, PDF, or Audio"
+ className="flex items-center gap-1 px-2 py-1 rounded bg-white border border-gray-300 hover:bg-gray-100 text-xs font-medium"
+ >
+
+ Attach
+
+
{
+ if (e.target.files?.length) {
+ await handleInsertFiles(Array.from(e.target.files));
+ e.target.value = '';
+ }
+ }}
+ />
+
+ {/* Audio Voice Note Recorder */}
+
+ {isRecording ? (
+ <>
+
+ Stop ({recordingSeconds}s)
+ >
+ ) : (
+ <>
+
+ Audio
+ >
+ )}
+
+
+ )}
+
+ {/* Editor Body */}
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx
new file mode 100644
index 0000000..787565f
--- /dev/null
+++ b/src/components/SettingsView.tsx
@@ -0,0 +1,1443 @@
+'use client';
+
+import React, { useState, useEffect, useRef } from 'react';
+import {
+ Upload,
+ Globe,
+ Puzzle,
+ Mail,
+ Smartphone,
+ Key,
+ CheckCircle2,
+ AlertCircle,
+ Loader2,
+ Download,
+ Plus,
+ Trash2,
+ Copy,
+ Check,
+ RefreshCw,
+ Send,
+ ArrowLeft,
+ Bookmark,
+ Terminal,
+} from 'lucide-react';
+import { NotebookItem } from '@/types/everclone';
+
+interface SettingsViewProps {
+ notebooks: NotebookItem[];
+ onBackToNotes: () => void;
+ onDataChanged: (firstNoteId?: string) => void;
+}
+
+interface ImportJobRecord {
+ id: string;
+ sourceType: string;
+ filename: string;
+ status: string;
+ totalNotes: number;
+ processedNotes: number;
+ failedNotes: number;
+ errorLog: string | null;
+ createdAt: string;
+}
+
+interface ApiKeyRecord {
+ id: string;
+ name: string;
+ keyPrefix: string;
+ lastUsedAt: string | null;
+ createdAt: string;
+}
+
+export default function SettingsView({
+ notebooks,
+ onBackToNotes,
+ onDataChanged,
+}: SettingsViewProps) {
+ const [activeTab, setActiveTab] = useState<
+ 'import' | 'clipper' | 'email' | 'android'
+ >('import');
+
+ // --- IMPORT STATE ---
+ const [selectedFiles, setSelectedFiles] = useState([]);
+ const [notebookId, setNotebookId] = useState('');
+ const [newNotebookName, setNewNotebookName] = useState('');
+ const [useFilenameAsNotebook, setUseFilenameAsNotebook] = useState(true);
+ const [clipUrl, setClipUrl] = useState('');
+ const [clipTitle, setClipTitle] = useState('');
+ const [clipTags, setClipTags] = useState('web-clip');
+ const [importing, setImporting] = useState(false);
+ const [progressInfo, setProgressInfo] = useState<{
+ phase: 'uploading' | 'processing';
+ fileName: string;
+ bytesUploaded: number;
+ totalBytes: number;
+ processedNotes: number;
+ totalNotes: number;
+ } | null>(null);
+ const [importResult, setImportResult] = useState<{
+ importedCount: number;
+ errors: string[];
+ } | null>(null);
+ const [recentJobs, setRecentJobs] = useState([]);
+ const abortRef = useRef(false);
+ const fileInputRef = useRef(null);
+
+ // --- API KEYS STATE ---
+ const [keys, setKeys] = useState([]);
+ const [newKeyName, setNewKeyName] = useState('Brave Clipper & Android');
+ const [generatedKey, setGeneratedKey] = useState('');
+ const [copied, setCopied] = useState('');
+
+ // --- EMAIL-TO-NOTE STATE ---
+ const [emailCfg, setEmailCfg] = useState({
+ enabled: false,
+ imapHost: 'imap.gmail.com',
+ imapPort: 993,
+ imapSecure: true,
+ imapUser: '',
+ imapPassword: '',
+ mailbox: 'INBOX',
+ subjectFilter: '',
+ allowedSenders: '',
+ defaultNotebook: 'Inbox',
+ defaultTags: 'emailed',
+ markSeen: true,
+ lastCheckedAt: null as string | null,
+ lastStatus: null as string | null,
+ emailsImported: 0,
+ });
+ const [savingEmail, setSavingEmail] = useState(false);
+ const [pollingEmail, setPollingEmail] = useState(false);
+ const [emailStatusMsg, setEmailStatusMsg] = useState('');
+
+ // Test Email-to-Note simulator
+ const [testEmailSubject, setTestEmailSubject] = useState(
+ 'Server Hardware Receipt @"Web Clips" #receipts #homelab !'
+ );
+ const [testEmailBody, setTestEmailBody] = useState(
+ 'Forwarded invoice for new NVMe drives.\n\nTotal: $249.00'
+ );
+ const [sendingTestEmail, setSendingTestEmail] = useState(false);
+
+ const origin =
+ typeof window !== 'undefined' ? window.location.origin : 'https://everclone.vloser.us';
+ const activeToken = generatedKey || 'evc_master_vloser_key_2026';
+
+ const loadJobs = async () => {
+ try {
+ const res = await fetch('/api/import');
+ const data = await res.json();
+ if (data.jobs) setRecentJobs(data.jobs);
+ } catch {}
+ };
+
+ const loadKeys = async () => {
+ try {
+ const res = await fetch('/api/keys');
+ const data = await res.json();
+ if (data.keys) setKeys(data.keys);
+ } catch {}
+ };
+
+ const loadEmailCfg = async () => {
+ try {
+ const res = await fetch('/api/email');
+ if (res.ok) {
+ const data = await res.json();
+ setEmailCfg((prev) => ({ ...prev, ...data }));
+ }
+ } catch {}
+ };
+
+ useEffect(() => {
+ loadJobs();
+ loadKeys();
+ loadEmailCfg();
+ }, []);
+
+ // Poll recentJobs every 2s if any job is running/processing
+ useEffect(() => {
+ const hasRunning = recentJobs.some(
+ (j) => j.status === 'running' || j.status === 'processing' || j.status === 'uploading'
+ );
+ if (!hasRunning) return;
+ const t = setInterval(() => {
+ loadJobs();
+ onDataChanged();
+ }, 2000);
+ return () => clearInterval(t);
+ }, [recentJobs, onDataChanged]);
+
+ const copyText = (text: string, label: string) => {
+ navigator.clipboard.writeText(text);
+ setCopied(label);
+ setTimeout(() => setCopied(''), 2000);
+ };
+
+ // --- CHUNKED UPLOAD & IMPORT ---
+ const uploadAndProcessFileInChunks = async (
+ file: File
+ ): Promise<{ importedCount: number; errors: string[] }> => {
+ const CHUNK_SIZE = 1536 * 1024; // 1.5 MB binary slices
+
+ const initRes = await fetch('/api/import?action=initChunkUpload', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ filename: file.name }),
+ });
+ if (!initRes.ok) {
+ throw new Error(`Failed to initialize upload (${initRes.status})`);
+ }
+ const { jobId } = await initRes.json();
+
+ let offset = 0;
+ while (offset < file.size) {
+ if (abortRef.current) {
+ throw new Error('Upload stopped by user.');
+ }
+ const end = Math.min(offset + CHUNK_SIZE, file.size);
+ const chunkBlob = file.slice(offset, end);
+
+ const appendRes = await fetch(
+ `/api/import?action=appendChunk&jobId=${encodeURIComponent(jobId)}`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/octet-stream' },
+ body: chunkBlob,
+ }
+ );
+
+ if (!appendRes.ok) {
+ throw new Error(
+ `Chunk upload failed at ${(offset / 1048576).toFixed(1)} MB (HTTP ${appendRes.status})`
+ );
+ }
+
+ offset = end;
+ setProgressInfo({
+ phase: 'uploading',
+ fileName: file.name,
+ bytesUploaded: offset,
+ totalBytes: file.size,
+ processedNotes: 0,
+ totalNotes: 0,
+ });
+ }
+
+ setProgressInfo({
+ phase: 'processing',
+ fileName: file.name,
+ bytesUploaded: file.size,
+ totalBytes: file.size,
+ processedNotes: 0,
+ totalNotes: 0,
+ });
+
+ const finishRes = await fetch('/api/import?action=finishChunkUpload', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ jobId,
+ notebookId: notebookId || undefined,
+ notebookName: newNotebookName.trim() || undefined,
+ useFilenameAsNotebook:
+ useFilenameAsNotebook && !notebookId && !newNotebookName.trim(),
+ }),
+ });
+
+ if (!finishRes.ok) {
+ throw new Error(`Failed to start server import (HTTP ${finishRes.status})`);
+ }
+
+ const finishData = await finishRes.json();
+ loadJobs();
+
+ if (!finishData.background) {
+ return {
+ importedCount: finishData.importedCount || 0,
+ errors: finishData.errors || [],
+ };
+ }
+
+ let lastCount = 0;
+ while (!abortRef.current) {
+ await new Promise((r) => setTimeout(r, 1000));
+ const statusRes = await fetch(
+ `/api/import?jobId=${encodeURIComponent(jobId)}`
+ );
+ if (!statusRes.ok) continue;
+ const job = await statusRes.json();
+
+ setProgressInfo({
+ phase: 'processing',
+ fileName: file.name,
+ bytesUploaded: file.size,
+ totalBytes: file.size,
+ processedNotes: job.processedNotes || 0,
+ totalNotes: job.totalNotes || 0,
+ });
+
+ if ((job.processedNotes || 0) > lastCount) {
+ lastCount = job.processedNotes;
+ onDataChanged();
+ }
+
+ if (job.status === 'completed' || job.status === 'failed') {
+ loadJobs();
+ const errs = job.errorLog
+ ? String(job.errorLog).split('\n').filter(Boolean)
+ : [];
+ return {
+ importedCount: job.processedNotes || 0,
+ errors: errs,
+ };
+ }
+ }
+
+ return {
+ importedCount: lastCount,
+ errors: ['Stopped polling in UI (server job continues in background).'],
+ };
+ };
+
+ const handleFileImport = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (selectedFiles.length === 0) return;
+
+ setImporting(true);
+ setImportResult(null);
+ abortRef.current = false;
+
+ let grandTotalImported = 0;
+ const allErrors: string[] = [];
+
+ try {
+ for (const file of selectedFiles) {
+ if (abortRef.current) break;
+ setProgressInfo({
+ phase: 'uploading',
+ fileName: file.name,
+ bytesUploaded: 0,
+ totalBytes: file.size,
+ processedNotes: 0,
+ totalNotes: 0,
+ });
+
+ const res = await uploadAndProcessFileInChunks(file);
+ grandTotalImported += res.importedCount;
+ allErrors.push(...res.errors);
+ onDataChanged();
+ }
+
+ setImportResult({
+ importedCount: grandTotalImported,
+ errors: allErrors,
+ });
+ setSelectedFiles([]);
+ } catch (err: unknown) {
+ setImportResult({
+ importedCount: grandTotalImported,
+ errors: [
+ ...allErrors,
+ err instanceof Error ? err.message : 'Import failed',
+ ],
+ });
+ } finally {
+ setImporting(false);
+ setProgressInfo(null);
+ loadJobs();
+ }
+ };
+
+ const handleUrlClip = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!clipUrl.trim()) return;
+
+ setImporting(true);
+ setImportResult(null);
+
+ try {
+ const res = await fetch('/api/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ url: clipUrl.trim(),
+ title: clipTitle.trim() || undefined,
+ notebookId: notebookId || undefined,
+ notebookName: newNotebookName.trim() || undefined,
+ tags: clipTags
+ .split(',')
+ .map((t) => t.trim())
+ .filter(Boolean),
+ }),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ throw new Error(data.error || 'URL clip failed');
+ }
+ setImportResult({
+ importedCount: data.importedCount || 1,
+ errors: data.errors || [],
+ });
+ setClipUrl('');
+ setClipTitle('');
+ onDataChanged(data.noteIds?.[0]);
+ } catch (err: unknown) {
+ setImportResult({
+ importedCount: 0,
+ errors: [err instanceof Error ? err.message : 'URL clip failed'],
+ });
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ // --- API KEYS HANDLERS ---
+ const handleCreateKey = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const res = await fetch('/api/keys', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: newKeyName }),
+ });
+ const data = await res.json();
+ if (data.rawKey) {
+ setGeneratedKey(data.rawKey);
+ loadKeys();
+ }
+ };
+
+ const handleDeleteKey = async (id: string) => {
+ await fetch(`/api/keys?id=${id}`, { method: 'DELETE' });
+ loadKeys();
+ };
+
+ const downloadAndroidHttpShortcuts = () => {
+ const shortcutsJson = {
+ version: 60,
+ categories: [
+ {
+ id: 'everclone-cat-1',
+ name: 'Everclone',
+ layoutType: 'linear_list',
+ shortcuts: [
+ {
+ id: 'everclone-share-url',
+ name: 'Clip Link / Text to Everclone',
+ description:
+ 'Shares any URL or text selection from Android Share sheet to Everclone',
+ method: 'POST',
+ url: `${origin}/api/hooks/note`,
+ headers: [
+ { key: 'Authorization', value: `Bearer ${activeToken}` },
+ { key: 'Content-Type', value: 'application/json' },
+ ],
+ bodyContent: JSON.stringify({
+ title: '{share_title}',
+ content: '{share_text}',
+ tags: ['android'],
+ }),
+ requestBodyType: 'custom_text',
+ launcherShortcut: true,
+ quickSettingsTileShortcut: true,
+ },
+ {
+ id: 'everclone-quick-note',
+ name: 'Quick Note to Everclone',
+ description: 'Prompts for a quick note and saves it to Everclone Inbox',
+ method: 'POST',
+ url: `${origin}/api/hooks/note`,
+ headers: [
+ { key: 'Authorization', value: `Bearer ${activeToken}` },
+ { key: 'Content-Type', value: 'application/json' },
+ ],
+ bodyContent: JSON.stringify({
+ title: '{note_title}',
+ markdown: '{note_body}',
+ tags: ['quick-note', 'android'],
+ }),
+ requestBodyType: 'custom_text',
+ launcherShortcut: true,
+ },
+ ],
+ },
+ ],
+ variables: [
+ {
+ id: 'var-share-title',
+ key: 'share_title',
+ type: 'constant',
+ value: '',
+ isShareTitle: true,
+ },
+ {
+ id: 'var-share-text',
+ key: 'share_text',
+ type: 'constant',
+ value: '',
+ isShareText: true,
+ },
+ {
+ id: 'var-note-title',
+ key: 'note_title',
+ type: 'text',
+ title: 'Note Title',
+ Flags: 0,
+ },
+ {
+ id: 'var-note-body',
+ key: 'note_body',
+ type: 'text',
+ title: 'Note Content (Markdown)',
+ isMultiline: true,
+ },
+ ],
+ };
+
+ const blob = new Blob([JSON.stringify(shortcutsJson, null, 2)], {
+ type: 'application/json',
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'everclone-android-http-shortcuts.json';
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ // --- EMAIL HANDLERS ---
+ const handleSaveEmailConfig = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setSavingEmail(true);
+ setEmailStatusMsg('');
+ try {
+ const res = await fetch('/api/email', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(emailCfg),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ setEmailCfg((prev) => ({ ...prev, ...data }));
+ setEmailStatusMsg('β Saved IMAP configuration.');
+ } else {
+ setEmailStatusMsg(`Error: ${data.error}`);
+ }
+ } finally {
+ setSavingEmail(false);
+ }
+ };
+
+ const handlePollImapNow = async () => {
+ setPollingEmail(true);
+ setEmailStatusMsg('');
+ try {
+ const res = await fetch('/api/email', { method: 'POST' });
+ const data = await res.json();
+ if (data.config) setEmailCfg((prev) => ({ ...prev, ...data.config }));
+ if (data.errors?.length) {
+ setEmailStatusMsg(`IMAP Sync Notice: ${data.errors.join('; ')}`);
+ } else {
+ setEmailStatusMsg(
+ `β Checked mailbox: imported ${data.importedCount || 0} new email(s).`
+ );
+ if (data.importedCount > 0) onDataChanged();
+ }
+ } finally {
+ setPollingEmail(false);
+ }
+ };
+
+ const handleSendTestEmailToNote = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setSendingTestEmail(true);
+ setEmailStatusMsg('');
+ try {
+ const res = await fetch('/api/hooks/email', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${activeToken}`,
+ },
+ body: JSON.stringify({
+ subject: testEmailSubject,
+ from: 'you@vloser.us',
+ text: testEmailBody,
+ }),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ setEmailStatusMsg(
+ `β Created note "${data.title}" from email subject syntax!`
+ );
+ onDataChanged(data.noteId);
+ } else {
+ setEmailStatusMsg(`Error: ${data.error}`);
+ }
+ } finally {
+ setSendingTestEmail(false);
+ }
+ };
+
+ const pct = (() => {
+ if (!progressInfo) return 0;
+ if (progressInfo.phase === 'uploading') {
+ return progressInfo.totalBytes > 0
+ ? Math.min(
+ 100,
+ Math.round(
+ (progressInfo.bytesUploaded / progressInfo.totalBytes) * 100
+ )
+ )
+ : 0;
+ }
+ return progressInfo.totalNotes > 0
+ ? Math.min(
+ 100,
+ Math.round(
+ (progressInfo.processedNotes / progressInfo.totalNotes) * 100
+ )
+ )
+ : 50;
+ })();
+
+ const bookmarkletHref = `javascript:(function(){var s=window.getSelection?window.getSelection().toString():'';var u=encodeURIComponent(location.href);var t=encodeURIComponent(document.title);var x=encodeURIComponent(s);window.open('${origin}/api/hooks/note?redirect=1&apiKey=${encodeURIComponent(
+ activeToken
+ )}&url='+u+'&title='+t+'&text='+x,'_blank');})();`;
+
+ return (
+
+ {/* Top Bar */}
+
+
+
+
+ Back to Notes
+
+
+
+ Settings, Import & Integrations
+
+
+ Manage bulk imports, Brave Web Clipper extension, Email-to-Note, and Android hooks
+
+
+
+
+
+ {/* Navigation Tabs */}
+
+
setActiveTab('import')}
+ className={`flex items-center gap-2 py-3 border-b-2 transition ${
+ activeTab === 'import'
+ ? 'border-evernote-green text-evernote-green'
+ : 'border-transparent text-gray-600 hover:text-gray-900'
+ }`}
+ >
+
+ Import & Export
+
+
+
setActiveTab('clipper')}
+ className={`flex items-center gap-2 py-3 border-b-2 transition ${
+ activeTab === 'clipper'
+ ? 'border-evernote-green text-evernote-green'
+ : 'border-transparent text-gray-600 hover:text-gray-900'
+ }`}
+ >
+
+ Brave Web Clipper
+
+
+
setActiveTab('email')}
+ className={`flex items-center gap-2 py-3 border-b-2 transition ${
+ activeTab === 'email'
+ ? 'border-evernote-green text-evernote-green'
+ : 'border-transparent text-gray-600 hover:text-gray-900'
+ }`}
+ >
+
+ Email to Note
+
+
+
setActiveTab('android')}
+ className={`flex items-center gap-2 py-3 border-b-2 transition ${
+ activeTab === 'android'
+ ? 'border-evernote-green text-evernote-green'
+ : 'border-transparent text-gray-600 hover:text-gray-900'
+ }`}
+ >
+
+ Android & API Keys
+
+
+
+ {/* Main Content Area */}
+
+
+ {/* TAB 1: IMPORT & EXPORT */}
+ {activeTab === 'import' && (
+ <>
+
+
+
+
+ Bulk Import Notes (.enex, .md, .html, .zip)
+
+
+ Uploads files in 1.5 MB binary slices and processes multi-GB Evernote archives in the background
+
+
+
window.open('/api/export?format=enex', '_blank')}
+ className="flex items-center gap-1.5 px-3.5 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 rounded-lg text-xs font-medium"
+ >
+
+ Export Full Backup (.enex)
+
+
+
+
+
+ {importResult && (
+
0 && importResult.importedCount === 0
+ ? 'bg-red-50 border-red-200 text-red-800'
+ : 'bg-emerald-50 border-emerald-200 text-emerald-900'
+ }`}
+ >
+
+ {importResult.importedCount > 0 ? (
+
+ ) : (
+
+ )}
+
+ Imported {importResult.importedCount} note
+ {importResult.importedCount === 1 ? '' : 's'}!
+
+
+
+ )}
+
+
+ {/* Clip Web URL by Link */}
+
+
+
+
+ Clip Web Page by URL
+
+
+
+
+
+ {/* Recent Import Jobs Table */}
+ {recentJobs.length > 0 && (
+
+
+
+ Recent & Background Import Jobs
+
+
+ Refresh
+
+
+
+ {recentJobs.map((j) => (
+
+
+ {j.filename}
+
+ {j.sourceType}
+
+
+
+
+ {j.totalNotes > 0
+ ? `${j.processedNotes} / ${j.totalNotes} notes`
+ : `${j.processedNotes} notes`}
+
+
+ {j.status}
+
+
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ {/* TAB 2: BRAVE WEB CLIPPER EXTENSION */}
+ {activeTab === 'clipper' && (
+ <>
+
+
+
+
+ Everclone Web Clipper Extension for Brave / Chrome
+
+
+ Runs directly inside your active Brave tab to capture the live rendered DOM (including lazy-loaded images, carousels, and highlighted text selections), lets you pick your target Notebook & Tags, and archives all graphics locally on Everclone.
+
+
+
+
+ Download Brave Extension (.zip)
+
+
+
+
+
+ How to Install in Brave (takes 15 seconds):
+
+
+
+ Open a new tab in Brave and go to{' '}
+
+ brave://extensions
+
+
+
+ Toggle on Developer mode in the top-right corner.
+
+
+ Click Load unpacked in the top-left and select either:
+
+
+ The ready-to-load folder already saved on your PC at:{' '}
+
+ C:\Users\Chris\Downloads\Everclone\brave-extension
+
+
+
+ Or unzip the downloaded everclone-brave-clipper.zip and select that folder.
+
+
+
+
+ Pin the green E (Everclone Web Clipper) icon in your Brave toolbar and click it on any article!
+
+
+
+
+
+
+
+
+
+ Alternative: 1-Click JavaScript Bookmarklet
+
+
+
+ Drag this button to your Brave Bookmarks Bar to clip any page with one click without an extension:
+
+
+
+ >
+ )}
+
+ {/* TAB 3: EMAIL TO NOTE */}
+ {activeTab === 'email' && (
+ <>
+ {/* Syntax Banner */}
+
+
+
+ Evernote Email Subject Line Syntax Supported!
+
+
+ Append @Notebook (or @"Notebook Name"),{' '}
+ #tag1 #tag2, and ! (to star/shortcut) at the end of any email Subject line:
+
+
+ Subject: Fwd: Q4 Server Invoice @"Home Lab" #receipts #docker !
+
+
+
+ {/* Live Test Simulator */}
+
+
+ Try It Now: Send a Test Email-to-Note
+
+
+
+
+ {/* IMAP Inbox Polling Setup */}
+
+
+
+
+ Option A: Connect an IMAP Mailbox / Gmail App Password
+
+
+ Everclone can check an inbox (or a dedicated folder/subject filter like [Everclone]) and import unseen emails + attachments automatically
+
+
+
+
+ {pollingEmail ? 'Checkingβ¦' : 'Check Mail Now'}
+
+
+
+
+
+
+
+
+
+
+
+
+ {emailCfg.lastCheckedAt
+ ? `Last checked: ${new Date(
+ emailCfg.lastCheckedAt
+ ).toLocaleString()} (${emailCfg.lastStatus || 'OK'})`
+ : 'Not polled yet'}
+
+
+ {savingEmail ? 'Savingβ¦' : 'Save IMAP Settings'}
+
+
+
+
+
+ {/* Inbound Email Webhook */}
+
+
+
+
+ Option B: Inbound Email Webhook (Cloudflare Email Routing / n8n / ForwardEmail)
+
+
+
+ Point any email webhook (or an n8n Email Trigger on vloser) to:
+
+
+ {`POST ${origin}/api/hooks/email
+Authorization: Bearer ${activeToken}
+Content-Type: application/json
+
+{
+ "subject": "Meeting Notes @Work #q4 !",
+ "from": "chris@vloser.us",
+ "html": "Email body here
"
+}`}
+
+
+ >
+ )}
+
+ {/* TAB 4: ANDROID & API KEYS */}
+ {activeTab === 'android' && (
+ <>
+
+
+
+
+ API Keys for Android, Brave Clipper & Webhooks
+
+
+
+
+ setNewKeyName(e.target.value)}
+ placeholder="Key description (e.g. Android Phone)"
+ className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
+ />
+
+
+ Generate Key
+
+
+
+ {generatedKey && (
+
+
+
+ New API Key Created:
+
+
+ {generatedKey}
+
+
+
copyText(generatedKey, 'key')}
+ className="flex items-center gap-1 px-3 py-1.5 bg-white border border-emerald-300 rounded text-xs font-medium text-emerald-800"
+ >
+ {copied === 'key' ? (
+
+ ) : (
+
+ )}
+ {copied === 'key' ? 'Copied' : 'Copy'}
+
+
+ )}
+
+ {keys.length > 0 && (
+
+ {keys.map((k) => (
+
+
+ {k.name}
+
+ {k.keyPrefix}
+
+
+
+
+ {k.lastUsedAt
+ ? `Last used ${new Date(k.lastUsedAt).toLocaleDateString()}`
+ : 'Never used'}
+
+ handleDeleteKey(k.id)}
+ className="text-red-500 hover:text-red-700 p-1"
+ >
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ Option A: Android HTTP Shortcuts (.json)
+
+
+ Import this file into the free HTTP Shortcuts Android app to add{' '}
+ “Clip to Everclone” and “Quick Note” to your Android Share sheet & home screen widgets:
+
+
+
+ Download Android Shortcuts (.json)
+
+
+
+
+
+ Option B: Install PWA Share Target
+
+
+ Open {origin} in Brave or Chrome on Android and tap{' '}
+ “Add to Home screen / Install app” . Everclone will register itself inside Android's native Share menu!
+
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/components/ThumbnailEditorModal.tsx b/src/components/ThumbnailEditorModal.tsx
new file mode 100644
index 0000000..9037cbe
--- /dev/null
+++ b/src/components/ThumbnailEditorModal.tsx
@@ -0,0 +1,1122 @@
+'use client';
+
+import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
+import {
+ X,
+ Image as ImageIcon,
+ Upload,
+ Crop,
+ RotateCcw,
+ RotateCw,
+ FlipHorizontal,
+ FlipVertical,
+ ZoomIn,
+ Sparkles,
+ EyeOff,
+ Check,
+ Wand2,
+ Palette,
+ Sliders,
+ Link as LinkIcon,
+} from 'lucide-react';
+import { NoteDetail } from '@/types/everclone';
+import { renderPdfPagesPreview } from '@/lib/pdfClient';
+
+interface ThumbnailEditorModalProps {
+ open: boolean;
+ note: NoteDetail | null;
+ initialImageUrl?: string | null;
+ onClose: () => void;
+ onSaveThumbnail: (thumbnailUrl: string | null) => Promise;
+}
+
+interface CandidateImage {
+ url: string;
+ label: string;
+ source: 'attachment' | 'inline' | 'current';
+}
+
+const BADGE_THEMES = [
+ { name: 'Emerald', c1: '#00a82d', c2: '#065f46', text: '#ffffff' },
+ { name: 'Midnight', c1: '#1e293b', c2: '#0f172a', text: '#38bdf8' },
+ { name: 'Ocean', c1: '#0284c7', c2: '#1e3a8a', text: '#ffffff' },
+ { name: 'Amber', c1: '#f59e0b', c2: '#b45309', text: '#ffffff' },
+ { name: 'Purple', c1: '#7c3aed', c2: '#4c1d95', text: '#ffffff' },
+ { name: 'Crimson', c1: '#e11d48', c2: '#881337', text: '#ffffff' },
+ { name: 'Teal', c1: '#0d9488', c2: '#115e59', text: '#ffffff' },
+ { name: 'Warm Dark', c1: '#292524', c2: '#1c1917', text: '#fbbf24' },
+];
+
+const QUICK_EMOJIS = [
+ 'π',
+ 'π',
+ 'π‘',
+ 'π οΈ',
+ 'π₯οΈ',
+ 'π',
+ 'π',
+ 'π',
+ 'β‘',
+ 'π―',
+ 'π°',
+ 'π ',
+ 'π',
+ 'π¦',
+ 'π§ͺ',
+ 'β',
+];
+
+export default function ThumbnailEditorModal({
+ open,
+ note,
+ initialImageUrl,
+ onClose,
+ onSaveThumbnail,
+}: ThumbnailEditorModalProps) {
+ const [activeTab, setActiveTab] = useState<'crop' | 'badge'>('crop');
+ const [selectedSourceUrl, setSelectedSourceUrl] = useState('');
+ const [customUrlInput, setCustomUrlInput] = useState('');
+ const [saving, setSaving] = useState(false);
+ const [imageLoadError, setImageLoadError] = useState(null);
+
+ // Crop & Transform State
+ const [zoom, setZoom] = useState(1);
+ const [panX, setPanX] = useState(0); // -150 to +150 px on 320x320 canvas
+ const [panY, setPanY] = useState(0);
+ const [rotation, setRotation] = useState(0); // degrees
+ const [flipH, setFlipH] = useState(false);
+ const [flipV, setFlipV] = useState(false);
+
+ // Color / Filter State
+ const [brightness, setBrightness] = useState(100);
+ const [contrast, setContrast] = useState(100);
+ const [saturation, setSaturation] = useState(100);
+
+ // Badge Generator State
+ const [badgeEmoji, setBadgeEmoji] = useState('π');
+ const [badgeLine1, setBadgeLine1] = useState('');
+ const [badgeLine2, setBadgeLine2] = useState('');
+ const [badgeThemeIdx, setBadgeThemeIdx] = useState(0);
+
+ // Dragging state for interactive canvas pan
+ const [isDragging, setIsDragging] = useState(false);
+ const dragStartRef = useRef<{ x: number; y: number; panX: number; panY: number }>({
+ x: 0,
+ y: 0,
+ panX: 0,
+ panY: 0,
+ });
+
+ const canvasRef = useRef(null);
+ const loadedImageRef = useRef(null);
+ const fileInputRef = useRef(null);
+ const [previewDataUrl, setPreviewDataUrl] = useState('');
+ const [pdfPageCandidates, setPdfPageCandidates] = useState([]);
+
+ // Extract pages 1..4 from any PDF attachments when modal opens
+ useEffect(() => {
+ if (!open || !note) {
+ setPdfPageCandidates([]);
+ return;
+ }
+ const pdfAtts = (note.attachments || []).filter(
+ (a) =>
+ a.mimeType === 'application/pdf' ||
+ a.filename.toLowerCase().endsWith('.pdf')
+ );
+ if (pdfAtts.length === 0) {
+ setPdfPageCandidates([]);
+ return;
+ }
+
+ let cancelled = false;
+ (async () => {
+ const extracted: CandidateImage[] = [];
+ for (const att of pdfAtts) {
+ const pages = await renderPdfPagesPreview(`/api/attachments/${att.id}`, 4);
+ for (const p of pages) {
+ extracted.push({
+ url: p.dataUrl,
+ label: `${att.filename} (Page ${p.page})`,
+ source: 'attachment',
+ });
+ }
+ }
+ if (!cancelled && extracted.length > 0) {
+ setPdfPageCandidates(extracted);
+ setSelectedSourceUrl((prev) => {
+ if (!prev) {
+ setActiveTab('crop');
+ return extracted[0].url;
+ }
+ return prev;
+ });
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [open, note]);
+
+ // Extract all candidate images from note attachments + inline HTML tags + PDF pages
+ const candidateImages = useMemo(() => {
+ if (!note) return [];
+ const list: CandidateImage[] = [];
+ const seen = new Set();
+
+ // 1. Current custom thumbnail if set
+ if (note.thumbnailUrl && note.thumbnailUrl !== 'none') {
+ seen.add(note.thumbnailUrl);
+ list.push({
+ url: note.thumbnailUrl,
+ label: 'Current Thumbnail',
+ source: 'current',
+ });
+ }
+
+ // 2. Image attachments
+ for (const att of note.attachments || []) {
+ if (att.mimeType.startsWith('image/')) {
+ const url = `/api/attachments/${att.id}`;
+ if (!seen.has(url)) {
+ seen.add(url);
+ list.push({
+ url,
+ label: att.filename || 'Attachment',
+ source: 'attachment',
+ });
+ }
+ }
+ }
+
+ // 3. Inline tags inside contentHtml
+ if (note.contentHtml && typeof window !== 'undefined') {
+ try {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(note.contentHtml, 'text/html');
+ const imgs = Array.from(doc.querySelectorAll('img'));
+ imgs.forEach((img, idx) => {
+ const src = img.getAttribute('src')?.trim();
+ if (src && !seen.has(src)) {
+ seen.add(src);
+ list.push({
+ url: src,
+ label: img.getAttribute('alt') || `Inline Image #${idx + 1}`,
+ source: 'inline',
+ });
+ }
+ });
+ } catch {
+ // ignore parse errors
+ }
+ }
+
+ // 4. Rendered PDF Pages
+ for (const pdfCand of pdfPageCandidates) {
+ if (!seen.has(pdfCand.url)) {
+ seen.add(pdfCand.url);
+ list.push(pdfCand);
+ }
+ }
+
+ return list;
+ }, [note, pdfPageCandidates]);
+
+ const resetTransforms = useCallback(() => {
+ setZoom(1);
+ setPanX(0);
+ setPanY(0);
+ setRotation(0);
+ setFlipH(false);
+ setFlipV(false);
+ setBrightness(100);
+ setContrast(100);
+ setSaturation(100);
+ }, []);
+
+ // Initialize state when modal opens
+ useEffect(() => {
+ if (!open || !note) return;
+ resetTransforms();
+ setImageLoadError(null);
+
+ const shortWords = (note.title || 'Note')
+ .trim()
+ .split(/\s+/)
+ .slice(0, 4);
+ setBadgeLine1(shortWords.slice(0, 2).join(' ').toUpperCase());
+ setBadgeLine2(shortWords.slice(2, 4).join(' ').toUpperCase());
+
+ if (initialImageUrl) {
+ setActiveTab('crop');
+ setSelectedSourceUrl(initialImageUrl);
+ } else if (note.thumbnailUrl && note.thumbnailUrl !== 'none') {
+ setActiveTab('crop');
+ setSelectedSourceUrl(note.thumbnailUrl);
+ } else if (candidateImages.length > 0) {
+ setActiveTab('crop');
+ setSelectedSourceUrl(candidateImages[0].url);
+ } else {
+ setActiveTab('badge');
+ setSelectedSourceUrl('');
+ }
+ }, [open, note, initialImageUrl, candidateImages, resetTransforms]);
+
+ // Draw the active crop or badge onto the 320x320 HTML5 Canvas
+ const renderCanvas = useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const SIZE = 320;
+ ctx.clearRect(0, 0, SIZE, SIZE);
+
+ if (activeTab === 'badge') {
+ const theme = BADGE_THEMES[badgeThemeIdx] || BADGE_THEMES[0];
+ const grad = ctx.createLinearGradient(0, 0, SIZE, SIZE);
+ grad.addColorStop(0, theme.c1);
+ grad.addColorStop(1, theme.c2);
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, SIZE, SIZE);
+
+ // Subtle decorative circle in top-right
+ ctx.save();
+ ctx.fillStyle = 'rgba(255,255,255,0.07)';
+ ctx.beginPath();
+ ctx.arc(SIZE - 40, 40, 110, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+
+ // Emoji / Icon
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.font = '88px system-ui, "Segoe UI Emoji", "Apple Color Emoji", sans-serif';
+ ctx.fillText(badgeEmoji || 'π', SIZE / 2, badgeLine1 || badgeLine2 ? 115 : SIZE / 2);
+
+ // Badge Text Lines
+ ctx.fillStyle = theme.text;
+ if (badgeLine1) {
+ ctx.font = '800 34px system-ui, -apple-system, sans-serif';
+ ctx.fillText(badgeLine1.slice(0, 14), SIZE / 2, badgeLine2 ? 215 : 235, SIZE - 32);
+ }
+ if (badgeLine2) {
+ ctx.font = '700 28px system-ui, -apple-system, sans-serif';
+ ctx.globalAlpha = 0.9;
+ ctx.fillText(badgeLine2.slice(0, 16), SIZE / 2, 258, SIZE - 32);
+ ctx.globalAlpha = 1;
+ }
+
+ try {
+ setPreviewDataUrl(canvas.toDataURL('image/jpeg', 0.9));
+ } catch {
+ // ignore
+ }
+ return;
+ }
+
+ // Crop mode
+ const img = loadedImageRef.current;
+ ctx.fillStyle = '#f3f4f6';
+ ctx.fillRect(0, 0, SIZE, SIZE);
+
+ if (!img || !img.complete || img.naturalWidth === 0) {
+ return;
+ }
+
+ ctx.save();
+ ctx.filter = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturation}%)`;
+
+ // Move origin to center + user pan offset
+ ctx.translate(SIZE / 2 + panX, SIZE / 2 + panY);
+ ctx.rotate((rotation * Math.PI) / 180);
+ ctx.scale(flipH ? -zoom : zoom, flipV ? -zoom : zoom);
+
+ // Base "object-cover" scale so zoom=1 fills the 320x320 square
+ const iw = img.naturalWidth;
+ const ih = img.naturalHeight;
+ const baseScale = Math.max(SIZE / iw, SIZE / ih);
+ const drawW = iw * baseScale;
+ const drawH = ih * baseScale;
+
+ ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
+ ctx.restore();
+
+ try {
+ setPreviewDataUrl(canvas.toDataURL('image/jpeg', 0.88));
+ } catch {
+ // If tainted by cross-origin image without CORS, fall back to raw URL
+ setPreviewDataUrl(selectedSourceUrl);
+ }
+ }, [
+ activeTab,
+ badgeEmoji,
+ badgeLine1,
+ badgeLine2,
+ badgeThemeIdx,
+ brightness,
+ contrast,
+ saturation,
+ zoom,
+ panX,
+ panY,
+ rotation,
+ flipH,
+ flipV,
+ selectedSourceUrl,
+ ]);
+
+ // Load selected image when selectedSourceUrl changes
+ useEffect(() => {
+ if (!open || activeTab !== 'crop' || !selectedSourceUrl) {
+ renderCanvas();
+ return;
+ }
+
+ setImageLoadError(null);
+ const img = new window.Image();
+ img.crossOrigin = 'anonymous';
+ img.onload = () => {
+ loadedImageRef.current = img;
+ renderCanvas();
+ };
+ img.onerror = () => {
+ // Retry without crossOrigin if external server blocks anonymous CORS
+ const fallbackImg = new window.Image();
+ fallbackImg.onload = () => {
+ loadedImageRef.current = fallbackImg;
+ renderCanvas();
+ };
+ fallbackImg.onerror = () => {
+ setImageLoadError('Could not load this image URL.');
+ };
+ fallbackImg.src = selectedSourceUrl;
+ };
+ img.src = selectedSourceUrl;
+ }, [open, activeTab, selectedSourceUrl, renderCanvas]);
+
+ // Re-render canvas whenever adjustments change
+ useEffect(() => {
+ if (open) {
+ renderCanvas();
+ }
+ }, [open, renderCanvas]);
+
+ // Support Ctrl+V clipboard image paste anywhere inside the modal
+ useEffect(() => {
+ if (!open) return;
+ const handlePaste = (e: ClipboardEvent) => {
+ const items = e.clipboardData?.items;
+ if (!items) return;
+ for (const item of Array.from(items)) {
+ if (item.type.startsWith('image/')) {
+ const file = item.getAsFile();
+ if (file) {
+ e.preventDefault();
+ const reader = new FileReader();
+ reader.onload = () => {
+ if (typeof reader.result === 'string') {
+ resetTransforms();
+ setActiveTab('crop');
+ setSelectedSourceUrl(reader.result);
+ }
+ };
+ reader.readAsDataURL(file);
+ break;
+ }
+ }
+ }
+ };
+ window.addEventListener('paste', handlePaste);
+ return () => window.removeEventListener('paste', handlePaste);
+ }, [open, resetTransforms]);
+
+ const handleFileSelect = (file: File) => {
+ if (!file.type.startsWith('image/')) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ if (typeof reader.result === 'string') {
+ resetTransforms();
+ setActiveTab('crop');
+ setSelectedSourceUrl(reader.result);
+ }
+ };
+ reader.readAsDataURL(file);
+ };
+
+ // Mouse drag handlers for interactive panning
+ const handleMouseDown = (e: React.MouseEvent) => {
+ if (activeTab !== 'crop') return;
+ setIsDragging(true);
+ dragStartRef.current = {
+ x: e.clientX,
+ y: e.clientY,
+ panX,
+ panY,
+ };
+ };
+
+ const handleMouseMove = (e: React.MouseEvent) => {
+ if (!isDragging || activeTab !== 'crop') return;
+ const dx = e.clientX - dragStartRef.current.x;
+ const dy = e.clientY - dragStartRef.current.y;
+ setPanX(Math.max(-240, Math.min(240, dragStartRef.current.panX + dx)));
+ setPanY(Math.max(-240, Math.min(240, dragStartRef.current.panY + dy)));
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ };
+
+ const handleWheel = (e: React.WheelEvent) => {
+ if (activeTab !== 'crop') return;
+ e.preventDefault();
+ const delta = e.deltaY < 0 ? 0.12 : -0.12;
+ setZoom((z) => Math.max(0.5, Math.min(4, Number((z + delta).toFixed(2)))));
+ };
+
+ const isUnmodifiedSource =
+ activeTab === 'crop' &&
+ zoom === 1 &&
+ panX === 0 &&
+ panY === 0 &&
+ rotation === 0 &&
+ !flipH &&
+ !flipV &&
+ brightness === 100 &&
+ contrast === 100 &&
+ saturation === 100 &&
+ selectedSourceUrl &&
+ !selectedSourceUrl.startsWith('data:');
+
+ const handleApplyAndSave = async () => {
+ setSaving(true);
+ try {
+ // If user picked an existing attachment/inline image and didn't crop/filter it, save its clean URL
+ if (isUnmodifiedSource) {
+ await onSaveThumbnail(selectedSourceUrl);
+ } else if (canvasRef.current) {
+ try {
+ const dataUrl = canvasRef.current.toDataURL('image/jpeg', 0.88);
+ await onSaveThumbnail(dataUrl);
+ } catch {
+ // Cross-origin fallback
+ await onSaveThumbnail(selectedSourceUrl || null);
+ }
+ }
+ onClose();
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (!open || !note) return null;
+
+ return (
+
+
+ {/* Modal Header */}
+
+
+
+
+
+
+
+ Thumbnail Studio β {note.title || 'Untitled Note'}
+
+
+ Pick any image from the note, crop & adjust the focal point, paste from clipboard (Ctrl+V), or design a cover badge
+
+
+
+
+
+
+
+
+
+ {/* Body Grid: Left Source Selector + Right Interactive Editor */}
+
+ {/* LEFT COLUMN (5 cols): Source Picker & Gallery */}
+
+ {/* Mode Switcher Tabs */}
+
+
setActiveTab('crop')}
+ className={`flex items-center justify-center gap-1.5 py-2 rounded-lg transition ${
+ activeTab === 'crop'
+ ? 'bg-white text-gray-900 shadow-xs'
+ : 'text-gray-600 hover:text-gray-900'
+ }`}
+ >
+
+ Photo / Note Image
+
+
setActiveTab('badge')}
+ className={`flex items-center justify-center gap-1.5 py-2 rounded-lg transition ${
+ activeTab === 'badge'
+ ? 'bg-white text-gray-900 shadow-xs'
+ : 'text-gray-600 hover:text-gray-900'
+ }`}
+ >
+
+ Cover Badge
+
+
+
+ {activeTab === 'crop' ? (
+ <>
+ {/* 1. Images inside this note */}
+
+
+
+ Images in This Note ({candidateImages.length})
+
+ {candidateImages.length > 0 && (
+ Click to load
+ )}
+
+
+ {candidateImages.length === 0 ? (
+
+ No images found inside this note yet. Upload or paste an image below, or switch to the Cover Badge tab!
+
+ ) : (
+
+ {candidateImages.map((img, i) => {
+ const isSelected = selectedSourceUrl === img.url;
+ return (
+
{
+ resetTransforms();
+ setSelectedSourceUrl(img.url);
+ }}
+ className={`group relative aspect-square rounded-xl overflow-hidden border-2 transition bg-white ${
+ isSelected
+ ? 'border-evernote-green ring-2 ring-emerald-500/20'
+ : 'border-gray-200 hover:border-gray-400'
+ }`}
+ title={img.label}
+ >
+
+ {isSelected && (
+
+
+
+ )}
+
+ {img.source === 'current' ? 'β
Active' : `#${i + 1}`}
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* 2. Upload or Paste Custom Image */}
+
+
+ Upload or Paste Custom Image
+
+
fileInputRef.current?.click()}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={(e) => {
+ e.preventDefault();
+ if (e.dataTransfer.files?.[0]) {
+ handleFileSelect(e.dataTransfer.files[0]);
+ }
+ }}
+ className="cursor-pointer p-3.5 rounded-xl border-2 border-dashed border-gray-300 hover:border-evernote-green bg-white transition text-center space-y-1"
+ >
+
+
+ Click to browse, drag & drop, or press Ctrl+V
+
+
+ Supports PNG, JPG, WebP, GIF
+
+
+
{
+ if (e.target.files?.[0]) {
+ handleFileSelect(e.target.files[0]);
+ e.target.value = '';
+ }
+ }}
+ />
+
+ {/* Direct Image URL */}
+
+
+
+ setCustomUrlInput(e.target.value)}
+ placeholder="Or paste an image URL (https://β¦)"
+ className="w-full pl-8 pr-2.5 py-1.5 bg-white border border-gray-300 rounded-lg text-xs focus:outline-none focus:border-evernote-green"
+ />
+
+
{
+ if (customUrlInput.trim()) {
+ resetTransforms();
+ setSelectedSourceUrl(customUrlInput.trim());
+ setCustomUrlInput('');
+ }
+ }}
+ className="px-3 py-1.5 bg-gray-800 hover:bg-gray-900 text-white rounded-lg text-xs font-medium shrink-0"
+ >
+ Load
+
+
+
+ >
+ ) : (
+ /* Cover Badge Studio Controls */
+
+
+
+ 1. Choose Icon / Emoji
+
+
+ {QUICK_EMOJIS.map((em) => (
+ setBadgeEmoji(em)}
+ className={`py-1.5 text-base rounded-lg border transition ${
+ badgeEmoji === em
+ ? 'bg-emerald-50 border-evernote-green ring-1 ring-evernote-green'
+ : 'bg-white border-gray-200 hover:bg-gray-100'
+ }`}
+ >
+ {em}
+
+ ))}
+
+
setBadgeEmoji(e.target.value)}
+ placeholder="Custom emoji or symbolβ¦"
+ className="w-full px-3 py-1.5 bg-white border border-gray-300 rounded-lg text-xs"
+ />
+
+
+
+
+ 2. Cover Label Text
+
+ setBadgeLine1(e.target.value)}
+ placeholder="Line 1 (e.g. HOMELAB)"
+ maxLength={14}
+ className="w-full px-3 py-1.5 bg-white border border-gray-300 rounded-lg text-xs font-semibold"
+ />
+ setBadgeLine2(e.target.value)}
+ placeholder="Line 2 (optional, e.g. CHECKLIST)"
+ maxLength={16}
+ className="w-full px-3 py-1.5 bg-white border border-gray-300 rounded-lg text-xs"
+ />
+
+
+
+
+ 3. Color Theme
+
+
+ {BADGE_THEMES.map((t, idx) => (
+ setBadgeThemeIdx(idx)}
+ style={{
+ background: `linear-gradient(135deg, ${t.c1}, ${t.c2})`,
+ color: t.text,
+ }}
+ className={`py-2 px-2 rounded-lg text-[11px] font-bold shadow-xs border-2 transition ${
+ badgeThemeIdx === idx
+ ? 'border-gray-900 scale-105'
+ : 'border-transparent opacity-85 hover:opacity-100'
+ }`}
+ >
+ {t.name}
+
+ ))}
+
+
+
+ )}
+
+ {/* Quick Reset / Hide Actions */}
+
+ {
+ setSaving(true);
+ await onSaveThumbnail(null);
+ setSaving(false);
+ onClose();
+ }}
+ className="w-full flex items-center justify-center gap-1.5 py-2 px-3 bg-white hover:bg-gray-100 text-gray-700 border border-gray-300 rounded-lg text-xs font-medium transition"
+ >
+
+ Reset to Automatic (First Note Image)
+
+
+ {
+ setSaving(true);
+ await onSaveThumbnail('none');
+ setSaving(false);
+ onClose();
+ }}
+ className="w-full flex items-center justify-center gap-1.5 py-2 px-3 bg-white hover:bg-red-50 text-red-600 border border-red-200 rounded-lg text-xs font-medium transition"
+ >
+
+ Hide Thumbnail for This Note
+
+
+
+
+ {/* RIGHT COLUMN (7 cols): Interactive Crop/Adjust Canvas & Live Note Preview */}
+
+
+ {/* Interactive 320x320 Canvas */}
+
+
+
+ {/* Rule of thirds subtle framing overlay when dragging */}
+ {activeTab === 'crop' && (
+
+ )}
+
+ {activeTab === 'crop' && (
+
+ Drag image to reposition Β· Scroll wheel to zoom
+
+ )}
+ {imageLoadError && (
+
+ {imageLoadError}
+
+ )}
+
+
+ {/* Live Snippet View Preview Card */}
+
+
+ Live Note List Preview
+
+
+ {/* Simulated Snippet Row */}
+
+
+
+
+ {note.title || 'Untitled Note'}
+
+
+ {note.excerpt || 'Your note preview text appears here alongside the thumbnailβ¦'}
+
+
Just now
+
+ {previewDataUrl && (
+
+ )}
+
+
+
+ {/* Simulated Card Size Preview */}
+
+ {previewDataUrl && (
+
+ )}
+
+
Card View Size (80Γ80)
+
+ Saved at crisp 320Γ320 resolution so it looks sharp on high-DPI displays.
+
+
+
+
+
+
+ {/* Crop, Rotate & Image Filter Controls (when in Photo/Crop mode) */}
+ {activeTab === 'crop' && (
+
+ {/* Zoom & Rotate Row */}
+
+
+
+
+
+ Zoom
+
+ {Math.round(zoom * 100)}%
+
+
setZoom(parseFloat(e.target.value))}
+ className="w-full accent-evernote-green"
+ />
+
+
+
+
+ Transform & Orientation
+
+ Reset All
+
+
+
+ setRotation((r) => (r - 90) % 360)}
+ title="Rotate 90Β° Left"
+ className="flex-1 flex items-center justify-center gap-1 py-1.5 px-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-xs font-medium text-gray-700"
+ >
+
+ -90Β°
+
+ setRotation((r) => (r + 90) % 360)}
+ title="Rotate 90Β° Right"
+ className="flex-1 flex items-center justify-center gap-1 py-1.5 px-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-xs font-medium text-gray-700"
+ >
+
+ +90Β°
+
+ setFlipH((f) => !f)}
+ title="Flip Horizontal"
+ className={`p-1.5 rounded-lg border text-xs ${
+ flipH
+ ? 'bg-emerald-50 border-evernote-green text-evernote-green'
+ : 'bg-gray-100 border-transparent text-gray-700 hover:bg-gray-200'
+ }`}
+ >
+
+
+ setFlipV((f) => !f)}
+ title="Flip Vertical"
+ className={`p-1.5 rounded-lg border text-xs ${
+ flipV
+ ? 'bg-emerald-50 border-evernote-green text-evernote-green'
+ : 'bg-gray-100 border-transparent text-gray-700 hover:bg-gray-200'
+ }`}
+ >
+
+
+
+
+
+
+ {/* Filter Presets + Sliders */}
+
+
+
+
+ Image Enhancements
+
+
+ {
+ setBrightness(100);
+ setContrast(100);
+ setSaturation(100);
+ }}
+ className="px-2 py-0.5 rounded bg-gray-100 hover:bg-gray-200 text-[11px] font-medium text-gray-700"
+ >
+ Normal
+
+ {
+ setBrightness(106);
+ setContrast(122);
+ setSaturation(135);
+ }}
+ className="px-2 py-0.5 rounded bg-emerald-50 hover:bg-emerald-100 text-[11px] font-medium text-emerald-800 flex items-center gap-0.5"
+ >
+ Vivid Pop
+
+ {
+ setBrightness(108);
+ setContrast(145);
+ setSaturation(90);
+ }}
+ className="px-2 py-0.5 rounded bg-gray-100 hover:bg-gray-200 text-[11px] font-medium text-gray-700"
+ >
+ Crisp Screenshot
+
+ {
+ setBrightness(105);
+ setContrast(125);
+ setSaturation(0);
+ }}
+ className="px-2 py-0.5 rounded bg-gray-100 hover:bg-gray-200 text-[11px] font-medium text-gray-700"
+ >
+ B&W
+
+
+
+
+
+
+
+ Brightness
+ {brightness}%
+
+
setBrightness(parseInt(e.target.value, 10))}
+ className="w-full accent-evernote-green"
+ />
+
+
+
+ Contrast
+ {contrast}%
+
+
setContrast(parseInt(e.target.value, 10))}
+ className="w-full accent-evernote-green"
+ />
+
+
+
+ Saturation
+ {saturation}%
+
+
setSaturation(parseInt(e.target.value, 10))}
+ className="w-full accent-evernote-green"
+ />
+
+
+
+
+ )}
+
+
+
+ {/* Modal Footer */}
+
+
+ {note.thumbnailUrl === 'none'
+ ? 'Current status: Thumbnail hidden'
+ : note.thumbnailUrl
+ ? 'Current status: Custom thumbnail active'
+ : 'Current status: Automatic (first image in note)'}
+
+
+
+
+ Cancel
+
+
+
+ {saving ? 'Savingβ¦' : 'Save Thumbnail'}
+
+
+
+
+
+ );
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..ce8ca2c
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,57 @@
+import crypto from 'crypto';
+import { NextRequest } from 'next/server';
+import { prisma } from './prisma';
+
+export function hashApiKey(rawKey: string): string {
+ return crypto.createHash('sha256').update(rawKey).digest('hex');
+}
+
+export function generateRawApiKey(): { rawKey: string; keyHash: string; keyPrefix: string } {
+ const randomBytes = crypto.randomBytes(24).toString('base64url');
+ const rawKey = `evc_${randomBytes}`;
+ const keyHash = hashApiKey(rawKey);
+ const keyPrefix = `${rawKey.slice(0, 10)}...`;
+ return { rawKey, keyHash, keyPrefix };
+}
+
+export async function verifyApiKeyFromRequest(req: NextRequest): Promise {
+ // Check Authorization: Bearer , X-API-Key header, or ?apiKey=
+ let token = '';
+ const authHeader = req.headers.get('authorization');
+ if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
+ token = authHeader.slice(7).trim();
+ }
+ if (!token) {
+ token = req.headers.get('x-api-key')?.trim() || '';
+ }
+ if (!token) {
+ token = req.nextUrl.searchParams.get('apiKey')?.trim() || '';
+ }
+
+ if (!token) {
+ return false;
+ }
+
+ // Allow fallback master API key from env if configured
+ if (process.env.EVERCLONE_MASTER_API_KEY && token === process.env.EVERCLONE_MASTER_API_KEY) {
+ return true;
+ }
+
+ const keyHash = hashApiKey(token);
+ const found = await prisma.apiKey.findUnique({
+ where: { keyHash },
+ });
+
+ if (!found) {
+ return false;
+ }
+
+ await prisma.apiKey
+ .update({
+ where: { id: found.id },
+ data: { lastUsedAt: new Date() },
+ })
+ .catch(() => {});
+
+ return true;
+}
diff --git a/src/lib/email.ts b/src/lib/email.ts
new file mode 100644
index 0000000..7f30c17
--- /dev/null
+++ b/src/lib/email.ts
@@ -0,0 +1,331 @@
+import { ImapFlow } from 'imapflow';
+import { simpleParser } from 'mailparser';
+import { prisma, ensureDefaultNotebook } from './prisma';
+import { getOrCreateNotebookByName, getOrCreateTagIds } from './importers';
+import { saveAttachmentBuffer, stripHtmlToText, makeExcerpt } from './storage';
+
+/**
+ * Parses Evernote's Email-to-Note Subject line syntax:
+ * Example: "Fwd: Q4 Receipt @"Tax 2026" #receipts #finance !"
+ * -> title: "Fwd: Q4 Receipt"
+ * -> notebookName: "Tax 2026"
+ * -> tags: ["receipts", "finance"]
+ * -> isShortcut: true
+ */
+export function parseEvernoteEmailSubject(rawSubject: string): {
+ title: string;
+ notebookName?: string;
+ tags: string[];
+ isShortcut: boolean;
+} {
+ let working = (rawSubject || 'Emailed Note').trim();
+ let notebookName: string | undefined;
+ const tags: string[] = [];
+ let isShortcut = false;
+
+ // Extract @"Notebook Name" or @NotebookName
+ const nbQuoted = /\s*@"([^"]+)"/g;
+ let m: RegExpExecArray | null;
+ while ((m = nbQuoted.exec(working)) !== null) {
+ notebookName = m[1].trim();
+ }
+ working = working.replace(nbQuoted, '');
+
+ const nbSimple = /(?:^|\s)@([a-zA-Z0-9_-]+)/g;
+ while ((m = nbSimple.exec(working)) !== null) {
+ notebookName = m[1].trim();
+ }
+ working = working.replace(nbSimple, ' ');
+
+ // Extract #"Tag Name" or #tag
+ const tagQuoted = /\s*#"([^"]+)"/g;
+ while ((m = tagQuoted.exec(working)) !== null) {
+ if (m[1].trim()) tags.push(m[1].trim());
+ }
+ working = working.replace(tagQuoted, '');
+
+ const tagSimple = /(?:^|\s)#([a-zA-Z0-9_-]+)/g;
+ while ((m = tagSimple.exec(working)) !== null) {
+ if (m[1].trim()) tags.push(m[1].trim());
+ }
+ working = working.replace(tagSimple, ' ');
+
+ // Extract trailing ! for shortcut / reminder
+ if (/(?:^|\s)!(?:\s|$)/.test(working)) {
+ isShortcut = true;
+ working = working.replace(/(?:^|\s)!(?:\s|$)/g, ' ');
+ }
+
+ const title = working.replace(/\s+/g, ' ').trim() || 'Emailed Note';
+ return { title, notebookName, tags, isShortcut };
+}
+
+export interface IncomingEmailPayload {
+ subject: string;
+ from?: string;
+ date?: Date;
+ html?: string;
+ text?: string;
+ defaultNotebook?: string;
+ defaultTags?: string[];
+ attachments?: Array<{
+ filename: string;
+ mimeType: string;
+ content: Buffer;
+ cid?: string;
+ }>;
+}
+
+export async function ingestEmailToNote(
+ email: IncomingEmailPayload
+): Promise<{ noteId: string; title: string }> {
+ const parsedSubj = parseEvernoteEmailSubject(email.subject);
+
+ const targetNbName = parsedSubj.notebookName || email.defaultNotebook || '';
+ const notebookId = targetNbName.trim()
+ ? await getOrCreateNotebookByName(targetNbName.trim())
+ : (await ensureDefaultNotebook()).id;
+
+ const combinedTags = [
+ ...(email.defaultTags || ['emailed']),
+ ...parsedSubj.tags,
+ ];
+ const tagIds = await getOrCreateTagIds(combinedTags);
+
+ let bodyHtml =
+ email.html ||
+ (email.text
+ ? `${email.text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/\n/g, ' ')}
`
+ : '
');
+
+ if (email.from) {
+ bodyHtml = `From: ${email.from.replace(
+ /\n${bodyHtml}`;
+ }
+
+ const note = await prisma.note.create({
+ data: {
+ title: parsedSubj.title,
+ contentHtml: bodyHtml,
+ contentText: stripHtmlToText(bodyHtml),
+ excerpt: makeExcerpt(stripHtmlToText(bodyHtml)),
+ notebookId,
+ author: email.from || null,
+ isShortcut: parsedSubj.isShortcut,
+ createdAt: email.date || new Date(),
+ updatedAt: new Date(),
+ tags: {
+ create: tagIds.map((tagId) => ({ tagId })),
+ },
+ },
+ });
+
+ // Save attachments & replace cid: inline images
+ let appendedMedia = '';
+ for (const att of email.attachments || []) {
+ if (!att.content || att.content.length === 0) continue;
+ const savedBlob = await saveAttachmentBuffer(
+ att.content,
+ att.filename || 'email-attachment',
+ att.mimeType || 'application/octet-stream'
+ );
+ const createdAtt = await prisma.attachment.create({
+ data: {
+ noteId: note.id,
+ filename: att.filename || 'email-attachment',
+ mimeType: att.mimeType || 'application/octet-stream',
+ sizeBytes: savedBlob.sizeBytes,
+ md5Hash: savedBlob.md5Hash,
+ storagePath: savedBlob.storagePath,
+ extractedText: savedBlob.extractedText,
+ },
+ });
+
+ const attUrl = `/api/attachments/${createdAtt.id}`;
+ if (att.cid && bodyHtml.includes(`cid:${att.cid}`)) {
+ bodyHtml = bodyHtml.split(`cid:${att.cid}`).join(attUrl);
+ } else if ((att.mimeType || '').startsWith('image/')) {
+ appendedMedia += `
`;
+ } else {
+ appendedMedia += `
π ${createdAtt.filename}
`;
+ }
+ }
+
+ const finalHtml = appendedMedia ? `${bodyHtml}\n${appendedMedia}` : bodyHtml;
+ if (finalHtml !== note.contentHtml) {
+ await prisma.note.update({
+ where: { id: note.id },
+ data: {
+ contentHtml: finalHtml,
+ contentText: stripHtmlToText(finalHtml),
+ excerpt: makeExcerpt(stripHtmlToText(finalHtml)),
+ },
+ });
+ }
+
+ return { noteId: note.id, title: note.title };
+}
+
+export async function ingestRawMimeEmail(
+ rawMime: Buffer | string,
+ options?: { defaultNotebook?: string; defaultTags?: string[] }
+): Promise<{ noteId: string; title: string }> {
+ const parsed = await simpleParser(rawMime);
+ const fromText = parsed.from?.text || '';
+ const subject = parsed.subject || 'Emailed Note';
+ const html = typeof parsed.html === 'string' ? parsed.html : undefined;
+ const text = parsed.text || undefined;
+
+ const attachments = (parsed.attachments || []).map((a) => ({
+ filename: a.filename || 'attachment',
+ mimeType: a.contentType || 'application/octet-stream',
+ content: a.content,
+ cid: a.cid,
+ }));
+
+ return ingestEmailToNote({
+ subject,
+ from: fromText,
+ date: parsed.date,
+ html,
+ text,
+ defaultNotebook: options?.defaultNotebook,
+ defaultTags: options?.defaultTags,
+ attachments,
+ });
+}
+
+export async function getOrCreateEmailConfig() {
+ let cfg = await prisma.emailConfig.findUnique({ where: { id: 'default' } });
+ if (!cfg) {
+ cfg = await prisma.emailConfig.create({
+ data: { id: 'default' },
+ });
+ }
+ return cfg;
+}
+
+/**
+ * Polls the configured IMAP inbox for unseen emails matching optional filters,
+ * imports them as Everclone notes, and marks them seen.
+ */
+export async function pollImapMailboxOnce(): Promise<{
+ importedCount: number;
+ errors: string[];
+}> {
+ const cfg = await getOrCreateEmailConfig();
+ if (!cfg.imapHost || !cfg.imapUser || !cfg.imapPassword) {
+ return {
+ importedCount: 0,
+ errors: ['IMAP host, username, and password are required.'],
+ };
+ }
+
+ const client = new ImapFlow({
+ host: cfg.imapHost,
+ port: cfg.imapPort || 993,
+ secure: cfg.imapSecure,
+ auth: {
+ user: cfg.imapUser,
+ pass: cfg.imapPassword,
+ },
+ logger: false,
+ });
+
+ let importedCount = 0;
+ const errors: string[] = [];
+
+ try {
+ await client.connect();
+ const lock = await client.getMailboxLock(cfg.mailbox || 'INBOX');
+ try {
+ const searchCriteria: Record
= { seen: false };
+ if (cfg.subjectFilter.trim()) {
+ searchCriteria.subject = cfg.subjectFilter.trim();
+ }
+
+ const allowedList = cfg.allowedSenders
+ .split(',')
+ .map((s) => s.trim().toLowerCase())
+ .filter(Boolean);
+
+ const defaultTags = cfg.defaultTags
+ .split(',')
+ .map((t) => t.trim())
+ .filter(Boolean);
+
+ const messages = client.fetch(searchCriteria, {
+ uid: true,
+ envelope: true,
+ source: true,
+ });
+
+ const processedUids: number[] = [];
+
+ for await (const msg of messages) {
+ try {
+ const senderAddr =
+ msg.envelope?.from?.[0]?.address?.toLowerCase() || '';
+ if (
+ allowedList.length > 0 &&
+ !allowedList.some((allowed) => senderAddr.includes(allowed))
+ ) {
+ continue;
+ }
+
+ if (msg.source) {
+ await ingestRawMimeEmail(msg.source, {
+ defaultNotebook: cfg.defaultNotebook || 'Inbox',
+ defaultTags: defaultTags.length > 0 ? defaultTags : ['emailed'],
+ });
+ importedCount += 1;
+ processedUids.push(msg.uid);
+ }
+ } catch (msgErr: unknown) {
+ errors.push(
+ msgErr instanceof Error ? msgErr.message : String(msgErr)
+ );
+ }
+ }
+
+ if (cfg.markSeen && processedUids.length > 0) {
+ await client.messageFlagsAdd(processedUids, ['\\Seen'], { uid: true });
+ }
+ } finally {
+ lock.release();
+ }
+ await client.logout();
+
+ await prisma.emailConfig.update({
+ where: { id: 'default' },
+ data: {
+ lastCheckedAt: new Date(),
+ lastStatus:
+ errors.length > 0
+ ? `Error: ${errors[0]}`
+ : `OK (${importedCount} imported)`,
+ emailsImported: { increment: importedCount },
+ },
+ });
+ } catch (err: unknown) {
+ const errMsg = err instanceof Error ? err.message : String(err);
+ errors.push(errMsg);
+ await prisma.emailConfig
+ .update({
+ where: { id: 'default' },
+ data: {
+ lastCheckedAt: new Date(),
+ lastStatus: `Failed: ${errMsg}`,
+ },
+ })
+ .catch(() => {});
+ }
+
+ return { importedCount, errors };
+}
diff --git a/src/lib/importers.ts b/src/lib/importers.ts
new file mode 100644
index 0000000..40f7287
--- /dev/null
+++ b/src/lib/importers.ts
@@ -0,0 +1,849 @@
+import sax from 'sax';
+import { marked } from 'marked';
+import { JSDOM } from 'jsdom';
+import { Readability } from '@mozilla/readability';
+import { prisma, getCurrentUserId, ensureDefaultNotebook } from './prisma';
+import {
+ saveAttachmentBuffer,
+ stripHtmlToText,
+ makeExcerpt,
+ guessExtensionFromMime,
+} from './storage';
+
+export function parseEvernoteTimestamp(ts?: string | null): Date | null {
+ if (!ts) return null;
+ const trimmed = ts.trim();
+ const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/i.exec(trimmed);
+ if (m) {
+ const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
+ const d = new Date(iso);
+ if (!isNaN(d.getTime())) return d;
+ }
+ const fallback = new Date(trimmed);
+ return isNaN(fallback.getTime()) ? null : fallback;
+}
+
+export function toEvernoteTimestamp(date: Date): string {
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
+}
+
+interface ParsedResource {
+ base64Data: string;
+ mime: string;
+ width?: number;
+ height?: number;
+ fileName?: string;
+}
+
+interface ParsedEnexNote {
+ title: string;
+ contentEnml: string;
+ created?: string;
+ updated?: string;
+ tags: string[];
+ sourceUrl?: string;
+ author?: string;
+ latitude?: number;
+ longitude?: number;
+ reminderTime?: string;
+ resources: ParsedResource[];
+}
+
+export async function getOrCreateNotebookByName(
+ name: string,
+ explicitUserId?: string
+): Promise {
+ const userId = explicitUserId || (await getCurrentUserId());
+ const cleanName = (name || 'Imported Notes').trim();
+ const existing = await prisma.notebook.findFirst({
+ where: { userId, name: { equals: cleanName, mode: 'insensitive' } },
+ });
+ if (existing) return existing.id;
+
+ const created = await prisma.notebook.create({
+ data: { userId, name: cleanName },
+ });
+ return created.id;
+}
+
+export async function getOrCreateTagIds(
+ tagNames: string[],
+ explicitUserId?: string
+): Promise {
+ const userId = explicitUserId || (await getCurrentUserId());
+ const uniqueNames = Array.from(
+ new Set(tagNames.map((t) => t.trim()).filter((t) => t.length > 0))
+ );
+ const ids: string[] = [];
+
+ for (const name of uniqueNames) {
+ let tag = await prisma.tag.findFirst({
+ where: { userId, name: { equals: name, mode: 'insensitive' } },
+ });
+ if (!tag) {
+ tag = await prisma.tag.create({
+ data: { userId, name },
+ });
+ }
+ ids.push(tag.id);
+ }
+ return ids;
+}
+
+export function convertEnmlToHtml(
+ enml: string,
+ mediaByHash: Map<
+ string,
+ { id: string; filename: string; mimeType: string; sizeBytes: number }
+ >
+): string {
+ if (!enml) return '';
+
+ let html = enml
+ .replace(/<\?xml[^>]*\?>/gi, '')
+ .replace(/]*>/gi, '')
+ .replace(/]*>/gi, '')
+ .replace(/<\/en-note>/gi, '
');
+
+ html = html.replace(/]*?)\/?>/gi, (_match, attrs: string) => {
+ const isChecked = /checked\s*=\s*["']true["']/i.test(attrs);
+ return ` `;
+ });
+
+ html = html.replace(
+ /]*?)(?:\/>|><\/en-media>)/gi,
+ (_match, attrs: string) => {
+ const hashMatch = /hash\s*=\s*["']([a-fA-F0-9]+)["']/i.exec(attrs);
+ const typeMatch = /type\s*=\s*["']([^"']+)["']/i.exec(attrs);
+ const hash = hashMatch ? hashMatch[1].toLowerCase() : '';
+ const mime = typeMatch ? typeMatch[1].toLowerCase() : '';
+
+ const attachment = hash ? mediaByHash.get(hash) : undefined;
+ if (!attachment) {
+ return `[Missing media ${hash.slice(0, 8)}] `;
+ }
+
+ const url = `/api/attachments/${attachment.id}`;
+ const effectiveMime = attachment.mimeType || mime;
+
+ if (effectiveMime.startsWith('image/')) {
+ return ` `;
+ }
+ if (effectiveMime.startsWith('audio/')) {
+ return `π΅ ${escapeHtml(attachment.filename)}
`;
+ }
+ if (effectiveMime === 'application/pdf') {
+ return ``;
+ }
+ return ``;
+ }
+ );
+
+ return html.trim();
+}
+
+function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+export async function saveParsedEnexNote(
+ parsed: ParsedEnexNote,
+ notebookId: string
+): Promise {
+ const userId = await getCurrentUserId();
+ const createdAt = parseEvernoteTimestamp(parsed.created) || new Date();
+ const updatedAt = parseEvernoteTimestamp(parsed.updated) || createdAt;
+ const reminderAt = parseEvernoteTimestamp(parsed.reminderTime);
+ const tagIds = await getOrCreateTagIds(parsed.tags, userId);
+
+ const note = await prisma.note.create({
+ data: {
+ userId,
+ title: parsed.title?.trim() || 'Untitled Note',
+ contentHtml: '',
+ contentText: '',
+ excerpt: '',
+ notebookId,
+ sourceUrl: parsed.sourceUrl || null,
+ author: parsed.author || null,
+ latitude: parsed.latitude ?? null,
+ longitude: parsed.longitude ?? null,
+ reminderAt: reminderAt ?? null,
+ createdAt,
+ updatedAt,
+ tags: {
+ create: tagIds.map((tagId) => ({ tagId })),
+ },
+ },
+ });
+
+ const mediaByHash = new Map<
+ string,
+ { id: string; filename: string; mimeType: string; sizeBytes: number }
+ >();
+
+ for (let i = 0; i < parsed.resources.length; i++) {
+ const res = parsed.resources[i];
+ if (!res.base64Data) continue;
+ const cleanB64 = res.base64Data.replace(/\s+/g, '');
+ const buffer = Buffer.from(cleanB64, 'base64');
+ if (buffer.length === 0) continue;
+
+ const mimeType = res.mime || 'application/octet-stream';
+ const ext = guessExtensionFromMime(mimeType);
+ const filename = res.fileName?.trim() || `attachment-${i + 1}${ext}`;
+
+ const savedBlob = await saveAttachmentBuffer(buffer, filename, mimeType);
+ const attachment = await prisma.attachment.create({
+ data: {
+ noteId: note.id,
+ filename,
+ mimeType,
+ sizeBytes: savedBlob.sizeBytes,
+ md5Hash: savedBlob.md5Hash,
+ storagePath: savedBlob.storagePath,
+ width: res.width ?? null,
+ height: res.height ?? null,
+ extractedText: savedBlob.extractedText,
+ },
+ });
+
+ mediaByHash.set(savedBlob.md5Hash.toLowerCase(), {
+ id: attachment.id,
+ filename: attachment.filename,
+ mimeType: attachment.mimeType,
+ sizeBytes: attachment.sizeBytes,
+ });
+ }
+
+ const contentHtml = convertEnmlToHtml(parsed.contentEnml, mediaByHash);
+ const contentText = stripHtmlToText(contentHtml);
+ const excerpt = makeExcerpt(contentText);
+
+ await prisma.note.update({
+ where: { id: note.id },
+ data: {
+ contentHtml,
+ contentText,
+ excerpt,
+ updatedAt,
+ },
+ });
+
+ return note.id;
+}
+
+export async function importEnexXml(
+ xmlContent: string,
+ options: { notebookId?: string; notebookName?: string }
+): Promise<{ importedCount: number; errors: string[]; noteIds: string[] }> {
+ const notebookId =
+ options.notebookId ||
+ (options.notebookName
+ ? await getOrCreateNotebookByName(options.notebookName)
+ : (await ensureDefaultNotebook()).id);
+
+ const parsedNotes: ParsedEnexNote[] = [];
+ const errors: string[] = [];
+
+ await new Promise((resolve) => {
+ const parser = sax.parser(false, { lowercase: true, trim: false });
+
+ let currentNote: ParsedEnexNote | null = null;
+ let currentResource: ParsedResource | null = null;
+ const tagStack: string[] = [];
+ let textBuffer = '';
+
+ parser.onopentag = (node) => {
+ tagStack.push(node.name);
+ textBuffer = '';
+
+ if (node.name === 'note') {
+ currentNote = {
+ title: 'Untitled Note',
+ contentEnml: '',
+ tags: [],
+ resources: [],
+ };
+ } else if (node.name === 'resource' && currentNote) {
+ currentResource = {
+ base64Data: '',
+ mime: 'application/octet-stream',
+ };
+ }
+ };
+
+ parser.ontext = (t) => {
+ textBuffer += t;
+ };
+
+ parser.oncdata = (cdata) => {
+ textBuffer += cdata;
+ };
+
+ parser.onclosetag = (tagName) => {
+ const parentTag = tagStack[tagStack.length - 2] || '';
+ const val = textBuffer.trim();
+
+ if (currentNote) {
+ if (currentResource) {
+ if (tagName === 'data') {
+ currentResource.base64Data = val;
+ } else if (tagName === 'mime') {
+ currentResource.mime = val;
+ } else if (tagName === 'width') {
+ const w = parseInt(val, 10);
+ if (!isNaN(w)) currentResource.width = w;
+ } else if (tagName === 'height') {
+ const h = parseInt(val, 10);
+ if (!isNaN(h)) currentResource.height = h;
+ } else if (tagName === 'file-name') {
+ currentResource.fileName = val;
+ } else if (tagName === 'resource') {
+ currentNote.resources.push(currentResource);
+ currentResource = null;
+ }
+ } else {
+ if (tagName === 'title' && parentTag === 'note') {
+ currentNote.title = val || 'Untitled Note';
+ } else if (tagName === 'content' && parentTag === 'note') {
+ currentNote.contentEnml = textBuffer;
+ } else if (tagName === 'created' && parentTag === 'note') {
+ currentNote.created = val;
+ } else if (tagName === 'updated' && parentTag === 'note') {
+ currentNote.updated = val;
+ } else if (tagName === 'tag' && parentTag === 'note') {
+ if (val) currentNote.tags.push(val);
+ } else if (tagName === 'source-url' && parentTag === 'note-attributes') {
+ currentNote.sourceUrl = val;
+ } else if (tagName === 'author' && parentTag === 'note-attributes') {
+ currentNote.author = val;
+ } else if (tagName === 'latitude' && parentTag === 'note-attributes') {
+ const lat = parseFloat(val);
+ if (!isNaN(lat)) currentNote.latitude = lat;
+ } else if (tagName === 'longitude' && parentTag === 'note-attributes') {
+ const lon = parseFloat(val);
+ if (!isNaN(lon)) currentNote.longitude = lon;
+ } else if (tagName === 'reminder-time' && parentTag === 'note-attributes') {
+ currentNote.reminderTime = val;
+ } else if (tagName === 'note') {
+ parsedNotes.push(currentNote);
+ currentNote = null;
+ }
+ }
+ }
+
+ tagStack.pop();
+ textBuffer = '';
+ };
+
+ parser.onerror = (err) => {
+ errors.push(err.message);
+ parser.resume();
+ };
+
+ parser.onend = () => {
+ resolve();
+ };
+
+ parser.write(xmlContent).close();
+ });
+
+ const noteIds: string[] = [];
+ for (const pNote of parsedNotes) {
+ try {
+ const id = await saveParsedEnexNote(pNote, notebookId);
+ noteIds.push(id);
+ } catch (e: unknown) {
+ errors.push(
+ `Failed to save "${pNote.title}": ${e instanceof Error ? e.message : String(e)}`
+ );
+ }
+ }
+
+ return { importedCount: noteIds.length, errors, noteIds };
+}
+
+export async function importMarkdownNote(
+ mdContent: string,
+ filename: string,
+ notebookId: string
+): Promise {
+ let body = mdContent.replace(/\r\n/g, '\n');
+ let title = filename.replace(/\.md$/i, '').trim() || 'Untitled Note';
+ let createdAt: Date | null = null;
+ let updatedAt: Date | null = null;
+ let sourceUrl: string | null = null;
+ const tags: string[] = [];
+
+ if (body.startsWith('---\n')) {
+ const endIdx = body.indexOf('\n---\n', 4);
+ if (endIdx !== -1) {
+ const fm = body.slice(4, endIdx);
+ body = body.slice(endIdx + 5);
+
+ for (const line of fm.split('\n')) {
+ const m = /^([a-zA-Z0-9_-]+):\s*(.+)$/.exec(line.trim());
+ if (!m) continue;
+ const key = m[1].toLowerCase();
+ const val = m[2].replace(/^["']|["']$/g, '').trim();
+ if (key === 'title' && val) title = val;
+ if ((key === 'created' || key === 'date' || key === 'created_at') && val) {
+ createdAt = parseEvernoteTimestamp(val);
+ }
+ if ((key === 'updated' || key === 'updated_at') && val) {
+ updatedAt = parseEvernoteTimestamp(val);
+ }
+ if ((key === 'source' || key === 'source_url' || key === 'url') && val) {
+ sourceUrl = val;
+ }
+ if (key === 'tags') {
+ const clean = val.replace(/^\[|\]$/g, '');
+ for (const t of clean.split(',')) {
+ const trimmed = t.replace(/^["']|["']$/g, '').trim();
+ if (trimmed) tags.push(trimmed);
+ }
+ }
+ }
+ }
+ }
+
+ const headingMatch = /^#\s+(.+)\n/.exec(body.trimStart());
+ if (headingMatch) {
+ title = headingMatch[1].trim();
+ body = body.trimStart().slice(headingMatch[0].length);
+ }
+
+ const createdMatch = /\*\*Created:\*\*\s*([^\n]+)\n?/i.exec(body);
+ if (createdMatch) {
+ createdAt = parseEvernoteTimestamp(createdMatch[1].trim()) || createdAt;
+ body = body.replace(createdMatch[0], '');
+ }
+
+ const updatedMatch = /\*\*Updated:\*\*\s*([^\n]+)\n?/i.exec(body);
+ if (updatedMatch) {
+ updatedAt = parseEvernoteTimestamp(updatedMatch[1].trim()) || updatedAt;
+ body = body.replace(updatedMatch[0], '');
+ }
+
+ const tagsMatch = /\*\*Tags:\*\*\s*([^\n]+)\n?/i.exec(body);
+ if (tagsMatch) {
+ for (const t of tagsMatch[1].split(',')) {
+ if (t.trim()) tags.push(t.trim());
+ }
+ body = body.replace(tagsMatch[0], '');
+ }
+
+ body = body.replace(/^\s*---\s*\n/, '').trim();
+
+ const userId = await getCurrentUserId();
+ const rawHtml = await marked.parse(body, { gfm: true, breaks: true });
+ const contentText = stripHtmlToText(rawHtml);
+ const excerpt = makeExcerpt(contentText);
+ const tagIds = await getOrCreateTagIds(tags, userId);
+
+ const finalCreated = createdAt || new Date();
+ const finalUpdated = updatedAt || finalCreated;
+
+ const note = await prisma.note.create({
+ data: {
+ userId,
+ title,
+ contentHtml: rawHtml,
+ contentText,
+ excerpt,
+ notebookId,
+ sourceUrl,
+ createdAt: finalCreated,
+ updatedAt: finalUpdated,
+ tags: {
+ create: tagIds.map((tagId) => ({ tagId })),
+ },
+ },
+ });
+
+ return note.id;
+}
+
+/**
+ * Normalizes lazy-loaded images, tags, and image carousels/galleries
+ * BEFORE Readability runs so Readability does not strip article graphics.
+ */
+export function prepareDomForReadability(doc: Document, baseUrl: string): { heroImgSrc: string | null } {
+ // 1. Remove duplicate thumbnail/lightbox carousels & author avatars that clutter clips
+ doc
+ .querySelectorAll(
+ '.gallery-thumbnails, .gallery-thumbnails-expanded, [id^="main-expanable-carousel"], [id^="gallery-thumbnails"], [id^="expanded-gallery-thumbnails"], .splide__arrows, .splide__pagination, .w-author, .author-bio, a[href*="/author/"] picture, a[href*="/author/"] img'
+ )
+ .forEach((el) => el.remove());
+
+ const resolveUrl = (raw: string | null | undefined): string => {
+ if (!raw) return '';
+ const cleaned = raw.replace(/&/g, '&').trim();
+ if (!cleaned || cleaned.startsWith('data:')) return '';
+ try {
+ return new URL(cleaned, baseUrl).href;
+ } catch {
+ return cleaned;
+ }
+ };
+
+ // 2. Promote lazy-loaded attributes on all elements
+ doc.querySelectorAll('img').forEach((img) => {
+ const candidates = [
+ img.getAttribute('data-img-url'),
+ img.getAttribute('data-src'),
+ img.getAttribute('data-lazy-src'),
+ img.getAttribute('data-original'),
+ img.getAttribute('data-full-url'),
+ img.getAttribute('src'),
+ ];
+ for (const c of candidates) {
+ const resolved = resolveUrl(c);
+ if (resolved) {
+ img.setAttribute('src', resolved);
+ break;
+ }
+ }
+ });
+
+ // 3. Unwrap elements into clean elements with best resolution source
+ doc.querySelectorAll('picture').forEach((pic) => {
+ const img = pic.querySelector('img') || doc.createElement('img');
+ if (!img.getAttribute('src')) {
+ const sources = Array.from(pic.querySelectorAll('source'));
+ for (const s of sources) {
+ const srcset = s.getAttribute('srcset') || s.getAttribute('data-srcset');
+ if (srcset) {
+ const firstUrl = srcset.split(',')[0].trim().split(/\s+/)[0];
+ const resolved = resolveUrl(firstUrl);
+ if (resolved) {
+ img.setAttribute('src', resolved);
+ break;
+ }
+ }
+ }
+ }
+ if (img.getAttribute('src')) {
+ pic.replaceWith(img);
+ }
+ });
+
+ // 4. Rescue lead/hero image before Readability strips
+ const heroEl = doc.querySelector(
+ '.heading_image img, header figure img, article header img, .post-thumbnail img, .featured-image img'
+ );
+ const ogImage = doc
+ .querySelector('meta[property="og:image"]')
+ ?.getAttribute('content');
+ const heroImgSrc =
+ resolveUrl(heroEl?.getAttribute('src')) || resolveUrl(ogImage) || null;
+
+ // 5. Flatten Valnet / Splide / Swiper / WordPress galleries into a DocumentFragment of
+ // nodes (not wrapped in a textless )
+ // so Readability's cleanConditionally("div") never prunes them!
+ doc
+ .querySelectorAll(
+ '.valnet-gallery, .article__gallery, .w-gallery-carousel, .splide-gallery, .custom-image-gallery, .wp-block-gallery, .swiper-wrapper'
+ )
+ .forEach((gal) => {
+ const frag = doc.createDocumentFragment();
+ const seenBaseUrls = new Set
();
+ gal.querySelectorAll('img').forEach((img) => {
+ const src = img.getAttribute('src') || '';
+ const baseKey = src.split('?')[0];
+ if (!src || seenBaseUrls.has(baseKey)) return;
+ seenBaseUrls.add(baseKey);
+
+ const fig = doc.createElement('figure');
+ const cleanImg = doc.createElement('img');
+ cleanImg.setAttribute('src', src);
+ const alt = img.getAttribute('alt')?.trim() || '';
+ if (alt) cleanImg.setAttribute('alt', alt);
+ fig.appendChild(cleanImg);
+
+ if (alt) {
+ const cap = doc.createElement('figcaption');
+ cap.textContent = alt;
+ fig.appendChild(cap);
+ }
+ frag.appendChild(fig);
+ });
+ if (seenBaseUrls.size > 0) {
+ gal.replaceWith(frag);
+ }
+ });
+
+ return { heroImgSrc };
+}
+
+/**
+ * Downloads remote images in the clipped HTML,
+ * stores them locally as Attachments on the Note, and rewrites the HTML
+ * to point to `/api/attachments/`.
+ */
+export async function localizeClippedImages(
+ noteId: string,
+ contentHtml: string,
+ sourceUrl?: string
+): Promise {
+ const dom = new JSDOM(contentHtml);
+ const doc = dom.window.document;
+ const imgs = Array.from(doc.querySelectorAll('img'));
+ if (imgs.length === 0) return contentHtml;
+
+ const urlToLocalSrc = new Map();
+ const uniqueUrls: string[] = [];
+
+ for (const img of imgs) {
+ const src = img.getAttribute('src')?.trim() || '';
+ if (src.startsWith('http://') || src.startsWith('https://')) {
+ if (!uniqueUrls.includes(src) && uniqueUrls.length < 30) {
+ uniqueUrls.push(src);
+ }
+ }
+ }
+
+ await Promise.all(
+ uniqueUrls.map(async (remoteUrl, idx) => {
+ try {
+ const res = await fetch(remoteUrl, {
+ headers: {
+ 'User-Agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
+ ...(sourceUrl ? { Referer: sourceUrl } : {}),
+ },
+ signal: AbortSignal.timeout(10000),
+ });
+ if (!res.ok) return;
+
+ const arrayBuf = await res.arrayBuffer();
+ const buffer = Buffer.from(arrayBuf);
+ if (buffer.length < 200) return; // skip tiny 1x1 tracking pixels
+
+ const contentType =
+ res.headers.get('content-type')?.split(';')[0].trim() || 'image/jpeg';
+ const ext = guessExtensionFromMime(contentType);
+ let urlName = '';
+ try {
+ const parsedUrl = new URL(remoteUrl);
+ urlName = parsedUrl.pathname.split('/').pop() || '';
+ } catch {
+ // ignore
+ }
+ const filename =
+ urlName && /\.[a-zA-Z0-9]{3,4}$/.test(urlName)
+ ? urlName
+ : `clip-image-${idx + 1}${ext}`;
+
+ const savedBlob = await saveAttachmentBuffer(buffer, filename, contentType);
+ const att = await prisma.attachment.create({
+ data: {
+ noteId,
+ filename,
+ mimeType: contentType,
+ sizeBytes: savedBlob.sizeBytes,
+ md5Hash: savedBlob.md5Hash,
+ storagePath: savedBlob.storagePath,
+ },
+ });
+ urlToLocalSrc.set(remoteUrl, `/api/attachments/${att.id}`);
+ } catch {
+ // Keep remote URL if download fails
+ }
+ })
+ );
+
+ for (const img of imgs) {
+ const src = img.getAttribute('src')?.trim() || '';
+ const localSrc = urlToLocalSrc.get(src);
+ if (localSrc) {
+ img.setAttribute('src', localSrc);
+ }
+ }
+
+ return doc.body.innerHTML;
+}
+
+export async function importHtmlNote(
+ htmlString: string,
+ fallbackTitle: string,
+ notebookId: string,
+ sourceUrl?: string
+): Promise {
+ const baseUrl = sourceUrl || 'https://example.com';
+ const dom = new JSDOM(htmlString, { url: baseUrl });
+ const doc = dom.window.document;
+ const docTitle = doc.querySelector('title')?.textContent?.trim();
+
+ const { heroImgSrc } = prepareDomForReadability(doc, baseUrl);
+ const reader = new Readability(doc);
+ const parsed = reader.parse();
+
+ const title =
+ parsed?.title?.trim() ||
+ docTitle ||
+ fallbackTitle.replace(/\.html?$/i, '').trim() ||
+ 'Web Clip';
+ let contentHtml = parsed?.content || doc.body?.innerHTML || htmlString;
+
+ if (heroImgSrc && !contentHtml.includes(heroImgSrc)) {
+ contentHtml = `
\n${contentHtml}`;
+ }
+
+ const userId = await getCurrentUserId();
+ const contentText = stripHtmlToText(contentHtml);
+ const excerpt = makeExcerpt(parsed?.excerpt || contentText);
+
+ const note = await prisma.note.create({
+ data: {
+ userId,
+ title,
+ contentHtml,
+ contentText,
+ excerpt,
+ notebookId,
+ sourceUrl: sourceUrl || null,
+ },
+ });
+
+ const localizedHtml = await localizeClippedImages(note.id, contentHtml, sourceUrl);
+ if (localizedHtml !== contentHtml) {
+ await prisma.note.update({
+ where: { id: note.id },
+ data: { contentHtml: localizedHtml },
+ });
+ }
+
+ return note.id;
+}
+
+export async function clipUrlToNote(
+ url: string,
+ options: {
+ title?: string;
+ notebookId?: string;
+ tags?: string[];
+ isShortcut?: boolean;
+ }
+): Promise {
+ const notebookId = options.notebookId || (await ensureDefaultNotebook()).id;
+ let html = '';
+ try {
+ const res = await fetch(url, {
+ headers: {
+ 'User-Agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
+ },
+ signal: AbortSignal.timeout(15000),
+ });
+ html = await res.text();
+ } catch {
+ html = `Saved link: ${escapeHtml(url)}
`;
+ }
+
+ const dom = new JSDOM(html, { url });
+ const doc = dom.window.document;
+ const docTitle = doc.title?.trim();
+
+ const { heroImgSrc } = prepareDomForReadability(doc, url);
+ const reader = new Readability(doc);
+ const article = reader.parse();
+
+ const title =
+ options.title?.trim() ||
+ article?.title?.trim() ||
+ docTitle ||
+ url;
+
+ let contentHtml =
+ article?.content ||
+ `Clipped from ${escapeHtml(url)}
${doc.body?.innerHTML || ''}`;
+
+ if (heroImgSrc && !contentHtml.includes(heroImgSrc)) {
+ contentHtml = `
\n${contentHtml}`;
+ }
+
+ const userId = await getCurrentUserId();
+ const contentText = stripHtmlToText(contentHtml);
+ const excerpt = makeExcerpt(article?.excerpt || contentText);
+ const tagIds = await getOrCreateTagIds(options.tags || ['web-clip'], userId);
+
+ const note = await prisma.note.create({
+ data: {
+ userId,
+ title,
+ contentHtml,
+ contentText,
+ excerpt,
+ notebookId,
+ sourceUrl: url,
+ isShortcut: options.isShortcut ?? false,
+ tags: {
+ create: tagIds.map((tagId) => ({ tagId })),
+ },
+ },
+ });
+
+ // Download all article images and store them locally as attachments on the note
+ const localizedHtml = await localizeClippedImages(note.id, contentHtml, url);
+ if (localizedHtml !== contentHtml) {
+ await prisma.note.update({
+ where: { id: note.id },
+ data: { contentHtml: localizedHtml },
+ });
+ }
+
+ return note.id;
+}
+
+export function exportNotesToEnex(
+ notes: Array<{
+ title: string;
+ contentHtml: string;
+ createdAt: Date;
+ updatedAt: Date;
+ sourceUrl?: string | null;
+ author?: string | null;
+ tags: Array<{ tag: { name: string } }>;
+ }>
+): string {
+ const exportedDate = toEvernoteTimestamp(new Date());
+ const noteBlocks = notes
+ .map((n) => {
+ const tagsXml = n.tags
+ .map((nt) => ` ${escapeHtml(nt.tag.name)} `)
+ .join('\n');
+ const attrsXml: string[] = [];
+ if (n.sourceUrl) {
+ attrsXml.push(` ${escapeHtml(n.sourceUrl)} `);
+ }
+ if (n.author) {
+ attrsXml.push(` ${escapeHtml(n.author)} `);
+ }
+
+ return `
+ ${escapeHtml(n.title)}
+
+
+${n.contentHtml} ]]>
+ ${toEvernoteTimestamp(n.createdAt)}
+ ${toEvernoteTimestamp(n.updatedAt)}
+${tagsXml ? tagsXml + '\n' : ''}${
+ attrsXml.length > 0
+ ? ` \n${attrsXml.join('\n')}\n \n`
+ : ''
+ } `;
+ })
+ .join('\n');
+
+ return `
+
+
+${noteBlocks}
+ `;
+}
diff --git a/src/lib/pdfClient.ts b/src/lib/pdfClient.ts
new file mode 100644
index 0000000..ab2d104
--- /dev/null
+++ b/src/lib/pdfClient.ts
@@ -0,0 +1,130 @@
+'use client';
+
+declare global {
+ interface Window {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ pdfjsLib?: any;
+ }
+}
+
+const PDFJS_VERSION = '3.11.174';
+const PDFJS_SCRIPT_URL = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${PDFJS_VERSION}/pdf.min.js`;
+const PDFJS_WORKER_URL = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${PDFJS_VERSION}/pdf.worker.min.js`;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let pdfjsPromise: Promise | null = null;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function loadPdfJs(): Promise {
+ if (typeof window === 'undefined') {
+ return Promise.reject(new Error('Window not available'));
+ }
+ if (window.pdfjsLib) {
+ return Promise.resolve(window.pdfjsLib);
+ }
+ if (pdfjsPromise) {
+ return pdfjsPromise;
+ }
+
+ pdfjsPromise = new Promise((resolve, reject) => {
+ const existing = document.querySelector(`script[src="${PDFJS_SCRIPT_URL}"]`);
+ if (existing && window.pdfjsLib) {
+ resolve(window.pdfjsLib);
+ return;
+ }
+
+ const script = document.createElement('script');
+ script.src = PDFJS_SCRIPT_URL;
+ script.async = true;
+ script.onload = () => {
+ if (window.pdfjsLib) {
+ window.pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
+ resolve(window.pdfjsLib);
+ } else {
+ reject(new Error('pdfjsLib did not initialize'));
+ }
+ };
+ script.onerror = () => reject(new Error('Failed to load PDF.js from CDN'));
+ document.head.appendChild(script);
+ });
+
+ return pdfjsPromise;
+}
+
+const pageRenderCache = new Map();
+
+/**
+ * Renders a single page of a PDF (default Page 1) into a crisp JPEG data URL.
+ */
+export async function renderPdfPageToDataUrl(
+ pdfUrl: string,
+ pageNumber = 1,
+ targetSize = 480
+): Promise {
+ const cacheKey = `${pdfUrl}#page=${pageNumber}@${targetSize}`;
+ if (pageRenderCache.has(cacheKey)) {
+ return pageRenderCache.get(cacheKey)!;
+ }
+
+ try {
+ const pdfjsLib = await loadPdfJs();
+ const loadingTask = pdfjsLib.getDocument(pdfUrl);
+ const pdf = await loadingTask.promise;
+
+ if (pageNumber < 1 || pageNumber > pdf.numPages) {
+ return null;
+ }
+
+ const page = await pdf.getPage(pageNumber);
+ const unscaledViewport = page.getViewport({ scale: 1 });
+ const scale = Math.max(
+ targetSize / unscaledViewport.width,
+ targetSize / unscaledViewport.height
+ );
+ const viewport = page.getViewport({ scale });
+
+ const canvas = document.createElement('canvas');
+ canvas.width = Math.round(viewport.width);
+ canvas.height = Math.round(viewport.height);
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return null;
+
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ await page.render({ canvasContext: ctx, viewport }).promise;
+
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.86);
+ pageRenderCache.set(cacheKey, dataUrl);
+ return dataUrl;
+ } catch (err) {
+ console.warn('PDF thumbnail render error:', err);
+ return null;
+ }
+}
+
+/**
+ * Renders up to maxPages of a PDF so the user can pick any page in Thumbnail Studio.
+ */
+export async function renderPdfPagesPreview(
+ pdfUrl: string,
+ maxPages = 4
+): Promise> {
+ try {
+ const pdfjsLib = await loadPdfJs();
+ const loadingTask = pdfjsLib.getDocument(pdfUrl);
+ const pdf = await loadingTask.promise;
+ const count = Math.min(pdf.numPages, maxPages);
+ const results: Array<{ page: number; dataUrl: string }> = [];
+
+ for (let p = 1; p <= count; p++) {
+ const dataUrl = await renderPdfPageToDataUrl(pdfUrl, p, 480);
+ if (dataUrl) {
+ results.push({ page: p, dataUrl });
+ }
+ }
+ return results;
+ } catch {
+ return [];
+ }
+}
diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts
new file mode 100644
index 0000000..fc9ee8e
--- /dev/null
+++ b/src/lib/prisma.ts
@@ -0,0 +1,156 @@
+import { PrismaClient } from '@prisma/client';
+import { cookies, headers } from 'next/headers';
+
+const globalForPrisma = globalThis as unknown as {
+ prisma: PrismaClient | undefined;
+};
+
+export const prisma =
+ globalForPrisma.prisma ??
+ new PrismaClient({
+ log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
+ });
+
+if (process.env.NODE_ENV !== 'production') {
+ globalForPrisma.prisma = prisma;
+}
+
+const AVATAR_COLORS = [
+ '#00a82d',
+ '#2563eb',
+ '#7c3aed',
+ '#db2777',
+ '#ea580c',
+ '#0d9488',
+ '#4f46e5',
+];
+
+function nameFromEmail(email: string): string {
+ const local = email.split('@')[0] || 'User';
+ return local
+ .split(/[._-]+/)
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ');
+}
+
+export async function getCurrentUser() {
+ let explicitUserId: string | undefined;
+ let forwardedEmail: string | undefined;
+
+ try {
+ const cookieStore = await cookies();
+ explicitUserId = cookieStore.get('evc_user_id')?.value?.trim() || undefined;
+ } catch {
+ // Called outside request context
+ }
+
+ try {
+ const hdrs = await headers();
+ if (!explicitUserId) {
+ explicitUserId = hdrs.get('x-user-id')?.trim() || undefined;
+ }
+ const rawForwarded =
+ hdrs.get('x-forwarded-user') || hdrs.get('x-auth-request-email') || '';
+ if (rawForwarded.trim()) {
+ forwardedEmail = rawForwarded.split(',')[0].trim().toLowerCase();
+ }
+ } catch {
+ // Called outside request context
+ }
+
+ // Ensure the primary 'default' user record exists so existing notes belong to it
+ let defaultUser = await prisma.user.findUnique({ where: { id: 'default' } });
+ if (!defaultUser) {
+ const initialEmail = forwardedEmail || 'default@everclone.local';
+ const initialName = forwardedEmail ? nameFromEmail(forwardedEmail) : 'Chris (Primary)';
+ defaultUser = await prisma.user.create({
+ data: {
+ id: 'default',
+ email: initialEmail,
+ name: initialName,
+ avatarColor: '#00a82d',
+ },
+ });
+ } else if (
+ forwardedEmail &&
+ defaultUser.email === 'default@everclone.local'
+ ) {
+ // Upgrade placeholder default user to the primary OAuth email on first login
+ const existingWithEmail = await prisma.user.findUnique({
+ where: { email: forwardedEmail },
+ });
+ if (!existingWithEmail) {
+ defaultUser = await prisma.user.update({
+ where: { id: 'default' },
+ data: {
+ email: forwardedEmail,
+ name:
+ defaultUser.name === 'Chris (Primary)'
+ ? nameFromEmail(forwardedEmail)
+ : defaultUser.name,
+ },
+ });
+ }
+ }
+
+ // 1. Explicit profile override via cookie or header
+ if (explicitUserId) {
+ const found = await prisma.user.findUnique({ where: { id: explicitUserId } });
+ if (found) return found;
+ }
+
+ // 2. Traefik ForwardAuth email
+ if (forwardedEmail) {
+ let byEmail = await prisma.user.findUnique({ where: { email: forwardedEmail } });
+ if (!byEmail) {
+ const count = await prisma.user.count();
+ byEmail = await prisma.user.create({
+ data: {
+ email: forwardedEmail,
+ name: nameFromEmail(forwardedEmail),
+ avatarColor: AVATAR_COLORS[count % AVATAR_COLORS.length],
+ },
+ });
+ }
+ return byEmail;
+ }
+
+ return defaultUser;
+}
+
+export async function getCurrentUserId(): Promise {
+ const user = await getCurrentUser();
+ return user.id;
+}
+
+export async function ensureDefaultNotebook(explicitUserId?: string) {
+ const userId = explicitUserId || (await getCurrentUserId());
+
+ let defaultNb = await prisma.notebook.findFirst({
+ where: { userId, isDefault: true },
+ });
+
+ if (!defaultNb) {
+ defaultNb = await prisma.notebook.findFirst({
+ where: { userId },
+ orderBy: { createdAt: 'asc' },
+ });
+ if (defaultNb) {
+ defaultNb = await prisma.notebook.update({
+ where: { id: defaultNb.id },
+ data: { isDefault: true },
+ });
+ } else {
+ defaultNb = await prisma.notebook.create({
+ data: {
+ userId,
+ name: 'Inbox',
+ isDefault: true,
+ sortOrder: 0,
+ },
+ });
+ }
+ }
+ return defaultNb;
+}
diff --git a/src/lib/search.ts b/src/lib/search.ts
new file mode 100644
index 0000000..c291f08
--- /dev/null
+++ b/src/lib/search.ts
@@ -0,0 +1,298 @@
+import { Prisma } from '@prisma/client';
+
+export interface ParsedSearchQuery {
+ notebookName?: string;
+ tagNames: string[];
+ excludeTagNames: string[];
+ inTitle: string[];
+ hasAttachment?: boolean;
+ hasTodo?: boolean;
+ isPinned?: boolean;
+ isShortcut?: boolean;
+ sourceUrl?: string;
+ createdAfter?: Date;
+ updatedAfter?: Date;
+ terms: string[];
+}
+
+/**
+ * Parses Evernote-style search grammar:
+ * notebook:"Work Projects" or notebook:Inbox
+ * tag:receipts -tag:archive
+ * intitle:"meeting notes"
+ * has:attachment resource:application/pdf has:todo
+ * is:pinned is:shortcut
+ * created:20250101 updated:20250101
+ * source:http
+ */
+export function parseEvernoteSearch(rawQuery: string): ParsedSearchQuery {
+ const result: ParsedSearchQuery = {
+ tagNames: [],
+ excludeTagNames: [],
+ inTitle: [],
+ terms: [],
+ };
+
+ if (!rawQuery || !rawQuery.trim()) return result;
+
+ // Match key:"quoted value" or key:value or "quoted phrase" or word
+ const tokenRegex = /(-?[a-zA-Z]+):"([^"]+)"|(-?[a-zA-Z]+):(\S+)|"([^"]+)"|(\S+)/g;
+ let match: RegExpExecArray | null;
+
+ while ((match = tokenRegex.exec(rawQuery)) !== null) {
+ const key = (match[1] || match[3] || '').toLowerCase();
+ const val = match[2] ?? match[4] ?? match[5] ?? match[6] ?? '';
+
+ if (key === 'notebook') {
+ result.notebookName = val;
+ } else if (key === 'tag') {
+ result.tagNames.push(val);
+ } else if (key === '-tag') {
+ result.excludeTagNames.push(val);
+ } else if (key === 'intitle') {
+ result.inTitle.push(val);
+ } else if (key === 'has') {
+ if (val.toLowerCase() === 'attachment' || val.toLowerCase() === 'attachments') {
+ result.hasAttachment = true;
+ } else if (val.toLowerCase() === 'todo' || val.toLowerCase() === 'checklist') {
+ result.hasTodo = true;
+ }
+ } else if (key === 'resource') {
+ result.hasAttachment = true;
+ } else if (key === 'is') {
+ if (val.toLowerCase() === 'pinned') result.isPinned = true;
+ if (val.toLowerCase() === 'shortcut' || val.toLowerCase() === 'starred') result.isShortcut = true;
+ } else if (key === 'source' || key === 'sourceurl') {
+ result.sourceUrl = val;
+ } else if (key === 'created') {
+ const d = parseEvernoteDateToken(val);
+ if (d) result.createdAfter = d;
+ } else if (key === 'updated') {
+ const d = parseEvernoteDateToken(val);
+ if (d) result.updatedAfter = d;
+ } else if (val) {
+ result.terms.push(val);
+ }
+ }
+
+ return result;
+}
+
+function parseEvernoteDateToken(val: string): Date | null {
+ // Supports YYYYMMDD or YYYY-MM-DD or day-N
+ if (/^\d{8}$/.test(val)) {
+ const y = val.slice(0, 4);
+ const m = val.slice(4, 6);
+ const d = val.slice(6, 8);
+ const parsed = new Date(`${y}-${m}-${d}T00:00:00Z`);
+ return isNaN(parsed.getTime()) ? null : parsed;
+ }
+ const parsed = new Date(val);
+ return isNaN(parsed.getTime()) ? null : parsed;
+}
+
+export function buildNoteWhereClause(params: {
+ userId?: string;
+ query?: string;
+ notebookId?: string;
+ stackId?: string;
+ tagId?: string;
+ tagIds?: string[];
+ untagged?: boolean;
+ hasImages?: boolean;
+ hasAttachments?: boolean;
+ hasTodo?: boolean;
+ hasWebClip?: boolean;
+ onlyPinned?: boolean;
+ trashed?: boolean;
+ shortcut?: boolean;
+ updatedSinceDays?: number;
+}): Prisma.NoteWhereInput {
+ const andConditions: Prisma.NoteWhereInput[] = [];
+
+ if (params.userId) {
+ andConditions.push({ userId: params.userId });
+ }
+
+ andConditions.push({
+ isTrashed: params.trashed === true,
+ });
+
+ if (params.shortcut) {
+ andConditions.push({ isShortcut: true });
+ }
+
+ if (params.onlyPinned) {
+ andConditions.push({ isPinned: true });
+ }
+
+ if (params.notebookId) {
+ andConditions.push({ notebookId: params.notebookId });
+ }
+
+ if (params.stackId) {
+ andConditions.push({
+ notebook: { stackId: params.stackId },
+ });
+ }
+
+ if (params.tagId) {
+ andConditions.push({
+ tags: { some: { tagId: params.tagId } },
+ });
+ }
+
+ if (params.tagIds && params.tagIds.length > 0) {
+ for (const tid of params.tagIds) {
+ andConditions.push({
+ tags: { some: { tagId: tid } },
+ });
+ }
+ }
+
+ if (params.untagged) {
+ andConditions.push({
+ tags: { none: {} },
+ });
+ }
+
+ if (params.hasImages) {
+ andConditions.push({
+ OR: [
+ { attachments: { some: { mimeType: { startsWith: 'image/' } } } },
+ { contentHtml: { contains: ' 0) {
+ const cutoff = new Date(Date.now() - params.updatedSinceDays * 24 * 60 * 60 * 1000);
+ andConditions.push({
+ updatedAt: { gte: cutoff },
+ });
+ }
+
+ if (params.query && params.query.trim()) {
+ const parsed = parseEvernoteSearch(params.query);
+
+ if (parsed.notebookName) {
+ andConditions.push({
+ notebook: {
+ name: { equals: parsed.notebookName, mode: 'insensitive' },
+ },
+ });
+ }
+
+ for (const tName of parsed.tagNames) {
+ andConditions.push({
+ tags: {
+ some: {
+ tag: { name: { equals: tName, mode: 'insensitive' } },
+ },
+ },
+ });
+ }
+
+ for (const exName of parsed.excludeTagNames) {
+ andConditions.push({
+ tags: {
+ none: {
+ tag: { name: { equals: exName, mode: 'insensitive' } },
+ },
+ },
+ });
+ }
+
+ for (const titleTerm of parsed.inTitle) {
+ andConditions.push({
+ title: { contains: titleTerm, mode: 'insensitive' },
+ });
+ }
+
+ if (parsed.hasAttachment) {
+ andConditions.push({
+ attachments: { some: {} },
+ });
+ }
+
+ if (parsed.hasTodo) {
+ andConditions.push({
+ contentHtml: { contains: 'data-type="taskItem"', mode: 'insensitive' },
+ });
+ }
+
+ if (parsed.isPinned !== undefined) {
+ andConditions.push({ isPinned: parsed.isPinned });
+ }
+
+ if (parsed.isShortcut !== undefined) {
+ andConditions.push({ isShortcut: parsed.isShortcut });
+ }
+
+ if (parsed.sourceUrl) {
+ andConditions.push({
+ sourceUrl: { contains: parsed.sourceUrl, mode: 'insensitive' },
+ });
+ }
+
+ if (parsed.createdAfter) {
+ andConditions.push({
+ createdAt: { gte: parsed.createdAfter },
+ });
+ }
+
+ if (parsed.updatedAfter) {
+ andConditions.push({
+ updatedAt: { gte: parsed.updatedAfter },
+ });
+ }
+
+ for (const term of parsed.terms) {
+ andConditions.push({
+ OR: [
+ { title: { contains: term, mode: 'insensitive' } },
+ { contentText: { contains: term, mode: 'insensitive' } },
+ { sourceUrl: { contains: term, mode: 'insensitive' } },
+ {
+ tags: {
+ some: {
+ tag: { name: { contains: term, mode: 'insensitive' } },
+ },
+ },
+ },
+ {
+ attachments: {
+ some: {
+ OR: [
+ { filename: { contains: term, mode: 'insensitive' } },
+ { extractedText: { contains: term, mode: 'insensitive' } },
+ ],
+ },
+ },
+ },
+ ],
+ });
+ }
+ }
+
+ return andConditions.length === 1 ? andConditions[0] : { AND: andConditions };
+}
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
new file mode 100644
index 0000000..1b6b502
--- /dev/null
+++ b/src/lib/storage.ts
@@ -0,0 +1,107 @@
+import crypto from 'crypto';
+import fs from 'fs/promises';
+import path from 'path';
+
+export function getAttachmentsDir(): string {
+ return process.env.ATTACHMENTS_DIR || path.join(process.cwd(), 'data', 'attachments');
+}
+
+export async function saveAttachmentBuffer(
+ buffer: Buffer,
+ originalFilename: string,
+ mimeType: string
+): Promise<{
+ md5Hash: string;
+ storagePath: string;
+ sizeBytes: number;
+ extractedText: string | null;
+}> {
+ const md5Hash = crypto.createHash('md5').update(buffer).digest('hex');
+ const ext = path.extname(originalFilename) || guessExtensionFromMime(mimeType);
+ const subDir = md5Hash.slice(0, 2);
+ const baseDir = getAttachmentsDir();
+ const targetDir = path.join(baseDir, subDir);
+ await fs.mkdir(targetDir, { recursive: true });
+
+ const fileNameOnDisk = `${md5Hash}${ext}`;
+ const fullPath = path.join(targetDir, fileNameOnDisk);
+
+ try {
+ await fs.access(fullPath);
+ } catch {
+ await fs.writeFile(fullPath, buffer);
+ }
+
+ let extractedText: string | null = null;
+ if (
+ mimeType.startsWith('text/') ||
+ mimeType === 'application/json' ||
+ mimeType === 'application/xml' ||
+ originalFilename.endsWith('.md') ||
+ originalFilename.endsWith('.txt') ||
+ originalFilename.endsWith('.csv')
+ ) {
+ extractedText = buffer.toString('utf-8').slice(0, 50000);
+ }
+
+ return {
+ md5Hash,
+ storagePath: path.join(subDir, fileNameOnDisk),
+ sizeBytes: buffer.length,
+ extractedText,
+ };
+}
+
+export function getFullAttachmentPath(storagePath: string): string {
+ const baseDir = getAttachmentsDir();
+ const resolved = path.resolve(baseDir, storagePath);
+ if (!resolved.startsWith(path.resolve(baseDir))) {
+ throw new Error('Invalid storage path');
+ }
+ return resolved;
+}
+
+export function guessExtensionFromMime(mime: string): string {
+ const map: Record = {
+ 'image/jpeg': '.jpg',
+ 'image/jpg': '.jpg',
+ 'image/png': '.png',
+ 'image/gif': '.gif',
+ 'image/webp': '.webp',
+ 'image/svg+xml': '.svg',
+ 'application/pdf': '.pdf',
+ 'audio/mpeg': '.mp3',
+ 'audio/mp3': '.mp3',
+ 'audio/wav': '.wav',
+ 'audio/ogg': '.ogg',
+ 'audio/webm': '.webm',
+ 'audio/mp4': '.m4a',
+ 'video/mp4': '.mp4',
+ 'text/plain': '.txt',
+ 'text/html': '.html',
+ 'text/markdown': '.md',
+ };
+ return map[mime.toLowerCase()] || '.bin';
+}
+
+export function stripHtmlToText(html: string): string {
+ if (!html) return '';
+ return html
+ .replace(/