Compare commits

...

14 Commits

Author SHA1 Message Date
962bb15298 chore: 不再跟踪 embed_static/static/spa 构建产物
SPA 由 Vite 输出至 embed_static,发布前通过 build 脚本构建即可。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 02:41:32 +08:00
55c256654b 支持板块图标与主题色自定义,并优化列表加载与未保存离开提示。
新增后端图标校验与色槽规范化,前端展示 BoardBadge/骨架屏,发帖与板块管理页增加未保存确认。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 17:13:32 +08:00
e038d4f9e9 add cursor rules 2026-06-26 05:27:06 +08:00
f527c91c27 chore: add .gitattributes to fix cross-platform line endings 2026-06-26 05:26:07 +08:00
freefire
773639fd7c 修复头部用户菜单关闭后按钮残留高亮,优化 hover 与 focus 样式。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 06:16:54 +08:00
freefire
9767b54192 限制首页 Feed 自动加载上限,超出后支持手动加载更多历史帖子。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 06:11:33 +08:00
freefire
0eae3f8c47 恢复 README 原有排版:居中标题、徽章、截图表格与 HTML 布局。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 06:00:44 +08:00
freefire
57172eb053 完善 TipTap 编辑器与个人资料页:头像裁剪、Tab 缩进、文章内链、Markdown 工具与 Tooltip,并更新样式与 Feed 缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 05:57:15 +08:00
freefire
e4d1dd139e 更新 README 界面截图,同步 Feed 排序与 TipTap 发帖等新 UI。
改进截图脚本:登录后抓取发帖页,使用富文本示例帖并优化等待逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 04:15:52 +08:00
freefire
b451703642 完善论坛配置与发帖体验:TipTap 富文本、图片上传、修订历史、Feed 排序与后台参数管理。
同步更新 README 与 ROADMAP,反映最新功能与开发状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 04:11:38 +08:00
freefire
1d273066b0 升级帖子编辑与 Feed 体验:TipTap 富文本、修订历史、排序与编辑时限。
将 Markdown 编辑器替换为 TipTap WYSIWYG,新增帖子修订记录与 diff 展示;首页支持最新/回复排序与本地缓存;后台可配置编辑时限与锁定帖子;侧边栏整合板块导航并优化 Feed 布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 03:05:45 +08:00
freefire
d0555de28e 统一 React 管理后台,修复评论换行与帖子置顶
- /admin/* 全部由 React SPA 渲染,替代旧版 HTML 后台页面
- 新增仪表盘、帖子/评论/用户管理、系统设置与 JSON API
- 帖子详情页支持管理员置顶;评论换行显示修复

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 23:06:44 +08:00
freefire
9230f7272d 添加路线图与 Issue 模板,呈现待办事项
- ROADMAP.md 记录已知缺陷、计划功能与已完成项
- README 增加开发状态说明
- Gitea Issue 模板与预填议题文档

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 23:01:18 +08:00
freefire
b27289bbea 开源准备:重写 README 展示 UI/UX,更新模块路径
- 新增界面截图与 CONTRIBUTING.md
- README 以三栏布局、暗色主题、移动端等视觉亮点为主
- go module 迁移至 git.iioio.com/freefire/jiang13-forum

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 21:15:51 +08:00
121 changed files with 10219 additions and 1728 deletions

View File

@@ -0,0 +1,36 @@
---
description: 编译与构建脚本约定Windows build.bat/ps1、Makefile、跨平台同步
alwaysApply: true
---
# 编译与构建脚本
Go 单二进制 + `go:embed` 前端;产物在 `dist/`。
## 运行编译(优先用封装命令,不要猜命令)
| 平台 | 命令 |
|------|------|
| Windows | `build.bat` 或 `build.bat -Target <target>` |
| Linux / macOS | `make` 或 `make <target>` |
**不要**在 Windows 上直接运行 `make`(系统自带多为 Embarcadero MAKE不兼容本项目 Makefile
Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。
常用 target`build`(默认)、`dev`、`run`、`frontend`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。
## 修改构建脚本时的约定
1. **双轨同步**`build.ps1` 与 `Makefile` 目标与行为保持一致;改其一须同步另一份。
2. **`build.bat` 仅用 ASCII 注释**`.bat` 会被 cmd 按 GBK 解析UTF-8 中文注释会导致整行乱码、`powershell` 无法执行。
3. **`build.ps1` 可用 UTF-8**:由 PowerShell 执行,中文注释无妨。
4. **构建顺序**:先 `frontend` 内 `npm run build`,再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。
5. **入口包**`./cmd/jiang13`Windows 产物带 `.exe`。
## 新增 target 检查清单
- [ ] `build.ps1` 的 `ValidateSet` 与 `switch` 分支
- [ ] `Makefile` 对应 `.PHONY` 与 recipe
- [ ] `build.bat -Target help` 说明help 输出在 ps1 内)
- [ ] README「快速开始」如需对外暴露再更新

17
.gitattributes vendored Normal file
View File

@@ -0,0 +1,17 @@
# 跨平台行尾:仓库内统一 LF避免 Windows autocrlf 造成「假 modified」
* text=auto eol=lf
# Windows 批处理在 cmd 下需要 CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
# 二进制
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.exe binary

View File

@@ -0,0 +1,56 @@
name: 缺陷报告
about: 报告一个可复现的问题
title: "[Bug] "
labels:
- bug
body:
- type: markdown
attributes:
value: |
感谢反馈!请尽量提供复现步骤,方便我们定位问题。
- type: textarea
id: description
attributes:
label: 问题描述
description: 简要说明发生了什么
placeholder: 评论回复换行后,页面上显示为一行…
validations:
required: true
- type: textarea
id: steps
attributes:
label: 复现步骤
placeholder: |
1. 打开帖子详情页
2. 在评论框输入多行文字
3. 点击发送
4. 观察到换行丢失
validations:
required: true
- type: textarea
id: expected
attributes:
label: 期望行为
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际行为
validations:
required: true
- type: input
id: version
attributes:
label: 版本 / 提交
placeholder: main @ abc1234 或 v1.0.0
- type: input
id: environment
attributes:
label: 环境
placeholder: Windows 11 / Chrome 125 / Go 1.26
- type: textarea
id: screenshots
attributes:
label: 截图或日志
description: 可粘贴图片或相关日志

View File

@@ -0,0 +1,44 @@
name: 功能建议
about: 提出新功能或改进现有体验
title: "[Feature] "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
欢迎提出想法!请先搜索已有 Issue避免重复。
- type: textarea
id: problem
attributes:
label: 要解决什么问题?
description: 从用户场景出发描述痛点
placeholder: 管理员在 React 前台无法置顶帖子,必须切到旧版后台…
validations:
required: true
- type: textarea
id: solution
attributes:
label: 期望的方案
description: 你心目中的实现方式(可简略)
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 备选方案
description: 如有其他思路可一并说明
- type: dropdown
id: area
attributes:
label: 涉及模块
options:
- 前台 UI/UX
- 管理后台
- 评论系统
- 帖子 / 板块
- 认证 / 权限
- 部署 / 构建
- 其他
validations:
required: true

2
.gitignore vendored
View File

@@ -2,8 +2,10 @@
/data/ /data/
# 前端依赖与构建缓存 # 前端依赖与构建缓存
/node_modules/
/frontend/node_modules/ /frontend/node_modules/
/frontend/dist/ /frontend/dist/
/embed_static/static/spa/
# Go 编译产物 # Go 编译产物
/dist/ /dist/

48
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,48 @@
# 参与贡献
感谢你对姜十三论坛的关注!这是一个开源项目,欢迎提交 Issue 和 Pull Request。
## 开发环境
**要求:** Go 1.26+、Node.js 18+
```powershell
# Windows一键启动后端 + 前端热更新
.\build.ps1 -Target dev
# 浏览器访问 http://localhost:5173
```
```bash
# Linux / macOS
make dev
```
## 提交规范
- 一个 PR 只做一件事,保持 diff 小而清晰
- 前端改动请确认浅色 / 暗色主题下都正常
- 涉及 UI 变更时,建议在 PR 中附上截图
## 完整构建
发布单二进制前需先构建前端并 embed
```powershell
.\build.ps1 # Windows
make build # Linux / macOS
```
## 报告问题
在 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues) 中描述(也可使用仓库自带的 Issue 模板):
1. 复现步骤
2. 期望行为 vs 实际行为
3. 环境信息系统、浏览器、Go/Node 版本)
4. 截图或日志(如有)
已知问题与计划功能见 [ROADMAP.md](../ROADMAP.md)。
## 行为准则
请保持友善、尊重他人。骚扰、歧视或恶意行为不被容忍。

316
README.md
View File

@@ -1,68 +1,135 @@
<div align="center">
# 姜十三论坛 Jiang13 Forum # 姜十三论坛 Jiang13 Forum
轻量自用论坛系统,面向小圈子内部交流。与 Gitea 相同,编译为**单个独立 Go 二进制**,前端静态资源 embed 内嵌,内置 SQLite开箱即用。 **轻量 · 好看 · 单文件部署的现代化论坛**
## 项目目录结构 面向小圈子内部交流,编译为单个 Go 二进制,前端 SPA 内嵌,内置 SQLite开箱即用。
``` <br>
js13-forum/
├── cmd/jiang13/main.go # 程序入口 [![License: MIT](https://img.shields.io/badge/License-MIT-18a058?style=flat-square)](LICENSE)
├── config/config.go # 命令行参数与配置 [![Go](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod)
├── model/ [![React](https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white)](frontend/package.json)
│ ├── models.go # GORM 模型定义 [![SQLite](https://img.shields.io/badge/SQLite-内置-003B57?style=flat-square&logo=sqlite&logoColor=white)](#)
│ └── db.go # 数据库初始化与自动迁移
├── service/ # 业务逻辑层 [快速开始](#-快速开始) ·
│ ├── auth.go # 注册/登录/JWT [界面预览](#-界面预览) ·
│ ├── user.go # 用户资料/头像 [功能亮点](#-功能亮点) ·
│ ├── board.go # 板块 CRUD [路线图](ROADMAP.md) ·
│ ├── post.go # 帖子/点赞/收藏 [参与贡献](CONTRIBUTING.md)
│ ├── comment.go # 楼层评论
│ ├── backup.go # SQLite 备份 <br>
│ ├── ratelimit.go # 访问限流
│ └── common.go # 敏感词过滤等 <img src="docs/screenshots/home-light.png" alt="姜十三论坛首页 - 浅色主题三栏布局" width="92%">
├── handler/
│ ├── handlers.go # 前台页面与 API <sub>浅色主题 · 三栏布局 · Feed 排序 · 虚拟滚动帖列表</sub>
│ └── admin.go # 后台管理
├── middleware/auth.go # JWT 鉴权与限流中间件 <br>
├── router/router.go # 路由注册
├── embed_static/ > **开发状态:** 项目积极开发中。管理后台已统一为 React SPA`/admin`),欢迎参与共建。
│ ├── embed.go # go:embed 声明 > 查看 [路线图 ROADMAP.md](ROADMAP.md) · [Issues 反馈](https://git.iioio.com/freefire/jiang13-forum/issues)
│ ├── static/css/style.css # 内嵌 CSS
│ ├── static/js/app.js # 内嵌 JS </div>
│ └── templates/ # 内嵌 HTML 模板
├── go.mod ---
├── Makefile
└── README.md ## 界面预览
```
> 论坛用户第一眼看到的是界面。姜十三论坛采用清新绿色主题、高密度信息布局,兼顾桌面与移动端体验。
<table>
<tr>
<td width="50%" align="center">
<img src="docs/screenshots/home-light.png" alt="浅色主题首页" width="100%">
<br><b>浅色主题</b><br>
<sub>左栏板块导航 · Feed 排序切换 · 右栏热门/动态/在线</sub>
</td>
<td width="50%" align="center">
<img src="docs/screenshots/home-dark.png" alt="暗色主题首页" width="100%">
<br><b>暗色主题</b><br>
<sub>一键切换 · 护眼阅读 · 全局色彩自适应</sub>
</td>
</tr>
<tr>
<td width="50%" align="center">
<img src="docs/screenshots/post-detail.png" alt="帖子详情页" width="100%">
<br><b>帖子详情</b><br>
<sub>TipTap 富文本渲染 · 标签展示 · 点赞收藏互动</sub>
</td>
<td width="50%" align="center">
<img src="docs/screenshots/mobile-home.png" alt="移动端首页" width="280">
<br><b>移动端适配</b><br>
<sub>板块快捷筛选 · Feed 排序 · 触控友好列表</sub>
</td>
</tr>
</table>
<p align="center">
<img src="docs/screenshots/compose.png" alt="发帖页" width="360">
<br><b>发帖</b> — 板块胶囊选择 · TipTap 工具栏 · 本地上传图片
</p>
---
## 功能亮点
### 界面与交互
| 特性 | 说明 |
|------|------|
| **三栏布局** | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/通知/在线 |
| **虚拟滚动** | `@tanstack/react-virtual` 驱动帖列表与楼层回复,长列表依然流畅 |
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论,一键切换 Feed 排序 |
| **主题切换** | 浅色 / 暗色一键切换,跟随 `prefers-color-scheme` 与本地记忆 |
| **响应式** | 平板 / 手机自动收起侧栏,搜索、发帖、登录触手可及 |
| **高密度排版** | V2EX / NGA 风格信息密度,一屏浏览更多内容 |
### 社区功能
- 用户注册 / 登录bcrypt + JWT Cookie
- 普通用户 / 管理员两级权限,**首个注册用户自动成为管理员**
- 板块管理、发帖、TipTap 富文本编辑、正文图片本地上传、标签、置顶
- 帖子修订历史:编辑后保留版本记录,支持 diff 对比查看
- 可配置编辑时限:管理员设定普通用户修改帖子的有效窗口
- 楼层式评论,支持回复指定楼层、@ 高亮、引用回复
- 点赞、收藏、热门帖、最新动态
- 管理员后台删帖、删评论、禁言、论坛参数配置、敏感词管理、SQLite 一键备份
- 内置敏感词过滤、发帖 / 评论 / 注册 / 登录限流(后台可配)
### 部署体验
- **单二进制部署** — 与 Gitea 同款 `go:embed` 打包,无需 Nginx 反代静态资源
- **零依赖数据库** — SQLite 内建,数据目录 `--data` 一处管理
- **跨平台** — Windows / Linux / macOS 一键编译
---
## 快速开始 ## 快速开始
### 1. 编译 ### 1. 编译
**Windows推荐,无需 GNU Make** **Windows推荐**
```powershell ```powershell
# PowerShell
.\build.ps1 .\build.ps1
# 或双击 build.bat
# 或双击 / cmd
build.bat
``` ```
**Linux / macOS(需 GNU Make** **Linux / macOS**
```bash ```bash
make build make build
``` ```
**手动分步(全平台通用** **手动分步(全平台):**
```bash ```bash
cd frontend && npm install && npm run build cd frontend && npm install && npm run build
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13.exe ./cmd/jiang13 cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
``` ```
> Windows 自带的 `make` 通常是 Embarcadero MAKE**不能**识别本项目 Makefile。请用 `.\build.ps1` 或安装 GNU Make`choco install make`后再用 `make build`。 > Windows 自带的 `make` 通常是 Embarcadero MAKE不能识别本项目 Makefile。请用 `.\build.ps1` 或安装 GNU Make 后再用 `make build`。
跨平台编译: 跨平台编译:
@@ -84,104 +151,109 @@ cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13.exe ./cmd/jiang13
### 3. 首次使用 ### 3. 首次使用
1. 浏览器打开 http://localhost:3000/register 注册账号 1. 浏览器打开 `http://localhost:3000/register` 注册账号
2. **第一个注册的用户自动成为管理员**,无需额外命令 2. **第一个注册的用户自动成为管理员**
3. 登录后访问 http://localhost:3000/admin/dashboard 进入后台 3. 登录后访问 `http://localhost:3000/admin/dashboard` 进入后台
## 启动参数 ### 启动参数
| 参数 | 默认值 | 说明 | | 参数 | 默认值 | 说明 |
|------|--------|------| |------|--------|------|
| `--port` | 3000 | HTTP 监听端口 | | `--port` | `3000` | HTTP 监听端口 |
| `--data` | ./data | 数据目录SQLite、上传、日志 | | `--data` | `./data` | 数据目录SQLite、上传、日志 |
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则自动生成并持久化到 data/.jwt_secret | | `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret` |
## 数据目录说明 ---
## 技术栈
| 层级 | 技术 |
|------|------|
| **后端** | Go 1.26 · Gin · GORM · SQLite |
| **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
| **构建** | Vite → `go:embed` 内嵌 SPA单二进制发布 |
| **认证** | bcrypt · JWT Cookie |
---
## 前端开发
日常改前端**不需要**重新 `npm run build``go build`Vite 开发服务器支持秒级热更新HMR热模块替换
```powershell
.\build.ps1 -Target dev # Windows
make dev # Linux / macOS
```
浏览器访问 `http://localhost:5173`API 自动代理到 `http://localhost:3000`
**何时需要完整构建:**
- 修改 Go 代码、HTML 模板、embed 静态资源 → `go build` / `make build`
- 发布单二进制前 → `npm run build` + `make build`
- 更新 README 界面截图 → 启动服务后执行 `node scripts/capture-screenshots.mjs`
> 直接访问 `:3000` 看到的是上次 build 嵌入的前端;开发时请用 `:5173`。
---
## 项目结构
```
jiang13-forum/
├── cmd/jiang13/ # 程序入口
├── config/ # 命令行参数与配置
├── model/ # GORM 模型与数据库迁移
├── service/ # 业务逻辑(认证、帖子、评论…)
├── handler/ # HTTP 处理器(前台 + 后台)
├── middleware/ # JWT 鉴权、在线状态
├── router/ # 路由注册
├── embed_static/ # go:embed 内嵌的 SPA 与模板
├── frontend/ # React 源码Vite 构建)
├── docs/screenshots/ # README 界面截图
├── docs/issue-templates.md # Issue 预填模板
├── ROADMAP.md # 路线图与已知问题
└── scripts/ # 开发辅助脚本
```
---
## 数据目录
``` ```
data/ data/
├── jiang13.db # SQLite 主数据库 ├── jiang13.db # SQLite 主数据库
├── jiang13.log # 运行日志 ├── jiang13.log # 运行日志
├── filter_words.txt # 敏感词配置(可编辑) ├── filter_words.txt # 敏感词配置
├── .jwt_secret # JWT 密钥(自动生成) ├── .jwt_secret # JWT 密钥(自动生成)
├── uploads/avatars/ # 用户头像(运行时写入,非 embed ├── uploads/avatars/ # 用户头像
── jiang13_backup_*.db # 后台导出的备份文件 ── uploads/posts/ # 帖子正文图片
└── jiang13_backup_*.db # 后台导出的备份
``` ```
## 修改前端并重新打包Gitea 同款流程) ---
1. 编辑 `embed_static/templates/` 下的 HTML 模板 ## 开发状态
2. 编辑 `embed_static/static/css/``static/js/` 下的静态资源
3. 重新编译:`make build`
4. 替换旧二进制,重启服务
前端资源通过 `//go:embed` 编译进二进制,**运行时不需要单独的前端文件夹**。只有用户上传的头像保存在 `--data` 目录 项目**积极开发中**,作为论坛产品功能尚未完善,欢迎参与共建
```go | 类型 | 示例 |
// embed_static/embed.go
//go:embed static/*
var staticFS embed.FS
//go:embed templates/*
var templatesFS embed.FS
```
## 功能概览
- 用户注册/登录bcrypt + JWT Cookie
- 普通用户 / 管理员两级权限
- 板块管理、发帖、富文本 HTML、标签、置顶
- 楼层式评论,支持回复指定楼层
- 点赞、收藏
- 管理员删帖、删评论、禁言、SQLite 备份
- 内置敏感词过滤、发帖/评论限流
## 技术栈
- **后端**Go 1.26 + Gin + GORM + SQLite
- **前端**React 18 + Arco Design + TanStack Virtual虚拟滚动
- **打包**Vite 构建 → `go:embed` 内嵌 SPA与 Gitea 同款单二进制
## 前端开发(热更新 HMR
日常改前端**不需要**重新 `npm run build``go build`,用 Vite 开发服务器即可秒级热更新:
```bash
# 一键启动Go 后端 + Vite 热更新(推荐)
.\build.ps1 -Target dev # Windows
make dev # Linux / macOS需 GNU Make
# 浏览器访问 http://localhost:5173
# API 自动代理到 http://localhost:3000
```
也可开两个终端分别启动:
```bash
# 终端 1仅后端
.\build.ps1 -Target run # 或 make run
# 终端 2仅前端热更新
cd frontend && npm install && npm run dev
```
**何时才需要完整构建:**
- 改 Go 代码、模板、embed 静态资源 → `go build` / `make build`
- 发布单二进制前 → `npm run build` + `make build`(前端产物写入 `embed_static/static/spa/` 后 embed 进二进制)
> 若直接访问 `:3000`,看到的是**上次 build 嵌入**的前端,不会有热更新。开发时请用 `:5173`。
### 前端特性(高密度 V2EX/NGA 风格)
| 特性 | 实现 |
|------|------| |------|------|
| 三栏布局 | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/通知/在线 | | ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 |
| 虚拟滚动 | `@tanstack/react-virtual` 帖列表 & 楼层回复 | | ✅ 发帖体验 | TipTap 富文本、正文图片上传、修订历史、可配置编辑时限 |
| 已读/未读 | 未读高亮 + 角标 + 批量标记已读 | | ✅ 管理后台 | React SPA仪表盘、帖子置顶、用户禁言、论坛参数与敏感词配置 |
| 主题 | 浅色/暗黑一键切换,平板/手机自动收起侧栏 | | 📋 计划中 | 通知动态优化、邮件提醒 |
| 交互 | 滚动加载更多、快捷回帖、楼层锚点、引用回复、@高亮 |
完整列表见 **[路线图 ROADMAP.md](ROADMAP.md)**。发现问题请提交 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues),认领任务请参考 [CONTRIBUTING.md](CONTRIBUTING.md)。
---
## 参与贡献
欢迎提交 Issue 和 Pull Request详见 [CONTRIBUTING.md](CONTRIBUTING.md)。
---
## 许可证 ## 许可证
MIT — 自用随意,欢迎修改 [MIT](LICENSE) — 自由使用、修改与分发

71
ROADMAP.md Normal file
View File

@@ -0,0 +1,71 @@
# 路线图 ROADMAP
> 姜十三论坛仍在积极开发中,功能尚未完善。
> 欢迎通过 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues) 反馈问题或认领任务。
**图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷
---
## 开发状态概览
| 模块 | 状态 | 说明 |
|------|------|------|
| 前台 SPAReact | ✅ | 浏览、发帖、回复、管理操作已统一在 SPA 内 |
| 管理后台 | ✅ | React 后台 `/admin/*`,与前台风格一致 |
| 评论系统 | ✅ | 换行显示已修复 |
---
## 🐛 已知缺陷Bug
_当前无已记录缺陷。发现新问题请提交 [Issue](https://git.iioio.com/freefire/jiang13-forum/issues/new/choose)。_
---
## 📋 计划中Planned
| 优先级 | 功能 | 说明 |
|--------|------|------|
| 中 | 通知动态优化 | 右栏最新动态的展示与交互 |
| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 |
| 低 | 邮件通知 | 回复提醒(需 SMTP 配置) |
---
## 🚧 进行中In Progress
_当前无公开认领任务。_
---
## ✅ 已完成Done
- [x] React 管理后台(仪表盘、板块、帖子、评论、用户、设置)
- [x] 帖子置顶(帖子详情 + 管理后台)
- [x] 评论回复换行正确显示
- [x] 三栏布局 + 虚拟滚动帖列表
- [x] 浅色 / 暗色主题切换
- [x] 移动端响应式适配
- [x] 用户注册登录、JWT 鉴权
- [x] 板块管理、发帖、TipTap 富文本编辑
- [x] 帖子正文图片本地上传
- [x] 帖子修订历史与 diff 对比
- [x] Feed 排序(最新发帖 / 最新回复 / 热门讨论)
- [x] 可配置编辑时限与论坛参数(限流、字数上限等)
- [x] 楼层式评论、引用回复、@ 高亮
- [x] 点赞、收藏、热门帖
- [x] 敏感词过滤、发帖限流
- [x] SQLite 备份、单二进制部署
---
## 如何参与
1. 在 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues) 挑选任务(预填内容见 [docs/issue-templates.md](docs/issue-templates.md)
2. Fork → 分支 → PR详见 [CONTRIBUTING.md](CONTRIBUTING.md)
3. 有新想法先开 Issue 讨论,避免重复劳动
---
_最后更新2026-06-16_

View File

@@ -1,4 +1,4 @@
@echo off @echo off
REM 姜十三论坛 Windows 快捷构建(双击或命令行均可) REM Jiang13 Forum Windows build wrapper (double-click or run from cmd)
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %* powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
exit /b %ERRORLEVEL% exit /b %ERRORLEVEL%

View File

@@ -11,9 +11,9 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/jiang13/forum/config" "git.iioio.com/freefire/jiang13-forum/config"
"github.com/jiang13/forum/model" "git.iioio.com/freefire/jiang13-forum/model"
"github.com/jiang13/forum/router" "git.iioio.com/freefire/jiang13-forum/router"
) )
func main() { func main() {

View File

@@ -34,6 +34,10 @@ func Parse() (*Config, error) {
if err := os.MkdirAll(uploadDir, 0755); err != nil { if err := os.MkdirAll(uploadDir, 0755); err != nil {
return nil, fmt.Errorf("创建上传目录失败: %w", err) return nil, fmt.Errorf("创建上传目录失败: %w", err)
} }
postImgDir := filepath.Join(*dataDir, "uploads", "posts")
if err := os.MkdirAll(postImgDir, 0755); err != nil {
return nil, fmt.Errorf("创建帖子图片目录失败: %w", err)
}
cfg := &Config{ cfg := &Config{
Port: *port, Port: *port,
@@ -61,11 +65,21 @@ func (c *Config) DBPath() string {
return filepath.Join(c.DataDir, "jiang13.db") return filepath.Join(c.DataDir, "jiang13.db")
} }
// UploadDir 返回头像上传目录 // AvatarUploadDir 返回头像上传目录
func (c *Config) UploadDir() string { func (c *Config) AvatarUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "avatars") return filepath.Join(c.DataDir, "uploads", "avatars")
} }
// PostImageUploadDir 返回帖子正文图片上传目录
func (c *Config) PostImageUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "posts")
}
// UploadDir 返回头像上传目录(兼容旧调用)
func (c *Config) UploadDir() string {
return c.AvatarUploadDir()
}
// FilterWordsPath 返回敏感词配置文件路径 // FilterWordsPath 返回敏感词配置文件路径
func (c *Config) FilterWordsPath() string { func (c *Config) FilterWordsPath() string {
return filepath.Join(c.DataDir, "filter_words.txt") return filepath.Join(c.DataDir, "filter_words.txt")

99
docs/issue-templates.md Normal file
View File

@@ -0,0 +1,99 @@
# Issue 预填模板
以下两条可直接复制到 [Gitea Issues](https://git.iioio.com/freefire/jiang13-forum/issues/new) 创建,或使用仓库自带的 Issue 模板。
---
## Issue #1 · 评论回复换行不显示
**标题:** `[Bug] 评论回复换行不显示`
**标签:** `bug` `ui/ux`
**正文:**
### 问题描述
在帖子详情页的评论框中输入多行文字(按 Enter 换行),提交后评论展示区域不保留换行,所有文字合并为一行。
### 复现步骤
1. 打开任意帖子详情页(如 `/post/2`
2. 在底部评论框输入:
```
第一行
第二行
第三行
```
3. 点击发送
4. 查看刚发布的评论
### 期望行为
评论正文按输入时的换行分段显示,行与行之间有明显间隔。
### 实际行为
多行内容被渲染成单行连续文字。
### 相关代码
- `frontend/src/components/CommentContent.tsx`
- `frontend/src/utils/content.ts``highlightMentions` 中的 `\n` → `<br>` 转换)
- `frontend/src/styles/global.css``.floor-body` 的 `white-space: pre-wrap`
### 可能原因
- 仅处理了 `\n`,未处理 Windows 的 `\r\n`
- `dangerouslySetInnerHTML` 与 `pre-wrap` 样式叠加导致表现异常
- 服务端 `strings.TrimSpace` 或其他处理误删换行(待排查)
### 环境
- 前台React SPA`:3000` 嵌入版或 `:5173` 开发版)
- 浏览器Chrome / Edge 最新版
---
## Issue #2 · React 前台支持帖子置顶
**标题:** `[Feature] React 前台增加帖子置顶操作`
**标签:** `enhancement` `ui/ux` `good first issue`
**正文:**
### 要解决的问题
管理员无法在 React SPA 前台对帖子执行置顶/取消置顶,必须跳转到旧版 HTML 管理后台(`/admin/posts`),体验割裂。
### 现状
| 能力 | 状态 |
| ----------------------------------- | ---- |
| 数据模型 `pinned` 字段 | ✅ 已有 |
| 列表按置顶排序 | ✅ 已有 |
| API `POST /admin/api/posts/:id/pin` | ✅ 已有 |
| 旧版后台置顶按钮 | ✅ 已有 |
| React 列表/详情显示置顶徽章 | ✅ 已有 |
| **React 前台置顶操作入口** | ❌ 缺失 |
### 期望方案
在 React SPA 中为管理员提供置顶操作,例如:
1. **帖子详情页**:标题旁增加「置顶 / 取消置顶」按钮(仅 `role === 'admin'` 可见)
2. **帖列表项**:管理员 hover 时显示置顶快捷操作(可选)
3. 调用已有 API成功后刷新列表/详情,无需跳转旧后台
### 相关代码
- 后端:`service/post.go` → `SetPinned``handler/admin.go` → `AdminAPIPinPost`
- 前端:`frontend/src/pages/PostDetailPage.tsx`、`frontend/src/components/PostListItem.tsx`
- 参考旧版:`embed_static/templates/admin/posts.html` 中的 `togglePin`
### 备注
适合作为 `good first issue`改动范围小、API 已就绪。

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{e as j,r as n,j as s}from"./react-vendor-CgzNJrV9.js";import{u as f,a as g,B as p}from"./index-474mTIgd.js";import{S as N}from"./spinner-DH1syOsQ.js";import{n as v}from"./notify-Zesh5czd.js";import{f as y}from"./content-CKr4Ur51.js";import{A as k}from"./ui-vendor-CdH1UOKW.js";function E(){const t=j(),{user:a,loading:r}=f(),[i,h]=n.useState([]),[u,x]=n.useState(!0);return n.useEffect(()=>{if(!r){if(!a){t("/login");return}g.favorites().then(e=>h(Array.isArray(e.favorites)?e.favorites:[])).catch(e=>v.error(e.message)).finally(()=>x(!1))}},[a,r,t]),r||u?s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(N,{size:"lg"})}):a?s.jsx("div",{className:"page-wrap",children:s.jsxs("div",{className:"page-inner-wide",children:[s.jsxs(p,{variant:"ghost",className:"mb-3",onClick:()=>t("/"),children:[s.jsx(k,{}),"返回"]}),s.jsx("h1",{className:"page-title",children:"我的收藏"}),s.jsxs("p",{className:"page-desc",children:["共 ",i.length," 篇收藏帖子"]}),i.length===0?s.jsxs("div",{className:"empty-state",children:[s.jsx("p",{children:"还没有收藏任何帖子"}),s.jsx(p,{onClick:()=>t("/"),children:"去逛逛"})]}):s.jsx("div",{className:"content-surface",children:i.map(e=>{var o,l,c,d,m;return s.jsx("div",{className:"post-row",onClick:()=>t(`/post/${e.post_id}`),children:s.jsxs("div",{className:"post-body",children:[s.jsx("div",{className:"post-title",children:((o=e.post)==null?void 0:o.title)||"帖子已删除"}),s.jsxs("div",{className:"post-meta",children:[((c=(l=e.post)==null?void 0:l.board)==null?void 0:c.name)&&s.jsx("span",{children:e.post.board.name}),((m=(d=e.post)==null?void 0:d.user)==null?void 0:m.nickname)&&s.jsx("span",{children:e.post.user.nickname}),s.jsxs("span",{children:["收藏于 ",y(e.created_at)]})]})]})},e.id)})})]})}):null}export{E as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{e as f,r as g,j as s,L as b}from"./react-vendor-CgzNJrV9.js";import{u as w,a as F,F as y,b as t,c as n,d as l,e as c,I as i,f as m,o as N,s as d}from"./form-BzHbu9bJ.js";import{u as v,B as L,a as S}from"./index-474mTIgd.js";import{n as u}from"./notify-Zesh5czd.js";import"./ui-vendor-CdH1UOKW.js";const C=N({username:d().min(1,"请输入用户名"),password:d().min(1,"请输入密码")});function z(){const x=f(),{refresh:p}=v(),[h,a]=g.useState(!1),r=w({resolver:F(C),defaultValues:{username:"",password:""}}),j=async e=>{a(!0);try{await S.login(e.username,e.password),await p(),u.success("登录成功"),x("/",{replace:!0})}catch(o){u.error(o instanceof Error?o.message:"登录失败")}finally{a(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"登录姜十三论坛"}),s.jsx("p",{className:"subtitle",children:"拾三一隅,自在交流"}),s.jsx(y,{...r,children:s.jsxs("form",{onSubmit:r.handleSubmit(j),className:"space-y-4",children:[s.jsx(t,{control:r.control,name:"username",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"用户名"}),s.jsx(c,{children:s.jsx(i,{placeholder:"用户名",autoComplete:"username",...e})}),s.jsx(m,{})]})}),s.jsx(t,{control:r.control,name:"password",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"密码"}),s.jsx(c,{children:s.jsx(i,{type:"password",placeholder:"密码",autoComplete:"current-password",...e})}),s.jsx(m,{})]})}),s.jsx(L,{type:"submit",className:"w-full",loading:h,children:"登录"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["没有账号?",s.jsx(b,{to:"/register",children:"注册"})]})]})})}export{z as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{e as f,r as g,j as s,L as w}from"./react-vendor-CgzNJrV9.js";import{u as b,a as F,F as y,b as a,c as n,d as o,e as t,I as c,f as l,o as k,s as i}from"./form-BzHbu9bJ.js";import{u as N,B as S,a as v}from"./index-474mTIgd.js";import{n as x}from"./notify-Zesh5czd.js";import"./ui-vendor-CdH1UOKW.js";const C=k({username:i().min(1,"请输入用户名"),nickname:i().optional(),password:i().min(6,"密码至少 6 位")});function R(){const u=f(),{refresh:j}=N(),[p,m]=g.useState(!1),r=b({resolver:F(C),defaultValues:{username:"",nickname:"",password:""}}),h=async e=>{m(!0);try{await v.register(e.username,e.password,e.nickname||e.username),await j(),x.success("注册成功"),u("/",{replace:!0})}catch(d){x.error(d instanceof Error?d.message:"注册失败")}finally{m(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"注册账号"}),s.jsx("p",{className:"subtitle",children:"首个注册用户自动成为管理员"}),s.jsx(y,{...r,children:s.jsxs("form",{onSubmit:r.handleSubmit(h),className:"space-y-4",children:[s.jsx(a,{control:r.control,name:"username",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"用户名"}),s.jsx(t,{children:s.jsx(c,{placeholder:"3-32 位字母数字下划线",autoComplete:"username",...e})}),s.jsx(l,{})]})}),s.jsx(a,{control:r.control,name:"nickname",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"昵称"}),s.jsx(t,{children:s.jsx(c,{placeholder:"显示名称(可选)",autoComplete:"nickname",...e})}),s.jsx(l,{})]})}),s.jsx(a,{control:r.control,name:"password",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"密码"}),s.jsx(t,{children:s.jsx(c,{type:"password",placeholder:"至少 6 位",autoComplete:"new-password",...e})}),s.jsx(l,{})]})}),s.jsx(S,{type:"submit",className:"w-full",loading:p,children:"注册"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["已有账号?",s.jsx(w,{to:"/login",children:"登录"})]})]})})}export{R as default};

View File

@@ -1 +0,0 @@
import{j as n}from"./react-vendor-CgzNJrV9.js";import{c as a,d as o}from"./index-474mTIgd.js";const s=o("inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground shadow",secondary:"border-transparent bg-secondary text-secondary-foreground",destructive:"border-transparent bg-destructive text-destructive-foreground shadow",outline:"text-foreground",green:"border-transparent bg-[var(--j13-green-bg)] text-[var(--j13-green)]",orange:"border-transparent bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300"}},defaultVariants:{variant:"default"}});function g({className:r,variant:e,...t}){return n.jsx("div",{className:a(s({variant:e}),r),...t})}export{g as B};

View File

@@ -1 +0,0 @@
function a(n,t){return n.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/@([\w\u4e00-\u9fa5_-]+)/g,'<span class="mention">@$1</span>')}function o(n){const t=new Date(n),e=(new Date().getTime()-t.getTime())/1e3;return e<60?"刚刚":e<3600?`${Math.floor(e/60)}分钟前`:e<86400?`${Math.floor(e/3600)}小时前`:`${t.getMonth()+1}-${t.getDate()} ${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`}export{o as f,a as h};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{t as s}from"./index-474mTIgd.js";const n={success:r=>s.success(r),error:r=>s.error(r),warning:r=>s.warning(r)};export{n};

View File

@@ -1,23 +0,0 @@
import{p as m}from"./markdown-vendor-DxR1h-Bq.js";const d={ADD_TAGS:["members-only"],ADD_ATTR:["data-locked","data-length"]},c=`
<div class="post-members-only__badge">
<span class="post-members-only__badge-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
<span>登录可见</span>
</div>`,a='<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';function p(s){const r=s>0?Math.min(6,Math.max(3,Math.ceil(s/42))):4,n=Array.from({length:r},(l,i)=>{const o=i%3;return`<div class="post-members-only__preview-line${o===1?" post-members-only__preview-line--medium":o===2?" post-members-only__preview-line--short":""}"></div>`}).join(""),e=s>0?`${s} 字的`:"一段";return`
<div class="post-members-only__locked-wrap">
<div class="post-members-only__badge post-members-only__badge--locked">
<span class="post-members-only__badge-icon" aria-hidden="true">${a}</span>
<span>登录可见</span>
</div>
<div class="post-members-only__preview" aria-hidden="true">
${n}
</div>
<div class="post-members-only__gate">
<div class="post-members-only__gate-icon" aria-hidden="true">${a}</div>
<p class="post-members-only__gate-title">此处有${e}专属内容</p>
<p class="post-members-only__gate-desc">作者已将这部分内容设为仅登录用户可见,登录后即可阅读全文。</p>
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
<span class="post-members-only__gate-alt">还没有账号?<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button></span>
</div>
</div>`}function b(s,r){if(!s.trim())return"";const n=new DOMParser().parseFromString(m.sanitize(s,d),"text/html");return n.querySelectorAll("members-only").forEach(e=>{var o;if(e.getAttribute("data-locked")==="true"||!r){const t=parseInt(e.getAttribute("data-length")||"0",10)||0;e.setAttribute("data-locked","true"),e.className="post-members-only post-members-only--locked",e.innerHTML=p(t);return}const i=((o=e.querySelector(".post-members-only__body"))==null?void 0:o.innerHTML)??Array.from(e.childNodes).filter(t=>!(t instanceof Element&&t.classList.contains("post-members-only__badge"))).map(t=>t instanceof Element?t.outerHTML:t.textContent??"").join("");e.className="post-members-only post-members-only--visible",e.innerHTML=`${c}<div class="post-members-only__body">${i}</div>`}),n.body.innerHTML}export{d as P,b as r};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{j as e}from"./react-vendor-CgzNJrV9.js";import{c as s}from"./index-474mTIgd.js";import{p as m}from"./ui-vendor-CdH1UOKW.js";const t={sm:"h-4 w-4",md:"h-5 w-5",lg:"h-8 w-8"};function p({className:r,size:a="md"}){return e.jsx(m,{className:s("animate-spin text-[var(--j13-green)]",t[a],r),"aria-label":"加载中"})}export{p as S};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,38 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>姜十三论坛 Jiang13 Forum</title>
<style>
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
html { scrollbar-gutter: stable; }
html, body, #root { height: 100%; margin: 0; }
body { overflow: hidden; font-size: 14px; line-height: 1.5; }
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; }
.app-header { height: 56px; flex-shrink: 0; }
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; }
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; }
.sidebar { width: 210px; flex-shrink: 0; }
.main-content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.aside-panel { width: 280px; flex-shrink: 0; }
@media (max-width: 1100px) { .aside-panel { display: none; } }
@media (max-width: 768px) { .sidebar { display: none; } }
</style>
<script>
(function () {
var theme = localStorage.getItem('j13-theme') || 'light';
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.style.colorScheme = theme;
})();
</script>
<script type="module" crossorigin src="/assets/index-474mTIgd.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-vendor-CgzNJrV9.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CdH1UOKW.js">
<link rel="stylesheet" crossorigin href="/assets/index-JNN-o0YU.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -16,28 +16,39 @@
"@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0", "@radix-ui/react-switch": "^1.3.0",
"@tanstack/react-virtual": "^3.11.2", "@tanstack/react-virtual": "^3.11.2",
"@tiptap/core": "^3.26.1",
"@tiptap/extension-image": "^3.26.1",
"@tiptap/extension-link": "^3.26.1",
"@tiptap/extension-placeholder": "^3.26.1",
"@tiptap/extension-underline": "^3.26.1",
"@tiptap/pm": "^3.26.1",
"@tiptap/react": "^3.26.1",
"@tiptap/starter-kit": "^3.26.1",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"diff": "^9.0.0",
"dompurify": "^3.4.10", "dompurify": "^3.4.10",
"lucide-react": "^1.18.0", "lucide-react": "^1.18.0",
"marked": "^18.0.5", "marked": "^18.0.5",
"postcss": "^8.5.15", "postcss": "^8.5.15",
"react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-easy-crop": "^6.0.2",
"react-hook-form": "^7.79.0", "react-hook-form": "^7.79.0",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.28.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.4",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.3", "@types/node": "^25.9.3",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.11" "vite": "^5.4.11"
@@ -824,6 +835,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1921,6 +1938,461 @@
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@tiptap/core": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.26.1.tgz",
"integrity": "sha512-TX9PyPqBoix0qDLjtok/bddtdSy54QhzLVha405C07V+WySOpH3s/pWYkywehZQY0SQtcrcY4MNSCeQjCbA28A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.26.1.tgz",
"integrity": "sha512-WaKjKmUaadgvZDDBk9JOn/oidlOFr6booqJIWHGL5S0aUUTKHS19oGfKQq/l9Z1y1niaRePk0Y4fy/jxCnfKPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.26.1.tgz",
"integrity": "sha512-VIlF2sAiV6K009pcIDotfY8mvsPaq90dxeG9Q0ZIqfMD958TUCqjHw4MGYZf0/FgP12xksBfmcR7W312xgUf9Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.26.1.tgz",
"integrity": "sha512-Y3R9wFKP/U9M04JG+0PM/yW3OV+MSbUp6YBKQWZmUu8x6y7TbcNvDsaJ6QEFZt5aRMS6qH1ksYPTOz47JdjcfA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.26.1.tgz",
"integrity": "sha512-JB6bEJJHxXNAXEXTIAN3/j70p1ARHdeMfhzshGZswWKUWtDibTCrspIp7p1VNeiuVtJ/HB6PpFkGi7yWtQ3RTg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.26.1"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.26.1.tgz",
"integrity": "sha512-t9/VR5k3rGPyhcGau9YvVgaAQ+nP9R9WzS996bQQ7GIrMOTSXb0FWwoQFBiYl83V6VA16Tlj/oScC7SFlA8lvA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.26.1.tgz",
"integrity": "sha512-NY7SYqcrqDVYTSWyaNGdSfCims6pOHoRQ2Rh4DEFb/rb8gLVkqbLZhcHzQCVfinlPqgV3xWF6cYMORwmnlBkXQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.26.1.tgz",
"integrity": "sha512-6W2vZjvi0Mv+4xEtwMDGhWwo7FotWR6eKfmntmduvehWevFpMxOKcTtyotjLigfZv738y50YWmvbaPuAPJG3BA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.26.1.tgz",
"integrity": "sha512-eVq3BvFIa3YD+pBIlj1i72vYEixlegGVKHnSYiVF2ovkQOSAH9sca7pkq6WgV1sMTCyWCU8e+WznTqtydvHUWA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.26.1"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.26.1.tgz",
"integrity": "sha512-xn0g4m/q2bjG+hULPwp6Aqb/6wpzUtc65jOhgJsG/S3Ey3kLJGUvZBuhozwNFu8FcugxM1fMUpNhkJkodCCGFw==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.26.1.tgz",
"integrity": "sha512-BWW1yMQQA4TbEU0LLK+4cd9ebLTuZG5KjHwFMBRD/bGiRW9V1gTWFsCqThBbczcANoQiZK9pn5/4Ad/rGM3HUg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.26.1"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.26.1.tgz",
"integrity": "sha512-gzNb1e/fK6HN+ko1axsrasjK7F1q0Bnm0G4ZY/0eq7pV7s1wZuwoCiGbvUx/9LCFKRV6+94FTqlb0A3NbYN36g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.26.1.tgz",
"integrity": "sha512-eRlv9XxzUL8FobKAiF1WjP35CT2QpbcxxeyYFF7BmGEONvKI7r5g7JGwyGli4Cvclh70h8w6JuoXSmGUVEU65A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.26.1.tgz",
"integrity": "sha512-l9lPZYeSmY90y/2GkQcKaICFD5Atr8sx2SzJGkQzpNC9tRxZXyAHnfJE3OjBkspuGzjWIN0DimxBj4ibz58sKw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-image": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.26.1.tgz",
"integrity": "sha512-IjoT+kRK4a1sTImvUz257yfk5l9kMxXxfxCfix5AUKdiWyn8SGUjJZapLICcZVY05UDqXmwsBvBK9lHkKX5ERg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.26.1.tgz",
"integrity": "sha512-cLKYvOLToWEkJkAPspgIZ/PYDzAxacLm1VWcAq1tO1QDQCDe2Kw+y/zsGlyYEq/aKsAgpp4JNopBwAXRXxt2/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.26.1.tgz",
"integrity": "sha512-aLLGLgikuhLFHRbjfUC6D4gRg+NUty4uhW7YkyVl8AxxPME47dPbCOX4H6uLCjEZcn3WnfNuCTr6HCTl0KEmGA==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.26.1.tgz",
"integrity": "sha512-06nOjnyXpzMO8Ys5k3IbYsDsKib1mv2OtaxBYX1/1uvRyOKwUX5tqDLb/qigic0LIANNL73lkNC8Z8XPeG4Tkg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.26.1.tgz",
"integrity": "sha512-5gLXJUiP763NA6i4HgrtcwUDXPP8820hsaBQyF1Y1VsXNi02uW9FVLe3RZK8jF0NZUNh9CqD0gogYJCbKOUU8A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.26.1"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.26.1.tgz",
"integrity": "sha512-EReSayePO6SIxtRbxx+7KfBQreWHvoZmMb3O/RemfT8W6J0hCG5N/Rh8Z12+YZOnCDRXJ4RzFpAikYka3E54jQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.26.1"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.26.1.tgz",
"integrity": "sha512-LeFPeFwb7ylkQVuuaHj+niu7WhWHpjDOi1GKZJE/ohOa2lgt7P221HMqhUzPiDlXOExN72oWTNmXUlT0ymCTkw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.26.1"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.26.1.tgz",
"integrity": "sha512-OkBeYUNM3eTzjm3z6IcC3NHryOX8g3eGNI86P/B+tFoFQSRuzLsKZU50ARCfIiLLg812NjcqujeJ1eX3BKDZrw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.26.1.tgz",
"integrity": "sha512-oJCEVmaaUY1Jn5v8KbRMdgYLFH9aptLkir+M0ZMnl+8TTmvMdLK2H02X9ofZQwAb12qreQgb890hB3PFen7TDg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.26.1"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.26.1.tgz",
"integrity": "sha512-7hmQ2mBsA+75GRrJIKYxb+10H23mblEQSGGsv9Ptl7JLaGmj+8sv2HGQGSUT9QBiBVprxaYTqyWFXQC9akfLWg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.26.1.tgz",
"integrity": "sha512-Gocui5WvcCCJJIX17gdOVCSdYi5H4fDwaR0qkMAUZPq5kJCdrfl+vNpt8BTt53Bk+/QumiUW21fhQ184w7RoeQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.26.1.tgz",
"integrity": "sha512-HUHtQ+DRWDM0opW7Nk3YQwrLzw876hMU7cr1X/ZTG+8Bp+AKHihlwU+bqrPgG5St0mqASyUEhHQ/vK5PlnUYOQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.26.1.tgz",
"integrity": "sha512-PmRaoe6bebTgz/ZQrjmzwZMST1d9js9ZTiKnUXeXl3Fm+V5U/c3TbbKDfqmL63qPQdjtShDMHi9tYuv+c77OFQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/pm": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.26.1.tgz",
"integrity": "sha512-48cJQRbvr9Ux0+IgM1BR5vOLU5hkC+n+uerdQy2JjrIRKpYE/huU8fQFm6PoRppoKYfilklzb29elsQ+n2TA+g==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.7",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.0",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.41.8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.26.1.tgz",
"integrity": "sha512-Gl7AhTJM7pjQ2WFwdIwD736oQeqUcw3GVaXYmCKtwTSO3F9PszLgeKEp6DvM+CmctTNYhu/apRfzkH3vU0h0uA==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.26.1",
"@tiptap/extension-floating-menu": "^3.26.1"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.26.1.tgz",
"integrity": "sha512-A0zsvwGU9exLND34F8e8KqUXFSfs835tNN+VC+ZT3yNeaO/WXnlh/Cgal1F6pHHbcxy7RV2CRwJU5S3cWLPxrA==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.26.1",
"@tiptap/extension-blockquote": "^3.26.1",
"@tiptap/extension-bold": "^3.26.1",
"@tiptap/extension-bullet-list": "^3.26.1",
"@tiptap/extension-code": "^3.26.1",
"@tiptap/extension-code-block": "^3.26.1",
"@tiptap/extension-document": "^3.26.1",
"@tiptap/extension-dropcursor": "^3.26.1",
"@tiptap/extension-gapcursor": "^3.26.1",
"@tiptap/extension-hard-break": "^3.26.1",
"@tiptap/extension-heading": "^3.26.1",
"@tiptap/extension-horizontal-rule": "^3.26.1",
"@tiptap/extension-italic": "^3.26.1",
"@tiptap/extension-link": "^3.26.1",
"@tiptap/extension-list": "^3.26.1",
"@tiptap/extension-list-item": "^3.26.1",
"@tiptap/extension-list-keymap": "^3.26.1",
"@tiptap/extension-ordered-list": "^3.26.1",
"@tiptap/extension-paragraph": "^3.26.1",
"@tiptap/extension-strike": "^3.26.1",
"@tiptap/extension-text": "^3.26.1",
"@tiptap/extension-underline": "^3.26.1",
"@tiptap/extensions": "^3.26.1",
"@tiptap/pm": "^3.26.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1987,14 +2459,12 @@
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.31", "version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
@@ -2005,7 +2475,6 @@
"version": "18.3.7", "version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
@@ -2018,6 +2487,19 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@types/turndown": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2299,7 +2781,6 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dayjs": { "node_modules/dayjs": {
@@ -2338,6 +2819,15 @@
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/diff": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
"integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dlv": { "node_modules/dlv": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -2416,6 +2906,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -2660,6 +3159,12 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -2779,6 +3284,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -2797,6 +3308,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/path-parse": { "node_modules/path-parse": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -2995,6 +3512,145 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.9",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz",
"integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.9",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.9.tgz",
"integrity": "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.8",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3020,6 +3676,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -3040,6 +3697,19 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-easy-crop": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.0.2.tgz",
"integrity": "sha512-nY/YiNEuRjc851+/PsOR6Q7XoshmnXMl+oEOsxp3Ah0PrhECi5388jjRnHwsTFx3W0o2zPwvq85oljzUqZNpEw==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.79.0", "version": "7.79.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz",
@@ -3264,6 +3934,12 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -3505,6 +4181,19 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/turndown": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
},
"engines": {
"node": ">=18",
"npm": ">=9"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3599,6 +4288,15 @@
} }
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -3665,6 +4363,12 @@
} }
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -17,28 +17,39 @@
"@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0", "@radix-ui/react-switch": "^1.3.0",
"@tanstack/react-virtual": "^3.11.2", "@tanstack/react-virtual": "^3.11.2",
"@tiptap/core": "^3.26.1",
"@tiptap/extension-image": "^3.26.1",
"@tiptap/extension-link": "^3.26.1",
"@tiptap/extension-placeholder": "^3.26.1",
"@tiptap/extension-underline": "^3.26.1",
"@tiptap/pm": "^3.26.1",
"@tiptap/react": "^3.26.1",
"@tiptap/starter-kit": "^3.26.1",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"diff": "^9.0.0",
"dompurify": "^3.4.10", "dompurify": "^3.4.10",
"lucide-react": "^1.18.0", "lucide-react": "^1.18.0",
"marked": "^18.0.5", "marked": "^18.0.5",
"postcss": "^8.5.15", "postcss": "^8.5.15",
"react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-easy-crop": "^6.0.2",
"react-hook-form": "^7.79.0", "react-hook-form": "^7.79.0",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.28.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.4",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.3", "@types/node": "^25.9.3",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.11" "vite": "^5.4.11"

View File

@@ -1,9 +1,16 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import {
createBrowserRouter,
createRoutesFromElements,
RouterProvider,
Route,
Navigate,
} from 'react-router-dom';
import './styles/global.css'; import './styles/global.css';
import { AuthProvider } from './hooks/useAuth'; import { AuthProvider } from './hooks/useAuth';
import { ThemeProvider } from './hooks/useTheme'; import { ThemeProvider } from './hooks/useTheme';
import MainLayout from './layouts/MainLayout'; import MainLayout from './layouts/MainLayout';
import AdminLayout from './layouts/AdminLayout';
import ErrorBoundary from './components/ErrorBoundary'; import ErrorBoundary from './components/ErrorBoundary';
import PageLoader from './components/PageLoader'; import PageLoader from './components/PageLoader';
import { Toaster } from './components/ui/sonner'; import { Toaster } from './components/ui/sonner';
@@ -16,27 +23,45 @@ const ComposePage = lazy(() => import('./pages/ComposePage'));
const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage')); const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
const ProfilePage = lazy(() => import('./pages/ProfilePage')); const ProfilePage = lazy(() => import('./pages/ProfilePage'));
const FavoritesPage = lazy(() => import('./pages/FavoritesPage')); const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
const AdminPostsPage = lazy(() => import('./pages/admin/AdminPostsPage'));
const AdminCommentsPage = lazy(() => import('./pages/admin/AdminCommentsPage'));
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage'));
const AdminSettingsPage = lazy(() => import('./pages/admin/AdminSettingsPage'));
const router = createBrowserRouter(
createRoutesFromElements(
<>
<Route path="/login" element={<Suspense fallback={<PageLoader />}><LoginPage /></Suspense>} />
<Route path="/register" element={<Suspense fallback={<PageLoader />}><RegisterPage /></Suspense>} />
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<Suspense fallback={<PageLoader />}><AdminDashboardPage /></Suspense>} />
<Route path="boards" element={<Suspense fallback={<PageLoader />}><BoardsManagePage /></Suspense>} />
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
</Route>
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/post/:id" element={<PostDetailPage />} />
<Route path="/post/:id/edit" element={<ComposePage />} />
<Route path="/compose" element={<ComposePage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/favorites" element={<FavoritesPage />} />
</Route>
</>,
),
);
export default function App() { export default function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<ErrorBoundary> <ErrorBoundary>
<BrowserRouter> <RouterProvider router={router} />
<Routes>
<Route path="/login" element={<Suspense fallback={<PageLoader />}><LoginPage /></Suspense>} />
<Route path="/register" element={<Suspense fallback={<PageLoader />}><RegisterPage /></Suspense>} />
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/post/:id" element={<PostDetailPage />} />
<Route path="/post/:id/edit" element={<ComposePage />} />
<Route path="/compose" element={<ComposePage />} />
<Route path="/boards" element={<BoardsManagePage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/favorites" element={<FavoritesPage />} />
</Route>
</Routes>
</BrowserRouter>
<Toaster /> <Toaster />
</ErrorBoundary> </ErrorBoundary>
</AuthProvider> </AuthProvider>

View File

@@ -1,4 +1,4 @@
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats } from './types'; import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision } from './types';
const BASE = ''; const BASE = '';
@@ -24,13 +24,17 @@ async function request<T>(url: string, opts: RequestInit = {}): Promise<T> {
export const api = { export const api = {
me: () => request<{ user: User | null }>('/api/me'), me: () => request<{ user: User | null }>('/api/me'),
stats: () => request<ForumStats>('/api/stats'), stats: () => request<ForumStats>('/api/stats'),
forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'),
boards: () => request<{ boards: Board[] }>('/api/boards'), boards: () => request<{ boards: Board[] }>('/api/boards'),
posts: (params: Record<string, string | number>) => { posts: (params: Record<string, string | number>) => {
const q = new URLSearchParams(params as Record<string, string>).toString(); const q = new URLSearchParams(params as Record<string, string>).toString();
return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`); return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`);
}, },
hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'), hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'),
post: (id: number) => request<{ post: PostItem; comment_count: number; liked: boolean; favorited: boolean }>(`/api/posts/${id}`), post: (id: number, opts?: { skipView?: boolean }) => {
const q = opts?.skipView ? '?skip_view=1' : '';
return request<PostDetailResponse>(`/api/posts/${id}${q}`);
},
comments: (id: number, myIds?: number[]) => { comments: (id: number, myIds?: number[]) => {
const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : ''; const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : '';
return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`); return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`);
@@ -39,11 +43,59 @@ export const api = {
online: () => request<OnlineStats>('/api/online'), online: () => request<OnlineStats>('/api/online'),
presence: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/presence', { method: 'POST' }), presence: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/presence', { method: 'POST' }),
favorites: () => request<{ favorites: unknown[]; total: number }>('/api/favorites'), favorites: () => request<{ favorites: unknown[]; total: number }>('/api/favorites'),
createBoard: (body: { name: string; description: string; sort_order: number }) => createBoard: (body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) =>
request<{ board: Board }>('/api/admin/boards', { method: 'POST', body: JSON.stringify(body) }), request<{ board: Board }>('/api/admin/boards', { method: 'POST', body: JSON.stringify(body) }),
updateBoard: (id: number, body: { name: string; description: string; sort_order: number }) => updateBoard: (id: number, body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) =>
request<{ board: Board }>(`/api/admin/boards/${id}`, { method: 'PUT', body: JSON.stringify(body) }), request<{ board: Board }>(`/api/admin/boards/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteBoard: (id: number) => request(`/api/admin/boards/${id}`, { method: 'DELETE' }), deleteBoard: (id: number) => request(`/api/admin/boards/${id}`, { method: 'DELETE' }),
// 管理后台 API
adminDashboard: () => request<AdminDashboard>('/api/admin/dashboard'),
adminSettings: () => request<AdminSettings>('/api/admin/settings'),
adminPosts: (params: { page?: number; keyword?: string }) => {
const q = new URLSearchParams();
if (params.page) q.set('page', String(params.page));
if (params.keyword) q.set('keyword', params.keyword);
const qs = q.toString();
return request<{ posts: PostItem[]; total: number; page: number; total_pages: number }>(
`/api/admin/posts${qs ? `?${qs}` : ''}`,
);
},
adminPinPost: (id: number, pinned: boolean) =>
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, {
method: 'POST', body: JSON.stringify({ pinned }),
}),
adminLockPost: (id: number, locked: boolean) =>
request<{ message: string; edit_locked: boolean }>(`/api/admin/posts/${id}/lock`, {
method: 'POST', body: JSON.stringify({ locked }),
}),
adminUpdateForumSettings: (body: ForumLimits) =>
request<{ message: string; limits: ForumLimits }>('/api/admin/settings/forum', {
method: 'PUT', body: JSON.stringify(body),
}),
adminUpdateFilterWords: (content: string) =>
request<{ message: string; word_count: number }>('/api/admin/settings/filter-words', {
method: 'PUT', body: JSON.stringify({ content }),
}),
postRevisions: (id: number) =>
request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`),
postRevision: (id: number, revId: number) =>
request<{ revision: PostRevision }>(`/api/posts/${id}/revisions/${revId}`),
adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }),
adminComments: (page = 1) =>
request<{ comments: Comment[]; total: number; page: number; total_pages: number }>(
`/api/admin/comments?page=${page}`,
),
adminDeleteComment: (id: number) => request(`/api/admin/comments/${id}`, { method: 'DELETE' }),
adminUsers: (page = 1) =>
request<{ users: User[]; total: number; page: number; total_pages: number }>(
`/api/admin/users?page=${page}`,
),
adminBanUser: (id: number, banned: boolean) =>
request<{ message: string; banned: boolean }>(`/api/admin/users/${id}/ban`, {
method: 'POST', body: JSON.stringify({ banned }),
}),
adminBackup: () =>
request<{ message: string; filename: string; download: string }>('/api/admin/backup', { method: 'POST' }),
updateNickname: (nickname: string) => { updateNickname: (nickname: string) => {
const fd = new FormData(); const fd = new FormData();
fd.append('nickname', nickname); fd.append('nickname', nickname);
@@ -60,6 +112,11 @@ export const api = {
fd.append('avatar', file); fd.append('avatar', file);
return request<{ avatar: string }>('/api/profile/avatar', { method: 'POST', body: fd, headers: {} }); return request<{ avatar: string }>('/api/profile/avatar', { method: 'POST', body: fd, headers: {} });
}, },
uploadPostImage: (file: File) => {
const fd = new FormData();
fd.append('image', file);
return request<{ url: string }>('/api/uploads/image', { method: 'POST', body: fd, headers: {} });
},
createPost: (data: { board_id: string; title: string; content: string; tags?: string }) => { createPost: (data: { board_id: string; title: string; content: string; tags?: string }) => {
const fd = new FormData(); const fd = new FormData();
fd.append('board_id', data.board_id); fd.append('board_id', data.board_id);

View File

@@ -4,12 +4,16 @@ export interface User {
nickname: string; nickname: string;
avatar: string; avatar: string;
role: 'user' | 'admin'; role: 'user' | 'admin';
banned?: boolean;
created_at?: string;
} }
export interface Board { export interface Board {
id: number; id: number;
name: string; name: string;
description: string; description: string;
icon?: string;
color_index?: number;
sort_order: number; sort_order: number;
post_count?: number; post_count?: number;
} }
@@ -28,14 +32,39 @@ export interface PostItem {
content?: string; content?: string;
tags: string; tags: string;
pinned: boolean; pinned: boolean;
edit_locked?: boolean;
like_count: number; like_count: number;
view_count: number; view_count: number;
comment_count: number; comment_count: number;
last_reply_at?: string;
created_at: string; created_at: string;
updated_at?: string;
board?: Board; board?: Board;
user?: User; user?: User;
} }
export interface PostRevision {
id: number;
post_id: number;
editor_id: number;
title: string;
content: string;
tags: string;
created_at: string;
editor?: User;
}
export interface PostDetailResponse {
post: PostItem;
comment_count: number;
liked: boolean;
favorited: boolean;
can_edit?: boolean;
edit_block_reason?: string;
is_edited?: boolean;
post_edit_window_hours?: number;
}
export interface Comment { export interface Comment {
id: number; id: number;
post_id: number; post_id: number;
@@ -50,9 +79,71 @@ export interface Comment {
content_hidden?: boolean; content_hidden?: boolean;
created_at: string; created_at: string;
user?: User; user?: User;
post?: PostItem;
reply_target?: Comment; reply_target?: Comment;
} }
export interface AdminDashboard {
users: number;
posts: number;
boards: number;
comments: number;
online: number;
recent_posts: PostItem[];
}
export interface ForumLimits {
post_edit_window_hours: number;
rate_limit_post: number;
rate_limit_comment: number;
rate_limit_register: number;
rate_limit_login: number;
rate_limit_window_sec: number;
post_title_max: number;
post_tags_max: number;
post_content_max: number;
comment_max: number;
search_keyword_min: number;
search_keyword_max: number;
page_size_default: number;
page_size_max: number;
feed_max_pages: number;
feed_max_items: number;
password_min_len: number;
avatar_max_mb: number;
}
export interface ForumLimitsPublic {
post_title_max: number;
post_tags_max: number;
post_content_max: number;
comment_max: number;
search_keyword_min: number;
search_keyword_max: number;
page_size_default: number;
feed_max_pages: number;
feed_max_items: number;
password_min_len: number;
avatar_max_mb: number;
}
export interface AdminSettings {
filter_path: string;
data_dir: string;
db_path: string;
port: number;
limits: ForumLimits;
filter_words: string;
filter_word_count: number;
}
export interface Paginated<T> {
total: number;
page: number;
total_pages: number;
items: T;
}
export interface Notification { export interface Notification {
id: number; id: number;
title: string; title: string;

View File

@@ -1,16 +1,40 @@
import { import {
useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef, useMemo, type ReactNode, useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo, type ReactNode,
} from 'react'; } from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import { TextSelection } from '@tiptap/pm/state';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import DOMPurify from 'dompurify';
import { import {
Bold, Italic, Strikethrough, Link, Code, Quote, Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
List, ListOrdered, Image, Eye, Pencil, Minus, LockKeyhole, List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
FileCode, PenLine, Maximize2, Minimize2,
} from 'lucide-react'; } from 'lucide-react';
import { markdownToHtml, countWords } from '../utils/markdown'; import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
import { renderPostContentHtml } from '../utils/postContent'; import { renderPostContentHtml } from '../utils/postContent';
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
import { handleMarkdownTabKey, insertAtCursor } from '../utils/markdownIndent';
import {
wrapMarkdownSelection,
prefixMarkdownLines,
cycleMarkdownHeading,
insertMarkdownMembersOnly,
insertMarkdownLink,
} from '../utils/markdownFormat';
import { countWords } from '../utils/text';
import { api } from '../api/client';
import { notify } from '@/lib/notify';
import { MembersOnly } from './editor/MembersOnlyExtension';
import { TabIndent } from './editor/TabIndentExtension';
import { ArticleLinkDialog } from './editor/ArticleLinkDialog';
import { Tooltip } from './ui/Tooltip';
export interface ArticleEditorHandle { export interface ArticleEditorHandle {
getHTML: () => string; getHTML: () => string;
getMarkdown: () => string;
isEmpty: () => boolean; isEmpty: () => boolean;
focus: () => void; focus: () => void;
} }
@@ -21,259 +45,476 @@ interface Props {
placeholder?: string; placeholder?: string;
} }
type ViewMode = 'edit' | 'preview' | 'split'; type EditorMode = 'rich' | 'markdown';
type LinkTarget = 'rich' | 'markdown';
interface ToolBtn { interface ToolBtn {
icon: ReactNode; icon: ReactNode;
title: string; title: string;
hint?: string;
align?: 'start' | 'center' | 'end';
active?: boolean;
className?: string;
action: () => void; action: () => void;
} }
/** 去掉行首已有的 Markdown 块级前缀 */ const MEMBERS_ONLY_PLACEHOLDER = '在此输入仅登录用户可见的内容…';
function stripLinePrefix(line: string): string {
return line /** 净化编辑器 HTML保留 members-only 自定义标签 */
.replace(/^\r/, '') function sanitizeHtml(html: string): string {
.replace(/^#{1,6}\s*/, '') return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
.replace(/^>\s*/, '') }
.replace(/^[-*+]\s*/, '')
.replace(/^\d+\.\s*/, ''); /** 判断编辑器内容是否为空 */
function isEditorEmpty(editor: Editor): boolean {
return editor.state.doc.textContent.trim().length === 0;
}
/** 标题循环:正文 → H2 → H3 → … → H6 → 正文 */
function cycleHeading(editor: Editor) {
for (let level = 2; level <= 6; level += 1) {
if (editor.isActive('heading', { level })) {
if (level === 6) {
editor.chain().focus().setParagraph().run();
} else {
editor.chain().focus().toggleHeading({ level: (level + 1) as 2 | 3 | 4 | 5 | 6 }).run();
}
return;
}
}
editor.chain().focus().toggleHeading({ level: 2 }).run();
}
/** 触发图片文件选择并上传 */
async function uploadPostImageFile(): Promise<string | null> {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) {
resolve(null);
return;
}
try {
const { url } = await api.uploadPostImage(file);
resolve(url);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
resolve(null);
}
};
input.click();
});
}
/** 渲染工具栏按钮列表 */
function renderToolButtons(tools: ToolBtn[]) {
return tools.map((t, i) => (
<span key={i} className={t.className === 'article-tool-btn--members' ? 'article-editor-tools-members' : undefined}>
{t.className === 'article-tool-btn--members' ? (
<span className="article-editor-tools-sep" aria-hidden="true" />
) : null}
<Tooltip content={t.title} hint={t.hint} align={t.align} side="bottom">
<button
type="button"
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
onMouseDown={e => e.preventDefault()}
onClick={t.action}
>
{t.icon}
</button>
</Tooltip>
</span>
));
} }
const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEditor( const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEditor(
{ value, onChange, placeholder = '在此撰写正文…' }, { value, onChange, placeholder = '在此撰写正文…' },
ref, ref,
) { ) {
const textareaRef = useRef<HTMLTextAreaElement>(null); const isInternalUpdate = useRef(false);
const selectionRef = useRef({ start: 0, end: 0 }); const lastValueRef = useRef(value);
const [viewMode, setViewMode] = useState<ViewMode>('split'); const [, setEditorTick] = useState(0);
const [previewHtml, setPreviewHtml] = useState(''); const [mode, setMode] = useState<EditorMode>('rich');
const [fullscreen, setFullscreen] = useState(false);
const [markdownSource, setMarkdownSource] = useState('');
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [linkDialogUrl, setLinkDialogUrl] = useState('');
const [linkTarget, setLinkTarget] = useState<LinkTarget>('rich');
const markdownRef = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(ref, () => ({ const editor = useEditor({
getHTML: () => markdownToHtml(value), extensions: [
getMarkdown: () => value, StarterKit.configure({
isEmpty: () => value.trim().length === 0, heading: { levels: [2, 3, 4, 5, 6] },
focus: () => textareaRef.current?.focus(), }),
})); Underline,
Link.configure({
openOnClick: false,
autolink: true,
defaultProtocol: 'https',
}),
Image.configure({ inline: false, allowBase64: false }),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'paragraph' && node.parent?.type.name === 'membersOnly') {
return MEMBERS_ONLY_PLACEHOLDER;
}
return placeholder;
},
includeChildren: true,
}),
MembersOnly,
TabIndent,
],
content: sanitizeHtml(value) || '',
onUpdate: ({ editor: ed }) => {
const html = sanitizeHtml(ed.getHTML());
isInternalUpdate.current = true;
lastValueRef.current = html;
onChange(html);
const saveSelection = useCallback(() => { // 清空后折叠选区,避免空文档残留全选高亮
const ta = textareaRef.current; if (isEditorEmpty(ed) && !ed.state.selection.empty) {
if (!ta) return; ed.commands.setTextSelection(1);
selectionRef.current = { start: ta.selectionStart, end: ta.selectionEnd }; }
}, []); },
onSelectionUpdate: () => {
setEditorTick(t => t + 1);
},
onTransaction: () => {
setEditorTick(t => t + 1);
},
editorProps: {
attributes: {
class: 'article-prosemirror post-detail-content',
spellcheck: 'false',
},
handleKeyDown: (view, event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
if (!view.state.doc.textContent.trim()) {
event.preventDefault();
view.dispatch(view.state.tr.setSelection(
TextSelection.create(view.state.doc, 1),
));
return true;
}
}
return false;
},
},
});
const getSelection = useCallback(() => { // 外部 value 变更时同步到编辑器(如加载已有帖子)
const ta = textareaRef.current;
if (ta && document.activeElement === ta) {
return { start: ta.selectionStart, end: ta.selectionEnd };
}
return selectionRef.current;
}, []);
const restoreSelection = useCallback((start: number, end = start) => {
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(start, end);
selectionRef.current = { start, end };
});
}, []);
// 实时预览,短 debounce 保证流畅
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setPreviewHtml(markdownToHtml(value)), 60); if (!editor || mode !== 'rich') return;
return () => clearTimeout(t); if (isInternalUpdate.current) {
}, [value]); isInternalUpdate.current = false;
// 编辑区随内容向下延伸,最小高度撑满视口剩余空间
const adjustTextareaHeight = useCallback(() => {
const ta = textareaRef.current;
if (!ta || viewMode === 'preview') return;
ta.style.height = '0px';
const contentHeight = ta.scrollHeight;
const top = ta.getBoundingClientRect().top;
const minHeight = Math.max(280, window.innerHeight - top - 56);
ta.style.height = `${Math.max(minHeight, contentHeight)}px`;
}, [viewMode]);
useEffect(() => {
adjustTextareaHeight();
}, [value, viewMode, adjustTextareaHeight]);
useEffect(() => {
const onResize = () => adjustTextareaHeight();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [adjustTextareaHeight]);
const insertAtCursor = useCallback((before: string, after = '', placeholderText = '') => {
const ta = textareaRef.current;
if (!ta) return;
const { start, end } = getSelection();
const selected = value.slice(start, end) || placeholderText;
const next = value.slice(0, start) + before + selected + after + value.slice(end);
onChange(next);
restoreSelection(start + before.length + selected.length);
}, [value, onChange, getSelection, restoreSelection]);
const wrapLine = useCallback((prefix: string) => {
const ta = textareaRef.current;
if (!ta) return;
const { start } = getSelection();
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineEnd = value.indexOf('\n', start);
const end = lineEnd === -1 ? value.length : lineEnd;
const line = value.slice(lineStart, end);
const stripped = stripLinePrefix(line);
const next = value.slice(0, lineStart) + prefix + stripped + value.slice(end);
onChange(next);
restoreSelection(lineStart + prefix.length + stripped.length);
}, [value, onChange, getSelection, restoreSelection]);
/** 标题:无 → H2 → H3 → … → H6 → 取消 */
const toggleHeading = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
const { start } = getSelection();
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineEnd = value.indexOf('\n', start);
const end = lineEnd === -1 ? value.length : lineEnd;
const line = value.slice(lineStart, end);
const normalized = line.replace(/^\r/, '');
const match = normalized.match(/^(#{1,6})\s+(.*)$/);
let nextLine: string;
if (match) {
const level = match[1].length;
const text = match[2];
nextLine = level >= 6 ? text : `${'#'.repeat(level + 1)} ${text}`;
} else {
nextLine = `## ${stripLinePrefix(normalized)}`;
}
const next = value.slice(0, lineStart) + nextLine + value.slice(end);
onChange(next);
const cursor = lineStart + nextLine.length;
restoreSelection(cursor);
}, [value, onChange, getSelection, restoreSelection]);
/** 包裹为仅登录用户可见区块 */
const wrapMembersOnly = useCallback(() => {
const { start, end } = getSelection();
if (start !== end) {
const selected = value.slice(start, end);
const block = `\n:::members\n${selected}\n:::\n`;
const next = value.slice(0, start) + block + value.slice(end);
onChange(next);
restoreSelection(start + ':::members\n'.length + 1);
return; return;
} }
insertAtCursor('\n:::members\n', '\n:::\n', '在此输入仅登录用户可见的内容…'); const next = sanitizeHtml(value);
}, [value, onChange, getSelection, restoreSelection, insertAtCursor]); if (next === lastValueRef.current) return;
lastValueRef.current = next;
editor.commands.setContent(next || '', { emitUpdate: false });
}, [value, editor, mode]);
const tools: ToolBtn[] = [ // 全屏时锁定页面滚动Esc 退出
{ icon: <strong>H</strong>, title: '标题H2再次点击升级', action: toggleHeading }, useEffect(() => {
{ icon: <Bold size={15} />, title: '加粗', action: () => insertAtCursor('**', '**', '加粗') }, if (!fullscreen) return undefined;
{ icon: <Italic size={15} />, title: '斜体', action: () => insertAtCursor('*', '*', '斜体') },
{ icon: <Strikethrough size={15} />, title: '删除线', action: () => insertAtCursor('~~', '~~', '删除') },
{ icon: <Minus size={15} />, title: '分割线', action: () => insertAtCursor('\n\n---\n\n') },
{ icon: <Quote size={15} />, title: '引用', action: () => wrapLine('> ') },
{ icon: <List size={15} />, title: '无序列表', action: () => wrapLine('- ') },
{ icon: <ListOrdered size={15} />, title: '有序列表', action: () => wrapLine('1. ') },
{ icon: <Code size={15} />, title: '代码块', action: () => insertAtCursor('\n```\n', '\n```\n', 'code') },
{ icon: <Link size={15} />, title: '链接', action: () => insertAtCursor('[', '](url)', '链接文字') },
{ icon: <Image size={15} />, title: '图片', action: () => insertAtCursor('![', '](url)', '描述') },
{ icon: <LockKeyhole size={15} />, title: '登录可见(选中文字后点击可包裹)', action: wrapMembersOnly },
];
const displayPreviewHtml = useMemo(() => { const prevOverflow = document.body.style.overflow;
if (!value.trim()) { document.body.style.overflow = 'hidden';
return `<p class="article-preview-placeholder">${placeholder}</p>`;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setFullscreen(false);
};
window.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = prevOverflow;
window.removeEventListener('keydown', onKeyDown);
};
}, [fullscreen]);
useImperativeHandle(ref, () => ({
getHTML: () => {
if (mode === 'markdown') {
return sanitizeHtml(markdownToHtml(markdownSource));
}
return editor ? sanitizeHtml(editor.getHTML()) : value;
},
isEmpty: () => {
if (mode === 'markdown') {
return markdownSource.trim().length === 0;
}
return editor ? isEditorEmpty(editor) : !value.trim();
},
focus: () => {
if (mode === 'markdown') {
markdownRef.current?.focus();
return;
}
editor?.commands.focus();
},
}), [editor, value, mode, markdownSource]);
const handleMarkdownChange = useCallback((next: string) => {
setMarkdownSource(next);
const html = sanitizeHtml(markdownToHtml(next));
isInternalUpdate.current = true;
lastValueRef.current = html;
onChange(html);
}, [onChange]);
const openLinkDialog = useCallback((target: LinkTarget) => {
if (target === 'rich') {
if (!editor) return;
const prev = editor.getAttributes('link').href as string | undefined;
setLinkDialogUrl(prev ?? '');
} else {
setLinkDialogUrl('');
} }
return renderPostContentHtml(previewHtml, true); setLinkTarget(target);
}, [value, previewHtml, placeholder]); setLinkDialogOpen(true);
}, [editor]);
const words = countWords(value); const applyLink = useCallback((url: string) => {
const showEdit = viewMode === 'edit' || viewMode === 'split'; if (linkTarget === 'markdown') {
const showPreview = viewMode === 'preview' || viewMode === 'split'; const textarea = markdownRef.current;
if (!textarea || !url) return;
insertMarkdownLink(textarea, markdownSource, url, handleMarkdownChange);
return;
}
if (!editor) return;
if (!url) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}, [editor, linkTarget, markdownSource, handleMarkdownChange]);
const removeLink = useCallback(() => {
if (!editor) return;
editor.chain().focus().extendMarkRange('link').unsetLink().run();
}, [editor]);
const setImage = useCallback(async () => {
if (!editor) return;
const url = await uploadPostImageFile();
if (url) {
editor.chain().focus().setImage({ src: url }).run();
}
}, [editor]);
const wrapMembersOnly = useCallback(() => {
if (!editor) return;
if (editor.isActive('membersOnly')) {
editor.chain().focus().exitMembersOnly().run();
return;
}
const { from, to, empty } = editor.state.selection;
if (!empty && from !== to) {
editor.chain().focus().wrapMembersOnly().run();
return;
}
editor.chain().focus().insertMembersOnly().run();
}, [editor]);
const switchToMarkdown = useCallback(() => {
if (!editor) return;
const html = sanitizeHtml(editor.getHTML());
lastValueRef.current = html;
setMarkdownSource(htmlToMarkdown(html));
setMode('markdown');
}, [editor]);
const switchToRich = useCallback(() => {
const html = sanitizeHtml(markdownToHtml(markdownSource));
lastValueRef.current = html;
isInternalUpdate.current = true;
onChange(html);
if (editor) {
editor.commands.setContent(html || '', { emitUpdate: false });
}
setMode('rich');
}, [editor, markdownSource, onChange]);
const withMarkdown = useCallback((fn: (
textarea: HTMLTextAreaElement,
value: string,
onChange: (v: string) => void,
) => void) => () => {
const textarea = markdownRef.current;
if (!textarea) return;
fn(textarea, markdownSource, handleMarkdownChange);
}, [markdownSource, handleMarkdownChange]);
const insertMarkdownImage = useCallback(async () => {
const textarea = markdownRef.current;
if (!textarea) return;
const url = await uploadPostImageFile();
if (!url) return;
insertAtCursor(textarea, markdownSource, `\n\n![图片](${url})\n\n`, handleMarkdownChange);
}, [markdownSource, handleMarkdownChange]);
const markdownPreviewHtml = useMemo(
() => renderPostContentHtml(sanitizeHtml(markdownToHtml(markdownSource)), true),
[markdownSource],
);
const buildRichTools = useCallback((): ToolBtn[] => {
if (!editor) return [];
return [
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', active: editor.isActive('heading'), action: () => cycleHeading(editor) },
{ icon: <Bold size={15} />, title: '加粗', active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() },
{ icon: <Italic size={15} />, title: '斜体', active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run() },
{ icon: <UnderlineIcon size={15} />, title: '下划线', active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() },
{ icon: <Strikethrough size={15} />, title: '删除线', active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run() },
{ icon: <Minus size={15} />, title: '分割线', action: () => editor.chain().focus().setHorizontalRule().run() },
{ icon: <Quote size={15} />, title: '引用', active: editor.isActive('blockquote'), action: () => editor.chain().focus().toggleBlockquote().run() },
{ icon: <List size={15} />, title: '无序列表', active: editor.isActive('bulletList'), action: () => editor.chain().focus().toggleBulletList().run() },
{ icon: <ListOrdered size={15} />, title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() },
{ icon: <Code size={15} />, title: '代码块', active: editor.isActive('codeBlock'), action: () => editor.chain().focus().toggleCodeBlock().run() },
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') },
{ icon: <ImageIcon size={15} />, title: '上传图片', action: setImage },
{
icon: <LockKeyhole size={15} />,
title: '登录可见',
hint: '独立输入区Ctrl+Enter 退出',
active: editor.isActive('membersOnly'),
className: 'article-tool-btn--members',
action: wrapMembersOnly,
},
];
}, [editor, openLinkDialog, setImage, wrapMembersOnly]);
const buildMarkdownTools = useCallback((): ToolBtn[] => [
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
{ icon: <Bold size={15} />, title: '加粗', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '**', '**', '加粗文字', ch)) },
{ icon: <Italic size={15} />, title: '斜体', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '*', '*', '斜体文字', ch)) },
{ icon: <UnderlineIcon size={15} />, title: '下划线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '<u>', '</u>', '下划线文字', ch)) },
{ icon: <Strikethrough size={15} />, title: '删除线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '~~', '~~', '删除线文字', ch)) },
{ icon: <Minus size={15} />, title: '分割线', action: withMarkdown((ta, v, ch) => insertAtCursor(ta, v, '\n\n---\n\n', ch)) },
{ icon: <Quote size={15} />, title: '引用', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '> ', ch)) },
{ icon: <List size={15} />, title: '无序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '- ', ch)) },
{ icon: <ListOrdered size={15} />, title: '有序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '1. ', ch)) },
{ icon: <Code size={15} />, title: '代码块', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '```\n', '\n```', '代码', ch)) },
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
{
icon: <LockKeyhole size={15} />,
title: '登录可见',
hint: '插入 <members-only> 区块',
className: 'article-tool-btn--members',
action: withMarkdown(insertMarkdownMembersOnly),
},
], [withMarkdown, openLinkDialog, insertMarkdownImage]);
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
const words = mode === 'markdown'
? countWords(markdownSource)
: (editor ? countWords(editor.getText()) : 0);
return ( return (
<div className="article-editor"> <div className={`article-editor article-editor--${mode}${fullscreen ? ' article-editor--fullscreen' : ''}`}>
<div className="article-editor-bar"> <div className="article-editor-bar">
<div className="article-editor-tools"> <div className="article-editor-tools">
{tools.map((t, i) => ( {renderToolButtons(tools)}
<button
key={i}
type="button"
className={`article-tool-btn${i === tools.length - 1 ? ' article-tool-btn--members' : ''}`}
title={t.title}
onMouseDown={e => e.preventDefault()}
onClick={t.action}
>
{t.icon}
</button>
))}
</div>
<div className="article-editor-modes">
<button
type="button"
className={`article-mode-btn${viewMode === 'edit' ? ' active' : ''}`}
onClick={() => setViewMode('edit')}
title="仅编辑"
>
<Pencil size={14} />
</button>
<button
type="button"
className={`article-mode-btn${viewMode === 'split' ? ' active' : ''}`}
onClick={() => setViewMode('split')}
title="分栏预览"
>
</button>
<button
type="button"
className={`article-mode-btn${viewMode === 'preview' ? ' active' : ''}`}
onClick={() => setViewMode('preview')}
title="仅预览"
>
<Eye size={14} />
</button>
</div> </div>
</div> </div>
<div className={`article-editor-panes article-editor-panes--${viewMode}`}> <div className="article-editor-body">
{showEdit && ( {mode === 'rich' ? (
<div className="article-pane article-pane--edit"> <div className="article-editor-pane article-editor-pane--rich">
<textarea <div className="article-editor-scroll">
ref={textareaRef} <EditorContent editor={editor} className="article-editor-content" />
className="article-textarea" </div>
value={value}
onChange={e => onChange(e.target.value)}
onSelect={saveSelection}
onKeyUp={saveSelection}
onClick={saveSelection}
onFocus={saveSelection}
placeholder={placeholder}
spellCheck={false}
/>
</div> </div>
)} ) : (
{showPreview && ( <div className="article-editor-markdown">
<div className="article-pane article-pane--preview"> <div className="article-editor-pane article-editor-pane--source">
{viewMode === 'split' && <div className="article-pane-label"></div>} <div className="article-editor-scroll">
<div <textarea
className={`article-preview post-detail-content${!value.trim() ? ' article-preview--empty' : ''}`} ref={markdownRef}
dangerouslySetInnerHTML={{ __html: displayPreviewHtml }} className="article-editor-markdown-input"
/> value={markdownSource}
onChange={e => handleMarkdownChange(e.target.value)}
onKeyDown={e => handleMarkdownTabKey(e, handleMarkdownChange)}
placeholder="在此编写 Markdown 源码…"
spellCheck={false}
/>
</div>
</div>
<div className="article-editor-markdown-preview">
<div className="article-editor-markdown-preview-label"></div>
<div
className="article-editor-markdown-preview-body post-detail-content"
dangerouslySetInnerHTML={{ __html: markdownPreviewHtml }}
/>
</div>
</div> </div>
)} )}
</div> </div>
<div className="article-editor-status"> <div className="article-editor-status">
<span>{words} </span> <div className="article-editor-status-meta">
<span>Markdown</span> <span>{words} </span>
<span className="article-editor-status-sep">·</span>
<span>
{mode === 'rich' ? '富文本' : 'Markdown 源码'}
{' · Tab 缩进 / Shift+Tab 回退'}
{mode === 'rich' ? ' · 登录可见内 Ctrl+Enter 退出' : ''}
</span>
</div>
<div className="article-editor-status-actions">
<Tooltip
content={mode === 'rich' ? 'Markdown 源码' : '富文本编辑'}
hint={mode === 'rich' ? '切换为 Markdown 源码编写' : '返回所见即所得编辑'}
align="end"
side="top"
>
<button
type="button"
className={`article-editor-view-btn${mode === 'markdown' ? ' active' : ''}`}
onMouseDown={e => e.preventDefault()}
onClick={mode === 'rich' ? switchToMarkdown : switchToRich}
>
{mode === 'rich' ? <FileCode size={15} /> : <PenLine size={15} />}
<span>{mode === 'rich' ? '源码' : '富文本'}</span>
</button>
</Tooltip>
<Tooltip
content={fullscreen ? '退出全屏' : '全屏编辑'}
hint={fullscreen ? 'Esc 也可退出' : '沉浸式编写长文'}
align="end"
side="top"
>
<button
type="button"
className="article-editor-view-btn"
onMouseDown={e => e.preventDefault()}
onClick={() => setFullscreen(v => !v)}
>
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
<span>{fullscreen ? '退出全屏' : '全屏'}</span>
</button>
</Tooltip>
</div>
</div> </div>
<ArticleLinkDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
initialUrl={linkDialogUrl}
onConfirm={applyLink}
onRemove={linkTarget === 'rich' && linkDialogUrl ? removeLink : undefined}
/>
</div> </div>
); );
}); });

