首次发布git

This commit is contained in:
2026-02-24 09:53:19 +08:00
commit dd97cf6a2c
71 changed files with 14875 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
---
trigger: always_on
---
# 中文原生协议 v5.0
## 一、核心身份
你是**中文原生**的技术专家。思维和输出必须遵循中文优先原则。
---
## 二、语言规则
### 2.1 输出语言
- 所有解释、分析、建议用**中文**
- 技术术语保留英文(如 API、JWT、Docker、Kubernetes
- 代码相关保持英文(变量名、函数名、文件路径、CLI 命令)
### 2.2 示例
- ✅ "检查 `UserService.java` 中的认证逻辑"
- ✅ "这个 `useEffect` Hook 存在依赖项问题"
- ❌ "Let me analyze the code structure"
- ❌ "I'll check the authentication logic"
### 2.3 工具调用
-**机器读的保留英文**file_path, function_name, endpoint
- **人读的必须中文**task_title, description, commit_message
---
## 三、项目上下文获取
### 3.1 新对话时,按优先级阅读以下文件(如果存在):
1.`contexts/context.md` - 项目核心上下文 ⭐最重要
2.`README.md` - 项目概述
3.`specs/*.md` - 技术规范
4.`.agent/workflows/*.md` - 工作流配置
### 3.2 如果项目没有上述文件:
- 先询问项目基本情况
- 建议创建 `contexts/context.md` 记录项目信息
---
## 四、通用开发规范
### 4.1 Implementation Plan 和 Task
- 标题必须使用**中文**
- 步骤说明必须使用**中文**
- 示例:`### 实现用户登录功能` 而非 `### Implement User Login`
### 4.2 代码注释
- 新代码的注释必须使用**中文**
- 保持注释简洁明了
- 示例:`// 检查用户是否已登录` 而非 `// Check if user is logged in`
### 4.3 Git 提交信息
- 使用中文,格式:`<类型>: <描述>`
- 示例:`feat: 添加用户登录功能``fix: 修复积分计算错误`
### 4.3 文档编写
- 技术文档使用中文
- 保持 Markdown 格式规范
---
## 五、工作模式
### 5.1 复杂任务
- 先阅读相关规范文档
- 制定计划后再执行
- 完成后更新相关文档
### 5.2 简单任务
- 直接执行
- 保持代码风格一致
### 5.3 不确定时
- 主动询问而非猜测
- 提供选项让用户决策
+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
.git
.env.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
dist
coverage
+43
View File
@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
+3
View File
@@ -0,0 +1,3 @@
{
"css.lint.unknownAtRules": "ignore"
}
+210
View File
@@ -0,0 +1,210 @@
# 部署指南 (Deployment Guide)
本指南将指导您将应用部署到已安装 iPanel/宝塔面板 和 Node.js 环境的 Linux 服务器上。
## 1. 准备工作
### 启用 Standalone 模式 (已自动配置)
为了减小服务器上的文件体积,我们已经在 `next.config.ts` 中启用了 `output: 'standalone'` 模式。这会创建一个包含所有必要依赖的精简构建包。
### 检查环境
确保您的服务器已安装:
- **Node.js**: 版本需 >= 18.0.0 (推荐 v20 LTS)
- **Nginx**: 用于反向代理
- **PM2**: 用于进程守护 (通常面板可以一键安装,或通过 `npm install -g pm2` 安装)
- **Database**: 您的代码目前使用 SQLite (本地文件),部署非常简单。
## 2. 构建项目
在**本地电脑**上执行构建命令:
```bash
# 1. 安装依赖 (仅当添加了新包时需要)
npm install
# 2. 生成 Prisma 客户端 (仅当修改了数据库结构时需要)
npx prisma generate
# 3. 构建项目
npm run build
```
构建完成后,您会看到一个 `.next` 文件夹。
## 3. 打包发布 (推荐方式)
我们已经将繁琐的文件复制步骤自动化了。请按照以下“标准流程”进行部署:
### 3.1 本地构建 & 打包
1. **构建**:
```bash
npm run build
```
2. **打包** (运行自动化脚本):
在 PowerShell 中执行:
```powershell
./scripts/build-dockerapp.ps1
```
*这个脚本会自动将 `standalone`, `static`, `public`, `prisma`, `docker-compose.yml` 等所有必要文件整理到项目根目录下的 **`dockerapp`** 文件夹中。*
### 3.2 上传 & 部署
1. **上传**:
将整个 **`dockerapp`** 文件夹上传到服务器(例如 `/www/wwwroot/wojide/dockerapp`)。
2. **启动**:
进入服务器上的该目录,运行 Docker Compose
```bash
cd dockerapp
docker-compose up -d --build
```
此方式会自动使用包内的 `Dockerfile` 和 `docker-compose.yml` 构建并启动服务,无需再手动配置 Nginx 或 PM2(Docker 会处理端口映射,通常映射到 3001 或您配置的端口)。
**目录结构说明 (`dockerapp` 文件夹内)**:
```text
dockerapp/
├── .next/ <-- 包含 static 和 standalone/server
├── node_modules/ <-- 最小化依赖
├── public/ <-- 静态资源
├── prisma/ <-- 数据库结构
├── Dockerfile <-- 构建脚本
├── docker-compose.yml <-- 编排脚本
└── ...
```
## 4. 1Panel 面板配置指南 (图形化界面)
根据您提供的 1Panel 创建运行环境截图,请按以下方式填写:
- **名称**: `wojide` (或任意您喜欢的名字)
- **应用**: `Node.js` (版本保持默认或选择 LTS 版本)
- **项目目录**: 选择您在第 3 步上传文件的目录 (例如 `/www/wwwroot/wojide`)
- **启动命令**:
- **开启 [自定义启动命令] 开关** (非常重要!)
- 输入命令: `node server.js`
- *解释: Standalone 模式下直接运行 server.js 即可,不需要 npm run start。*
- **包管理器**: `npm`
- **端口**: 如果有端口设置,请输入 `3000`
点击确认创建后,容器会自动启动。
## 5. 服务器端配置 (数据库迁移)
登录服务器终端 (SSH) 或使用面板的终端功能。
### 5.1 数据库迁移
进入网站目录并运行迁移,确保 `dev.db` 存在:
```bash
### 5.1 数据库迁移
进入网站目录并运行迁移,确保 `dev.db` 存在:
**情况 A: 直接安装的 Node 环境 (宝塔默认)**
```bash
cd /www/wwwroot/wojide
npx prisma migrate deploy
```
**情况 B: Docker 部署 (1Panel / 容器化)**
即使文件在宿主机上,Docker 容器也能通过“挂载”访问它们。您只需**进入容器**并找到那个挂载目录。
1. **确定容器**: 在 1Panel 找到您的应用容器 ID。
2. **进入容器并运行**:
```bash
# 1. 登录容器
docker exec -it <container_id> sh
# 2. 寻找项目目录 (关键步骤!)
# 在 1Panel 中,网站目录通常也会挂载到容器内的相同路径,或者 /app 目录。
# 尝试进入:
cd /www/wwwroot/wojide
# 或者 ls /app 看看是否有文件
# 3. 确认你在正确的目录下 (应该能看到 prisma 文件夹)
ls
# 4. 运行迁移 (更稳妥的方式: 先全局安装指定版本)
# 先安装 CLI 工具
npm install -g prisma@5.10.2
# 然后运行迁移
prisma migrate deploy
# 5. 退出
exit
```
```
### 5.2 启动服务
使用 PM2 启动项目:
```bash
# 启动
pm2 start server.js --name "wojide-app"
# 查看状态
pm2 status
# 如果报错,查看日志
pm2 logs wojide-app
```
此时,项目应该运行在 `http://localhost:3000`。
## 6. 初始化配置 (重要!)
首次部署后,数据库是空的,还没有设置密码。
您需要手动调用一次初始化接口来创建默认密码 (`admin`)。
**方法 A: 使用 Curl (在服务器终端)**
```bash
curl -X POST http://localhost:3000/api/settings/init
```
如果成功,会返回 `Initialized default settings`。
**方法 B: 使用浏览器控制台**
如果不方便用 Curl,可以在您的电脑浏览器打开网站登录页,按 `F12` 打开控制台,输入以下代码并回车:
```js
fetch('/api/settings/init', { method: 'POST' }).then(r=>r.json()).then(console.log)
```
初始化成功后,您可以使用默认密码 **`admin`** 登录。
## 7. 配置 Nginx 反向代理 (通过面板)
在面板中找到您的网站设置 -> **反向代理 (Reverse Proxy)**。
- **代理名称**: NextJS
- **目标 URL**: `http://127.0.0.1:3000`
- **发送域名**: `$host`
保存后,您应该可以通过域名访问您的网站了。
## 8. 后续更新 (代码修改后)
当您修改了代码并想要更新服务器版本时:
1. **本地构建**: 运行 `npm run build`。
2. **上传覆盖**:
- 上传 `.next/standalone` 中的内容覆盖服务器对应文件。
- 上传 `.next/static` 覆盖服务器上的 `.next/static`。
3. **重启服务**:
- 在 1Panel 容器列表中,点击该容器的 **“重启”** 按钮。
**注意**: 如果您修改了数据库结构 (`schema.prisma`),请在重启前参考第 5.1 步进入容器运行 `npx prisma migrate deploy`。
## 常见问题
- **样式丢失?**
请检查步骤 3/4,确保 `.next/static` 文件夹已正确上传到服务器的 `.next/static` 路径。Standalone 模式默认不包含静态资源,需要手动复制。
- **数据库报错?**
确保 `.env` 文件中的 `DATABASE_URL` 路径正确。对于 SQLite,建议使用绝对路径,例如 `file:/www/wwwroot/your-website/prisma/dev.db`。
- **权限问题?**
确保网站目录的所有者是运行 Nginx/Node 的用户 (通常是 `www` 或 `root`,视配置而定)。
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV PORT 3000
# Install OpenSSL for compatibility
RUN apk add --no-cache openssl
# Copy necessary files
# Standalone build already includes node_modules
COPY .next/standalone ./
COPY .next/static ./.next/static
COPY public ./public
# Copy prisma for database migrations
COPY prisma ./prisma
EXPOSE 3000
# Run migrations and then start the server
CMD ["sh", "-c", "npx prisma db push && node server.js"]
+36
View File
@@ -0,0 +1,36 @@
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).
## Getting Started
First, run the development server:
```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.
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.
+15
View File
@@ -0,0 +1,15 @@
version: '3'
services:
noteai:
image: noteai
build: .
container_name: noteai
restart: always
ports:
- "4500:3000"
volumes:
# Persist the database file
- ./prisma/dev.db:/app/prisma/dev.db
environment:
- DATABASE_URL=file:./prisma/dev.db
- TZ=Asia/Shanghai
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
import packageJson from "./package.json";
const nextConfig: NextConfig = {
output: "standalone",
env: {
NEXT_PUBLIC_APP_VERSION: packageJson.version,
},
};
export default nextConfig;
+8959
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
{
"name": "note-ai",
"version": "1.0.0105",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/typography": "^0.5.19",
"@tiptap/core": "^3.14.0",
"@tiptap/extension-bubble-menu": "^3.14.0",
"@tiptap/extension-code-block-lowlight": "^3.14.0",
"@tiptap/extension-floating-menu": "^3.14.0",
"@tiptap/extension-gapcursor": "^3.14.0",
"@tiptap/extension-highlight": "^3.14.0",
"@tiptap/extension-image": "^3.14.0",
"@tiptap/extension-link": "^3.14.0",
"@tiptap/extension-subscript": "^3.14.0",
"@tiptap/extension-superscript": "^3.14.0",
"@tiptap/extension-table": "^3.14.0",
"@tiptap/extension-table-cell": "^3.14.0",
"@tiptap/extension-table-header": "^3.14.0",
"@tiptap/extension-table-row": "^3.14.0",
"@tiptap/extension-task-item": "^3.14.0",
"@tiptap/extension-task-list": "^3.14.0",
"@tiptap/extension-text-align": "^3.14.0",
"@tiptap/extension-underline": "^3.14.0",
"@tiptap/extension-youtube": "^3.14.0",
"@tiptap/pm": "^3.14.0",
"@tiptap/react": "^3.14.0",
"@tiptap/starter-kit": "^3.14.0",
"@tiptap/suggestion": "^3.14.0",
"@types/file-saver": "^2.0.7",
"@types/marked": "^5.0.2",
"@types/turndown": "^5.0.6",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"file-saver": "^2.0.5",
"framer-motion": "^12.23.26",
"jszip": "^3.10.1",
"lowlight": "^2.9.0",
"lucide-react": "^0.562.0",
"marked": "^17.0.1",
"next": "16.1.1",
"next-themes": "^0.4.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.9.0",
"turndown-plugin-gfm": "^1.0.2",
"zustand": "^5.0.9",
"@prisma/client": "5.10.2",
"prisma": "^5.10.2"
},
"devDependencies": {
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
+8
View File
@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
BIN
View File
Binary file not shown.
@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "Page" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL DEFAULT '',
"type" TEXT NOT NULL DEFAULT 'file',
"parentId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Page_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Page" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE "GlobalSettings" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
"password" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"
+32
View File
@@ -0,0 +1,32 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Page {
id String @id @default(uuid())
title String
content String @default("")
tags String @default("[]") // JSON stringified array of tags
icon String? // Emoji or icon name
type String @default("file") // "file" or "folder"
parentId String?
parent Page? @relation("PageHierarchy", fields: [parentId], references: [id])
children Page[] @relation("PageHierarchy")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Int @default(0)
isLocked Boolean @default(false)
}
model GlobalSettings {
id String @id @default("default")
password String // Scrypt hashed password
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+44
View File
@@ -0,0 +1,44 @@
# Deploy Script
param (
# automatically determine root relative to this script (scripts/ folder)
[string]$ProjectRoot = (Resolve-Path "$PSScriptRoot/..").Path
)
$ErrorActionPreference = "Stop"
$DockerAppDir = Join-Path $ProjectRoot "dockerapp"
$NextDir = Join-Path $ProjectRoot ".next"
$PublicDir = Join-Path $ProjectRoot "public"
$PrismaDir = Join-Path $ProjectRoot "prisma"
Write-Host "📦 Starting Dockerapp Packaging..." -ForegroundColor Cyan
Write-Host " Project Root: $ProjectRoot"
# 1. Clean dockerapp (Keep Dockerfile & docker-compose.yml)
Write-Host " Cleaning previous build artifacts..."
Get-ChildItem -Path $DockerAppDir -Exclude Dockerfile, docker-compose.yml | Remove-Item -Recurse -Force
# 2. Copy Standalone (Exclude node_modules because Dockerfile reinstalls them)
Write-Host " Copying Standalone build (skipping node_modules)..."
if (!(Test-Path "$NextDir/standalone")) {
Write-Error "❌ .next/standalone not found! Run 'npm run build' first."
}
# Copy everything inside standalone EXCEPT node_modules
Get-ChildItem -Path "$NextDir/standalone" -Exclude "node_modules" | Copy-Item -Destination $DockerAppDir -Recurse -Force
# 3. Copy Static Assets (Crucial!)
Write-Host " Copying Static assets..."
$DestStatic = Join-Path $DockerAppDir ".next/static"
New-Item -ItemType Directory -Path $DestStatic -Force | Out-Null
Copy-Item -Path "$NextDir/static/*" -Destination $DestStatic -Recurse -Force
# 4. Copy Public Assets
Write-Host " Copying Public folder..."
Copy-Item -Path $PublicDir -Destination $DockerAppDir -Recurse -Force
# 5. Copy Prisma
Write-Host " Copying Prisma..."
Copy-Item -Path $PrismaDir -Destination $DockerAppDir -Recurse -Force
Write-Host "✅ Packaging Complete!" -ForegroundColor Green
Write-Host " Location: $DockerAppDir"
Write-Host " Next Step: Upload 'dockerapp' folder to server and run 'docker-compose up -d --build'"
+33
View File
@@ -0,0 +1,33 @@
const { PrismaClient } = require('@prisma/client');
const { scrypt, randomBytes } = require('crypto');
const { promisify } = require('util');
const prisma = new PrismaClient();
const scryptAsync = promisify(scrypt);
async function hashPassword(password) {
const salt = randomBytes(16).toString("hex");
const derivedKey = await scryptAsync(password, salt, 64);
return `${salt}:${derivedKey.toString("hex")}`;
}
async function main() {
const count = await prisma.globalSettings.count();
if (count === 0) {
console.log("Initializing default settings...");
const hashedPassword = await hashPassword("admin");
await prisma.globalSettings.create({
data: {
id: "default",
password: hashedPassword,
},
});
console.log("Done.");
} else {
console.log("Settings already exist.");
}
}
main()
.catch(e => console.error(e))
.finally(async () => await prisma.$disconnect());
+38
View File
@@ -0,0 +1,38 @@
const { PrismaClient } = require('@prisma/client');
const { scrypt, randomBytes } = require('crypto');
const { promisify } = require('util');
const prisma = new PrismaClient();
const scryptAsync = promisify(scrypt);
async function hashPassword(password) {
const salt = randomBytes(16).toString("hex");
const derivedKey = await scryptAsync(password, salt, 64);
return `${salt}:${derivedKey.toString("hex")}`;
}
async function main() {
console.log("Resetting password to 'admin'...");
const hashedPassword = await hashPassword("admin");
const settings = await prisma.globalSettings.findFirst();
if (settings) {
await prisma.globalSettings.update({
where: { id: settings.id },
data: { password: hashedPassword }
});
console.log("Password reset successfully.");
} else {
await prisma.globalSettings.create({
data: {
id: "default",
password: hashedPassword,
},
});
console.log("Settings created with default password.");
}
}
main()
.catch(e => console.error(e))
.finally(async () => await prisma.$disconnect());
+50
View File
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
export const runtime = "edge"; // Optional: Use edge runtime for lower latency
export async function POST(req: NextRequest) {
try {
const { messages, config } = await req.json();
const { apiKey, baseURL, model } = config || {};
if (!apiKey) {
return NextResponse.json({ error: "Missing API Key" }, { status: 401 });
}
// Clean up baseURL: ensure no trailing slash, add /chat/completions if missing?
// Actually, usually users provide standard base URL "https://api.openai.com/v1"
// We should append /chat/completions.
const url = `${baseURL.replace(/\/$/, "")}/chat/completions`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: model || "gpt-3.5-turbo",
messages,
stream: true, // Force streaming
}),
});
if (!res.ok) {
const errorText = await res.text();
return NextResponse.json({ error: `Upstream Error: ${res.statusText}`, details: errorText }, { status: res.status });
}
// Return the stream directly
return new Response(res.body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
} catch (error) {
console.error("AI API Error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/auth";
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 settings = await prisma.globalSettings.findUnique({
where: { id: "default" },
});
const isValid = settings
? await verifyPassword(password, settings.password)
: password === "admin"; // Fallback only if DB empty
if (isValid) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
}
} catch (error) {
return NextResponse.json({ error: "Login failed" }, { status: 500 });
}
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST() {
const cookieStore = await cookies();
cookieStore.delete("auth");
return NextResponse.json({ success: true });
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const id = (await params).id;
try {
const page = await prisma.page.findUnique({
where: { id },
});
if (!page) return NextResponse.json({ error: 'Page not found' }, { status: 404 });
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
} catch (error) {
return NextResponse.json({ error: 'Error fetching page' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const id = (await params).id;
try {
const body = await request.json();
// Separate update logic for flexibility (e.g. only updating title)
const updateData: any = {};
if (body.title !== undefined) updateData.title = body.title;
if (body.content !== undefined) updateData.content = body.content;
if (body.parentId !== undefined) updateData.parentId = body.parentId;
if (body.tags !== undefined) updateData.tags = JSON.stringify(body.tags);
if (body.icon !== undefined) updateData.icon = body.icon;
if (body.isLocked !== undefined) updateData.isLocked = body.isLocked;
const page = await prisma.page.update({
where: { id },
data: updateData,
});
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
} catch (error) {
return NextResponse.json({ error: 'Error updating page' }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const id = (await params).id;
try {
await prisma.page.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Error deleting page' }, { status: 500 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function PUT(request: Request) {
try {
const body = await request.json();
const { updates } = body;
if (!Array.isArray(updates)) {
return NextResponse.json({ error: 'Invalid updates' }, { status: 400 });
}
// Transaction for batch update
await prisma.$transaction(
updates.map((update: { id: string, order: number }) =>
prisma.page.update({
where: { id: update.id },
data: { order: update.order },
})
)
);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Error reordering pages' }, { status: 500 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const pages = await prisma.page.findMany({
orderBy: [{ order: 'asc' }, { createdAt: 'desc' }],
});
const parsedPages = pages.map(p => ({
...p,
tags: JSON.parse(p.tags || "[]")
}));
return NextResponse.json(parsedPages);
} catch (error) {
return NextResponse.json({ error: 'Error fetching pages' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { title, content, parentId, type } = body;
const page = await prisma.page.create({
data: {
title: title || '无标题',
content: content || '',
tags: JSON.stringify(body.tags || []),
parentId: parentId || null,
type: type || 'file',
order: await (async () => {
if (body.order !== undefined) return body.order;
const lastPage = await prisma.page.findFirst({
where: { parentId: parentId || null },
orderBy: { order: 'desc' },
});
return (lastPage?.order ?? -1) + 1;
})(),
icon: body.icon || null,
isLocked: body.isLocked || false,
},
});
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
} catch (error) {
return NextResponse.json({ error: 'Error creating page' }, { status: 500 });
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { hashPassword } from "@/lib/auth";
export async function POST() {
try {
const count = await prisma.globalSettings.count();
if (count > 0) {
return NextResponse.json({ message: "Settings already initialized" }, { status: 200 });
}
// Default password: "admin"
const hashedPassword = await hashPassword("admin");
await prisma.globalSettings.create({
data: {
id: "default",
password: hashedPassword,
},
});
return NextResponse.json({ message: "Initialized default settings" }, { status: 201 });
} catch (error) {
console.error("Init Settings Error:", error);
return NextResponse.json({ error: "Failed to initialize settings" }, { status: 500 });
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { hashPassword, verifyPassword } from "@/lib/auth";
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({
where: { id: "default" },
});
if (!settings) {
return NextResponse.json({ error: "Settings not found" }, { status: 404 });
}
const isValid = await verifyPassword(currentPassword, settings.password);
if (!isValid) {
return NextResponse.json({ error: "Current password incorrect" }, { status: 401 });
}
const hashedPassword = await hashPassword(newPassword);
await (prisma as any).globalSettings.update({
where: { id: "default" },
data: { password: hashedPassword },
});
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: "Failed to change password" }, { status: 500 });
}
}
+194
View File
@@ -0,0 +1,194 @@
import { NextRequest, NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
import JSZip from "jszip";
import { marked } from "marked";
// Use a global prisma instance to avoid "too many connections" in dev
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
// Disable body parser strictly (Next.js App Router handles FormData naturally)
// export const config = {
// api: {
// bodyParser: false,
// },
// };
// No need for config in App Router route handlers.
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
}
const buffer = await file.arrayBuffer();
const zip = await JSZip.loadAsync(buffer);
// Map to store directory paths to their new DB IDs
// Data format: "folder/subfolder" -> UUID
const pathIdMap = new Map<string, string>();
// Prepare data for proper insertion order (Folders first, then files?)
// Actually, we need to process by path depth to ensure parents exist.
const entries: Array<{ path: string; isDir: boolean; content?: string }> = [];
// 1. Read all entries
const filePromises: Promise<void>[] = [];
zip.forEach((relativePath, zipEntry) => {
if (relativePath.startsWith("__MACOSX") || relativePath.includes(".DS_Store")) {
return; // Skip system files
}
const promise = (async () => {
if (zipEntry.dir) {
// Remove trailing slash for consistency
const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath;
if (cleanPath) {
entries.push({ path: cleanPath, isDir: true });
}
} else {
if (relativePath.endsWith(".md")) {
const content = await zipEntry.async("string");
entries.push({ path: relativePath, isDir: false, content });
}
}
})();
filePromises.push(promise);
});
await Promise.all(filePromises);
// 2. Clear existing Pages (Transaction usually)
// Since sqlite doesn't support nested transactions well in Prisma sometimes,
// we'll just do it sequentially but quickly.
// Ideally: await prisma.$transaction([prisma.page.deleteMany(), ...])
// But logic is complex (recursive id generation), so we delete first.
// WARNING: This is destructive.
await prisma.page.deleteMany();
// 3. Sort entries by path depth (number of slashes)
entries.sort((a, b) => {
const depthA = a.path.split('/').length;
const depthB = b.path.split('/').length;
return depthA - depthB;
});
// Helper to get or create parent folder
const ensureParent = async (entryPath: string): Promise<string | null> => {
const parts = entryPath.split('/');
if (parts.length <= 1) return null; // Root level
const parentPath = parts.slice(0, -1).join('/');
// If parent already processed
if (pathIdMap.has(parentPath)) {
return pathIdMap.get(parentPath)!;
}
// If parent folder was not explicitly in Zip (implicit folder), create it
// Recursively ensure its parent exists
const grandParentId = await ensureParent(parentPath);
const folderName = parts[parts.length - 2];
const newFolder = await prisma.page.create({
data: {
title: folderName,
type: 'folder',
parentId: grandParentId
}
});
pathIdMap.set(parentPath, newFolder.id);
return newFolder.id;
};
// 4. Process entries
for (const entry of entries) {
// Determine parent
// If it's a file "A/B.md", parent path is "A".
// If it's a folder "A/B", parent path is "A".
// Since we sorted by depth, "A" should be processed before "A/B".
// However, implicit folders might be skipped in sorting if they aren't in `entries`.
// So `ensureParent` handles implicit creation.
const parentId = await ensureParent(entry.path);
if (entry.isDir) {
// Check if already created by ensureParent
if (!pathIdMap.has(entry.path)) {
const name = entry.path.split('/').pop() || "Untitled Folder";
const folder = await prisma.page.create({
data: {
title: name,
type: 'folder',
parentId: parentId
}
});
pathIdMap.set(entry.path, folder.id);
}
} else {
// It is a File (.md)
const filename = entry.path.split('/').pop()?.replace('.md', '') || "Untitled";
// Parse Frontmatter
let title = filename;
let tags: string[] = [];
let order = 0;
let markdownBody = entry.content || "";
// Regex for frontmatter
const fmMatch = markdownBody.match(/^---\n([\s\S]*?)\n---\n/);
if (fmMatch) {
const fmString = fmMatch[1];
markdownBody = markdownBody.slice(fmMatch[0].length);
// Simple parsing
// title: "Foo"
// tags: ["a", "b"]
// order: 1
const titleMatch = fmString.match(/title:\s*"(.*)"/);
if (titleMatch) title = titleMatch[1];
const tagsMatch = fmString.match(/tags:\s*\[(.*)\]/);
if (tagsMatch) {
// "a", "b" -> split
tags = tagsMatch[1].split(',').map(s => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
}
const orderMatch = fmString.match(/order:\s*(\d+)/);
if (orderMatch) order = parseInt(orderMatch[1]);
}
// Convert Markdown to HTML for storage (Editor uses HTML)
// Ensure GFM is enabled (default true in new versions, but explicit is good)
// breaks: true converts \n to <br> (GitHub style)
const htmlContent = await marked(markdownBody, { gfm: true, breaks: true });
await prisma.page.create({
data: {
title: title,
type: 'file',
content: htmlContent,
tags: JSON.stringify(tags),
order: order,
parentId: parentId
}
});
}
}
return NextResponse.json({ success: true, count: entries.length });
} catch (e) {
console.error("Restore failed:", e);
return NextResponse.json({ error: "Restore failed: " + String(e) }, { status: 500 });
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+220
View File
@@ -0,0 +1,220 @@
@import 'highlight.js/styles/atom-one-dark.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 240 10% 93%;
/* Main content: 93% gray */
--foreground: 240 5% 20%;
--card: 0 0% 100%;
--card-foreground: 240 5% 15%;
--popover: 0 0% 100%;
--popover-foreground: 240 5% 15%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 10% 90%;
/* Sidebar: 90% gray */
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 96%;
--muted-foreground: 240 3.8% 46%;
--accent: 240 4.8% 96%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.75rem;
}
.dark {
/* Notion-like Dark Mode (Inverted Hierarchy) - Deepened */
--background: 0 0% 9%;
/* Main Content: Deep Dark (#171717) */
--foreground: 0 0% 92%;
--card: 0 0% 9%;
/* Match background */
--card-foreground: 0 0% 92%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 92%;
--primary: 0 0% 92%;
--primary-foreground: 0 0% 10%;
--secondary: 0 0% 13%;
/* Sidebar: Lighter than main (#212121), but deeper than before */
--secondary-foreground: 0 0% 92%;
--muted: 0 0% 13%;
--muted-foreground: 0 0% 65%;
--accent: 0 0% 13%;
--accent-foreground: 0 0% 92%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 92%;
--border: 0 0% 18%;
/* Subtle borders */
--input: 0 0% 18%;
--ring: 0 0% 80%;
}
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* Editor Specific Styles for Notion-like feel */
.ProseMirror {
outline: none;
min-height: 300px;
padding-bottom: 50px;
font-size: 1.05rem;
line-height: var(--editor-line-height, 1.5);
/* Use CSS variable with fallback */
/* Reduced from 1.75 */
}
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: hsl(var(--muted-foreground));
pointer-events: none;
height: 0;
}
.ProseMirror blockquote {
border-left: 3px solid hsl(var(--primary));
/* Make border color slightly more visible (primary) */
padding-left: 1rem;
color: hsl(var(--muted-foreground));
font-family: "Georgia", "Cambria", "Times New Roman", serif !important;
/* Force serif font */
font-style: italic;
margin: 1rem 0;
background: hsl(var(--muted) / 0.3);
/* Subtle background */
padding-top: 0.5rem;
padding-bottom: 0.5rem;
border-radius: 0 0.5rem 0.5rem 0;
/* Rounded right corners */
/* Rounded right corners */
}
.ProseMirror code {
background-color: hsl(var(--primary) / 0.1);
color: hsl(var(--foreground));
border-radius: 0.25rem;
padding: 0.2rem 0.4rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.85em;
font-weight: 500;
}
.dark .ProseMirror code {
background-color: hsl(var(--primary) / 0.2);
color: hsl(var(--foreground));
}
.ProseMirror code::before,
.ProseMirror code::after {
content: none !important;
}
/* ... existing heading styles ... */
/* Table Styles - Ensure content is compact */
.ProseMirror table p {
margin: 0;
/* Remove paragraph margin inside tables */
line-height: var(--table-line-height, 1.2);
/* Use CSS variable with fallback */
/* Tighter line height for table content */
}
.ProseMirror table {
border-collapse: collapse;
margin: 1em 0;
/* Add some vertical space around the table itself */
overflow: hidden;
table-layout: fixed;
width: 100%;
}
.ProseMirror td,
.ProseMirror th {
border-width: 1px;
border-style: solid;
border-color: hsl(var(--border));
box-sizing: border-box;
min-width: 1em;
padding: 2px 4px;
/* Further reduced padding */
position: relative;
vertical-align: top;
}
.ProseMirror th {
background-color: hsl(var(--muted));
font-weight: bold;
text-align: left;
/* Make header borders visible by using a contrasting color if bg matches border */
border-color: hsl(var(--foreground) / 0.1);
}
/* AI Content Style */
.ai-content {
font-family: "KaiTi", "STKaiti", "楷体", "Georgia", serif;
font-style: italic;
font-weight: normal;
color: hsl(var(--foreground));
/* Ensure text color is standard */
}
/* Specific fix for dark mode if needed, but opacity trick usually works */
.dark .ProseMirror th {
border-color: hsl(var(--background));
/* Use background color for borders in dark mode header to show separation */
}
/* Task List Styles */
ul[data-type="taskList"],
.ProseMirror ul[data-type="taskList"] {
list-style: none !important;
padding: 0 !important;
margin: 0 !important;
}
.ProseMirror li[data-type="taskItem"] div,
.ProseMirror li[data-type="taskItem"] label {
line-height: normal !important;
/* Force tight line height for checkbox items */
margin-top: 2px;
margin-bottom: 2px;
}
/* We are now using a Custom NodeView for li[data-type="taskItem"], so no global overriding needed.
The component handles its own layout. */
/* Override Highlight.js background for a softer look */
.ProseMirror pre {
background: #252529 !important;
/* Softer dark gray (hsl(240 5% 15%)), approx matching foreground */
border-radius: 0.5rem;
}
.hljs {
background: transparent !important;
/* Let pre handle the background */
}
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "NoteAI - 轻量级个人笔记网站",
description: "基于 Next.js 的 AI 驱动笔记应用",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Lock, Sparkles } from "lucide-react";
export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [duration, setDuration] = useState("1"); // days
const router = useRouter();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
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 || "登录失败");
}
} catch (err) {
setError("发生错误,请重试");
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-8 bg-card border p-8 rounded-2xl shadow-xl animate-in fade-in zoom-in-95 duration-500">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl text-primary-foreground mb-4">
<Sparkles size={32} />
</div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">访</p>
</div>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
<input
type="password"
placeholder="访问密码"
value={password}
onChange={(e) => 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"
required
/>
</div>
{error && <p className="text-sm text-destructive font-medium">{error}</p>}
</div>
<div className="flex items-center justify-between text-sm px-1">
<label className="flex items-center gap-2 cursor-pointer text-muted-foreground hover:text-foreground transition-colors select-none">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 rounded border-input bg-background/50 text-primary focus:ring-primary/50"
/>
</label>
{rememberMe && (
<select
value={duration}
onChange={(e) => setDuration(e.target.value)}
className="bg-transparent border-none outline-none text-muted-foreground hover:text-foreground cursor-pointer text-xs"
>
<option value="1">1</option>
<option value="7">7</option>
<option value="30">30</option>
</select>
)}
</div>
<button
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"
>
</button>
</form>
<div className="text-center text-xs text-muted-foreground">
NoteAI
</div>
</div>
</div>
);
}
+306
View File
@@ -0,0 +1,306 @@
"use client";
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 { useSettingsStore } from "@/lib/settings-store";
import { exportPageAsMarkdown } from "@/lib/export";
import { useState } from "react";
import { cn, getTagColor } from "@/lib/utils";
import { useSearchStore } from "@/lib/search-store";
import { type Editor as TiptapEditor } from "@tiptap/react";
import { ImportProvider } from "@/components/import-context";
import { AIChatPanel } from "@/components/chat/ai-chat-panel";
export default function Home() {
const { activePageId, pages, updatePage } = useEditorStore();
const { openSearchWithTag } = useSearchStore();
const activePage = pages.find(p => p.id === activePageId);
const [isChatOpen, setIsChatOpen] = useState(false);
const [editor, setEditor] = useState<TiptapEditor | null>(null);
const [isAddingTag, setIsAddingTag] = useState(false);
const [tagInput, setTagInput] = useState("");
return (
<ImportProvider>
<div className="flex h-[100dvh] w-full bg-background overflow-hidden relative">
{/* Mobile: Sidebar List View (Only visible when no page is active) */}
<div className={cn(
activePageId ? "hidden" : "flex-1 h-full md:hidden block"
)}>
<SidebarContent />
</div>
{/* Desktop: Sidebar (Resizable) */}
<ResizableSidebar />
{/* Main Content Area */}
<main className={cn(
"flex-1 h-full overflow-hidden flex flex-col relative z-0",
// Mobile: Hidden when no page active (showing list instead)
!activePageId && "hidden md:flex"
)}>
{activePage ? (
<div className="flex-1 overflow-y-auto scroll-smooth">
<div className="max-w-7xl mx-auto px-4 md:px-16 py-6 min-h-screen content-start">
{/* Mobile Back Button */}
<div className="md:hidden mb-4 flex items-center text-muted-foreground" onClick={() => 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`.
*/}
</div>
<div className="group mb-8 relative">
{/* Mobile Back Button Integration in Header */}
<div className="md:hidden absolute -top-12 left-0 flex items-center gap-1 py-2 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => useEditorStore.getState().setActivePageId(null)}>
<ChevronLeft size={20} />
<span></span>
</div>
{/* Breadcrumb Navigation */}
<div className="flex items-center flex-wrap gap-1 text-sm text-muted-foreground mb-4">
{(() => {
const breadcrumbs = [];
let current: typeof activePage | undefined = activePage;
while (current) {
breadcrumbs.unshift(current);
if (current.parentId) {
current = pages.find(p => p.id === current?.parentId);
} else {
current = undefined;
}
}
return breadcrumbs.map((crumb, index) => (
<div key={crumb.id} className="flex items-center gap-1">
{index > 0 && <ChevronRight size={14} className="opacity-50" />}
<button
onClick={() => useEditorStore.getState().setActivePageId(crumb.id)}
className={cn(
"hover:underline hover:text-foreground transition-colors flex items-center gap-1",
crumb.id === activePage.id && "font-medium text-foreground pointer-events-none"
)}
>
{crumb.icon && <span>{crumb.icon}</span>}
<span>{crumb.title || "无标题"}</span>
</button>
</div>
));
})()}
</div>
<div className="flex items-center gap-2">
{activePage.icon && (
<span className="text-3xl select-none animate-in fade-in zoom-in-75 duration-300">
{activePage.icon}
</span>
)}
<input
value={activePage.title}
onChange={(e) => updatePage(activePage.id, { title: e.target.value })}
placeholder="无标题"
disabled={activePage.isLocked}
className={cn(
"w-full text-3xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/20 text-foreground transition-colors",
activePage.isLocked && "opacity-80 cursor-not-allowed select-none"
)}
/>
</div>
{/* Last Updated Info moved */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1 transition-opacity">
{/* Buttons moved to Editors Toolbar */}
</div>
</div>
{/* Tags Section */}
<div className="flex flex-wrap items-center gap-2 mb-6 animate-in fade-in slide-in-from-top-2 duration-300">
{activePage.tags?.map((tag) => {
const colors = getTagColor(tag);
return (
<span
key={tag}
onClick={() => openSearchWithTag(tag)}
className={cn(
"inline-flex items-center gap-1 px-2.5 py-1 rounded-[3px] text-[11px] font-medium transition-colors border shadow-sm",
colors.bg, colors.text, colors.border
)}>
<Hash size={10} className="opacity-70" />
{tag}
<button
onClick={(e) => {
e.stopPropagation();
const newTags = activePage.tags?.filter(t => t !== tag) || [];
updatePage(activePage.id, { tags: newTags });
}}
className="ml-1 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10 opacity-0 group-hover:opacity-100 transition-all"
>
<X size={10} />
</button>
</span>
);
})}
<div className="relative">
{isAddingTag ? (
<input
autoFocus
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onBlur={() => {
if (tagInput.trim()) {
const newTags = [...(activePage.tags || []), tagInput.trim()];
updatePage(activePage.id, { tags: Array.from(new Set(newTags)) });
}
setTagInput("");
setIsAddingTag(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (tagInput.trim()) {
const newTags = [...(activePage.tags || []), tagInput.trim()];
updatePage(activePage.id, { tags: Array.from(new Set(newTags)) });
}
setTagInput("");
setIsAddingTag(false);
}
if (e.key === 'Escape') {
setTagInput("");
setIsAddingTag(false);
}
}}
className="w-24 px-2 py-0.5 text-xs bg-transparent border border-primary rounded-sm outline-none animate-in fade-in zoom-in-95 duration-200"
placeholder="输入标签..."
/>
) : (
<div className="flex items-center gap-2">
<button
onClick={() => setIsAddingTag(true)}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-sm text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-all border border-transparent hover:border-border"
>
<Plus size={12} />
</button>
{/* Icon Picker */}
<div className="relative group/icon-picker">
<button
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-sm text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-all border border-transparent hover:border-border"
>
<Sparkles size={12} />
{activePage.icon ? '更改图标' : '添加图标'}
</button>
<div className="absolute left-0 top-full mt-1 z-50 hidden group-hover/icon-picker:block w-80 p-2 bg-popover border shadow-md rounded-md animate-in fade-in zoom-in-95">
<div className="grid grid-cols-8 gap-1 h-64 overflow-y-auto p-1">
{[
"📄", "📝", "📁", "📂", "📊", "📈", "📉", "📅", "✅", "❌", "📌", "📍", "📎", "🗑️", "⚙️", "🔒",
"✨", "💡", "🔥", "🚀", "🎨", "🎯", "🏆", "💎", "❤️", "👍", "👋", "🎉", "🌟", "⭐", "🌈", "⚡",
"🤖", "🧠", "💻", "⌨️", "📱", "⌚", "📷", "🎥", "🎧", "🎮", "🕹️", "🎲", "🧩", "🎳", "🥋", "🥊",
"🚗", "✈️", "🛸", "🌍", "🪐", "☀️", "🌙", "☁️", "🌧️", "❄️", "🌊", "💧", "🌀",
"🏠", "🏢", "🏥", "🏫", "🏰", "🏯", "⛺", "🏕️", "🌲", "🌳", "🌴", "🌵", "🌷", "🌸", "🌹", "🌻",
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🐔",
"🍎", "🍌", "🍇", "🍉", "🍊", "🍋", "🍍", "🥭", "🍓", "🍒", "🍑", "🥝", "🍅", "🥑", "🍆", "🥔",
"🍔", "🍟", "🍕", "🌭", "🥪", "🌮", "🌯", "🥗", "🥘", "🍝", "🍜", "🍲", "🍛", "🍣", "🍱", "🥟",
"🍺", "🍻", "🥂", "🍷", "🥃", "🍸", "🍹", "🍾", "☕", "🍵", "🥤", "🧃", "🧊", "🥄", "🍴", "🍽️"
].map(icon => (
<button
key={icon}
onClick={(e) => {
e.stopPropagation();
updatePage(activePage.id, { icon });
}}
className={cn(
"w-8 h-8 flex items-center justify-center rounded-sm hover:bg-accent text-lg transition-colors",
activePage.icon === icon && "bg-accent/50 ring-1 ring-primary/20"
)}
>
{icon}
</button>
))}
<button
onClick={(e) => {
e.stopPropagation();
updatePage(activePage.id, { icon: null });
}}
className="w-8 h-8 flex items-center justify-center rounded-sm hover:bg-red-50 text-red-500 hover:text-red-600 transition-colors col-span-1"
title="清除图标"
>
<X size={14} />
</button>
</div>
</div>
</div>
{/* Lock Button */}
<button
onClick={() => updatePage(activePage.id, { isLocked: !activePage.isLocked })}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-sm text-xs font-medium transition-all border border-transparent hover:border-border",
activePage.isLocked
? "text-orange-600 bg-orange-50 hover:bg-orange-100 border-orange-200"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
title={activePage.isLocked ? "解锁编辑" : "锁定编辑 (防止误触)"}
>
{activePage.isLocked ? <Lock size={12} /> : <Unlock size={12} />}
{activePage.isLocked ? "已锁定" : "锁定"}
</button>
{/* Last Updated Info */}
{activePage.updatedAt && (
<div className="text-xs text-muted-foreground/40 select-none flex items-center gap-1 border-l pl-2 ml-1 h-4">
<span>
{new Date(activePage.updatedAt).toLocaleString('zh-CN', {
timeZone: useSettingsStore.getState().timezone || 'Asia/Shanghai',
hour12: false
})}
</span>
</div>
)}
</div>
)}
</div>
</div>
{/* Editor Area or Folder Placeholder */}
{activePage.type === 'folder' ? (
<div className="flex flex-col items-center justify-center h-[50vh] text-muted-foreground animate-in fade-in duration-500">
{/* ... folder icon ... */}
</div>
) : (
<div className="min-h-[60vh] pb-24">
<Editor
content={activePage.content}
onChange={(content) => updatePage(activePage.id, { content })}
onEditorReady={setEditor}
onToggleAI={() => setIsChatOpen(!isChatOpen)}
onExport={() => exportPageAsMarkdown(activePage)}
editable={!activePage.isLocked}
/>
</div>
)}
</div>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground gap-4 animate-in fade-in zoom-in-95 duration-500">
<div className="w-16 h-16 bg-muted/50 rounded-2xl flex items-center justify-center">
<span className="text-4xl">👋</span>
</div>
<div className="text-center space-y-1">
<h3 className="text-lg font-semibold text-foreground">使 NoteAI</h3>
<p className="text-sm text-muted-foreground/80"></p>
</div>
</div>
)}
</main>
<AIChatPanel editor={editor} isOpen={isChatOpen} onClose={() => setIsChatOpen(false)} />
</div>
</ImportProvider>
);
}
+453
View File
@@ -0,0 +1,453 @@
"use client";
import { useState } from "react";
import { useSettingsStore, defaultPrompts, fontOptions, timezoneOptions } from "@/lib/settings-store";
import { Lock, Type, Save, Sparkles } from "lucide-react";
import { ResizableSidebar } from "@/components/sidebar";
import { PromptManagement } from "@/components/settings/prompt-management";
import { ImportProvider } from "@/components/import-context";
export default function SettingsPage() {
// Appearance
const {
fontSize, setFontSize,
fontFamily, setFontFamily,
lineHeight, setLineHeight,
tableLineHeight, setTableLineHeight,
timezone, setTimezone,
aiConfig, setAIConfig,
prompts, resetPrompts, updatePrompt, deletePrompt, addPrompt
} = useSettingsStore();
// Security
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [msg, setMsg] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
setMsg(null);
if (newPassword !== confirmPassword) {
setMsg({ type: 'error', text: "两次输入的新密码不一致" });
return;
}
try {
const res = await fetch("/api/settings/password", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
});
if (res.ok) {
setMsg({ type: 'success', text: "密码修改成功" });
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} else {
const data = await res.json();
setMsg({ type: 'error', text: data.error || "修改失败" });
}
} catch (e) {
setMsg({ type: 'error', text: "系统错误,请重试" });
}
};
return (
<ImportProvider>
<div className="flex h-screen w-full bg-background overflow-hidden relative">
<ResizableSidebar />
<main className="flex-1 h-full flex overflow-hidden bg-background/50">
{/* Settings Navigation Sidebar (Desktop Only) */}
<aside className="w-56 lg:w-64 border-r bg-background/30 hidden md:flex flex-col p-6 overflow-y-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-sm text-muted-foreground mt-1"></p>
</div>
<nav className="space-y-1">
<button
onClick={() => document.getElementById('appearance')?.scrollIntoView({ behavior: 'smooth' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
>
<Type size={16} />
<span></span>
</button>
<button
onClick={() => document.getElementById('ai-config')?.scrollIntoView({ behavior: 'smooth' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
>
<Sparkles size={16} />
<span>AI </span>
</button>
<button
onClick={() => document.getElementById('prompts')?.scrollIntoView({ behavior: 'smooth' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><line x1="9" x2="15" y1="9" y2="15" /><line x1="15" x2="9" y1="9" y2="15" /></svg>
<span></span>
</button>
<button
onClick={() => document.getElementById('security')?.scrollIntoView({ behavior: 'smooth' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
>
<Lock size={16} />
<span></span>
</button>
<button
onClick={() => document.getElementById('backup')?.scrollIntoView({ behavior: 'smooth' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" x2="12" y1="3" y2="15" /></svg>
<span></span>
</button>
</nav>
</aside>
{/* Settings Content Area */}
<div className="flex-1 h-full overflow-y-auto p-4 md:p-12 scroll-smooth">
<div className="max-w-3xl mx-auto space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-20">
{/* Mobile Header (Hidden on Desktop) */}
<div className="md:hidden">
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground mt-2"></p>
</div>
{/* Appearance Section */}
<section id="appearance" className="bg-card border shadow-sm rounded-xl p-6 space-y-6">
<div className="flex items-center gap-2 pb-2 border-b">
<Type size={20} className="text-primary" />
<h2 className="text-xl font-semibold"></h2>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-3">
<label className="text-sm font-medium"></label>
<select
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 transition-all"
value={fontFamily}
onChange={(e) => setFontFamily(e.target.value)}
>
{fontOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="space-y-3">
<label className="text-sm font-medium"> ({fontSize}px)</label>
<div className="flex items-center gap-4">
<input
type="range"
min="12"
max="32"
step="1"
value={fontSize}
onChange={(e) => setFontSize(parseInt(e.target.value))}
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
<span className="w-12 text-center font-mono bg-muted p-1 rounded text-sm">{fontSize}</span>
</div>
</div>
<div className="space-y-3">
<label className="text-sm font-medium"> ({lineHeight})</label>
<div className="flex items-center gap-4">
<input
type="range"
min="1.0"
max="3.0"
step="0.1"
value={lineHeight}
onChange={(e) => setLineHeight(parseFloat(e.target.value))}
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
<span className="w-12 text-center font-mono bg-muted p-1 rounded text-sm">{lineHeight}</span>
</div>
</div>
<div className="space-y-3">
<label className="text-sm font-medium"> ({tableLineHeight})</label>
<div className="flex items-center gap-4">
<input
type="range"
min="1.0"
max="3.0"
step="0.1"
value={tableLineHeight}
onChange={(e) => setTableLineHeight(parseFloat(e.target.value))}
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
<span className="w-12 text-center font-mono bg-muted p-1 rounded text-sm">{tableLineHeight}</span>
</div>
</div>
{/* Timezone takes full width or 2 cols */}
<div className="md:col-span-2 space-y-3">
<label className="text-sm font-medium">Timezone ()</label>
<div className="grid md:grid-cols-2 gap-4">
<select
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 transition-all"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
>
{timezoneOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{/* Future placeholder for more timezone settings or info */}
<div className="hidden md:block"></div>
</div>
</div>
</div>
<div className="p-6 bg-muted/30 border rounded-xl">
<p className="text-sm text-muted-foreground mb-2">:</p>
<div
style={{ fontFamily, fontSize: `${fontSize}px` }}
className="leading-relaxed transition-all"
>
NoteAI
</div>
</div>
</section>
{/* AI Configuration Section */}
<section id="ai-config" className="bg-card border shadow-sm rounded-xl p-6 space-y-6">
<div className="flex items-center gap-2 pb-2 border-b">
<Sparkles size={20} className="text-primary" />
<h2 className="text-xl font-semibold">AI </h2>
</div>
<div className="space-y-4 max-w-xl">
<div className="space-y-2">
<label className="text-sm font-medium">API Base URL</label>
<input
type="text"
value={aiConfig.baseURL}
onChange={(e) => setAIConfig({ baseURL: e.target.value })}
placeholder="https://api.openai.com/v1"
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 text-sm font-mono"
/>
<p className="text-xs text-muted-foreground"> OpenAI </p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">API Key</label>
<input
type="password"
value={aiConfig.apiKey}
onChange={(e) => setAIConfig({ apiKey: e.target.value })}
placeholder="sk-..."
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 text-sm font-mono"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Model Name</label>
<input
type="text"
value={aiConfig.model}
onChange={(e) => setAIConfig({ model: e.target.value })}
placeholder="gpt-3.5-turbo"
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 text-sm font-mono"
/>
</div>
<div className="pt-2">
<button
onClick={async () => {
const btn = document.activeElement as HTMLButtonElement;
const originalText = btn.innerText;
btn.innerText = "Testing...";
btn.disabled = true;
try {
const res = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: aiConfig, // Use current config from store
messages: [{ role: "user", content: "Hi" }]
})
});
if (res.ok) {
const text = await res.text();
// Simple check if we got a stream or text
if (text.length > 0) {
alert("连接成功!API 返回正常。");
} else {
alert("连接成功,但没有返回内容。");
}
} else {
alert(`连接失败: ${res.status} ${res.statusText}`);
}
} catch (e) {
alert("连接出错: " + String(e));
} finally {
btn.innerText = originalText;
btn.disabled = false;
}
}}
className="px-4 py-2 bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-lg text-sm font-medium transition-colors"
>
</button>
</div>
</div>
</section>
{/* Prompt Management Section */}
<div id="prompts" className="bg-card border shadow-sm rounded-xl p-6">
<PromptManagement />
</div>
{/* Security Section */}
<section id="security" className="bg-card border shadow-sm rounded-xl p-6 space-y-6">
<div className="flex items-center gap-2 pb-2 border-b">
<Lock size={20} className="text-primary" />
<h2 className="text-xl font-semibold"></h2>
</div>
<form onSubmit={handlePasswordChange} className="space-y-4 max-w-md">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50"
required
/>
</div>
{msg && (
<div className={`p-3 rounded-lg text-sm ${msg.type === 'success' ? 'bg-green-500/10 text-green-600' : 'bg-red-500/10 text-red-600'}`}>
{msg.text}
</div>
)}
<button
type="submit"
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
>
<Save size={16} />
</button>
</form>
</section>
{/* Data Management Section */}
<section id="backup" className="bg-card border shadow-sm rounded-xl p-6 space-y-6">
<div className="flex items-center gap-2 pb-2 border-b">
<span className="text-xl font-semibold">📦 </span>
</div>
<div className="p-4 border rounded-lg bg-orange-50/50 dark:bg-orange-900/10 border-orange-200 dark:border-orange-800/30">
<h3 className="font-medium text-orange-800 dark:text-orange-300 mb-2 flex items-center gap-2">
</h3>
<ul className="list-disc list-inside text-sm text-orange-700 dark:text-orange-400/80 space-y-1">
<li> Markdown (Zip)</li>
<li><b></b></li>
</ul>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<button
onClick={async () => {
const { pages } = await import('@/lib/store').then(m => m.useEditorStore.getState());
const { exportAllPagesAsZip } = await import('@/lib/export');
try {
await exportAllPagesAsZip(pages);
} catch (e) {
alert("备份失败: " + String(e));
}
}}
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-lg font-medium transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" /></svg>
(Zip)
</button>
<div className="relative">
<input
type="file"
accept=".zip"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!confirm("⚠️ 警告:此操作将永久删除当前所有文档并恢复备份!\n\n确定要继续吗?")) {
e.target.value = ''; // Reset
return;
}
try {
const formData = new FormData();
formData.append("file", file);
const btn = e.target.parentElement?.querySelector('button');
if (btn) btn.innerText = "恢复中...";
const res = await fetch("/api/settings/restore", {
method: "POST",
body: formData
});
if (res.ok) {
alert("恢复成功!页面将刷新。");
window.location.reload();
} else {
const err = await res.json();
alert("恢复失败: " + (err.error || res.statusText));
}
} catch (error) {
alert("系统错误: " + String(error));
} finally {
if (e.target) e.target.value = '';
const btn = e.target.parentElement?.querySelector('button');
if (btn) btn.innerText = "上传备份并恢复";
}
}}
/>
<button className="w-full sm:w-auto flex items-center justify-center gap-2 px-4 py-2.5 bg-destructive/10 text-destructive hover:bg-destructive/20 rounded-lg font-medium transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" x2="12" y1="3" y2="15" /></svg>
</button>
</div>
</div>
</section>
{/* Version Info */}
<div className="py-8 text-center text-xs text-muted-foreground/60">
<p>NoteAI v{process.env.NEXT_PUBLIC_APP_VERSION || '0.1.0'}</p>
</div>
</div>
</div>
</main >
</div >
</ImportProvider>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import React from "react";
import { Sparkles, Wand2 } from "lucide-react";
import { createPortal } from "react-dom";
import { useSettingsStore, AIPrompt } from "@/lib/settings-store";
interface AIAssistProps {
onSuggest: (prompt: AIPrompt) => void;
isOpen: boolean;
onClose: () => void;
}
export function AIAssist({ onSuggest, isOpen, onClose }: AIAssistProps) {
const { prompts } = useSettingsStore();
if (!isOpen) return null;
// Check if we are in browser to avoid CSR mismatches, though useClient handles most.
if (typeof document === 'undefined') return null;
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/10 backdrop-blur-[1px]" onClick={onClose}>
<div
className="bg-popover border rounded-lg shadow-xl w-72 overflow-hidden animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
<div className="p-3 border-b bg-muted/50 flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Sparkles size={16} className="text-blue-500" />
AI
</div>
<div className="p-1.5 space-y-0.5 max-h-[300px] overflow-y-auto scrollbar-thin">
{prompts.map((prompt) => (
<button
key={prompt.id}
onClick={() => onSuggest(prompt)}
className="w-full flex items-start gap-3 p-2.5 hover:bg-muted/80 rounded-md transition-colors text-left group"
>
<div className="mt-0.5 p-1.5 bg-primary/10 rounded-md group-hover:bg-primary/20 transition-colors">
<Wand2 size={16} className="text-primary" />
</div>
<div>
<div className="text-sm font-medium text-foreground">{prompt.label}</div>
<div className="text-[11px] text-muted-foreground">{prompt.description}</div>
</div>
</button>
))}
</div>
<div className="p-2 bg-muted/20 border-t">
<div className="flex items-center justify-between text-[10px] text-muted-foreground px-1">
<span>AI Powered</span>
<span className="opacity-50">ESC to close</span>
</div>
</div>
</div>
</div>,
document.body
);
}
+270
View File
@@ -0,0 +1,270 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Send, User, Bot, Copy, FileText, X, Sparkles, Loader2, Eraser } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/lib/settings-store";
import { Editor } from "@tiptap/react";
import { marked } from "marked";
interface Message {
id: string;
role: "user" | "assistant" | "system";
content: string;
}
interface AIChatPanelProps {
editor: Editor | null;
isOpen: boolean;
onClose: () => void;
}
export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
const { aiConfig } = useSettingsStore();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [useContext, setUseContext] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [tokenCount, setTokenCount] = useState(0);
// ... (rest of the code)
// Simple token estimation: ~4 chars per token for English, ~1 char per token for Chinese
useEffect(() => {
const text = messages.map(m => m.content).join("") + input;
setTokenCount(Math.ceil(text.length * 0.7));
}, [messages, input]);
// Scroll to bottom effect
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const abortControllerRef = useRef<AbortController | null>(null);
const handleStop = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsLoading(false);
};
const handleSend = async () => {
if (!input.trim() || isLoading) return;
if (!aiConfig.apiKey) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: "system", content: "Error: No API Key configured. Please go to Settings > AI Config." }]);
return;
}
const userMsg: Message = { id: Date.now().toString(), role: "user", content: input };
setMessages(prev => [...prev, userMsg]);
setInput("");
setIsLoading(true);
abortControllerRef.current = new AbortController();
const contextMsg: Message | null = (useContext && editor)
? { id: "context", role: "system", content: `Current Document Context:\n${editor.getText().slice(0, 4000)}...` }
: null;
const apiMessages = [
...(contextMsg ? [contextMsg] : []),
...messages.filter(m => m.role !== "system"),
userMsg
];
try {
const res = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: apiMessages.map(m => ({ role: m.role, content: m.content })),
config: aiConfig
}),
signal: abortControllerRef.current.signal
});
if (!res.ok) throw new Error(res.statusText);
if (!res.body) throw new Error("No response body");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let assistantMsg: Message = { id: (Date.now() + 1).toString(), role: "assistant", content: "" };
setMessages(prev => [...prev, assistantMsg]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split("\n\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
const content = parsed.choices[0]?.delta?.content || "";
if (content) {
assistantMsg.content += content;
setMessages(prev => prev.map(m => m.id === assistantMsg.id ? { ...assistantMsg } : m));
}
} catch (e) {
console.error("Parse error", e);
}
}
}
}
} catch (e: any) {
if (e.name === 'AbortError') {
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"}` }]);
}
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
};
const handleInsert = (content: string) => {
if (editor) {
editor.commands.insertContent(content);
}
};
if (!isOpen) return null;
return (
<div className="fixed right-0 top-0 bottom-0 w-96 bg-background border-l shadow-xl z-50 flex flex-col animate-in slide-in-from-right duration-300">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<Sparkles className="text-primary w-5 h-5" />
<div>
<h2 className="font-semibold text-lg leading-none">AI </h2>
<p className="text-[10px] text-muted-foreground mt-0.5 font-mono opacity-80">
{aiConfig.model || "gpt-3.5-turbo"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground mr-2">Est. Tokens: {tokenCount}</span>
<button onClick={onClose} className="p-1 hover:bg-muted rounded-md transition-colors">
<X size={18} />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="text-center text-muted-foreground mt-20">
<p></p>
<p className="text-sm mt-2"></p>
</div>
)}
{messages.map((msg) => (
<div key={msg.id} className={cn("flex gap-3", msg.role === "user" ? "flex-row-reverse" : "")}>
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
)}>
{msg.role === "user" ? <User size={16} /> : <Bot size={16} />}
</div>
<div className={cn(
"group relative max-w-[85%] rounded-lg p-3 text-sm",
msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
msg.role === "system" && "bg-destructive/10 text-destructive w-full max-w-full"
)}>
{/* Markdown Rendering for Assistant */}
{msg.role === "assistant" ? (
<div
className="prose dark:prose-invert prose-sm max-w-none break-words [&>p]:mb-2 [&>ul]:list-disc [&>ul]:pl-4 [&>ol]:list-decimal [&>ol]:pl-4"
dangerouslySetInnerHTML={{ __html: marked.parse(msg.content) as string }}
/>
) : (
<p className="whitespace-pre-wrap">{msg.content}</p>
)}
{/* Assistant Actions */}
{msg.role === "assistant" && !isLoading && (
<div className="absolute -bottom-6 left-0 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
<button
onClick={() => handleInsert(msg.content)}
className="p-1 text-xs bg-background border rounded shadow hover:bg-muted flex items-center gap-1"
title="插入到光标位置"
>
<FileText size={12} />
</button>
<button
onClick={() => navigator.clipboard.writeText(msg.content)}
className="p-1 text-xs bg-background border rounded shadow hover:bg-muted"
title="复制"
>
<Copy size={12} />
</button>
</div>
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-4 border-t bg-muted/20">
<div className="flex items-center justify-between mb-2">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={useContext}
onChange={(e) => setUseContext(e.target.checked)}
className="rounded border-gray-300 text-primary focus:ring-primary"
/>
</label>
<button
onClick={() => setMessages([])}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
title="清空对话"
>
<Eraser size={12} />
</button>
</div>
<div className="relative">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="输入消息..."
className="w-full resize-none rounded-md border bg-background p-3 pr-10 text-sm focus:outline-none focus:ring-1 focus:ring-primary min-h-[80px]"
/>
<button
onClick={isLoading ? handleStop : handleSend}
disabled={!input.trim() && !isLoading}
className={cn(
"absolute right-2 bottom-2 p-2 rounded-md hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-all",
isLoading ? "bg-red-500 text-white" : "bg-primary text-primary-foreground"
)}
title={isLoading ? "停止生成" : "发送"}
>
{isLoading ? <div className="h-4 w-4 bg-current rounded-sm animate-pulse" /> : <Send size={16} />}
</button>
</div>
</div>
</div>
);
}
+290
View File
@@ -0,0 +1,290 @@
"use client";
import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useState, useRef } from "react";
import { AIAssist } from "./ai-assist";
import { Sparkles } from "lucide-react";
import { Toolbar } from "./editor/toolbar";
import { SlashCommand, getSuggestionItems, renderSuggestionItems } from "./editor/slash-command";
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";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import { Callout } from "./editor/extensions/callout";
import { AIMark } from "./editor/extensions/ai-mark";
import { TaskItemComponent } from "./editor/extensions/task-item";
import { useSettingsStore } from "@/lib/settings-store";
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 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';
interface EditorProps {
content: string;
onChange: (content: string) => void;
onEditorReady?: (editor: any) => void;
onToggleAI?: () => void;
onExport?: () => void;
editable?: boolean;
}
export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, editable = true }: EditorProps) {
const [showAI, setShowAI] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const { fontFamily, fontSize } = useSettingsStore();
const editor = useEditor({
extensions: [
Gapcursor,
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
codeBlock: false,
bulletList: {
keepMarks: true,
keepAttributes: false,
},
orderedList: {
keepMarks: true,
keepAttributes: false,
},
}),
SlashCommand.configure({
suggestion: {
items: getSuggestionItems,
render: renderSuggestionItems,
},
}),
CodeBlockLowlight
.extend({
addNodeView() {
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({
multicolor: true,
}),
TaskList,
TaskItem.configure({
nested: true,
}).extend({
addNodeView() {
return ReactNodeViewRenderer(TaskItemComponent)
}
}),
Callout,
AIMark,
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
Image.configure({
inline: true,
allowBase64: true,
}),
Youtube.configure({
controls: false,
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Markdown.configure({
html: true, // Allow HTML input/output
transformPastedText: true, // Auto-transform pasted markdown
transformCopiedText: true, // Auto-transform copied markdown
})
],
content: content,
onUpdate: ({ editor }) => {
if (editor.getHTML() !== content) {
onChange(editor.getHTML());
}
},
editorProps: {
attributes: {
class: "prose prose-zinc dark:prose-invert max-w-none focus:outline-none min-h-[300px]",
style: `font-family: ${fontFamily}; font-size: ${fontSize}px; --editor-line-height: ${useSettingsStore.getState().lineHeight}; --table-line-height: ${useSettingsStore.getState().tableLineHeight};`,
spellcheck: "false",
},
},
editable: editable,
immediatelyRender: false,
});
useEffect(() => {
if (editor) {
editor.setEditable(editable);
}
}, [editor, editable]);
useEffect(() => {
if (editor && content !== editor.getHTML()) {
queueMicrotask(() => {
editor.commands.setContent(content);
});
}
if (editor && onEditorReady) {
onEditorReady(editor);
}
}, [content, editor, onEditorReady]);
const handleStopAI = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsGenerating(false);
};
const handleAISuggest = async (prompt: any) => {
if (!editor) return;
const { aiConfig } = useSettingsStore.getState();
if (!aiConfig.apiKey) {
alert("请先在设置中配置 AI API Key");
return;
}
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, " ");
// Custom prompt logic
const systemPrompt = prompt.systemPrompt || "You are a helpful assistant.";
let userPrompt = selectedText;
if (!userPrompt) {
const pos = from;
userPrompt = editor.state.doc.textBetween(Math.max(0, pos - 1000), pos, "\n");
}
setShowAI(false);
setIsGenerating(true);
abortControllerRef.current = new AbortController();
try {
const response = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: aiConfig,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt }
]
}),
signal: abortControllerRef.current.signal
});
if (!response.ok || !response.body) {
throw new Error(await response.text());
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
editor.chain().focus().insertContent("\n\n").toggleMark('aiMark').run();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
try {
const data = JSON.parse(line.slice(6));
const content = data.choices[0]?.delta?.content;
if (content) {
editor.commands.insertContent(content);
}
} catch (e) {
// ignore
}
}
}
}
editor.chain().focus().insertContent("\n\n").unsetMark('aiMark').run();
} catch (e: any) {
if (e.name === 'AbortError') {
editor.chain().focus().insertContent(" [已停止]").unsetMark('aiMark').run();
} else {
console.error("AI Error", e);
alert("AI 请求失败,请检查配置或网络");
}
} finally {
setIsGenerating(false);
abortControllerRef.current = null;
}
};
if (!editor) return null;
return (
<div className="relative group/editor flex flex-col min-h-full">
<Toolbar editor={editor} onToggleAI={onToggleAI} onExport={onExport} />
{showAI && (
<div className="absolute top-12 right-4 z-50">
<AIAssist
isOpen={showAI}
onSuggest={handleAISuggest}
onClose={() => setShowAI(false)}
/>
</div>
)}
<div className="flex-1 mt-4">
<EditorContent editor={editor} />
</div>
<button
onClick={(e) => {
e.preventDefault();
if (isGenerating) {
handleStopAI();
} else {
setShowAI(!showAI);
}
}}
className={`fixed bottom-8 right-8 p-3 rounded-full shadow-lg hover:scale-110 transition-transform z-40 ${isGenerating
? "bg-red-500 text-white animate-pulse"
: "bg-primary text-primary-foreground"
}`}
title={isGenerating ? "停止生成 (Stop)" : "AI 助手 (AI Assist)"}
>
{isGenerating ? <div className="h-5 w-5 bg-current rounded-sm" /> : <Sparkles size={20} />}
</button>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { NodeViewContent, NodeViewWrapper, NodeViewProps } from "@tiptap/react";
import React, { useMemo } from "react";
import { Copy, Check, Trash2, ArrowUpToLine, ArrowDownToLine } from "lucide-react";
import { LanguageSelector } from "./language-selector";
export function CodeBlockComponent({
node,
updateAttributes,
extension,
deleteNode,
editor,
getPos,
}: NodeViewProps) {
const { language: defaultLanguage } = node.attrs;
const { textContent } = node;
const [copied, setCopied] = React.useState(false);
const languages = extension.options.lowlight ? extension.options.lowlight.listLanguages() : [];
// Custom logic to prioritize common languages and ensure 'html' is present
const popularLanguages = ['html', 'javascript', 'typescript', 'css', 'json', 'python', 'java', 'go', 'bash', 'sql'];
const otherLanguages = languages.filter((lang: string) => !popularLanguages.includes(lang) && lang !== 'xml'); // Hide xml if we show html
// We map 'html' to 'xml' when saving if needed, but for the selector we want 'html'
// Actually highlight.js understands 'html' if aliased.
// Let's just create a flat list for the selector, but maybe we want groups?
// For now, just a flat sorted list: Popular + Others
const displayLanguages = Array.from(new Set([...popularLanguages, ...otherLanguages]));
const insertAbove = () => {
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos !== 'number') return;
editor.chain()
.insertContentAt(pos, { type: 'paragraph' })
.focus()
.setTextSelection(pos + 1)
.scrollIntoView()
.run();
}
};
const insertBelow = () => {
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos !== 'number') return;
const targetPos = pos + node.nodeSize;
editor.chain()
.insertContentAt(targetPos, { type: 'paragraph' })
.focus()
.setTextSelection(targetPos + 1)
.scrollIntoView()
.run();
}
};
const handleCopy = () => {
const code = textContent;
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// Calculate line numbers
const lineNumbers = useMemo(() => {
const lines = textContent.split('\n').length;
return Array.from({ length: lines }, (_, i) => i + 1);
}, [textContent]);
return (
<NodeViewWrapper className="relative group code-block rounded-lg border border-border/40 bg-[#252529] my-4 shadow-sm overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 bg-[#2f2f35] border-b border-white/5 text-xs select-none text-zinc-400">
<div className="flex items-center gap-2">
<LanguageSelector
language={defaultLanguage || 'null'}
onChange={(val) => {
// Map helpful aliases if necessary, though highlight.js is smart.
// But Tiptap highlight extension might strict check?
// Usually 'html' works fine if the grammar is loaded.
// Since we use 'common', 'xml' is the grammar. 'html' is alias.
// If I pass 'html', does lowlight find it? Yes if alias is registered.
// Earlier we tried to register alias but reverted.
// Let's force 'xml' if 'html' is selected to be safe, OR rely on 'xml' being the backend.
// Actually, let's keep it simple: Pass 'val'.
updateAttributes({ language: val })
}}
languages={displayLanguages}
/>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={insertAbove}
className="hover:text-white hover:bg-white/10 p-1.5 rounded transition-all"
title="在上方插入"
>
<ArrowUpToLine size={14} />
</button>
<button
onClick={insertBelow}
className="hover:text-white hover:bg-white/10 p-1.5 rounded transition-all"
title="在下方插入"
>
<ArrowDownToLine size={14} />
</button>
<div className="w-px h-3 bg-white/10 mx-1" />
<button
onClick={handleCopy}
className="hover:text-white hover:bg-white/10 p-1.5 rounded transition-all"
title="复制"
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
<button
onClick={() => deleteNode()}
className="hover:text-red-400 hover:bg-white/10 p-1.5 rounded transition-all"
title="删除"
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Code Area with Line Numbers - Use grid for better alignment control */}
<div className="relative grid grid-cols-[auto_1fr] bg-[#252529] font-mono text-sm leading-6">
{/* Line Numbers Gutter */}
<div
className="py-4 px-2 text-right select-none border-r border-white/5 bg-[#252529] text-zinc-500"
style={{ minWidth: '2.5rem' }}
contentEditable={false}
>
{lineNumbers.map((line) => (
<div key={line} className="px-1">{line}</div>
))}
</div>
{/* Actual Code Content */}
<pre className="!bg-transparent overflow-x-auto !p-0 !my-0 !border-0 text-zinc-300 scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
<NodeViewContent className="block min-w-full !p-4 !bg-transparent !whitespace-pre outline-none !font-mono !text-sm !leading-6" />
</pre>
</div>
</NodeViewWrapper>
);
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import React, { Component } from "react";
import { cn } from "@/lib/utils";
export class CommandList extends Component<{
items: any[];
command: any;
editor: any;
range: any;
}, {
selectedIndex: number;
}> {
constructor(props: any) {
super(props);
this.state = {
selectedIndex: 0,
};
}
componentDidUpdate(prevProps: any) {
if (this.props.items !== prevProps.items) {
this.setState({
selectedIndex: 0,
});
}
}
onKeyDown({ event }: { event: KeyboardEvent }) {
if (event.key === "ArrowUp") {
this.upHandler();
return true;
}
if (event.key === "ArrowDown") {
this.downHandler();
return true;
}
if (event.key === "Enter") {
this.enterHandler();
return true;
}
return false;
}
upHandler() {
this.setState({
selectedIndex:
(this.state.selectedIndex + this.props.items.length - 1) %
this.props.items.length,
});
}
downHandler() {
this.setState({
selectedIndex: (this.state.selectedIndex + 1) % this.props.items.length,
});
}
enterHandler() {
this.selectItem(this.state.selectedIndex);
}
selectItem(index: number) {
const items = this.getFlattenedItems();
const item = items[index];
if (item) {
this.props.command(item);
}
}
getFlattenedItems() {
const grouped = this.groupItems(this.props.items);
return Object.values(grouped).flat();
}
groupItems(items: any[]) {
const groups: Record<string, any[]> = {
"基础": [],
"排版": [],
"插入": [],
"高级": [],
"列表": [],
"样式": []
};
items.forEach(item => {
if (item.group && groups[item.group]) {
groups[item.group].push(item);
} else {
groups["基础"].push(item);
}
});
// Remove empty groups
return Object.keys(groups)
.filter(key => groups[key].length > 0)
.reduce((obj, key) => {
// @ts-ignore
obj[key] = groups[key];
return obj;
}, {} as Record<string, any[]>);
}
render() {
const { items } = this.props;
const { selectedIndex } = this.state;
const grouped = this.groupItems(items);
let globalIndex = 0;
return (
<div className="z-50 h-auto max-h-[500px] w-[800px] overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950/95 shadow-2xl backdrop-blur-lg animate-in fade-in zoom-in-95 duration-200">
<div className="columns-3 gap-2 p-3 space-y-4">
{Object.entries(grouped).map(([groupName, groupItems]: [string, any[]]) => (
<div key={groupName} className="break-inside-avoid mb-4">
<div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-500 select-none mb-1">
{groupName}
</div>
<div className="space-y-0.5">
{groupItems.map((item: any, index: number) => {
const currentGlobalIndex = globalIndex++;
const isSelected = selectedIndex === currentGlobalIndex;
return (
<button
className={cn(
"flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm transition-all duration-200 select-none group",
isSelected
? "bg-zinc-800 text-zinc-100"
: "text-zinc-400 hover:bg-zinc-900/50 hover:text-zinc-200"
)}
key={index}
onClick={() => this.selectItem(currentGlobalIndex)}
>
<div className="flex items-center gap-2">
<div className={cn(
"flex h-6 w-6 items-center justify-center rounded bg-zinc-900 shadow-sm border border-zinc-800",
isSelected ? "border-zinc-700 bg-zinc-700" : "group-hover:border-zinc-700"
)}>
{item.icon}
</div>
<span className="text-xs font-medium">
{item.title}
</span>
</div>
{item.shortcut && (
<span className="text-[10px] font-mono text-zinc-600 group-hover:text-zinc-500 ml-4">
{item.shortcut}
</span>
)}
</button>
);
})}
</div>
</div>
))}
</div>
{items.length === 0 && (
<div className="flex flex-col items-center justify-center py-6 text-zinc-500 w-full">
<p className="text-xs">No matching commands</p>
</div>
)}
</div>
);
}
}
@@ -0,0 +1,29 @@
import { Mark, mergeAttributes } from '@tiptap/core';
export interface AIMarkOptions {
HTMLAttributes: Record<string, any>;
}
export const AIMark = Mark.create<AIMarkOptions>({
name: 'aiMark',
addOptions() {
return {
HTMLAttributes: {
class: 'ai-content',
},
};
},
parseHTML() {
return [
{
tag: 'span.ai-content',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
});
@@ -0,0 +1,77 @@
import { NodeViewContent, NodeViewWrapper, NodeViewProps } from '@tiptap/react'
import { Info, CheckCircle, AlertTriangle, XCircle, Lightbulb } from 'lucide-react'
import { cn } from '@/lib/utils'
export const CalloutComponent = (props: NodeViewProps) => {
const { node, updateAttributes } = props
const type = node.attrs.type || 'info'
const styles = {
info: {
borderColor: 'border-blue-200 dark:border-blue-500/30',
bgColor: 'bg-blue-50 dark:bg-blue-500/10',
textColor: 'text-blue-900 dark:text-blue-100', // Dark text for light mode
icon: <Info className="w-5 h-5 text-blue-600 dark:text-blue-400" />,
placeholder: 'Info'
},
success: {
borderColor: 'border-green-200 dark:border-green-500/30',
bgColor: 'bg-green-50 dark:bg-green-500/10',
textColor: 'text-green-900 dark:text-green-100',
icon: <CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" />,
placeholder: 'Success'
},
warning: {
borderColor: 'border-orange-200 dark:border-yellow-500/30',
bgColor: 'bg-orange-50 dark:bg-yellow-500/10',
textColor: 'text-orange-900 dark:text-yellow-100', // Use orange for better visibility in light mode
icon: <AlertTriangle className="w-5 h-5 text-orange-600 dark:text-yellow-400" />,
placeholder: 'Warning'
},
error: {
borderColor: 'border-red-200 dark:border-red-500/30',
bgColor: 'bg-red-50 dark:bg-red-500/10',
textColor: 'text-red-900 dark:text-red-100',
icon: <XCircle className="w-5 h-5 text-red-600 dark:text-red-400" />,
placeholder: 'Error'
},
idea: {
borderColor: 'border-purple-200 dark:border-purple-500/30',
bgColor: 'bg-purple-50 dark:bg-purple-500/10',
textColor: 'text-purple-900 dark:text-purple-100',
icon: <Lightbulb className="w-5 h-5 text-purple-600 dark:text-purple-400" />,
placeholder: 'Idea'
}
}
const currentStyle = styles[type as keyof typeof styles] || styles.info
return (
<NodeViewWrapper className="my-4">
<div
className={cn(
"flex gap-3 p-4 rounded-lg border transition-all",
currentStyle.borderColor,
currentStyle.bgColor
)}
>
<div
className="flex-shrink-0 mt-0.5 cursor-pointer select-none"
contentEditable={false}
title="Click to cycle type"
onClick={() => {
const types = Object.keys(styles)
const currentIndex = types.indexOf(type)
const nextType = types[(currentIndex + 1) % types.length]
updateAttributes({ type: nextType })
}}
>
{currentStyle.icon}
</div>
<div className={cn("flex-1 min-w-0 prose-p:my-0 prose-p:leading-normal", currentStyle.textColor)}>
<NodeViewContent />
</div>
</div>
</NodeViewWrapper>
)
}
@@ -0,0 +1,85 @@
import { mergeAttributes, Node } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { CalloutComponent } from './callout-component'
export interface CalloutOptions {
HTMLAttributes: Record<string, any>
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
callout: {
setCallout: () => ReturnType
toggleCallout: () => ReturnType
unsetCallout: () => ReturnType
}
}
}
export const Callout = Node.create<CalloutOptions>({
name: 'callout',
group: 'block',
content: 'block+',
draggable: true,
addOptions() {
return {
HTMLAttributes: {},
}
},
addAttributes() {
return {
type: {
default: 'info',
parseHTML: element => element.getAttribute('data-type'),
renderHTML: attributes => {
return { 'data-type': attributes.type }
},
},
}
},
parseHTML() {
return [
{
tag: 'div[data-type="callout"]',
},
// Backwards compatibility or paste handling could go here
{
tag: 'div[class^="callout-"]',
}
]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': 'callout' }), 0]
},
addNodeView() {
return ReactNodeViewRenderer(CalloutComponent)
},
addCommands() {
return {
setCallout:
() =>
({ commands }) => {
return commands.wrapIn(this.name)
},
toggleCallout:
() =>
({ commands }) => {
return commands.toggleWrap(this.name)
},
unsetCallout:
() =>
({ commands }) => {
return commands.lift(this.name)
},
}
},
})
@@ -0,0 +1,28 @@
import { NodeViewWrapper, NodeViewContent, NodeViewProps } from '@tiptap/react'
import React from 'react'
import { cn } from '@/lib/utils'
export const TaskItemComponent: React.FC<NodeViewProps> = ({ node, updateAttributes, extension }) => {
return (
<NodeViewWrapper as="li" data-type="taskItem" className="relative !pl-7 !my-1 task-item-custom group">
{/* Absolute Checkbox Wrapper */}
<div
className="absolute left-0 top-[-3.5px] !flex !items-center !justify-center !h-[1.5em] !w-5 select-none"
contentEditable={false}
>
<input
type="checkbox"
checked={node.attrs.checked}
onChange={event => updateAttributes({ checked: event.target.checked })}
className="peer h-4 w-4 !m-0 rounded border border-zinc-400 bg-transparent accent-green-600 cursor-pointer appearance-none checked:bg-green-600 checked:border-green-600 checked:bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20fill%3D%22white%22%3E%3Cpath%20d%3D%22M12.207%204.793a1%201%200%20010%201.414l-5%205a1%201%200%2001-1.414%200l-2-2a1%201%200%20011.414-1.414L6.5%209.086l4.293-4.293a1%201%200%20011.414%200z%22%2F%3E%3C%2Fsvg%3E')] bg-center bg-no-repeat bg-[length:100%_100%] transition-all"
/>
</div>
{/* Text Content */}
<NodeViewContent className={cn(
"!min-w-0 transition-opacity !p-0 [&>p]:!m-0 [&>p]:!p-0 [&>p]:!leading-normal",
node.attrs.checked && "opacity-50 text-zinc-500 line-through decoration-zinc-500"
)} />
</NodeViewWrapper>
)
}
+104
View File
@@ -0,0 +1,104 @@
"use client"
import * as React from "react"
import { Check, ChevronsUpDown, Search } from "lucide-react"
import { Command } from "cmdk"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
// Minimal Popover implementation since we don't have full shadcn setup
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-zinc-900 border-zinc-800 text-zinc-200",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
interface LanguageSelectorProps {
language: string
onChange: (value: string) => void
languages: string[]
}
export function LanguageSelector({ language, onChange, languages }: LanguageSelectorProps) {
const [open, setOpen] = React.useState(false)
const [search, setSearch] = React.useState("")
const filteredLanguages = languages.filter((lang) =>
lang.toLowerCase().includes(search.toLowerCase())
)
// Manual 'all' option plus filtered list
const items = ["auto", ...filteredLanguages]
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
role="combobox"
aria-expanded={open}
className="flex items-center justify-between gap-2 px-2 py-1 text-xs font-medium text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/50 rounded transition-colors outline-none"
onClick={(e) => e.stopPropagation()} // Prevent editor focus loss if possible, though specific to Tiptap needs
>
{language || "Start coding..."}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0 bg-[#1e1e1e] border-[#333] text-zinc-300 shadow-xl rounded-lg overflow-hidden">
<Command className="flex h-full w-full flex-col overflow-hidden rounded-md bg-transparent">
<div className="flex items-center border-b border-[#333] px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
className="flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Search language..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="max-h-[200px] overflow-y-auto p-1 scrollbar-thin scrollbar-thumb-zinc-700">
{items.length === 0 && (
<div className="py-6 text-center text-sm">No language found.</div>
)}
{items.map((framework) => (
<div
key={framework}
onClick={() => {
onChange(framework === "auto" ? "null" : framework)
setOpen(false)
setSearch("")
}}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-zinc-800 hover:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
language === framework ? "bg-zinc-800 text-white" : ""
)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
language === framework ? "opacity-100" : "opacity-0"
)}
/>
{framework === "null" ? "Auto" : framework}
</div>
))}
</div>
</Command>
</PopoverContent>
</Popover>
)
}
+325
View File
@@ -0,0 +1,325 @@
import { Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import tippy, { Instance as TippyInstance } from "tippy.js";
import { CommandList } from "./command-list";
import {
Heading1, Heading2, Heading3, Heading4, Heading5, Heading6,
List, ListOrdered, Quote,
Code, CheckSquare, Minus, Info, Type,
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Highlighter
} from "lucide-react";
export const SlashCommand = Extension.create({
name: "slashCommand",
addOptions() {
return {
suggestion: {
char: "/",
command: ({ editor, range, props }: any) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
export const getSuggestionItems = ({ query }: { query: string }) => {
const items = [
// --- 基础块 ---
{
title: "一级标题",
description: "Big section heading",
group: "基础",
icon: <Heading1 size={16} />,
shortcut: "Ctrl+Alt+1",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
},
},
{
title: "二级标题",
description: "Medium section heading",
group: "基础",
icon: <Heading2 size={16} />,
shortcut: "Ctrl+Alt+2",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
},
},
{
title: "三级标题",
description: "Small section heading",
group: "基础",
icon: <Heading3 size={16} />,
shortcut: "Ctrl+Alt+3",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
},
},
{
title: "普通文本",
description: "Just start writing with plain text",
group: "基础",
icon: <Type size={16} />,
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setParagraph().run();
},
},
// --- 列表 & 引用 ---
{
title: "无序列表",
description: "Create a simple bullet list",
group: "列表",
icon: <List size={16} />,
shortcut: "-",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "有序列表",
description: "Create a numbered list",
group: "列表",
icon: <ListOrdered size={16} />,
shortcut: "1.",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: "任务列表",
description: "Track tasks",
group: "列表",
icon: <CheckSquare size={16} />,
shortcut: "Ctrl+L",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "引述",
description: "Capture a quote",
group: "列表",
icon: <Quote size={16} />,
shortcut: ">",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setBlockquote().run();
},
},
// --- 插入 ---
{
title: "代码块",
description: "Capture a code snippet",
group: "插入",
icon: <Code size={16} />,
shortcut: "```",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setCodeBlock().run();
},
},
{
title: "分割线",
description: "Horizontal rule",
group: "插入",
icon: <Minus size={16} />,
shortcut: "---",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: "高亮块 (Callout)",
description: "Callout box",
group: "插入",
icon: <Info size={16} />,
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setCallout().run();
},
},
{
title: "表格",
description: "Insert a 3x3 table",
group: "插入",
icon: <div className="text-xs font-bold border rounded px-1">T</div>, // Use generic icon if lucide missing or import specific
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
},
},
{
title: "图片",
description: "Insert image from URL",
group: "插入",
icon: <div className="text-xs font-bold border rounded px-1">I</div>,
command: ({ editor, range }: any) => {
const url = window.prompt('Image URL:');
if (url) {
editor.chain().focus().deleteRange(range).setImage({ src: url }).run();
}
},
},
{
title: "YouTube",
description: "Embed YouTube video",
group: "插入",
icon: <div className="text-xs font-bold border rounded px-1">Y</div>,
command: ({ editor, range }: any) => {
const url = window.prompt('YouTube URL:');
if (url) {
editor.chain().focus().deleteRange(range).setYoutubeVideo({ src: url }).run();
}
},
},
// --- 样式 ---
{
title: "粗体",
description: "Bold text",
group: "样式",
icon: <Bold size={16} />,
shortcut: "Ctrl+B",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleBold().run();
},
},
{
title: "斜体",
description: "Italic text",
group: "样式",
icon: <Italic size={16} />,
shortcut: "Ctrl+I",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleItalic().run();
},
},
{
title: "下划线",
description: "Underline text",
group: "样式",
icon: <UnderlineIcon size={16} />,
shortcut: "Ctrl+U",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleUnderline().run();
},
},
{
title: "删除线",
description: "Strike text",
group: "样式",
icon: <Strikethrough size={16} />,
shortcut: "Ctrl+Shift+S",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleStrike().run();
},
},
{
title: "高亮 (Mark)",
description: "Highlight text",
group: "样式",
icon: <Highlighter size={16} />,
shortcut: "Alt+D",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleHighlight().run();
},
},
// --- 排版补充 ---
{
title: "四级标题",
group: "基础",
icon: <Heading4 size={16} />,
shortcut: "Ctrl+Alt+4",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run();
},
},
{
title: "五级标题",
group: "基础",
icon: <Heading5 size={16} />,
shortcut: "Ctrl+Alt+5",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run();
},
},
{
title: "六级标题",
group: "基础",
icon: <Heading6 size={16} />,
shortcut: "Ctrl+Alt+6",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run();
},
},
];
return items.filter((item) => item.title.toLowerCase().includes(query.toLowerCase()));
};
export const renderSuggestionItems = () => {
let component: ReactRenderer;
let popup: TippyInstance[];
return {
onStart: (props: any) => {
component = new ReactRenderer(CommandList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: any) => {
component.updateProps(props);
if (!props.clientRect) {
return;
}
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: any) => {
if (props.event.key === "Escape") {
popup[0].hide();
return true;
}
// @ts-ignore
return component.ref?.onKeyDown(props);
},
onExit: () => {
popup?.[0]?.destroy();
component.destroy();
},
};
};
+224
View File
@@ -0,0 +1,224 @@
"use client";
import { type Editor } from "@tiptap/react";
import {
Bold, Italic, Strikethrough, Code,
Heading1, Heading2, Heading3,
List, ListOrdered, Quote,
Undo, Redo, Minus, RemoveFormatting,
AlignLeft, AlignCenter, AlignRight, CheckSquare, Link as LinkIcon, Underline as UnderlineIcon, Image as ImageIcon,
Table as TableIcon, Sparkles, FileDown, SquareCode
} from "lucide-react";
import { cn } from "@/lib/utils";
interface ToolbarProps {
editor: Editor | null;
onToggleAI?: () => void;
onExport?: () => void;
}
export function Toolbar({ editor, onToggleAI, onExport }: ToolbarProps) {
if (!editor) {
return null;
}
const items = [
{
icon: Bold,
title: "加粗",
action: () => editor.chain().focus().toggleBold().run(),
isActive: editor.isActive("bold"),
},
{
icon: Italic,
title: "斜体",
action: () => editor.chain().focus().toggleItalic().run(),
isActive: editor.isActive("italic"),
},
{
icon: Strikethrough,
title: "删除线",
action: () => editor.chain().focus().toggleStrike().run(),
isActive: editor.isActive("strike"),
},
{
icon: Code,
title: "行内代码",
action: () => editor.chain().focus().toggleCode().run(),
isActive: editor.isActive("code"),
},
{
icon: SquareCode,
title: "代码块",
action: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: editor.isActive("codeBlock"),
},
{
divider: true,
},
{
icon: Heading1,
title: "标题 1",
action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: editor.isActive("heading", { level: 1 }),
},
{
icon: Heading2,
title: "标题 2",
action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: editor.isActive("heading", { level: 2 }),
},
{
icon: Heading3,
title: "标题 3",
action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: editor.isActive("heading", { level: 3 }),
},
{
divider: true,
},
{
icon: List,
title: "无序列表",
action: () => editor.chain().focus().toggleBulletList().run(),
isActive: editor.isActive("bulletList"),
},
{
icon: ListOrdered,
title: "有序列表",
action: () => editor.chain().focus().toggleOrderedList().run(),
isActive: editor.isActive("orderedList"),
},
{
icon: Quote,
title: "引用",
action: () => editor.chain().focus().toggleBlockquote().run(),
isActive: editor.isActive("blockquote"),
},
{
divider: true,
},
{
icon: Minus,
title: "水平分割线",
action: () => editor.chain().focus().setHorizontalRule().run(),
},
{
icon: RemoveFormatting,
title: "清除格式",
action: () => editor.chain().focus().unsetAllMarks().clearNodes().run(),
},
{
divider: true,
},
{
icon: AlignLeft,
title: "左对齐",
action: () => editor.chain().focus().setTextAlign("left").run(),
isActive: editor.isActive({ textAlign: "left" }),
},
{
icon: AlignCenter,
title: "居中对齐",
action: () => editor.chain().focus().setTextAlign("center").run(),
isActive: editor.isActive({ textAlign: "center" }),
},
{
icon: AlignRight,
title: "右对齐",
action: () => editor.chain().focus().setTextAlign("right").run(),
isActive: editor.isActive({ textAlign: "right" }),
},
{
divider: true,
},
{
icon: CheckSquare,
title: "任务列表",
action: () => editor.chain().focus().toggleTaskList().run(),
isActive: editor.isActive("taskList"),
},
{
icon: LinkIcon,
title: "链接",
action: () => {
const previousUrl = editor.getAttributes("link").href;
const url = window.prompt("URL", previousUrl);
if (url === null) return;
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
},
isActive: editor.isActive("link"),
},
{
icon: TableIcon,
title: "插入表格",
action: () => {
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
},
},
{
icon: ImageIcon,
title: "插入图片",
action: () => {
const url = window.prompt("Image URL");
if (url) editor.chain().focus().setImage({ src: url }).run();
},
},
{
divider: true,
},
{
icon: Undo,
title: "撤销",
action: () => editor.chain().focus().undo().run(),
disabled: !editor.can().undo(),
},
{
icon: Redo,
title: "重做",
action: () => editor.chain().focus().redo().run(),
disabled: !editor.can().redo(),
},
{
divider: true,
},
{
icon: Sparkles,
title: "AI 助手",
action: () => onToggleAI?.(),
},
{
icon: FileDown,
title: "导出 Markdown",
action: () => onExport?.(),
},
];
return (
<div className="flex items-center gap-1 p-1 bg-background/50 backdrop-blur border-b sticky top-0 z-10 overflow-x-auto no-scrollbar">
{items.map((item, index) => (
item.divider ? (
<div key={index} className="w-px h-6 bg-border mx-1" />
) : (
<button
key={index}
onClick={item.action}
disabled={item.disabled}
className={cn(
"p-2 rounded-md transition-colors hover:bg-muted text-muted-foreground hover:text-foreground",
item.isActive && "bg-accent text-accent-foreground",
item.disabled && "opacity-50 cursor-not-allowed"
)}
title={item.title}
>
{item.icon && <item.icon size={16} />}
</button>
)
))}
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
"use client";
import React, { createContext, useContext, useRef, useState, useCallback } from "react";
import { useEditorStore } from "@/lib/store";
import { marked } from "marked";
import JSZip from "jszip";
interface ImportContextType {
triggerImport: (parentId?: string | null) => void;
isImporting: boolean;
}
const ImportContext = createContext<ImportContextType | undefined>(undefined);
export function ImportProvider({ children }: { children: React.ReactNode }) {
const [isImporting, setIsImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const targetParentIdRef = useRef<string | null>(null);
const { fetchPages } = useEditorStore();
const triggerImport = useCallback((parentId: string | null = null) => {
targetParentIdRef.current = parentId;
if (fileInputRef.current) {
fileInputRef.current.value = ''; // Reset
fileInputRef.current.click();
}
}, []);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsImporting(true);
try {
if (file.name.toLowerCase().endsWith('.zip')) {
await handleZipImport(file);
} else {
await handleSingleFileImport(file);
}
} catch (error) {
console.error("Import failed", error);
alert("导入失败,请检查文件");
} finally {
setIsImporting(false);
fetchPages();
}
};
const handleSingleFileImport = async (file: File) => {
const text = await file.text();
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({
title,
content: html,
parentId: targetParentIdRef.current,
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 rootRes = await fetch('/api/pages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: rootName,
parentId: targetParentIdRef.current,
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 folderPaths = new Set<string>();
zip.forEach((relativePath, zipEntry) => {
if (relativePath.startsWith('__MACOSX') || relativePath.includes('/.') || relativePath.startsWith('.')) return; // Skip hidden/mac files
if (zipEntry.dir) {
// Remove trailing slash
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++) {
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<string, string>();
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;
});
// Create folders sequentially
for (const folderPath of sortedFolders) {
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
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({
title: name,
parentId: parentId,
type: 'folder'
}),
});
if (res.ok) {
const folder = await res.json();
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('/');
const fileName = parts.pop() || "";
const parentPath = parts.join('/');
const parentId = pathMap.get(parentPath);
if (!parentId) continue;
if (!fileName.endsWith('.md') && !fileName.endsWith('.txt')) continue; // Verify extension again
const contentMsg = await file.async('string');
const title = fileName.replace(/\.md$/i, '').replace(/\.txt$/i, '');
const html = await marked.parse(contentMsg);
await fetch('/api/pages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
content: html,
parentId: parentId,
type: 'file'
}),
});
}
};
return (
<ImportContext.Provider value={{ triggerImport, isImporting }}>
{children}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
accept=".md,.txt,.zip"
/>
</ImportContext.Provider>
);
}
export function useImport() {
const context = useContext(ImportContext);
if (!context) {
throw new Error("useImport must be used within an ImportProvider");
}
return context;
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useSearchStore } from "@/lib/search-store";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useEditorStore } from "@/lib/store";
import { Command } from "cmdk";
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)) {
e.preventDefault();
setOpen(!isOpen);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, [isOpen, setOpen]);
const runCommand = (command: () => void) => {
setOpen(false);
command();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in duration-200">
<div
className="fixed inset-0"
onClick={() => setOpen(false)}
/>
<div className="relative w-full max-w-lg bg-popover border rounded-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<Command className="w-full">
<div className="flex items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Command.Input
placeholder="搜索文档..."
value={query}
onValueChange={setQuery}
className="flex h-12 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<Command.List className="max-h-[300px] overflow-y-auto p-2">
<Command.Empty className="py-6 text-center text-sm text-muted-foreground"></Command.Empty>
<Command.Group heading="文档">
{pages.map((page) => (
<Command.Item
key={page.id}
value={`${page.title} ${page.tags?.join(" ")}`} // Search by title AND tags
onSelect={() => runCommand(() => setActivePageId(page.id))}
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm cursor-pointer hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground transition-colors"
>
{page.type === 'folder' ? <Folder size={14} className="text-blue-400" /> : <FileText size={14} />}
<span>{page.title || "无标题"}</span>
</Command.Item>
))}
</Command.Group>
</Command.List>
</Command>
</div>
</div>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { Plus, Trash2, Edit2, X, Sparkles } from "lucide-react";
import { useSettingsStore, AIPrompt } from "@/lib/settings-store";
export function PromptManagement() {
const { prompts, addPrompt, updatePrompt, deletePrompt, resetPrompts } = useSettingsStore();
const [editing, setEditing] = useState<AIPrompt | null>(null);
const [isNew, setIsNew] = useState(false);
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (!editing) return;
if (isNew) {
addPrompt(editing);
} else {
updatePrompt(editing.id, editing);
}
setEditing(null);
setIsNew(false);
};
const startNew = () => {
setEditing({
id: `custom_${Date.now()}`,
label: "新功能",
description: "描述...",
systemPrompt: "你是...",
});
setIsNew(true);
};
return (
<section className="space-y-6">
<div className="flex items-center justify-between gap-2 pb-2 border-b">
<div className="flex items-center gap-2">
<Sparkles size={20} className="text-primary" />
<h2 className="text-xl font-semibold"> AI </h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={startNew}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors"
>
<Plus size={14} />
</button>
<button
onClick={resetPrompts}
className="px-3 py-1.5 text-xs font-medium border rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
</button>
</div>
</div>
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
{prompts.map((prompt) => (
<div key={prompt.id} className="flex items-start justify-between p-3 bg-muted/30 border rounded-lg group">
<div>
<h3 className="font-medium text-sm flex items-center gap-2">
{prompt.label}
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground font-mono">
{prompt.id}
</span>
</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
{prompt.description}
</p>
</div>
<div className="flex items-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => {
setEditing(prompt);
setIsNew(false);
}}
className="p-1.5 hover:bg-muted rounded text-muted-foreground hover:text-foreground"
>
<Edit2 size={14} />
</button>
<button
onClick={() => deletePrompt(prompt.id)}
className="p-1.5 hover:bg-red-500/10 rounded text-muted-foreground hover:text-red-500"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
</div>
{/* Edit/Create Dialog (Simple Overlay) */}
{editing && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-background border rounded-xl shadow-2xl w-full max-w-lg p-6 space-y-4 animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between pb-2 border-b">
<h3 className="font-semibold text-lg">{isNew ? "新建 AI 功能" : "编辑 AI 功能"}</h3>
<button onClick={() => setEditing(null)}>
<X size={20} />
</button>
</div>
<form onSubmit={handleSave} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium"> (Label)</label>
<input
type="text"
value={editing.label}
onChange={e => setEditing({ ...editing!, label: e.target.value })}
className="w-full p-2 bg-muted/50 border rounded text-sm"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"> (ID)</label>
<input
type="text"
value={editing.id}
onChange={e => setEditing({ ...editing!, id: e.target.value })}
className="w-full p-2 bg-muted/50 border rounded text-sm"
readOnly={!isNew}
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="text"
value={editing.description}
onChange={e => setEditing({ ...editing!, description: e.target.value })}
className="w-full p-2 bg-muted/50 border rounded text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"> (System Prompt)</label>
<textarea
value={editing.systemPrompt}
onChange={e => setEditing({ ...editing!, systemPrompt: e.target.value })}
className="w-full h-32 p-2 bg-muted/50 border rounded text-sm font-mono leading-relaxed"
required
/>
<p className="text-xs text-muted-foreground">
AI
</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setEditing(null)}
className="px-4 py-2 text-sm font-medium bg-muted hover:bg-muted/80 rounded"
>
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 rounded"
>
</button>
</div>
</form>
</div>
</div>
)}
</section>
);
}
+434
View File
@@ -0,0 +1,434 @@
"use client";
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 { useTheme } from "next-themes";
import { TreeView } from "./sidebar/tree-view";
import Link from "next/link";
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 { arrayMove } from "@dnd-kit/sortable";
function RootDropZone() {
const { setNodeRef, isOver } = useDroppable({
id: 'root-drop-zone',
});
return (
<div
ref={setNodeRef}
className={cn(
"flex-1 min-h-[50px] rounded-sm flex items-center justify-center text-xs text-muted-foreground/0 hover:text-muted-foreground/50 border-2 border-dashed border-transparent transition-all",
isOver && "bg-accent/30 border-primary/20 text-primary/70"
)}
>
{isOver ? "移动到根目录" : ""}
</div>
);
}
export function ResizableSidebar() {
// State
const [width, setWidth] = useState(256);
const [isCollapsed, setIsCollapsed] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLElement>(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());
localStorage.setItem('sidebar-collapsed', isCollapsed.toString());
}, [width, isCollapsed]);
// Resizing Logic
const startResizing = React.useCallback(() => {
setIsResizing(true);
}, []);
const stopResizing = React.useCallback(() => {
setIsResizing(false);
}, []);
const resize = React.useCallback((mouseMoveEvent: MouseEvent) => {
if (isResizing) {
const newWidth = mouseMoveEvent.clientX;
if (newWidth >= 200 && newWidth <= 480) {
setWidth(newWidth);
}
}
}, [isResizing]);
useEffect(() => {
window.addEventListener("mousemove", resize);
window.addEventListener("mouseup", stopResizing);
return () => {
window.removeEventListener("mousemove", resize);
window.removeEventListener("mouseup", stopResizing);
};
}, [resize, stopResizing]);
return (
<aside
ref={sidebarRef}
className={cn(
"hidden md:flex border-r bg-secondary h-screen flex-col group/sidebar relative z-20",
isResizing ? "transition-none" : "transition-[width] duration-300 ease-in-out",
isCollapsed && "w-[0px] border-none"
)}
style={{ width: isCollapsed ? 0 : width }}
>
{/* Collapse Toggle (Desktop only) */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className={cn(
"absolute -right-3 top-6 z-50 p-1 rounded-full bg-border text-foreground hover:bg-primary hover:text-primary-foreground border shadow-sm transition-all opacity-0 group-hover/sidebar:opacity-100",
isCollapsed && "opacity-100 -right-8" // Visible when collapsed
)}
title={isCollapsed ? "展开侧边栏" : "收起侧边栏"}
>
{isCollapsed ? <PanelLeftOpen size={14} /> : <PanelLeftClose size={14} />}
</button>
<div className={cn("flex-1 flex flex-col overflow-hidden", isCollapsed ? "hidden" : "visible")}>
<SidebarContent />
</div>
{/* Resize Handle */}
{!isCollapsed && (
<div
className="absolute right-0 top-0 w-1 h-full cursor-col-resize hover:bg-primary/50 transition-colors z-40"
onMouseDown={startResizing}
/>
)}
</aside>
);
}
export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }) {
const { pages, fetchPages, addPage, isLoading, movePage, reorderPages } = useEditorStore();
const { setOpen, openSearchWithTag } = useSearchStore();
const { triggerImport, isImporting } = useImport();
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
const router = useRouter();
// Collapsible State
const [isWorkspaceOpen, setIsWorkspaceOpen] = React.useState(true);
const [isTagsOpen, setIsTagsOpen] = React.useState(true);
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh(); // Refresh to ensure middleware runs
} catch (error) {
console.error("Logout failed", error);
// Fallback redirect
router.push('/login');
}
};
// DnD Sensors
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // Require 8px movement to start drag (prevents accidental drags on click)
},
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
if (activeId === overId) return;
// Check if moving to root
if (overId === 'root-drop-zone') {
movePage(activeId, null);
return;
}
// Check relationship: Reparenting vs Reordering
const activeNode = pages.find(p => p.id === activeId);
const overNode = pages.find(p => p.id === overId);
if (!activeNode || !overNode) return;
// If 'over' is a folder and logic dictates "drop INTO", do move
// But dnd-kit sortable also triggers over events for items.
// We need a way to distinguish "Into Folder" vs "Sort Next To".
// Often 'center' collision is sort, maybe explicit 'folder' drop zone needed?
// For now, let's assume if dropping on a Folder that is NOT same parent, it implies move ONTO.
// If dropping on a sibling (same parent), it is sort.
// Prioritize moving file into folder if dropping ON a folder
if (activeNode.type === 'file' && overNode.type === 'folder' && activeNode.parentId !== overNode.id) {
movePage(activeId, overId);
return;
}
const isSameParent = activeNode.parentId === overNode.parentId;
if (isSameParent) {
// Reordering
// Get all siblings
const siblings = pages.filter(p => p.parentId === activeNode.parentId).sort((a, b) => (a.order || 0) - (b.order || 0));
const oldIndex = siblings.findIndex(p => p.id === activeId);
const newIndex = siblings.findIndex(p => p.id === overId);
if (oldIndex !== newIndex) {
const newOrder = arrayMove(siblings, oldIndex, newIndex);
// Assign new order values
const updates = newOrder.map((p, index) => ({ id: p.id, order: index }));
reorderPages(updates);
}
} else {
// Reparenting
if (overNode.type === 'folder') {
// Drop ON folder -> Move INTO folder
movePage(activeId, overId);
} else {
// Drop ON file (in different group) -> Move to SIBLING (adopt overNode's parent)
// This allows moving back to root by dropping on a root file
movePage(activeId, overNode.parentId);
}
}
};
const [activeDragItem, setActiveDragItem] = React.useState<Page | null>(null);
const onDragStart = (event: any) => {
const item = pages.find(p => p.id === event.active.id);
if (item) setActiveDragItem(item);
}
useEffect(() => {
setMounted(true);
fetchPages();
}, []);
// Handle clicks that should close mobile sidebar
const handleItemClick = () => {
if (onCloseMobile) onCloseMobile();
}
return (
<div className="flex flex-col h-full bg-secondary">
{/* Header */}
<div className="p-4 flex items-center justify-between">
<div className="flex items-center gap-2 font-semibold text-foreground/80 hover:text-foreground transition-colors cursor-pointer">
<div className="w-6 h-6 bg-primary rounded-md flex items-center justify-center text-primary-foreground text-[10px] shadow-sm">AI</div>
<span className="tracking-tight">NoteAI</span>
</div>
<div className="flex items-center gap-1 transition-opacity">
<button
onClick={() => addPage(null, 'file')}
className="p-1.5 hover:bg-muted-foreground/10 rounded-md text-muted-foreground transition-all hover:text-foreground"
title="新建文件"
>
<FilePlus size={16} />
</button>
<button
onClick={() => addPage(null, 'folder')}
className="p-1.5 hover:bg-muted-foreground/10 rounded-md text-muted-foreground transition-all hover:text-foreground"
title="新建文件夹"
>
<FolderPlus size={16} />
</button>
{/* Mobile Close Button (Added to layout properly) */}
{onCloseMobile && (
<button
onClick={onCloseMobile}
className="p-1.5 ml-1 bg-muted/50 hover:bg-destructive/10 text-muted-foreground hover:text-destructive rounded-md transition-all"
title="关闭菜单"
>
<PanelLeftClose size={16} />
</button>
)}
</div>
</div>
{/* Main Actions */}
<div className="px-3 space-y-1 mb-4">
<SearchCommand />
<button
onClick={() => {
setOpen(true);
handleItemClick(); // Close sidebar on mobile when opening global search
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-muted-foreground/10 rounded-md text-muted-foreground transition-colors group"
>
<Search size={16} className="group-hover:text-foreground" />
<span className="group-hover:text-foreground"></span>
<kbd className="ml-auto pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
<span className="text-xs"></span>K
</kbd>
</button>
</div>
{/* Pages List (Tree View) */}
{/* Pages List (Tree View) */}
<div className="flex-1 overflow-y-auto px-2 space-y-1">
<button
onClick={() => setIsWorkspaceOpen(!isWorkspaceOpen)}
className="w-full px-2 py-1.5 text-xs font-medium text-muted-foreground/70 uppercase tracking-wider mb-1 flex items-center justify-between hover:text-foreground transition-colors group/workspace"
>
<span className="flex items-center gap-1.5">
{isWorkspaceOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<Layers size={14} className="opacity-70" />
</span>
<Plus
size={14}
className="opacity-0 group-hover/workspace:opacity-100 hover:bg-muted/50 rounded cursor-pointer"
onClick={(e) => {
e.stopPropagation();
addPage(null, 'file');
setIsWorkspaceOpen(true);
}}
/>
</button>
{isWorkspaceOpen && (
<>
{isLoading ? (
<div className="px-4 text-xs text-muted-foreground animate-pulse">...</div>
) : pages.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-muted-foreground">
<p className="mb-2"></p>
<button
onClick={() => addPage(null, 'file')}
className="text-primary hover:underline"
>
?
</button>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={onDragStart}
onDragEnd={handleDragEnd}
>
<div onClick={handleItemClick} className="flex-1 flex flex-col min-h-0">
<TreeView pages={pages} parentId={null} />
<RootDropZone />
</div>
<DragOverlay>
{activeDragItem ? (
<div className="px-2 py-1 text-sm rounded-md bg-popover shadow-lg border opacity-90 flex items-center gap-2">
{activeDragItem.icon ? (
<span className="text-[16px] leading-none shrink-0 w-4 h-4 flex items-center justify-center grayscale-[0.2]">{activeDragItem.icon}</span>
) : activeDragItem.type === 'folder' ? (
<Folder size={16} className="text-indigo-500 fill-indigo-500/20 shrink-0" />
) : (
<FileText size={16} className="text-muted-foreground/70 shrink-0" />
)}
{activeDragItem.title}
</div>
) : null}
</DragOverlay>
</DndContext>
)}
</>
)}
</div>
{/* Tags List */}
<div className="px-2 py-2 border-t border-border/40">
<button
onClick={() => setIsTagsOpen(!isTagsOpen)}
className="w-full px-2 py-1.5 text-xs font-medium text-muted-foreground/70 uppercase tracking-wider mb-1 flex items-center justify-between hover:text-foreground transition-colors group/tags"
>
<span className="flex items-center gap-1.5">
{isTagsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<TagIcon size={13} className="opacity-70" />
</span>
</button>
{isTagsOpen && (
<div className="flex flex-wrap gap-1 px-2">
{Array.from(new Set(pages.flatMap(p => p.tags || []))).map(tag => {
const colors = getTagColor(tag);
return (
<button
key={tag}
onClick={() => {
openSearchWithTag(tag);
handleItemClick();
}}
className={cn(
"inline-flex items-center gap-1 px-2.5 py-1 rounded-[3px] text-[11px] font-medium transition-colors border shadow-sm",
colors.bg, colors.text, colors.border,
"hover:scale-105 active:scale-95 duration-200"
)}
title={`Filter by ${tag}`}
>
<Hash size={12} className="opacity-70" />
{tag}
</button>
);
})}
{pages.flatMap(p => p.tags || []).length === 0 && (
<span className="text-[10px] text-muted-foreground/50 italic px-1"></span>
)}
</div>
)}
</div>
{/* Footer Actions */}
<div className="p-3 border-t bg-muted/20 backdrop-blur-sm space-y-1">
<button
onClick={() => triggerImport(null)}
disabled={isImporting}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-background rounded-md text-muted-foreground transition-all shadow-sm border border-transparent hover:border-border disabled:opacity-50"
>
<Upload size={16} />
<span>{isImporting ? '导入中...' : '导入文档'}</span>
</button>
<div className="flex items-center gap-1">
<Link
href="/settings"
onClick={handleItemClick}
className="flex-1 flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-background rounded-md text-muted-foreground transition-all shadow-sm border border-transparent hover:border-border"
>
<Settings size={16} />
<span></span>
</Link>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 text-muted-foreground hover:text-foreground hover:bg-background rounded-md transition-all shadow-sm border border-transparent hover:border-border"
title={mounted ? (theme === 'dark' ? '切换到明亮模式' : '切换到暗黑模式') : '切换主题'}
>
{mounted ? (theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />) : <Sun size={16} className="opacity-0" />}
</button>
<button
onClick={handleLogout}
className="p-2 text-muted-foreground hover:text-destructive hover:bg-background rounded-md transition-all shadow-sm border border-transparent hover:border-border"
title="退出登录"
>
<LogOut size={16} />
</button>
</div>
</div>
</div>
);
}
+229
View File
@@ -0,0 +1,229 @@
"use client";
import { Page, useEditorStore } from "@/lib/store";
import { ChevronRight, FileText, Folder, FolderOpen, MoreHorizontal, File, Plus, Trash2, FilePlus, FolderPlus, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { useState } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { exportPageAsMarkdown, exportFolderAsZip } from "@/lib/export";
import { useRouter, usePathname } from "next/navigation";
import { useImport } from "@/components/import-context";
import { Upload } from "lucide-react";
import { useDroppable } from "@dnd-kit/core";
import { useSortable, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
interface TreeViewProps {
pages: Page[];
parentId: string | null;
level?: number;
}
function TreeNode({ node, pages, level, expanded, toggleExpand }: {
node: Page,
pages: Page[],
level: number,
expanded: Record<string, boolean>,
toggleExpand: (id: string, e: React.MouseEvent) => void
}) {
const { activePageId, setActivePageId, addPage, deletePage } = useEditorStore();
const router = useRouter();
const pathname = usePathname();
const { triggerImport } = useImport();
const isFolder = node.type === 'folder';
const hasChildren = pages.some(p => p.parentId === node.id);
const isExpanded = expanded[node.id];
// DnD Hooks - Sorting
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: node.id,
data: { type: node.type, title: node.title, parentId: node.parentId }
});
const style = {
paddingLeft: `${level * 12 + 8}px`,
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div key={node.id} style={style} ref={setNodeRef} {...attributes} {...listeners}>
<div
className={cn(
"group flex items-center justify-between px-2 py-1 text-sm rounded-md transition-colors cursor-pointer select-none border border-transparent",
activePageId === node.id
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
// Use simple hover effect for "drop over" visual or rely on drag overlay
)}
onClick={() => {
setActivePageId(node.id);
if (pathname !== '/') router.push('/');
}}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<button
onPointerDown={(e) => e.stopPropagation()} // Prevent drag start on expand button
onClick={(e) => {
e.stopPropagation();
toggleExpand(node.id, e);
}}
className={cn(
"p-0.5 rounded-sm hover:bg-muted-foreground/20 transition-colors",
!hasChildren && !isFolder && "invisible"
)}
>
<ChevronRight
size={16}
className={cn("transition-transform shrink-0 text-muted-foreground/50", isExpanded && "rotate-90")}
/>
</button>
{node.icon ? (
<span className="text-[16px] leading-none shrink-0 w-4 h-4 flex items-center justify-center grayscale-[0.2]">{node.icon}</span>
) : isFolder ? (
isExpanded ? (
<FolderOpen size={16} className="text-indigo-500 fill-indigo-500/20 shrink-0" />
) : (
<Folder size={16} className="text-indigo-500 fill-indigo-500/20 shrink-0" />
)
) : (
<FileText size={16} className="text-muted-foreground/70 shrink-0" />
)}
<span className="truncate text-[14px] leading-none mb-0.5">{node.title}</span>
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
onPointerDown={(e) => e.stopPropagation()} // Prevent drag
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 p-1 hover:bg-muted-foreground/20 rounded text-muted-foreground transition-opacity"
>
<MoreHorizontal size={14} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="min-w-[120px] bg-popover text-popover-foreground rounded-md border shadow-md p-1 z-50 animate-in fade-in zoom-in-95" align="start">
<DropdownMenu.Item
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
onClick={(e) => {
e.stopPropagation();
const targetParentId = node.type === 'folder' ? node.id : node.parentId;
// If creating sibling (node.type !== 'folder'), insert after current node
// Note: Order might be float/int. We just increment for now.
// Ideally we'd find the mid-point, but simpler logic: just +1 and let sort handle basic "after"
const order = node.type === 'folder' ? undefined : (node.order || 0) + 1;
addPage(targetParentId, 'file', undefined, order);
if (node.type === 'folder') toggleExpand(node.id, e);
}}
>
<FilePlus size={14} />
<span></span>
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
onClick={(e) => {
e.stopPropagation();
const targetParentId = node.type === 'folder' ? node.id : node.parentId;
const order = node.type === 'folder' ? undefined : (node.order || 0) + 1;
addPage(targetParentId, 'folder', undefined, order);
if (node.type === 'folder') toggleExpand(node.id, e);
}}
>
<FolderPlus size={14} />
<span></span>
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
onClick={(e) => {
e.stopPropagation();
const targetParentId = node.type === 'folder' ? node.id : node.parentId;
triggerImport(targetParentId);
if (node.type === 'folder') toggleExpand(node.id, e);
}}
>
<Upload size={14} />
<span>...</span>
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
onClick={(e) => {
e.stopPropagation();
if (node.type === 'folder') {
exportFolderAsZip(node.id, pages, node.title);
} else {
exportPageAsMarkdown(node);
}
}}
>
<Download size={14} />
<span></span>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px bg-muted my-1" />
<DropdownMenu.Item
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-red-50 text-red-600 cursor-pointer outline-none"
onClick={(e) => {
e.stopPropagation();
if (confirm("确定要删除吗?此操作无法撤销。")) {
deletePage(node.id);
}
}}
>
<Trash2 size={14} />
<span></span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
{(isExpanded || hasChildren) && isExpanded && (
<TreeView pages={pages} parentId={node.id} level={level + 1} />
)}
</div>
);
}
export function TreeView({ pages, parentId, level = 0 }: TreeViewProps) {
// Sort nodes by order field
const nodes = pages
.filter(p => p.parentId === parentId)
.sort((a, b) => (a.order || 0) - (b.order || 0));
// Simple state for expansion
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const toggleExpand = (id: string, e: React.MouseEvent) => {
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
};
if (nodes.length === 0) return null;
return (
<SortableContext
items={nodes.map(n => n.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-0.5">
{nodes.map(node => (
<TreeNode
key={node.id}
node={node}
pages={pages}
level={level}
expanded={expanded}
toggleExpand={toggleExpand}
/>
))}
</div>
</SortableContext>
);
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
+27
View File
@@ -0,0 +1,27 @@
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
import { promisify } from "util";
const scryptAsync = promisify(scrypt);
/**
* Hashes a password using scrypt.
* Format: salt:hash
*/
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString("hex");
const derivedKey = (await scryptAsync(password, salt, 64)) as Buffer;
return `${salt}:${derivedKey.toString("hex")}`;
}
/**
* Verifies a password against a stored hash.
*/
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
const [salt, key] = storedHash.split(":");
if (!salt || !key) return false;
const keyBuffer = Buffer.from(key, "hex");
const derivedKey = (await scryptAsync(password, salt, 64)) as Buffer;
return timingSafeEqual(keyBuffer, derivedKey);
}
+186
View File
@@ -0,0 +1,186 @@
import TurndownService from "turndown";
import JSZip from "jszip";
import { saveAs } from "file-saver";
import { Page } from "./store";
import { useSettingsStore } from "./settings-store";
import { gfm } from "turndown-plugin-gfm";
// Initialize Turndown service
const turndownService = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
});
// Use GFM plugin for tables
turndownService.use(gfm);
// Configure Turndown to handle some common elements better if needed
turndownService.addRule('strikethrough', {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
filter: ['del', 's', 'strike'] as any,
replacement: function (content: string) {
return '~' + content + '~';
}
});
// Task List Support (Simple)
turndownService.addRule('taskListItems', {
filter: function (node: HTMLElement) {
return node.nodeName === 'LI' && node.className.includes('task-list-item');
},
replacement: function (content: string, node: HTMLElement) {
const isChecked = node.getAttribute('data-checked') === 'true';
return (isChecked ? '- [x] ' : '- [ ] ') + content.replace(/^- /, '') + '\n';
}
});
/**
* Converts HTML content to Markdown
*/
export function htmlToMarkdown(html: string): string {
if (!html) return "";
return turndownService.turndown(html);
}
// Helper to sanitize filenames
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
}
// Helper to escape YAML strings
function escapeYamlString(str: string): string {
return str.replace(/"/g, '\\"');
}
/**
* Converts a page to Markdown with Frontmatter
*/
export function pageToMarkdown(page: Page): string {
const rawMarkdown = htmlToMarkdown(page.content || "");
const tags = page.tags ? JSON.parse(typeof page.tags === 'string' ? page.tags : JSON.stringify(page.tags)) : [];
// Format Date to Local Time with Timezone
const dateObj = page.updatedAt ? new Date(page.updatedAt) : new Date();
// Get timezone from store
const { timezone } = useSettingsStore.getState();
// Use Intl to format date in the target timezone
// We want format: YYYY-MM-DDTHH:mm:ss
// Since Intl isn't extremely flexible with custom formats without parts, we can use "sv-SE" locale which is usually ISO-like (YYYY-MM-DD HH:mm:ss)
// Or just construct it using parts.
let localDate;
try {
const options: Intl.DateTimeFormatOptions = {
timeZone: timezone || "Asia/Shanghai",
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
// sv-SE outputs YYYY-MM-DD HH:mm:ss
const parts = new Intl.DateTimeFormat('sv-SE', options).formatToParts(dateObj);
const getPart = (type: string) => parts.find(p => p.type === type)?.value || "";
localDate = `${getPart('year')}-${getPart('month')}-${getPart('day')}T${getPart('hour')}:${getPart('minute')}:${getPart('second')}`;
} catch (e) {
// Fallback if timezone is invalid
localDate = dateObj.toISOString().slice(0, 19);
}
// Construct Frontmatter
const frontmatter = [
"---",
`title: "${escapeYamlString(page.title)}"`,
`id: "${page.id}"`,
`date: "${localDate}"`,
`tags: [${tags.map((t: string) => `"${escapeYamlString(t)}"`).join(", ")}]`,
`order: ${page.order || 0}`,
"---",
"",
""
].join("\n");
return frontmatter + rawMarkdown;
}
/**
* Exports a single page as a Markdown file
*/
export function exportPageAsMarkdown(page: Page) {
const markdown = pageToMarkdown(page);
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
const filename = sanitizeFilename(page.title || "Untitled");
saveAs(blob, `${filename}.md`);
}
/**
* Exports a folder and its contents as a Zip file
*/
export async function exportFolderAsZip(folderId: string, allPages: Page[], folderName: string) {
const zip = new JSZip();
const cleanFolderName = sanitizeFilename(folderName);
const folder = zip.folder(cleanFolderName);
if (!folder) {
console.error("Failed to create zip folder");
return;
}
addPagesToZipRecursive(folder, folderId, allPages);
try {
const content = await zip.generateAsync({ type: "blob" });
saveAs(content, `${folderName}.zip`);
} catch (e) {
console.error("Failed to generate zip", e);
}
}
/**
* Exports ALL pages as a Zip backup
*/
export async function exportAllPagesAsZip(allPages: Page[]) {
const zip = new JSZip();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16); // YYYY-MM-DDTHH-mm
const zipName = `noteai-backup-${timestamp}`;
// Root level pages (parentId is null)
addPagesToZipRecursive(zip, null, allPages);
try {
const content = await zip.generateAsync({ type: "blob" });
saveAs(content, `${zipName}.zip`);
} catch (e) {
console.error("Failed to generate backup zip", e);
throw e;
}
}
// Helper to recursively add pages to zip
function addPagesToZipRecursive(currentFolder: JSZip, parentId: string | null, allPages: Page[]) {
const children = allPages.filter(p => p.parentId === parentId);
children.forEach(page => {
const cleanTitle = sanitizeFilename(page.title);
if (page.type === 'folder') {
const subFolder = currentFolder.folder(cleanTitle);
if (subFolder) {
// Determine if we should look for children with this page's ID
addPagesToZipRecursive(subFolder, page.id, allPages);
}
} else {
const markdown = pageToMarkdown(page);
// Handle duplicate filenames by appending ID if necessary?
// For simplicity, we assume titles are unique enough or zip handles overwrite (last wins).
// Better: just append a counter if exists, but JSZip might overwrite.
currentFolder.file(`${cleanTitle}.md`, markdown);
}
});
}
+54
View File
@@ -0,0 +1,54 @@
export function exportToMarkdown(title: string, htmlContent: string) {
// Simple HTML to MD conversion (basic elements)
let md = htmlContent
.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n')
.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n')
.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n')
.replace(/<p>(.*?)<\/p>/gi, '$1\n\n')
.replace(/<strong>(.*?)<\/strong>/gi, '**$1**')
.replace(/<em>(.*?)<\/em>/gi, '*$1*')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/&nbsp;/g, ' ')
.replace(/<.*?>/g, ''); // Strip remaining tags
const blob = new Blob([`# ${title}\n\n${md}`], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'untitled'}.md`;
a.click();
URL.revokeObjectURL(url);
}
export function importFromMarkdown(file: File): Promise<{ title: string; content: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
const lines = text.split('\n');
let title = "导入文档";
let content = "";
if (lines[0]?.startsWith('# ')) {
title = lines[0].substring(2).trim();
content = lines.slice(1).join('\n');
} else {
content = text;
}
// Simple MD to HTML for TipTap (basic)
const html = content
.split('\n\n')
.map(p => {
if (p.startsWith('## ')) return `<h2>${p.substring(3)}</h2>`;
if (p.startsWith('### ')) return `<h3>${p.substring(4)}</h3>`;
return `<p>${p}</p>`;
})
.join('');
resolve({ title, content: html });
};
reader.onerror = reject;
reader.readAsText(file);
});
}
+11
View File
@@ -0,0 +1,11 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: ['query'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
+17
View File
@@ -0,0 +1,17 @@
import { create } from "zustand";
interface SearchState {
isOpen: boolean;
query: string;
setOpen: (open: boolean) => void;
setQuery: (query: string) => void;
openSearchWithTag: (tag: string) => void;
}
export const useSearchStore = create<SearchState>((set) => ({
isOpen: false,
query: "",
setOpen: (isOpen) => set({ isOpen }),
setQuery: (query) => set({ query }),
openSearchWithTag: (tag) => set({ isOpen: true, query: tag }),
}));
+119
View File
@@ -0,0 +1,119 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export interface AIPrompt {
id: string;
label: string;
description: string;
systemPrompt: string;
}
export const defaultPrompts: AIPrompt[] = [
{
id: "improve",
label: "润色内容",
description: "提升语言表达质量",
systemPrompt: "你是专业的写作润色助手。请优化以下文本,使其更流畅、专业,具有吸引力,同时保持原意。请直接输出优化后的文本,不要包含任何解释或'优化后'等前缀。",
},
{
id: "complete",
label: "续写内容",
description: "基于上下文自动续写",
systemPrompt: "你是富有创意的写作助手。请根据以下上下文逻辑,自然地续写一段内容,风格与前文保持一致。",
},
{
id: "summarize",
label: "生成摘要",
description: "提取核心观点",
systemPrompt: "你是专业的文档总结助手。请为以下文本生成一份简明扼要的摘要,提取核心观点,使用中文回答。",
},
];
interface SettingsState {
fontFamily: string;
fontSize: number; // in pixels (e.g. 16)
lineHeight: number; // e.g. 1.5
tableLineHeight: number; // e.g. 1.2
timezone: string;
// AI Config
aiConfig: {
apiKey: string;
baseURL: string;
model: string;
};
// Custom Prompts
prompts: AIPrompt[];
setFontFamily: (font: string) => void;
setFontSize: (size: number) => void;
setLineHeight: (height: number) => void;
setTableLineHeight: (height: number) => void;
setTimezone: (timezone: string) => void;
setAIConfig: (config: Partial<{ apiKey: string; baseURL: string; model: string }>) => void;
// Prompt Actions
setPrompts: (prompts: AIPrompt[]) => void;
addPrompt: (prompt: AIPrompt) => void;
updatePrompt: (id: string, prompt: Partial<AIPrompt>) => void;
deletePrompt: (id: string) => void;
resetPrompts: () => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
fontFamily: "Inter", // Default
fontSize: 16,
lineHeight: 1.5,
tableLineHeight: 1.2,
timezone: "Asia/Shanghai",
aiConfig: {
apiKey: "",
baseURL: "https://api.openai.com/v1",
model: "gpt-3.5-turbo",
},
prompts: defaultPrompts,
setFontFamily: (font) => set({ fontFamily: font }),
setFontSize: (size) => set({ fontSize: size }),
setLineHeight: (height) => set({ lineHeight: height }),
setTableLineHeight: (height) => set({ tableLineHeight: height }),
setTimezone: (timezone) => set({ timezone }),
setAIConfig: (config) => set((state) => ({ aiConfig: { ...state.aiConfig, ...config } })),
setPrompts: (prompts) => set({ prompts }),
addPrompt: (prompt) => set((state) => ({ prompts: [...state.prompts, prompt] })),
updatePrompt: (id, prompt) => set((state) => ({
prompts: state.prompts.map((p) => (p.id === id ? { ...p, ...prompt } : p)),
})),
deletePrompt: (id) => set((state) => ({ prompts: state.prompts.filter((p) => p.id !== id) })),
resetPrompts: () => set({ prompts: defaultPrompts }),
}),
{
name: "noteai-settings",
}
)
);
export const fontOptions = [
{ label: "Default (Inter)", value: "Inter" },
{ label: "Serif", value: "serif" },
{ label: "Mono", value: "monospace" },
{ label: "System", value: "system-ui" },
];
export const timezoneOptions = [
{ label: "UTC", value: "UTC" },
{ label: "Shanghai (UTC+8)", value: "Asia/Shanghai" },
{ label: "Tokyo (UTC+9)", value: "Asia/Tokyo" },
{ label: "New York (UTC-5/EDT)", value: "America/New_York" },
{ label: "London (UTC+0/BST)", value: "Europe/London" },
{ label: "Paris (UTC+1/CEST)", value: "Europe/Paris" },
{ label: "Sydney (UTC+10/AEST)", value: "Australia/Sydney" },
{ label: "Dubai (UTC+4)", value: "Asia/Dubai" },
{ label: "Los Angeles (UTC-8/PDT)", value: "America/Los_Angeles" },
];
+156
View File
@@ -0,0 +1,156 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export interface Page {
id: string;
title: string;
content: string;
parentId: string | null;
type: "file" | "folder";
children?: Page[]; // For tree view structures (virtual field)
createdAt?: string;
updatedAt?: string;
icon?: string | null;
tags?: string[];
order?: number;
isLocked?: boolean;
}
interface EditorState {
pages: Page[];
activePageId: string | null;
isLoading: boolean;
// Actions
fetchPages: () => Promise<void>;
setActivePageId: (id: string | null) => void;
addPage: (parentId?: string | null, type?: "file" | "folder", initialData?: { title: string, content: string }, order?: number) => Promise<void>;
updatePage: (id: string, data: Partial<Page>) => Promise<void>;
deletePage: (id: string) => Promise<void>;
movePage: (id: string, parentId: string | null) => Promise<void>;
reorderPages: (data: { id: string, order: number }[]) => Promise<void>;
}
export const useEditorStore = create<EditorState>()(
persist(
(set, get) => ({
pages: [],
activePageId: null,
isLoading: false,
fetchPages: async () => {
set({ isLoading: true });
try {
const res = await fetch("/api/pages");
if (res.ok) {
const data = await res.json();
set({ pages: data });
// If no active page, maybe select the first one? Or leave null.
// Persistence will handle restoring activePageId if it exists.
}
} catch (e) {
console.error("Failed to fetch pages", e);
} finally {
set({ isLoading: false });
}
},
setActivePageId: (id) => set({ activePageId: id }),
addPage: async (parentId = null, type = "file", initialData, order) => {
try {
const res = await fetch("/api/pages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: initialData?.title || "无标题",
content: initialData?.content || "",
tags: [],
parentId,
type,
order
}),
});
if (res.ok) {
const newPage = await res.json();
set((state) => ({
pages: [newPage, ...state.pages],
activePageId: newPage.id // Auto select new page
}));
}
} catch (e) {
console.error("Failed to create page", e);
}
},
updatePage: async (id, data) => {
// Optimistic update
set((state) => ({
pages: state.pages.map((p) => (p.id === id ? { ...p, ...data, updatedAt: new Date().toISOString() } : p)),
}));
// Debounce logic could be added here, but for now direct call
try {
await fetch(`/api/pages/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
} catch (e) {
console.error("Failed to update page", e);
}
},
movePage: async (id, parentId) => {
// Reuse updatePage logic
await get().updatePage(id, { parentId });
},
deletePage: async (id) => {
// Optimistic delete
const currentActive = get().activePageId;
set((state) => ({
pages: state.pages.filter((p) => p.id !== id),
activePageId: currentActive === id ? null : currentActive
}));
try {
await fetch(`/api/pages/${id}`, {
method: "DELETE",
});
} catch (e) {
console.error("Failed to delete page", e);
// Rollback could be added here
}
},
reorderPages: async (updates) => {
// Optimistic update
set((state) => {
const newPages = [...state.pages];
updates.forEach(({ id, order }) => {
const page = newPages.find(p => p.id === id);
if (page) page.order = order;
});
// Re-sort locally? Or just trust UI to sort based on updated order property
// It's safer if 'pages' remains the master list
return { pages: newPages };
});
try {
await fetch("/api/pages/reorder", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updates }),
});
} catch (e) {
console.error("Failed to reorder pages", e);
}
},
}),
{
name: "editor-storage",
partialize: (state) => ({ activePageId: state.activePageId }),
}
)
);
+32
View File
@@ -0,0 +1,32 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
const TAG_COLORS = [
{ bg: "bg-red-100 dark:bg-red-900/40", text: "text-red-700 dark:text-red-300", border: "border-red-200 dark:border-red-800" },
{ bg: "bg-orange-100 dark:bg-orange-900/40", text: "text-orange-700 dark:text-orange-300", border: "border-orange-200 dark:border-orange-800" },
{ bg: "bg-amber-100 dark:bg-amber-900/40", text: "text-amber-700 dark:text-amber-300", border: "border-amber-200 dark:border-amber-800" },
{ bg: "bg-green-100 dark:bg-green-900/40", text: "text-green-700 dark:text-green-300", border: "border-green-200 dark:border-green-800" },
{ bg: "bg-emerald-100 dark:bg-emerald-900/40", text: "text-emerald-700 dark:text-emerald-300", border: "border-emerald-200 dark:border-emerald-800" },
{ bg: "bg-teal-100 dark:bg-teal-900/40", text: "text-teal-700 dark:text-teal-300", border: "border-teal-200 dark:border-teal-800" },
{ bg: "bg-cyan-100 dark:bg-cyan-900/40", text: "text-cyan-700 dark:text-cyan-300", border: "border-cyan-200 dark:border-cyan-800" },
{ bg: "bg-blue-100 dark:bg-blue-900/40", text: "text-blue-700 dark:text-blue-300", border: "border-blue-200 dark:border-blue-800" },
{ bg: "bg-indigo-100 dark:bg-indigo-900/40", text: "text-indigo-700 dark:text-indigo-300", border: "border-indigo-200 dark:border-indigo-800" },
{ bg: "bg-violet-100 dark:bg-violet-900/40", text: "text-violet-700 dark:text-violet-300", border: "border-violet-200 dark:border-violet-800" },
{ bg: "bg-purple-100 dark:bg-purple-900/40", text: "text-purple-700 dark:text-purple-300", border: "border-purple-200 dark:border-purple-800" },
{ bg: "bg-fuchsia-100 dark:bg-fuchsia-900/40", text: "text-fuchsia-700 dark:text-fuchsia-300", border: "border-fuchsia-200 dark:border-fuchsia-800" },
{ bg: "bg-pink-100 dark:bg-pink-900/40", text: "text-pink-700 dark:text-pink-300", border: "border-pink-200 dark:border-pink-800" },
{ bg: "bg-rose-100 dark:bg-rose-900/40", text: "text-rose-700 dark:text-rose-300", border: "border-rose-200 dark:border-rose-800" },
];
export function getTagColor(tag: string) {
let hash = 0;
for (let i = 0; i < tag.length; i++) {
hash = tag.charCodeAt(i) + ((hash << 5) - hash);
}
const index = Math.abs(hash % TAG_COLORS.length);
return TAG_COLORS[index];
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export default function proxy(request: NextRequest) {
const authCookie = request.cookies.get('auth');
const isLoginPage = request.nextUrl.pathname === '/login';
if (!authCookie && !isLoginPage) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (authCookie && isLoginPage) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
+4
View File
@@ -0,0 +1,4 @@
declare module 'turndown-plugin-gfm' {
import TurndownService from 'turndown';
export function gfm(service: TurndownService): void;
}
+59
View File
@@ -0,0 +1,59 @@
import { type Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/lib/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [
require("@tailwindcss/typography"),
require("tailwindcss-animate"),
],
} satisfies Config;
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}