升级帖子编辑与 Feed 体验:TipTap 富文本、修订历史、排序与编辑时限。
将 Markdown 编辑器替换为 TipTap WYSIWYG,新增帖子修订记录与 diff 展示;首页支持最新/回复排序与本地缓存;后台可配置编辑时限与锁定帖子;侧边栏整合板块导航并优化 Feed 布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
664
frontend/package-lock.json
generated
664
frontend/package-lock.json
generated
@@ -16,10 +16,19 @@
|
||||
"@radix-ui/react-slot": "^1.2.5",
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@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",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
@@ -1921,6 +1930,461 @@
|
||||
"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": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1987,14 +2451,12 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -2005,7 +2467,6 @@
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
@@ -2018,6 +2479,12 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"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": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -2299,7 +2766,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
@@ -2338,6 +2804,15 @@
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"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": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
@@ -2416,6 +2891,15 @@
|
||||
"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": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -2660,6 +3144,12 @@
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -2797,6 +3287,12 @@
|
||||
"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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -2995,6 +3491,145 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -3264,6 +3899,12 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -3599,6 +4240,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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -3665,6 +4315,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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -17,10 +17,19 @@
|
||||
"@radix-ui/react-slot": "^1.2.5",
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@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",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings } from './types';
|
||||
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings, PostDetailResponse, PostRevision } from './types';
|
||||
|
||||
const BASE = '';
|
||||
|
||||
@@ -30,7 +30,7 @@ export const api = {
|
||||
return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`);
|
||||
},
|
||||
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) => request<PostDetailResponse>(`/api/posts/${id}`),
|
||||
comments: (id: number, myIds?: number[]) => {
|
||||
const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : '';
|
||||
return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`);
|
||||
@@ -60,6 +60,18 @@ export const api = {
|
||||
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: { post_edit_window_hours: number }) =>
|
||||
request<{ message: string; post_edit_window_hours: number }>('/api/admin/settings/forum', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
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 }>(
|
||||
|
||||
@@ -30,14 +30,39 @@ export interface PostItem {
|
||||
content?: string;
|
||||
tags: string;
|
||||
pinned: boolean;
|
||||
edit_locked?: boolean;
|
||||
like_count: number;
|
||||
view_count: number;
|
||||
comment_count: number;
|
||||
last_reply_at?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
board?: Board;
|
||||
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 {
|
||||
id: number;
|
||||
post_id: number;
|
||||
@@ -70,6 +95,7 @@ export interface AdminSettings {
|
||||
data_dir: string;
|
||||
db_path: string;
|
||||
port: number;
|
||||
post_edit_window_hours: number;
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import {
|
||||
useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef, useMemo, type ReactNode,
|
||||
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, type ReactNode,
|
||||
} from 'react';
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||||
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 {
|
||||
Bold, Italic, Strikethrough, Link, Code, Quote,
|
||||
List, ListOrdered, Image, Eye, Pencil, Minus, LockKeyhole,
|
||||
Bold, Italic, Strikethrough, Link as LinkIcon, Code, Quote,
|
||||
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
|
||||
} from 'lucide-react';
|
||||
import { markdownToHtml, countWords } from '../utils/markdown';
|
||||
import { renderPostContentHtml } from '../utils/postContent';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||
import { countWords } from '../utils/markdown';
|
||||
import { MembersOnly } from './editor/MembersOnlyExtension';
|
||||
|
||||
export interface ArticleEditorHandle {
|
||||
getHTML: () => string;
|
||||
getMarkdown: () => string;
|
||||
isEmpty: () => boolean;
|
||||
focus: () => void;
|
||||
}
|
||||
@@ -21,181 +28,206 @@ interface Props {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
type ViewMode = 'edit' | 'preview' | 'split';
|
||||
|
||||
interface ToolBtn {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
/** 去掉行首已有的 Markdown 块级前缀 */
|
||||
function stripLinePrefix(line: string): string {
|
||||
return line
|
||||
.replace(/^\r/, '')
|
||||
.replace(/^#{1,6}\s*/, '')
|
||||
.replace(/^>\s*/, '')
|
||||
.replace(/^[-*+]\s*/, '')
|
||||
.replace(/^\d+\.\s*/, '');
|
||||
/** 净化编辑器 HTML,保留 members-only 自定义标签 */
|
||||
function sanitizeHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
}
|
||||
|
||||
/** 判断编辑器内容是否为空 */
|
||||
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();
|
||||
}
|
||||
|
||||
const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEditor(
|
||||
{ value, onChange, placeholder = '在此撰写正文…' },
|
||||
ref,
|
||||
) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const selectionRef = useRef({ start: 0, end: 0 });
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split');
|
||||
const [previewHtml, setPreviewHtml] = useState('');
|
||||
const isInternalUpdate = useRef(false);
|
||||
const lastValueRef = useRef(value);
|
||||
const [, setEditorTick] = useState(0);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getHTML: () => markdownToHtml(value),
|
||||
getMarkdown: () => value,
|
||||
isEmpty: () => value.trim().length === 0,
|
||||
focus: () => textareaRef.current?.focus(),
|
||||
}));
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3, 4, 5, 6] },
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
defaultProtocol: 'https',
|
||||
}),
|
||||
Image.configure({ inline: false, allowBase64: false }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
MembersOnly,
|
||||
],
|
||||
content: sanitizeHtml(value) || '',
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
const html = sanitizeHtml(ed.getHTML());
|
||||
isInternalUpdate.current = true;
|
||||
lastValueRef.current = html;
|
||||
onChange(html);
|
||||
},
|
||||
onSelectionUpdate: () => setEditorTick(t => t + 1),
|
||||
onTransaction: () => setEditorTick(t => t + 1),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'article-prosemirror post-detail-content',
|
||||
spellcheck: 'false',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const saveSelection = useCallback(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
selectionRef.current = { start: ta.selectionStart, end: ta.selectionEnd };
|
||||
}, []);
|
||||
|
||||
const getSelection = useCallback(() => {
|
||||
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 保证流畅
|
||||
// 外部 value 变更时同步到编辑器(如加载已有帖子)
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setPreviewHtml(markdownToHtml(value)), 60);
|
||||
return () => clearTimeout(t);
|
||||
}, [value]);
|
||||
|
||||
// 编辑区随内容向下延伸,最小高度撑满视口剩余空间
|
||||
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);
|
||||
if (!editor) return;
|
||||
if (isInternalUpdate.current) {
|
||||
isInternalUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
insertAtCursor('\n:::members\n', '\n:::\n', '在此输入仅登录用户可见的内容…');
|
||||
}, [value, onChange, getSelection, restoreSelection, insertAtCursor]);
|
||||
const next = sanitizeHtml(value);
|
||||
if (next === lastValueRef.current) return;
|
||||
lastValueRef.current = next;
|
||||
editor.commands.setContent(next || '', { emitUpdate: false });
|
||||
}, [value, editor]);
|
||||
|
||||
const tools: ToolBtn[] = [
|
||||
{ icon: <strong>H</strong>, title: '标题(H2,再次点击升级)', action: toggleHeading },
|
||||
{ icon: <Bold size={15} />, title: '加粗', action: () => insertAtCursor('**', '**', '加粗') },
|
||||
{ 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('', '描述') },
|
||||
{ icon: <LockKeyhole size={15} />, title: '登录可见(选中文字后点击可包裹)', action: wrapMembersOnly },
|
||||
];
|
||||
useImperativeHandle(ref, () => ({
|
||||
getHTML: () => (editor ? sanitizeHtml(editor.getHTML()) : value),
|
||||
isEmpty: () => (editor ? isEditorEmpty(editor) : !value.trim()),
|
||||
focus: () => editor?.commands.focus(),
|
||||
}), [editor, value]);
|
||||
|
||||
const displayPreviewHtml = useMemo(() => {
|
||||
if (!value.trim()) {
|
||||
return `<p class="article-preview-placeholder">${placeholder}</p>`;
|
||||
const setLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const prev = editor.getAttributes('link').href as string | undefined;
|
||||
const url = window.prompt('链接地址', prev ?? 'https://');
|
||||
if (url === null) return;
|
||||
if (!url.trim()) {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
return renderPostContentHtml(previewHtml, true);
|
||||
}, [value, previewHtml, placeholder]);
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url.trim() }).run();
|
||||
}, [editor]);
|
||||
|
||||
const words = countWords(value);
|
||||
const showEdit = viewMode === 'edit' || viewMode === 'split';
|
||||
const showPreview = viewMode === 'preview' || viewMode === 'split';
|
||||
const setImage = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const url = window.prompt('图片地址');
|
||||
if (!url?.trim()) return;
|
||||
editor.chain().focus().setImage({ src: url.trim() }).run();
|
||||
}, [editor]);
|
||||
|
||||
const wrapMembersOnly = useCallback(() => {
|
||||
if (!editor) 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 buildTools = useCallback((): ToolBtn[] => {
|
||||
if (!editor) return [];
|
||||
return [
|
||||
{
|
||||
icon: <strong>H</strong>,
|
||||
title: '标题(H2,再次点击升级)',
|
||||
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: <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: setLink,
|
||||
},
|
||||
{
|
||||
icon: <ImageIcon size={15} />,
|
||||
title: '图片',
|
||||
action: setImage,
|
||||
},
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见(选中文字后点击可包裹)',
|
||||
active: editor.isActive('membersOnly'),
|
||||
className: 'article-tool-btn--members',
|
||||
action: wrapMembersOnly,
|
||||
},
|
||||
];
|
||||
}, [editor, setLink, setImage, wrapMembersOnly]);
|
||||
|
||||
const tools = buildTools();
|
||||
const words = editor ? countWords(editor.getText()) : 0;
|
||||
|
||||
return (
|
||||
<div className="article-editor">
|
||||
@@ -205,7 +237,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={`article-tool-btn${i === tools.length - 1 ? ' article-tool-btn--members' : ''}`}
|
||||
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
|
||||
title={t.title}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={t.action}
|
||||
@@ -214,65 +246,15 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
</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 className={`article-editor-panes article-editor-panes--${viewMode}`}>
|
||||
{showEdit && (
|
||||
<div className="article-pane article-pane--edit">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="article-textarea"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onSelect={saveSelection}
|
||||
onKeyUp={saveSelection}
|
||||
onClick={saveSelection}
|
||||
onFocus={saveSelection}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showPreview && (
|
||||
<div className="article-pane article-pane--preview">
|
||||
{viewMode === 'split' && <div className="article-pane-label">实时预览</div>}
|
||||
<div
|
||||
className={`article-preview post-detail-content${!value.trim() ? ' article-preview--empty' : ''}`}
|
||||
dangerouslySetInnerHTML={{ __html: displayPreviewHtml }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="article-editor-body">
|
||||
<EditorContent editor={editor} className="article-editor-content" />
|
||||
</div>
|
||||
|
||||
<div className="article-editor-status">
|
||||
<span>{words} 字</span>
|
||||
<span>Markdown</span>
|
||||
<span>富文本</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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('/admin/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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Settings } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Board, ForumStats } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
interface Props {
|
||||
boardId: number;
|
||||
@@ -14,55 +11,40 @@ interface Props {
|
||||
|
||||
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal }: Props) {
|
||||
const nav = useNavigate();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const board = boards.find(b => b.id === boardId);
|
||||
|
||||
const title = keyword
|
||||
? `搜索:${keyword}`
|
||||
: (boardId && board ? board.name : '全部帖子');
|
||||
|
||||
const hint = keyword
|
||||
? `找到 ${postTotal} 篇相关帖子`
|
||||
: boardId && board
|
||||
? (board.description || '欢迎在本板块交流讨论')
|
||||
: '姜十三论坛 · 拾三一隅,自在交流';
|
||||
const boardHint = boardId && board ? (board.description || '') : '';
|
||||
|
||||
return (
|
||||
<div className="feed-banner">
|
||||
<div className="feed-banner-row">
|
||||
<div className="feed-banner-title">
|
||||
<h2>{title}</h2>
|
||||
<p>{hint}</p>
|
||||
</div>
|
||||
{!keyword && (
|
||||
<div className="feed-actions flex flex-wrap items-center gap-2">
|
||||
{authLoading ? (
|
||||
<span className="feed-action-slot" aria-hidden />
|
||||
) : user ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => nav(boardId ? `/compose?board=${boardId}` : '/compose')}
|
||||
>
|
||||
<Plus />
|
||||
{boardId ? '在此发帖' : '发布帖子'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => nav('/login')}>登录参与</Button>
|
||||
)}
|
||||
{!authLoading && user?.role === 'admin' && (
|
||||
<Button size="sm" variant="outline" onClick={() => nav('/admin/boards')}>
|
||||
<Settings />
|
||||
管理板块
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={`feed-head${keyword ? ' feed-head--solo' : ''}`}>
|
||||
<div className="feed-head__title">
|
||||
<h2 title={boardHint || undefined}>{title}</h2>
|
||||
{!keyword && stats && (
|
||||
<span className="feed-head__meta">
|
||||
会员 <strong>{stats.users}</strong>
|
||||
<span className="feed-head__dot" aria-hidden>·</span>
|
||||
帖子 <strong>{stats.posts}</strong>
|
||||
<span className="feed-head__dot" aria-hidden>·</span>
|
||||
板块 <strong>{stats.boards}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="feed-stats">
|
||||
<span>会员 <strong>{stats?.users ?? '—'}</strong></span>
|
||||
<span>帖子 <strong>{stats?.posts ?? '—'}</strong></span>
|
||||
<span>板块 <strong>{stats?.boards ?? '—'}</strong></span>
|
||||
</div>
|
||||
{keyword && (
|
||||
<button
|
||||
type="button"
|
||||
className="feed-head__clear"
|
||||
onClick={() => nav('/')}
|
||||
>
|
||||
清除搜索
|
||||
</button>
|
||||
)}
|
||||
{keyword && (
|
||||
<span className="feed-toolbar__count">共 {postTotal} 条</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
64
frontend/src/components/FeedSortBar.tsx
Normal file
64
frontend/src/components/FeedSortBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/PinnedIcon.tsx
Normal file
20
frontend/src/components/PinnedIcon.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from './FeedSortBar';
|
||||
import { formatTime } from '../utils/content';
|
||||
|
||||
interface Props {
|
||||
post: PostItem;
|
||||
sort?: FeedSort;
|
||||
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 timeLabel = sort === 'reply'
|
||||
? (post.last_reply_at
|
||||
? `${formatTime(post.last_reply_at)} 回复`
|
||||
: '暂无回复')
|
||||
: formatTime(post.created_at);
|
||||
|
||||
return (
|
||||
<div className="post-row" onClick={onClick}>
|
||||
@@ -17,13 +25,13 @@ export default function PostListItem({ post, onClick }: Props) {
|
||||
</div>
|
||||
<div className="post-body">
|
||||
<div className="post-title">
|
||||
{post.pinned && <Badge variant="orange" className="mr-1.5">置顶</Badge>}
|
||||
{post.pinned && <PinnedIcon className="mr-1.5" />}
|
||||
{post.title}
|
||||
</div>
|
||||
<div className="post-meta">
|
||||
{post.board && <Badge variant="green">{post.board.name}</Badge>}
|
||||
<span>{post.user?.nickname || '匿名'}</span>
|
||||
<span>{formatTime(post.created_at)}</span>
|
||||
<span>{timeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-stats">
|
||||
|
||||
334
frontend/src/components/PostRevisionPanel.tsx
Normal file
334
frontend/src/components/PostRevisionPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import {
|
||||
Home, Star, LayoutDashboard,
|
||||
} 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 { useAuth } from '../hooks/useAuth';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
|
||||
|
||||
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
|
||||
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
|
||||
@@ -29,6 +30,8 @@ interface Props {
|
||||
export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const sort = parseFeedSort(params.get('sort'));
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
@@ -50,7 +53,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-section">浏览</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); nav('/'); })}
|
||||
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); nav(buildHomeUrl(0, sort)); })}
|
||||
{user && navItem('favorites', '我的收藏', <Star />, () => nav('/favorites'))}
|
||||
</nav>
|
||||
|
||||
@@ -63,7 +66,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
type="button"
|
||||
key={b.id}
|
||||
className={cn('sidebar-nav-item', menuKey != null && menuKey === String(b.id) && 'active')}
|
||||
onClick={() => { onSelectBoard(b.id); nav(`/?board=${b.id}`); }}
|
||||
onClick={() => { onSelectBoard(b.id); nav(buildHomeUrl(b.id, sort)); }}
|
||||
>
|
||||
<span className="flex-1 truncate">{b.name}</span>
|
||||
</button>
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import PostListItem from './PostListItem';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from './FeedSortBar';
|
||||
|
||||
interface Props {
|
||||
posts: PostItem[];
|
||||
sort?: FeedSort;
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
onSelect: (id: number) => void;
|
||||
/** 返回列表时恢复的滚动位置 */
|
||||
restoreScrollTop?: number | null;
|
||||
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,
|
||||
onLoadMore,
|
||||
onSelect,
|
||||
restoreScrollTop,
|
||||
onScrollTopChange,
|
||||
onScrollRestored,
|
||||
}: Props) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: posts.length,
|
||||
@@ -22,17 +39,29 @@ export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, o
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
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(() => {
|
||||
const el = parentRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
onScrollTopChange?.(el.scrollTop);
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && hasMore && !loading) {
|
||||
onLoadMore();
|
||||
}
|
||||
};
|
||||
el.addEventListener('scroll', onScroll);
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, [hasMore, loading, onLoadMore]);
|
||||
}, [hasMore, loading, onLoadMore, onScrollTopChange]);
|
||||
|
||||
return (
|
||||
<div className="post-list-scroll" ref={parentRef}>
|
||||
@@ -50,7 +79,7 @@ export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, o
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
<PostListItem post={post} onClick={() => onSelect(post.id)} />
|
||||
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
84
frontend/src/components/editor/MembersOnlyExtension.tsx
Normal file
84
frontend/src/components/editor/MembersOnlyExtension.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import {
|
||||
ReactNodeViewRenderer,
|
||||
NodeViewWrapper,
|
||||
NodeViewContent,
|
||||
type NodeViewProps,
|
||||
} from '@tiptap/react';
|
||||
import { LockKeyhole } from 'lucide-react';
|
||||
|
||||
/** 编辑态「登录可见」区块视图 */
|
||||
function MembersOnlyView({ selected }: NodeViewProps) {
|
||||
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>
|
||||
</div>
|
||||
<NodeViewContent className="post-members-only__body" />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
membersOnly: {
|
||||
insertMembersOnly: () => ReturnType;
|
||||
wrapMembersOnly: () => 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);
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertMembersOnly: () => ({ chain }) => chain()
|
||||
.insertContent({
|
||||
type: this.name,
|
||||
content: [{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: '在此输入仅登录用户可见的内容…' }],
|
||||
}],
|
||||
})
|
||||
.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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -35,6 +35,9 @@ export function useGlobalWheelScroll(scrollRef: RefObject<HTMLElement | null>, e
|
||||
if (!root) return;
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// Ctrl/⌘ + 滚轮用于浏览器缩放,不拦截
|
||||
if (e.ctrlKey || e.metaKey) return;
|
||||
|
||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||
if (!target) return;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../
|
||||
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
|
||||
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
|
||||
import RightPanel from '../components/RightPanel';
|
||||
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
|
||||
|
||||
export default function MainLayout() {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
@@ -34,6 +35,7 @@ export default function MainLayout() {
|
||||
const [online, setOnline] = useState<OnlineStats | null>(null);
|
||||
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
|
||||
const [keyword, setKeyword] = useState(params.get('keyword') || '');
|
||||
const feedSort = parseFeedSort(params.get('sort'));
|
||||
|
||||
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
|
||||
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
|
||||
@@ -195,13 +197,13 @@ export default function MainLayout() {
|
||||
<div className="mobile-board-bar">
|
||||
<span
|
||||
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
|
||||
onClick={() => { setBoardId(0); nav('/'); }}
|
||||
onClick={() => { setBoardId(0); nav(buildHomeUrl(0, feedSort)); }}
|
||||
>全部</span>
|
||||
{boards.map(b => (
|
||||
<span
|
||||
key={b.id}
|
||||
className={`board-chip ${mobileActiveBoard === b.id ? 'active' : ''}`}
|
||||
onClick={() => { setBoardId(b.id); nav(`/?board=${b.id}`); }}
|
||||
onClick={() => { setBoardId(b.id); nav(buildHomeUrl(b.id, feedSort)); }}
|
||||
>{b.name}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,9 @@ import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import type { Board } from '../api/types';
|
||||
import { isHtmlEmpty } from '../utils/postContent';
|
||||
import ArticleEditor from '../components/ArticleEditor';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { markdownToHtml, htmlToMarkdown } from '../utils/markdown';
|
||||
|
||||
export default function ComposePage() {
|
||||
const nav = useNavigate();
|
||||
@@ -37,16 +37,21 @@ export default function ComposePage() {
|
||||
const list = boardsData.boards ?? [];
|
||||
setBoards(list);
|
||||
const post = postData.post;
|
||||
const canEdit = user.role === 'admin' || post.user_id === user.id;
|
||||
if (!canEdit) {
|
||||
const isOwnerOrAdmin = user.role === 'admin' || post.user_id === user.id;
|
||||
if (!isOwnerOrAdmin) {
|
||||
notify.error('无权编辑此帖子');
|
||||
nav(`/post/${editId}`);
|
||||
return;
|
||||
}
|
||||
if (!postData.can_edit) {
|
||||
notify.error(postData.edit_block_reason || '当前无法编辑此帖子');
|
||||
nav(`/post/${editId}`);
|
||||
return;
|
||||
}
|
||||
setBoardId(String(post.board_id));
|
||||
setTitle(post.title);
|
||||
setTags(post.tags ?? '');
|
||||
setContent(htmlToMarkdown(post.content ?? ''));
|
||||
setContent(post.content ?? '');
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notify.error(e instanceof Error ? e.message : '加载帖子失败');
|
||||
@@ -108,13 +113,13 @@ export default function ComposePage() {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!isEdit && !boardId) { notify.warning('请选择板块'); return; }
|
||||
if (!trimmedTitle) { notify.warning('请输入标题'); return; }
|
||||
if (!content.trim()) { notify.warning('请输入正文内容'); return; }
|
||||
if (isHtmlEmpty(content)) { notify.warning('请输入正文内容'); return; }
|
||||
|
||||
setPublishing(true);
|
||||
try {
|
||||
const payload = {
|
||||
title: trimmedTitle,
|
||||
content: markdownToHtml(content),
|
||||
content: content.trim(),
|
||||
tags: tags.trim(),
|
||||
};
|
||||
if (isEdit) {
|
||||
@@ -204,7 +209,7 @@ export default function ComposePage() {
|
||||
<ArticleEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
placeholder="开始写作。支持 Markdown 语法,右侧可实时预览渲染效果。"
|
||||
placeholder="开始写作。所见即所得,选中文字后使用工具栏设置格式。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
@@ -6,7 +6,8 @@ import type { PostItem } from '../api/types';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import VirtualPostList from '../components/VirtualPostList';
|
||||
import FeedHeader from '../components/FeedHeader';
|
||||
import BoardGrid from '../components/BoardGrid';
|
||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||
import { getFeedCache, setFeedCache, clearAllFeedCache } from '../utils/feedCache';
|
||||
|
||||
export default function HomePage() {
|
||||
const nav = useNavigate();
|
||||
@@ -14,17 +15,27 @@ export default function HomePage() {
|
||||
const ctx = useOutletContext<LayoutCtx>();
|
||||
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
||||
const keyword = params.get('keyword') || '';
|
||||
const sort = parseFeedSort(params.get('sort'));
|
||||
const initialCache = getFeedCache(boardId, keyword, sort);
|
||||
|
||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||
const [postTotal, setPostTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [posts, setPosts] = useState<PostItem[]>(() => initialCache?.posts ?? []);
|
||||
const [postTotal, setPostTotal] = useState(() => initialCache?.postTotal ?? 0);
|
||||
const [page, setPage] = useState(() => initialCache?.page ?? 1);
|
||||
const [hasMore, setHasMore] = useState(() => initialCache?.hasMore ?? true);
|
||||
const [loading, setLoading] = useState(() => !initialCache);
|
||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => initialCache?.scrollTop ?? null);
|
||||
const scrollTopRef = useRef(initialCache?.scrollTop ?? 0);
|
||||
|
||||
const load = useCallback(async (p: number, reset = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.posts({ page: p, size: 30, board_id: boardId || '', keyword });
|
||||
const data = await api.posts({
|
||||
page: p,
|
||||
size: 30,
|
||||
board_id: boardId || '',
|
||||
keyword,
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||
// 切换筛选时保留旧列表,避免中间区域瞬间空白
|
||||
setPosts(prev => (reset ? batch : [...prev, ...batch]));
|
||||
@@ -37,50 +48,113 @@ export default function HomePage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [boardId, keyword]);
|
||||
}, [boardId, keyword, sort]);
|
||||
|
||||
/** 有缓存时静默拉取已加载页,更新置顶等状态同时保留滚动位置 */
|
||||
const revalidate = useCallback(async (maxPage: number) => {
|
||||
try {
|
||||
const all: PostItem[] = [];
|
||||
let total = 0;
|
||||
let hasMore = true;
|
||||
for (let p = 1; p <= maxPage; p++) {
|
||||
const data = await api.posts({
|
||||
page: p,
|
||||
size: 30,
|
||||
board_id: boardId || '',
|
||||
keyword,
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||
all.push(...batch);
|
||||
total = data.total ?? 0;
|
||||
hasMore = !!data.has_more;
|
||||
if (!data.has_more) break;
|
||||
}
|
||||
setPosts(all);
|
||||
setPostTotal(total);
|
||||
setHasMore(hasMore);
|
||||
setPage(maxPage);
|
||||
} catch {
|
||||
// 静默失败,保留缓存数据
|
||||
}
|
||||
}, [boardId, keyword, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
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(cached.page);
|
||||
return;
|
||||
}
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
load(1, true);
|
||||
}, [boardId, keyword, load]);
|
||||
}, [boardId, keyword, sort, load, revalidate]);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = () => load(1, true);
|
||||
return () => {
|
||||
if (posts.length === 0) return;
|
||||
setFeedCache(boardId, keyword, sort, {
|
||||
posts,
|
||||
postTotal,
|
||||
page,
|
||||
hasMore,
|
||||
scrollTop: scrollTopRef.current,
|
||||
});
|
||||
};
|
||||
}, [boardId, keyword, sort, posts, postTotal, page, hasMore]);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = () => {
|
||||
clearAllFeedCache();
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
load(1, true);
|
||||
};
|
||||
window.addEventListener('posts-refresh', fn);
|
||||
return () => window.removeEventListener('posts-refresh', fn);
|
||||
}, [load]);
|
||||
}, [boardId, keyword, sort, load]);
|
||||
|
||||
const showBoardGrid = !keyword;
|
||||
const handleSortChange = (next: FeedSort) => {
|
||||
if (next === sort) return;
|
||||
clearAllFeedCache();
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
nav(buildHomeUrl(boardId, next));
|
||||
};
|
||||
|
||||
const showSortBar = !keyword;
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<FeedHeader
|
||||
boardId={boardId}
|
||||
keyword={keyword}
|
||||
boards={ctx?.boards ?? []}
|
||||
stats={ctx?.stats ?? null}
|
||||
postTotal={postTotal}
|
||||
/>
|
||||
{showBoardGrid && (
|
||||
<BoardGrid
|
||||
<div className="feed-top">
|
||||
<FeedHeader
|
||||
boardId={boardId}
|
||||
keyword={keyword}
|
||||
boards={ctx?.boards ?? []}
|
||||
loading={!ctx?.layoutReady}
|
||||
selectedId={boardId}
|
||||
onSelect={(id) => {
|
||||
ctx?.setBoardId(id);
|
||||
nav(id ? `/?board=${id}` : '/');
|
||||
}}
|
||||
stats={ctx?.stats ?? null}
|
||||
postTotal={postTotal}
|
||||
/>
|
||||
)}
|
||||
<div className="post-list-bar">
|
||||
<span>{keyword ? '搜索结果' : '帖子列表'}</span>
|
||||
<span>共 {postTotal} 条</span>
|
||||
{showSortBar && (
|
||||
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
|
||||
)}
|
||||
</div>
|
||||
<VirtualPostList
|
||||
posts={posts}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={() => !loading && hasMore && load(page + 1)}
|
||||
onSelect={(id) => nav(`/post/${id}`)}
|
||||
restoreScrollTop={restoreScrollTop}
|
||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin } 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 { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
@@ -10,9 +11,11 @@ import type { PostItem, Comment } from '../api/types';
|
||||
import CommentThreadList from '../components/CommentThreadList';
|
||||
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
|
||||
import PostContent from '../components/PostContent';
|
||||
import PostRevisionPanel from '../components/PostRevisionPanel';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { formatTime } from '../utils/content';
|
||||
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
||||
import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
|
||||
import { clearAllFeedCache } from '../utils/feedCache';
|
||||
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
|
||||
|
||||
export default function PostDetailPage() {
|
||||
@@ -30,6 +33,10 @@ export default function PostDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
|
||||
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 commentSectionRef = useRef<HTMLDivElement>(null);
|
||||
@@ -55,6 +62,9 @@ export default function PostDetailPage() {
|
||||
setPost(detail.post);
|
||||
setLiked(detail.liked);
|
||||
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);
|
||||
await refresh();
|
||||
} catch (e: unknown) {
|
||||
@@ -161,14 +171,34 @@ export default function PostDetailPage() {
|
||||
|
||||
const authorInitial = post.user?.nickname?.[0] || '?';
|
||||
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 : '操作失败');
|
||||
@@ -190,7 +220,7 @@ export default function PostDetailPage() {
|
||||
|
||||
<div className="post-detail-head">
|
||||
<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}
|
||||
</h1>
|
||||
<div className="post-detail-author-row">
|
||||
@@ -200,7 +230,16 @@ export default function PostDetailPage() {
|
||||
<div className="post-detail-author-info">
|
||||
<span className="post-detail-author-name">{post.user?.nickname}</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,15 +268,40 @@ export default function PostDetailPage() {
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button variant="outline" size="sm" onClick={handlePin}>
|
||||
<Pin />
|
||||
{post.pinned ? '取消置顶' : '置顶'}
|
||||
{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>
|
||||
|
||||
<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-bar">
|
||||
<span className="comment-section-title">评论区</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search } from 'lucide-react';
|
||||
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';
|
||||
@@ -14,6 +14,7 @@ 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';
|
||||
|
||||
export default function AdminPostsPage() {
|
||||
const nav = useNavigate();
|
||||
@@ -44,6 +45,18 @@ export default function AdminPostsPage() {
|
||||
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) {
|
||||
@@ -100,6 +113,7 @@ export default function AdminPostsPage() {
|
||||
<th>板块</th>
|
||||
<th>作者</th>
|
||||
<th>置顶</th>
|
||||
<th>锁定</th>
|
||||
<th>点赞</th>
|
||||
<th>浏览</th>
|
||||
<th>时间</th>
|
||||
@@ -118,6 +132,7 @@ export default function AdminPostsPage() {
|
||||
<td>{p.board?.name ?? '—'}</td>
|
||||
<td>{p.user?.nickname ?? '—'}</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>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
|
||||
@@ -126,6 +141,9 @@ export default function AdminPostsPage() {
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Database } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../../api/client';
|
||||
@@ -12,14 +13,37 @@ export default function AdminSettingsPage() {
|
||||
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [backing, setBacking] = useState(false);
|
||||
const [editWindowHours, setEditWindowHours] = useState('24');
|
||||
const [savingForum, setSavingForum] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
api.adminSettings()
|
||||
.then(setSettings)
|
||||
.then(s => {
|
||||
setSettings(s);
|
||||
setEditWindowHours(String(s.post_edit_window_hours ?? 24));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [ready]);
|
||||
|
||||
const handleSaveForumSettings = async () => {
|
||||
const hours = parseInt(editWindowHours, 10);
|
||||
if (Number.isNaN(hours) || hours < 0) {
|
||||
notify.warning('请输入有效的小时数(0 表示不限)');
|
||||
return;
|
||||
}
|
||||
setSavingForum(true);
|
||||
try {
|
||||
const r = await api.adminUpdateForumSettings({ post_edit_window_hours: hours });
|
||||
notify.success(r.message);
|
||||
setSettings(s => s ? { ...s, post_edit_window_hours: r.post_edit_window_hours } : s);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSavingForum(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackup = async () => {
|
||||
setBacking(true);
|
||||
try {
|
||||
@@ -45,6 +69,27 @@ export default function AdminSettingsPage() {
|
||||
<p>数据目录、敏感词配置与数据库备份</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-head">论坛规则</div>
|
||||
<p className="admin-card-desc">
|
||||
设置普通用户发布后可编辑帖子的时限。超过时限后仅管理员可编辑。设为 0 表示不限时。
|
||||
</p>
|
||||
<div className="admin-form-row">
|
||||
<label htmlFor="edit-window-hours">编辑时限(小时)</label>
|
||||
<Input
|
||||
id="edit-window-hours"
|
||||
type="number"
|
||||
min={0}
|
||||
value={editWindowHours}
|
||||
onChange={e => setEditWindowHours(e.target.value)}
|
||||
className="max-w-[120px]"
|
||||
/>
|
||||
<Button onClick={handleSaveForumSettings} loading={savingForum}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-head">运行信息</div>
|
||||
<dl className="admin-dl">
|
||||
|
||||
@@ -513,7 +513,7 @@ a:hover { text-decoration: underline; }
|
||||
.header-inner { padding: 0 12px; gap: 10px; }
|
||||
.header-search-wrap { max-width: none; }
|
||||
.header-compose-btn { width: 34px; padding: 0; justify-content: center; }
|
||||
.feed-banner-row { flex-direction: column; gap: 10px; }
|
||||
.feed-banner-row { flex-direction: row; gap: 10px; }
|
||||
.board-grid { flex-wrap: nowrap; overflow-x: auto; padding: 8px 12px; scrollbar-width: none; }
|
||||
.board-grid::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
@@ -533,7 +533,8 @@ a:hover { text-decoration: underline; }
|
||||
display: none;
|
||||
}
|
||||
|
||||
.virtual-list-wrap {
|
||||
.virtual-list-wrap,
|
||||
.post-list-scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
@@ -541,7 +542,8 @@ a:hover { text-decoration: underline; }
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.virtual-list-wrap::-webkit-scrollbar {
|
||||
.virtual-list-wrap::-webkit-scrollbar,
|
||||
.post-list-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.page-inner { padding: 20px 24px; max-width: 720px; }
|
||||
@@ -574,31 +576,131 @@ a:hover { text-decoration: underline; }
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
}
|
||||
|
||||
.feed-top {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
background: var(--j13-bg-block);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.feed-top:not(:has(.feed-head--solo)) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 6px 16px;
|
||||
}
|
||||
|
||||
.feed-top:not(:has(.feed-head--solo)) .feed-head {
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feed-top:not(:has(.feed-head--solo)) .feed-toolbar {
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.feed-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 16px 6px;
|
||||
}
|
||||
|
||||
.feed-head--solo {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.feed-head__title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.feed-head h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feed-head__meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.feed-head__meta strong {
|
||||
color: var(--j13-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feed-head__dot {
|
||||
margin: 0 4px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.feed-head__clear {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: var(--j13-green);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.feed-head__clear:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.feed-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 16px 8px;
|
||||
}
|
||||
|
||||
.feed-toolbar__count {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 兼容旧类名,避免其他页面引用失效 */
|
||||
.feed-banner {
|
||||
padding: 14px 20px 12px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
}
|
||||
|
||||
.feed-banner-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feed-banner-title { flex: 1; min-width: 0; }
|
||||
|
||||
.feed-banner h2 { margin: 0 0 4px; font-size: 16px; font-weight: 600; }
|
||||
.feed-banner h2 { margin: 0; font-size: 15px; font-weight: 600; }
|
||||
.feed-banner p { margin: 0; font-size: 12px; color: var(--color-text-3); line-height: 1.45; }
|
||||
|
||||
.feed-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 16px;
|
||||
margin-top: 10px;
|
||||
min-height: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
gap: 8px 12px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.feed-stats strong { color: var(--j13-green); font-weight: 600; }
|
||||
@@ -637,9 +739,10 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
.board-chip.active {
|
||||
background: var(--j13-green);
|
||||
border-color: var(--j13-green);
|
||||
color: #fff;
|
||||
background: var(--j13-green-bg);
|
||||
border-color: transparent;
|
||||
color: var(--j13-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.board-grid {
|
||||
@@ -650,6 +753,67 @@ a:hover { text-decoration: underline; }
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
}
|
||||
|
||||
/* 帖子排序栏:与左侧栏 active 样式保持一致 */
|
||||
.feed-sort-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.feed-sort-bar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.feed-sort-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text-2);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.feed-sort-tab:hover {
|
||||
background: var(--j13-bg-block-muted);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.feed-sort-tab.active {
|
||||
background: var(--j13-green-bg);
|
||||
color: var(--j13-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feed-sort-tab svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.feed-head {
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px 6px;
|
||||
}
|
||||
|
||||
.feed-head__meta { display: none; }
|
||||
|
||||
.feed-toolbar {
|
||||
padding: 0 12px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.board-grid-empty {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
@@ -772,6 +936,13 @@ a:hover { text-decoration: underline; }
|
||||
.post-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.post-body { flex: 1; min-width: 0; }
|
||||
.post-title { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 4px; }
|
||||
.post-pinned-icon {
|
||||
display: inline-block;
|
||||
vertical-align: -0.15em;
|
||||
flex-shrink: 0;
|
||||
color: #e53935;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.post-meta { font-size: 12px; color: var(--color-text-3); display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.post-stats { display: flex; gap: 12px; font-size: 12px; color: var(--color-text-3); flex-shrink: 0; padding-top: 4px; }
|
||||
|
||||
@@ -857,6 +1028,497 @@ a:hover { text-decoration: underline; }
|
||||
.post-detail-meta-line {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.post-detail-locked-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 6px;
|
||||
color: hsl(var(--destructive));
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.post-detail-edit-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.post-revision-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.post-revision-overlay--fullscreen {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.post-revision-panel {
|
||||
background: hsl(var(--background));
|
||||
border-radius: 12px;
|
||||
width: min(1080px, 100%);
|
||||
height: min(88vh, 900px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-revision-panel--fullscreen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.post-revision-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.post-revision-head-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.post-revision-head-left h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.post-revision-head-sub {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--j13-border-light);
|
||||
}
|
||||
|
||||
.post-revision-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.post-revision-view-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.post-revision-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.post-revision-tab:hover {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.post-revision-tab.active {
|
||||
background: hsl(var(--background));
|
||||
color: var(--color-text-1);
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.post-revision-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.post-revision-icon-btn:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.post-revision-loading,
|
||||
.post-revision-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.post-revision-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-revision-sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--j13-border-light);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.post-revision-sidebar-label {
|
||||
padding: 10px 14px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.post-revision-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.post-revision-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.post-revision-item:hover,
|
||||
.post-revision-item.active {
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.post-revision-item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.post-revision-item-num {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.post-revision-item-title {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.post-revision-item-meta {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.post-revision-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.post-revision-badge--none {
|
||||
background: hsl(var(--muted));
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.post-revision-current {
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--j13-border-light);
|
||||
background: hsl(var(--muted) / 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.post-revision-current-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.post-revision-current-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.post-revision-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.post-revision-main-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.post-revision-main-editor {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.post-revision-main-time {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.post-revision-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
padding: 16px 20px 24px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Diff 视图 */
|
||||
.revision-diff-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.revision-diff-section h4 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.revision-diff-block {
|
||||
padding: 12px 14px;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.revision-diff-inline del {
|
||||
background: hsl(0 70% 95%);
|
||||
color: hsl(0 60% 35%);
|
||||
text-decoration: line-through;
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.revision-diff-inline ins {
|
||||
background: hsl(140 60% 93%);
|
||||
color: hsl(140 50% 28%);
|
||||
text-decoration: none;
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.revision-diff-unchanged {
|
||||
color: var(--color-text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.revision-diff-stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.revision-diff-stat {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.revision-diff-stat--add {
|
||||
background: hsl(140 60% 93%);
|
||||
color: hsl(140 50% 28%);
|
||||
}
|
||||
|
||||
.revision-diff-stat--del {
|
||||
background: hsl(0 70% 95%);
|
||||
color: hsl(0 60% 35%);
|
||||
}
|
||||
|
||||
.revision-diff-pre {
|
||||
margin: 0;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.revision-diff-line {
|
||||
display: flex;
|
||||
padding: 1px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.revision-diff-line--add {
|
||||
background: hsl(140 60% 95%);
|
||||
}
|
||||
|
||||
.revision-diff-line--del {
|
||||
background: hsl(0 70% 96%);
|
||||
}
|
||||
|
||||
.revision-diff-line--same {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.revision-diff-gutter {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-3);
|
||||
user-select: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.revision-diff-line--add .revision-diff-gutter { color: hsl(140 50% 40%); }
|
||||
.revision-diff-line--del .revision-diff-gutter { color: hsl(0 60% 45%); }
|
||||
|
||||
.revision-diff-text {
|
||||
flex: 1;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.revision-diff-line--add .revision-diff-text { color: hsl(140 45% 22%); }
|
||||
.revision-diff-line--del .revision-diff-text {
|
||||
color: hsl(0 55% 30%);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* 完整版本视图 */
|
||||
.revision-full-view h4 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.revision-full-tags {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.post-revision-overlay:not(.post-revision-overlay--fullscreen) {
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.post-revision-panel:not(.post-revision-panel--fullscreen) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.post-revision-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.post-revision-head-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.post-revision-view-tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.post-revision-tab-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.post-revision-tab.active .post-revision-tab-label {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.post-revision-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.post-revision-sidebar {
|
||||
width: 100%;
|
||||
max-height: 180px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
}
|
||||
|
||||
.post-revision-current {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.post-detail-tags {
|
||||
@@ -2173,112 +2835,64 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
color: var(--j13-green);
|
||||
}
|
||||
|
||||
.article-tool-btn.active {
|
||||
background: rgba(24, 160, 88, 0.12);
|
||||
color: var(--j13-green);
|
||||
}
|
||||
|
||||
.article-tool-btn strong {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.article-editor-modes {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
background: var(--j13-bg-block-muted);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.article-mode-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
|
||||
.article-mode-btn.active {
|
||||
background: var(--j13-bg-surface);
|
||||
color: var(--j13-green);
|
||||
font-weight: 500;
|
||||
box-shadow: var(--j13-shadow-soft);
|
||||
}
|
||||
|
||||
.article-editor-panes {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.article-editor-panes--edit .article-pane--edit,
|
||||
.article-editor-panes--preview .article-pane--preview {
|
||||
.article-editor-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.article-editor-panes--split .article-pane--edit,
|
||||
.article-editor-panes--split .article-pane--preview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-editor-panes--split .article-pane--edit {
|
||||
border-right: 1px solid var(--j13-border-light);
|
||||
}
|
||||
|
||||
.article-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.article-pane-label {
|
||||
padding: 6px 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
flex-shrink: 0;
|
||||
.article-editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.article-textarea {
|
||||
width: 100%;
|
||||
.article-editor-content .tiptap,
|
||||
.article-prosemirror {
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
padding: 12px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.article-textarea::placeholder {
|
||||
.article-prosemirror p.is-editor-empty:first-child::before {
|
||||
color: var(--color-text-4);
|
||||
font-family: var(--j13-font-family);
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.article-preview {
|
||||
padding: 12px 0 12px 16px;
|
||||
overflow: visible;
|
||||
.article-prosemirror a {
|
||||
color: var(--j13-green);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.article-editor-panes--edit .article-preview,
|
||||
.article-editor-panes--preview .article-preview {
|
||||
padding-left: 0;
|
||||
.article-prosemirror img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.article-preview--empty .article-preview-placeholder {
|
||||
color: var(--color-text-4);
|
||||
font-style: italic;
|
||||
.editor-members-only {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.editor-members-only--selected {
|
||||
box-shadow: 0 0 0 2px rgba(24, 160, 88, 0.35);
|
||||
}
|
||||
|
||||
.article-editor-status {
|
||||
@@ -2292,21 +2906,6 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.article-editor-panes--split {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.article-editor-panes--split .article-pane--edit {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
}
|
||||
|
||||
.article-preview {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.compose-canvas {
|
||||
padding: 0 16px;
|
||||
@@ -2424,6 +3023,15 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
}
|
||||
.admin-card-desc { padding: 12px 16px 0; font-size: 13px; color: hsl(var(--muted-foreground)); }
|
||||
.admin-card .admin-card-desc + button { margin: 12px 16px 16px; }
|
||||
.admin-form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-form-row label { color: hsl(var(--muted-foreground)); white-space: nowrap; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.admin-table th, .admin-table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--j13-border); }
|
||||
.admin-table th { font-weight: 600; color: hsl(var(--muted-foreground)); background: hsl(var(--muted) / 0.3); }
|
||||
|
||||
@@ -17,10 +17,46 @@ export function highlightMentions(text: string, _onClick?: (name: string) => voi
|
||||
|
||||
export function formatTime(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
|
||||
const now = new Date();
|
||||
const diff = (now.getTime() - d.getTime()) / 1000;
|
||||
if (diff < 60) return '刚刚';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
|
||||
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;
|
||||
}
|
||||
|
||||
112
frontend/src/utils/feedCache.ts
Normal file
112
frontend/src/utils/feedCache.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { PostItem } from '../api/types';
|
||||
|
||||
import type { FeedSort } from '../components/FeedSortBar';
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -51,6 +51,16 @@ function buildLockedGateHtml(charLength: number): string {
|
||||
</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 */
|
||||
export function renderPostContentHtml(html: string, isLoggedIn: boolean): string {
|
||||
if (!html.trim()) return '';
|
||||
|
||||
77
frontend/src/utils/revisionDiff.ts
Normal file
77
frontend/src/utils/revisionDiff.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user