View File

@@ -0,0 +1,116 @@
import { useCallback, useEffect, useState } from 'react';
import Cropper, { type Area } from 'react-easy-crop';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { getCroppedAvatarFile } from '../utils/avatarCrop';
interface Props {
open: boolean;
imageSrc: string | null;
fileName?: string;
onOpenChange: (open: boolean) => void;
onConfirm: (file: File) => void;
}
export default function AvatarCropDialog({
open,
imageSrc,
fileName,
onOpenChange,
onConfirm,
}: Props) {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const [confirming, setConfirming] = useState(false);
useEffect(() => {
if (open) {
setCrop({ x: 0, y: 0 });
setZoom(1);
setCroppedAreaPixels(null);
}
}, [open, imageSrc]);
const onCropComplete = useCallback((_: Area, pixels: Area) => {
setCroppedAreaPixels(pixels);
}, []);
const handleConfirm = async () => {
if (!imageSrc || !croppedAreaPixels) return;
setConfirming(true);
try {
const file = await getCroppedAvatarFile(imageSrc, croppedAreaPixels, fileName);
onConfirm(file);
onOpenChange(false);
} catch {
notify.error('裁剪失败,请重试');
} finally {
setConfirming(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="avatar-crop-dialog">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
GIF JPG
</DialogDescription>
</DialogHeader>
<div className="avatar-crop-stage">
{imageSrc ? (
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
) : (
<div className="avatar-crop-loading">
<Spinner size="lg" />
</div>
)}
</div>
<div className="avatar-crop-zoom">
<span className="avatar-crop-zoom-label"></span>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={e => setZoom(Number(e.target.value))}
aria-label="缩放"
/>
</div>
<DialogFooter>
<Button variant="outline" disabled={confirming} onClick={() => onOpenChange(false)}>
</Button>
<Button loading={confirming} disabled={!imageSrc} onClick={handleConfirm}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,93 @@
import { cn } from '@/lib/utils';
import {
BOARD_ICON_OPTIONS,
BOARD_PALETTE_SIZE,
getBoardIcon,
getBoardThemeIndex,
} from '../utils/boardTheme';
import type { Board } from '../api/types';
interface IconPickerProps {
value: string;
onChange: (key: string) => void;
board: Pick<Board, 'id' | 'color_index'>;
}
interface ColorPickerProps {
value: number;
onChange: (index: number) => void;
boardId: number;
}
/** 板块图标选择器 */
export function BoardIconPicker({ value, onChange, board }: IconPickerProps) {
const previewBoard = { id: board.id, icon: value || undefined, color_index: board.color_index };
const PreviewIcon = getBoardIcon(previewBoard);
const themeIdx = getBoardThemeIndex(previewBoard);
return (
<div className="board-appearance-picker">
<div className="board-appearance-picker__preview">
<span className={cn('board-appearance-picker__preview-icon', `sidebar-board-icon--${themeIdx}`)}>
<PreviewIcon aria-hidden />
</span>
<span className="board-appearance-picker__preview-hint"></span>
</div>
<div className="board-icon-grid" role="listbox" aria-label="选择板块图标">
<button
type="button"
role="option"
aria-selected={!value}
className={cn('board-icon-option', !value && 'board-icon-option--active')}
title="自动(按板块 ID"
onClick={() => onChange('')}
>
<span className="board-icon-option__auto">A</span>
</button>
{BOARD_ICON_OPTIONS.map(({ key, label, Icon }) => (
<button
key={key}
type="button"
role="option"
aria-selected={value === key}
aria-label={label}
title={label}
className={cn('board-icon-option', value === key && 'board-icon-option--active')}
onClick={() => onChange(key)}
>
<Icon aria-hidden />
</button>
))}
</div>
</div>
);
}
/** 板块色标选择器(-1 为自动) */
export function BoardColorPicker({ value, onChange, boardId }: ColorPickerProps) {
return (
<div className="board-color-grid" role="listbox" aria-label="选择板块色标">
<button
type="button"
role="option"
aria-selected={value < 0}
className={cn('board-color-option board-color-option--auto', value < 0 && 'board-color-option--active')}
title="自动(按板块 ID"
onClick={() => onChange(-1)}
>
A
</button>
{Array.from({ length: BOARD_PALETTE_SIZE }, (_, i) => (
<button
key={i}
type="button"
role="option"
aria-selected={value === i}
className={cn('board-color-option', `board-color-option--${i}`, value === i && 'board-color-option--active')}
title={`色标 ${i + 1}`}
onClick={() => onChange(i)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { cn } from '@/lib/utils';
import { getBoardThemeIndex } from '../utils/boardTheme';
import type { Board } from '../api/types';
interface Props {
board: Pick<Board, 'id' | 'name' | 'color_index'>;
className?: string;
}
/** 按板块配置或 id 映射不同色标的 Badge */
export default function BoardBadge({ board, className }: Props) {
const idx = getBoardThemeIndex(board);
return (
<span className={cn('board-badge', `board-badge--${idx}`, className)}>
{board.name}
</span>
);
}

View File

@@ -1,70 +0,0 @@
import { Button } from '@/components/ui/button';
import { LayoutGrid, Folder, Plus } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { Board } from '../api/types';
import { useAuth } from '../hooks/useAuth';
interface Props {
boards: Board[];
loading?: boolean;
selectedId?: number;
onSelect: (id: number) => void;
}
export default function BoardGrid({ boards, loading = false, selectedId = 0, onSelect }: Props) {
const nav = useNavigate();
const { user } = useAuth();
if (loading) {
return (
<div className="board-grid board-grid--skeleton" aria-hidden>
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="board-tab board-tab--skeleton" />
))}
</div>
);
}
if (boards.length === 0) {
return (
<div className="board-grid-empty">
<p className="text-sm text-muted-foreground py-4 text-center">
{user?.role === 'admin' ? '还没有板块,请先创建' : '管理员尚未创建板块'}
</p>
{user?.role === 'admin' && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<Button onClick={() => nav('/boards')}>
<Plus />
</Button>
</div>
)}
</div>
);
}
return (
<div className="board-grid">
<button
type="button"
className={`board-tab ${selectedId === 0 ? 'active' : ''}`}
onClick={() => onSelect(0)}
>
<span className="board-tab-icon"><LayoutGrid size={16} /></span>
<span className="board-tab-name"></span>
</button>
{boards.map(b => (
<button
key={b.id}
type="button"
className={`board-tab ${selectedId === b.id ? 'active' : ''}`}
title={b.description ? `${b.name}${b.description}` : b.name}
onClick={() => onSelect(b.id)}
>
<span className="board-tab-icon"><Folder size={16} /></span>
<span className="board-tab-name">{b.name}</span>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { getBoardIcon } from '../utils/boardTheme';
import type { Board } from '../api/types';
/** 渲染板块 Lucide 图标 */
export default function BoardIconDisplay({
board,
className,
}: {
board: Pick<Board, 'id' | 'icon' | 'color_index'>;
className?: string;
}) {
const Icon = getBoardIcon(board);
return <Icon className={className} aria-hidden />;
}

View File

@@ -8,12 +8,11 @@ interface Props {
/** 渲染评论正文(支持正文内 @ 高亮) */ /** 渲染评论正文(支持正文内 @ 高亮) */
export default function CommentContent({ content, onMentionClick }: Props) { export default function CommentContent({ content, onMentionClick }: Props) {
return ( return (
<div className="floor-body"> <div
<span className="floor-body"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: highlightMentions(content, onMentionClick), __html: highlightMentions(content, onMentionClick),
}} }}
/> />
</div>
); );
} }

View File

@@ -1,8 +1,6 @@
import { Button } from '@/components/ui/button'; import { Users, FileText, LayoutGrid } from 'lucide-react';
import { Plus, Settings } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { Board, ForumStats } from '../api/types'; import type { Board, ForumStats } from '../api/types';
import { useAuth } from '../hooks/useAuth';
interface Props { interface Props {
boardId: number; boardId: number;
@@ -14,55 +12,47 @@ interface Props {
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal }: Props) { export default function FeedHeader({ boardId, keyword, boards, stats, postTotal }: Props) {
const nav = useNavigate(); const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const board = boards.find(b => b.id === boardId); const board = boards.find(b => b.id === boardId);
const title = keyword const title = keyword
? `搜索:${keyword}` ? `搜索:${keyword}`
: (boardId && board ? board.name : '全部帖子'); : (boardId && board ? board.name : '全部帖子');
const hint = keyword const boardHint = boardId && board ? (board.description || '') : '';
? `找到 ${postTotal} 篇相关帖子`
: boardId && board
? (board.description || '欢迎在本板块交流讨论')
: '姜十三论坛 · 拾三一隅,自在交流';
return ( return (
<div className="feed-banner"> <div className={`feed-head${keyword ? ' feed-head--solo' : ''}`}>
<div className="feed-banner-row"> <div className="feed-head__title">
<div className="feed-banner-title"> <h2 title={boardHint || undefined}>{title}</h2>
<h2>{title}</h2> {!keyword && stats && (
<p>{hint}</p> <div className="feed-head__stats">
</div> <span className="feed-stat-chip">
{!keyword && ( <Users aria-hidden />
<div className="feed-actions flex flex-wrap items-center gap-2"> <strong>{stats.users}</strong>
{authLoading ? ( </span>
<span className="feed-action-slot" aria-hidden /> <span className="feed-stat-chip">
) : user ? ( <FileText aria-hidden />
<Button <strong>{stats.posts}</strong>
size="sm" </span>
onClick={() => nav(boardId ? `/compose?board=${boardId}` : '/compose')} <span className="feed-stat-chip">
> <LayoutGrid aria-hidden />
<Plus /> <strong>{stats.boards}</strong>
{boardId ? '在此发帖' : '发布帖子'} </span>
</Button>
) : (
<Button size="sm" onClick={() => nav('/login')}></Button>
)}
{!authLoading && user?.role === 'admin' && (
<Button size="sm" variant="outline" onClick={() => nav('/boards')}>
<Settings />
</Button>
)}
</div> </div>
)} )}
</div> </div>
<div className="feed-stats"> {keyword && (
<span> <strong>{stats?.users ?? '—'}</strong></span> <button
<span> <strong>{stats?.posts ?? '—'}</strong></span> type="button"
<span> <strong>{stats?.boards ?? '—'}</strong></span> className="feed-head__clear"
</div> onClick={() => nav('/')}
>
</button>
)}
{keyword && (
<span className="feed-toolbar__count"> {postTotal} </span>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,64 @@
import { Clock, MessageCircle, Flame } from 'lucide-react';
import { cn } from '@/lib/utils';
export type FeedSort = 'latest' | 'reply' | 'hot';
const SORT_OPTIONS: {
key: FeedSort;
label: string;
hint: string;
icon: typeof Clock;
}[] = [
{ key: 'latest', label: '最新发帖', hint: '按发布时间', icon: Clock },
{ key: 'reply', label: '最新回复', hint: '最近有人回复', icon: MessageCircle },
{ key: 'hot', label: '热门讨论', hint: '按互动热度', icon: Flame },
];
interface Props {
value: FeedSort;
onChange: (sort: FeedSort) => void;
postTotal?: number;
}
export function parseFeedSort(raw: string | null): FeedSort {
if (raw === 'reply' || raw === 'hot') return raw;
return 'latest';
}
export function buildHomeUrl(boardId: number, sort: FeedSort = 'latest') {
const p = new URLSearchParams();
if (boardId) p.set('board', String(boardId));
if (sort !== 'latest') p.set('sort', sort);
const qs = p.toString();
return qs ? `/?${qs}` : '/';
}
export function feedSortLabel(sort: FeedSort): string {
return SORT_OPTIONS.find(o => o.key === sort)?.label ?? '帖子列表';
}
export default function FeedSortBar({ value, onChange, postTotal }: Props) {
return (
<div className="feed-toolbar">
<div className="feed-sort-bar" role="tablist" aria-label="帖子排序">
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }) => (
<button
key={key}
type="button"
role="tab"
aria-selected={value === key}
title={`${label} · ${hint}`}
className={cn('feed-sort-tab', value === key && 'active')}
onClick={() => onChange(key)}
>
<Icon aria-hidden />
<span className="feed-sort-tab__label">{label}</span>
</button>
))}
</div>
{postTotal != null && (
<span className="feed-toolbar__count"> {postTotal} </span>
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { Pin } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Props {
className?: string;
size?: number;
}
/** 置顶图钉标识 */
export default function PinnedIcon({ className, size = 16 }: Props) {
return (
<Pin
className={cn('post-pinned-icon', className)}
size={size}
fill="currentColor"
aria-label="置顶"
role="img"
/>
);
}

View File

@@ -1,14 +1,25 @@
import { Badge } from '@/components/ui/badge'; import { MessageCircle, ThumbsUp } from 'lucide-react';
import BoardBadge from '@/components/BoardBadge';
import PinnedIcon from '@/components/PinnedIcon';
import type { PostItem } from '../api/types'; import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
import { formatTime } from '../utils/content'; import { formatTime } from '../utils/content';
interface Props { interface Props {
post: PostItem; post: PostItem;
sort?: FeedSort;
onClick: () => void; onClick: () => void;
} }
export default function PostListItem({ post, onClick }: Props) { export default function PostListItem({ post, sort = 'latest', onClick }: Props) {
const initial = post.user?.nickname?.[0] || '?'; const initial = post.user?.nickname?.[0] || '?';
const timeLabel = sort === 'reply'
? (post.last_reply_at
? `${formatTime(post.last_reply_at)} 回复`
: '暂无回复')
: formatTime(post.created_at);
const commentCount = post.comment_count ?? 0;
const likeCount = post.like_count ?? 0;
return ( return (
<div className="post-row" onClick={onClick}> <div className="post-row" onClick={onClick}>
@@ -17,18 +28,24 @@ export default function PostListItem({ post, onClick }: Props) {
</div> </div>
<div className="post-body"> <div className="post-body">
<div className="post-title"> <div className="post-title">
{post.pinned && <Badge variant="orange" className="mr-1.5"></Badge>} {post.pinned && <PinnedIcon className="mr-1.5" />}
{post.title} {post.title}
</div> </div>
<div className="post-meta"> <div className="post-meta">
{post.board && <Badge variant="green">{post.board.name}</Badge>} {post.board && <BoardBadge board={post.board} />}
<span>{post.user?.nickname || '匿名'}</span> <span>{post.user?.nickname || '匿名'}</span>
<span>{formatTime(post.created_at)}</span> <span>{timeLabel}</span>
</div> </div>
</div> </div>
<div className="post-stats"> <div className="post-stats">
<span>💬 {post.comment_count ?? 0}</span> <span className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`}>
<span>👍 {post.like_count ?? 0}</span> <MessageCircle aria-hidden />
{commentCount}
</span>
<span className={`post-stat${likeCount === 0 ? ' post-stat--zero' : ''}`}>
<ThumbsUp aria-hidden />
{likeCount}
</span>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,30 @@
import { Skeleton } from '@/components/ui/skeleton';
interface Props {
count?: number;
}
/** 帖子列表加载骨架屏 */
export default function PostListSkeleton({ count = 8 }: Props) {
return (
<div className="post-list-skeleton" aria-busy="true" aria-label="加载中">
{Array.from({ length: count }, (_, i) => (
<div key={i} className="post-row post-row--skeleton">
<Skeleton className="skeleton--avatar" />
<div className="post-body">
<Skeleton className="skeleton--title" style={{ width: `${55 + (i % 4) * 10}%` }} />
<div className="skeleton-meta-row">
<Skeleton className="skeleton--badge" />
<Skeleton className="skeleton--meta" />
<Skeleton className="skeleton--meta skeleton--meta-short" />
</div>
</div>
<div className="post-stats">
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,334 @@
import { useEffect, useMemo, useState } from 'react';
import {
History, X, Maximize2, Minimize2, GitCompare, FileText, ArrowRight,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostRevision } from '../api/types';
import PostContent from './PostContent';
import { formatDateTime } from '../utils/content';
import {
type PostSnapshot,
htmlToDiffText,
summarizeChange,
diffTextLines,
diffTextWords,
countLineChanges,
} from '../utils/revisionDiff';
interface Props {
postId: number;
currentPost: PostSnapshot;
open: boolean;
onClose: () => void;
isLoggedIn: boolean;
}
type ViewMode = 'diff' | 'before' | 'after';
interface RevisionEntry {
rev: PostRevision;
after: PostSnapshot;
summary: ReturnType<typeof summarizeChange>;
index: number;
}
function DiffWords({ before, after }: { before: string; after: string }) {
const parts = diffTextWords(before, after);
if (before === after) {
return <span className="revision-diff-unchanged">{before || '(空)'}</span>;
}
return (
<span className="revision-diff-inline">
{parts.map((part, i) => {
if (part.added) return <ins key={i}>{part.value}</ins>;
if (part.removed) return <del key={i}>{part.value}</del>;
return <span key={i}>{part.value}</span>;
})}
</span>
);
}
function DiffLines({ before, after }: { before: string; after: string }) {
const parts = diffTextLines(before, after);
const { added, removed } = countLineChanges(parts);
if (before === after) {
return <p className="revision-diff-unchanged"></p>;
}
return (
<div className="revision-diff-lines">
<div className="revision-diff-stats">
{removed > 0 && <span className="revision-diff-stat revision-diff-stat--del"> {removed} </span>}
{added > 0 && <span className="revision-diff-stat revision-diff-stat--add"> {added} </span>}
</div>
<pre className="revision-diff-pre">
{parts.map((part, i) => {
const lines = part.value.split('\n');
return lines.map((line, j) => {
if (j === lines.length - 1 && line === '') return null;
const cls = part.added
? 'revision-diff-line revision-diff-line--add'
: part.removed
? 'revision-diff-line revision-diff-line--del'
: 'revision-diff-line revision-diff-line--same';
const prefix = part.added ? '+' : part.removed ? '' : ' ';
return (
<div key={`${i}-${j}`} className={cls}>
<span className="revision-diff-gutter" aria-hidden="true">{prefix}</span>
<span className="revision-diff-text">{line || ' '}</span>
</div>
);
});
})}
</pre>
</div>
);
}
function ChangeBadges({ summary }: { summary: ReturnType<typeof summarizeChange> }) {
if (!summary.hasChanges) return <span className="revision-badge revision-badge--none"></span>;
return (
<>
{summary.titleChanged && <span className="revision-badge"></span>}
{summary.contentChanged && <span className="revision-badge"></span>}
{summary.tagsChanged && <span className="revision-badge"></span>}
</>
);
}
export default function PostRevisionPanel({ postId, currentPost, open, onClose, isLoggedIn }: Props) {
const [revisions, setRevisions] = useState<PostRevision[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('diff');
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
if (!open) {
setSelectedId(null);
setViewMode('diff');
setFullscreen(false);
return;
}
setLoading(true);
api.postRevisions(postId)
.then(d => {
const list = d.revisions ?? [];
setRevisions(list);
if (list.length > 0) setSelectedId(list[0].id);
})
.catch(e => notify.error(e instanceof Error ? e.message : '加载历史失败'))
.finally(() => setLoading(false));
}, [open, postId]);
// 阻止背景滚动
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [open]);
const entries: RevisionEntry[] = useMemo(() => {
return revisions.map((rev, index) => {
const after: PostSnapshot = index === 0
? currentPost
: {
title: revisions[index - 1].title,
content: revisions[index - 1].content,
tags: revisions[index - 1].tags,
};
const before: PostSnapshot = {
title: rev.title,
content: rev.content,
tags: rev.tags,
};
return {
rev,
after,
summary: summarizeChange(before, after),
index: revisions.length - index,
};
});
}, [revisions, currentPost]);
const selected = entries.find(e => e.rev.id === selectedId) ?? null;
if (!open) return null;
const beforeSnap: PostSnapshot | null = selected
? { title: selected.rev.title, content: selected.rev.content, tags: selected.rev.tags }
: null;
return (
<div
className={`post-revision-overlay${fullscreen ? ' post-revision-overlay--fullscreen' : ''}`}
onClick={onClose}
>
<div
className={`post-revision-panel${fullscreen ? ' post-revision-panel--fullscreen' : ''}`}
onClick={e => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="编辑历史"
>
<header className="post-revision-head">
<div className="post-revision-head-left">
<History size={18} />
<h3></h3>
{selected && (
<span className="post-revision-head-sub">
{selected.index}
</span>
)}
</div>
<div className="post-revision-head-actions">
<div className="post-revision-view-tabs" role="tablist">
{([
['diff', GitCompare, '变更对比'],
['before', FileText, '编辑前'],
['after', ArrowRight, '编辑后'],
] as const).map(([mode, Icon, label]) => (
<button
key={mode}
type="button"
role="tab"
aria-selected={viewMode === mode}
className={`post-revision-tab${viewMode === mode ? ' active' : ''}`}
onClick={() => setViewMode(mode)}
>
<Icon size={14} />
<span className="post-revision-tab-label">{label}</span>
</button>
))}
</div>
<button
type="button"
className="post-revision-icon-btn"
onClick={() => setFullscreen(f => !f)}
title={fullscreen ? '退出全屏' : '全屏显示'}
aria-label={fullscreen ? '退出全屏' : '全屏显示'}
>
{fullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
<button type="button" className="post-revision-icon-btn" onClick={onClose} aria-label="关闭">
<X size={18} />
</button>
</div>
</header>
{loading ? (
<div className="post-revision-loading"><Spinner size="lg" /></div>
) : entries.length === 0 ? (
<p className="post-revision-empty"></p>
) : (
<div className="post-revision-body">
<aside className="post-revision-sidebar">
<div className="post-revision-sidebar-label">线</div>
<ul className="post-revision-list">
{entries.map(entry => (
<li key={entry.rev.id}>
<button
type="button"
className={`post-revision-item${selectedId === entry.rev.id ? ' active' : ''}`}
onClick={() => setSelectedId(entry.rev.id)}
>
<div className="post-revision-item-head">
<span className="post-revision-item-num">#{entry.index}</span>
<ChangeBadges summary={entry.summary} />
</div>
<span className="post-revision-item-title">{entry.rev.title}</span>
<span className="post-revision-item-meta">
{entry.rev.editor?.nickname ?? '未知'} · {formatDateTime(entry.rev.created_at)}
</span>
</button>
</li>
))}
</ul>
<div className="post-revision-current">
<span className="post-revision-current-label"></span>
<span className="post-revision-current-title">{currentPost.title}</span>
</div>
</aside>
<main className="post-revision-main">
{selected && beforeSnap ? (
<>
<div className="post-revision-main-head">
<div>
<span className="post-revision-main-editor">
{selected.rev.editor?.nickname ?? '未知'}
</span>
<span className="post-revision-main-time">
{formatDateTime(selected.rev.created_at)}
</span>
</div>
<ChangeBadges summary={selected.summary} />
</div>
<div className="post-revision-scroll">
{viewMode === 'diff' && (
<div className="revision-diff-view">
<section className="revision-diff-section">
<h4></h4>
<div className="revision-diff-block">
<DiffWords before={beforeSnap.title} after={selected.after.title} />
</div>
</section>
{(beforeSnap.tags || selected.after.tags) && (
<section className="revision-diff-section">
<h4></h4>
<div className="revision-diff-block">
<DiffWords
before={beforeSnap.tags || '(无)'}
after={selected.after.tags || '(无)'}
/>
</div>
</section>
)}
<section className="revision-diff-section revision-diff-section--content">
<h4></h4>
<DiffLines
before={htmlToDiffText(beforeSnap.content)}
after={htmlToDiffText(selected.after.content)}
/>
</section>
</div>
)}
{viewMode === 'before' && (
<div className="revision-full-view">
<h4>{beforeSnap.title}</h4>
{beforeSnap.tags && (
<p className="revision-full-tags">{beforeSnap.tags}</p>
)}
<PostContent html={beforeSnap.content} isLoggedIn={isLoggedIn} />
</div>
)}
{viewMode === 'after' && (
<div className="revision-full-view">
<h4>{selected.after.title}</h4>
{selected.after.tags && (
<p className="revision-full-tags">{selected.after.tags}</p>
)}
<PostContent html={selected.after.content} isLoggedIn={isLoggedIn} />
</div>
)}
</div>
</>
) : (
<div className="post-revision-empty"></div>
)}
</main>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { Flame, Megaphone, Users } from 'lucide-react';
import type { PostItem, Notification, OnlineStats } from '../api/types'; import type { PostItem, Notification, OnlineStats } from '../api/types';
interface Props { interface Props {
@@ -7,58 +8,73 @@ interface Props {
onPostClick: (id: number) => void; onPostClick: (id: number) => void;
} }
function hotRankClass(index: number): string {
if (index === 0) return 'widget-rank widget-rank--1';
if (index === 1) return 'widget-rank widget-rank--2';
if (index === 2) return 'widget-rank widget-rank--3';
return 'widget-rank';
}
export default function RightPanel({ hot, notifications, online, onPostClick }: Props) { export default function RightPanel({ hot, notifications, online, onPostClick }: Props) {
const hotList = hot?.slice(0, 8) ?? []; const hotList = hot?.slice(0, 8) ?? [];
const noticeList = notifications?.slice(0, 6) ?? []; const noticeList = notifications?.slice(0, 6) ?? [];
const members = online?.users ?? []; const members = online?.users ?? [];
return ( return (
<> <div className="aside-panel-inner">
<div className="widget-card"> <div className="widget-card">
<div className="widget-card-head">🔥 </div> <div className="widget-card-head">
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
</div>
<div className="widget-card-body"> <div className="widget-card-body">
{hotList.length === 0 ? ( {hotList.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--color-text-3)', padding: '8px 0' }}></div> <div className="widget-empty"></div>
) : hotList.map((item, i) => ( ) : hotList.map((item, i) => (
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}> <div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
<span style={{ color: i < 3 ? '#e74c3c' : 'var(--color-text-3)', fontWeight: 600, minWidth: 18 }}>{i + 1}</span> <span className={hotRankClass(i)}>{i + 1}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span> <span className="widget-item-title">{item.title}</span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
<div className="widget-card"> <div className="widget-card">
<div className="widget-card-head">📢 </div> <div className="widget-card-head">
<Megaphone className="widget-card-icon widget-card-icon--notice" aria-hidden />
</div>
<div className="widget-card-body"> <div className="widget-card-body">
{noticeList.length === 0 ? ( {noticeList.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--color-text-3)', padding: '8px 0' }}></div> <div className="widget-empty"></div>
) : noticeList.map(item => ( ) : noticeList.map(item => (
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}> <div key={item.id} className="widget-item widget-item--notice" onClick={() => onPostClick(item.id)}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span> <span className="widget-item-title">{item.title}</span>
<span style={{ fontSize: 11, color: 'var(--color-text-4)', flexShrink: 0 }}>{item.created_at}</span> <span className="widget-item-time">{item.created_at}</span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
<div className="widget-card"> <div className="widget-card">
<div className="widget-card-head">👀 {online?.count ?? '—'} </div> <div className="widget-card-head">
<Users className="widget-card-icon widget-card-icon--online" aria-hidden />
<span className="widget-head-count">{online?.count ?? '—'}</span>
</div>
<div className="widget-card-body"> <div className="widget-card-body">
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginBottom: 8 }}> <div className="widget-online-meta">
{online?.members ?? 0} · {online?.guests ?? 0} {online?.members ?? 0} · {online?.guests ?? 0}
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}> <div className="widget-online-list">
{members.map(u => ( {members.map(u => (
<span key={u.id} title={u.nickname} style={{ <span key={u.id} className="widget-online-avatar" title={u.nickname}>
width: 28, height: 28, borderRadius: '50%', background: 'var(--j13-green)', {u.avatar
color: '#fff', fontSize: 12, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', ? <img src={u.avatar} alt="" />
}}> : (u.nickname?.[0] || '?')}
{u.nickname?.[0] || '?'}
</span> </span>
))} ))}
{members.length === 0 && ( {members.length === 0 && (
<span style={{ fontSize: 13, color: 'var(--color-text-3)' }}>线</span> <span className="widget-empty widget-empty--inline">线</span>
)} )}
</div> </div>
</div> </div>
@@ -72,6 +88,6 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
</p> </p>
</div> </div>
</div> </div>
</> </div>
); );
} }

View File

@@ -1,11 +1,14 @@
import { import {
Home, Settings, Star, LayoutDashboard, Home, Star, LayoutDashboard,
} from 'lucide-react'; } from 'lucide-react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import type { Board } from '../api/types'; import type { Board } from '../api/types';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { openAdminDashboard } from '../utils/admin';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import BoardIconDisplay from './BoardIconDisplay';
import { getBoardThemeIndex } from '../utils/boardTheme';
// 内容页不参与左侧栏高亮(非 feed 浏览上下文) // 内容页不参与左侧栏高亮(非 feed 浏览上下文)
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile']; const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
@@ -17,7 +20,7 @@ export function isNeutralSidebarRoute(pathname: string): boolean {
function resolveMenuKey(pathname: string, activeBoard: number): string | null { function resolveMenuKey(pathname: string, activeBoard: number): string | null {
if (isNeutralSidebarRoute(pathname)) return null; if (isNeutralSidebarRoute(pathname)) return null;
if (pathname.startsWith('/favorites')) return 'favorites'; if (pathname.startsWith('/favorites')) return 'favorites';
if (pathname.startsWith('/boards')) return 'boards'; if (pathname.startsWith('/admin')) return 'admin';
return activeBoard === 0 ? 'all' : String(activeBoard); return activeBoard === 0 ? 'all' : String(activeBoard);
} }
@@ -30,6 +33,8 @@ interface Props {
export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) { export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
const nav = useNavigate(); const nav = useNavigate();
const loc = useLocation(); const loc = useLocation();
const [params] = useSearchParams();
const sort = parseFeedSort(params.get('sort'));
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
@@ -51,24 +56,40 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar-section"></div> <div className="sidebar-section"></div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); nav('/'); })} {navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
{user && navItem('favorites', '我的收藏', <Star />, () => nav('/favorites'))} {user && navItem('favorites', '我的收藏', <Star />, () => nav('/favorites'))}
</nav> </nav>
{boards.length > 0 && ( {boards.length > 0 && (
<> <>
<div className="sidebar-section" style={{ marginTop: 8 }}></div> <div className="sidebar-section sidebar-section--boards"></div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{boards.map(b => ( {boards.map(b => {
<button const isActive = menuKey != null && menuKey === String(b.id);
type="button" const themeIdx = getBoardThemeIndex(b);
key={b.id} return (
className={cn('sidebar-nav-item', menuKey != null && menuKey === String(b.id) && 'active')} <button
onClick={() => { onSelectBoard(b.id); nav(`/?board=${b.id}`); }} type="button"
> key={b.id}
<span className="flex-1 truncate">{b.name}</span> className={cn(
</button> 'sidebar-nav-item',
))} 'sidebar-nav-item--board',
isActive && 'active',
isActive && `sidebar-nav-item--board-${themeIdx}`,
)}
onClick={() => { onSelectBoard(b.id); navigateFeed(nav, buildHomeUrl(b.id, sort)); }}
>
<BoardIconDisplay
board={b}
className={cn('sidebar-board-icon', `sidebar-board-icon--${themeIdx}`)}
/>
<span className="flex-1 truncate">{b.name}</span>
{(b.post_count ?? 0) > 0 && (
<span className="sidebar-nav-item__meta">{b.post_count}</span>
)}
</button>
);
})}
</nav> </nav>
</> </>
)} )}
@@ -77,8 +98,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
<> <>
<div className="sidebar-section" style={{ marginTop: 8 }}></div> <div className="sidebar-section" style={{ marginTop: 8 }}></div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{navItem('boards', '管理板块', <Settings />, () => nav('/boards'))} {navItem('admin', '管理后台', <LayoutDashboard />, () => nav('/admin/dashboard'))}
{navItem('admin', '系统后台', <LayoutDashboard />, openAdminDashboard)}
</nav> </nav>
</> </>
)} )}

View File

@@ -0,0 +1,45 @@
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface Props {
open: boolean;
onStay: () => void;
onLeave: () => void;
/** 编辑已有帖子时为 true */
isEdit?: boolean;
}
export default function UnsavedChangesDialog({ open, onStay, onLeave, isEdit = false }: Props) {
return (
<AlertDialog open={open}>
<AlertDialogContent onEscapeKeyDown={onStay}>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{isEdit
? '你对这篇文章的修改尚未保存,离开后将无法恢复。'
: '你正在撰写的内容尚未发布,离开后将无法恢复。'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onStay}></AlertDialogCancel>
<Button
type="button"
variant="destructive"
onClick={onLeave}
>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,19 +1,45 @@
import { useRef, useEffect } from 'react'; import { useRef, useEffect, useLayoutEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Spinner } from '@/components/ui/spinner'; import { Button } from '@/components/ui/button';
import PostListItem from './PostListItem'; import PostListItem from './PostListItem';
import PostListSkeleton from './PostListSkeleton';
import type { PostItem } from '../api/types'; import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
interface Props { interface Props {
posts: PostItem[]; posts: PostItem[];
sort?: FeedSort;
loading: boolean; loading: boolean;
hasMore: boolean; hasMore: boolean;
/** 是否允许滚动触底自动加载(达到上限后为 false */
canAutoLoad: boolean;
postTotal: number;
onLoadMore: () => void; onLoadMore: () => void;
onSelect: (id: number) => void; onSelect: (id: number) => void;
/** 返回列表时恢复的滚动位置 */
restoreScrollTop?: number | null;
/** 递增时强制回到列表顶部(主动刷新导航) */
resetScrollKey?: number;
onScrollTopChange?: (top: number) => void;
onScrollRestored?: () => void;
} }
export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, onSelect }: Props) { export default function VirtualPostList({
posts,
sort = 'latest',
loading,
hasMore,
canAutoLoad,
postTotal,
onLoadMore,
onSelect,
restoreScrollTop,
resetScrollKey = 0,
onScrollTopChange,
onScrollRestored,
}: Props) {
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: posts.length, count: posts.length,
@@ -22,46 +48,86 @@ export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, o
overscan: 8, overscan: 8,
}); });
const showHistoryPrompt = hasMore && !canAutoLoad && !loading;
const showEnd = !hasMore && posts.length > 0 && !loading;
const isInitialLoad = loading && posts.length === 0;
const isLoadingMore = loading && posts.length > 0;
useLayoutEffect(() => {
if (resetScrollKey <= 0) return;
const el = parentRef.current;
if (el) {
el.scrollTop = 0;
virtualizer.scrollToOffset(0);
}
restoredRef.current = true;
onScrollTopChange?.(0);
}, [resetScrollKey, virtualizer, onScrollTopChange]);
useLayoutEffect(() => {
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
virtualizer.scrollToOffset(restoreScrollTop);
restoredRef.current = true;
onScrollRestored?.();
}, [restoreScrollTop, posts.length, virtualizer, onScrollRestored]);
useEffect(() => {
restoredRef.current = false;
}, [restoreScrollTop]);
useEffect(() => { useEffect(() => {
const el = parentRef.current; const el = parentRef.current;
if (!el) return; if (!el) return;
const onScroll = () => { const onScroll = () => {
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && hasMore && !loading) { onScrollTopChange?.(el.scrollTop);
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && canAutoLoad && hasMore && !loading) {
onLoadMore(); onLoadMore();
} }
}; };
el.addEventListener('scroll', onScroll); el.addEventListener('scroll', onScroll);
return () => el.removeEventListener('scroll', onScroll); return () => el.removeEventListener('scroll', onScroll);
}, [hasMore, loading, onLoadMore]); }, [canAutoLoad, hasMore, loading, onLoadMore, onScrollTopChange]);
return ( return (
<div className="post-list-scroll" ref={parentRef}> <div className="post-list-scroll" ref={parentRef}>
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}> {isInitialLoad ? (
{virtualizer.getVirtualItems().map(vi => { <PostListSkeleton />
const post = posts[vi.index]; ) : (
return ( <>
<div <div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
key={post.id} {virtualizer.getVirtualItems().map(vi => {
style={{ const post = posts[vi.index];
position: 'absolute', return (
top: 0, <div
left: 0, key={post.id}
width: '100%', style={{
transform: `translateY(${vi.start}px)`, position: 'absolute',
}} top: 0,
> left: 0,
<PostListItem post={post} onClick={() => onSelect(post.id)} /> width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
</div>
);
})}
</div>
{isLoadingMore && <PostListSkeleton count={2} />}
{showHistoryPrompt && (
<div className="feed-list-footer feed-list-footer--history">
<p className="feed-list-footer__hint">
{posts.length} / {postTotal}
</p>
<Button type="button" variant="outline" size="sm" onClick={onLoadMore}>
</Button>
</div> </div>
); )}
})} {showEnd && (
</div> <div className="feed-list-footer feed-list-footer--end"> </div>
{loading && ( )}
<div className="flex justify-center py-4"> </>
<Spinner />
</div>
)}
{!loading && !hasMore && posts.length > 0 && (
<div style={{ textAlign: 'center', padding: 6, fontSize: 12, color: 'var(--color-text-3)' }}> </div>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
initialUrl?: string;
onConfirm: (url: string) => void;
onRemove?: () => void;
}
/** 文章编辑器链接输入弹窗 */
export function ArticleLinkDialog({
open,
onOpenChange,
initialUrl = '',
onConfirm,
onRemove,
}: Props) {
const [url, setUrl] = useState(initialUrl);
useEffect(() => {
if (open) setUrl(initialUrl || 'https://');
}, [open, initialUrl]);
const handleConfirm = () => {
onConfirm(url.trim());
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="article-link-dialog">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> URL</DialogDescription>
</DialogHeader>
<Input
type="url"
value={url}
placeholder="https://"
onChange={e => setUrl(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
autoFocus
/>
<DialogFooter className="article-link-dialog__footer">
{onRemove && initialUrl ? (
<Button type="button" variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}>
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="button" onClick={handleConfirm}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,171 @@
import { Node, mergeAttributes } from '@tiptap/core';
import {
ReactNodeViewRenderer,
NodeViewWrapper,
NodeViewContent,
type NodeViewProps,
} from '@tiptap/react';
import { LockKeyhole, LogOut } from 'lucide-react';
/** 查找光标所在的登录可见节点深度 */
function findMembersOnlyDepth($pos: { depth: number; node: (d: number) => { type: { name: string } } }): number {
for (let d = $pos.depth; d > 0; d -= 1) {
if ($pos.node(d).type.name === 'membersOnly') return d;
}
return -1;
}
/** 编辑态「登录可见」区块视图 */
function MembersOnlyView({ selected, editor }: NodeViewProps) {
const handleExit = () => {
editor.chain().focus().exitMembersOnly().run();
};
const handleUnwrap = () => {
editor.chain().focus().unwrapMembersOnly().run();
};
return (
<NodeViewWrapper
as="members-only"
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}`}
>
<div className="post-members-only__badge" contentEditable={false}>
<span className="post-members-only__badge-icon" aria-hidden="true">
<LockKeyhole size={12} />
</span>
<span></span>
<button
type="button"
className="post-members-only__exit-btn"
title="Ctrl+Enter 退出到公开区域"
onMouseDown={e => e.preventDefault()}
onClick={handleExit}
>
<LogOut size={11} />
退
</button>
<span className="post-members-only__shortcut-hint">Ctrl+Enter 退</span>
<button
type="button"
className="post-members-only__unwrap-btn"
title="取消登录可见包裹,保留正文"
onMouseDown={e => e.preventDefault()}
onClick={handleUnwrap}
>
</button>
</div>
<NodeViewContent className="post-members-only__body" />
</NodeViewWrapper>
);
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
membersOnly: {
insertMembersOnly: () => ReturnType;
wrapMembersOnly: () => ReturnType;
exitMembersOnly: () => ReturnType;
unwrapMembersOnly: () => ReturnType;
};
}
}
/** TipTap 自定义节点:登录可见内容区块 */
export const MembersOnly = Node.create({
name: 'membersOnly',
group: 'block',
content: 'block+',
defining: true,
isolating: true,
parseHTML() {
return [{ tag: 'members-only' }];
},
renderHTML({ HTMLAttributes }) {
return ['members-only', mergeAttributes(HTMLAttributes), 0];
},
addNodeView() {
return ReactNodeViewRenderer(MembersOnlyView);
},
addKeyboardShortcuts() {
return {
// 在区块末尾空行按 Enter 时退出到公开区域
Enter: ({ editor }) => {
const { $from, empty } = editor.state.selection;
if (!empty) return false;
const depth = findMembersOnlyDepth($from);
if (depth < 0) return false;
const parent = $from.parent;
const atBlockEnd = $from.parentOffset === parent.content.size;
const isEmptyBlock = parent.textContent.trim().length === 0;
if (!atBlockEnd || !isEmptyBlock) return false;
return editor.commands.exitMembersOnly();
},
// Ctrl+Enter / Cmd+Enter 退出到公开区域
'Mod-Enter': ({ editor }) => {
if (!editor.isActive('membersOnly')) return false;
return editor.commands.exitMembersOnly();
},
};
},
addCommands() {
return {
insertMembersOnly: () => ({ chain }) => chain()
.insertContent({
type: this.name,
content: [{ type: 'paragraph' }],
})
.run(),
wrapMembersOnly: () => ({ tr, state, dispatch }) => {
const { from, to, empty } = state.selection;
if (empty) return false;
const slice = state.doc.slice(from, to);
if (!slice.content.size) return false;
const node = state.schema.nodes.membersOnly.create(null, slice.content);
if (dispatch) {
tr.replaceRangeWith(from, to, node);
}
return true;
},
exitMembersOnly: () => ({ state, chain }) => {
const { $from } = state.selection;
const depth = findMembersOnlyDepth($from);
if (depth < 0) return false;
const pos = $from.before(depth);
const node = $from.node(depth);
const end = pos + node.nodeSize;
return chain()
.insertContentAt(end, { type: 'paragraph' })
.setTextSelection(end + 1)
.run();
},
unwrapMembersOnly: () => ({ tr, state, dispatch }) => {
const { $from } = state.selection;
const depth = findMembersOnlyDepth($from);
if (depth < 0) return false;
const pos = $from.before(depth);
const node = $from.node(depth);
tr.replaceWith(pos, pos + node.nodeSize, node.content);
if (dispatch) dispatch(tr);
return true;
},
};
},
});

View File

@@ -0,0 +1,189 @@
import { Extension } from '@tiptap/core';
import type { Editor } from '@tiptap/react';
export const TAB_SPACES = ' ';
/** 收集选区覆盖的文本块 */
function collectSelectedTextblocks(editor: Editor): { pos: number }[] {
const { from, to } = editor.state.selection;
const blocks: { pos: number }[] = [];
editor.state.doc.nodesBetween(from, to, (node, pos) => {
if (node.isTextblock) {
blocks.push({ pos });
return false;
}
return undefined;
});
return blocks;
}
/** 代码块多行选区:每行首插入缩进 */
function indentCodeBlockSelection(editor: Editor): boolean {
const { from, to, empty } = editor.state.selection;
if (empty) return false;
const text = editor.state.doc.textBetween(from, to, '\n', '\n');
const lineStarts = [from];
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '\n') lineStarts.push(from + i + 1);
}
let tr = editor.state.tr;
let offset = 0;
lineStarts.forEach(pos => {
tr = tr.insertText(TAB_SPACES, pos + offset);
offset += TAB_SPACES.length;
});
editor.view.dispatch(tr);
return true;
}
/** 代码块多行选区:每行首移除最多 4 个空格 */
function outdentCodeBlockSelection(editor: Editor): boolean {
const { from, to, empty } = editor.state.selection;
if (empty) return false;
const doc = editor.state.doc;
const text = doc.textBetween(from, to, '\n', '\n');
const lineStarts = [from];
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '\n') lineStarts.push(from + i + 1);
}
let tr = editor.state.tr;
let offset = 0;
let changed = false;
lineStarts.forEach(pos => {
const adjPos = pos + offset;
const lineEnd = doc.textBetween(adjPos, to + offset).split('\n')[0] ?? '';
const match = lineEnd.match(/^ {1,4}/);
if (!match) return;
tr = tr.delete(adjPos, adjPos + match[0].length);
offset -= match[0].length;
changed = true;
});
if (!changed) return false;
editor.view.dispatch(tr);
return true;
}
/** 多行选区:在每个文本块行首插入缩进 */
function indentSelection(editor: Editor): boolean {
const { empty } = editor.state.selection;
if (empty) return false;
if (editor.isActive('codeBlock')) {
return indentCodeBlockSelection(editor);
}
if (editor.isActive('listItem')) {
return editor.chain().focus().sinkListItem('listItem').run();
}
const blocks = collectSelectedTextblocks(editor);
if (blocks.length === 0) return false;
let tr = editor.state.tr;
let offset = 0;
blocks.forEach(({ pos }) => {
const insertPos = pos + 1 + offset;
tr = tr.insertText(TAB_SPACES, insertPos);
offset += TAB_SPACES.length;
});
editor.view.dispatch(tr);
return true;
}
/** 多行选区:移除每个文本块行首最多 4 个空格 */
function outdentSelection(editor: Editor): boolean {
const { empty } = editor.state.selection;
if (empty) return false;
if (editor.isActive('codeBlock')) {
return outdentCodeBlockSelection(editor);
}
if (editor.isActive('listItem')) {
return editor.chain().focus().liftListItem('listItem').run();
}
const blocks = collectSelectedTextblocks(editor);
if (blocks.length === 0) return false;
let tr = editor.state.tr;
let offset = 0;
blocks.forEach(({ pos }) => {
const node = editor.state.doc.nodeAt(pos);
if (!node?.isTextblock) return;
const text = node.textContent;
const match = text.match(/^ {1,4}/);
if (!match) return;
const removeLen = match[0].length;
const from = pos + 1 + offset;
tr = tr.delete(from, from + removeLen);
offset -= removeLen;
});
if (tr.steps.length === 0) return false;
editor.view.dispatch(tr);
return true;
}
/** 在代码块或段落中移除光标前的 4 个空格缩进 */
function outdentTextblock(editor: Editor): boolean {
const { state } = editor;
const { $from, empty } = state.selection;
if (!empty) return false;
if (!$from.parent.isTextblock) return false;
const lineStart = $from.start();
const beforeCursor = state.doc.textBetween(lineStart, $from.pos);
if (beforeCursor.endsWith(TAB_SPACES)) {
return editor.chain().focus().deleteRange({ from: $from.pos - TAB_SPACES.length, to: $from.pos }).run();
}
const leading = beforeCursor.match(/^ {1,4}/);
if (leading) {
return editor.chain().focus().deleteRange({ from: lineStart, to: lineStart + leading[0].length }).run();
}
return false;
}
/** Tab 缩进:列表缩进/反缩进,多行选区整体缩进,其余插入 4 个空格 */
export const TabIndent = Extension.create({
name: 'tabIndent',
addKeyboardShortcuts() {
return {
Tab: ({ editor }) => {
if (!editor.state.selection.empty) {
return indentSelection(editor);
}
if (editor.isActive('codeBlock')) {
return editor.chain().focus().insertContent(TAB_SPACES).run();
}
if (editor.isActive('listItem')) {
return editor.chain().focus().sinkListItem('listItem').run();
}
return editor.chain().focus().insertContent(TAB_SPACES).run();
},
'Shift-Tab': ({ editor }) => {
if (!editor.state.selection.empty) {
return outdentSelection(editor);
}
if (editor.isActive('codeBlock')) {
return outdentTextblock(editor);
}
if (editor.isActive('listItem')) {
return editor.chain().focus().liftListItem('listItem').run();
}
return outdentTextblock(editor);
},
};
},
});

View File

@@ -0,0 +1,49 @@
import { cloneElement, isValidElement, type ReactElement } from 'react';
import { cn } from '@/lib/utils';
type TooltipSide = 'top' | 'bottom';
type TooltipAlign = 'start' | 'center' | 'end';
interface TooltipProps {
content: string;
hint?: string;
side?: TooltipSide;
align?: TooltipAlign;
children: ReactElement;
className?: string;
}
/** 即时显示的网页式气泡提示,替代原生 title */
export function Tooltip({
content,
hint,
side = 'bottom',
align = 'center',
children,
className,
}: TooltipProps) {
if (!isValidElement(children)) return children;
const ariaLabel = hint ? `${content}${hint}` : content;
const child = cloneElement(children, {
'aria-label': ariaLabel,
} as Record<string, unknown>);
return (
<span className={cn('ui-tooltip', className)}>
{child}
<span
role="tooltip"
className={cn(
'ui-tooltip-bubble',
`ui-tooltip-bubble--${side}`,
`ui-tooltip-bubble--${align}`,
hint ? 'ui-tooltip-bubble--rich' : 'ui-tooltip-bubble--compact',
)}
>
<span className="ui-tooltip-bubble__title">{content}</span>
{hint ? <span className="ui-tooltip-bubble__hint">{hint}</span> : null}
</span>
</span>
);
}

View File

@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('skeleton', className)} {...props} />;
}
export { Skeleton };

View File

@@ -0,0 +1,49 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { ForumLimitsPublic } from '../api/types';
const DEFAULT_LIMITS: ForumLimitsPublic = {
post_title_max: 128,
post_tags_max: 256,
post_content_max: 50000,
comment_max: 5000,
search_keyword_min: 1,
search_keyword_max: 50,
page_size_default: 30,
feed_max_pages: 10,
feed_max_items: 300,
password_min_len: 6,
avatar_max_mb: 2,
};
let cached: ForumLimitsPublic | null = null;
let inflight: Promise<ForumLimitsPublic> | null = null;
function fetchLimits(): Promise<ForumLimitsPublic> {
if (cached) return Promise.resolve(cached);
if (inflight) return inflight;
inflight = api.forumLimits()
.then(limits => {
cached = limits;
return limits;
})
.catch(() => DEFAULT_LIMITS)
.finally(() => { inflight = null; });
return inflight;
}
/** 获取前台可见的论坛限制配置 */
export function useForumLimits() {
const [limits, setLimits] = useState<ForumLimitsPublic>(cached ?? DEFAULT_LIMITS);
const [loading, setLoading] = useState(!cached);
useEffect(() => {
fetchLimits().then(setLimits).finally(() => setLoading(false));
}, []);
return { limits, loading };
}
export function invalidateForumLimitsCache() {
cached = null;
}

View File

@@ -35,6 +35,9 @@ export function useGlobalWheelScroll(scrollRef: RefObject<HTMLElement | null>, e
if (!root) return; if (!root) return;
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent) => {
// Ctrl/⌘ + 滚轮用于浏览器缩放,不拦截
if (e.ctrlKey || e.metaKey) return;
const target = e.target instanceof HTMLElement ? e.target : null; const target = e.target instanceof HTMLElement ? e.target : null;
if (!target) return; if (!target) return;

View File

@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBlocker } from 'react-router-dom';
interface Options {
/** 是否存在未保存的修改 */
isDirty: boolean;
}
/**
* 拦截页面内导航与关闭标签页,在存在未保存修改时提示用户确认。
*/
export function useUnsavedChangesGuard({ isDirty }: Options) {
const [dialogOpen, setDialogOpen] = useState(false);
const pendingLeaveRef = useRef<(() => void) | null>(null);
/** 同步标记,避免 setState 未及时生效导致 useBlocker 二次拦截 */
const allowNavigationRef = useRef(false);
const shouldBlock = isDirty && !allowNavigationRef.current;
const blocker = useBlocker(() => isDirty && !allowNavigationRef.current);
useEffect(() => {
if (blocker.state === 'blocked') {
setDialogOpen(true);
}
}, [blocker.state]);
useEffect(() => {
if (!shouldBlock) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [shouldBlock]);
const stayOnPage = useCallback(() => {
setDialogOpen(false);
pendingLeaveRef.current = null;
if (blocker.state === 'blocked') {
blocker.reset?.();
}
}, [blocker]);
const discardAndLeave = useCallback(() => {
setDialogOpen(false);
allowNavigationRef.current = true;
if (blocker.state === 'blocked') {
blocker.proceed?.();
return;
}
const action = pendingLeaveRef.current;
pendingLeaveRef.current = null;
action?.();
}, [blocker]);
/** 主动发起离开(如点击返回按钮) */
const requestLeave = useCallback((action: () => void) => {
if (!shouldBlock) {
action();
return;
}
pendingLeaveRef.current = action;
setDialogOpen(true);
}, [shouldBlock]);
/** 保存成功后调用,允许后续导航不再拦截 */
const markSaved = useCallback(() => {
allowNavigationRef.current = true;
}, []);
return {
dialogOpen,
stayOnPage,
discardAndLeave,
requestLeave,
markSaved,
};
}

View File

@@ -0,0 +1,97 @@
import { useEffect } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import {
LayoutDashboard, FolderKanban, FileText, MessageSquare, Users, Settings, ArrowLeft,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useAuth } from '../hooks/useAuth';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
const NAV = [
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
{ to: '/admin/boards', label: '板块管理', icon: FolderKanban },
{ to: '/admin/posts', label: '帖子管理', icon: FileText },
{ to: '/admin/comments', label: '评论管理', icon: MessageSquare },
{ to: '/admin/users', label: '用户管理', icon: Users },
{ to: '/admin/settings', label: '系统设置', icon: Settings },
];
/** React 管理后台布局,与前台 SPA 风格统一 */
export default function AdminLayout() {
const { user, loading } = useAuth();
const nav = useNavigate();
useEffect(() => {
if (loading) return;
if (!user) {
nav('/login');
return;
}
if (user.role !== 'admin') {
notify.warning('需要管理员权限');
nav('/');
}
}, [user, loading, nav]);
if (loading) {
return <div className="flex justify-center py-24"><Spinner size="lg" /></div>;
}
if (!user || user.role !== 'admin') return null;
return (
<div className="admin-shell">
<header className="admin-topbar">
<div className="admin-topbar-brand">
<div className="admin-topbar-mark"></div>
<div>
<div className="admin-topbar-title"></div>
<div className="admin-topbar-sub"></div>
</div>
</div>
<div className="admin-topbar-actions">
<button type="button" className="admin-link-btn" onClick={() => nav('/')}>
<ArrowLeft size={16} />
</button>
<span className="admin-topbar-user">{user.nickname}</span>
</div>
</header>
<div className="admin-body">
<aside className="admin-sidebar">
{NAV.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) => cn('admin-nav-item', isActive && 'active')}
>
<Icon size={16} />
{label}
</NavLink>
))}
</aside>
<main className="admin-main">
<Outlet />
</main>
</div>
</div>
);
}
/** 管理页通用权限守卫 */
export function useAdminGuard() {
const { user, loading } = useAuth();
const nav = useNavigate();
useEffect(() => {
if (loading) return;
if (!user) nav('/login');
else if (user.role !== 'admin') {
notify.warning('需要管理员权限');
nav('/');
}
}, [user, loading, nav]);
return { user, loading, ready: !loading && !!user && user.role === 'admin' };
}

View File

@@ -9,7 +9,6 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { openAdminDashboard } from '../utils/admin';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { useTheme, useMediaQuery } from '../hooks/useTheme'; import { useTheme, useMediaQuery } from '../hooks/useTheme';
import { api } from '../api/client'; import { api } from '../api/client';
@@ -17,6 +16,12 @@ import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache'; import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar'; import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
import RightPanel from '../components/RightPanel'; import RightPanel from '../components/RightPanel';
import { useForumLimits } from '../hooks/useForumLimits';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { getBoardThemeIndex } from '../utils/boardTheme';
export default function MainLayout() { export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth(); const { user, loading: authLoading, logout } = useAuth();
@@ -35,6 +40,8 @@ export default function MainLayout() {
const [online, setOnline] = useState<OnlineStats | null>(null); const [online, setOnline] = useState<OnlineStats | null>(null);
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0); const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
const [keyword, setKeyword] = useState(params.get('keyword') || ''); const [keyword, setKeyword] = useState(params.get('keyword') || '');
const feedSort = parseFeedSort(params.get('sort'));
const { limits: forumLimits } = useForumLimits();
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]); useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]); useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
@@ -84,8 +91,21 @@ export default function MainLayout() {
}, [refreshBoards, refreshOnline]); }, [refreshBoards, refreshOnline]);
const doSearch = () => { const doSearch = () => {
if (keyword.trim()) nav(`/?keyword=${encodeURIComponent(keyword.trim())}`); const kw = keyword.trim();
else nav('/'); if (!kw) {
nav('/');
return;
}
const len = [...kw].length;
if (forumLimits.search_keyword_min > 0 && len < forumLimits.search_keyword_min) {
notify.warning(`搜索关键词至少 ${forumLimits.search_keyword_min} 个字`);
return;
}
if (forumLimits.search_keyword_max > 0 && len > forumLimits.search_keyword_max) {
notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
return;
}
nav(`/?keyword=${encodeURIComponent(kw)}`);
}; };
const userInitial = user?.nickname?.charAt(0) || '?'; const userInitial = user?.nickname?.charAt(0) || '?';
@@ -96,7 +116,7 @@ export default function MainLayout() {
<div className="app-frame"> <div className="app-frame">
<header className="app-header"> <header className="app-header">
<div className="header-inner"> <div className="header-inner">
<button type="button" className="header-brand" onClick={() => nav('/')}> <button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
<span className="header-logo-mark"></span> <span className="header-logo-mark"></span>
{!isMobile && <span className="header-logo-text"></span>} {!isMobile && <span className="header-logo-text"></span>}
</button> </button>
@@ -110,6 +130,7 @@ export default function MainLayout() {
placeholder="搜索帖子..." placeholder="搜索帖子..."
value={keyword} value={keyword}
onChange={e => setKeyword(e.target.value)} onChange={e => setKeyword(e.target.value)}
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
onKeyDown={e => e.key === 'Enter' && doSearch()} onKeyDown={e => e.key === 'Enter' && doSearch()}
/> />
{keyword && ( {keyword && (
@@ -156,14 +177,17 @@ export default function MainLayout() {
: <span className="header-user-initial">{userInitial}</span>} : <span className="header-user-initial">{userInitial}</span>}
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40"> <DropdownMenuContent
align="end"
className="w-40"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onClick={() => nav('/profile')}></DropdownMenuItem> <DropdownMenuItem onClick={() => nav('/profile')}></DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/favorites')}></DropdownMenuItem> <DropdownMenuItem onClick={() => nav('/favorites')}></DropdownMenuItem>
{user.role === 'admin' && ( {user.role === 'admin' && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => nav('/boards')}></DropdownMenuItem> <DropdownMenuItem onClick={() => nav('/admin/dashboard')}></DropdownMenuItem>
<DropdownMenuItem onClick={openAdminDashboard}></DropdownMenuItem>
</> </>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -197,15 +221,23 @@ export default function MainLayout() {
<div className="mobile-board-bar"> <div className="mobile-board-bar">
<span <span
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`} className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
onClick={() => { setBoardId(0); nav('/'); }} onClick={() => { setBoardId(0); navigateFeed(nav, buildHomeUrl(0, feedSort)); }}
></span> ></span>
{boards.map(b => ( {boards.map(b => {
<span const themeIdx = getBoardThemeIndex(b);
key={b.id} const isActive = mobileActiveBoard === b.id;
className={`board-chip ${mobileActiveBoard === b.id ? 'active' : ''}`} return (
onClick={() => { setBoardId(b.id); nav(`/?board=${b.id}`); }} <span
>{b.name}</span> key={b.id}
))} className={cn(
'board-chip',
isActive && 'active',
isActive && `board-chip--${themeIdx}`,
)}
onClick={() => { setBoardId(b.id); navigateFeed(nav, buildHomeUrl(b.id, feedSort)); }}
>{b.name}</span>
);
})}
</div> </div>
)} )}
<Suspense fallback={<PageLoader />}> <Suspense fallback={<PageLoader />}>

View File

@@ -1,9 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { ArrowLeft, Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@@ -24,21 +23,26 @@ import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage, Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { api } from '../api/client'; import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth'; import { useAdminGuard } from '../layouts/AdminLayout';
import type { Board } from '../api/types'; import type { Board } from '../api/types';
import { BoardColorPicker, BoardIconPicker } from '../components/BoardAppearancePicker';
import BoardIconDisplay from '../components/BoardIconDisplay';
import { getBoardThemeIndex } from '../utils/boardTheme';
const boardSchema = z.object({ const boardSchema = z.object({
name: z.string().min(1, '请输入名称').max(64), name: z.string().min(1, '请输入名称').max(64),
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
sort_order: z.coerce.number().min(0), sort_order: z.coerce.number().min(0),
icon: z.string().max(64).optional(),
color_index: z.coerce.number().min(-1).max(7),
}); });
type BoardFormValues = z.infer<typeof boardSchema>; type BoardFormValues = z.infer<typeof boardSchema>;
export default function BoardsManagePage() { export default function BoardsManagePage() {
const nav = useNavigate(); const { ready } = useAdminGuard();
const { user, loading: authLoading } = useAuth();
const [boards, setBoards] = useState<Board[]>([]); const [boards, setBoards] = useState<Board[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
@@ -47,9 +51,12 @@ export default function BoardsManagePage() {
const form = useForm<BoardFormValues>({ const form = useForm<BoardFormValues>({
resolver: zodResolver(boardSchema), resolver: zodResolver(boardSchema),
defaultValues: { name: '', description: '', sort_order: 1 }, defaultValues: { name: '', description: '', sort_order: 1, icon: '', color_index: -1 },
}); });
const watchColorIndex = form.watch('color_index');
const editingPreviewId = editing?.id ?? boards.length + 1;
const load = () => { const load = () => {
setLoading(true); setLoading(true);
api.boards() api.boards()
@@ -59,15 +66,12 @@ export default function BoardsManagePage() {
}; };
useEffect(() => { useEffect(() => {
if (authLoading) return; if (ready) load();
if (!user) { nav('/login'); return; } }, [ready]);
if (user.role !== 'admin') { nav('/'); notify.warning('需要管理员权限'); return; }
load();
}, [user, authLoading, nav]);
const openCreate = () => { const openCreate = () => {
setEditing(null); setEditing(null);
form.reset({ name: '', description: '', sort_order: boards.length + 1 }); form.reset({ name: '', description: '', sort_order: boards.length + 1, icon: '', color_index: -1 });
setModalOpen(true); setModalOpen(true);
}; };
@@ -77,6 +81,8 @@ export default function BoardsManagePage() {
name: board.name, name: board.name,
description: board.description ?? '', description: board.description ?? '',
sort_order: board.sort_order, sort_order: board.sort_order,
icon: board.icon ?? '',
color_index: board.color_index ?? -1,
}); });
setModalOpen(true); setModalOpen(true);
}; };
@@ -84,11 +90,18 @@ export default function BoardsManagePage() {
const handleSubmit = async (values: BoardFormValues) => { const handleSubmit = async (values: BoardFormValues) => {
setSubmitting(true); setSubmitting(true);
try { try {
const body = {
name: values.name,
description: values.description ?? '',
sort_order: values.sort_order,
icon: values.icon ?? '',
color_index: values.color_index ?? -1,
};
if (editing) { if (editing) {
await api.updateBoard(editing.id, values); await api.updateBoard(editing.id, body);
notify.success('板块已更新'); notify.success('板块已更新');
} else { } else {
await api.createBoard(values); await api.createBoard(body);
notify.success('板块已创建'); notify.success('板块已创建');
} }
setModalOpen(false); setModalOpen(false);
@@ -112,31 +125,26 @@ export default function BoardsManagePage() {
} }
}; };
if (authLoading) { if (!ready) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>; return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
} }
if (!user || user.role !== 'admin') return null;
return ( return (
<div className="page-wrap"> <div className="admin-page">
<div className="page-inner-wide"> <div className="admin-page-head">
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<ArrowLeft />
</Button>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20 }}>
<div> <div>
<h1 className="page-title"></h1> <h1></h1>
<p className="page-desc"></p> <p></p>
</div> </div>
<Button onClick={openCreate}> <Button onClick={openCreate}>
<Plus /> <Plus />
</Button> </Button>
</div> </div>
</div>
<div className="section-card" style={{ padding: 0, overflow: 'hidden' }}> <div className="admin-card">
{loading ? ( {loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div> <div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : ( ) : (
@@ -145,6 +153,7 @@ export default function BoardsManagePage() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[60px]">ID</TableHead> <TableHead className="w-[60px]">ID</TableHead>
<TableHead className="w-[52px]"></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="w-[70px]"></TableHead> <TableHead className="w-[70px]"></TableHead>
@@ -153,9 +162,16 @@ export default function BoardsManagePage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{boards.map(board => ( {boards.map(board => {
const themeIdx = getBoardThemeIndex(board);
return (
<TableRow key={board.id}> <TableRow key={board.id}>
<TableCell>{board.id}</TableCell> <TableCell>{board.id}</TableCell>
<TableCell>
<span className={cn('board-table-icon', `sidebar-board-icon--${themeIdx}`)}>
<BoardIconDisplay board={board} />
</span>
</TableCell>
<TableCell><strong>{board.name}</strong></TableCell> <TableCell><strong>{board.name}</strong></TableCell>
<TableCell className="max-w-[200px] truncate">{board.description}</TableCell> <TableCell className="max-w-[200px] truncate">{board.description}</TableCell>
<TableCell>{board.sort_order}</TableCell> <TableCell>{board.sort_order}</TableCell>
@@ -187,7 +203,8 @@ export default function BoardsManagePage() {
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} );
})}
</TableBody> </TableBody>
</Table> </Table>
{boards.length === 0 && ( {boards.length === 0 && (
@@ -197,11 +214,10 @@ export default function BoardsManagePage() {
)} )}
</> </>
)} )}
</div>
</div> </div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}> <Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent> <DialogContent className="board-manage-dialog">
<DialogHeader> <DialogHeader>
<DialogTitle>{editing ? '编辑板块' : '新建板块'}</DialogTitle> <DialogTitle>{editing ? '编辑板块' : '新建板块'}</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -233,6 +249,40 @@ export default function BoardsManagePage() {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<BoardIconPicker
value={field.value ?? ''}
onChange={field.onChange}
board={{ id: editingPreviewId, color_index: watchColorIndex }}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="color_index"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<BoardColorPicker
value={field.value ?? -1}
onChange={field.onChange}
boardId={editingPreviewId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="sort_order" name="sort_order"

View File

@@ -1,13 +1,23 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useNavigate, useSearchParams, useParams } from 'react-router-dom'; import { useNavigate, useSearchParams, useParams } from 'react-router-dom';
import { ArrowLeft, Send, Tag } from 'lucide-react'; import { ArrowLeft, Send, Tag } from 'lucide-react';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { api } from '../api/client'; import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import type { Board } from '../api/types'; import type { Board } from '../api/types';
import { isHtmlEmpty } from '../utils/postContent';
import { useForumLimits } from '../hooks/useForumLimits';
import { useUnsavedChangesGuard } from '../hooks/useUnsavedChangesGuard';
import ArticleEditor from '../components/ArticleEditor'; import ArticleEditor from '../components/ArticleEditor';
import UnsavedChangesDialog from '../components/UnsavedChangesDialog';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { markdownToHtml, htmlToMarkdown } from '../utils/markdown';
interface ComposeBaseline {
title: string;
tags: string;
content: string;
boardId: string;
}
export default function ComposePage() { export default function ComposePage() {
const nav = useNavigate(); const nav = useNavigate();
@@ -17,6 +27,7 @@ export default function ComposePage() {
const [params] = useSearchParams(); const [params] = useSearchParams();
const defaultBoard = params.get('board') || ''; const defaultBoard = params.get('board') || '';
const { user, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
const { limits } = useForumLimits();
const [boards, setBoards] = useState<Board[]>([]); const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState(defaultBoard); const [boardId, setBoardId] = useState(defaultBoard);
@@ -25,6 +36,7 @@ export default function ComposePage() {
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
const [loading, setLoading] = useState(isEdit); const [loading, setLoading] = useState(isEdit);
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
useEffect(() => { useEffect(() => {
if (authLoading) return; if (authLoading) return;
@@ -32,21 +44,33 @@ export default function ComposePage() {
if (isEdit) { if (isEdit) {
setLoading(true); setLoading(true);
Promise.all([api.boards(), api.post(editId!)]) Promise.all([api.boards(), api.post(editId!, { skipView: true })])
.then(([boardsData, postData]) => { .then(([boardsData, postData]) => {
const list = boardsData.boards ?? []; const list = boardsData.boards ?? [];
setBoards(list); setBoards(list);
const post = postData.post; const post = postData.post;
const canEdit = user.role === 'admin' || post.user_id === user.id; const isOwnerOrAdmin = user.role === 'admin' || post.user_id === user.id;
if (!canEdit) { if (!isOwnerOrAdmin) {
notify.error('无权编辑此帖子'); notify.error('无权编辑此帖子');
nav(`/post/${editId}`); nav(`/post/${editId}`);
return; return;
} }
setBoardId(String(post.board_id)); if (!postData.can_edit) {
notify.error(postData.edit_block_reason || '当前无法编辑此帖子');
nav(`/post/${editId}`);
return;
}
const loadedBoardId = String(post.board_id);
setBoardId(loadedBoardId);
setTitle(post.title); setTitle(post.title);
setTags(post.tags ?? ''); setTags(post.tags ?? '');
setContent(htmlToMarkdown(post.content ?? '')); setContent(post.content ?? '');
setBaseline({
title: post.title,
tags: post.tags ?? '',
content: post.content ?? '',
boardId: loadedBoardId,
});
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
notify.error(e instanceof Error ? e.message : '加载帖子失败'); notify.error(e instanceof Error ? e.message : '加载帖子失败');
@@ -59,12 +83,37 @@ export default function ComposePage() {
api.boards().then(d => { api.boards().then(d => {
const list = d.boards ?? []; const list = d.boards ?? [];
setBoards(list); setBoards(list);
const initialBoardId = defaultBoard || (list.length > 0 ? String(list[0].id) : '');
if (!defaultBoard && list.length > 0) { if (!defaultBoard && list.length > 0) {
setBoardId(String(list[0].id)); setBoardId(initialBoardId);
} }
setBaseline({
title: '',
tags: '',
content: '',
boardId: initialBoardId,
});
}).catch(() => {}); }).catch(() => {});
}, [user, authLoading, nav, defaultBoard, isEdit, editId]); }, [user, authLoading, nav, defaultBoard, isEdit, editId]);
const isDirty = useMemo(() => {
if (!baseline) return false;
return (
title !== baseline.title
|| tags !== baseline.tags
|| content !== baseline.content
|| (!isEdit && boardId !== baseline.boardId)
);
}, [baseline, title, tags, content, boardId, isEdit]);
const {
dialogOpen,
stayOnPage,
discardAndLeave,
requestLeave,
markSaved,
} = useUnsavedChangesGuard({ isDirty });
if (authLoading) { if (authLoading) {
return ( return (
<div className="compose-page compose-page--empty"> <div className="compose-page compose-page--empty">
@@ -91,7 +140,7 @@ export default function ComposePage() {
<h2></h2> <h2></h2>
<p></p> <p></p>
{user.role === 'admin' ? ( {user.role === 'admin' ? (
<button type="button" className="compose-primary-btn" onClick={() => nav('/boards')}> <button type="button" className="compose-primary-btn" onClick={() => nav('/admin/boards')}>
</button> </button>
) : ( ) : (
@@ -108,22 +157,24 @@ export default function ComposePage() {
const trimmedTitle = title.trim(); const trimmedTitle = title.trim();
if (!isEdit && !boardId) { notify.warning('请选择板块'); return; } if (!isEdit && !boardId) { notify.warning('请选择板块'); return; }
if (!trimmedTitle) { notify.warning('请输入标题'); return; } if (!trimmedTitle) { notify.warning('请输入标题'); return; }
if (!content.trim()) { notify.warning('请输入正文内容'); return; } if (isHtmlEmpty(content)) { notify.warning('请输入正文内容'); return; }
setPublishing(true); setPublishing(true);
try { try {
const payload = { const payload = {
title: trimmedTitle, title: trimmedTitle,
content: markdownToHtml(content), content: content.trim(),
tags: tags.trim(), tags: tags.trim(),
}; };
if (isEdit) { if (isEdit) {
await api.updatePost(editId!, payload); await api.updatePost(editId!, payload);
notify.success('帖子已更新'); notify.success('帖子已更新');
markSaved();
nav(`/post/${editId}`); nav(`/post/${editId}`);
} else { } else {
const res = await api.createPost({ board_id: boardId, ...payload }); const res = await api.createPost({ board_id: boardId, ...payload });
notify.success('发帖成功'); notify.success('发帖成功');
markSaved();
nav(`/post/${res.post_id}`); nav(`/post/${res.post_id}`);
} }
} catch (e: unknown) { } catch (e: unknown) {
@@ -139,7 +190,11 @@ export default function ComposePage() {
<div className="compose-page"> <div className="compose-page">
<div className="compose-canvas"> <div className="compose-canvas">
<header className="compose-header"> <header className="compose-header">
<button type="button" className="compose-back" onClick={() => nav(isEdit ? `/post/${editId}` : -1)}> <button
type="button"
className="compose-back"
onClick={() => requestLeave(() => nav(isEdit ? `/post/${editId}` : -1))}
>
<ArrowLeft size={16} /> <ArrowLeft size={16} />
<span></span> <span></span>
</button> </button>
@@ -182,7 +237,7 @@ export default function ComposePage() {
placeholder="添加标签,逗号分隔" placeholder="添加标签,逗号分隔"
value={tags} value={tags}
onChange={e => setTags(e.target.value)} onChange={e => setTags(e.target.value)}
maxLength={128} maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
/> />
</div> </div>
</div> </div>
@@ -194,7 +249,7 @@ export default function ComposePage() {
placeholder="输入文章标题…" placeholder="输入文章标题…"
value={title} value={title}
onChange={e => setTitle(e.target.value)} onChange={e => setTitle(e.target.value)}
maxLength={256} maxLength={limits.post_title_max > 0 ? limits.post_title_max : undefined}
/> />
{currentBoard && ( {currentBoard && (
<div className="compose-subtitle"> <div className="compose-subtitle">
@@ -204,10 +259,16 @@ export default function ComposePage() {
<ArticleEditor <ArticleEditor
value={content} value={content}
onChange={setContent} onChange={setContent}
placeholder="开始写作。支持 Markdown 语法,右侧可实时预览渲染效果。" placeholder="开始写作。所见即所得,选中文字后使用工具栏设置格式。"
/> />
</div> </div>
</div> </div>
<UnsavedChangesDialog
open={dialogOpen}
onStay={stayOnPage}
onLeave={discardAndLeave}
isEdit={isEdit}
/>
</div> </div>
); );
} }

View File

@@ -1,32 +1,78 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'; import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { api } from '../api/client'; import { api } from '../api/client';
import type { PostItem } from '../api/types'; import type { PostItem } from '../api/types';
import type { LayoutCtx } from '../layouts/MainLayout'; import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList'; import VirtualPostList from '../components/VirtualPostList';
import FeedHeader from '../components/FeedHeader'; import FeedHeader from '../components/FeedHeader';
import BoardGrid from '../components/BoardGrid'; import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
import { useForumLimits } from '../hooks/useForumLimits';
import {
getFeedCache,
setFeedCache,
clearAllFeedCache,
navigateFeed,
FEED_RESET_EVENT,
type FeedNavState,
} from '../utils/feedCache';
export default function HomePage() { export default function HomePage() {
const nav = useNavigate(); const nav = useNavigate();
const location = useLocation();
const [params] = useSearchParams(); const [params] = useSearchParams();
const ctx = useOutletContext<LayoutCtx>(); const ctx = useOutletContext<LayoutCtx>();
const { limits } = useForumLimits();
const pageSize = limits.page_size_default;
const feedMaxPages = limits.feed_max_pages;
const feedMaxItems = limits.feed_max_items;
const boardId = Number(params.get('board')) || ctx?.boardId || 0; const boardId = Number(params.get('board')) || ctx?.boardId || 0;
const keyword = params.get('keyword') || ''; const keyword = params.get('keyword') || '';
const sort = parseFeedSort(params.get('sort'));
const initialCache = getFeedCache(boardId, keyword, sort);
const [posts, setPosts] = useState<PostItem[]>([]); const [posts, setPosts] = useState<PostItem[]>(() => initialCache?.posts ?? []);
const [postTotal, setPostTotal] = useState(0); const [postTotal, setPostTotal] = useState(() => initialCache?.postTotal ?? 0);
const [page, setPage] = useState(1); const [page, setPage] = useState(() => initialCache?.page ?? 1);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(() => initialCache?.hasMore ?? true);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(() => !initialCache);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => initialCache?.scrollTop ?? null);
const [listResetKey, setListResetKey] = useState(0);
const scrollTopRef = useRef(initialCache?.scrollTop ?? 0);
const pageWrapRef = useRef<HTMLDivElement>(null);
/** 主动刷新时不把旧列表/滚动位置写回 cache */
const skipCacheSaveRef = useRef(false);
const canAutoLoad = useMemo(
() => hasMore && page < feedMaxPages && posts.length < feedMaxItems,
[hasMore, page, feedMaxPages, posts.length, feedMaxItems],
);
const resetFeedView = useCallback(() => {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
setListResetKey(k => k + 1);
pageWrapRef.current?.scrollTo(0);
}, []);
const beginFeedRefresh = useCallback(() => {
skipCacheSaveRef.current = true;
clearAllFeedCache();
resetFeedView();
}, [resetFeedView]);
const load = useCallback(async (p: number, reset = false) => { const load = useCallback(async (p: number, reset = false) => {
setLoading(true); setLoading(true);
try { try {
const data = await api.posts({ page: p, size: 30, board_id: boardId || '', keyword }); const data = await api.posts({
page: p,
size: pageSize,
board_id: boardId || '',
keyword,
sort: sort === 'latest' ? '' : sort,
});
const batch = Array.isArray(data.posts) ? data.posts : []; const batch = Array.isArray(data.posts) ? data.posts : [];
// 切换筛选时保留旧列表,避免中间区域瞬间空白
setPosts(prev => (reset ? batch : [...prev, ...batch])); setPosts(prev => (reset ? batch : [...prev, ...batch]));
setPostTotal(data.total ?? 0); setPostTotal(data.total ?? 0);
setHasMore(!!data.has_more); setHasMore(!!data.has_more);
@@ -37,51 +83,134 @@ export default function HomePage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [boardId, keyword]); }, [boardId, keyword, sort, pageSize]);
/** 有缓存时静默刷新第 1 页,合并置顶等变化同时保留已加载的历史 */
const revalidate = useCallback(async () => {
try {
const data = await api.posts({
page: 1,
size: pageSize,
board_id: boardId || '',
keyword,
sort: sort === 'latest' ? '' : sort,
});
const fresh = Array.isArray(data.posts) ? data.posts : [];
const freshIds = new Set(fresh.map(p => p.id));
setPosts(prev => [...fresh, ...prev.filter(p => !freshIds.has(p.id))]);
setPostTotal(data.total ?? 0);
setHasMore(!!data.has_more);
} catch {
// 静默失败,保留缓存数据
}
}, [boardId, keyword, sort, pageSize]);
const loadNextPage = useCallback(() => {
if (loading || !hasMore) return;
load(page + 1);
}, [loading, hasMore, page, load]);
useEffect(() => { useEffect(() => {
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
if (forceRefresh) {
beginFeedRefresh();
load(1, true);
return;
}
const cached = getFeedCache(boardId, keyword, sort);
if (cached) {
setPosts(cached.posts);
setPostTotal(cached.postTotal);
setPage(cached.page);
setHasMore(cached.hasMore);
setRestoreScrollTop(cached.scrollTop);
scrollTopRef.current = cached.scrollTop;
setLoading(false);
revalidate();
return;
}
setRestoreScrollTop(null);
scrollTopRef.current = 0;
load(1, true); load(1, true);
}, [boardId, keyword, load]); }, [boardId, keyword, sort, location.key, location.state, load, revalidate, beginFeedRefresh]);
useEffect(() => { useEffect(() => {
const fn = () => load(1, true); return () => {
if (skipCacheSaveRef.current || posts.length === 0) return;
setFeedCache(boardId, keyword, sort, {
posts,
postTotal,
page,
hasMore,
scrollTop: scrollTopRef.current,
});
};
}, [boardId, keyword, sort, posts, postTotal, page, hasMore]);
useEffect(() => {
if (!loading && posts.length > 0) {
skipCacheSaveRef.current = false;
}
}, [loading, posts.length]);
useEffect(() => {
const onFeedReset = () => {
beginFeedRefresh();
};
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
}, [beginFeedRefresh]);
useEffect(() => {
const fn = () => {
beginFeedRefresh();
load(1, true);
};
window.addEventListener('posts-refresh', fn); window.addEventListener('posts-refresh', fn);
return () => window.removeEventListener('posts-refresh', fn); return () => window.removeEventListener('posts-refresh', fn);
}, [load]); }, [beginFeedRefresh, load]);
const showBoardGrid = !keyword; const handleSortChange = (next: FeedSort) => {
if (next === sort) {
beginFeedRefresh();
load(1, true);
return;
}
navigateFeed(nav, buildHomeUrl(boardId, next));
};
const showSortBar = !keyword;
return ( return (
<div className="page-wrap"> <div className="page-wrap" ref={pageWrapRef}>
<FeedHeader <div className="feed-panel">
boardId={boardId} <div className="feed-top">
keyword={keyword} <FeedHeader
boards={ctx?.boards ?? []} boardId={boardId}
stats={ctx?.stats ?? null} keyword={keyword}
postTotal={postTotal} boards={ctx?.boards ?? []}
/> stats={ctx?.stats ?? null}
{showBoardGrid && ( postTotal={postTotal}
<BoardGrid />
boards={ctx?.boards ?? []} {showSortBar && (
loading={!ctx?.layoutReady} <FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
selectedId={boardId} )}
onSelect={(id) => { </div>
ctx?.setBoardId(id); <VirtualPostList
nav(id ? `/?board=${id}` : '/');
}}
/>
)}
<div className="post-list-bar">
<span>{keyword ? '搜索结果' : '帖子列表'}</span>
<span> {postTotal} </span>
</div>
<VirtualPostList
posts={posts} posts={posts}
sort={sort}
loading={loading} loading={loading}
hasMore={hasMore} hasMore={hasMore}
onLoadMore={() => !loading && hasMore && load(page + 1)} canAutoLoad={canAutoLoad}
postTotal={postTotal}
onLoadMore={loadNextPage}
onSelect={(id) => nav(`/post/${id}`)} onSelect={(id) => nav(`/post/${id}`)}
restoreScrollTop={restoreScrollTop}
resetScrollKey={listResetKey}
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
onScrollRestored={() => setRestoreScrollTop(null)}
/> />
</div>
</div> </div>
); );
} }

View File

@@ -1,8 +1,10 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Pencil } from 'lucide-react'; import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock } from 'lucide-react';
import PinnedIcon from '@/components/PinnedIcon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import BoardBadge from '@/components/BoardBadge';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { api } from '../api/client'; import { api } from '../api/client';
@@ -10,9 +12,11 @@ import type { PostItem, Comment } from '../api/types';
import CommentThreadList from '../components/CommentThreadList'; import CommentThreadList from '../components/CommentThreadList';
import CommentBox, { type CommentSubmitData } from '../components/CommentBox'; import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
import PostContent from '../components/PostContent'; import PostContent from '../components/PostContent';
import PostRevisionPanel from '../components/PostRevisionPanel';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { formatTime } from '../utils/content'; import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
import { loadMyCommentIds, addMyCommentId } from '../utils/guest'; import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
import { clearAllFeedCache } from '../utils/feedCache';
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll'; import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
export default function PostDetailPage() { export default function PostDetailPage() {
@@ -30,6 +34,10 @@ export default function PostDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [highlightFloor, setHighlightFloor] = useState<number | null>(null); const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
const [submitCount, setSubmitCount] = useState(0); const [submitCount, setSubmitCount] = useState(0);
const [canEdit, setCanEdit] = useState(false);
const [isEdited, setIsEdited] = useState(false);
const [editBlockReason, setEditBlockReason] = useState('');
const [showRevisions, setShowRevisions] = useState(false);
const pageRef = useRef<HTMLDivElement>(null); const pageRef = useRef<HTMLDivElement>(null);
const commentSectionRef = useRef<HTMLDivElement>(null); const commentSectionRef = useRef<HTMLDivElement>(null);
@@ -55,6 +63,9 @@ export default function PostDetailPage() {
setPost(detail.post); setPost(detail.post);
setLiked(detail.liked); setLiked(detail.liked);
setFavorited(detail.favorited); setFavorited(detail.favorited);
setCanEdit(detail.can_edit ?? false);
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
setEditBlockReason(detail.edit_block_reason ?? '');
setComments(commList); setComments(commList);
await refresh(); await refresh();
} catch (e: unknown) { } catch (e: unknown) {
@@ -161,7 +172,39 @@ export default function PostDetailPage() {
const authorInitial = post.user?.nickname?.[0] || '?'; const authorInitial = post.user?.nickname?.[0] || '?';
const tags = post.tags?.split(/[,]/).map(t => t.trim()).filter(Boolean) ?? []; const tags = post.tags?.split(/[,]/).map(t => t.trim()).filter(Boolean) ?? [];
const canEdit = user && (user.role === 'admin' || user.id === post.user_id); const isOwnerOrAdmin = user && (user.role === 'admin' || user.id === post.user_id);
const isAdmin = user?.role === 'admin';
const showEdited = isEdited && post.updated_at;
const handlePin = async () => {
if (!post) return;
try {
const r = await api.adminPinPost(postId, !post.pinned);
setPost(p => p ? { ...p, pinned: r.pinned } : p);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const handleLock = async () => {
if (!post) return;
try {
const r = await api.adminLockPost(postId, !post.edit_locked);
setPost(p => p ? { ...p, edit_locked: r.edit_locked } : p);
if (user?.role === 'admin') {
setCanEdit(true);
} else if (user?.id === post.user_id && r.edit_locked) {
setCanEdit(false);
setEditBlockReason('帖子已被管理员锁定,无法编辑');
}
notify.success(r.message);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
return ( return (
<div className="page-wrap post-detail-page" ref={pageRef}> <div className="page-wrap post-detail-page" ref={pageRef}>
@@ -172,13 +215,13 @@ export default function PostDetailPage() {
</Button> </Button>
{post.board && ( {post.board && (
<Badge variant="green" className="post-detail-board-tag">{post.board.name}</Badge> <BoardBadge board={post.board} className="post-detail-board-tag" />
)} )}
</div> </div>
<div className="post-detail-head"> <div className="post-detail-head">
<h1 className="post-detail-title"> <h1 className="post-detail-title">
{post.pinned && <Badge variant="orange" className="mr-2 align-middle"></Badge>} {post.pinned && <PinnedIcon className="mr-2" size={18} />}
{post.title} {post.title}
</h1> </h1>
<div className="post-detail-author-row"> <div className="post-detail-author-row">
@@ -188,7 +231,16 @@ export default function PostDetailPage() {
<div className="post-detail-author-info"> <div className="post-detail-author-info">
<span className="post-detail-author-name">{post.user?.nickname}</span> <span className="post-detail-author-name">{post.user?.nickname}</span>
<span className="post-detail-meta-line"> <span className="post-detail-meta-line">
{formatTime(post.created_at)} · {post.view_count} {formatDateTime(post.created_at)}
{showEdited && (
<> · {formatDateTime(post.updated_at!)}</>
)}
{' · '}{post.view_count}
{post.edit_locked && (
<span className="post-detail-locked-tag" title="管理员已锁定编辑">
<Lock size={12} />
</span>
)}
</span> </span>
</div> </div>
</div> </div>
@@ -217,9 +269,40 @@ export default function PostDetailPage() {
</Button> </Button>
)} )}
{isOwnerOrAdmin && isEdited && (
<Button variant="outline" size="sm" onClick={() => setShowRevisions(true)}>
<History />
</Button>
)}
{isOwnerOrAdmin && !canEdit && editBlockReason && (
<span className="post-detail-edit-hint" title={editBlockReason}>
{editBlockReason}
</span>
)}
{isAdmin && (
<>
<Button variant="outline" size="sm" onClick={handlePin}>
<Pin />
{post.pinned ? '取消置顶' : '置顶'}
</Button>
<Button variant="outline" size="sm" onClick={handleLock}>
<Lock />
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
</Button>
</>
)}
</div> </div>
</div> </div>
<PostRevisionPanel
postId={postId}
currentPost={{ title: post.title, content: post.content ?? '', tags: post.tags ?? '' }}
open={showRevisions}
onClose={() => setShowRevisions(false)}
isLoggedIn={!!user}
/>
<div className="comment-section" ref={commentSectionRef}> <div className="comment-section" ref={commentSectionRef}>
<div className="comment-section-bar"> <div className="comment-section-bar">
<span className="comment-section-title"></span> <span className="comment-section-title"></span>

View File

@@ -1,9 +1,9 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { ArrowLeft, LayoutDashboard, Settings } from 'lucide-react'; import { ArrowLeft, Camera, LayoutDashboard, Settings, Upload, X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -12,15 +12,17 @@ import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { api } from '../api/client'; import { api } from '../api/client';
import { openAdminDashboard } from '../utils/admin'; import { useForumLimits } from '../hooks/useForumLimits';
import AvatarCropDialog from '../components/AvatarCropDialog';
import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
const nickSchema = z.object({ const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64), nickname: z.string().min(1, '请输入昵称').max(64),
}); });
const pwdSchema = z.object({ const pwdSchema = (minLen: number) => z.object({
old_password: z.string().min(1, '请输入当前密码'), old_password: z.string().min(1, '请输入当前密码'),
new_password: z.string().min(6, '新密码至少 6 位'), new_password: z.string().min(minLen, `新密码至少 ${minLen}`),
confirm_password: z.string().min(1, '请确认新密码'), confirm_password: z.string().min(1, '请确认新密码'),
}).refine(d => d.new_password === d.confirm_password, { }).refine(d => d.new_password === d.confirm_password, {
message: '两次输入的新密码不一致', message: '两次输入的新密码不一致',
@@ -28,7 +30,7 @@ const pwdSchema = z.object({
}); });
type NickValues = z.infer<typeof nickSchema>; type NickValues = z.infer<typeof nickSchema>;
type PwdValues = z.infer<typeof pwdSchema>; type PwdValues = z.infer<ReturnType<typeof pwdSchema>>;
export default function ProfilePage() { export default function ProfilePage() {
const nav = useNavigate(); const nav = useNavigate();
@@ -36,7 +38,16 @@ export default function ProfilePage() {
const [nickLoading, setNickLoading] = useState(false); const [nickLoading, setNickLoading] = useState(false);
const [pwdLoading, setPwdLoading] = useState(false); const [pwdLoading, setPwdLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false); const [avatarLoading, setAvatarLoading] = useState(false);
const [pendingAvatar, setPendingAvatar] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [cropOpen, setCropOpen] = useState(false);
const [cropImageSrc, setCropImageSrc] = useState<string | null>(null);
const [cropFileName, setCropFileName] = useState('');
const [dragOver, setDragOver] = useState(false);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const dragCounter = useRef(0);
const { limits } = useForumLimits();
const nickForm = useForm<NickValues>({ const nickForm = useForm<NickValues>({
resolver: zodResolver(nickSchema), resolver: zodResolver(nickSchema),
@@ -44,7 +55,7 @@ export default function ProfilePage() {
}); });
const pwdForm = useForm<PwdValues>({ const pwdForm = useForm<PwdValues>({
resolver: zodResolver(pwdSchema), resolver: zodResolver(pwdSchema(limits.password_min_len)),
defaultValues: { old_password: '', new_password: '', confirm_password: '' }, defaultValues: { old_password: '', new_password: '', confirm_password: '' },
}); });
@@ -54,6 +65,30 @@ export default function ProfilePage() {
} }
}, [authLoading, user, nav]); }, [authLoading, user, nav]);
useEffect(() => {
return () => {
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
};
}, [avatarPreview]);
useEffect(() => {
return () => {
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
};
}, [cropImageSrc]);
const closeCropDialog = useCallback((open: boolean) => {
if (!open) {
setCropOpen(false);
setCropImageSrc(prev => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
setCropFileName('');
if (fileRef.current) fileRef.current.value = '';
}
}, []);
if (authLoading) { if (authLoading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>; return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
} }
@@ -88,26 +123,88 @@ export default function ProfilePage() {
} }
}; };
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const clearPendingAvatar = () => {
const file = e.target.files?.[0]; if (avatarPreview) URL.revokeObjectURL(avatarPreview);
if (!file) return; setPendingAvatar(null);
if (file.size > 2 * 1024 * 1024) { setAvatarPreview(null);
notify.error('头像不能超过 2MB'); if (fileRef.current) fileRef.current.value = '';
};
const openCropForFile = (file: File) => {
const err = validateAvatarFile(file, limits.avatar_max_mb);
if (err) {
notify.error(err);
if (fileRef.current) fileRef.current.value = '';
return; return;
} }
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
setCropFileName(file.name);
setCropImageSrc(URL.createObjectURL(file));
setCropOpen(true);
};
const onAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) openCropForFile(file);
};
const onCropConfirm = (file: File) => {
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
setPendingAvatar(file);
setAvatarPreview(URL.createObjectURL(file));
};
const onSaveAvatar = async () => {
if (!pendingAvatar) return;
setAvatarLoading(true); setAvatarLoading(true);
try { try {
await api.uploadAvatar(file); await api.uploadAvatar(pendingAvatar);
await refresh(); await refresh();
clearPendingAvatar();
notify.success('头像已更新'); notify.success('头像已更新');
} catch (err: unknown) { } catch (err: unknown) {
notify.error(err instanceof Error ? err.message : '上传失败'); notify.error(err instanceof Error ? err.message : '上传失败');
} finally { } finally {
setAvatarLoading(false); setAvatarLoading(false);
if (fileRef.current) fileRef.current.value = '';
} }
}; };
const onDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current += 1;
if (e.dataTransfer.types.includes('Files')) {
setDragOver(true);
}
};
const onDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current -= 1;
if (dragCounter.current <= 0) {
dragCounter.current = 0;
setDragOver(false);
}
};
const onDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setDragOver(false);
if (avatarLoading) return;
const file = e.dataTransfer.files?.[0];
if (file) openCropForFile(file);
};
const displayAvatar = avatarPreview ?? user.avatar;
return ( return (
<div className="page-wrap"> <div className="page-wrap">
<div className="page-inner-wide" style={{ maxWidth: 640 }}> <div className="page-inner-wide" style={{ maxWidth: 640 }}>
@@ -115,18 +212,82 @@ export default function ProfilePage() {
<ArrowLeft /> <ArrowLeft />
</Button> </Button>
<h1 className="page-title mb-4"></h1>
<div className="profile-header"> <div
<div className="profile-avatar-lg"> className={`profile-header-card${dragOver ? ' profile-header-card--dragover' : ''}`}
{user.avatar ? <img src={user.avatar} alt="" /> : user.nickname[0]} onDragEnter={onDragEnter}
</div> onDragLeave={onDragLeave}
<div> onDragOver={onDragOver}
<h1 className="page-title" style={{ marginBottom: 4 }}>{user.nickname}</h1> onDrop={onDrop}
<div style={{ fontSize: 13, color: 'var(--color-text-3)' }}>@{user.username}</div> >
{user.role === 'admin' && <Badge variant="green" className="mt-1.5"></Badge>} {dragOver && (
<div className="profile-drop-overlay">
<Upload size={28} strokeWidth={1.5} />
<span></span>
</div>
)}
<input
ref={fileRef}
type="file"
accept={AVATAR_ACCEPT}
className="sr-only"
onChange={onAvatarSelect}
/>
<button
type="button"
className="profile-avatar-btn"
title="点击或拖拽图片到此处更换头像"
disabled={avatarLoading}
onClick={() => fileRef.current?.click()}
>
<div className={`profile-avatar-lg${pendingAvatar ? ' profile-avatar-lg--pending' : ''}`}>
{displayAvatar
? <img src={displayAvatar} alt="" />
: user.nickname[0]}
<span className="profile-avatar-overlay">
{avatarLoading
? <Spinner size="sm" className="text-white" />
: <Camera size={22} strokeWidth={1.75} />}
</span>
</div>
</button>
<div className="profile-header-info">
<div className="profile-header-main">
<h1 className="profile-display-name">{user.nickname}</h1>
<div className="profile-username">@{user.username}</div>
<p className="profile-avatar-tip"></p>
{user.role === 'admin' && <Badge variant="green" className="mt-1.5"></Badge>}
</div>
{pendingAvatar && (
<div className="profile-avatar-actions">
<span className="profile-avatar-hint"></span>
<Button size="sm" loading={avatarLoading} onClick={onSaveAvatar}>
</Button>
<Button
size="sm"
variant="ghost"
disabled={avatarLoading}
onClick={clearPendingAvatar}
aria-label="取消"
>
<X size={16} />
</Button>
</div>
)}
</div> </div>
</div> </div>
<AvatarCropDialog
open={cropOpen}
imageSrc={cropImageSrc}
fileName={cropFileName}
onOpenChange={closeCropDialog}
onConfirm={onCropConfirm}
/>
{user.role === 'admin' && ( {user.role === 'admin' && (
<div className="section-card admin-entry-card"> <div className="section-card admin-entry-card">
<div className="section-card-title"></div> <div className="section-card-title"></div>
@@ -134,11 +295,11 @@ export default function ProfilePage() {
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => nav('/boards')}> <Button variant="outline" onClick={() => nav('/admin/boards')}>
<Settings /> <Settings />
</Button> </Button>
<Button onClick={openAdminDashboard}> <Button onClick={() => nav('/admin/dashboard')}>
<LayoutDashboard /> <LayoutDashboard />
</Button> </Button>
@@ -149,7 +310,7 @@ export default function ProfilePage() {
<div className="section-card"> <div className="section-card">
<div className="section-card-title"></div> <div className="section-card-title"></div>
<Form {...nickForm}> <Form {...nickForm}>
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="space-y-4"> <form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
@@ -169,32 +330,20 @@ export default function ProfilePage() {
</FormItem> </FormItem>
)} )}
/> />
<Button type="submit" loading={nickLoading}></Button> <div className="profile-form-footer">
<span className="profile-form-hint">
JPGPNGGIFWebP {limits.avatar_max_mb}MB
</span>
<Button type="submit" loading={nickLoading}></Button>
</div>
</form> </form>
</Form> </Form>
</div> </div>
<div className="section-card">
<div className="section-card-title"></div>
<p style={{ fontSize: 13, color: 'var(--color-text-3)', margin: '0 0 12px' }}>
JPGPNGGIFWebP 2MB
</p>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: 'none' }}
onChange={onAvatarChange}
/>
<Button variant="outline" loading={avatarLoading} onClick={() => fileRef.current?.click()}>
</Button>
</div>
<div className="section-card"> <div className="section-card">
<div className="section-card-title"></div> <div className="section-card-title"></div>
<Form {...pwdForm}> <Form {...pwdForm}>
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="space-y-4"> <form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="profile-form">
<FormField <FormField
control={pwdForm.control} control={pwdForm.control}
name="old_password" name="old_password"
@@ -234,9 +383,11 @@ export default function ProfilePage() {
</FormItem> </FormItem>
)} )}
/> />
<Button type="submit" variant="destructive" loading={pwdLoading}> <div className="profile-form-footer profile-form-footer--end">
<Button type="submit" variant="destructive" loading={pwdLoading}>
</Button>
</Button>
</div>
</form> </form>
</Form> </Form>
</div> </div>

View File

@@ -8,22 +8,24 @@ import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { api } from '../api/client'; import { api } from '../api/client';
import { useForumLimits } from '../hooks/useForumLimits';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
const schema = z.object({ const schema = (minLen: number) => z.object({
username: z.string().min(1, '请输入用户名'), username: z.string().min(1, '请输入用户名'),
nickname: z.string().optional(), nickname: z.string().optional(),
password: z.string().min(6, '密码至少 6 位'), password: z.string().min(minLen, `密码至少 ${minLen}`),
}); });
type FormValues = z.infer<typeof schema>; type FormValues = z.infer<ReturnType<typeof schema>>;
export default function RegisterPage() { export default function RegisterPage() {
const { limits } = useForumLimits();
const nav = useNavigate(); const nav = useNavigate();
const { refresh } = useAuth(); const { refresh } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema(limits.password_min_len)),
defaultValues: { username: '', nickname: '', password: '' }, defaultValues: { username: '', nickname: '', password: '' },
}); });
@@ -82,7 +84,7 @@ export default function RegisterPage() {
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<Input type="password" placeholder="至少 6 位" autoComplete="new-password" {...field} /> <Input type="password" placeholder={`至少 ${limits.password_min_len}`} autoComplete="new-password" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { Comment } from '../../api/types';
export default function AdminCommentsPage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const load = (p = page) => {
setLoading(true);
api.adminComments(p)
.then(d => {
setComments(d.comments ?? []);
setPage(d.page);
setTotalPages(d.total_pages);
})
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
if (ready) load(1);
}, [ready]);
const remove = async (id: number) => {
try {
await api.adminDeleteComment(id);
notify.success('评论已删除');
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '删除失败');
}
};
if (!ready) return null;
return (
<div className="admin-page">
<div className="admin-page-head">
<h1></h1>
<p></p>
</div>
<div className="admin-card">
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
<>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{comments.map(c => (
<tr key={c.id}>
<td>{c.id}</td>
<td>#{c.floor}</td>
<td>
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${c.post_id}`)}>
{c.post?.title ?? `#${c.post_id}`}
</button>
</td>
<td>{c.user?.nickname || c.guest_nick || '游客'}</td>
<td className="max-w-[200px] truncate">{c.content}</td>
<td>{c.is_private ? <Badge variant="secondary"></Badge> : '—'}</td>
<td>{new Date(c.created_at).toLocaleString('zh-CN')}</td>
<td>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive"></Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription></AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => remove(c.id)}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</td>
</tr>
))}
</tbody>
</table>
{comments.length === 0 && <div className="admin-empty"></div>}
{totalPages > 1 && (
<div className="admin-pagination">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}></Button>
<span> {page} / {totalPages} </span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}></Button>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Spinner } from '@/components/ui/spinner';
import { Badge } from '@/components/ui/badge';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { AdminDashboard } from '../../api/types';
export default function AdminDashboardPage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [data, setData] = useState<AdminDashboard | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!ready) return;
api.adminDashboard()
.then(setData)
.finally(() => setLoading(false));
}, [ready]);
if (!ready || loading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!data) return null;
const stats = [
{ label: '注册用户', value: data.users, cls: 'admin-stat-users' },
{ label: '帖子总数', value: data.posts, cls: 'admin-stat-posts' },
{ label: '板块数量', value: data.boards, cls: 'admin-stat-boards' },
{ label: '评论总数', value: data.comments, cls: 'admin-stat-comments' },
{ label: '当前在线', value: data.online, cls: 'admin-stat-online' },
];
return (
<div className="admin-page">
<div className="admin-page-head">
<h1></h1>
<p></p>
</div>
<div className="admin-stat-grid">
{stats.map(s => (
<div key={s.label} className={`admin-stat-card ${s.cls}`}>
<div className="admin-stat-value">{s.value}</div>
<div className="admin-stat-label">{s.label}</div>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-card-head">
<span></span>
<button type="button" className="admin-text-link" onClick={() => nav('/admin/posts')}>
</button>
</div>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data.recent_posts.map(p => (
<tr key={p.id}>
<td>{p.id}</td>
<td>
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${p.id}`)}>
{p.title}
</button>
</td>
<td>{p.user?.nickname ?? '—'}</td>
<td>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td>
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
</tr>
))}
</tbody>
</table>
{data.recent_posts.length === 0 && <div className="admin-empty"></div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,206 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Lock, LockOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { PostItem } from '../../api/types';
import { clearAllFeedCache } from '../../utils/feedCache';
import { isTimeDiffSignificant } from '../../utils/content';
function formatAdminTime(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
export default function AdminPostsPage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [posts, setPosts] = useState<PostItem[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [keyword, setKeyword] = useState('');
const [search, setSearch] = useState('');
const load = (p = page, kw = search) => {
setLoading(true);
api.adminPosts({ page: p, keyword: kw })
.then(d => {
setPosts(d.posts ?? []);
setPage(d.page);
setTotalPages(d.total_pages);
})
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
if (ready) load(1, search);
}, [ready, search]);
const togglePin = async (post: PostItem) => {
try {
const r = await api.adminPinPost(post.id, !post.pinned);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message);
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const toggleLock = async (post: PostItem) => {
try {
const r = await api.adminLockPost(post.id, !post.edit_locked);
notify.success(r.message);
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const remove = async (id: number) => {
try {
await api.adminDeletePost(id);
notify.success('帖子已删除');
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '删除失败');
}
};
if (!ready) return null;
return (
<div className="admin-page">
<div className="admin-page-head">
<h1></h1>
<p></p>
</div>
<form
className="admin-search-bar"
onSubmit={e => { e.preventDefault(); setSearch(keyword.trim()); }}
>
<Input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索标题、标签或正文…"
/>
<Button type="submit"><Search size={16} /></Button>
{search && (
<Button type="button" variant="outline" onClick={() => { setKeyword(''); setSearch(''); }}>
</Button>
)}
</form>
<div className="admin-card">
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
<>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{posts.map(p => {
const edited = p.updated_at && isTimeDiffSignificant(p.created_at, p.updated_at);
return (
<tr key={p.id}>
<td>{p.id}</td>
<td className="max-w-[200px] truncate">
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${p.id}`)}>
{p.title}
</button>
{edited && <Badge variant="secondary" className="ml-1"></Badge>}
</td>
<td>{p.board?.name ?? '—'}</td>
<td>{p.user?.nickname ?? '—'}</td>
<td className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td>
<td>{p.comment_count ?? 0}</td>
<td>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td>
<td>{p.edit_locked ? <Badge variant="destructive"></Badge> : '—'}</td>
<td>{p.like_count}</td>
<td>{p.view_count}</td>
<td className="text-sm whitespace-nowrap">
<span>{formatAdminTime(p.created_at)}</span>
{edited && p.updated_at && (
<span className="block text-muted-foreground text-xs">
{formatAdminTime(p.updated_at)}
</span>
)}
</td>
<td>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
{p.pinned ? '取消置顶' : '置顶'}
</Button>
<Button size="sm" variant="outline" onClick={() => toggleLock(p)}>
{p.edit_locked ? <><LockOpen size={14} /> </> : <><Lock size={14} /> </>}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive"></Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription></AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => remove(p.id)}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{posts.length === 0 && <div className="admin-empty"></div>}
{totalPages > 1 && (
<div className="admin-pagination">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}></Button>
<span> {page} / {totalPages} </span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}></Button>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,326 @@
import { useEffect, useState } from 'react';
import { Database, Shield, Server, SlidersHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
import type { AdminSettings, ForumLimits } from '../../api/types';
type TabId = 'limits' | 'filter' | 'system';
type SettingRow = {
key: keyof ForumLimits;
label: string;
unit?: string;
hint?: string;
min?: number;
};
type SettingSection = {
id: string;
title: string;
summary: string;
rows: SettingRow[];
};
const SETTING_SECTIONS: SettingSection[] = [
{
id: 'rule',
title: '编辑规则',
summary: '控制普通用户修改自己帖子的时限',
rows: [
{ key: 'post_edit_window_hours', label: '可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
],
},
{
id: 'rate',
title: '操作限流',
summary: '同一用户或 IP 在窗口期内的最大请求次数',
rows: [
{ key: 'rate_limit_window_sec', label: '限流窗口', unit: '秒', min: 10 },
{ key: 'rate_limit_post', label: '发帖', unit: '次', min: 1 },
{ key: 'rate_limit_comment', label: '评论', unit: '次', min: 1 },
{ key: 'rate_limit_register', label: '注册', unit: '次', min: 1 },
{ key: 'rate_limit_login', label: '登录', unit: '次', min: 1 },
],
},
{
id: 'content',
title: '内容长度',
summary: '发帖与评论的字数上限,服务端强制校验',
rows: [
{ key: 'post_title_max', label: '帖子标题', unit: '字', min: 1 },
{ key: 'post_tags_max', label: '帖子标签', unit: '字', hint: '0 = 不限', min: 0 },
{ key: 'post_content_max', label: '帖子正文', unit: '字', hint: '0 = 不限', min: 0 },
{ key: 'comment_max', label: '评论内容', unit: '字', min: 1 },
],
},
{
id: 'search',
title: '搜索与列表',
summary: '关键词长度与帖子列表分页',
rows: [
{ key: 'search_keyword_min', label: '关键词最短', unit: '字', min: 0 },
{ key: 'search_keyword_max', label: '关键词最长', unit: '字', min: 1 },
{ key: 'page_size_default', label: '默认每页', unit: '条', min: 1 },
{ key: 'page_size_max', label: '最大每页', unit: '条', min: 1 },
{ key: 'feed_max_pages', label: '首页自动加载页数', unit: '页', hint: '超出后需手动加载', min: 1 },
{ key: 'feed_max_items', label: '首页自动加载条数', unit: '条', hint: '与页数取先到上限', min: 1 },
],
},
{
id: 'user',
title: '用户账号',
summary: '注册、改密与头像上传限制',
rows: [
{ key: 'password_min_len', label: '密码最短', unit: '位', min: 4 },
{ key: 'avatar_max_mb', label: '头像上限', unit: 'MB', min: 1 },
],
},
];
const TABS: { id: TabId; label: string; icon: typeof SlidersHorizontal }[] = [
{ id: 'limits', label: '论坛限制', icon: SlidersHorizontal },
{ id: 'filter', label: '敏感词', icon: Shield },
{ id: 'system', label: '系统维护', icon: Server },
];
function SettingTable({
sections,
limits,
onChange,
}: {
sections: SettingSection[];
limits: ForumLimits;
onChange: (key: keyof ForumLimits, value: string) => void;
}) {
return (
<div className="admin-settings-sections">
{sections.map(section => (
<section key={section.id} className="admin-settings-section" id={`settings-${section.id}`}>
<div className="admin-settings-section-head">
<h3>{section.title}</h3>
<p>{section.summary}</p>
</div>
<div className="admin-settings-table" role="group" aria-label={section.title}>
{section.rows.map(row => (
<div key={row.key} className="admin-settings-row">
<label htmlFor={`limit-${row.key}`} className="admin-settings-row-label">
{row.label}
</label>
<div className="admin-settings-row-input">
<Input
id={`limit-${row.key}`}
type="number"
min={row.min ?? 0}
value={limits[row.key]}
onChange={e => onChange(row.key, e.target.value)}
className="admin-settings-input"
/>
{row.unit && <span className="admin-settings-unit">{row.unit}</span>}
</div>
<span className="admin-settings-row-hint">{row.hint ?? ''}</span>
</div>
))}
</div>
</section>
))}
</div>
);
}
export default function AdminSettingsPage() {
const { ready } = useAdminGuard();
const [settings, setSettings] = useState<AdminSettings | null>(null);
const [limits, setLimits] = useState<ForumLimits | null>(null);
const [filterWords, setFilterWords] = useState('');
const [activeTab, setActiveTab] = useState<TabId>('limits');
const [loading, setLoading] = useState(true);
const [backing, setBacking] = useState(false);
const [savingForum, setSavingForum] = useState(false);
const [savingFilter, setSavingFilter] = useState(false);
useEffect(() => {
if (!ready) return;
api.adminSettings()
.then(s => {
setSettings(s);
setLimits(s.limits);
setFilterWords(s.filter_words);
})
.finally(() => setLoading(false));
}, [ready]);
const handleLimitChange = (key: keyof ForumLimits, value: string) => {
const n = parseInt(value, 10);
if (Number.isNaN(n)) return;
setLimits(prev => prev ? { ...prev, [key]: n } : prev);
};
const handleSaveForumSettings = async () => {
if (!limits) return;
setSavingForum(true);
try {
const r = await api.adminUpdateForumSettings(limits);
notify.success(r.message);
invalidateForumLimitsCache();
setLimits(r.limits);
setSettings(s => s ? { ...s, limits: r.limits } : s);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingForum(false);
}
};
const handleSaveFilterWords = async () => {
setSavingFilter(true);
try {
const r = await api.adminUpdateFilterWords(filterWords);
notify.success(r.message);
setSettings(s => s ? { ...s, filter_words: filterWords, filter_word_count: r.word_count } : s);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingFilter(false);
}
};
const handleBackup = async () => {
setBacking(true);
try {
const r = await api.adminBackup();
notify.success(r.message);
window.location.href = r.download;
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '备份失败');
} finally {
setBacking(false);
}
};
if (!ready || loading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!settings || !limits) return null;
return (
<div className="admin-settings-page">
<header className="admin-page-head">
<h1></h1>
<p></p>
</header>
<nav className="admin-settings-tabs" aria-label="设置分类">
{TABS.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
className={`admin-settings-tab${activeTab === id ? ' active' : ''}`}
onClick={() => setActiveTab(id)}
aria-selected={activeTab === id}
>
<Icon size={15} />
{label}
</button>
))}
</nav>
{activeTab === 'limits' && (
<div className="admin-settings-panel">
<div className="admin-card admin-settings-card">
<div className="admin-card-head">
<span></span>
<span className="admin-settings-card-badge"> {SETTING_SECTIONS.length} </span>
</div>
<div className="admin-card-body">
<SettingTable sections={SETTING_SECTIONS} limits={limits} onChange={handleLimitChange} />
</div>
</div>
<div className="admin-settings-bar">
<p></p>
<Button onClick={handleSaveForumSettings} loading={savingForum}>
</Button>
</div>
</div>
)}
{activeTab === 'filter' && (
<div className="admin-settings-panel">
<div className="admin-card admin-settings-card">
<div className="admin-card-head">
<span></span>
<span className="admin-settings-card-badge">{settings.filter_word_count} </span>
</div>
<div className="admin-card-body admin-settings-filter-body">
<p className="admin-settings-filter-tip">
<code>#</code> <code>filter_words.txt</code>
</p>
<Textarea
rows={16}
value={filterWords}
onChange={e => setFilterWords(e.target.value)}
className="admin-settings-filter-textarea"
spellCheck={false}
placeholder={'# 示例\n违禁词\n广告刷单'}
/>
</div>
</div>
<div className="admin-settings-bar">
<p> *</p>
<Button onClick={handleSaveFilterWords} loading={savingFilter}>
</Button>
</div>
</div>
)}
{activeTab === 'system' && (
<div className="admin-settings-panel">
<div className="admin-card admin-settings-card">
<div className="admin-card-head"></div>
<div className="admin-card-body">
<dl className="admin-settings-info">
<div className="admin-settings-info-row">
<dt></dt>
<dd><code>{settings.data_dir}</code></dd>
</div>
<div className="admin-settings-info-row">
<dt></dt>
<dd><code>{settings.db_path}</code></dd>
</div>
<div className="admin-settings-info-row">
<dt></dt>
<dd><code>{settings.filter_path}</code></dd>
</div>
<div className="admin-settings-info-row">
<dt></dt>
<dd>{settings.port}</dd>
</div>
</dl>
</div>
</div>
<div className="admin-card admin-settings-card">
<div className="admin-card-head"></div>
<div className="admin-card-body admin-settings-backup-body">
<p> SQLite 便</p>
<p className="admin-settings-backup-name">
<code>jiang13_backup_YYYYMMDD_HHMMSS.db</code>
</p>
<Button onClick={handleBackup} loading={backing}>
<Database size={16} />
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { User } from '../../api/types';
export default function AdminUsersPage() {
const { ready } = useAdminGuard();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const load = (p = page) => {
setLoading(true);
api.adminUsers(p)
.then(d => {
setUsers(d.users ?? []);
setPage(d.page);
setTotalPages(d.total_pages);
})
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
if (ready) load(1);
}, [ready]);
const toggleBan = async (user: User) => {
if (user.role === 'admin') {
notify.warning('不能禁言管理员');
return;
}
try {
const r = await api.adminBanUser(user.id, !user.banned);
notify.success(r.message);
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
if (!ready) return null;
return (
<div className="admin-page">
<div className="admin-page-head">
<h1></h1>
<p></p>
</div>
<div className="admin-card">
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
<>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.username}</td>
<td>{u.nickname}</td>
<td>
{u.role === 'admin'
? <Badge variant="orange"></Badge>
: <Badge variant="secondary"></Badge>}
</td>
<td>{u.banned ? <Badge variant="destructive"></Badge> : '正常'}</td>
<td>{u.created_at ? new Date(u.created_at).toLocaleString('zh-CN') : '—'}</td>
<td>
{u.role !== 'admin' && (
<Button size="sm" variant="outline" onClick={() => toggleBan(u)}>
{u.banned ? '解除禁言' : '禁言'}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
{users.length === 0 && <div className="admin-empty"></div>}
{totalPages > 1 && (
<div className="admin-pagination">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}></Button>
<span> {page} / {totalPages} </span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}></Button>
</div>
)}
</>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
/** 服务端管理后台地址(非 SPA 路由) */ /** 管理后台入口React SPA 路由) */
export const ADMIN_DASHBOARD_URL = '/admin/dashboard'; export const ADMIN_DASHBOARD_URL = '/admin/dashboard';
/** 跳转到系统管理后台 */
export function openAdminDashboard() { export function openAdminDashboard() {
window.location.href = ADMIN_DASHBOARD_URL; window.location.href = ADMIN_DASHBOARD_URL;
} }

View File

@@ -0,0 +1,66 @@
import type { Area } from 'react-easy-crop';
export const AVATAR_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp';
export const AVATAR_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
/** 头像输出尺寸 */
export const AVATAR_OUTPUT_SIZE = 512;
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('图片加载失败'));
img.src = src;
});
}
/** 校验头像文件,返回错误信息或 null */
export function validateAvatarFile(file: File, maxMb: number): string | null {
if (!AVATAR_MIME_TYPES.includes(file.type)) {
return '仅支持 JPG、PNG、GIF、WebP 格式';
}
if (file.size > maxMb * 1024 * 1024) {
return `头像不能超过 ${maxMb}MB`;
}
return null;
}
/** 将裁剪区域渲染为 JPEG 文件 */
export async function getCroppedAvatarFile(
imageSrc: string,
pixelCrop: Area,
originalName = 'avatar.jpg',
): Promise<File> {
const image = await loadImage(imageSrc);
const canvas = document.createElement('canvas');
const size = AVATAR_OUTPUT_SIZE;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('裁剪失败');
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
size,
size,
);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
b => (b ? resolve(b) : reject(new Error('裁剪失败'))),
'image/jpeg',
0.92,
);
});
const baseName = originalName.replace(/\.[^.]+$/, '') || 'avatar';
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' });
}

View File

@@ -0,0 +1,71 @@
import {
Code2, Coffee, HelpCircle, MessageSquare, Lightbulb,
BookOpen, Gamepad2, Palette, Music, Camera, Heart, Zap,
Globe, Users, Briefcase, GraduationCap, ShoppingBag, MapPin,
Megaphone, Flame, Star, Folder, Wrench, Cpu, type LucideIcon,
} from 'lucide-react';
import type { Board } from '../api/types';
/** 板块主题色槽位数(与 global.css 中 board-badge--N 对应) */
export const BOARD_PALETTE_SIZE = 8;
/** 可选板块图标key 须与后端 AllowedBoardIcons 一致) */
export const BOARD_ICON_OPTIONS: { key: string; label: string; Icon: LucideIcon }[] = [
{ key: 'code-2', label: '代码', Icon: Code2 },
{ key: 'coffee', label: '咖啡', Icon: Coffee },
{ key: 'help-circle', label: '问答', Icon: HelpCircle },
{ key: 'message-square', label: '讨论', Icon: MessageSquare },
{ key: 'lightbulb', label: '灵感', Icon: Lightbulb },
{ key: 'book-open', label: '阅读', Icon: BookOpen },
{ key: 'gamepad-2', label: '游戏', Icon: Gamepad2 },
{ key: 'palette', label: '设计', Icon: Palette },
{ key: 'music', label: '音乐', Icon: Music },
{ key: 'camera', label: '摄影', Icon: Camera },
{ key: 'heart', label: '生活', Icon: Heart },
{ key: 'zap', label: '快讯', Icon: Zap },
{ key: 'globe', label: '综合', Icon: Globe },
{ key: 'users', label: '社区', Icon: Users },
{ key: 'briefcase', label: '职场', Icon: Briefcase },
{ key: 'graduation-cap', label: '学习', Icon: GraduationCap },
{ key: 'shopping-bag', label: '交易', Icon: ShoppingBag },
{ key: 'map-pin', label: '本地', Icon: MapPin },
{ key: 'megaphone', label: '公告', Icon: Megaphone },
{ key: 'flame', label: '热门', Icon: Flame },
{ key: 'star', label: '精华', Icon: Star },
{ key: 'folder', label: '资源', Icon: Folder },
{ key: 'wrench', label: '工具', Icon: Wrench },
{ key: 'cpu', label: '硬件', Icon: Cpu },
];
const ICON_MAP = Object.fromEntries(
BOARD_ICON_OPTIONS.map(o => [o.key, o.Icon]),
) as Record<string, LucideIcon>;
const DEFAULT_ICONS: LucideIcon[] = [
Code2, Coffee, HelpCircle, MessageSquare,
Lightbulb, BookOpen, Gamepad2, Palette,
];
export type BoardVisual = Pick<Board, 'id' | 'icon' | 'color_index'>;
/** 按板块 id / color_index 取稳定色槽索引 */
export function getBoardThemeIndex(board: BoardVisual | number): number {
if (typeof board === 'object' && board.color_index != null && board.color_index >= 0) {
return board.color_index % BOARD_PALETTE_SIZE;
}
const id = typeof board === 'number' ? board : board.id;
return ((id % BOARD_PALETTE_SIZE) + BOARD_PALETTE_SIZE) % BOARD_PALETTE_SIZE;
}
/** 解析板块图标:优先使用后台配置,否则按 id 回退 */
export function getBoardIcon(board: BoardVisual | number): LucideIcon {
if (typeof board === 'object' && board.icon && ICON_MAP[board.icon]) {
return ICON_MAP[board.icon];
}
return DEFAULT_ICONS[getBoardThemeIndex(board)];
}
export function getBoardIconOption(key: string | undefined) {
if (!key) return undefined;
return BOARD_ICON_OPTIONS.find(o => o.key === key);
}

View File

@@ -1,20 +1,62 @@
/** @用户名 高亮(仅用于评论正文中用户主动输入的 @ */ /** 转义 HTML 并保留换行 */
export function highlightMentions(text: string, _onClick?: (name: string) => void): string { function escapeWithBreaks(text: string): string {
return text return text
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
// innerHTML 解析会吞掉换行,需转为 <br> .replace(/\n/g, '<br>');
.replace(/\n/g, '<br>') }
/** @用户名 高亮(仅用于评论正文中用户主动输入的 @ */
export function highlightMentions(text: string, _onClick?: (name: string) => void): string {
return escapeWithBreaks(text)
.replace(/@([\w\u4e00-\u9fa5_-]+)/g, '<span class="mention">@$1</span>'); .replace(/@([\w\u4e00-\u9fa5_-]+)/g, '<span class="mention">@$1</span>');
} }
export function formatTime(iso: string) { export function formatTime(iso: string) {
const d = new Date(iso); const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const now = new Date(); const now = new Date();
const diff = (now.getTime() - d.getTime()) / 1000; const diff = (now.getTime() - d.getTime()) / 1000;
if (diff < 60) return '刚刚'; if (diff < 60) return '刚刚';
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`; if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`; if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
return `${d.getMonth() + 1}-${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const pad = (n: number) => String(n).padStart(2, '0');
const clock = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (
d.getFullYear() === yesterday.getFullYear()
&& d.getMonth() === yesterday.getMonth()
&& d.getDate() === yesterday.getDate()
) {
return `昨天 ${clock}`;
}
if (d.getFullYear() === now.getFullYear()) {
return `${d.getMonth() + 1}${d.getDate()}${clock}`;
}
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}${clock}`;
}
/** 完整日期时间(用于帖子发布/修改时间展示) */
export function formatDateTime(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** 判断两个 ISO 时间是否相差超过 1 分钟 */
export function isTimeDiffSignificant(a: string, b: string) {
const da = new Date(a).getTime();
const db = new Date(b).getTime();
if (Number.isNaN(da) || Number.isNaN(db)) return false;
return Math.abs(da - db) > 60_000;
} }

View File

@@ -0,0 +1,123 @@
import type { NavigateFunction } from 'react-router-dom';
import type { PostItem } from '../api/types';
import type { FeedSort } from '../components/FeedSortBar';
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
export type FeedNavState = { refreshFeed?: boolean };
export type FeedCache = {
posts: PostItem[];
postTotal: number;
page: number;
hasMore: boolean;
scrollTop: number;
};
const PREFIX = 'j13-feed-cache:';
function cacheKey(boardId: number, keyword: string, sort: FeedSort) {
return `${PREFIX}${boardId}:${keyword}:${sort}`;
}
/** 读取帖子列表缓存,用于从详情页返回时恢复浏览位置 */
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort): FeedCache | null {
try {
const raw = sessionStorage.getItem(cacheKey(boardId, keyword, sort));
return raw ? (JSON.parse(raw) as FeedCache) : null;
} catch {
return null;
}
}
/** 保存帖子列表缓存 */
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache) {
try {
sessionStorage.setItem(cacheKey(boardId, keyword, sort), JSON.stringify(data));
} catch {
// sessionStorage 不可用时忽略
}
}
/** 清除指定筛选条件下的列表缓存 */
export function clearFeedCache(boardId: number, keyword: string, sort: FeedSort) {
try {
sessionStorage.removeItem(cacheKey(boardId, keyword, sort));
} catch {
// ignore
}
}
/** 清除所有帖子列表缓存(置顶等操作后列表需全量刷新) */
export function clearAllFeedCache() {
try {
for (let i = sessionStorage.length - 1; i >= 0; i--) {
const key = sessionStorage.key(i);
if (key?.startsWith(PREFIX)) sessionStorage.removeItem(key);
}
} catch {
// ignore
}
}
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
export const FEED_RESET_EVENT = 'feed-reset';
/** 清除缓存并导航到帖子列表(重复点击同一入口时也会刷新) */
export function navigateFeed(nav: NavigateFunction, url: string) {
clearAllFeedCache();
window.dispatchEvent(new Event(FEED_RESET_EVENT));
nav(url, { state: { refreshFeed: true } satisfies FeedNavState });
}

