diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6a0233e --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DATABASE_URL="file:./dev.db" +SESSION_SECRET="replace-with-a-long-random-secret" diff --git a/.gitignore b/.gitignore index f390d12..1df4829 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/README.md b/README.md index e215bc4..ee03569 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,30 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# NoteAI -## Getting Started +基于 Next.js + Prisma + TipTap 的本地知识笔记应用。 -First, run the development server: +## 环境变量 + +在项目根目录创建 `.env`: + +```env +DATABASE_URL="file:./dev.db" +SESSION_SECRET="replace-with-a-long-random-secret" +``` + +- `SESSION_SECRET` 必填,用于服务端签名登录会话。 +- 生产环境请使用长度至少 32 的随机字符串。 + +## 开发 ```bash npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +默认端口为 `3001`。 -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## 代码检查 -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +```bash +npx tsc --noEmit +npm run lint +``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..cd5fa36 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,6 +5,13 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + files: ["src/components/editor/**/*.{ts,tsx}", "src/components/editor.tsx"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/ban-ts-comment": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/prisma/dev.db b/prisma/dev.db index 40121f1..1f27d18 100644 Binary files a/prisma/dev.db and b/prisma/dev.db differ diff --git a/scripts/init-settings.js b/scripts/init-settings.js index 065fe9e..be81391 100644 --- a/scripts/init-settings.js +++ b/scripts/init-settings.js @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ const { PrismaClient } = require('@prisma/client'); const { scrypt, randomBytes } = require('crypto'); const { promisify } = require('util'); diff --git a/scripts/reset-password.js b/scripts/reset-password.js index ba8fe71..af6f496 100644 --- a/scripts/reset-password.js +++ b/scripts/reset-password.js @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ const { PrismaClient } = require('@prisma/client'); const { scrypt, randomBytes } = require('crypto'); const { promisify } = require('util'); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index c5c3bd8..afdd6d9 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,31 +1,38 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { verifyPassword } from "@/lib/auth"; +import { createSessionToken, getSessionCookieName, getSessionTtlSeconds } from "@/lib/session"; export async function POST(req: Request) { try { - const { password } = await req.json(); - - // Ensure settings exist, if not, maybe we should init or fail? - // Ideally init should happen on app startup or manual trigger, but for simplicity: - // If no settings exist, check against "admin" (fallback) but DO NOT create DB entry implicitely here for security, - // unless we strictly define that "admin" is the default. - // Let's assume DB must be populated. + const { password, rememberMe, durationDays } = await req.json(); const settings = await prisma.globalSettings.findUnique({ where: { id: "default" }, }); - const isValid = settings - ? await verifyPassword(password, settings.password) - : password === "admin"; // Fallback only if DB empty + if (!settings) { + return NextResponse.json({ error: "Settings not initialized" }, { status: 503 }); + } + + const isValid = await verifyPassword(password, settings.password); if (isValid) { - return NextResponse.json({ success: true }); + const days = rememberMe ? Number(durationDays) || 1 : 1; + const token = await createSessionToken(days); + const response = NextResponse.json({ success: true }); + response.cookies.set(getSessionCookieName(), token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: getSessionTtlSeconds(days), + }); + return response; } else { return NextResponse.json({ error: "Invalid password" }, { status: 401 }); } - } catch (error) { + } catch { return NextResponse.json({ error: "Login failed" }, { status: 500 }); } } diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 01177a1..d3a749d 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,9 +1,10 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; +import { getSessionCookieName } from "@/lib/session"; export async function POST() { const cookieStore = await cookies(); - cookieStore.delete("auth"); + cookieStore.delete(getSessionCookieName()); return NextResponse.json({ success: true }); } diff --git a/src/app/api/pages/[id]/route.ts b/src/app/api/pages/[id]/route.ts index c810f2c..846f990 100644 --- a/src/app/api/pages/[id]/route.ts +++ b/src/app/api/pages/[id]/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; +import type { Prisma } from '@prisma/client'; export async function GET( request: Request, @@ -12,7 +13,7 @@ export async function GET( }); if (!page) return NextResponse.json({ error: 'Page not found' }, { status: 404 }); return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") }); - } catch (error) { + } catch { return NextResponse.json({ error: 'Error fetching page' }, { status: 500 }); } } @@ -25,7 +26,7 @@ export async function PUT( try { const body = await request.json(); // Separate update logic for flexibility (e.g. only updating title) - const updateData: any = {}; + const updateData: Prisma.PageUncheckedUpdateInput = {}; if (body.title !== undefined) updateData.title = body.title; if (body.content !== undefined) updateData.content = body.content; if (body.parentId !== undefined) updateData.parentId = body.parentId; @@ -38,7 +39,7 @@ export async function PUT( data: updateData, }); return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") }); - } catch (error) { + } catch { return NextResponse.json({ error: 'Error updating page' }, { status: 500 }); } } @@ -53,7 +54,7 @@ export async function DELETE( where: { id }, }); return NextResponse.json({ success: true }); - } catch (error) { + } catch { return NextResponse.json({ error: 'Error deleting page' }, { status: 500 }); } } diff --git a/src/app/api/pages/reorder/route.ts b/src/app/api/pages/reorder/route.ts index 9e5afca..f7a59c7 100644 --- a/src/app/api/pages/reorder/route.ts +++ b/src/app/api/pages/reorder/route.ts @@ -21,7 +21,7 @@ export async function PUT(request: Request) { ); return NextResponse.json({ success: true }); - } catch (error) { + } catch { return NextResponse.json({ error: 'Error reordering pages' }, { status: 500 }); } } diff --git a/src/app/api/pages/route.ts b/src/app/api/pages/route.ts index 86fef59..dc09c1d 100644 --- a/src/app/api/pages/route.ts +++ b/src/app/api/pages/route.ts @@ -11,7 +11,7 @@ export async function GET() { tags: JSON.parse(p.tags || "[]") })); return NextResponse.json(parsedPages); - } catch (error) { + } catch { return NextResponse.json({ error: 'Error fetching pages' }, { status: 500 }); } } @@ -41,7 +41,7 @@ export async function POST(request: Request) { }, }); return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") }); - } catch (error) { + } catch { return NextResponse.json({ error: 'Error creating page' }, { status: 500 }); } } diff --git a/src/app/api/settings/password/route.ts b/src/app/api/settings/password/route.ts index e31e352..4f0cea6 100644 --- a/src/app/api/settings/password/route.ts +++ b/src/app/api/settings/password/route.ts @@ -6,8 +6,7 @@ export async function PUT(req: Request) { try { const { currentPassword, newPassword } = await req.json(); - // Cast to any to bypass build error until server restart allows prisma generate to run - const settings = await (prisma as any).globalSettings.findUnique({ + const settings = await prisma.globalSettings.findUnique({ where: { id: "default" }, }); @@ -22,13 +21,13 @@ export async function PUT(req: Request) { const hashedPassword = await hashPassword(newPassword); - await (prisma as any).globalSettings.update({ + await prisma.globalSettings.update({ where: { id: "default" }, data: { password: hashedPassword }, }); return NextResponse.json({ success: true }); - } catch (error) { + } catch { return NextResponse.json({ error: "Failed to change password" }, { status: 500 }); } } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index ee77d9d..a5d9db4 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; @@ -8,7 +8,7 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [rememberMe, setRememberMe] = useState(false); - const [duration, setDuration] = useState("1"); // days + const [duration, setDuration] = useState("1"); const router = useRouter(); const handleLogin = async (e: React.FormEvent) => { @@ -19,23 +19,21 @@ export default function LoginPage() { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }), + body: JSON.stringify({ + password, + rememberMe, + durationDays: parseInt(duration, 10), + }), }); if (res.ok) { - let cookieString = "auth=true; path=/"; - if (rememberMe) { - const seconds = parseInt(duration) * 24 * 60 * 60; - cookieString += `; max-age=${seconds}`; - } - document.cookie = cookieString; router.push("/"); } else { const data = await res.json(); - setError(data.error || "登录失败"); + setError(data.error || "Login failed"); } - } catch (err) { - setError("发生错误,请重试"); + } catch { + setError("Something went wrong. Please try again."); } }; @@ -46,8 +44,8 @@ export default function LoginPage() {
-

