')
20 |
21 | let $addTagBtn: JQuery | undefined
22 |
23 | const callbacks: CallbackFunctions = {
24 | onRemoveExistingTagBlock: () => {
25 | // eslint-disable-next-line @typescript-eslint/no-use-before-define
26 | insertAddBtn()
27 | },
28 |
29 | onInsertNewTagBlock: ({ $tags }) => {
30 | $tagBlock.append($tags)
31 | $addTagBtn?.remove()
32 | },
33 | }
34 |
35 | const insertAddBtn = () => {
36 | $addTagBtn = createButton({ children: '添加用户标签' })
37 | .on('click', () => {
38 | openTagsSetter({ memberName, memberAvatar, ...callbacks })
39 | })
40 | .appendTo($tagBlock)
41 | }
42 |
43 | if (tagData && Reflect.has(tagData, memberName)) {
44 | const tags = tagData[memberName].tags
45 | const tagsText = tags ? getTagsText(tags) : undefined
46 |
47 | if (tagsText) {
48 | const $tags = $(
49 | `
# ${tagsText}
`
50 | )
51 | $tags.on('click', () => {
52 | openTagsSetter({ memberName, memberAvatar, ...callbacks })
53 | })
54 | $tagBlock.append($tags)
55 | } else {
56 | insertAddBtn()
57 | }
58 | } else {
59 | insertAddBtn()
60 | }
61 |
62 | $tagBlock.insertAfter($memberName)
63 | }
64 | })()
65 |
--------------------------------------------------------------------------------
/website/src/content/changelog.md:
--------------------------------------------------------------------------------
1 | ## 1.10.x
2 |
3 | ### 优化改进
4 |
5 | - 优化「主题预览」功能。
6 | - 优化 modal 点击区域意外关闭的问题。
7 |
8 | ---
9 |
10 | ## 1.9.x
11 |
12 | ### 新功能
13 |
14 | - 支持在主题列表中直接屏蔽主题。
15 | - 增加图片分享操作按钮。
16 | - 增加「近期热议主题」板块。
17 |
18 | ### 优化改进
19 |
20 | - 新增 `s.v2ex.com` 作为匹配子域名。
21 | - 优化渲染用户标签的逻辑。
22 | - 修复无法自动隐藏回复时间的问题。
23 | - 修复无法正确打开 Chrome 侧边栏的问题。
24 | - 修复 Popup 主题列表内容转义的问题。
25 | - 修复导致登录页无法正常跳转的问题。
26 |
27 | ---
28 |
29 | ## 1.8.x
30 |
31 | ### 新功能
32 |
33 | - 主题页支持水平方向划分内容区和回复区。
34 | - 选项页中新增用户标签管理。
35 | - Popup 中支持查看未读消息的数量。
36 | - 支持自动备份和同步配置数据。
37 | - 自动标注 30 天内注册的用户。
38 |
39 | ### 优化改进
40 |
41 | - 回复内容中默认不显示 @ 提及的用户名。
42 | - 默认不显示回复时间。
43 | - 新增更多对 v2ex.com 子域名的匹配。
44 | - 选项页支持自动跟随系统切换主题颜色。
45 | - 优化了选项页的响应式设计。
46 | - 整合了⌈热门回复⌋和⌈最近回复⌋的展示,新增⌈题主回复⌋。
47 |
48 | ---
49 |
50 | ## 1.7.x
51 |
52 | ### 新功能
53 |
54 | - 添加主题回复内容预览。
55 | - 新增生成分享图片功能。
56 | - 支持隐藏回复中用户名称。
57 | - 回复表情添加「bilibili / 小红书」流行表情。
58 |
59 | ### 优化改进
60 |
61 | - 美化主题编辑页。
62 |
63 | ---
64 |
65 | ## 1.5.x - 1.6.x
66 |
67 | ### 新功能
68 |
69 | - Popup 支持深色模式的颜色主题。
70 | - 添加了对 `cn.v2ex.com` 域名的识别。
71 | - 支持设置「用户标签」的展示形式。
72 | - 添加关闭「楼中楼」功能的选项。
73 | - 支持预览热议主题。
74 |
75 | ### 优化改进
76 |
77 | - 使用 [lucide icon](https://lucide.dev/icons/) 作为新的展示图标。
78 | - 支持在预览内容中展示附言。
79 | - 用户标签改进:支持单独给题主(OP)设置标签。
80 |
81 | ### 优化改进
82 |
83 | - 使用 [lucide icon](https://lucide.dev/icons/) 作为新的展示图标。
84 | - 支持在预览内容中展示附言。
85 |
86 | ---
87 |
88 | ## 1.4.x
89 |
90 | ### 新功能
91 |
92 | - 新增“稍后阅读”功能。
93 | - 支持预加载多页回复,让嵌套回复更完美。
94 | - 创建主题时也支持上传图片。
95 |
96 | ### 优化改进
97 |
98 | - 修复感谢功能状态与提示不同步的问题。
99 | - 修复 LOL 节点下文本颜色不兼容的问题。
100 | - 完善用户配置的备份与同步。
101 | - 回复时自动添加楼层号。
102 | - 收藏主题时无须刷新页面。
103 |
104 | ---
105 |
106 | ## 1.3.x
107 |
108 | ### 新功能
109 |
110 | - 新增同步和备份个人配置功能。
111 | - 新增设置用户标签功能。
112 | - 新增 SOV2EX 作为搜索选项。
113 |
114 | ### 优化改进
115 |
116 | - 优化用户信息卡片:鼠标悬浮头像弹出。
117 | - 浅色和深色模式现在可以自动跟随系统切换。
118 | - 支持设置是否自动折叠长回复。
119 | - 调整部分样式细节。
120 |
121 | ---
122 |
123 | ## 1.2.x
124 |
125 | ### 新功能和改进
126 |
127 | - 主题样式优化。
128 | - 便捷回复工具箱:文字转 Base64、上传图片。
129 | - 支持更多个性化配置。
130 | - 支持油猴脚本。
131 |
--------------------------------------------------------------------------------
/src/background/daily-check-in.ts:
--------------------------------------------------------------------------------
1 | import { StorageKey, V2EX } from '../constants'
2 | import type { DailyInfo } from '../types'
3 | import { getStorage, isSameDay, setStorage } from '../utils'
4 |
5 | const successText = '每日登录奖励已领取'
6 |
7 | const handleCheckedIn = async (htmlText: string) => {
8 | const matchedArr = htmlText.match(/已连续登录 (\d+) 天/)
9 |
10 | let checkInDays: number | undefined
11 |
12 | if (matchedArr) {
13 | const days = Number([...matchedArr].at(1))
14 | if (!Number.isNaN(days)) {
15 | checkInDays = days
16 | }
17 | }
18 |
19 | const dailyInfo: DailyInfo = { lastCheckInTime: Date.now(), checkInDays }
20 |
21 | await setStorage(StorageKey.Daily, dailyInfo)
22 | }
23 |
24 | export async function checkIn() {
25 | // 「自动签到」在每天早上 8 点后才生效。
26 | if (new Date().getHours() < 8) {
27 | return
28 | }
29 |
30 | const storage = await getStorage(false)
31 | const dailyInfo = storage[StorageKey.Daily]
32 | const lastCheckInTime = dailyInfo?.lastCheckInTime
33 |
34 | if (lastCheckInTime) {
35 | if (isSameDay(lastCheckInTime, Date.now())) {
36 | return
37 | }
38 | }
39 |
40 | const targetTextFragment = '/mission/daily/redeem'
41 | const targetUrl = `${V2EX.Origin}${targetTextFragment}`
42 |
43 | const res = await fetch(targetUrl, { headers: { Referer: V2EX.Origin } })
44 | const htmlPlainText = await res.text()
45 |
46 | const startIndex = htmlPlainText.indexOf(targetTextFragment)
47 |
48 | if (startIndex !== -1) {
49 | const endIndex = htmlPlainText.indexOf("'", startIndex + targetTextFragment.length)
50 |
51 | if (endIndex !== -1) {
52 | const matchedString = htmlPlainText.slice(startIndex, endIndex) // 拿到 /mission/daily/redeem?once=xxxxx
53 | const checkInUrl = `${V2EX.Origin}${matchedString}`
54 | const checkInResult = await fetch(checkInUrl, { headers: { Referer: `${V2EX.Origin}/mission/daily` } })
55 | const text = await checkInResult.text()
56 |
57 | if (text.includes(successText)) {
58 | await handleCheckedIn(text)
59 | }
60 | }
61 | } else {
62 | if (htmlPlainText.includes(successText)) {
63 | await handleCheckedIn(htmlPlainText)
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "v2ex_polish",
3 | "version": "0.1.0",
4 | "private": "true",
5 | "license": "UNLICENSED",
6 | "author": "LeoKu (https://github.com/Codennnn)",
7 | "scripts": {
8 | "dev": "run-p build:manifest watch run:chrome",
9 | "dev:firefox": "run-p watch run:firefox",
10 | "run:chrome": "web-ext run -t chromium --source-dir ./extension --chromium-profile ./chrome-profile --profile-create-if-missing --keep-profile-changes --start-url https://www.v2ex.com/",
11 | "run:firefox": "web-ext run --source-dir ./extension --start-url https://www.v2ex.com/",
12 | "build": "bun build:all && bun scripts/build.ts && bun pack:chrome && bun pack:firefox",
13 | "build:all": "run-p build:manifest build:style build:ext build:userscript",
14 | "build:userscript": "run-s output:css output:userscript",
15 | "build:ext": "tsup",
16 | "build:manifest": "bun scripts/build-manifest.ts",
17 | "build:style": "sass src/styles:extension/css --no-source-map --style=compressed",
18 | "pack:chrome": "web-ext build -s build-chrome -a build-chrome -o",
19 | "pack:firefox": "web-ext build -s build-firefox -a build-firefox -o",
20 | "output:userscript": "tsup src/user-scripts/index.ts --no-minify --outDir dist",
21 | "output:css": "bun src/user-scripts/write-style.mjs",
22 | "watch": "run-p watch:style watch:ext",
23 | "watch:ext": "bun build:ext --watch",
24 | "watch:style": "bun build:style --watch",
25 | "lint": "run-p lint:ts lint:es lint:style lint:prettier",
26 | "lint:ts": "tsc --noEmit --skipLibCheck",
27 | "lint:es": "eslint --config ./.eslintrc.cjs **/*.{js,ts}",
28 | "lint:prettier": "prettier --write **/*.{md,json}",
29 | "lint:style": "stylelint **/*.{css,scss}",
30 | "deps": "pnpm up --interactive --latest"
31 | },
32 | "dependencies": {
33 | "@floating-ui/dom": "1.4.5",
34 | "lucide": "0.445.0",
35 | "webext-patterns": "1.5.0"
36 | },
37 | "devDependencies": {
38 | "@codennnn/tsconfig": "^1.2.1",
39 | "@types/chrome": "^0.0.271",
40 | "@types/firefox-webext-browser": "^120.0.4",
41 | "@types/jquery": "^3.5.30",
42 | "@types/node": "^20.14.12",
43 | "bun": "^1.1.29",
44 | "npm-run-all": "^4.1.5",
45 | "postcss-scss": "^4.0.9",
46 | "prefer-code-style": "2.1.7",
47 | "sass": "^1.79.3",
48 | "stylelint": "^16.9.0",
49 | "tsup": "^8.3.0",
50 | "typescript": "^5.5.4",
51 | "web-ext": "^8.3.0"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/.github/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # 贡献指南
2 |
3 | 感谢您考虑为这个项目做出贡献!请先阅读以下内容,以确保贡献过程顺利。
4 |
5 | ## 项目结构
6 |
7 | ```bash
8 | ├── extension # 运行时的代码
9 | ├── src # 开发时的代码
10 | │ ├── background # 插件后台脚本
11 | │ ├── contents # 网页内容脚本
12 | │ ├── pages # 浏览器扩展页面相关的页面文件
13 | │ ├── styles # 浏览器扩展相关的样式文件
14 | │ ├── user-scripts # 油猴脚本相关
15 | │ ├── constants.ts # 常量
16 | │ ├── icons.ts # SVG 图标
17 | │ ├── services # API 服务
18 | │ ├── types.ts # TS 类型定义
19 | │ └── utils.ts # 工具函数
20 | ├── website # 产品官网(基于 Next.js)
21 | ├── scripts # 与项目构建相关的脚本
22 | └── tsup.config.ts # tsup 配置
23 | ```
24 |
25 | ## 开发运行
26 |
27 | ### 本地开发
28 |
29 | 本项目使用 [web-ext](https://github.com/mozilla/web-ext) 帮助开发,会在代码改动后自动重新加载扩展,所以不需要每次都在扩展程序页面中手动刷新扩展。
30 |
31 | #### 如果使用 web-ext,在运行 `pnpm dev` 后,会自动打开 Chrome 浏览器,并自动加载扩展。
32 |
33 | #### 如果不使用 web-ext,则可以遵循以下的开发流程:
34 |
35 | 1. `pnpm install` 安装依赖。
36 | 1. `pnpm dev` 启动本地开发服务器。
37 | 1. 打开 Chrome 浏览器,输入 `chrome://extensions/` 进入扩展程序页面。
38 | 1. 点击右上角的开发者模式,然后点击 `加载已解压的扩展程序`,选择 `extension` 文件夹。
39 | 1. 编辑 `src` 目录中的代码,保存文件后会自动编译。
40 | 1. 在扩展程序页面中,点击刷新按钮,接着再刷新目标页面查看效果。
41 |
42 | ### 生产构建
43 |
44 | 在发布前,进入 `/scripts/build-manifest.ts`,修改其中的 `version`,然后执行 `pnpm build`,这可以分别构建出浏览器(Chrome/Firefox)扩展和油猴脚本。
45 |
46 | `pnpm build` 其实包含了多条命令的执行,包括编译输出 JS 脚本、样式、打包产物。执行完这条命令之后,会在根目录下生成 `build-chrome` 和 `build-firefox` 目录,这两个目录下的 `v2ex_polish-[版本号].zip` 就是可以上传到扩展平台的最终产物。
47 |
48 | 此外,还会生成 `dist` 目录,其中包含了油猴脚本的 JS 脚本,可以直接粘贴到油猴脚本编辑器中,然后发布。
49 |
50 |
51 | 项目脚本解释:
52 |
53 | | 脚本名称 | 描述 |
54 | | ------------------- | ------------------------ |
55 | | `build:manifest` | 构建 manifest.json |
56 | | `build:style` | 构建浏览器扩展用到的样式 |
57 | | `build:ext` | 构建浏览器扩展 |
58 | | `build:userscript` | 构建油猴脚本 |
59 | | `pack:chrome` | 打包最终产物 |
60 | | `output:userscript` | 生成油猴脚本 |
61 | | `output:css` | 生成油猴脚本样式 |
62 |
63 |
64 |
65 | ### 运行官网
66 |
67 | ```bash
68 | cd website/ # 进入官网项目目录
69 | pnpm install && pnpm dev # 安装依赖、启动本地开发服务器
70 | ```
71 |
72 | ## 提交问题和请求功能
73 |
74 | 如果您在使用该项目时遇到了问题或需要某个功能,请先查看项目是否已有相应的记录。如果没有记录,请提交新的 [issue](https://github.com/coolpace/V2EX_Polish/issues)。
75 |
76 | ## 贡献代码
77 |
78 | 直接向本仓库提交 **pull request** 即可。本项目的维护者将根据 CONTRIBUTING.md 文件和提交的代码进行评估和审查。如果您的代码被接受,您将成为该项目的贡献者之一。如果您的代码被拒绝,我们将向您提供反馈意见。
79 |
80 | 以下是一些有助于您的代码被接受的建议:
81 |
82 | 1. 请确保您的代码符合项目的风格和规范。
83 | 2. 请确保您的代码已经经过充分的测试,并且可以成功编译。
84 | 3. 请将您的代码提交到新的分支上,并且使用清晰的 commit 信息。
85 |
--------------------------------------------------------------------------------
/src/styles/reset.css:
--------------------------------------------------------------------------------
1 | *,
2 | *::before,
3 | *::after {
4 | box-sizing: border-box;
5 | margin: 0;
6 | padding: 0;
7 | border-color: currentColor;
8 | border-style: solid;
9 | border-width: 0;
10 | }
11 |
12 | :where([hidden]:not([hidden='until-found'])) {
13 | display: none !important;
14 | }
15 |
16 | @supports not (min-block-size: 100dvb) {
17 | :where(html) {
18 | block-size: 100%;
19 | }
20 | }
21 |
22 | @media (prefers-reduced-motion: no-preference) {
23 | :where(html:focus-within) {
24 | scroll-behavior: smooth;
25 | }
26 | }
27 |
28 | :where(body) {
29 | font-family: system-ui, sans-serif;
30 | -webkit-font-smoothing: antialiased;
31 | line-height: 1.5;
32 | }
33 |
34 | :where(input, button, textarea, select, textarea) {
35 | margin: 0;
36 | padding: 0;
37 | font-family: inherit;
38 | font-size: 100%;
39 | font-weight: inherit;
40 | line-height: inherit;
41 | color: inherit;
42 | }
43 |
44 | :where(textarea) {
45 | resize: vertical;
46 | }
47 |
48 | :where(button, label, select, summary, [role='button'], [role='option']) {
49 | cursor: pointer;
50 | }
51 |
52 | :where(button, [type='button'], [type='reset'], [type='submit']) {
53 | appearance: button;
54 | background-color: transparent;
55 | background-image: none;
56 | }
57 |
58 | :where(:disabled) {
59 | cursor: not-allowed;
60 | }
61 |
62 | :where(label:has(> input:disabled), label:has(+ input:disabled)) {
63 | cursor: not-allowed;
64 | }
65 |
66 | :where(h1, h2, h3, h4, h5, h6) {
67 | font-size: inherit;
68 | font-weight: inherit;
69 | }
70 |
71 | :where(a) {
72 | color: inherit;
73 | text-decoration: none;
74 | }
75 |
76 | :where(ul, ol) {
77 | margin: 0;
78 | padding: 0;
79 | list-style: none;
80 | }
81 |
82 | :where(fieldset) {
83 | margin: 0;
84 | padding: 0;
85 | }
86 |
87 | :where(legend) {
88 | padding: 0;
89 | }
90 |
91 | :where(img, svg, video, canvas, audio, iframe, embed, object) {
92 | display: block;
93 | }
94 |
95 | :where(img, picture, svg) {
96 | max-inline-size: 100%;
97 | block-size: auto;
98 | }
99 |
100 | :where(p, h1, h2, h3, h4, h5, h6) {
101 | overflow-wrap: break-word;
102 | }
103 |
104 | :where(h1, h2, h3) {
105 | line-height: calc(1em + 0.5rem);
106 | }
107 |
108 | :where(hr) {
109 | overflow: visible;
110 | height: 0;
111 | block-size: 0;
112 | color: inherit;
113 | border: none;
114 | border-block-start: 1px solid;
115 | }
116 |
117 | :where(:focus-visible) {
118 | outline: 2px solid var(--focus-color, Highlight);
119 | outline-offset: 2px;
120 | }
121 |
--------------------------------------------------------------------------------
/website/src/app/api/share/route.ts:
--------------------------------------------------------------------------------
1 | import { type NextRequest, NextResponse } from 'next/server'
2 | import { load } from 'cheerio'
3 | import { array, type Input, nullish, number, object, parse, string } from 'valibot'
4 |
5 | const RequestDataSchema = object({
6 | topicId: string(),
7 | })
8 |
9 | export type RequestData = Input
10 |
11 | const TopicInfoSchema = object({
12 | title: string(),
13 | content: nullish(string()),
14 | supplements: nullish(array(object({ content: string() }))),
15 | member: object({
16 | username: string(),
17 | avatar: string(),
18 | }),
19 | time: object({
20 | year: number(),
21 | month: number(),
22 | day: number(),
23 | }),
24 | url: string(),
25 | })
26 |
27 | export type TopicInfo = Input
28 |
29 | export const enum ResponseCode {
30 | Success,
31 | /** 由于不存在主题或需要授权访问,无法爬取到主题内容。 */
32 | NotFound,
33 | }
34 |
35 | export type ResponseJson =
36 | | {
37 | code: ResponseCode.Success
38 | data: TopicInfo
39 | }
40 | | {
41 | code: ResponseCode.NotFound
42 | data: null
43 | }
44 |
45 | export async function POST(request: NextRequest) {
46 | const body = parse(RequestDataSchema, await request.json())
47 |
48 | const url = `https://www.v2ex.com/t/${body.topicId}`
49 | const res = await fetch(url, { method: 'GET' })
50 | const htmlText = await res.text()
51 |
52 | const $ = load(htmlText)
53 | const title = $('.header h1').text()
54 | const content = $('.topic_content').html()
55 |
56 | if (!title && !content) {
57 | return NextResponse.json(
58 | { code: ResponseCode.NotFound, data: null },
59 | { status: 200 }
60 | )
61 | }
62 |
63 | let supplements: TopicInfo['supplements'] = null
64 | const $subtles = $('.subtle .topic_content')
65 | if ($subtles.length > 1) {
66 | supplements = []
67 | $subtles.each((_: number, ele: any) => {
68 | supplements!.push({ content: $(ele).html()! })
69 | })
70 | }
71 |
72 | const timeString = $('.header .gray span[title^="20"]').prop('title')
73 | const date = new Date(timeString)
74 | const time = {
75 | year: date.getFullYear(),
76 | month: date.getMonth() + 1,
77 | day: date.getDate(),
78 | }
79 | const member = {
80 | username: $('.header .gray a[href^="/member"]').text(),
81 | avatar: $('.header .avatar').prop('src'),
82 | }
83 |
84 | const data = parse(TopicInfoSchema, {
85 | title,
86 | content,
87 | supplements,
88 | member,
89 | time,
90 | url,
91 | })
92 |
93 | return NextResponse.json({ code: ResponseCode.Success, data }, { status: 200 })
94 | }
95 |
--------------------------------------------------------------------------------
/src/pages/popup.helper.ts:
--------------------------------------------------------------------------------
1 | import type { ReadingItem, Topic } from '../types'
2 | import { escapeHTML } from '../utils'
3 | import { TabId } from './popup.var'
4 |
5 | export function isTabId(tabId: any): tabId is TabId {
6 | if (typeof tabId === 'string') {
7 | if (
8 | /* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
9 | tabId === TabId.Reading ||
10 | tabId === TabId.Hot ||
11 | tabId === TabId.Latest ||
12 | tabId === TabId.Message ||
13 | tabId === TabId.Feature ||
14 | tabId === TabId.Setting
15 | /* eslint-enable @typescript-eslint/no-unsafe-enum-comparison */
16 | ) {
17 | return true
18 | }
19 | }
20 | return false
21 | }
22 |
23 | export const generateReadingItmes = (items: ReadingItem[]) => {
24 | return items
25 | .map((topic) => {
26 | const escapedText = $('').text(topic.content).html()
27 |
28 | return `
29 |
39 | `
40 | })
41 | .join('')
42 | }
43 |
44 | export const generateTopicItmes = (topics: Topic[]) => {
45 | return topics
46 | .map((topic) => {
47 | const escapedText = $('
').text(topic.content).html()
48 |
49 | return `
50 |
51 |
52 | ${escapeHTML(topic.title)}
53 | ${escapedText}
54 |
55 |
56 | `
57 | })
58 | .join('')
59 | }
60 |
61 | /**
62 | * 计算 local storage 的数据大小。
63 | */
64 | export function calculateLocalStorageSize(): number {
65 | let total = 0
66 |
67 | for (let i = 0; i < window.localStorage.length; i++) {
68 | const key = window.localStorage.key(i)
69 | if (key) {
70 | const value = window.localStorage.getItem(key)
71 | if (value) {
72 | total += key.length + value.length
73 | }
74 | }
75 | }
76 |
77 | return total
78 | }
79 |
80 | /**
81 | * 将字节数格式化易读的单位。
82 | */
83 | export function formatSizeUnits(bytes: number): string {
84 | const units = ['bytes', 'KB', 'MB', 'GB', 'TB']
85 | let i = 0
86 |
87 | while (bytes >= 1024 && i < 4) {
88 | bytes /= 1024
89 | i++
90 | }
91 |
92 | return bytes.toFixed(2) + ' ' + units[i]
93 | }
94 |
--------------------------------------------------------------------------------
/website/src/app/blog/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 | import Image from 'next/image'
3 | import Link from 'next/link'
4 | import { allBlogs } from 'contentlayer/generated'
5 | import { format, parseISO } from 'date-fns'
6 |
7 | import { Article } from '~/components/Article'
8 | import { PageContainer } from '~/components/PageContainer'
9 | import { HOST } from '~/utils'
10 |
11 | export const generateStaticParams = () => {
12 | return allBlogs.map((blog) => ({
13 | slug: blog.slug,
14 | }))
15 | }
16 |
17 | export const generateMetadata = ({ params }: { params: { slug: string } }): Metadata => {
18 | const blog = allBlogs.find((blog) => blog.slug === params.slug)
19 |
20 | if (blog) {
21 | return {
22 | title: blog.title,
23 | openGraph: {
24 | type: 'article',
25 | title: blog.title,
26 | description: '专为 V2EX 用户设计的浏览器插件,提供了丰富的扩展功能为你带来出色的体验。',
27 | url: `${HOST}/blog/${blog.slug}`,
28 | images: `${HOST}/api/og/blog?title=${blog.title}`,
29 | },
30 | }
31 | }
32 |
33 | return {}
34 | }
35 |
36 | export default function BlogPage({ params }: { params: { slug: string } }) {
37 | const blog = allBlogs.find((blog) => blog.slug === params.slug)
38 |
39 | if (!blog) {
40 | return
41 | }
42 |
43 | return (
44 |
45 |
51 |
52 |
55 | {blog.title}
56 |
57 |
58 |
62 |
69 | {blog.author.name}
70 |
71 |
74 |
75 | >
76 | }
77 | >
78 |
79 |
80 |
81 | )
82 | }
83 |
--------------------------------------------------------------------------------
/website/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | /* 解决 tailwindCSS 和 radixCSS 顺序冲突的问题: https://github.com/radix-ui/themes/issues/109 */
2 | @import url('tailwindcss/base');
3 | @import url('@radix-ui/themes/styles.css');
4 |
5 | @tailwind components;
6 | @tailwind utilities;
7 |
8 | :root {
9 | --v2p-color-main-50: #f7f9fb;
10 | --v2p-color-main-100: #f1f5f9;
11 | --v2p-color-main-200: #e2e8f0;
12 | --v2p-color-main-300: #cbd5e1;
13 | --v2p-color-main-350: #94a3b8cc;
14 | --v2p-color-main-400: #94a3b8;
15 | --v2p-color-main-500: #64748b;
16 | --v2p-color-main-600: #475569;
17 | --v2p-color-main-700: #334155;
18 | --v2p-color-main-800: #1e293b;
19 | --v2p-color-accent-50: #ecfdf5;
20 | --v2p-color-accent-100: #d1fae5;
21 | --v2p-color-accent-200: #a7f3d0;
22 | --v2p-color-accent-300: #6ee7b7;
23 | --v2p-color-accent-400: #34d399;
24 | --v2p-color-accent-500: #10b981;
25 | --v2p-color-accent-600: #059669;
26 | --v2p-color-content: #fff;
27 | --v2p-box-shadow: 0 3px 5px 0 rgb(0 0 0 / 4%);
28 | --v2p-color-border: var(--v2p-color-main-200);
29 | --v2p-color-foreground: var(--v2p-color-main-800);
30 | --v2p-color-background: #f2f3f5;
31 | --v2p-color-bg-input: var(--v2p-color-main-50);
32 | --v2p-color-bg-subtle: rgb(236 253 245 / 90%);
33 |
34 | .radix-themes {
35 | --cursor-button: pointer;
36 | }
37 | }
38 |
39 | .theme-dark {
40 | --v2p-color-main-50: #1c2127;
41 | --v2p-color-main-100: #2d333b;
42 | --v2p-color-main-200: #374151;
43 | --v2p-color-main-300: #374151;
44 | --v2p-color-main-350: #6b7280cc;
45 | --v2p-color-main-400: #6b7280;
46 | --v2p-color-main-500: #9ca3af;
47 | --v2p-color-main-600: #9ca3af;
48 | --v2p-color-main-700: #d1d5db;
49 | --v2p-color-main-800: #e5e7eb;
50 | --v2p-color-accent-50: #064e3b;
51 | --v2p-color-accent-100: #065f46;
52 | --v2p-color-accent-200: #047857;
53 | --v2p-color-accent-300: #059669;
54 | --v2p-color-accent-400: #10b981;
55 | --v2p-color-accent-500: #34d399;
56 | --v2p-color-accent-600: #6ee7b7;
57 | --v2p-color-content: #22272e;
58 | --v2p-color-border: #444c56;
59 | --v2p-color-foreground: #adbac7;
60 | --v2p-color-background: #1c2128;
61 | --v2p-color-bg-input: var(--v2p-color-background);
62 | --v2p-color-bg-subtle: rgb(6 78 59 / 30%);
63 | }
64 |
65 | ::selection {
66 | color: currentcolor;
67 | background-color: rgb(30 41 59 / 10%);
68 | }
69 |
70 | .text-with-shadow {
71 | text-shadow: 2px 2px 0 var(--v2p-color-main-300);
72 | }
73 |
74 | @media (width >= 768px) {
75 | .text-with-shadow {
76 | text-shadow: 2px 4px 0 var(--v2p-color-main-300);
77 | }
78 | }
79 |
80 | .text-polish {
81 | text-shadow: 2px 4px 0 var(--v2p-color-main-600);
82 | -webkit-text-fill-color: #fff;
83 | -webkit-text-stroke-color: var(--v2p-color-main-800);
84 | -webkit-text-stroke-width: 2px;
85 | }
86 |
--------------------------------------------------------------------------------
/src/contents/topic/layout.ts:
--------------------------------------------------------------------------------
1 | import { createElement, PanelRight, PanelTop } from 'lucide'
2 |
3 | import { StorageKey } from '../../constants'
4 | import { getStorageSync } from '../../utils'
5 | import { $main, $topicContentBox, $wrapperContent } from '../globals'
6 |
7 | const $layoutToggle = $('
')
8 |
9 | const iconLayoutV = createElement(PanelTop)
10 | iconLayoutV.setAttribute('width', '100%')
11 | iconLayoutV.setAttribute('height', '100%')
12 |
13 | const iconLayoutH = createElement(PanelRight)
14 | iconLayoutH.setAttribute('width', '100%')
15 | iconLayoutH.setAttribute('height', '100%')
16 |
17 | /** 将主题页切换为水平布局。 */
18 | const switchToHorizontalLayout = () => {
19 | if (!$wrapperContent.hasClass('v2p-content-layout')) {
20 | const $divider1 = $main.find('> .sep20:first-of-type')
21 | const $leftGroup = $divider1.add($divider1.next('.box'))
22 | const $leftSide = $('')
23 | $leftGroup.wrapAll($leftSide)
24 | const $content = $leftGroup.find('> .cell')
25 | $content.add($content.nextAll('.subtle')).wrapAll('
')
26 |
27 | const $divider2 = $main.find('.sep20:nth-of-type(2)')
28 | const $rightGroup = $divider2.add($divider2.nextAll())
29 | $rightGroup.wrapAll('
')
30 |
31 | $wrapperContent.addClass('v2p-content-layout')
32 | $main.addClass('v2p-horizontal-layout')
33 | }
34 |
35 | $layoutToggle.html(iconLayoutV)
36 | $layoutToggle.attr('title', '切换为垂直布局')
37 | $('.v2p-reply-tool-layout').text('切换为垂直布局')
38 | }
39 |
40 | /** 将主题页切换为垂直布局。 */
41 | const switchToVerticalLayout = () => {
42 | if ($wrapperContent.hasClass('v2p-content-layout')) {
43 | $wrapperContent.removeClass('v2p-content-layout')
44 | $main.removeClass('v2p-horizontal-layout')
45 |
46 | $('.v2p-left-side-content').children().unwrap()
47 |
48 | $('.v2p-left-side').children().unwrap()
49 | $('.v2p-right-side').children().unwrap()
50 | }
51 |
52 | $layoutToggle.html(iconLayoutH)
53 | $layoutToggle.attr('title', '切换为水平布局')
54 | $('.v2p-reply-tool-layout').text('切换为水平布局')
55 | }
56 |
57 | export function toggleTopicLayout() {
58 | if ($wrapperContent.hasClass('v2p-content-layout')) {
59 | switchToVerticalLayout()
60 | } else {
61 | switchToHorizontalLayout()
62 | }
63 | }
64 |
65 | /**
66 | * 控制主题布局水平分屏显示。
67 | */
68 | export function handleLayout() {
69 | const storage = getStorageSync()
70 | const options = storage[StorageKey.Options]
71 |
72 | if (options.reply.layout === 'auto') {
73 | const contentHeight = $topicContentBox.height()
74 |
75 | if (typeof contentHeight === 'number' && contentHeight >= 600) {
76 | switchToHorizontalLayout()
77 | } else {
78 | switchToVerticalLayout()
79 | }
80 | } else {
81 | if (options.reply.layout === 'horizontal') {
82 | switchToHorizontalLayout()
83 | } else {
84 | switchToVerticalLayout()
85 | }
86 | }
87 |
88 | $layoutToggle.on('click', () => {
89 | toggleTopicLayout()
90 | })
91 |
92 | $('.tools').prepend($layoutToggle)
93 | }
94 |
--------------------------------------------------------------------------------
/website/src/components/screens/ScreenHome.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Content,
4 | Header,
5 | RightSide,
6 | TopicItem,
7 | TopicItemRight,
8 | UserPanel,
9 | Wrapper,
10 | } from '~/components/ui'
11 |
12 | export function ScreenHome() {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 | 技术
21 | 创意
22 | 好玩
23 | Apple
24 | 酷工作
25 | 交易
26 |
27 | 城市
28 |
29 | 问与答
30 | 最热
31 | 全部
32 | R2
33 | 节点
34 | 关注
35 |
36 |
37 |
38 | 北京
39 | 上海
40 | 深圳
41 | 广州
42 | 杭州
43 | 成都
44 | 新加波
45 | 纽约
46 | 洛杉矶
47 |
48 |
49 |
50 |
58 |
66 |
74 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 | 今日热议主题
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | )
98 | }
99 |
--------------------------------------------------------------------------------
/src/components/image-upload.ts:
--------------------------------------------------------------------------------
1 | import { uploadImage } from '../services'
2 |
3 | interface ImageUploadProps {
4 | $wrapper: JQuery
5 | $input?: JQuery
6 | /** 向文本输入框中插入占位文本。 */
7 | insertText: (text: string) => void
8 | /** 替换占位文本,当 replace 为非空字符串时,表示图片上传成功,参数值为图片链接。 */
9 | replaceText: (find: string, replace: string) => void
10 | }
11 |
12 | const uploadTip = '选择、粘贴、拖放上传图片。'
13 |
14 | interface ImageUploadControl {
15 | uploadBar: JQuery
16 | }
17 |
18 | export function bindImageUpload(props: ImageUploadProps): ImageUploadControl {
19 | const { $wrapper, $input, insertText, replaceText } = props
20 |
21 | const $uploadBar = $(`
${uploadTip}
`)
22 |
23 | const handleUploadImage = (file: File) => {
24 | const placeholder = '[上传图片中...]'
25 | insertText(` ${placeholder} `)
26 | $uploadBar.addClass('v2p-reply-upload-bar-disabled').text('正在上传图片...')
27 |
28 | uploadImage(file)
29 | .then((imgLink) => {
30 | replaceText(placeholder, imgLink)
31 | })
32 | .catch(() => {
33 | replaceText(placeholder, '')
34 |
35 | window.alert('❌ 上传图片失败,请打开控制台查看原因')
36 | })
37 | .finally(() => {
38 | $uploadBar.removeClass('v2p-reply-upload-bar-disabled').text(uploadTip)
39 | })
40 | }
41 |
42 | const handleClickUploadImage = () => {
43 | const imgInput = document.createElement('input')
44 |
45 | imgInput.style.display = 'none'
46 | imgInput.type = 'file'
47 | imgInput.accept = 'image/*'
48 |
49 | imgInput.addEventListener('change', () => {
50 | const selectedFile = imgInput.files?.[0]
51 |
52 | if (selectedFile) {
53 | handleUploadImage(selectedFile)
54 | }
55 | })
56 |
57 | imgInput.click()
58 | }
59 |
60 | // 粘贴图片并上传的功能。
61 | document.addEventListener('paste', (ev) => {
62 | if (!(ev instanceof ClipboardEvent)) {
63 | return
64 | }
65 |
66 | if ($input && !$input.get(0)?.matches(':focus')) {
67 | return
68 | }
69 |
70 | const items = ev.clipboardData?.items
71 |
72 | if (!items) {
73 | return
74 | }
75 |
76 | // 查找属于图像类型的数据项。
77 | const imageItem = Array.from(items).find((item) => item.type.includes('image'))
78 |
79 | if (imageItem) {
80 | const file = imageItem.getAsFile()
81 |
82 | if (file) {
83 | handleUploadImage(file)
84 | }
85 | }
86 | })
87 |
88 | $wrapper.get(0)?.addEventListener('drop', (ev) => {
89 | ev.preventDefault()
90 |
91 | if (!(ev instanceof DragEvent)) {
92 | return
93 | }
94 |
95 | const file = ev.dataTransfer?.files[0]
96 |
97 | if (file) {
98 | handleUploadImage(file)
99 | }
100 | })
101 |
102 | $('.flex-one-row:last-of-type > .gray').text('')
103 |
104 | $uploadBar.on('click', () => {
105 | if (!$uploadBar.hasClass('v2p-reply-upload-bar-disabled')) {
106 | handleClickUploadImage()
107 | }
108 | })
109 |
110 | $wrapper.append($uploadBar)
111 |
112 | return {
113 | uploadBar: $uploadBar,
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/website/src/app/share/components/ShareCardThemeBasic.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar } from '@radix-ui/themes'
2 |
3 | import type { TopicInfo } from '~/app/api/share/route'
4 |
5 | interface ShareCardProps {
6 | avatarRef: React.RefObject
7 | topicInfo: TopicInfo
8 | showSubtle?: boolean
9 | showQRCode?: boolean
10 | }
11 |
12 | export function ShareCardThemeBasic(props: ShareCardProps) {
13 | const { topicInfo, avatarRef, showSubtle, showQRCode } = props
14 |
15 | return (
16 |
17 |
18 |
V2EX
19 |
20 |
{topicInfo.title}
21 |
22 |
23 |
31 |
{topicInfo.member.username}
32 |
33 |
34 | {topicInfo.time.year}-{topicInfo.time.month}-{topicInfo.time.day}
35 |
36 |
37 |
38 | {topicInfo.content && (
39 | <>
40 |
41 |
42 |
54 |
55 |
56 | {showSubtle &&
57 | topicInfo.supplements?.map((item, idx) => {
58 | return (
59 |
60 |
61 |
62 | )
63 | })}
64 |
65 | >
66 | )}
67 |
68 | {showQRCode && (
69 |
70 |
71 |
75 | {/* eslint-disable @next/next/no-img-element */}
76 |

80 |
81 |
82 | )}
83 |
84 |
85 | )
86 | }
87 |
--------------------------------------------------------------------------------
/src/components/modal.ts:
--------------------------------------------------------------------------------
1 | import { createButton } from './button'
2 |
3 | interface ModalElements {
4 | $mask: JQuery
5 | $main: JQuery
6 | $header: JQuery
7 | $container: JQuery
8 | $title: JQuery
9 | $actions: JQuery
10 | $content: JQuery
11 | }
12 |
13 | interface ModalControl extends ModalElements {
14 | open: () => void
15 | close: () => void
16 | }
17 |
18 | interface CreateModalProps {
19 | root?: JQuery
20 | title?: string
21 | onMount?: (elements: ModalElements) => void
22 | onOpen?: (elements: ModalElements) => void
23 | onClose?: (elements: ModalElements) => void
24 | }
25 |
26 | /**
27 | * 创建 modal 框。
28 | */
29 | export function createModal(props: CreateModalProps): ModalControl {
30 | const { root, title, onMount, onOpen, onClose } = props
31 |
32 | const $mask = $('')
33 |
34 | const $content = $('
')
35 |
36 | const $closeBtn = createButton({
37 | children: '关闭
Esc',
38 | className: 'v2p-modal-close-btn',
39 | })
40 |
41 | const $title = $(`
${title ?? ''}
`)
42 |
43 | const $actions = $('
').append($closeBtn)
44 |
45 | const $header = $('