View File

@@ -1,130 +0,0 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
marked.setOptions({ breaks: true, gfm: true });
const MEMBERS_BLOCK_RE = /:::members[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*:::/g;
type MdPart = { type: 'text' | 'members'; content: string };
/** 拆分普通 Markdown 与 :::members 区块 */
function splitMembersBlocks(md: string): MdPart[] {
const parts: MdPart[] = [];
let lastIndex = 0;
const re = new RegExp(MEMBERS_BLOCK_RE.source, 'g');
let match: RegExpExecArray | null;
while ((match = re.exec(md)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', content: md.slice(lastIndex, match.index) });
}
parts.push({ type: 'members', content: match[1] });
lastIndex = re.lastIndex;
}
if (lastIndex < md.length) {
parts.push({ type: 'text', content: md.slice(lastIndex) });
}
if (parts.length === 0) {
parts.push({ type: 'text', content: md });
}
return parts;
}
/** Markdown 转 HTML用于预览与发布 */
export function markdownToHtml(md: string): string {
const parts = splitMembersBlocks(md);
const html = parts.map(part => {
if (part.type === 'members') {
const inner = marked.parse(part.content.trim()) as string;
return `<members-only>${inner}</members-only>`;
}
return marked.parse(part.content) as string;
}).join('');
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
}
/** HTML 转 Markdown用于编辑已有帖子时回填编辑器 */
export function htmlToMarkdown(html: string): string {
if (!html.trim()) return '';
const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
const walk = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? '';
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
const inner = () => Array.from(el.childNodes).map(walk).join('');
switch (tag) {
case 'members-only': {
if (el.getAttribute('data-locked') === 'true') return '';
const body = el.querySelector('.post-members-only__body');
const content = body ? Array.from(body.childNodes).map(walk).join('') : inner();
return `:::members\n${content.trim()}\n:::\n\n`;
}
case 'br': return '\n';
case 'p': return inner().trimEnd() + '\n\n';
case 'h1': return `# ${inner().trim()}\n\n`;
case 'h2': return `## ${inner().trim()}\n\n`;
case 'h3': return `### ${inner().trim()}\n\n`;
case 'h4': return `#### ${inner().trim()}\n\n`;
case 'h5': return `##### ${inner().trim()}\n\n`;
case 'h6': return `###### ${inner().trim()}\n\n`;
case 'strong':
case 'b': return `**${inner()}**`;
case 'em':
case 'i': return `*${inner()}*`;
case 'code': return `\`${inner()}\``;
case 'pre': return `\`\`\`\n${el.textContent ?? ''}\n\`\`\`\n\n`;
case 'a': return `[${inner()}](${el.getAttribute('href') ?? ''})`;
case 'img': return `![${el.getAttribute('alt') ?? ''}](${el.getAttribute('src') ?? ''})`;
case 'ul':
return Array.from(el.children)
.map(li => `- ${walk(li).trim()}`)
.join('\n') + '\n\n';
case 'ol':
return Array.from(el.children)
.map((li, i) => `${i + 1}. ${walk(li).trim()}`)
.join('\n') + '\n\n';
case 'li': return inner();
case 'blockquote': {
const text = inner().trim().replace(/\n/g, '\n> ');
return `> ${text}\n\n`;
}
case 'hr': return '---\n\n';
case 'div':
if (el.classList.contains('post-members-only__badge')
|| el.classList.contains('post-members-only__gate')
|| el.classList.contains('post-members-only__gate-icon')) {
return '';
}
if (el.classList.contains('post-members-only__body')) {
return inner();
}
return inner();
default: return inner();
}
};
return walk(doc.body).replace(/\n{3,}/g, '\n\n').trim();
}
/** 统计正文字数(不含空白) */
export function countWords(text: string): number {
const t = text.trim();
if (!t) return 0;
const cjk = t.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
const en = t.match(/[a-zA-Z0-9]+/g)?.length ?? 0;
return cjk + en;
}
/** 编辑器插入用的会员专属区块模板 */
export const MEMBERS_ONLY_TEMPLATE = ':::members\n在此输入仅登录用户可见的内容…\n:::';