欢迎回来

-

请输入访问密码以进入您的个人空间

+

Welcome back

+

Enter your access password to continue.

@@ -56,7 +54,7 @@ export default function LoginPage() { setPassword(e.target.value)} className="w-full pl-10 pr-4 py-3 bg-muted/50 border rounded-xl focus:ring-2 focus:ring-primary outline-none transition-all" @@ -74,7 +72,7 @@ export default function LoginPage() { onChange={(e) => setRememberMe(e.target.checked)} className="w-4 h-4 rounded border-input bg-background/50 text-primary focus:ring-primary/50" /> - 记住我 + Remember me {rememberMe && ( @@ -83,9 +81,9 @@ export default function LoginPage() { onChange={(e) => setDuration(e.target.value)} className="bg-transparent border-none outline-none text-muted-foreground hover:text-foreground cursor-pointer text-xs" > - - - + + + )} @@ -94,12 +92,12 @@ export default function LoginPage() { type="submit" className="w-full py-3 bg-primary text-primary-foreground font-semibold rounded-xl hover:opacity-90 active:scale-[0.98] transition-all shadow-lg" > - 开启灵感 + Sign in
- NoteAI • 您的私人第二大脑 + NoteAI - Your private second brain
diff --git a/src/app/page.tsx b/src/app/page.tsx index 06a57e2..32dc2aa 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,7 +3,7 @@ import { SidebarContent, ResizableSidebar } from "@/components/sidebar"; import { Editor } from "@/components/editor"; import { useEditorStore } from "@/lib/store"; -import { FileDown, Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock } from "lucide-react"; +import { Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock } from "lucide-react"; import { useSettingsStore } from "@/lib/settings-store"; import { exportPageAsMarkdown } from "@/lib/export"; import { useState } from "react"; @@ -46,14 +46,6 @@ export default function Home() {
- {/* Mobile Back Button */} -
updatePage(null as any, {} as any)}> - {/* Note: updatePage isn't the right way to clear selection. We need setPageId(null). - But store only exposes updatePage. Let's fix store usage or use a store action if available. - Actually, looking at store.ts, we need `setActivePageId`. - */} -
-
{/* Mobile Back Button Integration in Header */}
[...prev, assistantMsg]); @@ -122,8 +122,9 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) { } } } - } catch (e: any) { - if (e.name === 'AbortError') { + } catch (e: unknown) { + const isAbortError = e instanceof Error && e.name === "AbortError"; + if (isAbortError) { setMessages(prev => [...prev, { id: Date.now().toString(), role: "system", content: "Genertion stopped by user." }]); } else { setMessages(prev => [...prev, { id: Date.now().toString(), role: "system", content: `Error: ${e instanceof Error ? e.message : "Unknown error"}` }]); diff --git a/src/components/editor.tsx b/src/components/editor.tsx index 43871bc..0cd50bb 100644 --- a/src/components/editor.tsx +++ b/src/components/editor.tsx @@ -225,7 +225,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, if (content) { editor.commands.insertContent(content); } - } catch (e) { + } catch { // ignore } } diff --git a/src/components/editor/extensions/task-item.tsx b/src/components/editor/extensions/task-item.tsx index 6b0768c..750d1a1 100644 --- a/src/components/editor/extensions/task-item.tsx +++ b/src/components/editor/extensions/task-item.tsx @@ -3,7 +3,7 @@ import { NodeViewWrapper, NodeViewContent, NodeViewProps } from '@tiptap/react' import React from 'react' import { cn } from '@/lib/utils' -export const TaskItemComponent: React.FC = ({ node, updateAttributes, extension }) => { +export const TaskItemComponent: React.FC = ({ node, updateAttributes }) => { return ( {/* Absolute Checkbox Wrapper */} diff --git a/src/components/editor/language-selector.tsx b/src/components/editor/language-selector.tsx index c1fabba..bc2449c 100644 --- a/src/components/editor/language-selector.tsx +++ b/src/components/editor/language-selector.tsx @@ -38,6 +38,7 @@ interface LanguageSelectorProps { export function LanguageSelector({ language, onChange, languages }: LanguageSelectorProps) { const [open, setOpen] = React.useState(false) const [search, setSearch] = React.useState("") + const listboxId = React.useId() const filteredLanguages = languages.filter((lang) => lang.toLowerCase().includes(search.toLowerCase()) @@ -52,6 +53,7 @@ export function LanguageSelector({ language, onChange, languages }: LanguageSele
-
+
{items.length === 0 && (
No language found.
)} diff --git a/src/components/editor/toolbar.tsx b/src/components/editor/toolbar.tsx index 0c3a3f0..0fea2a3 100644 --- a/src/components/editor/toolbar.tsx +++ b/src/components/editor/toolbar.tsx @@ -6,7 +6,7 @@ import { Heading1, Heading2, Heading3, List, ListOrdered, Quote, Undo, Redo, Minus, RemoveFormatting, - AlignLeft, AlignCenter, AlignRight, CheckSquare, Link as LinkIcon, Underline as UnderlineIcon, Image as ImageIcon, + AlignLeft, AlignCenter, AlignRight, CheckSquare, Link as LinkIcon, Image as ImageIcon, Table as TableIcon, Sparkles, FileDown, SquareCode } from "lucide-react"; import { cn } from "@/lib/utils"; diff --git a/src/components/search-command.tsx b/src/components/search-command.tsx index 43dd45e..f4a35e6 100644 --- a/src/components/search-command.tsx +++ b/src/components/search-command.tsx @@ -1,8 +1,7 @@ "use client"; import { useSearchStore } from "@/lib/search-store"; -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useEffect } from "react"; import { useEditorStore } from "@/lib/store"; import { Command } from "cmdk"; import { Search, FileText, Folder } from "lucide-react"; @@ -10,8 +9,6 @@ import { Search, FileText, Folder } from "lucide-react"; export function SearchCommand() { const { isOpen, setOpen, query, setQuery } = useSearchStore(); const { pages, setActivePageId } = useEditorStore(); - const router = useRouter(); - useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 552f31b..831507f 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -2,7 +2,7 @@ import React, { useRef, useEffect, useState } from "react"; import { useEditorStore, Page } from "@/lib/store"; -import { Plus, Search, PanelLeftClose, Sun, Moon, Upload, FolderPlus, FilePlus, Settings, Hash, Tag as TagIcon, FileText, Folder, Menu, X, LogOut, ChevronRight, ChevronDown, Layers, PanelLeftOpen } from "lucide-react"; +import { Plus, Search, PanelLeftClose, Sun, Moon, Upload, FolderPlus, FilePlus, Settings, Hash, Tag as TagIcon, FileText, Folder, LogOut, ChevronRight, ChevronDown, Layers, PanelLeftOpen } from "lucide-react"; import { useTheme } from "next-themes"; import { TreeView } from "./sidebar/tree-view"; import Link from "next/link"; @@ -10,8 +10,8 @@ import { useRouter } from "next/navigation"; import { SearchCommand } from "@/components/search-command"; import { cn, getTagColor } from "@/lib/utils"; import { useSearchStore } from "@/lib/search-store"; -import { ImportProvider, useImport } from "@/components/import-context"; -import { DndContext, DragEndEvent, DragOverlay, useSensor, useSensors, PointerSensor, pointerWithin, DragStartEvent, DragOverEvent, useDroppable } from "@dnd-kit/core"; +import { useImport } from "@/components/import-context"; +import { DndContext, DragEndEvent, DragOverlay, useSensor, useSensors, PointerSensor, pointerWithin, DragStartEvent, useDroppable } from "@dnd-kit/core"; import { arrayMove } from "@dnd-kit/sortable"; function RootDropZone() { @@ -34,19 +34,18 @@ function RootDropZone() { export function ResizableSidebar() { // State - const [width, setWidth] = useState(256); - const [isCollapsed, setIsCollapsed] = useState(false); + const [width, setWidth] = useState(() => { + if (typeof window === "undefined") return 256; + const savedWidth = localStorage.getItem("sidebar-width"); + return savedWidth ? parseInt(savedWidth, 10) : 256; + }); + const [isCollapsed, setIsCollapsed] = useState(() => { + if (typeof window === "undefined") return false; + return localStorage.getItem("sidebar-collapsed") === "true"; + }); const [isResizing, setIsResizing] = useState(false); const sidebarRef = useRef(null); - // Load state from localStorage on mount - useEffect(() => { - const savedWidth = localStorage.getItem('sidebar-width'); - const savedCollapsed = localStorage.getItem('sidebar-collapsed'); - if (savedWidth) setWidth(parseInt(savedWidth)); - if (savedCollapsed) setIsCollapsed(savedCollapsed === 'true'); - }, []); - // Save state useEffect(() => { localStorage.setItem('sidebar-width', width.toString()); @@ -122,7 +121,6 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void } const { setOpen, openSearchWithTag } = useSearchStore(); const { triggerImport, isImporting } = useImport(); const { theme, setTheme } = useTheme(); - const [mounted, setMounted] = React.useState(false); const router = useRouter(); // Collapsible State @@ -214,15 +212,14 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void } const [activeDragItem, setActiveDragItem] = React.useState(null); - const onDragStart = (event: any) => { + const onDragStart = (event: DragStartEvent) => { const item = pages.find(p => p.id === event.active.id); if (item) setActiveDragItem(item); } useEffect(() => { - setMounted(true); fetchPages(); - }, []); + }, [fetchPages]); // Handle clicks that should close mobile sidebar const handleItemClick = () => { @@ -416,9 +413,9 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }