diff --git a/prisma/dev.db b/prisma/dev.db index 1cbb1bb..8aa2a7d 100644 Binary files a/prisma/dev.db and b/prisma/dev.db differ diff --git a/src/app/api/settings/init/route.ts b/src/app/api/settings/init/route.ts index bb5db50..298970f 100644 --- a/src/app/api/settings/init/route.ts +++ b/src/app/api/settings/init/route.ts @@ -22,13 +22,6 @@ export async function POST(req: Request) { } const initToken = process.env.INIT_SETUP_TOKEN; - const isProduction = process.env.NODE_ENV === "production"; - if (isProduction && !initToken) { - return NextResponse.json( - { error: "Server misconfigured: INIT_SETUP_TOKEN is required in production." }, - { status: 500 } - ); - } if (initToken) { const providedToken = req.headers.get("x-init-token"); diff --git a/src/app/api/settings/status/route.ts b/src/app/api/settings/status/route.ts new file mode 100644 index 0000000..c40ee64 --- /dev/null +++ b/src/app/api/settings/status/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + try { + const settings = await prisma.globalSettings.findUnique({ + where: { id: "default" }, + select: { id: true }, + }); + return NextResponse.json({ initialized: Boolean(settings) }); + } catch { + return NextResponse.json({ initialized: false, error: "Failed to check settings status" }, { status: 500 }); + } +} + diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index eee5d14..1e0a030 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,19 +1,42 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Lock, Sparkles } from "lucide-react"; export default function LoginPage() { const [password, setPassword] = useState(""); + const [initPassword, setInitPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(""); + const [isInitialized, setIsInitialized] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); const [rememberMe, setRememberMe] = useState(false); const [duration, setDuration] = useState("1"); const router = useRouter(); + useEffect(() => { + const loadStatus = async () => { + try { + const res = await fetch("/api/settings/status", { cache: "no-store" }); + if (!res.ok) { + setIsInitialized(true); + return; + } + const data = await res.json(); + setIsInitialized(Boolean(data.initialized)); + } catch { + setIsInitialized(true); + } + }; + loadStatus(); + }, []); + const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); + if (isSubmitting) return; setError(""); + setIsSubmitting(true); try { const res = await fetch("/api/auth/login", { @@ -34,9 +57,72 @@ export default function LoginPage() { } } catch { setError("发生错误,请重试"); + } finally { + setIsSubmitting(false); } }; + const handleInitialize = async (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; + setError(""); + + if (initPassword.length < 6) { + setError("密码至少 6 位"); + return; + } + + if (initPassword !== confirmPassword) { + setError("两次输入的密码不一致"); + return; + } + + setIsSubmitting(true); + try { + const initRes = await fetch("/api/settings/init", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: initPassword }), + }); + const initData = await initRes.json().catch(() => ({})); + if (!initRes.ok) { + setError(initData.error || "初始化失败"); + return; + } + + const loginRes = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + password: initPassword, + rememberMe: false, + durationDays: 1, + }), + }); + const loginData = await loginRes.json().catch(() => ({})); + if (!loginRes.ok) { + setError(loginData.error || "初始化成功,但自动登录失败,请手动登录"); + setIsInitialized(true); + setPassword(initPassword); + return; + } + + router.push("/"); + } catch { + setError("初始化失败,请重试"); + } finally { + setIsSubmitting(false); + } + }; + + if (isInitialized === null) { + return ( +
+
加载中...
+
+ ); + } + return (
@@ -44,61 +130,85 @@ export default function LoginPage() {
-

欢迎回来

-

请输入访问密码以继续。

+

{isInitialized ? "欢迎回来" : "首次初始化"}

+

{isInitialized ? "请输入访问密码以继续。" : "请先设置访问密码,完成后将自动登录。"}

-
+
-
- - setPassword(e.target.value)} - className="ui-input pl-10 pr-4 py-3" - required - /> -
+ {isInitialized ? ( +
+ + setPassword(e.target.value)} + className="ui-input pl-10 pr-4 py-3" + required + /> +
+ ) : ( + <> +
+ + setInitPassword(e.target.value)} + className="ui-input pl-10 pr-4 py-3" + required + /> +
+
+ + setConfirmPassword(e.target.value)} + className="ui-input pl-10 pr-4 py-3" + required + /> +
+ + )} {error &&

{error}

}
-
- + {isInitialized && ( +
+ - {rememberMe && ( - - )} -
+ {rememberMe && ( + + )} +
+ )} -
-
- NoteAI - 你的私人第二大脑 -
+
NoteAI - 你的私人第二大脑
); diff --git a/src/app/page.tsx b/src/app/page.tsx index d50f74e..8741101 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -293,53 +293,63 @@ export default function Home() { editable={!activePage.isLocked} /> -
-
-

关联页面

-

在正文中使用 `[[页面名]]` 自动建立链接关系,支持 `Ctrl/Cmd + 点击` 跳转。

-
+
+
+
+
+

关联页面

+ {outgoingLinks.length} +
+

正文写 `[[页面名]]` 可自动关联。

+
{outgoingLinks.length > 0 ? ( outgoingLinks.map((item) => ( )) ) : ( - 暂无关联页面 + 暂无 )}
-
+
-
-

反向链接

-

这些页面提到了当前页面。

-
+
+
+

反向链接

+ {backlinks.length} +
+

被哪些页面引用。

+
{backlinks.length > 0 ? ( backlinks.map((item) => ( )) ) : ( - 暂无反向链接 + 暂无 )}
-
+
-
-

未解析链接

-

这些 `[[页面名]]` 还没有对应页面,可一键创建。

-
+
+
+

未解析链接

+ {unresolvedLinks.length} +
+

可一键创建缺失页面。

+
{unresolvedLinks.length > 0 ? ( unresolvedLinks.map((title) => ( )) ) : ( - 全部链接已解析 + 全部已解析 )}
+
-
+
-

自动保存最近修改快照(当前浏览器本地)。

+

自动保存最近修改快照(当前浏览器本地)。

{isHistoryOpen && ( -
+
{localHistory.length > 0 ? ( <>
diff --git a/src/components/editor.tsx b/src/components/editor.tsx index 6f7afee..111b0c7 100644 --- a/src/components/editor.tsx +++ b/src/components/editor.tsx @@ -27,8 +27,6 @@ import { SlashCommand, getSuggestionItems, renderSuggestionItems } from "./edito import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import { lowlight } from "lowlight"; import { CodeBlockComponent } from "./editor/code-block"; -import Link from "@tiptap/extension-link"; -import Underline from "@tiptap/extension-underline"; import Subscript from "@tiptap/extension-subscript"; import Superscript from "@tiptap/extension-superscript"; import Highlight from "@tiptap/extension-highlight"; @@ -45,7 +43,6 @@ import TableHeader from "@tiptap/extension-table-header"; import Image from "@tiptap/extension-image"; import Youtube from "@tiptap/extension-youtube"; import TextAlign from "@tiptap/extension-text-align"; -import Gapcursor from "@tiptap/extension-gapcursor"; import { Markdown } from "tiptap-markdown"; import { Fragment, type Node as ProseMirrorNode, type Schema } from "@tiptap/pm/model"; import { TextSelection } from "@tiptap/pm/state"; @@ -624,11 +621,16 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, const editor = useEditor({ extensions: [ - Gapcursor, StarterKit.configure({ heading: { levels: [1, 2, 3], }, + link: { + openOnClick: false, + HTMLAttributes: { + class: "cursor-pointer text-blue-600 dark:text-blue-400 hover:underline hover:text-blue-800 dark:hover:text-blue-300 transition-colors", + }, + }, codeBlock: false, bulletList: { keepMarks: true, @@ -650,13 +652,6 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, return ReactNodeViewRenderer(CodeBlockComponent); }, }).configure({ lowlight, defaultLanguage: "plaintext" }), - Link.configure({ - openOnClick: false, - HTMLAttributes: { - class: "cursor-pointer text-blue-600 dark:text-blue-400 hover:underline hover:text-blue-800 dark:hover:text-blue-300 transition-colors", - }, - }), - Underline, Subscript, Superscript, Highlight.configure({ diff --git a/src/components/import-context.tsx b/src/components/import-context.tsx index 3cfb5b4..9438898 100644 --- a/src/components/import-context.tsx +++ b/src/components/import-context.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import React, { createContext, useContext, useRef, useState, useCallback } from "react"; import { useEditorStore } from "@/lib/store"; @@ -10,6 +10,17 @@ interface ImportContextType { isImporting: boolean; } +class ImportApiError extends Error { + status: number; + detail: string; + + constructor(message: string, status: number, detail: string) { + super(message); + this.status = status; + this.detail = detail; + } +} + const ImportContext = createContext(undefined); export function ImportProvider({ children }: { children: React.ReactNode }) { @@ -18,10 +29,43 @@ export function ImportProvider({ children }: { children: React.ReactNode }) { const targetParentIdRef = useRef(null); const { fetchPages } = useEditorStore(); + const postPage = useCallback( + async ( + payload: { + title: string; + content?: string; + parentId?: string | null; + type: "file" | "folder"; + }, + contextLabel: string + ) => { + const res = await fetch("/api/pages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + credentials: "same-origin", + }); + + if (!res.ok) { + let detail = ""; + try { + const data = await res.json(); + detail = typeof data?.error === "string" ? data.error : JSON.stringify(data); + } catch { + detail = await res.text(); + } + throw new ImportApiError(`${contextLabel}失败(${res.status})${detail ? `: ${detail}` : ""}`, res.status, detail); + } + + return res.json(); + }, + [] + ); + const triggerImport = useCallback((parentId: string | null = null) => { targetParentIdRef.current = parentId; if (fileInputRef.current) { - fileInputRef.current.value = ''; // Reset + fileInputRef.current.value = ""; fileInputRef.current.click(); } }, []); @@ -32,14 +76,15 @@ export function ImportProvider({ children }: { children: React.ReactNode }) { setIsImporting(true); try { - if (file.name.toLowerCase().endsWith('.zip')) { + if (file.name.toLowerCase().endsWith(".zip")) { await handleZipImport(file); } else { await handleSingleFileImport(file); } } catch (error) { console.error("Import failed", error); - alert("导入失败,请检查文件"); + const message = error instanceof Error ? error.message : "导入失败,请检查文件"; + alert(message); } finally { setIsImporting(false); fetchPages(); @@ -48,131 +93,123 @@ export function ImportProvider({ children }: { children: React.ReactNode }) { const handleSingleFileImport = async (file: File) => { const text = await file.text(); - const title = file.name.replace(/\.md$/i, ''); + const title = file.name.replace(/\.md$/i, ""); const html = await marked.parse(text); - await fetch('/api/pages', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + await postPage( + { title, content: html, parentId: targetParentIdRef.current, - type: 'file' - }), - }); + type: "file", + }, + "创建导入文件" + ); }; const handleZipImport = async (file: File) => { const zip = await JSZip.loadAsync(file); - // Create root folder based on zip name - // Remove .md.zip or .zip - const rootName = file.name.replace(/(\.md)?\.zip$/i, ''); + const rootName = file.name.replace(/(\.md)?\.zip$/i, ""); + let rootFolder: { id: string }; - const rootRes = await fetch('/api/pages', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: rootName, - parentId: targetParentIdRef.current, - type: 'folder' - }), - }); + try { + rootFolder = await postPage( + { + title: rootName, + parentId: targetParentIdRef.current, + type: "folder", + }, + "创建导入根目录" + ); + } catch (error) { + const invalidParent = + error instanceof ImportApiError && error.status === 400 && /Parent folder not found/i.test(error.detail); + if (!invalidParent || !targetParentIdRef.current) { + throw error; + } + // Parent folder may have been deleted; fallback to root level and continue import. + rootFolder = await postPage( + { + title: rootName, + parentId: null, + type: "folder", + }, + "创建导入根目录" + ); + } - if (!rootRes.ok) throw new Error("Failed to create root folder"); - const rootFolder = await rootRes.json(); const rootId = rootFolder.id; - // Collect all file entries and infer folder structure - const fileEntries: { path: string, file: JSZip.JSZipObject }[] = []; + const fileEntries: { path: string; file: JSZip.JSZipObject }[] = []; const folderPaths = new Set(); zip.forEach((relativePath, zipEntry) => { - if (relativePath.startsWith('__MACOSX') || relativePath.includes('/.') || relativePath.startsWith('.')) return; // Skip hidden/mac files + if (relativePath.startsWith("__MACOSX") || relativePath.includes("/.") || relativePath.startsWith(".")) return; if (zipEntry.dir) { - // Remove trailing slash - const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; + const cleanPath = relativePath.endsWith("/") ? relativePath.slice(0, -1) : relativePath; if (cleanPath) folderPaths.add(cleanPath); } else { fileEntries.push({ path: relativePath, file: zipEntry }); - // Also infer parent folders for files - const parts = relativePath.split('/'); - let currentPath = ''; - for (let i = 0; i < parts.length - 1; i++) { + const parts = relativePath.split("/"); + let currentPath = ""; + for (let i = 0; i < parts.length - 1; i += 1) { currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; folderPaths.add(currentPath); } } }); - // Map path (e.g. "folder/sub") to database ID. Empty key '' maps to rootId. const pathMap = new Map(); - pathMap.set('', rootId); + pathMap.set("", rootId); - // Sort folders by depth (length of path splits) to create parents before children - const sortedFolders = Array.from(folderPaths).sort((a, b) => { - return a.split('/').length - b.split('/').length; - }); + const sortedFolders = Array.from(folderPaths).sort((a, b) => a.split("/").length - b.split("/").length); - // Create folders sequentially for (const folderPath of sortedFolders) { - const parts = folderPath.split('/'); + const parts = folderPath.split("/"); const name = parts[parts.length - 1]; - const parentPath = parts.slice(0, -1).join('/'); - const parentId = pathMap.get(parentPath); // Should exist because we sorted by depth + const parentPath = parts.slice(0, -1).join("/"); + const parentId = pathMap.get(parentPath); if (!parentId) { console.warn(`Parent not found for ${folderPath}, skipping`); continue; } - // Check if we need to create it (users might have zip with folder/ and folder/file, avoiding dups) - // But we didn't check if it existed on server. We assume new import. - // Actually, we are just building map here. - - const res = await fetch('/api/pages', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const folder = await postPage( + { title: name, - parentId: parentId, - type: 'folder' - }), - }); - - if (res.ok) { - const folder = await res.json(); - pathMap.set(folderPath, folder.id); - } + parentId, + type: "folder", + }, + `创建目录 ${folderPath}` + ); + pathMap.set(folderPath, folder.id); } - // Create files parallel-ish or sequential? Sequential is safer for order but parallel faster. - // Let's do batch sequential to avoid overwhelming server if huge - for (const { path, file } of fileEntries) { - const parts = path.split('/'); + for (const { path, file: zipFile } of fileEntries) { + const parts = path.split("/"); const fileName = parts.pop() || ""; - const parentPath = parts.join('/'); + const parentPath = parts.join("/"); const parentId = pathMap.get(parentPath); if (!parentId) continue; - if (!fileName.endsWith('.md') && !fileName.endsWith('.txt')) continue; // Verify extension again + if (!fileName.endsWith(".md") && !fileName.endsWith(".txt")) continue; - const contentMsg = await file.async('string'); - const title = fileName.replace(/\.md$/i, '').replace(/\.txt$/i, ''); - const html = await marked.parse(contentMsg); + const markdown = await zipFile.async("string"); + const title = fileName.replace(/\.md$/i, "").replace(/\.txt$/i, ""); + const html = await marked.parse(markdown); - await fetch('/api/pages', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + await postPage( + { title, content: html, - parentId: parentId, - type: 'file' - }), - }); + parentId, + type: "file", + }, + `创建文件 ${path}` + ); } }; diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 680e676..955afd6 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -5,7 +5,7 @@ const globalForPrisma = global as unknown as { prisma: PrismaClient }; export const prisma = globalForPrisma.prisma || new PrismaClient({ - log: ['query'], + log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'], }); if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; diff --git a/src/proxy.ts b/src/proxy.ts index 8fc70a3..58bbbf3 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -3,7 +3,7 @@ import type { NextRequest } from 'next/server'; import { getSessionCookieName, verifySessionToken } from '@/lib/session'; const PUBLIC_PATHS = new Set(['/login']); -const PUBLIC_API_PATHS = new Set(['/api/auth/login', '/api/settings/init']); +const PUBLIC_API_PATHS = new Set(['/api/auth/login', '/api/settings/init', '/api/settings/status']); export default async function proxy(request: NextRequest) { const authCookie = request.cookies.get(getSessionCookieName());