View File

@@ -0,0 +1,229 @@
import TurndownService from 'turndown';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
const MEMBERS_ONLY_BLOCK_RE = /<members-only>([\s\S]*?)<\/members-only>/gi;
const TURNDOWN_OPTIONS = {
headingStyle: 'atx' as const,
codeBlockStyle: 'fenced' as const,
emDelimiter: '*',
bulletListMarker: '-',
};
/** 仅去除区块首尾空行,保留各行行首缩进 */
function trimBlockBoundaryLines(content: string): string {
return content.replace(/^\n+/, '').replace(/\n+$/, '');
}
/** 不换行空格还原为普通空格,便于 Markdown 源码编辑 */
function nbspToSpaces(text: string): string {
return text.replace(/\u00A0/g, ' ');
}
/** 将含 <br> 的段落拆成多个 <p>,避免聚合转换时丢失首行缩进 */
function splitParagraphBreaks(html: string): string {
if (!/<br\s*\/?>/i.test(html)) return html;
const doc = new DOMParser().parseFromString(`<div data-wrap="1">${html}</div>`, 'text/html');
const container = doc.querySelector('[data-wrap]');
if (!container) return html;
[...container.querySelectorAll('p')].forEach(p => {
const inner = p.innerHTML;
if (!/<br\s*\/?>/i.test(inner)) return;
const parts = inner.split(/<br\s*\/?>/i);
const fragment = doc.createDocumentFragment();
parts.forEach((part, index) => {
if (part === '' && index === parts.length - 1) return;
const newP = doc.createElement('p');
newP.innerHTML = part;
fragment.appendChild(newP);
});
p.replaceWith(fragment);
});
return container.innerHTML;
}
/** 为 Turndown 注册通用正文规则(不含 members-only */
function addTurndownContentRules(service: TurndownService): void {
service.addRule('image', {
filter: 'img',
replacement: (_content, node) => {
const el = node as HTMLImageElement;
const alt = el.getAttribute('alt') ?? '';
const src = el.getAttribute('src') ?? '';
return src ? `![${alt}](${src})` : '';
},
});
service.addRule('underline', {
filter: ['u'],
replacement: (content) => `<u>${content}</u>`,
});
service.addRule('strikethrough', {
filter: ['del', 's', 'strike'],
replacement: (content) => `~~${content}~~`,
});
service.addRule('horizontalRule', {
filter: 'hr',
replacement: () => '\n\n---\n\n',
});
service.addRule('anchor', {
filter: (node) => {
if (node.nodeName !== 'A') return false;
const href = (node as HTMLAnchorElement).getAttribute('href');
return Boolean(href);
},
replacement: (content, node) => {
const el = node as HTMLAnchorElement;
const href = el.getAttribute('href') ?? '';
const title = el.getAttribute('title');
return title ? `[${content}](${href} "${title}")` : `[${content}](${href})`;
},
});
}
/** 子节点转 Markdown 专用,避免 members-only 规则递归 */
const contentTurndown = new TurndownService(TURNDOWN_OPTIONS);
addTurndownContentRules(contentTurndown);
const turndown = new TurndownService(TURNDOWN_OPTIONS);
addTurndownContentRules(turndown);
/** 登录可见区块转为 Markdown逐子节点转换保留首行缩进 */
turndown.addRule('membersOnly', {
filter: 'members-only',
replacement: (_content, node) => {
const el = node as HTMLElement;
const parts: string[] = [];
el.childNodes.forEach(child => {
if (child.nodeType === Node.TEXT_NODE) {
const text = (child.textContent ?? '').trim();
if (text) parts.push(nbspToSpaces(text));
return;
}
if (child instanceof HTMLElement) {
parts.push(nbspToSpaces(contentTurndown.turndown(child.outerHTML).trim()));
}
});
const body = trimBlockBoundaryLines(parts.join('\n\n'));
return `\n\n<members-only>\n\n${body}\n\n</members-only>\n\n`;
},
});
marked.setOptions({
gfm: true,
breaks: true,
});
/** 禁用缩进代码块Tab/空格缩进仅作正文排版;围栏 ``` 代码块不受影响 */
marked.use({
tokenizer: {
code() {
return undefined;
},
},
});
/** 净化并保留 members-only 标签 */
function sanitizeContentHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
}
/** 将行首空格转为不换行空格,避免 HTML 折叠缩进;跳过围栏代码块 */
function preserveLeadingIndent(markdown: string): string {
return markdown.split(/(```[\s\S]*?```)/g).map((part, index) => {
if (index % 2 === 1) return part;
return part.replace(/^( +)(?=\S)/gm, (_match, spaces: string) => '\u00A0'.repeat(spaces.length));
}).join('');
}
/** 将普通 Markdown 片段转为 HTML */
function parseMarkdownFragment(markdown: string): string {
if (!markdown.trim()) return '';
return marked.parse(preserveLeadingIndent(markdown), { async: false }) as string;
}
/** 转换前清理编辑态装饰结构,避免污染 Markdown */
function prepareHtmlForMarkdown(html: string): string {
const doc = new DOMParser().parseFromString(sanitizeContentHtml(html), 'text/html');
doc.querySelectorAll('.post-members-only__badge, .post-members-only__exit-btn, .post-members-only__unwrap-btn').forEach(el => {
el.remove();
});
doc.querySelectorAll('members-only').forEach(el => {
const body = el.querySelector('.post-members-only__body');
const raw = body ? body.innerHTML : el.innerHTML;
el.innerHTML = splitParagraphBreaks(raw);
});
return doc.body.innerHTML;
}
/** 规范化 members-only 标签边界,避免闭合标签与正文粘连 */
function normalizeMembersOnlyMarkdown(markdown: string): string {
return markdown
.replace(/<\/members-only>(?=[^\s\n])/g, '</members-only>\n\n')
.replace(/<members-only>\s*<\/members-only>/g, '<members-only>\n\n</members-only>');
}
/** 列表标记后统一为单个空格Turndown 默认会输出两个及以上空格) */
function normalizeListMarkerSpacing(markdown: string): string {
return markdown
.replace(/^(\s*[-+*])\s+(?=\S)/gm, '$1 ')
.replace(/^(\s*\d+\.)\s+(?=\S)/gm, '$1 ');
}
/** 编辑器 HTML 转为 Markdown 源码 */
export function htmlToMarkdown(html: string): string {
if (!html.trim()) return '';
const prepared = prepareHtmlForMarkdown(html);
const raw = turndown.turndown(prepared).replace(/\n{3,}/g, '\n\n').trim();
return normalizeListMarkerSpacing(raw);
}
/**
* Markdown 源码转为编辑器 HTML。
* 先提取 members-only 块再分别解析,避免闭合标签后同行文字被吞入区块。
*/
export function markdownToHtml(markdown: string): string {
if (!markdown.trim()) return '';
const normalized = normalizeMembersOnlyMarkdown(markdown);
const re = new RegExp(MEMBERS_ONLY_BLOCK_RE.source, 'gi');
let result = '';
let lastIndex = 0;
let match: RegExpExecArray | null = re.exec(normalized);
while (match) {
const before = normalized.slice(lastIndex, match.index);
if (before.trim()) {
result += parseMarkdownFragment(before);
}
const innerMd = trimBlockBoundaryLines(match[1]);
const innerHtml = innerMd.trim()
? splitParagraphBreaks(parseMarkdownFragment(innerMd))
: '';
result += `<members-only>${innerHtml}</members-only>`;
lastIndex = re.lastIndex;
match = re.exec(normalized);
}
const tail = normalized.slice(lastIndex);
if (tail.trim()) {
result += parseMarkdownFragment(tail);
}
return sanitizeContentHtml(result);
}

