首次发布git
This commit is contained in:
@@ -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 不确定时
|
||||||
|
- 主动询问而非猜测
|
||||||
|
- 提供选项让用户决策
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
+43
@@ -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
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"css.lint.unknownAtRules": "ignore"
|
||||||
|
}
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Generated
+8959
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
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
|
||||||
|
);
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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'"
|
||||||
@@ -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());
|
||||||
@@ -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());
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 */
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(/ /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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -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" },
|
||||||
|
];
|
||||||
@@ -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 }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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).*)'],
|
||||||
|
};
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module 'turndown-plugin-gfm' {
|
||||||
|
import TurndownService from 'turndown';
|
||||||
|
export function gfm(service: TurndownService): void;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user