View File

@@ -0,0 +1,109 @@
import { applyTextareaChange } from './markdownIndent';
type ChangeHandler = (value: string) => void;
/** 在选区两侧包裹 Markdown 标记 */
export function wrapMarkdownSelection(
textarea: HTMLTextAreaElement,
value: string,
prefix: string,
suffix: string,
placeholder: string,
onChange: ChangeHandler,
) {
const { selectionStart, selectionEnd } = textarea;
const selected = value.slice(selectionStart, selectionEnd);
const text = selected || placeholder;
const insert = prefix + text + suffix;
const next = value.slice(0, selectionStart) + insert + value.slice(selectionEnd);
const newStart = selectionStart + prefix.length;
const newEnd = newStart + text.length;
applyTextareaChange(textarea, next, newStart, newEnd, onChange);
}
/** 为当前行或选区行添加前缀(如引用) */
export function prefixMarkdownLines(
textarea: HTMLTextAreaElement,
value: string,
prefix: string,
onChange: ChangeHandler,
) {
const { selectionStart, selectionEnd } = textarea;
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
const nextNewline = value.indexOf('\n', selectionEnd);
const lineEnd = nextNewline === -1 ? value.length : nextNewline;
const block = value.slice(lineStart, lineEnd);
const lines = block.split('\n').map(line => `${prefix}${line}`);
const next = value.slice(0, lineStart) + lines.join('\n') + value.slice(lineEnd);
applyTextareaChange(
textarea,
next,
selectionStart + prefix.length,
selectionEnd + prefix.length * lines.length,
onChange,
);
}
/** 切换当前行标题级别(源码模式) */
export function cycleMarkdownHeading(
textarea: HTMLTextAreaElement,
value: string,
onChange: ChangeHandler,
) {
const { selectionStart } = textarea;
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
const lineEnd = value.indexOf('\n', selectionStart);
const end = lineEnd === -1 ? value.length : lineEnd;
const line = value.slice(lineStart, end);
const match = line.match(/^(#{2,6})\s+(.*)$/);
let nextLine: string;
if (!match) {
nextLine = `## ${line}`;
} else {
const level = match[1].length;
const body = match[2];
nextLine = level >= 6 ? body : `${'#'.repeat(level + 1)} ${body}`;
}
const next = value.slice(0, lineStart) + nextLine + value.slice(end);
const offset = nextLine.length - line.length;
applyTextareaChange(
textarea,
next,
selectionStart + Math.max(0, offset),
selectionStart + Math.max(0, offset),
onChange,
);
}
/** 插入登录可见区块模板 */
export function insertMarkdownMembersOnly(
textarea: HTMLTextAreaElement,
value: string,
onChange: ChangeHandler,
) {
const { selectionStart, selectionEnd } = textarea;
const snippet = '\n\n<members-only>\n\n\n</members-only>\n\n';
const next = value.slice(0, selectionStart) + snippet + value.slice(selectionEnd);
const cursor = selectionStart + '\n\n<members-only>\n\n'.length;
applyTextareaChange(textarea, next, cursor, cursor, onChange);
}
/** 在光标处插入链接 Markdown */
export function insertMarkdownLink(
textarea: HTMLTextAreaElement,
value: string,
url: string,
onChange: ChangeHandler,
) {
const { selectionStart, selectionEnd } = textarea;
const selected = value.slice(selectionStart, selectionEnd) || '链接文字';
const insert = `[${selected}](${url})`;
const next = value.slice(0, selectionStart) + insert + value.slice(selectionEnd);
applyTextareaChange(
textarea,
next,
selectionStart + insert.length,
selectionStart + insert.length,
onChange,
);
}

View File

@@ -0,0 +1,149 @@
export const TAB_SPACES = ' ';
interface LineRange {
lineStart: number;
lineEnd: number;
}
/** 获取选区覆盖的整行文本范围 */
function getLineRange(value: string, start: number, end: number): LineRange {
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const nextNewline = value.indexOf('\n', end);
const lineEnd = nextNewline === -1 ? value.length : nextNewline;
return { lineStart, lineEnd };
}
/** 多行整体增加一级缩进 */
function indentBlock(value: string, lineStart: number, lineEnd: number): string {
const block = value.slice(lineStart, lineEnd);
const indented = block.split('\n').map(line => TAB_SPACES + line).join('\n');
return value.slice(0, lineStart) + indented + value.slice(lineEnd);
}
/** 多行整体减少一级缩进,返回新文本及选区偏移 */
function outdentBlock(
value: string,
lineStart: number,
lineEnd: number,
selectionStart: number,
selectionEnd: number,
): { next: string; newStart: number; newEnd: number } {
const block = value.slice(lineStart, lineEnd);
const lines = block.split('\n');
let cursor = lineStart;
let newStart = selectionStart;
let newEnd = selectionEnd;
const outdented = lines.map(line => {
const match = line.match(/^ {1,4}/);
const removed = match ? match[0].length : 0;
if (removed > 0) {
if (selectionStart > cursor) {
newStart -= Math.min(removed, selectionStart - cursor);
}
if (selectionEnd > cursor) {
newEnd -= Math.min(removed, selectionEnd - cursor);
}
}
cursor += line.length + 1;
return line.replace(/^ {1,4}/, '');
}).join('\n');
return {
next: value.slice(0, lineStart) + outdented + value.slice(lineEnd),
newStart: Math.max(lineStart, newStart),
newEnd: Math.max(lineStart, newEnd),
};
}
/** 在光标处插入文本并恢复选区 */
export function applyTextareaChange(
textarea: HTMLTextAreaElement,
nextValue: string,
cursorStart: number,
cursorEnd = cursorStart,
onChange: (value: string) => void,
) {
onChange(nextValue);
requestAnimationFrame(() => {
textarea.selectionStart = cursorStart;
textarea.selectionEnd = cursorEnd;
textarea.focus();
});
}
/** 在 textarea 光标处插入文本 */
export function insertAtCursor(
textarea: HTMLTextAreaElement,
currentValue: string,
insertText: string,
onChange: (value: string) => void,
) {
const { selectionStart, selectionEnd } = textarea;
const next = currentValue.slice(0, selectionStart) + insertText + currentValue.slice(selectionEnd);
const cursor = selectionStart + insertText.length;
applyTextareaChange(textarea, next, cursor, cursor, onChange);
}
/** Markdown 源码 Tab / Shift+Tab 缩进 */
export function handleMarkdownTabKey(
e: React.KeyboardEvent<HTMLTextAreaElement>,
onChange: (value: string) => void,
) {
if (e.key !== 'Tab') return;
e.preventDefault();
const textarea = e.currentTarget;
const { selectionStart, selectionEnd, value } = textarea;
const { lineStart, lineEnd } = getLineRange(value, selectionStart, selectionEnd);
const hasSelection = selectionStart !== selectionEnd;
const block = value.slice(lineStart, lineEnd);
const multiLine = hasSelection && block.split('\n').length > 1;
if (e.shiftKey) {
if (multiLine) {
const { next, newStart, newEnd } = outdentBlock(value, lineStart, lineEnd, selectionStart, selectionEnd);
applyTextareaChange(textarea, next, newStart, newEnd, onChange);
return;
}
const lineText = value.slice(lineStart, selectionStart);
const trailingMatch = lineText.match(/ {1,4}$/);
if (trailingMatch) {
const removeLen = trailingMatch[0].length;
const next = value.slice(0, selectionStart - removeLen) + value.slice(selectionStart);
applyTextareaChange(textarea, next, selectionStart - removeLen, selectionEnd - removeLen, onChange);
return;
}
const leadingMatch = value.slice(lineStart, selectionStart).match(/^ {1,4}/);
if (leadingMatch) {
const removeLen = leadingMatch[0].length;
const next = value.slice(0, lineStart) + value.slice(lineStart + removeLen);
applyTextareaChange(
textarea,
next,
selectionStart - removeLen,
selectionEnd - removeLen,
onChange,
);
}
return;
}
if (multiLine) {
const next = indentBlock(value, lineStart, lineEnd);
const lineCount = value.slice(lineStart, lineEnd).split('\n').length;
applyTextareaChange(
textarea,
next,
selectionStart + TAB_SPACES.length,
selectionEnd + TAB_SPACES.length * lineCount,
onChange,
);
return;
}
const next = value.slice(0, selectionStart) + TAB_SPACES + value.slice(selectionEnd);
applyTextareaChange(textarea, next, selectionStart + TAB_SPACES.length, selectionStart + TAB_SPACES.length, onChange);
}

View File

@@ -51,6 +51,16 @@ function buildLockedGateHtml(charLength: number): string {
</div>`; </div>`;
} }
/** 判断 HTML 正文是否为空(忽略空段落等) */
export function isHtmlEmpty(html: string): boolean {
if (!html.trim()) return true;
const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
return (doc.body.textContent ?? '').trim().length === 0;
}
/** 根据登录状态渲染帖子正文 HTML */ /** 根据登录状态渲染帖子正文 HTML */
export function renderPostContentHtml(html: string, isLoggedIn: boolean): string { export function renderPostContentHtml(html: string, isLoggedIn: boolean): string {
if (!html.trim()) return ''; if (!html.trim()) return '';

View File

@@ -0,0 +1,77 @@
import { diffLines, diffWords } from 'diff';
import DOMPurify from 'dompurify';
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
export interface PostSnapshot {
title: string;
content: string;
tags: string;
}
export interface RevisionChangeSummary {
titleChanged: boolean;
tagsChanged: boolean;
contentChanged: boolean;
hasChanges: boolean;
}
export interface DiffPart {
value: string;
added?: boolean;
removed?: boolean;
}
/** 将 HTML 正文转为适合 diff 的纯文本(保留段落换行) */
export function htmlToDiffText(html: string): string {
if (!html.trim()) return '';
const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
doc.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
const blocks = doc.querySelectorAll('p, div, li, h1, h2, h3, h4, h5, h6, blockquote, pre, members-only');
blocks.forEach(el => {
el.prepend(doc.createTextNode('\n'));
el.append(doc.createTextNode('\n'));
});
return (doc.body.textContent ?? '')
.replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
export function summarizeChange(before: PostSnapshot, after: PostSnapshot): RevisionChangeSummary {
const titleChanged = before.title.trim() !== after.title.trim();
const tagsChanged = normalizeTags(before.tags) !== normalizeTags(after.tags);
const contentChanged = htmlToDiffText(before.content) !== htmlToDiffText(after.content);
return {
titleChanged,
tagsChanged,
contentChanged,
hasChanges: titleChanged || tagsChanged || contentChanged,
};
}
function normalizeTags(tags: string) {
return tags.split(/[,]/).map(t => t.trim()).filter(Boolean).join(',');
}
export function diffTextLines(before: string, after: string): DiffPart[] {
return diffLines(before || '', after || '');
}
export function diffTextWords(before: string, after: string): DiffPart[] {
return diffWords(before || '', after || '');
}
/** 统计 diff 片段中的增删行数 */
export function countLineChanges(parts: DiffPart[]) {
let added = 0;
let removed = 0;
for (const p of parts) {
const lines = p.value.split('\n').filter(l => l.length > 0);
if (p.added) added += lines.length;
else if (p.removed) removed += lines.length;
}
return { added, removed };
}

View File

@@ -0,0 +1,8 @@
/** 统计正文字数CJK 按字、英文按词) */
export function countWords(text: string): number {
const t = text.trim();
if (!t) return 0;
const cjk = t.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
const en = t.match(/[a-zA-Z0-9]+/g)?.length ?? 0;
return cjk + en;
}

View File

@@ -21,7 +21,7 @@ export default defineConfig({
output: { output: {
manualChunks(id) { manualChunks(id) {
if (!id.includes('node_modules')) return; if (!id.includes('node_modules')) return;
if (id.includes('marked') || id.includes('dompurify')) return 'markdown-vendor'; if (id.includes('dompurify')) return 'purify-vendor';
if (id.includes('@tanstack/react-virtual')) return 'virtual-vendor'; if (id.includes('@tanstack/react-virtual')) return 'virtual-vendor';
if (id.includes('@radix-ui') || id.includes('lucide-react')) return 'ui-vendor'; if (id.includes('@radix-ui') || id.includes('lucide-react')) return 'ui-vendor';
if ( if (

2
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/jiang13/forum module git.iioio.com/freefire/jiang13-forum
go 1.26 go 1.26

View File

@@ -7,8 +7,8 @@ import (
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jiang13/forum/model" "git.iioio.com/freefire/jiang13-forum/model"
"github.com/jiang13/forum/service" "git.iioio.com/freefire/jiang13-forum/service"
) )
// --- 后台管理页面 --- // --- 后台管理页面 ---
@@ -114,7 +114,8 @@ func calcTotalPages(total int64, size int) int {
func (h *Handlers) AdminAPICreateBoard(c *gin.Context) { func (h *Handlers) AdminAPICreateBoard(c *gin.Context) {
sortOrder, _ := strconv.Atoi(c.PostForm("sort_order")) sortOrder, _ := strconv.Atoi(c.PostForm("sort_order"))
board, err := h.Board.Create(c.PostForm("name"), c.PostForm("description"), sortOrder) colorIndex, _ := strconv.Atoi(c.PostForm("color_index"))
board, err := h.Board.Create(c.PostForm("name"), c.PostForm("description"), c.PostForm("icon"), colorIndex, sortOrder)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -125,7 +126,8 @@ func (h *Handlers) AdminAPICreateBoard(c *gin.Context) {
func (h *Handlers) AdminAPIUpdateBoard(c *gin.Context) { func (h *Handlers) AdminAPIUpdateBoard(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64) id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
sortOrder, _ := strconv.Atoi(c.PostForm("sort_order")) sortOrder, _ := strconv.Atoi(c.PostForm("sort_order"))
if err := h.Board.Update(uint(id), c.PostForm("name"), c.PostForm("description"), sortOrder); err != nil { colorIndex, _ := strconv.Atoi(c.PostForm("color_index"))
if err := h.Board.Update(uint(id), c.PostForm("name"), c.PostForm("description"), c.PostForm("icon"), colorIndex, sortOrder); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }

View File

@@ -1,12 +1,16 @@
package handler package handler
import ( import (
"errors"
"net/http" "net/http"
"path/filepath"
"strconv" "strconv"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jiang13/forum/model" "git.iioio.com/freefire/jiang13-forum/model"
"github.com/jiang13/forum/service" "git.iioio.com/freefire/jiang13-forum/service"
) )
// APIMe 当前登录用户 // APIMe 当前登录用户
@@ -50,18 +54,25 @@ func (h *Handlers) APIStats(c *gin.Context) {
}) })
} }
// APIForumLimits 前台可见的论坛限制配置
func (h *Handlers) APIForumLimits(c *gin.Context) {
c.JSON(http.StatusOK, h.Settings.PublicLimits())
}
// APIAdminCreateBoard 管理员创建板块JSON // APIAdminCreateBoard 管理员创建板块JSON
func (h *Handlers) APIAdminCreateBoard(c *gin.Context) { func (h *Handlers) APIAdminCreateBoard(c *gin.Context) {
var req struct { var req struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Description string `json:"description"` Description string `json:"description"`
Icon string `json:"icon"`
ColorIndex int `json:"color_index"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
} }
board, err := h.Board.Create(req.Name, req.Description, req.SortOrder) board, err := h.Board.Create(req.Name, req.Description, req.Icon, req.ColorIndex, req.SortOrder)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -75,13 +86,15 @@ func (h *Handlers) APIAdminUpdateBoard(c *gin.Context) {
var req struct { var req struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Description string `json:"description"` Description string `json:"description"`
Icon string `json:"icon"`
ColorIndex int `json:"color_index"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
} }
if err := h.Board.Update(uint(id), req.Name, req.Description, req.SortOrder); err != nil { if err := h.Board.Update(uint(id), req.Name, req.Description, req.Icon, req.ColorIndex, req.SortOrder); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -99,10 +112,257 @@ func (h *Handlers) APIAdminDeleteBoard(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "板块已删除"}) c.JSON(http.StatusOK, gin.H{"message": "板块已删除"})
} }
// APIAdminDashboard 管理后台概览
func (h *Handlers) APIAdminDashboard(c *gin.Context) {
var userCount, postCount, boardCount, commentCount int64
model.DB.Model(&model.User{}).Count(&userCount)
model.DB.Model(&model.Post{}).Count(&postCount)
model.DB.Model(&model.Board{}).Count(&boardCount)
model.DB.Model(&model.Comment{}).Count(&commentCount)
recentPosts, _, _ := h.Post.List(service.PostListQuery{Page: 1, Size: 8})
if recentPosts == nil {
recentPosts = []model.Post{}
}
c.JSON(http.StatusOK, gin.H{
"users": userCount, "posts": postCount, "boards": boardCount,
"comments": commentCount, "online": h.Online.Count(),
"recent_posts": recentPosts,
})
}
// APIAdminPosts 管理员帖子列表
func (h *Handlers) APIAdminPosts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
keyword := strings.TrimSpace(c.Query("keyword"))
posts, total, err := h.Post.ListItems(service.PostListQuery{Page: page, Size: size, Keyword: keyword})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if posts == nil {
posts = []service.PostListItem{}
}
c.JSON(http.StatusOK, gin.H{
"posts": posts, "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
})
}
// APIAdminLockPost 锁定/解锁帖子编辑
func (h *Handlers) APIAdminLockPost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Locked bool `json:"locked"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Post.SetEditLocked(uint(id), req.Locked); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已解锁编辑"
if req.Locked {
msg = "已锁定编辑"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "edit_locked": req.Locked})
}
// APIAdminPinPost 置顶/取消置顶JSON
func (h *Handlers) APIAdminPinPost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Pinned bool `json:"pinned"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Post.SetPinned(uint(id), req.Pinned); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已取消置顶"
if req.Pinned {
msg = "已置顶"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "pinned": req.Pinned})
}
// APIAdminDeletePost 管理员删除帖子
func (h *Handlers) APIAdminDeletePost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.Post.Delete(0, uint(id), true); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "帖子已删除"})
}
// APIAdminComments 管理员评论列表
func (h *Handlers) APIAdminComments(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
comments, total, err := h.Comment.ListRecent(page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if comments == nil {
comments = []model.Comment{}
}
c.JSON(http.StatusOK, gin.H{
"comments": comments, "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
})
}
// APIAdminDeleteComment 管理员删除评论
func (h *Handlers) APIAdminDeleteComment(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.Comment.AdminDelete(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "评论已删除"})
}
// APIAdminUsers 管理员用户列表
func (h *Handlers) APIAdminUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
users, total, err := h.User.ListUsers(page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if users == nil {
users = []model.User{}
}
c.JSON(http.StatusOK, gin.H{
"users": users, "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
})
}
// APIAdminBanUser 禁言/解除禁言JSON
func (h *Handlers) APIAdminBanUser(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Banned bool `json:"banned"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.User.BanUser(uint(id), req.Banned); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已解除禁言"
if req.Banned {
msg = "已禁言"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "banned": req.Banned})
}
// APIAdminBackup 导出 SQLite 备份
func (h *Handlers) APIAdminBackup(c *gin.Context) {
path, err := h.Backup.ExportSQLite()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filename := filepath.Base(path)
c.JSON(http.StatusOK, gin.H{
"message": "备份成功",
"filename": filename,
"download": "/api/admin/backup/download/" + filename,
})
}
// APIAdminDownloadBackup 下载备份文件
func (h *Handlers) APIAdminDownloadBackup(c *gin.Context) {
name := c.Param("name")
if !strings.HasPrefix(name, "jiang13_backup_") || !strings.HasSuffix(name, ".db") {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的备份文件名"})
return
}
path := filepath.Join(h.Cfg.DataDir, name)
c.FileAttachment(path, name)
}
// APIAdminSettings 系统设置信息
func (h *Handlers) APIAdminSettings(c *gin.Context) {
limits := h.Settings.Limits()
filterContent, _ := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
c.JSON(http.StatusOK, gin.H{
"filter_path": h.Cfg.FilterWordsPath(),
"data_dir": h.Cfg.DataDir,
"db_path": h.Cfg.DBPath(),
"port": h.Cfg.Port,
"limits": limits,
"filter_words": filterContent,
"filter_word_count": service.CountFilterWords(filterContent),
})
}
// APIAdminUpdateForumSettings 更新论坛设置
func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
var req service.ForumLimits
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateLimits(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "设置已保存",
"limits": h.Settings.Limits(),
})
}
// APIAdminFilterWords 读取敏感词配置
func (h *Handlers) APIAdminFilterWords(c *gin.Context) {
content, err := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取敏感词配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"content": content,
"word_count": service.CountFilterWords(content),
"path": h.Cfg.FilterWordsPath(),
})
}
// APIAdminUpdateFilterWords 更新敏感词配置
func (h *Handlers) APIAdminUpdateFilterWords(c *gin.Context) {
var req struct {
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := service.WriteFilterWordsFile(h.Cfg.FilterWordsPath(), req.Content, h.Filter); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存敏感词配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "敏感词已保存并生效",
"word_count": service.CountFilterWords(req.Content),
})
}
// APIPosts 帖子列表(分页) // APIPosts 帖子列表(分页)
func (h *Handlers) APIPosts(c *gin.Context) { func (h *Handlers) APIPosts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "30")) size, _ := strconv.Atoi(c.DefaultQuery("size", strconv.Itoa(h.Settings.PageSizeDefault())))
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64) boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
keyword := c.Query("keyword") keyword := c.Query("keyword")
@@ -111,9 +371,14 @@ func (h *Handlers) APIPosts(c *gin.Context) {
Page: page, Page: page,
Size: size, Size: size,
Keyword: keyword, Keyword: keyword,
Sort: c.DefaultQuery("sort", "latest"),
} }
items, total, err := h.Post.ListItems(q) items, total, err := h.Post.ListItems(q)
if err != nil { if err != nil {
if isClientLimitError(err) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -132,28 +397,42 @@ func (h *Handlers) APIPosts(c *gin.Context) {
// APIPostDetail 帖子详情 // APIPostDetail 帖子详情
func (h *Handlers) APIPostDetail(c *gin.Context) { func (h *Handlers) APIPostDetail(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64) id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.GetByID(uint(id)) post, err := h.Post.FindByID(uint(id))
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return return
} }
if c.Query("skip_view") != "1" {
h.Post.RecordView(uint(id))
}
uid := h.currentUserID(c) uid := h.currentUserID(c)
if uid == 0 { if uid == 0 {
post.Content = service.RedactMembersOnlyHTML(post.Content) post.Content = service.RedactMembersOnlyHTML(post.Content)
} }
comments, _ := h.Comment.ListByPost(uint(id), uid, h.isAdmin(c), post.UserID, h.parseGuestCommentIDs(c)) comments, _ := h.Comment.ListByPost(uint(id), uid, h.isAdmin(c), post.UserID, h.parseGuestCommentIDs(c))
isAdmin := h.isAdmin(c)
canEdit := h.Post.CanUserEdit(post, uid, isAdmin)
editReason := ""
if !canEdit && uid > 0 {
editReason = h.Post.UserEditBlockReason(post, uid, isAdmin)
}
isEdited := post.UpdatedAt.Sub(post.CreatedAt) > time.Minute
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"post": post, "post": post,
"comment_count": len(comments), "comment_count": len(comments),
"liked": h.Post.IsLiked(uid, uint(id)), "liked": h.Post.IsLiked(uid, uint(id)),
"favorited": h.Post.IsFavorited(uid, uint(id)), "favorited": h.Post.IsFavorited(uid, uint(id)),
"can_edit": canEdit,
"edit_block_reason": editReason,
"is_edited": isEdited,
"post_edit_window_hours": h.Settings.PostEditWindowHours(),
}) })
} }
// APIPostComments 楼层列表 // APIPostComments 楼层列表
func (h *Handlers) APIPostComments(c *gin.Context) { func (h *Handlers) APIPostComments(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64) id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.GetByID(uint(id)) post, err := h.Post.FindByID(uint(id))
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return return
@@ -232,6 +511,56 @@ func (h *Handlers) APIFavorites(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"favorites": favs, "total": total, "page": page}) c.JSON(http.StatusOK, gin.H{"favorites": favs, "total": total, "page": page})
} }
// APIPostRevisions 帖子编辑历史列表(作者或管理员)
func (h *Handlers) APIPostRevisions(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
uid := h.currentUserID(c)
isAdmin := h.isAdmin(c)
if !isAdmin && post.UserID != uid {
c.JSON(http.StatusForbidden, gin.H{"error": "无权查看编辑历史"})
return
}
revs, err := h.Post.ListRevisions(uint(id))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"revisions": revs})
}
// APIPostRevisionDetail 查看某个历史版本
func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
revID, _ := strconv.ParseUint(c.Param("revId"), 10, 64)
post, err := h.Post.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
uid := h.currentUserID(c)
isAdmin := h.isAdmin(c)
if !isAdmin && post.UserID != uid {
c.JSON(http.StatusForbidden, gin.H{"error": "无权查看编辑历史"})
return
}
rev, err := h.Post.GetRevision(uint(id), uint(revID))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"revision": rev})
}
func (h *Handlers) APIPing(c *gin.Context) { func (h *Handlers) APIPing(c *gin.Context) {
h.APIPresence(c) h.APIPresence(c)
} }
func isClientLimitError(err error) bool {
return errors.Is(err, service.ErrSearchKeywordTooShort) ||
errors.Is(err, service.ErrSearchKeywordTooLong)
}

View File

@@ -1,15 +1,16 @@
package handler package handler
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jiang13/forum/config" "git.iioio.com/freefire/jiang13-forum/config"
"github.com/jiang13/forum/middleware" "git.iioio.com/freefire/jiang13-forum/middleware"
"github.com/jiang13/forum/model" "git.iioio.com/freefire/jiang13-forum/model"
"github.com/jiang13/forum/service" "git.iioio.com/freefire/jiang13-forum/service"
) )
// Handlers 聚合所有 HTTP 处理器 // Handlers 聚合所有 HTTP 处理器
@@ -24,6 +25,7 @@ type Handlers struct {
Filter *service.SensitiveFilter Filter *service.SensitiveFilter
Limiter *service.RateLimiter Limiter *service.RateLimiter
Online *service.OnlineService Online *service.OnlineService
Settings *service.ForumSettingsService
} }
func (h *Handlers) setAuthCookie(c *gin.Context, token string) { func (h *Handlers) setAuthCookie(c *gin.Context, token string) {
@@ -135,7 +137,7 @@ func (h *Handlers) PostNewPage(c *gin.Context) {
func (h *Handlers) PostEditPage(c *gin.Context) { func (h *Handlers) PostEditPage(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64) id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.GetByID(uint(id)) post, err := h.Post.FindByID(uint(id))
if err != nil || (!h.isAdmin(c) && post.UserID != h.currentUserID(c)) { if err != nil || (!h.isAdmin(c) && post.UserID != h.currentUserID(c)) {
c.Redirect(http.StatusFound, "/") c.Redirect(http.StatusFound, "/")
return return
@@ -237,6 +239,11 @@ func (h *Handlers) APIUploadAvatar(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择头像文件"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请选择头像文件"})
return return
} }
maxBytes := int64(h.Settings.AvatarMaxMB()) * 1024 * 1024
if file.Size > maxBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "头像文件过大"})
return
}
url, err := h.User.UploadAvatar(h.currentUserID(c), file, h.Cfg.UploadDir()) url, err := h.User.UploadAvatar(h.currentUserID(c), file, h.Cfg.UploadDir())
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -245,6 +252,26 @@ func (h *Handlers) APIUploadAvatar(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "头像已更新", "avatar": url}) c.JSON(http.StatusOK, gin.H{"message": "头像已更新", "avatar": url})
} }
func (h *Handlers) APIUploadPostImage(c *gin.Context) {
file, err := c.FormFile("image")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
return
}
uid := h.currentUserID(c)
url, err := service.SaveUploadedImage(
file,
h.Cfg.PostImageUploadDir(),
"/uploads/posts",
fmt.Sprintf("%d", uid),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "图片已上传", "url": url})
}
func (h *Handlers) APICreatePost(c *gin.Context) { func (h *Handlers) APICreatePost(c *gin.Context) {
boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64) boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
title := c.PostForm("title") title := c.PostForm("title")

View File

@@ -6,8 +6,8 @@ import (
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jiang13/forum/model" "git.iioio.com/freefire/jiang13-forum/model"
"github.com/jiang13/forum/service" "git.iioio.com/freefire/jiang13-forum/service"
) )
const ( const (
@@ -142,11 +142,11 @@ func respondBanned(c *gin.Context) {
// RateLimitMiddleware 限流中间件 // RateLimitMiddleware 限流中间件
func RateLimitMiddleware(limiter *service.RateLimiter, action string) gin.HandlerFunc { func RateLimitMiddleware(limiter *service.RateLimiter, action string) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
key := c.ClientIP() + ":" + action key := c.ClientIP()
if uid, ok := c.Get(CtxUserID); ok { if uid, ok := c.Get(CtxUserID); ok {
key = fmt.Sprintf("%s:%d", action, uid.(uint)) key = fmt.Sprintf("%d", uid.(uint))
} }
if !limiter.Allow(key) { if !limiter.Allow(action, key) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "操作过于频繁,请稍后再试"}) c.JSON(http.StatusTooManyRequests, gin.H{"error": "操作过于频繁,请稍后再试"})
c.Abort() c.Abort()
return return

Some files were not shown because too many files have changed in this diff Show More