├── src
├── Markdown.css
├── infrastructure
│ ├── .gitignore
│ ├── const.js
│ ├── appicon
│ │ ├── hole.png
│ │ ├── score.png
│ │ ├── dropdown.png
│ │ ├── homepage.png
│ │ ├── imasugu.png
│ │ ├── syllabus.png
│ │ ├── imasugu_rev.png
│ │ ├── course_survey.png
│ │ └── dropdown_rev.png
│ ├── global.css
│ ├── functions.js
│ ├── elevator.js
│ ├── widgets.css
│ └── widgets.js
├── .DS_Store
├── old_infrastructure
│ ├── const.js
│ ├── global.css
│ ├── functions.js
│ └── widgets.css
├── login.css
├── PressureHelper.css
├── index.js
├── App.css
├── Config.css
├── color_picker.js
├── ErrorBoundary.js
├── Markdown.js
├── Common.css
├── Title.css
├── index.css
├── Sidebar.js
├── Message.js
├── text_splitter.js
├── AudioWidget.js
├── UserAction.css
├── PressureHelper.js
├── Sidebar.css
├── registerServiceWorker.js
├── flows_api.js
├── cache.js
├── Title.js
├── App.js
├── delete_account.js
├── Flows.css
├── Config.js
├── Common.js
└── login.js
├── .gitignore
├── .DS_Store
├── public
├── static
│ ├── bg
│ │ ├── bj.jpg
│ │ ├── gbp.jpg
│ │ ├── sif.jpg
│ │ ├── eriri.jpg
│ │ ├── cyberpunk.jpg
│ │ ├── minecraft.jpg
│ │ └── yurucamp.jpg
│ ├── favicon
│ │ ├── 180.png
│ │ ├── 192.png
│ │ └── 256.png
│ ├── abcsx
│ │ ├── icomoon.ttf
│ │ ├── icomoon.woff
│ │ ├── icomoon.css
│ │ └── icomoon.svg
│ ├── fonts_9
│ │ ├── icomoon.eot
│ │ ├── icomoon.ttf
│ │ ├── icomoon.woff
│ │ └── icomoon.css
│ ├── splash
│ │ ├── 1242x2208.png
│ │ ├── 1668x2388.png
│ │ ├── 2388x1668.png
│ │ └── 750x1334.png
│ └── manifest.json
└── index.html
├── .gitmodules
├── .prettierrc.js
├── .env
├── .eslintrc
├── package.json
├── README.md
└── .github
└── workflows
└── deploy.yml
/src/Markdown.css:
--------------------------------------------------------------------------------
1 | .hljs {
2 | white-space: pre-wrap;
3 | }
--------------------------------------------------------------------------------
/src/infrastructure/.gitignore:
--------------------------------------------------------------------------------
1 | /.idea/
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.idea/
2 | node_modules/
3 | /build/
4 | build.*
5 |
--------------------------------------------------------------------------------
/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/.DS_Store
--------------------------------------------------------------------------------
/src/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/src/.DS_Store
--------------------------------------------------------------------------------
/src/infrastructure/const.js:
--------------------------------------------------------------------------------
1 | export const HOLE_API_ROOT=process.env.REACT_APP_API_ROOT;
2 |
--------------------------------------------------------------------------------
/src/old_infrastructure/const.js:
--------------------------------------------------------------------------------
1 | export const API_ROOT=process.env.REACT_APP_API_ROOT;
2 |
--------------------------------------------------------------------------------
/public/static/bg/bj.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/bg/bj.jpg
--------------------------------------------------------------------------------
/public/static/bg/gbp.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/bg/gbp.jpg
--------------------------------------------------------------------------------
/public/static/bg/sif.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/bg/sif.jpg
--------------------------------------------------------------------------------
/public/static/bg/eriri.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/bg/eriri.jpg
--------------------------------------------------------------------------------
/public/static/bg/cyberpunk.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/bg/cyberpunk.jpg
--------------------------------------------------------------------------------
/public/static/bg/minecraft.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/bg/minecraft.jpg
--------------------------------------------------------------------------------
/public/static/bg/yurucamp.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/bg/yurucamp.jpg
--------------------------------------------------------------------------------
/public/static/favicon/180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/favicon/180.png
--------------------------------------------------------------------------------
/public/static/favicon/192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/favicon/192.png
--------------------------------------------------------------------------------
/public/static/favicon/256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/favicon/256.png
--------------------------------------------------------------------------------
/public/static/abcsx/icomoon.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/abcsx/icomoon.ttf
--------------------------------------------------------------------------------
/public/static/abcsx/icomoon.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/abcsx/icomoon.woff
--------------------------------------------------------------------------------
/public/static/fonts_9/icomoon.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/fonts_9/icomoon.eot
--------------------------------------------------------------------------------
/public/static/fonts_9/icomoon.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/fonts_9/icomoon.ttf
--------------------------------------------------------------------------------
/public/static/fonts_9/icomoon.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/fonts_9/icomoon.woff
--------------------------------------------------------------------------------
/public/static/splash/1242x2208.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/splash/1242x2208.png
--------------------------------------------------------------------------------
/public/static/splash/1668x2388.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/splash/1668x2388.png
--------------------------------------------------------------------------------
/public/static/splash/2388x1668.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/splash/2388x1668.png
--------------------------------------------------------------------------------
/public/static/splash/750x1334.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/public/static/splash/750x1334.png
--------------------------------------------------------------------------------
/src/infrastructure/appicon/hole.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/src/infrastructure/appicon/hole.png
--------------------------------------------------------------------------------
/src/infrastructure/appicon/score.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/src/infrastructure/appicon/score.png
--------------------------------------------------------------------------------
/src/infrastructure/appicon/dropdown.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/src/infrastructure/appicon/dropdown.png
--------------------------------------------------------------------------------
/src/infrastructure/appicon/homepage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/src/infrastructure/appicon/homepage.png
--------------------------------------------------------------------------------
/src/infrastructure/appicon/imasugu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/src/infrastructure/appicon/imasugu.png
--------------------------------------------------------------------------------
/src/infrastructure/appicon/syllabus.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/src/infrastructure/appicon/syllabus.png
--------------------------------------------------------------------------------
/src/login.css:
--------------------------------------------------------------------------------
1 | .thuhole-login-popup p {
2 | }
3 |
4 | .margin-popup {
5 | padding-left: 10px;
6 | padding-right: 10px;
7 | }
8 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "src/react-lazyload"]
2 | path = src/react-lazyload
3 | url = https://github.com/pkuhollow/react-lazyload.git
4 |
--------------------------------------------------------------------------------
/src/infrastructure/appicon/imasugu_rev.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/src/infrastructure/appicon/imasugu_rev.png
--------------------------------------------------------------------------------
/src/infrastructure/appicon/course_survey.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/src/infrastructure/appicon/course_survey.png
--------------------------------------------------------------------------------
/src/infrastructure/appicon/dropdown_rev.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkuhollow/webhole/HEAD/src/infrastructure/appicon/dropdown_rev.png
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | trailingComma: 'all',
3 | tabWidth: 2,
4 | semi: true,
5 | singleQuote: true,
6 | endOfLine: 'auto'
7 | };
8 |
--------------------------------------------------------------------------------
/src/PressureHelper.css:
--------------------------------------------------------------------------------
1 | .pressure-box {
2 | border: 500px /* also change js! */ solid orange;
3 | position: fixed;
4 | margin: auto;
5 | z-index: 100;
6 | pointer-events: none;
7 | }
8 |
9 | .pressure-box-empty {
10 | visibility: hidden;
11 | }
12 |
13 | .pressure-box-fired {
14 | border-color: orangered;
15 | pointer-events: initial !important;
16 | }
--------------------------------------------------------------------------------
/public/static/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "树洞",
3 | "name": "树洞",
4 | "icons": [
5 | {
6 | "src": "favicon/256.png",
7 | "sizes": "256x256",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "favicon/192.png",
12 | "sizes": "192x192",
13 | "type": "image/png"
14 | }
15 | ],
16 | "start_url": "../",
17 | "display": "standalone",
18 | "theme_color": "#333333",
19 | "background_color": "#333333"
20 | }
21 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import { ErrorBoundary } from './ErrorBoundary';
6 | //import {elevate} from './infrastructure/elevator';
7 | import registerServiceWorker from './registerServiceWorker';
8 |
9 | //elevate();
10 |
11 | ReactDOM.render(
12 |
13 |
14 | ,
15 | document.getElementById('root'),
16 | );
17 | registerServiceWorker();
18 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .flows-anim-exit {
2 | opacity: 1;
3 | transform: unset;
4 | }
5 |
6 | .flows-anim-exit-active {
7 | opacity: 0;
8 | transform: translateY(1.5em) scaleX(.9);
9 | transition: opacity .1s ease-out, transform .1s ease-out;
10 | }
11 |
12 | .flows-anim-enter-active, .flows-anim-appear-active {
13 | opacity: 1;
14 | transform: unset;
15 | transition: opacity .1s ease-out, transform .1s ease-out;
16 | }
17 |
18 | .flows-anim-enter, .flows-anim-appear {
19 | opacity: 0;
20 | transform: translateY(-1em);
21 | }
22 |
--------------------------------------------------------------------------------
/src/Config.css:
--------------------------------------------------------------------------------
1 | .config-ui-header {
2 | text-align: center;
3 | top: 1em;
4 | position: sticky;
5 | }
6 |
7 | .config-description {
8 | font-size: 0.75em;
9 | }
10 |
11 | .config-select {
12 | height: 2em;
13 | }
14 |
15 | .config-textarea {
16 | margin-top: 0.5em;
17 | width: 100%;
18 | max-width: 100%;
19 | min-width: 100%;
20 | height: 7em;
21 | min-height: 2em;
22 | }
23 |
24 | .bg-preview {
25 | height: 18em;
26 | width: 32em;
27 | max-height: 60vh;
28 | max-width: 100%;
29 | margin: .5em auto 1em;
30 | box-shadow: 0 1px 5px rgba(0,0,0,.4);
31 | }
32 |
--------------------------------------------------------------------------------
/src/infrastructure/global.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --foreground-dark: hsl(0,0%,93%);
3 | }
4 |
5 | body {
6 | margin: 0;
7 | padding: 0;
8 | overflow-x: hidden;
9 | text-size-adjust: 100%;
10 | }
11 |
12 | body, textarea, pre {
13 | font-family: 'Segoe UI', '微软雅黑', 'Microsoft YaHei', sans-serif;
14 | }
15 |
16 | * {
17 | box-sizing: border-box;
18 | word-wrap: break-word;
19 | -webkit-overflow-scrolling: touch;
20 | }
21 |
22 | p, pre {
23 | margin: 0;
24 | }
25 |
26 | a {
27 | text-decoration: none;
28 | cursor: pointer;
29 | }
30 |
31 | pre {
32 | white-space: pre-line;
33 | }
34 |
35 | code {
36 | font-family: Consolas, Courier, monospace;
37 | }
--------------------------------------------------------------------------------
/src/old_infrastructure/global.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --foreground-dark: hsl(0,0%,93%);
3 | }
4 |
5 | body {
6 | margin: 0;
7 | padding: 0;
8 | overflow-x: hidden;
9 | text-size-adjust: 100%;
10 | }
11 |
12 | body, textarea, pre {
13 | font-family: 'Segoe UI', '微软雅黑', 'Microsoft YaHei', sans-serif;
14 | }
15 |
16 | * {
17 | box-sizing: border-box;
18 | word-wrap: break-word;
19 | -webkit-overflow-scrolling: touch;
20 | }
21 |
22 | p, pre {
23 | margin: 0;
24 | }
25 |
26 | a {
27 | text-decoration: none;
28 | cursor: pointer;
29 | }
30 |
31 | pre {
32 | white-space: pre-line;
33 | }
34 |
35 | code {
36 | font-family: Consolas, Courier, monospace;
37 | }
--------------------------------------------------------------------------------
/src/color_picker.js:
--------------------------------------------------------------------------------
1 | // https://martin.ankerl.com/2009/12/09/how-to-create-random-colors-programmatically/
2 |
3 | const golden_ratio_conjugate = 0.618033988749895;
4 |
5 | export class ColorPicker {
6 | constructor() {
7 | this.names = {};
8 | this.current_h = Math.random();
9 | }
10 |
11 | get(name) {
12 | name = name.toLowerCase();
13 | if (name === '洞主') return ['hsl(0,0%,97%)', 'hsl(0,0%,16%)'];
14 |
15 | if (!this.names[name]) {
16 | this.current_h += golden_ratio_conjugate;
17 | this.current_h %= 1;
18 | this.names[name] = [
19 | `hsl(${this.current_h * 360}, 50%, 90%)`,
20 | `hsl(${this.current_h * 360}, 60%, 20%)`,
21 | ];
22 | }
23 | return this.names[name];
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/ErrorBoundary.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | export class ErrorBoundary extends Component {
4 | constructor(props) {
5 | super(props);
6 | this.state = { error: null };
7 | }
8 |
9 | static getDerivedStateFromError(error) {
10 | console.log(error);
11 | return { error: error };
12 | }
13 |
14 | render() {
15 | if (this.state.error)
16 | return (
17 |
45 |
{
49 | e.preventDefault();
50 | e.target.click();
51 | }}
52 | />
53 |
{contents}
54 |
69 |
70 | );
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/Message.js:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react';
2 | import { API_ROOT, get_json, API_VERSION_PARAM } from './flows_api';
3 | import { Time } from './Common';
4 |
5 | export class MessageViewer extends PureComponent {
6 | constructor(props) {
7 | super(props);
8 | this.state = {
9 | loading_status: 'idle',
10 | msg: [],
11 | };
12 | }
13 |
14 | componentDidMount() {
15 | this.load();
16 | }
17 |
18 | load() {
19 | if (this.state.loading_status === 'loading') return;
20 | this.setState(
21 | {
22 | loading_status: 'loading',
23 | },
24 | () => {
25 | fetch(API_ROOT + 'contents/system_msg?' + API_VERSION_PARAM(), {
26 | headers: {
27 | TOKEN: this.props.token,
28 | },
29 | })
30 | .then(get_json)
31 | .then((json) => {
32 | if (json.code !== 0) throw new Error(json.msg);
33 | else
34 | this.setState({
35 | loading_status: 'done',
36 | msg: json.data,
37 | });
38 | })
39 | .catch((err) => {
40 | console.error(err);
41 | alert('' + err);
42 | this.setState({
43 | loading_status: 'failed',
44 | });
45 | });
46 | },
47 | );
48 | }
49 |
50 | render() {
51 | if (this.state.loading_status === 'loading')
52 | return
加载中……
;
53 | else if (this.state.loading_status === 'failed')
54 | return (
55 |
64 | );
65 | else if (this.state.loading_status === 'done')
66 | return this.state.msg.map((msg) => (
67 |
68 |
69 |
70 | {msg.title}
71 |
72 |
75 |
76 | ));
77 | else return null;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
%REACT_APP_TITLE%
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/src/text_splitter.js:
--------------------------------------------------------------------------------
1 | // regexp should match the WHOLE segmented part
2 | // export const PID_RE=/(^|[^\d\u20e3\ufe0e\ufe0f])([2-9]\d{4,5}|1\d{4,6})(?![\d\u20e3\ufe0e\ufe0f])/g;
3 | export const PID_RE = /(^|[^\d\u20e3\ufe0e\ufe0f])(#\d{1,7})(?![\d\u20e3\ufe0e\ufe0f])/g;
4 | // TODO: fix this re
5 | // export const URL_PID_RE=/((?:https?:\/\/)?pkuhollow\.com\/?#(?:#|%23)([2-9]\d{4,5}|1\d{4,6}))(?!\d|\u20e3|\ufe0e|\ufe0f)/g;
6 | // export const URL_PID_RE = /((?:https?:\/\/)?pkuhollow\.com\/?#(?:#|%23)(\d{1,7}))(?!\d|\u20e3|\ufe0e|\ufe0f)/g;
7 | export const NICKNAME_RE = /(^|[^A-Za-z])((?:(?:Angry|Baby|Crazy|Diligent|Excited|Fat|Greedy|Hungry|Interesting|Jolly|Kind|Little|Magic|Naïve|Old|Powerful|Quiet|Rich|Superman|THU|Undefined|Valuable|Wifeless|Xiangbuchulai|Young|Zombie)\s)?(?:Alice|Bob|Carol|Dave|Eve|Francis|Grace|Hans|Isabella|Jason|Kate|Louis|Margaret|Nathan|Olivia|Paul|Queen|Richard|Susan|Thomas|Uma|Vivian|Winnie|Xander|Yasmine|Zach)|You Win(?: \d+)?|洞主)(?![A-Za-z])/gi;
8 | // export const URL_PID_RE=/((?:https?:\/\/)?thuhole\.com\/?#(?:#|%23)([2-9]\d{4,5}|1\d{4,6}))(?!\d|\u20e3|\ufe0e|\ufe0f)/g;
9 | // export const URL_PID_RE = /((?:https?:\/\/)?thuhole\.com\/?#(?:#|%23)(\d{1,7}))(?!\d|\u20e3|\ufe0e|\ufe0f)/g;
10 | // export const NICKNAME_RE = /(^|[^A-Za-z])((?:(?:Angry|Baby|Crazy|Diligent|Excited|Fat|Greedy|Hungry|Interesting|Jolly|Kind|Little|Magic|Naïve|Old|PKU|Quiet|Rich|Superman|Tough|Undefined|Valuable|Wifeless|Xiangbuchulai|Young|Zombie)\s)?(?:Alice|Bob|Carol|Dave|Eve|Francis|Grace|Hans|Isabella|Jason|Kate|Louis|Margaret|Nathan|Olivia|Paul|Queen|Richard|Susan|Thomas|Uma|Vivian|Winnie|Xander|Yasmine|Zach)|You Win(?: \d+)?|洞主)(?![A-Za-z])/gi;
11 | export const URL_RE = /(^|[^.@a-zA-Z0-9_])((?:https?:\/\/)?(?:(?:[\w-]+\.)+[a-zA-Z]{2,3}|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?::\d{1,5})?(?:\/[\w~!@#$%^&*()\-_=+[\]{};:,./?|]*)?)(?![a-zA-Z0-9])/gi;
12 |
13 | export function split_text(txt, rules) {
14 | // rules: [['name',/regex/],...]
15 | // return: [['name','part'],[null,'part'],...]
16 |
17 | txt = [[null, txt]];
18 | rules.forEach((rule) => {
19 | let [name, regex] = rule;
20 | txt = [].concat.apply(
21 | [],
22 | txt.map((part) => {
23 | let [rule, content] = part;
24 | if (rule)
25 | // already tagged by previous rules
26 | return [part];
27 | else {
28 | return content
29 | .split(regex)
30 | .map((seg) => (regex.test(seg) ? [name, seg] : [null, seg]))
31 | .filter(([name, seg]) => name !== null || seg);
32 | }
33 | }),
34 | );
35 | });
36 | return txt;
37 | }
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 网页版 未名树洞:
2 |
3 | ## 安装方式
4 | ```bash
5 | git clone https://github.com/pkuhollow/webhole
6 | cd webhole
7 | git submodule update --init --recursive
8 |
9 | # Edit environment configs
10 | vim .env
11 |
12 | # Build
13 | VERSION_NUMBER="v$(grep -oP '"version": "\K[^"]+' package.json | head -n1)"
14 | REACT_APP_BUILD_INFO=$VERSION_NUMBER npm run build
15 | ```
16 |
17 | 后端安装方式请见 [pkuhollow/pkuhollow-go-backend](https://github.com/pkuhollow/pkuhollow-go-backend )。
18 |
19 | ## CDN说明
20 |
21 | 使用优秀的免费jsdelivr CDN加速主页.css/.js静态内容.
22 |
23 | ## 浏览器兼容
24 |
25 | 下表为当前 未名树洞 网页版的浏览器兼容目标:
26 |
27 | | 平台 | Desktop | | | Windows | | macOS | iOS | | Android | |
28 | | -------- | ------- | -------------------------- | ------- | -------- | ---- | ------ | ------ | ------------------- | ------- | ----------------------- |
29 | | 浏览器 | Chrome | Chromium
(国产浏览器) | Firefox | EdgeHTML | IE | Safari | Safari | 微信
(WebView) | Chrome | Chromium
(WebView) |
30 | | 优先兼容 | 76+ | 无 | 最新版 | 无 | 无 | 无 | 12+ | 无 | 最新版 | 无 |
31 | | 兼容 | 56+ | 最新版 | 56+ | 最新版 | 无 | 10+ | 10+ | 最新版 | 56+ | 最新版 |
32 | | 不兼容 | 其他 | 其他 | 其他 | 其他 | 全部 | 其他 | 其他 | 其他 | 其他 | 其他 |
33 |
34 |
35 | **优先兼容** 指不应有 bug 和性能问题,可以 Polyfill 的功能尽可能提供,若发现问题会立刻修复。
36 |
37 | **兼容** 指不应有恶性 bug 和严重性能问题,若发现问题会在近期修复。
38 |
39 | **不兼容** 指在此种浏览器上访问本网站是未定义行为,问题反馈一般会被忽略。
40 |
41 | `num+` 指符合版本号 `num` 的最新版本及后续所有版本。`最新版` 以 stable 分支为准。
42 |
43 | ## 问题反馈
44 |
45 | 对 未名树洞 网页版的 bug 反馈请在相应仓库提交 Issue。
46 |
47 | 欢迎提出功能和 UI 建议,但可能不会被采纳。根据 GPL,你有权自行实现你的想法。
48 |
49 | 不方便在 GitHub 上说明的问题可以邮件 contact@pkuhollow.com。邮件内容可能会被公开。
50 |
51 | 对 未名树洞 后端服务、账号、树洞内容的反馈请联系邮件 contact@pkuhollow.com。
52 |
53 | ## branch说明:
54 | - master branch: 主分支
55 | - dev branch: 开发分支
56 | - gh-pages branch: dev分支的部署分支,用于测试
57 | - gh-pages-master branch: master分支的部署分支,用于jsdelivr CDN
58 |
59 | ## License
60 |
61 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
62 |
63 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the [GNU General Public License](https://www.gnu.org/licenses/gpl-3.0.zh-cn.html) for more details.
64 |
--------------------------------------------------------------------------------
/src/AudioWidget.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import load from 'load-script';
3 |
4 | window.audio_cache = {};
5 |
6 | function load_amrnb() {
7 | return new Promise((resolve, reject) => {
8 | if (window.AMR) resolve();
9 | else
10 | load('static/amr_all.min.js', (err) => {
11 | if (err) reject(err);
12 | else resolve();
13 | });
14 | });
15 | }
16 |
17 | export class AudioWidget extends Component {
18 | constructor(props) {
19 | super(props);
20 | this.state = {
21 | url: this.props.src,
22 | state: 'waiting',
23 | data: null,
24 | };
25 | }
26 |
27 | load() {
28 | if (window.audio_cache[this.state.url]) {
29 | this.setState({
30 | state: 'loaded',
31 | data: window.audio_cache[this.state.url],
32 | });
33 | return;
34 | }
35 |
36 | console.log('fetching audio', this.state.url);
37 | this.setState({
38 | state: 'loading',
39 | });
40 | Promise.all([fetch(this.state.url), load_amrnb()]).then((res) => {
41 | res[0].blob().then((blob) => {
42 | const reader = new FileReader();
43 | reader.onload = (event) => {
44 | const raw = new window.AMR().decode(event.target.result);
45 | if (!raw) {
46 | alert('audio decoding failed');
47 | return;
48 | }
49 | const wave = window.PCMData.encode({
50 | sampleRate: 8000,
51 | channelCount: 1,
52 | bytesPerSample: 2,
53 | data: raw,
54 | });
55 | const binary_wave = new Uint8Array(wave.length);
56 | for (let i = 0; i < wave.length; i++)
57 | binary_wave[i] = wave.charCodeAt(i);
58 |
59 | const objurl = URL.createObjectURL(
60 | new Blob([binary_wave], { type: 'audio/wav' }),
61 | );
62 | window.audio_cache[this.state.url] = objurl;
63 | this.setState({
64 | state: 'loaded',
65 | data: objurl,
66 | });
67 | };
68 | reader.readAsBinaryString(blob);
69 | });
70 | this.setState({
71 | state: 'decoding',
72 | });
73 | });
74 | }
75 |
76 | render() {
77 | if (this.state.state === 'waiting')
78 | return (
79 |
80 | 加载音频
81 |
82 | );
83 | if (this.state.state === 'loading') return
正在下载……
;
84 | else if (this.state.state === 'decoding') return
正在解码……
;
85 | else if (this.state.state === 'loaded')
86 | return (
87 |
88 |
89 |
90 | );
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/public/static/abcsx/icomoon.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'icomoon';
3 | src:
4 | url('icomoon.ttf?8qh3rt') format('truetype'),
5 | url('icomoon.woff?8qh3rt') format('woff'),
6 | url('icomoon.svg?8qh3rt#icomoon') format('svg');
7 | font-weight: normal;
8 | font-style: normal;
9 | font-display: block;
10 | }
11 |
12 | .icon {
13 | /* use !important to prevent issues with browser extensions that change fonts */
14 | /*noinspection CssNoGenericFontName*/
15 | font-family: 'icomoon' !important;
16 | speak: none;
17 | font-style: normal;
18 | font-weight: normal;
19 | font-variant: normal;
20 | text-transform: none;
21 | line-height: 1;
22 | vertical-align: -.0625em;
23 |
24 | /* Better Font Rendering =========== */
25 | -webkit-font-smoothing: antialiased;
26 | -moz-osx-font-smoothing: grayscale;
27 | }
28 |
29 | .icon-send:before {
30 | content: "\e900";
31 | }
32 | .icon-textfile:before {
33 | content: "\e926";
34 | }
35 | .icon-history:before {
36 | content: "\e94d";
37 | }
38 | .icon-reply:before {
39 | content: "\e96b";
40 | }
41 | .icon-quote:before {
42 | content: "\e977";
43 | }
44 | .icon-loading:before {
45 | content: "\e979";
46 | }
47 | .icon-login:before {
48 | content: "\e98d";
49 | }
50 | .icon-settings:before {
51 | content: "\e994";
52 | }
53 | .icon-stats:before {
54 | content: "\e99b";
55 | }
56 | .icon-locate:before {
57 | content: "\e9b3";
58 | }
59 | .icon-upload:before {
60 | content: "\e9c3";
61 | }
62 | .icon-image:before {
63 | content: "\e90d";
64 | }
65 | .icon-flag:before {
66 | content: "\e9cc";
67 | }
68 | .icon-attention:before {
69 | content: "\e9d3";
70 | }
71 | .icon-fire:before {
72 | content: "\e9a9";
73 | }
74 | .icon-star:before {
75 | content: "\e9d7";
76 | }
77 | .icon-star-ok:before {
78 | content: "\e9d9";
79 | }
80 | .icon-plus:before {
81 | content: "\ea0a";
82 | }
83 | .icon-about:before {
84 | content: "\ea0c";
85 | }
86 | .icon-close:before {
87 | content: "\ea0d";
88 | }
89 | .icon-logout:before {
90 | content: "\ea14";
91 | }
92 | .icon-refresh:before {
93 | content: "\ea2e";
94 | }
95 | .icon-forward:before {
96 | content: "\ea42";
97 | }
98 | .icon-back:before {
99 | content: "\ea44";
100 | }
101 | .icon-order-rev:before {
102 | content: "\ea46";
103 | font-size: 1.2em;
104 | }
105 | .icon-order-rev-down:before {
106 | content: "\ea47";
107 | font-size: 1.2em;
108 | }
109 | .icon-github:before {
110 | content: "\eab0";
111 | }
112 | .icon-new-tab:before {
113 | content: "\ea7e";
114 | }
115 | .icon-eye:before {
116 | content: "\e9ce";
117 | }
118 | .icon-eye-blocked:before {
119 | content: "\e9d1";
120 | }
121 |
--------------------------------------------------------------------------------
/src/UserAction.css:
--------------------------------------------------------------------------------
1 | .login-form p {
2 | margin: 1em 0;
3 | text-align: center;
4 | }
5 | .login-form button {
6 | width: 6rem;
7 | }
8 |
9 | /*.reply-form {*/
10 | /* display: flex;*/
11 | /*}*/
12 | .reply-sticky {
13 | position: sticky;
14 | bottom: 0;
15 | }
16 |
17 | .reply-form textarea {
18 | resize: vertical;
19 | width: 100%;
20 | min-height: 2em;
21 | height: 4em;
22 | }
23 |
24 | /*.reply-form button {*/
25 | /* flex: 0 0 3em;*/
26 | /* margin-right: 0;*/
27 | /*}*/
28 |
29 | .reply-preview {
30 | width: 100%;
31 | min-height: 2em;
32 | }
33 |
34 |
35 | .post-form-bar {
36 | line-height: 2em;
37 | display: flex;
38 | margin-bottom: .5em;
39 | }
40 |
41 | .post-form-bar label {
42 | flex: 1;
43 | }
44 |
45 | .post-form-bar input[type=file] {
46 | border: 0;
47 | padding: 0 0 0 .5em;
48 | }
49 |
50 | @media screen and (max-width: 580px) {
51 | .post-form-bar input[type=file] {
52 | width: 120px;
53 | }
54 | }
55 |
56 | @media screen and (max-width: 320px) {
57 | .post-form-bar input[type=file] {
58 | width: 100px;
59 | }
60 | }
61 |
62 | .post-form-bar button {
63 | flex: 0 0 6em;
64 | margin-right: 0;
65 | }
66 |
67 | @media screen and (max-width: 580px) {
68 | .post-form-bar button {
69 | flex: 0 0 4.5em;
70 | margin-right: 0;
71 | }
72 | }
73 |
74 | .post-form-img-tip {
75 | font-size: small;
76 | margin-top: -.5em;
77 | margin-bottom: .5em;
78 | }
79 |
80 | .post-form textarea {
81 | resize: vertical;
82 | width: 100%;
83 | min-height: 5em;
84 | height: 20em;
85 | }
86 |
87 | .post-preview {
88 | width: 100%;
89 | min-height: 5em;
90 | }
91 |
92 | input[type="file"] {
93 | display: none;
94 | }
95 |
96 | .post-upload {
97 | padding: .3em .5em;
98 | cursor: pointer;
99 | opacity: 1;
100 | /*background-color: #fcfcfc;*/
101 | box-shadow: 0 0 3px rgba(0,0,0,.5);
102 | border-radius: 1em;
103 | color: var(--var-link-color);
104 | }
105 |
106 | .selectCss{
107 | background-color: rgba(255, 255, 255, 0);
108 | color: rgb(0, 0, 0);
109 | padding: 5px;
110 | margin-bottom: 10px;
111 | text-align: center;
112 | text-align-last: center;
113 | }
114 |
115 | .root-dark-mode .selectCss{
116 | background-color: #333;
117 | color: white;
118 | padding: 5px;
119 | margin-bottom: 10px;
120 | text-align: center;
121 | text-align-last: center;
122 | }
123 |
124 | .selectOption{
125 | background-color: rgba(255, 255, 255, 0);
126 | }
127 |
128 | .root-dark-mode .selectOption{
129 | background-color: #333;
130 | }
131 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | # This is a basic workflow to help you get started with Actions
2 | name: Build and Deploy
3 | on: [ push ]
4 | jobs:
5 | build-and-deploy:
6 | runs-on: ubuntu-latest
7 | steps:
8 | - name: Checkout 🛎️
9 | uses: actions/checkout@v2.3.1 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly.
10 | with:
11 | persist-credentials: false
12 | submodules: true
13 |
14 | - name: Extract branch name
15 | shell: bash
16 | run: echo "branch_name=$(echo ${GITHUB_REF#refs/heads/})" >> $GITHUB_ENV
17 | id: extract_branch
18 |
19 | - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
20 | env:
21 | REPO_NAME: ${{ github.event.repository.full_name }}
22 | BRANCH_NAME: ${{ env.branch_name }}
23 | run: |
24 | if [ "$BRANCH_NAME" = "master" ]; then
25 | export DEPLOY_BRANCH="gh-pages-master"
26 | else
27 | export DEPLOY_BRANCH="gh-pages"
28 | fi
29 | echo "deploy_branch=${DEPLOY_BRANCH}" >> $GITHUB_ENV
30 | CDN_URL="https://cdn.jsdelivr.net/gh/${REPO_NAME}@${DEPLOY_BRANCH}"
31 | #CDN_URL="."
32 | VERSION_NUMBER="v$(grep -oP '"version": "\K[^"]+' package.json | head -n1)"
33 | npm install
34 | echo "DEPLOY_BRANCH=$DEPLOY_BRANCH, VERSION_NUMBER=$VERSION_NUMBER, CDN_URL=$CDN_URL"
35 | CI=false PUBLIC_URL=$CDN_URL REACT_APP_BUILD_INFO=$VERSION_NUMBER npm run build
36 | ## 额,这里用了个骚操作来修复Service Worker在Precache CDN内容的时候index.html返回content-type text/plain的问题
37 | sed -i 's|https://cdn.jsdelivr.net/gh/'"${REPO_NAME}"'@'"$DEPLOY_BRANCH"'/index.html|./index.html|g' build/*-*
38 | sed -i 's|https://cdn.jsdelivr.net/gh/'"${REPO_NAME}"'@'"$DEPLOY_BRANCH"'/service-worker.js|./service-worker.js|g' build/*-*
39 | sed -i 's|"https://cdn.jsdelivr.net/gh/'"${REPO_NAME}"'@'"$DEPLOY_BRANCH"'","/service-worker.js"|".","/service-worker.js"|' build/static/js/*.js
40 | sed -i 's|storage.googleapis.com/workbox-cdn/releases/4.3.1|cdn.jsdelivr.net/npm/workbox-cdn@4.3.1/workbox|g' build/service-worker.js
41 |
42 | - name: Deploy 🚀
43 | if: github.event_name != 'pull_request'
44 | uses: JamesIves/github-pages-deploy-action@3.7.1
45 | with:
46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
47 | BRANCH: ${{ env.deploy_branch }} # The branch the action should deploy to.
48 | FOLDER: build # The folder the action should deploy.
49 | CLEAN: true # Automatically remove deleted files from the deploy branch
50 |
--------------------------------------------------------------------------------
/src/PressureHelper.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Pressure from 'pressure';
3 |
4 | import './PressureHelper.css';
5 |
6 | const THRESHOLD = 0.4;
7 | const MULTIPLIER = 25;
8 | const BORDER_WIDTH = 500; // also change css!
9 |
10 | export class PressureHelper extends Component {
11 | constructor(props) {
12 | super(props);
13 | this.state = {
14 | level: 0,
15 | fired: false,
16 | };
17 | this.callback = props.callback;
18 | this.esc_interval = null;
19 | }
20 |
21 | do_fire() {
22 | if (this.esc_interval) {
23 | clearInterval(this.esc_interval);
24 | this.esc_interval = null;
25 | }
26 | this.setState({
27 | level: 1,
28 | fired: true,
29 | });
30 | this.callback();
31 | window.setTimeout(() => {
32 | this.setState({
33 | level: 0,
34 | fired: false,
35 | });
36 | }, 300);
37 | }
38 |
39 | componentDidMount() {
40 | if (window.config.pressure) {
41 | Pressure.set(
42 | document.body,
43 | {
44 | change: (force) => {
45 | if (!this.state.fired) {
46 | if (force >= 0.999) {
47 | this.do_fire();
48 | } else
49 | this.setState({
50 | level: force,
51 | });
52 | }
53 | },
54 | end: () => {
55 | this.setState({
56 | level: 0,
57 | fired: false,
58 | });
59 | },
60 | },
61 | {
62 | polyfill: false,
63 | only: 'touch',
64 | preventSelect: false,
65 | },
66 | );
67 |
68 | document.addEventListener('keydown', (e) => {
69 | if (!e.repeat && e.key === 'Escape') {
70 | if (this.esc_interval) clearInterval(this.esc_interval);
71 | this.setState(
72 | {
73 | level: THRESHOLD / 2,
74 | },
75 | () => {
76 | this.esc_interval = setInterval(() => {
77 | let new_level = this.state.level + 0.1;
78 | if (new_level >= 0.999) this.do_fire();
79 | else
80 | this.setState({
81 | level: new_level,
82 | });
83 | }, 30);
84 | },
85 | );
86 | }
87 | });
88 | document.addEventListener('keyup', (e) => {
89 | if (e.key === 'Escape') {
90 | if (this.esc_interval) {
91 | clearInterval(this.esc_interval);
92 | this.esc_interval = null;
93 | }
94 | this.setState({
95 | level: 0,
96 | });
97 | }
98 | });
99 | }
100 | }
101 |
102 | render() {
103 | const pad = MULTIPLIER * (this.state.level - THRESHOLD) - BORDER_WIDTH;
104 | return (
105 |
118 | );
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/Sidebar.css:
--------------------------------------------------------------------------------
1 | .sidebar-shadow {
2 | will-change: opacity;
3 | opacity: 0;
4 | background-color: black;
5 | pointer-events: none;
6 | transition: opacity 150ms ease-out;
7 | position: fixed;
8 | left: 0;
9 | top: 0;
10 | height: 100%;
11 | width: 100%;
12 | z-index: 20;
13 | }
14 | .sidebar-on .sidebar-shadow {
15 | opacity: .3;
16 | pointer-events: initial;
17 | }
18 | .sidebar-on .sidebar-shadow:active {
19 | opacity: .5;
20 | transition: unset;
21 | }
22 |
23 | .root-dark-mode .sidebar-on .sidebar-shadow {
24 | opacity: .65;
25 | }
26 | .root-dark-mode .sidebar-on .sidebar-shadow:active {
27 | opacity: .8;
28 | }
29 |
30 | .sidebar {
31 | user-select: text;
32 | position: fixed;
33 | top: 0;
34 | /* think twice before you use 100vh
35 | https://dev.to/peiche/100vh-behavior-on-chrome-2hm8
36 | */
37 | height: 100%;
38 | background-color: rgba(255,255,255,.7);
39 | overflow-y: auto;
40 | padding-top: 3em;
41 | /* padding-bottom: 1em; */ /* move to sidebar-content */
42 | backdrop-filter: blur(5px);
43 | }
44 |
45 | .sidebar-content {
46 | backdrop-filter: blur(0px); /* fix scroll performance issues */
47 | }
48 |
49 | .root-dark-mode .sidebar {
50 | background-color: hsla(0,0%,5%,.4);
51 | }
52 |
53 | .sidebar, .sidebar-title {
54 | left: 700px;
55 | will-change: opacity, transform;
56 | z-index: 21;
57 | width: calc(100% - 700px);
58 | }
59 |
60 | .sidebar-on .sidebar, .sidebar-on .sidebar-title {
61 | animation: sidebar-fadein .15s cubic-bezier(0.15, 0.4, 0.6, 1);
62 | }
63 | .sidebar-off .sidebar, .sidebar-off .sidebar-title {
64 | visibility: hidden;
65 | pointer-events: none;
66 | backdrop-filter: none;
67 | animation: sidebar-fadeout .2s cubic-bezier(0.15, 0.4, 0.6, 1);
68 | }
69 | .sidebar-container {
70 | animation: sidebar-initial .25s linear; /* skip initial animation */
71 | }
72 |
73 | @keyframes sidebar-fadeout {
74 | from {
75 | visibility: visible;
76 | opacity: 1;
77 | transform: none;
78 | backdrop-filter: none;
79 | }
80 | to {
81 | visibility: visible;
82 | opacity: 0;
83 | transform: translateX(40vw);
84 | backdrop-filter: none;
85 | }
86 | }
87 | @keyframes sidebar-fadein {
88 | from {
89 | opacity: 0;
90 | transform: translateX(40vw);
91 | backdrop-filter: none;
92 | }
93 | to {
94 | opacity: 1;
95 | transform: none;
96 | backdrop-filter: none;
97 | }
98 | }
99 | @keyframes sidebar-initial {
100 | from {opacity: 0;}
101 | to {opacity: 0;}
102 | }
103 |
104 | .sidebar-title {
105 | text-shadow: 0 0 3px white;
106 | font-weight: bold;
107 | position: fixed;
108 | width: 100%;
109 | top: 0;
110 | line-height: 3em;
111 | padding-left: .5em;
112 | background-color: rgba(255,255,255,.6);
113 | pointer-events: none;
114 | backdrop-filter: blur(5px);
115 | box-shadow: 0 3px 5px rgba(0,0,0,.2);
116 | }
117 |
118 | .root-dark-mode .sidebar-title {
119 | background-color: hsla(0,0%,18%,.6);
120 | color: var(--foreground-dark);
121 | text-shadow: 0 0 3px black;
122 | }
123 |
124 | .sidebar-title a {
125 | pointer-events: initial;
126 | }
127 |
128 |
129 | /* move all padding to sidebar-content - the scrolling div (overflow-y: auto) */
130 | /* .sidebar, */
131 | .sidebar-content,
132 | .sidebar-title {
133 | padding-left: 1em;
134 | padding-right: 1em;
135 | }
136 |
137 | .sidebar-content {
138 | padding-bottom: 1em;
139 | }
140 |
141 | @media screen and (max-width: 1300px) {
142 | .sidebar, .sidebar-title {
143 | left: calc(100% - 550px);
144 | width: 550px;/*
145 | padding-left: .5em;
146 | padding-right: .5em; */
147 | }
148 | .sidebar-content, .sidebar-title {
149 | padding-left: .5em;
150 | padding-right: .5em;
151 | }
152 | }
153 | @media screen and (max-width: 580px) {
154 | .sidebar, .sidebar-title {
155 | left: 27px;
156 | width: calc(100% - 27px);
157 | /* padding-left: .25em;
158 | padding-right: .25em; */
159 | }
160 | .sidebar-content, .sidebar-title {
161 | padding-left: .25em;
162 | padding-right: .25em;
163 | }
164 | }
165 |
166 | .sidebar-flow-item {
167 | display: block;
168 | }
169 | .sidebar-flow-item .box {
170 | width: 100%;
171 | }
172 |
173 | .sidebar-content-show {
174 | height: 100%;
175 | overflow-y: auto;
176 | }
177 |
178 | .sidebar-content-hide{
179 | /* will make lazyload working correctly */
180 | height: 0;
181 | padding: 0;
182 | overflow-y: scroll;
183 | }
184 |
--------------------------------------------------------------------------------
/public/static/fonts_9/icomoon.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'icomoon';
3 | src: url('./icomoon.eot?r77vn3');
4 | src: url('./icomoon.eot?r77vn3#iefix') format('embedded-opentype'),
5 | url('./icomoon.ttf?r77vn3') format('truetype'),
6 | url('./icomoon.woff?r77vn3') format('woff'),
7 | url('./icomoon.svg?r77vn3#icomoon') format('svg');
8 | font-weight: normal;
9 | font-style: normal;
10 | font-display: block;
11 | }
12 |
13 | [class^="icon-"], [class*=" icon-"] {
14 | /* use !important to prevent issues with browser extensions that change fonts */
15 | font-family: 'icomoon' !important;
16 | speak: never;
17 | font-style: normal;
18 | font-weight: normal;
19 | font-variant: normal;
20 | text-transform: none;
21 | line-height: 1;
22 |
23 | /* Better Font Rendering =========== */
24 | -webkit-font-smoothing: antialiased;
25 | -moz-osx-font-smoothing: grayscale;
26 | }
27 |
28 | .icon-send:before {
29 | content: "\e900";
30 | }
31 | .icon-send1:before {
32 | content: "\e901";
33 | }
34 | .icon-pen:before {
35 | content: "\e908";
36 | }
37 | .icon-image:before {
38 | content: "\e90d";
39 | }
40 | .icon-mic:before {
41 | content: "\e91e";
42 | }
43 | .icon-textfile:before {
44 | content: "\e926";
45 | }
46 | .icon-history:before {
47 | content: "\e94d";
48 | }
49 | .icon-reply:before {
50 | content: "\e96b";
51 | }
52 | .icon-quote:before {
53 | content: "\e977";
54 | }
55 | .icon-loading:before {
56 | content: "\e979";
57 | }
58 | .icon-login:before {
59 | content: "\e98d";
60 | }
61 | .icon-settings:before {
62 | content: "\e994";
63 | }
64 | .icon-stats:before {
65 | content: "\e99b";
66 | }
67 | .icon-fire:before {
68 | content: "\e9a9";
69 | }
70 | .icon-locate:before {
71 | content: "\e9b3";
72 | }
73 | .icon-upload:before {
74 | content: "\e9c3";
75 | }
76 | .icon-flag:before {
77 | content: "\e9cc";
78 | }
79 | .icon-attention:before {
80 | content: "\e9d3";
81 | }
82 | .icon-star:before {
83 | content: "\e9d7";
84 | }
85 | .icon-star-ok:before {
86 | content: "\e9d9";
87 | }
88 | .icon-plus:before {
89 | content: "\ea0a";
90 | }
91 | .icon-about:before {
92 | content: "\ea0c";
93 | }
94 | .icon-close:before {
95 | content: "\ea0e";
96 | }
97 | .icon-logout:before {
98 | content: "\ea14";
99 | }
100 | .icon-play:before {
101 | content: "\ea16";
102 | }
103 | .icon-pause:before {
104 | content: "\ea18";
105 | }
106 | .icon-refresh:before {
107 | content: "\ea2e";
108 | }
109 | .icon-forward:before {
110 | content: "\ea42";
111 | }
112 | .icon-back:before {
113 | content: "\ea44";
114 | }
115 | .icon-order-rev:before {
116 | content: "\ea46";
117 | }
118 | .icon-order-rev-down:before {
119 | content: "\ea48";
120 | }
121 | .icon-github:before {
122 | content: "\eab0";
123 | }
124 | .icon-pen1:before {
125 | content: "\e909";
126 | }
127 | .icon-image1:before {
128 | content: "\e90e";
129 | }
130 | .icon-mic1:before {
131 | content: "\e91f";
132 | }
133 | .icon-textfile1:before {
134 | content: "\e927";
135 | }
136 | .icon-history1:before {
137 | content: "\e94e";
138 | }
139 | .icon-reply1:before {
140 | content: "\e96c";
141 | }
142 | .icon-quote1:before {
143 | content: "\e978";
144 | }
145 | .icon-loading1:before {
146 | content: "\e97a";
147 | }
148 | .icon-login1:before {
149 | content: "\e98e";
150 | }
151 | .icon-settings1:before {
152 | content: "\e995";
153 | }
154 | .icon-stats1:before {
155 | content: "\e99c";
156 | }
157 | .icon-fire1:before {
158 | content: "\e9aa";
159 | }
160 | .icon-locate1:before {
161 | content: "\e9b4";
162 | }
163 | .icon-upload1:before {
164 | content: "\e9c4";
165 | }
166 | .icon-flag1:before {
167 | content: "\e9cd";
168 | }
169 | .icon-eye:before {
170 | content: "\e9ce";
171 | }
172 | .icon-eye-blocked:before {
173 | content: "\e9d1";
174 | }
175 | .icon-attention1:before {
176 | content: "\e9d4";
177 | }
178 | .icon-star1:before {
179 | content: "\e9d8";
180 | }
181 | .icon-star-ok1:before {
182 | content: "\e9da";
183 | }
184 | .icon-plus1:before {
185 | content: "\ea0b";
186 | }
187 | .icon-about1:before {
188 | content: "\ea0d";
189 | }
190 | .icon-close1:before {
191 | content: "\ea0f";
192 | }
193 | .icon-logout1:before {
194 | content: "\ea15";
195 | }
196 | .icon-play1:before {
197 | content: "\ea17";
198 | }
199 | .icon-pause1:before {
200 | content: "\ea19";
201 | }
202 | .icon-refresh1:before {
203 | content: "\ea2f";
204 | }
205 | .icon-forward1:before {
206 | content: "\ea43";
207 | }
208 | .icon-back1:before {
209 | content: "\ea45";
210 | }
211 | .icon-order-rev1:before {
212 | content: "\ea47";
213 | }
214 | .icon-order-rev-down1:before {
215 | content: "\ea49";
216 | }
217 | .icon-github1:before {
218 | content: "\eab1";
219 | }
220 | .icon-how_to_vote:before {
221 | content: "\e91ca";
222 | }
223 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/,
18 | ),
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | // const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | // if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | // return;
30 | // }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (isLocalhost) {
36 | // This is running on localhost. Lets check if a service worker still exists or not.
37 | checkValidServiceWorker(swUrl);
38 |
39 | // Add some additional logging to localhost, pointing developers to the
40 | // service worker/PWA documentation.
41 | navigator.serviceWorker.ready.then(() => {
42 | console.log(
43 | 'This web app is being served cache-first by a service ' +
44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ',
45 | );
46 | });
47 | } else {
48 | // Is not local host. Just register service worker
49 | registerValidSW(swUrl);
50 | }
51 | });
52 | }
53 | }
54 |
55 | function registerValidSW(swUrl) {
56 | navigator.serviceWorker
57 | .register(swUrl)
58 | .then((registration) => {
59 | registration.onupdatefound = () => {
60 | const installingWorker = registration.installing;
61 | installingWorker.onstatechange = () => {
62 | if (installingWorker.state === 'installed') {
63 | if (navigator.serviceWorker.controller) {
64 | // At this point, the old content will have been purged and
65 | // the fresh content will have been added to the cache.
66 | // It's the perfect time to display a "New content is
67 | // available; please refresh." message in your web app.
68 | console.log('New content is available; please refresh.');
69 | } else {
70 | // At this point, everything has been precached.
71 | // It's the perfect time to display a
72 | // "Content is cached for offline use." message.
73 | console.log('Content is cached for offline use.');
74 | }
75 | }
76 | };
77 | };
78 | })
79 | .catch((error) => {
80 | console.error('Error during service worker registration:', error);
81 | });
82 | }
83 |
84 | function checkValidServiceWorker(swUrl) {
85 | // Check if the service worker can be found. If it can't reload the page.
86 | fetch(swUrl)
87 | .then((response) => {
88 | // Ensure service worker exists, and that we really are getting a JS file.
89 | if (
90 | response.status === 404 ||
91 | response.headers.get('content-type').indexOf('javascript') === -1
92 | ) {
93 | // No service worker found. Probably a different app. Reload the page.
94 | navigator.serviceWorker.ready.then((registration) => {
95 | registration.unregister().then(() => {
96 | window.location.reload();
97 | });
98 | });
99 | } else {
100 | // Service worker found. Proceed as normal.
101 | registerValidSW(swUrl);
102 | }
103 | })
104 | .catch(() => {
105 | console.log(
106 | 'No internet connection found. App is running in offline mode.',
107 | );
108 | });
109 | }
110 |
111 | export function unregister() {
112 | if ('serviceWorker' in navigator) {
113 | navigator.serviceWorker.ready.then((registration) => {
114 | registration.unregister();
115 | });
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/flows_api.js:
--------------------------------------------------------------------------------
1 | import { API_VERSION_PARAM } from './old_infrastructure/functions';
2 | import { API_ROOT } from './old_infrastructure/const';
3 | import { cache } from './cache';
4 |
5 | export { API_ROOT, API_VERSION_PARAM };
6 |
7 | export function get_json(res) {
8 | if (!res.ok) throw Error(`网络错误 ${res.status} ${res.statusText}`);
9 | return res.text().then((t) => {
10 | try {
11 | return JSON.parse(t);
12 | } catch (e) {
13 | console.error('json parse error');
14 | console.trace(e);
15 | console.log(t);
16 | throw new SyntaxError('JSON Parse Error ' + t.substr(0, 50));
17 | }
18 | });
19 | }
20 |
21 | function add_variant(li) {
22 | li.forEach((item) => {
23 | item.variant = {};
24 | });
25 | }
26 |
27 | const SEARCH_PAGESIZE = 50;
28 |
29 | const handle_response = async (response, notify = false, add_v = true) => {
30 | let json = await get_json(response);
31 | if (json.code !== 0) {
32 | if (json.msg) {
33 | if (notify) alert(json.msg);
34 | else throw new Error(json.msg);
35 | } else throw new Error(JSON.stringify(json));
36 | }
37 | if (add_v) {
38 | add_variant(json.data);
39 | }
40 | return json;
41 | };
42 |
43 | export const API = {
44 | load_replies: (pid, token, color_picker) => {
45 | pid = parseInt(pid);
46 | return fetch(
47 | API_ROOT + 'contents/post/detail?pid=' + pid + API_VERSION_PARAM(),
48 | {
49 | headers: {
50 | TOKEN: token,
51 | },
52 | },
53 | )
54 | .then(get_json)
55 | .then((json) => {
56 | if (json.code !== 0) {
57 | throw new Error(json.msg);
58 | }
59 | cache().put(pid, json.post.updated_at, json);
60 |
61 | // also change load_replies_with_cache!
62 | json.post.variant = {};
63 | json.data = json.data.map((info) => {
64 | info._display_color = color_picker.get(info.name);
65 | info.variant = {};
66 | return info;
67 | });
68 |
69 | return json;
70 | });
71 | },
72 |
73 | load_replies_with_cache: (pid, token, color_picker, cache_version) => {
74 | pid = parseInt(pid);
75 | return cache()
76 | .get(pid, cache_version)
77 | .then(([json, reason]) => {
78 | if (json) {
79 | // also change load_replies!
80 | json.post.variant = {};
81 | json.data = json.data.map((info) => {
82 | info._display_color = color_picker.get(info.name);
83 | info.variant = {};
84 | return info;
85 | });
86 |
87 | return json;
88 | } else {
89 | return API.load_replies(pid, token, color_picker).then((json) => {
90 | if (reason === 'expired') json.post.variant.new_reply = true;
91 | return json;
92 | });
93 | }
94 | });
95 | },
96 |
97 | set_attention: async (pid, attention, token) => {
98 | let data = new URLSearchParams();
99 | data.append('pid', pid);
100 | data.append('switch', attention ? '1' : '0');
101 | let response = await fetch(
102 | API_ROOT + 'edit/attention?' + API_VERSION_PARAM(),
103 | {
104 | method: 'POST',
105 | headers: {
106 | 'Content-Type': 'application/x-www-form-urlencoded',
107 | TOKEN: token,
108 | },
109 | body: data,
110 | },
111 | );
112 | // Delete cache to update `attention` on next reload
113 | cache().delete(pid);
114 | return handle_response(response, true, false);
115 | },
116 |
117 | report: (item_type, id, report_type, reason, token) => {
118 | if (item_type !== 'post' && item_type !== 'comment')
119 | throw Error('bad type');
120 | let data = new URLSearchParams();
121 | data.append('id', id);
122 | data.append('reason', reason);
123 | data.append('type', report_type);
124 | return fetch(
125 | API_ROOT + 'edit/report/' + item_type + '?' + API_VERSION_PARAM(),
126 | {
127 | method: 'POST',
128 | headers: {
129 | 'Content-Type': 'application/x-www-form-urlencoded',
130 | TOKEN: token,
131 | },
132 | body: data,
133 | },
134 | )
135 | .then(get_json)
136 | .then((json) => {
137 | if (json.code !== 0) throw new Error(json.msg);
138 |
139 | return json;
140 | });
141 | },
142 |
143 | get_list: async (page, token) => {
144 | let response = await fetch(
145 | API_ROOT + 'contents/post/list' + '?page=' + page + API_VERSION_PARAM(),
146 | {
147 | headers: {
148 | TOKEN: token,
149 | },
150 | },
151 | );
152 | return handle_response(response);
153 | },
154 |
155 | get_search: async (page, keyword, token, is_attention = false) => {
156 | console.log(is_attention === true ? '/attentions' : '');
157 | let response = await fetch(
158 | API_ROOT +
159 | 'contents/search' +
160 | (is_attention === true ? '/attentions' : '') +
161 | '?pagesize=' +
162 | SEARCH_PAGESIZE +
163 | '&page=' +
164 | page +
165 | '&keywords=' +
166 | encodeURIComponent(keyword) +
167 | API_VERSION_PARAM(),
168 | {
169 | headers: {
170 | TOKEN: token,
171 | },
172 | },
173 | );
174 | return handle_response(response);
175 | },
176 |
177 | get_attention: async (page, token) => {
178 | let response = await fetch(
179 | API_ROOT +
180 | 'contents/post/attentions?page=' +
181 | page +
182 | API_VERSION_PARAM(), {
183 | headers: {
184 | TOKEN: token,
185 | },
186 | },
187 | );
188 | return handle_response(response);
189 | },
190 | };
191 |
--------------------------------------------------------------------------------
/src/cache.js:
--------------------------------------------------------------------------------
1 | const HOLE_CACHE_DB_NAME = 'hole_v2_cache_db';
2 | const CACHE_DB_VER = 1;
3 | const MAINTENANCE_STEP = 150;
4 | const MAINTENANCE_COUNT = 1000;
5 |
6 | //const ENC_KEY=42;
7 |
8 | class Cache {
9 | constructor() {
10 | this.db = null;
11 | this.added_items_since_maintenance = 0;
12 | this.encrypt = this.encrypt.bind(this);
13 | this.decrypt = this.decrypt.bind(this);
14 | const open_req = indexedDB.open(HOLE_CACHE_DB_NAME, CACHE_DB_VER);
15 | open_req.onerror = console.error.bind(console);
16 | open_req.onupgradeneeded = (event) => {
17 | console.log('comment cache db upgrade');
18 | const db = event.target.result;
19 | const store = db.createObjectStore('comment', {
20 | keyPath: 'pid',
21 | });
22 | store.createIndex('last_access', 'last_access', { unique: false });
23 | };
24 | open_req.onsuccess = (event) => {
25 | console.log('comment cache db loaded');
26 | this.db = event.target.result;
27 | setTimeout(this.maintenance.bind(this), 1);
28 | };
29 | }
30 |
31 | // use window.hole_cache.encrypt() only after cache is loaded!
32 | encrypt(pid, data) {
33 | let s = JSON.stringify(data);
34 | return s;
35 | /*
36 | let o='';
37 | for(let i=0,key=(ENC_KEY^pid)%128;i
{
74 | if (!this.db) return resolve([null, 'fail']);
75 | let get_req, store;
76 | try {
77 | const tx = this.db.transaction(['comment'], 'readwrite');
78 | store = tx.objectStore('comment');
79 | get_req = store.get(pid);
80 | } catch (e) {
81 | // ios sometimes fail at here, just ignore it
82 | console.error(e);
83 | resolve([null, 'fail']);
84 | }
85 | get_req.onsuccess = () => {
86 | let res = get_req.result;
87 | if (!res || !res.data_str) {
88 | //console.log('comment cache miss',pid);
89 | resolve([null, 'miss']);
90 | } else if (target_version === res.version) {
91 | // hit
92 | console.log('comment cache hit', pid);
93 | res.last_access = +new Date();
94 | store.put(res);
95 | let data = this.decrypt(pid, res.data_str);
96 | resolve([data, 'hit']); // obj or null
97 | } else {
98 | // expired
99 | console.log(
100 | 'comment cache expired',
101 | pid,
102 | ': ver',
103 | res.version,
104 | 'target',
105 | target_version,
106 | );
107 | store.delete(pid);
108 | resolve([null, 'expired']);
109 | }
110 | };
111 | get_req.onerror = (e) => {
112 | console.warn('comment cache indexeddb open failed');
113 | console.error(e);
114 | resolve([null, 'fail']);
115 | };
116 | });
117 | }
118 |
119 | put(pid, target_version, data) {
120 | pid = parseInt(pid);
121 | return new Promise((resolve, reject) => {
122 | if (!this.db) return resolve();
123 | try {
124 | const tx = this.db.transaction(['comment'], 'readwrite');
125 | const store = tx.objectStore('comment');
126 | store.put({
127 | pid: pid,
128 | version: target_version,
129 | data_str: this.encrypt(pid, data),
130 | last_access: +new Date(),
131 | });
132 | if (++this.added_items_since_maintenance === MAINTENANCE_STEP)
133 | setTimeout(this.maintenance.bind(this), 1);
134 | } catch (e) {
135 | console.error(e);
136 | return resolve();
137 | }
138 | });
139 | }
140 |
141 | delete(pid) {
142 | pid = parseInt(pid);
143 | return new Promise((resolve, reject) => {
144 | if (!this.db) return resolve();
145 | let req;
146 | try {
147 | const tx = this.db.transaction(['comment'], 'readwrite');
148 | const store = tx.objectStore('comment');
149 | req = store.delete(pid);
150 | } catch (e) {
151 | console.error(e);
152 | return resolve();
153 | }
154 | //console.log('comment cache delete',pid);
155 | req.onerror = () => {
156 | console.warn('comment cache delete failed ', pid);
157 | return resolve();
158 | };
159 | req.onsuccess = () => resolve();
160 | });
161 | }
162 |
163 | maintenance() {
164 | if (!this.db) return;
165 | const tx = this.db.transaction(['comment'], 'readwrite');
166 | const store = tx.objectStore('comment');
167 | let count_req = store.count();
168 | count_req.onsuccess = () => {
169 | let count = count_req.result;
170 | if (count > MAINTENANCE_COUNT) {
171 | console.log('comment cache db maintenance', count);
172 | store.index('last_access').openKeyCursor().onsuccess = (e) => {
173 | let cur = e.target.result;
174 | if (cur) {
175 | //console.log('maintenance: delete',cur);
176 | store.delete(cur.primaryKey);
177 | if (--count > MAINTENANCE_COUNT) cur.continue();
178 | }
179 | };
180 | } else {
181 | console.log('comment cache db no need to maintenance', count);
182 | }
183 | this.added_items_since_maintenance = 0;
184 | };
185 | count_req.onerror = console.error.bind(console);
186 | }
187 |
188 | clear() {
189 | if (!this.db) return;
190 | try {
191 | indexedDB.deleteDatabase(HOLE_CACHE_DB_NAME);
192 | console.log('delete comment cache db');
193 | } catch (e) {
194 | console.error(e);
195 | }
196 | }
197 | }
198 |
199 | export function cache() {
200 | if (!window.hole_cache) window.hole_cache = new Cache();
201 | return window.hole_cache;
202 | }
203 |
--------------------------------------------------------------------------------
/src/Title.js:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react';
2 | // import {AppSwitcher} from './infrastructure/widgets';
3 | import { InfoSidebar, PostForm } from './UserAction';
4 | import { TokenCtx } from './UserAction';
5 |
6 | import './Title.css';
7 |
8 | const flag_re = /^\/\/setflag ([a-zA-Z0-9_]+)=(.*)$/;
9 |
10 | class ControlBar extends PureComponent {
11 | constructor(props) {
12 | super(props);
13 | this.state = {
14 | search_text: '',
15 | };
16 | this.set_mode = props.set_mode;
17 |
18 | this.on_change_bound = this.on_change.bind(this);
19 | this.on_keypress_bound = this.on_keypress.bind(this);
20 | this.do_refresh_bound = this.do_refresh.bind(this);
21 | this.do_attention_bound = this.do_attention.bind(this);
22 | this.do_hot_posts_bound = this.do_hot_posts.bind(this);
23 | }
24 |
25 | componentDidMount() {
26 | if (window.location.hash) {
27 | let text = decodeURIComponent(window.location.hash).substr(1);
28 | if (text.lastIndexOf('?') !== -1)
29 | text = text.substr(0, text.lastIndexOf('?')); // fuck wechat '#param?nsukey=...'
30 | this.setState(
31 | {
32 | search_text: text,
33 | },
34 | () => {
35 | this.on_keypress({ key: 'Enter' });
36 | },
37 | );
38 | }
39 | }
40 |
41 | on_change(event) {
42 | this.setState({
43 | search_text: event.target.value,
44 | });
45 | }
46 |
47 | on_keypress(event) {
48 | if (event.key === 'Enter') {
49 | let flag_res = flag_re.exec(this.state.search_text);
50 | if (flag_res) {
51 | let r = confirm('Please confirm:\n' + this.state.search_text);
52 | if (r === true) {
53 | if (flag_res[2]) {
54 | localStorage[flag_res[1]] = flag_res[2];
55 | alert(
56 | 'Set Flag ' +
57 | flag_res[1] +
58 | '=' +
59 | flag_res[2] +
60 | '\nYou may need to refresh this webpage.',
61 | );
62 | } else {
63 | delete localStorage[flag_res[1]];
64 | alert(
65 | 'Clear Flag ' +
66 | flag_res[1] +
67 | '\nYou may need to refresh this webpage.',
68 | );
69 | }
70 | }
71 | return;
72 | }
73 |
74 | const mode = /#[0-9]+/.test(this.state.search_text)
75 | ? 'single'
76 | : this.props.mode !== 'attention'
77 | ? 'search'
78 | : 'attention';
79 | this.set_mode(mode, this.state.search_text || '');
80 | }
81 | }
82 |
83 | do_refresh() {
84 | window.scrollTo(0, 0);
85 | this.setState({
86 | search_text: '',
87 | });
88 | this.set_mode('list', null);
89 | }
90 |
91 | do_attention() {
92 | window.scrollTo(0, 0);
93 | this.setState({
94 | search_text: '',
95 | });
96 | this.set_mode('attention', null);
97 | }
98 |
99 | do_hot_posts() {
100 | window.scrollTo(0, 0);
101 | this.setState({
102 | search_text: '热榜',
103 | });
104 | this.set_mode('search', '热榜');
105 | }
106 |
107 | render() {
108 | return (
109 |
110 | {({ value: token }) => (
111 |
181 | )}
182 |
183 | );
184 | }
185 | }
186 |
187 | export function Title(props) {
188 | return (
189 |
190 | {/*
*/}
191 |
192 |
193 |
194 |
196 | props.show_sidebar(
197 | process.env.REACT_APP_TITLE,
198 | ,
199 | )
200 | }
201 | >
202 | {process.env.REACT_APP_TITLE}
203 |
204 |
205 |
206 |
211 |
212 |
213 | );
214 | }
215 |
--------------------------------------------------------------------------------
/src/infrastructure/widgets.css:
--------------------------------------------------------------------------------
1 | .centered-line {
2 | overflow: hidden;
3 | text-align: center;
4 | }
5 |
6 | .centered-line::before,
7 | .centered-line::after {
8 | background-color: #000;
9 | content: "";
10 | display: inline-block;
11 | height: 1px;
12 | position: relative;
13 | vertical-align: middle;
14 | width: 50%;
15 | }
16 |
17 | .root-dark-mode .centered-line {
18 | color: var(--foreground-dark);
19 | }
20 | .root-dark-mode .centered-line::before, .root-dark-mode .centered-line::after {
21 | background-color: var(--foreground-dark);
22 | }
23 |
24 | .centered-line::before {
25 | right: 1em;
26 | margin-left: -50%;
27 | }
28 |
29 | .centered-line::after {
30 | left: 1em;
31 | margin-right: -50%;
32 | }
33 |
34 | .title-line {
35 | color: #fff;
36 | margin-top: 1em;
37 | }
38 | .title-line::before,
39 | .title-line::after {
40 | background-color: #fff;
41 | box-shadow: 0 1px 1px #000;
42 | }
43 |
44 | .root-dark-mode .title-line {
45 | color: var(--foreground-dark);
46 | }
47 | .root-dark-mode .title-line::before, .root-dark-mode .title-line::after {
48 | background-color: var(--foreground-dark);
49 | }
50 |
51 | .app-switcher {
52 | display: flex;
53 | height: 2em;
54 | text-align: center;
55 | margin: 0 .1em;
56 | user-select: none;
57 | }
58 | .app-switcher-desc {
59 | margin: 0 .5em;
60 | flex: 1 1 0;
61 | opacity: .5;
62 | height: 2em;
63 | line-height: 2rem;
64 | font-size: .8em;
65 | }
66 |
67 | .root-dark-mode .app-switcher-desc {
68 | color: var(--foreground-dark);
69 | }
70 |
71 | @media screen and (max-width: 570px) {
72 | .app-switcher-desc {
73 | flex: 1 1 0;
74 | display: none;
75 | }
76 | .app-switcher-item {
77 | flex: 1 1 0 !important;
78 | padding: 0 !important;
79 | }
80 | .app-switcher-dropdown-title {
81 | padding-left: 0 !important;
82 | padding-right: 0 !important;
83 | text-align: center !important;
84 | }
85 | .app-switcher-dropdown-item {
86 | margin-left: -2em !important;
87 | margin-right: 0 !important;
88 | }
89 | }
90 |
91 | .app-switcher a:hover { /* reset underline from /hole style */
92 | border-bottom: unset;
93 | margin-bottom: unset;
94 | }
95 |
96 | .app-switcher-desc a {
97 | color: unset;
98 | }
99 |
100 | .app-switcher-left {
101 | text-align: right;
102 | }
103 | .app-switcher-right {
104 | text-align: left;
105 | }
106 | .app-switcher-item {
107 | flex: 0 0 auto;
108 | border-radius: 3px;
109 | height: 1.6em;
110 | line-height: 1.6em;
111 | margin: .2em .1em;
112 | padding: 0 .45em;
113 | }
114 | a.app-switcher-item, .app-switcher-item a {
115 | transition: unset; /* override ant design */
116 | color: black;
117 | }
118 | .app-switcher-item img {
119 | width: 1.2rem;
120 | height: 1.2rem;
121 | position: relative;
122 | top: .2rem;
123 | vertical-align: unset; /* override ant design */
124 | }
125 | .app-switcher-item span:not(:empty) {
126 | margin-left: .2rem;
127 | }
128 | .app-switcher-logo-hover {
129 | margin-left: -1.2rem;
130 | }
131 |
132 | .app-switcher-item:hover {
133 | background-color: black;
134 | color: white !important;
135 | }
136 | .app-switcher-item:hover a {
137 | color: white !important;
138 | }
139 | .app-switcher-item-current {
140 | background-color: rgba(0,0,0,.4);
141 | text-shadow: 0 0 5px rgba(0,0,0,.5);
142 | color: white !important;
143 | }
144 | .app-switcher-item-current a {
145 | color: white !important;
146 | }
147 |
148 | .root-dark-mode .app-switcher-item, .root-dark-mode .app-switcher-dropdown-title a {
149 | color: var(--foreground-dark);
150 | }
151 | .root-dark-mode .app-switcher-item:hover, .root-dark-mode .app-switcher-item-current, .root-dark-mode .app-switcher-dropdown-title:hover a {
152 | background-color: #555;
153 | color: var(--foreground-dark);
154 | }
155 |
156 | .app-switcher-item:hover .app-switcher-logo-normal, .app-switcher-item-current .app-switcher-logo-normal {
157 | opacity: 0;
158 | }
159 | .app-switcher-item:not(.app-switcher-item-current):not(:hover) .app-switcher-logo-hover {
160 | opacity: 0;
161 | }
162 |
163 | .root-dark-mode .app-switcher-logo-normal {
164 | opacity: 0 !important;
165 | }
166 | .root-dark-mode .app-switcher-logo-hover {
167 | opacity: 1 !important;
168 | }
169 |
170 | .app-switcher-dropdown {
171 | padding: 0;
172 | text-align: left;
173 | }
174 |
175 | .app-switcher-dropdown:not(:hover) {
176 | max-height: 1.6rem;
177 | overflow: hidden;
178 | }
179 |
180 | .app-switcher-dropdown-item {
181 | background-color: hsla(0,0%,35%,.9);
182 | padding: .125em .25em;
183 | margin-left: -.75em;
184 | margin-right: -.75em;
185 | position: relative;
186 | z-index: 10;
187 | cursor: pointer;
188 | }
189 | .app-switcher-dropdown-item:hover {
190 | background-color: rgba(0,0,0,.9);
191 | }
192 | .app-switcher-dropdown-item:nth-child(2) {
193 | border-top-left-radius: 3px;
194 | border-top-right-radius: 3px;
195 | }
196 | .app-switcher-dropdown-item:last-child {
197 | border-bottom-left-radius: 3px;
198 | border-bottom-right-radius: 3px;
199 | }
200 |
201 | .app-switcher-dropdown-title {
202 | padding-bottom: .2em;
203 | padding-left: .5em;
204 | padding-right: .25em;
205 | }
206 | .app-switcher-dropdown-title a {
207 | cursor: unset;
208 | }
209 |
210 | .login-popup {
211 | font-size: 1rem;
212 | background-color: #f7f7f7;
213 | color: black;
214 | position: fixed;
215 | left: 50%;
216 | top: 50%;
217 | width: 320px;
218 | z-index: 114515;
219 | transform: translateX(-50%) translateY(-50%);
220 | border-radius: 5px;
221 | }
222 | .login-popup a {
223 | color: #00c;
224 | }
225 | .login-popup p {
226 | margin: .75em 0;
227 | text-align: center;
228 | }
229 | /* override ant design */
230 | .login-popup input, .login-popup button {
231 | font-size: .85em;
232 | vertical-align: middle;
233 | }
234 | .login-popup input:not([type="checkbox"]) {
235 | width: 8rem;
236 | border-radius: 5px;
237 | border: 1px solid black;
238 | outline: none;
239 | margin: 0;
240 | padding: 0 .5em;
241 | line-height: 2em;
242 | }
243 | .login-popup button {
244 | width: 6rem;
245 | color: black;
246 | background-color: rgba(235,235,235,.5);
247 | border-radius: 5px;
248 | text-align: center;
249 | border: 1px solid black;
250 | line-height: 2em;
251 | margin: 0 .5rem;
252 | }
253 | .login-popup button:hover {
254 | background-color: rgba(255,255,255,.7);
255 | }
256 | .login-popup button:disabled {
257 | background-color: rgba(128,128,128,.5);
258 | }
259 | .login-type {
260 | display: inline-block;
261 | width: 6rem;
262 | margin: 0 .5rem;
263 | }
264 | .login-popup-shadow {
265 | opacity: .5;
266 | background-color: black;
267 | position: fixed;
268 | left: 0;
269 | top: 0;
270 | height: 100%;
271 | width: 100%;
272 | z-index: 114514;
273 | }
274 |
275 | .login-popup label.perm-item {
276 | font-size: .8em;
277 | vertical-align: .1rem;
278 | margin-left: .5rem;
279 | }
280 |
281 | .aux-margin {
282 | width: calc(100% - 2 * 50px);
283 | margin: 0 50px;
284 | }
285 | @media screen and (max-width: 1300px) {
286 | .aux-margin {
287 | width: calc(100% - 2 * 10px);
288 | margin: 0 10px;
289 | }
290 | }
291 |
292 | .title {
293 | font-size: 1.5em;
294 | height: 4rem;
295 | padding-top: 1rem;
296 | text-align: center;
297 | }
298 |
299 | .time-str {
300 | color: #999999;
301 | }
302 |
303 | /*.g-recaptcha {*/
304 | /* -webkit-transform: scale(0.77);*/
305 | /* -moz-transform: scale(0.77);*/
306 | /* -ms-transform: scale(0.77);*/
307 | /* -o-transform: scale(0.77);*/
308 | /* transform: scale(0.77);*/
309 | /* -webkit-transform-origin: 0 0;*/
310 | /* -moz-transform-origin: 0 0;*/
311 | /* -ms-transform-origin: 0 0;*/
312 | /* -o-transform-origin: 0 0;*/
313 | /* transform-origin: 0 0;*/
314 | /*}*/
--------------------------------------------------------------------------------
/src/old_infrastructure/widgets.css:
--------------------------------------------------------------------------------
1 | .centered-line {
2 | overflow: hidden;
3 | text-align: center;
4 | }
5 |
6 | .centered-line::before,
7 | .centered-line::after {
8 | background-color: #000;
9 | content: "";
10 | display: inline-block;
11 | height: 1px;
12 | position: relative;
13 | vertical-align: middle;
14 | width: 50%;
15 | }
16 |
17 | .root-dark-mode .centered-line {
18 | color: var(--foreground-dark);
19 | }
20 | .root-dark-mode .centered-line::before, .root-dark-mode .centered-line::after {
21 | background-color: var(--foreground-dark);
22 | }
23 |
24 | .centered-line::before {
25 | right: 1em;
26 | margin-left: -50%;
27 | }
28 |
29 | .centered-line::after {
30 | left: 1em;
31 | margin-right: -50%;
32 | }
33 |
34 | .title-line {
35 | color: #fff;
36 | margin-top: 1em;
37 | }
38 | .title-line::before,
39 | .title-line::after {
40 | background-color: #fff;
41 | box-shadow: 0 1px 1px #000;
42 | }
43 |
44 | .root-dark-mode .title-line {
45 | color: var(--foreground-dark);
46 | }
47 | .root-dark-mode .title-line::before, .root-dark-mode .title-line::after {
48 | background-color: var(--foreground-dark);
49 | }
50 |
51 | .app-switcher {
52 | display: flex;
53 | height: 2em;
54 | text-align: center;
55 | margin: 0 .1em;
56 | user-select: none;
57 | }
58 | .app-switcher-desc {
59 | margin: 0 .5em;
60 | flex: 1 1 0;
61 | opacity: .5;
62 | height: 2em;
63 | line-height: 2rem;
64 | font-size: .8em;
65 | }
66 |
67 | .root-dark-mode .app-switcher-desc {
68 | color: var(--foreground-dark);
69 | }
70 |
71 | @media screen and (max-width: 570px) {
72 | .app-switcher-desc {
73 | flex: 1 1 0;
74 | display: none;
75 | }
76 | .app-switcher-item {
77 | flex: 1 1 0 !important;
78 | padding: 0 !important;
79 | }
80 | .app-switcher-dropdown-title {
81 | padding-left: 0 !important;
82 | padding-right: 0 !important;
83 | text-align: center !important;
84 | }
85 | .app-switcher-dropdown-item {
86 | margin-left: -2em !important;
87 | margin-right: 0 !important;
88 | }
89 | }
90 |
91 | .app-switcher a:hover { /* reset underline from /hole style */
92 | border-bottom: unset;
93 | margin-bottom: unset;
94 | }
95 |
96 | .app-switcher-desc a {
97 | color: unset;
98 | }
99 |
100 | .app-switcher-left {
101 | text-align: right;
102 | }
103 | .app-switcher-right {
104 | text-align: left;
105 | }
106 | .app-switcher-item {
107 | flex: 0 0 auto;
108 | border-radius: 3px;
109 | height: 1.6em;
110 | line-height: 1.6em;
111 | margin: .2em .1em;
112 | padding: 0 .45em;
113 | }
114 | a.app-switcher-item, .app-switcher-item a {
115 | transition: unset; /* override ant design */
116 | color: black;
117 | }
118 | .app-switcher-item img {
119 | width: 1.2rem;
120 | height: 1.2rem;
121 | position: relative;
122 | top: .2rem;
123 | vertical-align: unset; /* override ant design */
124 | }
125 | .app-switcher-item span:not(:empty) {
126 | margin-left: .2rem;
127 | }
128 | .app-switcher-logo-hover {
129 | margin-left: -1.2rem;
130 | }
131 |
132 | .app-switcher-item:hover {
133 | background-color: black;
134 | color: white !important;
135 | }
136 | .app-switcher-item:hover a {
137 | color: white !important;
138 | }
139 | .app-switcher-item-current {
140 | background-color: rgba(0,0,0,.4);
141 | text-shadow: 0 0 5px rgba(0,0,0,.5);
142 | color: white !important;
143 | }
144 | .app-switcher-item-current a {
145 | color: white !important;
146 | }
147 |
148 | .root-dark-mode .app-switcher-item, .root-dark-mode .app-switcher-dropdown-title a {
149 | color: var(--foreground-dark);
150 | }
151 | .root-dark-mode .app-switcher-item:hover, .root-dark-mode .app-switcher-item-current, .root-dark-mode .app-switcher-dropdown-title:hover a {
152 | background-color: #555;
153 | color: var(--foreground-dark);
154 | }
155 |
156 | .app-switcher-item:hover .app-switcher-logo-normal, .app-switcher-item-current .app-switcher-logo-normal {
157 | opacity: 0;
158 | }
159 | .app-switcher-item:not(.app-switcher-item-current):not(:hover) .app-switcher-logo-hover {
160 | opacity: 0;
161 | }
162 |
163 | .root-dark-mode .app-switcher-logo-normal {
164 | opacity: 0 !important;
165 | }
166 | .root-dark-mode .app-switcher-logo-hover {
167 | opacity: 1 !important;
168 | }
169 |
170 | .app-switcher-dropdown {
171 | padding: 0;
172 | text-align: left;
173 | }
174 |
175 | .app-switcher-dropdown:not(:hover) {
176 | max-height: 1.6rem;
177 | overflow: hidden;
178 | }
179 |
180 | .app-switcher-dropdown-item {
181 | background-color: hsla(0,0%,35%,.9);
182 | padding: .125em .25em;
183 | margin-left: -.75em;
184 | margin-right: -.75em;
185 | position: relative;
186 | z-index: 10;
187 | cursor: pointer;
188 | }
189 | .app-switcher-dropdown-item:hover {
190 | background-color: rgba(0,0,0,.9);
191 | }
192 | .app-switcher-dropdown-item:nth-child(2) {
193 | border-top-left-radius: 3px;
194 | border-top-right-radius: 3px;
195 | }
196 | .app-switcher-dropdown-item:last-child {
197 | border-bottom-left-radius: 3px;
198 | border-bottom-right-radius: 3px;
199 | }
200 |
201 | .app-switcher-dropdown-title {
202 | padding-bottom: .2em;
203 | padding-left: .5em;
204 | padding-right: .25em;
205 | }
206 | .app-switcher-dropdown-title a {
207 | cursor: unset;
208 | }
209 |
210 | .treehollow-login-popup {
211 | font-size: 1rem;
212 | background-color: #f7f7f7;
213 | color: black;
214 | position: fixed;
215 | left: 50%;
216 | top: 50%;
217 | width: 320px;
218 | z-index: 114515;
219 | transform: translateX(-50%) translateY(-50%);
220 | border-radius: 5px;
221 | }
222 | .treehollow-login-popup a {
223 | color: #00c;
224 | }
225 | .treehollow-login-popup p {
226 | margin: .75em 0;
227 | text-align: center;
228 | }
229 | /* override ant design */
230 | .treehollow-login-popup input, .treehollow-login-popup button {
231 | font-size: .85em;
232 | vertical-align: middle;
233 | }
234 | .treehollow-login-popup input:not([type="checkbox"]) {
235 | width: 8rem;
236 | border-radius: 5px;
237 | border: 1px solid black;
238 | outline: none;
239 | margin: 0;
240 | padding: 0 .5em;
241 | line-height: 2em;
242 | }
243 | .treehollow-login-popup button {
244 | width: 6rem;
245 | color: black;
246 | background-color: rgba(235,235,235,.5);
247 | border-radius: 5px;
248 | text-align: center;
249 | border: 1px solid black;
250 | line-height: 2em;
251 | margin: 0 .5rem;
252 | }
253 | .treehollow-login-popup button:hover {
254 | background-color: rgba(255,255,255,.7);
255 | }
256 | .treehollow-login-popup button:disabled {
257 | background-color: rgba(128,128,128,.5);
258 | }
259 | .treehollow-login-type {
260 | display: inline-block;
261 | width: 6rem;
262 | margin: 0 .5rem;
263 | }
264 | .treehollow-login-popup-shadow {
265 | opacity: .5;
266 | background-color: black;
267 | position: fixed;
268 | left: 0;
269 | top: 0;
270 | height: 100%;
271 | width: 100%;
272 | z-index: 114514;
273 | }
274 |
275 | .treehollow-login-popup label.perm-item {
276 | font-size: .8em;
277 | vertical-align: .1rem;
278 | margin-left: .5rem;
279 | }
280 |
281 | .aux-margin {
282 | width: calc(100% - 2 * 50px);
283 | margin: 0 50px;
284 | }
285 | @media screen and (max-width: 1300px) {
286 | .aux-margin {
287 | width: calc(100% - 2 * 10px);
288 | margin: 0 10px;
289 | }
290 | }
291 |
292 | .title {
293 | font-size: 1.5em;
294 | height: 4rem;
295 | padding-top: 1rem;
296 | text-align: center;
297 | }
298 |
299 | .time-str {
300 | color: #999999;
301 | }
302 |
303 | /*.g-recaptcha {*/
304 | /* -webkit-transform: scale(0.77);*/
305 | /* -moz-transform: scale(0.77);*/
306 | /* -ms-transform: scale(0.77);*/
307 | /* -o-transform: scale(0.77);*/
308 | /* transform: scale(0.77);*/
309 | /* -webkit-transform-origin: 0 0;*/
310 | /* -moz-transform-origin: 0 0;*/
311 | /* -ms-transform-origin: 0 0;*/
312 | /* -o-transform-origin: 0 0;*/
313 | /* transform-origin: 0 0;*/
314 | /*}*/
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Flow } from './Flows';
3 | import { Title } from './Title';
4 | import { Sidebar } from './Sidebar';
5 | import { SwitchTransition, CSSTransition } from 'react-transition-group';
6 | import { PressureHelper } from './PressureHelper';
7 | import { TokenCtx } from './UserAction';
8 | import { load_config, bgimg_style } from './Config';
9 | import { listen_darkmode } from './old_infrastructure/functions';
10 | import { TitleLine } from './old_infrastructure/widgets';
11 | import { LoginPopup } from './login';
12 | import { cache } from './cache';
13 | import './App.css';
14 | import { HighlightedMarkdown } from './Common';
15 |
16 | const MAX_SIDEBAR_STACK_SIZE = 10;
17 |
18 | function DeprecatedAlert(props) {
19 | return ;
20 | }
21 |
22 | function needShowSuicidePrompt(text) {
23 | return text && text.indexOf('自杀') !== -1;
24 | }
25 |
26 | class App extends Component {
27 | constructor(props) {
28 | super(props);
29 | load_config();
30 | listen_darkmode(
31 | { default: undefined, light: false, dark: true }[
32 | window.config.color_scheme
33 | ],
34 | );
35 | this.state = {
36 | sidebar_stack: [[null, null]], // list of [status, content]
37 | mode: 'list', // list, single, search, attention
38 | search_text: null,
39 | flow_render_key: +new Date(),
40 | token: localStorage['TOKEN'] || null,
41 | override_suicide: false,
42 | };
43 | this.show_sidebar_bound = this.show_sidebar.bind(this);
44 | this.set_mode_bound = this.set_mode.bind(this);
45 | this.on_pressure_bound = this.on_pressure.bind(this);
46 | // a silly self-deceptive approach to ban guests, enough to fool those muggles
47 | // document cookie 'pku_ip_flag=yes'
48 | this.inthu_flag = false;
49 | // window[atob('ZG9jdW1lbnQ')][atob('Y29va2ll')].indexOf(
50 | // atob('dGh1X2lwX2ZsYWc9eWVz'),
51 | // ) === -1;
52 | }
53 |
54 | static is_darkmode() {
55 | if (window.config.color_scheme === 'dark') return true;
56 | if (window.config.color_scheme === 'light') return false;
57 | else {
58 | // 'default'
59 | return window.matchMedia('(prefers-color-scheme: dark)').matches;
60 | }
61 | }
62 | componentDidMount() {
63 | cache(); // init indexeddb
64 | }
65 |
66 | on_pressure() {
67 | if (this.state.sidebar_stack.length > 1)
68 | this.show_sidebar(null, null, 'clear');
69 | else this.set_mode('list', null);
70 | }
71 |
72 | show_sidebar(title, content, mode = 'push') {
73 | this.setState((prevState) => {
74 | let ns = prevState.sidebar_stack.slice();
75 | if (mode === 'push') {
76 | if (ns.length === 1) {
77 | document.body.style.top = `-${window.scrollY}px`;
78 | document.body.style.position = 'fixed';
79 | document.body.style.width = '100vw'; // Be responsive with fixed position
80 | }
81 | if (ns.length > MAX_SIDEBAR_STACK_SIZE) ns.splice(1, 1);
82 | ns = ns.concat([[title, content]]);
83 | } else if (mode === 'pop') {
84 | if (ns.length === 1) return;
85 | if (ns.length === 2) {
86 | const scrollY = document.body.style.top;
87 | document.body.style.position = '';
88 | document.body.style.top = '';
89 | document.body.style.width = '';
90 | window.scrollTo(0, parseInt(scrollY || '0') * -1);
91 | }
92 | ns.pop();
93 | } else if (mode === 'replace') {
94 | ns.pop();
95 | ns = ns.concat([[title, content]]);
96 | } else if (mode === 'clear') {
97 | const scrollY = document.body.style.top;
98 | document.body.style.position = '';
99 | document.body.style.top = '';
100 | document.body.style.width = '';
101 | window.scrollTo(0, parseInt(scrollY || '0') * -1);
102 | ns = [[null, null]];
103 | } else throw new Error('bad show_sidebar mode');
104 | return {
105 | sidebar_stack: ns,
106 | };
107 | });
108 | }
109 |
110 | set_mode(mode, search_text) {
111 | this.setState({
112 | mode: mode,
113 | search_text: search_text,
114 | flow_render_key: +new Date(),
115 | });
116 | }
117 |
118 | render() {
119 | return (
120 | {
124 | localStorage['TOKEN'] = x || '';
125 | this.setState({
126 | token: x,
127 | });
128 | },
129 | }}
130 | >
131 |
132 |
133 |
138 |
139 | {(token) => (
140 |
141 |
142 | {!token.value && (
143 |
144 |
156 |
157 |
158 |
公告栏
159 |
162 |
163 |
166 |
167 |
168 |
169 | )}
170 | {needShowSuicidePrompt(this.state.search_text) &&
171 | !this.state.override_suicide && (
172 |
173 |
174 |
需要帮助?
175 |
176 | 北京24小时心理援助热线:
177 | 010-8295-1332
178 |
179 |
180 | 希望24小时热线:
181 | 400-161-9995
182 |
183 |
184 |
185 |
193 |
194 |
203 |
204 |
205 |
206 | )}
207 | {this.inthu_flag || token.value ? (
208 | (this.state.override_suicide ||
209 | !needShowSuicidePrompt(this.state.search_text)) && (
210 |
211 |
216 |
223 |
224 |
225 | )
226 | ) : (
227 |
228 | )}
229 |
230 |
231 | )}
232 |
233 |
237 |
238 | );
239 | }
240 | }
241 |
242 | export default App;
243 |
--------------------------------------------------------------------------------
/src/delete_account.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import './login.css';
5 |
6 | import { API_ROOT } from './old_infrastructure/const';
7 | import { get_json, API_VERSION_PARAM } from './old_infrastructure/functions';
8 | import { RecaptchaV2Popup } from './login.js';
9 |
10 | import {
11 | GoogleReCaptchaProvider,
12 | GoogleReCaptcha,
13 | } from 'react-google-recaptcha-v3';
14 |
15 | const LOGIN_POPUP_ANCHOR_ID = 'pkuhelper_login_popup_anchor';
16 |
17 | class UnregisterPopupSelf extends Component {
18 | constructor(props) {
19 | super(props);
20 | this.state = {
21 | loading_status: 'idle',
22 | recaptcha_verified: false,
23 | phase: -1,
24 | // excluded_scopes: [],
25 | };
26 |
27 | this.ref = {
28 | username: React.createRef(),
29 | email_verification: React.createRef(),
30 | nonce: React.createRef(),
31 | checkbox_account: React.createRef(),
32 | };
33 |
34 | this.popup_anchor = document.getElementById(LOGIN_POPUP_ANCHOR_ID);
35 | if (!this.popup_anchor) {
36 | this.popup_anchor = document.createElement('div');
37 | this.popup_anchor.id = LOGIN_POPUP_ANCHOR_ID;
38 | document.body.appendChild(this.popup_anchor);
39 | }
40 | }
41 |
42 | valid_registration() {
43 | if (!this.ref.checkbox_account.current.checked) {
44 | alert('请同意条款与条件!');
45 | return 1;
46 | }
47 | return 0;
48 | }
49 |
50 | next_step() {
51 | if (this.state.loading_status === 'loading') return;
52 | switch (this.state.phase) {
53 | case -1:
54 | this.verify_email('v3', () => {});
55 | break;
56 | case 1:
57 | this.delete_account();
58 | break;
59 | case 3:
60 | this.need_recaptcha();
61 | break;
62 | }
63 | }
64 |
65 | verify_email(version, failed_callback) {
66 | const old_token = new URL(location.href).searchParams.get('old_token');
67 | const email = this.ref.username.current.value;
68 | const recaptcha_version = version;
69 | const recaptcha_token = localStorage['recaptcha'];
70 | // VALIDATE EMAIL IN FRONT-END HERE
71 | const body = new URLSearchParams();
72 | Object.entries({
73 | email,
74 | old_token,
75 | recaptcha_version,
76 | recaptcha_token,
77 | }).forEach((param) => body.append(...param));
78 | this.setState(
79 | {
80 | loading_status: 'loading',
81 | },
82 | () => {
83 | fetch(
84 | API_ROOT +
85 | 'security/login/check_email_unregister?' +
86 | API_VERSION_PARAM(),
87 | {
88 | method: 'POST',
89 | body,
90 | },
91 | )
92 | .then((res) => res.json())
93 | .then((json) => {
94 | // COMMENT NEXT LINE
95 | //json.code = 2;
96 | if (json.code < 0) throw new Error(json.msg);
97 | this.setState({
98 | loading_status: 'done',
99 | phase: json.code,
100 | });
101 | if (json.code === 3) failed_callback();
102 | })
103 | .catch((e) => {
104 | alert('邮箱检验失败\n' + e);
105 | this.setState({
106 | loading_status: 'done',
107 | });
108 | console.error(e);
109 | });
110 | },
111 | );
112 | }
113 |
114 | async delete_account() {
115 | if (this.valid_registration() !== 0) return;
116 | const email = this.ref.username.current.value;
117 | const valid_code = this.ref.email_verification.current.value;
118 | const nonce = this.ref.nonce.current.value;
119 | const body = new URLSearchParams();
120 | Object.entries({
121 | email,
122 | nonce,
123 | valid_code,
124 | }).forEach((param) => body.append(...param));
125 | this.setState(
126 | {
127 | loading_status: 'loading',
128 | },
129 | () => {
130 | fetch(API_ROOT + 'security/login/unregister?' + API_VERSION_PARAM(), {
131 | method: 'POST',
132 | body,
133 | })
134 | .then(get_json)
135 | .then((json) => {
136 | if (json.code !== 0) {
137 | if (json.msg) throw new Error(json.msg);
138 | throw new Error(JSON.stringify(json));
139 | }
140 |
141 | alert('注销账户成功');
142 | this.setState({
143 | loading_status: 'done',
144 | });
145 | this.props.on_close();
146 | })
147 | .catch((e) => {
148 | console.error(e);
149 | alert('失败\n' + e);
150 | this.setState({
151 | loading_status: 'done',
152 | });
153 | });
154 | },
155 | );
156 | }
157 |
158 | need_recaptcha() {
159 | console.log(3);
160 | }
161 |
162 | render() {
163 | window.recaptchaOptions = {
164 | useRecaptchaNet: true,
165 | };
166 | return ReactDOM.createPortal(
167 |
171 |
172 |
173 |
174 | {this.state.phase === -1 && (
175 | <>
176 |
177 | 输入邮箱来注销账户/找回密码
178 |
179 | >
180 | )}
181 |
182 |
196 |
197 | {this.state.phase === 1 && (
198 | <>
199 |
200 | {process.env.REACT_APP_TITLE} 注销账户
201 |
202 |
203 |
211 |
212 |
213 | Nonce:
214 |
217 |
218 |
219 | 注:Nonce是注册树洞时欢迎邮件中的“找回密码口令”,形如xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx。
220 |
221 |
222 |
226 |
227 | >
228 | )}
229 | {this.state.phase === 3 && (
230 | <>
231 |
232 | 输入验证码 {process.env.REACT_APP_TITLE}
233 |
234 |
{
236 | this.verify_email('v2', () => {
237 | alert('reCAPTCHA风控系统校验失败');
238 | });
239 | }}
240 | >
241 | {(do_popup) => (
242 |
243 | {!this.state.recaptcha_verified && (
244 | {
246 | this.setState({
247 | recaptcha_verified: true,
248 | });
249 | console.log(token);
250 | localStorage['recaptcha'] = token;
251 | this.verify_email('v3', do_popup);
252 | }}
253 | />
254 | )}
255 |
256 | )}
257 |
258 | >
259 | )}
260 |
261 |
267 |
268 |
269 |
270 |
271 | ,
272 | this.popup_anchor,
273 | );
274 | }
275 | }
276 |
277 | export class UnregisterPopup extends Component {
278 | constructor(props) {
279 | super(props);
280 | this.state = {
281 | popup_show: false,
282 | };
283 | this.on_popup_bound = this.on_popup.bind(this);
284 | this.on_close_bound = this.on_close.bind(this);
285 | }
286 |
287 | on_popup() {
288 | this.setState({
289 | popup_show: true,
290 | });
291 | }
292 |
293 | on_close() {
294 | this.setState({
295 | popup_show: false,
296 | });
297 | }
298 |
299 | render() {
300 | return (
301 | <>
302 | {this.props.children(this.on_popup_bound)}
303 | {this.state.popup_show && (
304 |
305 | )}
306 | >
307 | );
308 | }
309 | }
310 |
--------------------------------------------------------------------------------
/src/Flows.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --box-bgcolor-light: hsl(0, 0%, 97%);
3 | --box-bgcolor-dark: hsl(0, 0%, 16%);
4 | }
5 |
6 | .box {
7 | background-color: var(--box-bgcolor-light);
8 | color: black;
9 | border-radius: 5px;
10 | margin: 1em 0;
11 | padding: 0.5em;
12 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4);
13 | }
14 |
15 | .root-dark-mode .box {
16 | background-color: var(--box-bgcolor-dark);
17 | color: var(--foreground-dark);
18 | box-shadow: 0 0 2px rgba(255, 255, 255, 0.25), 0 0 7px rgba(0, 0, 0, 0.15);
19 | }
20 |
21 | .box-tip {
22 | min-width: 100px;
23 | z-index: 1;
24 | text-align: center;
25 | }
26 |
27 | .box-danger {
28 | background-color: #d44;
29 | color: white;
30 | text-shadow: 0 0 2px black;
31 | }
32 | .root-dark-mode .box-danger {
33 | background-color: #c44;
34 | color: var(--foreground-dark);
35 | }
36 |
37 | .box-announcement {
38 | /*background-color: #db8e14;*/
39 | background-color: rgb(164, 185, 217);
40 | /*color: white;*/
41 | /*text-shadow: 0 0 1px black;*/
42 | /*--var-link-color: rgb(164, 185, 217);*/
43 | }
44 |
45 | .root-dark-mode .box-announcement {
46 | /*background-color: #b47209;*/
47 | background-color: rgb(164, 185, 217);
48 | /*background-color: rgb(35, 67, 161);*/
49 | color: black;
50 | --var-link-color: #00c;
51 | /*text-shadow: 0 0 1px black;*/
52 | }
53 |
54 | .box-warning {
55 | background-color: #db8e14;
56 | color: white;
57 | text-shadow: 0 0 2px black;
58 | }
59 | .root-dark-mode .box-warning {
60 | background-color: #b47209;
61 | text-shadow: 0 0 2px black;
62 | }
63 |
64 | .box-danger a,
65 | .box-warning a {
66 | color: #ddf;
67 | }
68 | .box-danger a:hover,
69 | .box-warning a:hover {
70 | border-bottom: 1px solid #ddf;
71 | }
72 |
73 | .left-container .flow-item {
74 | display: inline-block;
75 | width: 600px;
76 | float: left;
77 | }
78 |
79 | .flow-reply-row {
80 | display: inline-flex;
81 | align-items: flex-start;
82 | width: calc(100% - 625px);
83 | margin-left: -25px;
84 | padding-left: 18px;
85 | overflow-x: auto;
86 | }
87 |
88 | .sidebar-flow-item .flow-item pre,
89 | .sidebar-flow-item .flow-reply pre {
90 | cursor: text;
91 | }
92 |
93 | .flow-reply-row::-webkit-scrollbar {
94 | display: none;
95 | }
96 | .flow-reply-row {
97 | scrollbar-width: none;
98 | -ms-overflow-style: none;
99 | }
100 |
101 | .flow-reply-row:empty {
102 | margin: 0 !important;
103 | display: none;
104 | }
105 |
106 | .flow-item-row::after {
107 | content: '';
108 | display: block;
109 | clear: both;
110 | }
111 |
112 | .left-container .flow-reply {
113 | flex: 0 0 300px;
114 | max-height: 15em;
115 | margin-right: -7px;
116 | overflow-y: hidden;
117 | }
118 |
119 | .left-container .flow-item {
120 | margin-left: 50px;
121 | }
122 |
123 | @media screen and (min-width: 1301px) {
124 | .left-container .flow-item-row-with-prompt:hover::before {
125 | content: '>>';
126 | position: absolute;
127 | left: 10px;
128 | margin-top: 1.5em;
129 | color: white;
130 | text-shadow: /* copied from .black-outline */ -1px -1px 0 rgba(0, 0, 0, 0.6),
131 | 0 -1px 0 rgba(0, 0, 0, 0.6), 1px -1px 0 rgba(0, 0, 0, 0.6),
132 | -1px 1px 0 rgba(0, 0, 0, 0.6), 0 1px 0 rgba(0, 0, 0, 0.6),
133 | 1px 1px 0 rgba(0, 0, 0, 0.6);
134 | font-family: 'Consolas', 'Courier', monospace;
135 | }
136 | }
137 |
138 | @media screen and (max-width: 1300px) {
139 | .left-container .flow-item {
140 | margin-left: 10px;
141 | }
142 |
143 | .flow-reply-row {
144 | width: calc(100% - 485px);
145 | }
146 |
147 | .left-container .flow-item {
148 | width: 500px;
149 | }
150 |
151 | .flow-item-row:hover::before {
152 | display: none;
153 | }
154 | }
155 |
156 | @media screen and (max-width: 900px) {
157 | .left-container .flow-item {
158 | display: block;
159 | width: calc(100vw - 20px);
160 | max-width: 500px;
161 | float: none;
162 | }
163 |
164 | .flow-reply-row {
165 | display: flex;
166 | width: 100% !important;
167 | margin-left: 0;
168 | padding-left: 30px;
169 | margin-top: -2.5em;
170 | margin-bottom: -1em;
171 | }
172 | }
173 |
174 | .left-container .flow-item-row {
175 | cursor: default;
176 | }
177 |
178 | .box-header,
179 | .box-footer {
180 | font-size: 0.8em;
181 | }
182 |
183 | .flow-item-row p.img {
184 | text-align: center;
185 | margin-top: 0.5em;
186 | }
187 | .flow-item-row p.img img {
188 | max-width: 100%;
189 | box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);
190 | }
191 |
192 | .left-container .flow-item-row p.img img {
193 | max-height: 80vh;
194 | }
195 |
196 | .root-dark-mode .flow-item-row p.img img {
197 | filter: brightness(85%);
198 | }
199 |
200 | .box-header-badge {
201 | float: right;
202 | margin: 0 0.5em;
203 | }
204 |
205 | .flow-item-dot {
206 | position: relative;
207 | top: calc(-0.5em - 3px);
208 | left: calc(-0.5em - 3px);
209 | width: 9px;
210 | height: 9px;
211 | margin-bottom: -9px;
212 | border-radius: 50%;
213 | box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.5);
214 | display: none;
215 | }
216 |
217 | .flow-item-dot-post {
218 | background-color: #ffcc77;
219 | }
220 | .root-dark-mode .flow-item-dot-post {
221 | background-color: #eebb66;
222 | }
223 |
224 | .flow-item-dot-comment {
225 | background-color: #aaddff;
226 | }
227 | .root-dark-mode .flow-item-dot-comment {
228 | background-color: #99ccee;
229 | }
230 |
231 | .left-container .flow-item-dot {
232 | display: block;
233 | }
234 |
235 | .box-content {
236 | padding: 0.5em 0;
237 | overflow-x: auto;
238 | }
239 |
240 | .left-container .box-content {
241 | max-height: calc(100vh + 15em);
242 | overflow-y: hidden;
243 | }
244 |
245 | .box-id {
246 | color: #666666;
247 | }
248 |
249 | .root-dark-mode .box-id {
250 | color: #bbbbbb;
251 | }
252 |
253 | .box-id a:hover::before {
254 | content: '复制全文';
255 | position: relative;
256 | width: 5em;
257 | height: 1.3em;
258 | line-height: 1.3em;
259 | margin-bottom: -1.3em;
260 | border-radius: 3px;
261 | text-align: center;
262 | top: -1.5em;
263 | display: block;
264 | color: white;
265 | background-color: rgba(0, 0, 0, 0.6);
266 | pointer-events: none;
267 | }
268 |
269 | .flow-item-row-quote {
270 | opacity: 0.9;
271 | filter: brightness(90%);
272 | }
273 |
274 | .root-dark-mode .flow-item-row-quote {
275 | opacity: 0.7;
276 | filter: unset;
277 | }
278 |
279 | .flow-item-quote > .box {
280 | margin-left: 2.5em;
281 | max-height: 15em;
282 | overflow-y: hidden;
283 | }
284 |
285 | .flow-item-quote .flow-item-dot,
286 | .flow-item-quote .box-id a:hover::before {
287 | display: none;
288 | }
289 |
290 | .quote-tip {
291 | margin-top: 0.5em;
292 | margin-bottom: -10em; /* so that it will not block reply bar */
293 | float: left;
294 | display: flex;
295 | flex-direction: column;
296 | width: 2.5em;
297 | text-align: center;
298 | color: white;
299 | }
300 |
301 | .box-header-tag {
302 | color: white;
303 | background-color: #00c;
304 | font-weight: bold;
305 | border-radius: 3px;
306 | margin-right: 0.25em;
307 | padding: 0 0.25em;
308 | }
309 |
310 | .root-dark-mode .box-header-tag {
311 | background-color: #00a;
312 | }
313 |
314 | .filter-name-bar {
315 | animation: slide-in-from-top 0.15s ease-out;
316 | position: sticky;
317 | top: 1em;
318 | }
319 |
320 | @keyframes slide-in-from-top {
321 | 0% {
322 | opacity: 0;
323 | transform: translateY(-50%);
324 | }
325 | 100% {
326 | opacity: 1;
327 | }
328 | }
329 |
330 | .reply-header-badge {
331 | float: right;
332 | padding: 0.5em 0.2em 0.5em 0.2em;
333 | margin: -0.5em -0.2em -0.5em 0.2em;
334 | opacity: 0.45;
335 | }
336 | .reply-header-badge:hover {
337 | opacity: 1;
338 | }
339 |
340 | .flow-variant-warning {
341 | color: red;
342 | font-weight: bold;
343 | }
344 |
345 | .report-toolbar button {
346 | line-height: 2em;
347 | margin: 0.2em 0.5em 0.2em 0;
348 | min-width: 4em;
349 | }
350 | .report-reason {
351 | font-size: 0.9em;
352 | }
353 |
354 | .flow-hint {
355 | opacity: 0.6;
356 | font-size: 0.8em;
357 | }
358 |
359 | .box-header-text {
360 | opacity: 0.6;
361 | }
362 |
363 | /* 投票相关 */
364 | .voteButton {
365 | width: 100%;
366 | margin: 2px 0px;
367 | background-color: white;
368 | color: black;
369 | border-color: #d9d9d9;
370 | border-style: solid;
371 | border-width: 1px;
372 | border-radius: 3px;
373 | cursor: pointer;
374 | }
375 | .div-shell {
376 | position: relative;
377 | color: #000;
378 | width: 100%;
379 | height: 2em;
380 | margin: 5px 0;
381 | border:none;
382 | }
383 | .div-background {
384 | position: absolute;
385 | left: 0px;
386 | top: 0px;
387 | background-color: #fff;
388 | width: 100%;
389 | height: 2em;
390 | border: 1px solid #dfdfdf;
391 | border-radius:5px;
392 | }
393 | /*普通选项的进度条*/
394 | .div-optionBar {
395 | position: absolute;
396 | left: 0px;
397 | top: 0px;
398 | background-color: #d3d3d3;
399 | width: 0px;
400 | height: 2em;
401 | border-radius:5px;
402 | border: 1px solid #dfdfdf;
403 | }
404 |
405 | /*选中选项的进度条*/
406 | .div-votedOptionBar {
407 | position: absolute;
408 | left: 0px;
409 | top: 0px;
410 | background-color: #ffc7ab;
411 | border: 1px solid #ffc7ab;
412 | width: 0px;
413 | height: 100%;
414 | border-radius:5px;
415 | }
416 |
417 | /* 字体:左面选项右面票数 */
418 | .div-text {
419 | position: absolute;
420 | left: 0px;
421 | top: 0px;
422 | background-color: 0;
423 | width: 100%;
424 | height: 100%;
425 | margin: 0px;
426 | padding: 0px;
427 | vertical-align: 0;
428 | }
429 |
430 | .p-voteDataShow{
431 | margin: 0;
432 | padding: 0;
433 | display: inline-block;
434 | vertical-align: middle;
435 | text-align: left;
436 | font-size: 14px;
437 | width: 85%;
438 | padding-left: 5px;
439 | }
440 |
441 | .p-voteDataShow-right{
442 | margin: 0;
443 | padding: 0;
444 | display: inline-block;
445 | vertical-align: middle;
446 | font-size: 14px;
447 | padding-left: 5px;
448 | text-align: right;
449 | width: 15%;
450 | padding-right: 5px;
451 | }
452 |
453 | /* 被引时的字体变小 */
454 | .flow-item-quote .p-voteDataShow{
455 | margin: 0;
456 | padding: 0;
457 | display: inline-block;
458 | vertical-align: middle;
459 | text-align: left;
460 | font-size: 0.7em;
461 | width: 85%;
462 | padding-left: 5px;
463 | }
464 |
465 | .flow-item-quote .p-voteDataShow-right{
466 | margin: 0;
467 | padding: 0;
468 | display: inline-block;
469 | vertical-align: middle;
470 | font-size: 0.7em;
471 | padding-left: 5px;
472 | text-align: right;
473 | width: 15%;
474 | padding-right: 5px;
475 | }
476 |
477 | .voteGroupPanel{
478 | background-color:#f2f2f5;
479 | padding:10px;
480 | }
481 |
482 | /*Dark mode*/
483 | .root-dark-mode .voteButton {
484 | width: 100%;
485 | margin: 2px 0px;
486 | background-color: #666;
487 | color: rgb(223, 223, 223);
488 | border-color: #666;
489 | border-style: solid;
490 | border-width: 1px;
491 | border-radius: 3px;
492 | cursor: pointer;
493 | }
494 | .root-dark-mode .div-shell {
495 | position: relative;
496 | color: #000;
497 | width: 100%;
498 | height: 2em;
499 | margin: 5px 0;
500 | border:none;
501 | }
502 | .root-dark-mode .div-background {
503 | position: absolute;
504 | left: 0px;
505 | top: 0px;
506 | background-color: #202020;
507 | width: 100%;
508 | height: 2em;
509 | border: 1px solid #2e2e2e;
510 | border-radius:5px;
511 | }
512 | /*普通选项的进度条*/
513 | .root-dark-mode .div-optionBar {
514 | position: absolute;
515 | left: 0px;
516 | top: 0px;
517 | background-color: #858585ea;
518 | width: 0px;
519 | height: 2em;
520 | border-radius:5px;
521 | border: 1px solid #1f1f1f;
522 | }
523 |
524 | /*选中选项的进度条*/
525 | .root-dark-mode .div-votedOptionBar {
526 | position: absolute;
527 | left: 0px;
528 | top: 0px;
529 | background-color: #44a5f5e5;
530 | border: 1px solid #44a5f5e5;
531 | width: 0px;
532 | height: 100%;
533 | border-radius:5px;
534 | }
535 |
536 | .root-dark-mode .p-voteDataShow{
537 | margin: 0;
538 | padding: 0;
539 | display: inline-block;
540 | vertical-align: middle;
541 | text-align: left;
542 | font-size: 14px;
543 | width: 85%;
544 | padding-left: 5px;
545 | color: rgb(240, 240, 240);
546 | }
547 |
548 | .root-dark-mode .p-voteDataShow-right{
549 | margin: 0;
550 | padding: 0;
551 | display: inline-block;
552 | vertical-align: middle;
553 | font-size: 14px;
554 | padding-left: 5px;
555 | text-align: right;
556 | width: 15%;
557 | padding-right: 5px;
558 | color: rgb(240, 240, 240);
559 | }
560 |
561 | .root-dark-mode .voteGroupPanel{
562 | background-color:#ffffff0e;
563 | padding:10px;
564 | }
565 |
566 | /* 被引时的字体变小 */
567 | .root-dark-mode .flow-item-quote .p-voteDataShow{
568 | margin: 0;
569 | padding: 0;
570 | display: inline-block;
571 | vertical-align: middle;
572 | text-align: left;
573 | font-size: 0.7em;
574 | width: 85%;
575 | padding-left: 5px;
576 | color: rgb(240, 240, 240);
577 | }
578 |
579 | .root-dark-mode .flow-item-quote .p-voteDataShow-right{
580 | margin: 0;
581 | padding: 0;
582 | display: inline-block;
583 | vertical-align: middle;
584 | font-size: 0.7em;
585 | padding-left: 5px;
586 | text-align: right;
587 | width: 15%;
588 | padding-right: 5px;
589 | color: rgb(240, 240, 240);
590 | }
591 |
592 | .liu_area{
593 | display: inline-block;
594 | height: 100%;
595 | vertical-align: middle;
596 | }
--------------------------------------------------------------------------------
/src/Config.js:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react';
2 |
3 | import './Config.css';
4 | import { HighlightedMarkdown } from './Common';
5 |
6 | const BUILTIN_IMGS = {
7 | 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/gbp.jpg':
8 | '寻觅繁星(默认)',
9 | 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/eriri.jpg':
10 | '平成著名画师',
11 | 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/yurucamp.jpg':
12 | '露营天下第一',
13 | 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/minecraft.jpg':
14 | '麦恩·库拉夫特',
15 | 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/cyberpunk.jpg':
16 | '赛博城市',
17 | 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/bj.jpg':
18 | '城市的星光',
19 | 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/sif.jpg':
20 | '梦开始的地方',
21 | };
22 |
23 | const DEFAULT_CONFIG = {
24 | background_img:
25 | 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/gbp.jpg',
26 | background_color: '#113366',
27 | pressure: false,
28 | easter_egg: true,
29 | color_scheme: 'default',
30 | fold: true,
31 | block_words: [],
32 | hidden_tags: [],
33 | };
34 |
35 | export function load_config() {
36 | let config = Object.assign({}, DEFAULT_CONFIG);
37 | let loaded_config;
38 | try {
39 | loaded_config = JSON.parse(localStorage['hole_config'] || '{}');
40 | } catch (e) {
41 | alert('设置加载失败,将重置为默认设置!\n' + e);
42 | delete localStorage['hole_config'];
43 | loaded_config = {};
44 | }
45 |
46 | // unrecognized configs are removed
47 | Object.keys(loaded_config).forEach((key) => {
48 | if (config[key] !== undefined) config[key] = loaded_config[key];
49 | });
50 |
51 | console.log('config loaded', config);
52 | window.config = config;
53 | }
54 | export function save_config() {
55 | localStorage['hole_config'] = JSON.stringify(window.config);
56 | load_config();
57 | }
58 |
59 | export function bgimg_style(img, color) {
60 | if (img === undefined) img = window.config.background_img;
61 | if (color === undefined) color = window.config.background_color;
62 | return {
63 | background: 'transparent center center',
64 | backgroundImage: img === null ? 'unset' : 'url("' + encodeURI(img) + '")',
65 | backgroundColor: color,
66 | backgroundSize: 'cover',
67 | };
68 | }
69 |
70 | class ConfigBackground extends PureComponent {
71 | constructor(props) {
72 | super(props);
73 | this.state = {
74 | img: window.config.background_img,
75 | color: window.config.background_color,
76 | };
77 | }
78 |
79 | save_changes() {
80 | this.props.callback({
81 | background_img: this.state.img,
82 | background_color: this.state.color,
83 | });
84 | }
85 |
86 | on_select(e) {
87 | let value = e.target.value;
88 | this.setState(
89 | {
90 | img: value === '##other' ? '' : value === '##color' ? null : value,
91 | },
92 | this.save_changes.bind(this),
93 | );
94 | }
95 | on_change_img(e) {
96 | this.setState(
97 | {
98 | img: e.target.value,
99 | },
100 | this.save_changes.bind(this),
101 | );
102 | }
103 | on_change_color(e) {
104 | this.setState(
105 | {
106 | color: e.target.value,
107 | },
108 | this.save_changes.bind(this),
109 | );
110 | }
111 |
112 | render() {
113 | let img_select =
114 | this.state.img === null
115 | ? '##color'
116 | : Object.keys(BUILTIN_IMGS).indexOf(this.state.img) === -1
117 | ? '##other'
118 | : this.state.img;
119 | return (
120 |
159 | );
160 | }
161 | }
162 |
163 | class ConfigColorScheme extends PureComponent {
164 | constructor(props) {
165 | super(props);
166 | this.state = {
167 | color_scheme: window.config.color_scheme,
168 | };
169 | }
170 |
171 | save_changes() {
172 | this.props.callback({
173 | color_scheme: this.state.color_scheme,
174 | });
175 | }
176 |
177 | on_select(e) {
178 | let value = e.target.value;
179 | this.setState(
180 | {
181 | color_scheme: value,
182 | },
183 | this.save_changes.bind(this),
184 | );
185 | }
186 |
187 | render() {
188 | return (
189 |
190 |
191 | 夜间模式:
192 |
201 | #color_scheme
202 |
203 |
204 | 选择浅色或深色模式,深色模式下将会调暗图片亮度
205 |
206 |
207 | );
208 | }
209 | }
210 |
211 | class ConfigTextArea extends PureComponent {
212 | constructor(props) {
213 | super(props);
214 | this.state = {
215 | [props.id]: window.config[props.id],
216 | };
217 | }
218 |
219 | save_changes() {
220 | this.props.callback({
221 | [this.props.id]: this.props.sift(this.state[this.props.id]),
222 | });
223 | }
224 |
225 | on_change(e) {
226 | let value = this.props.parse(e.target.value);
227 | this.setState(
228 | {
229 | [this.props.id]: value,
230 | },
231 | this.save_changes.bind(this),
232 | );
233 | }
234 |
235 | render() {
236 | return (
237 |
238 |
251 |
252 | );
253 | }
254 | }
255 |
256 | /* class ConfigBlockWords extends PureComponent {
257 | constructor(props) {
258 | super(props);
259 | this.state = {
260 | block_words: window.config.block_words,
261 | };
262 | }
263 |
264 | save_changes() {
265 | this.props.callback({
266 | block_words: this.state.block_words.filter((v) => v),
267 | });
268 | }
269 |
270 | on_change(e) {
271 | // Filter out those blank lines
272 | let value = e.target.value.split('\n');
273 | this.setState(
274 | {
275 | block_words: value,
276 | },
277 | this.save_changes.bind(this),
278 | );
279 | }
280 |
281 | render() {
282 | return (
283 |
284 |
285 | {' '}
286 | 设置屏蔽词
287 |
288 |
289 |
294 |
295 |
296 | );
297 | }
298 | } */
299 |
300 | class ConfigSwitch extends PureComponent {
301 | constructor(props) {
302 | super(props);
303 | this.state = {
304 | switch: window.config[this.props.id],
305 | };
306 | }
307 |
308 | on_change(e) {
309 | let val = e.target.checked;
310 | this.setState(
311 | {
312 | switch: val,
313 | },
314 | () => {
315 | this.props.callback({
316 | [this.props.id]: val,
317 | });
318 | },
319 | );
320 | }
321 |
322 | render() {
323 | return (
324 |
325 |
326 |
336 |
337 |
{this.props.description}
338 |
339 | );
340 | }
341 | }
342 |
343 | export class ConfigUI extends PureComponent {
344 | constructor(props) {
345 | super(props);
346 | this.save_changes_bound = this.save_changes.bind(this);
347 | }
348 |
349 | save_changes(chg) {
350 | console.log(chg);
351 | Object.keys(chg).forEach((key) => {
352 | window.config[key] = chg[key];
353 | });
354 | save_config();
355 | }
356 |
357 | reset_settings() {
358 | if (window.confirm('重置所有设置?')) {
359 | window.config = {};
360 | save_config();
361 | window.location.reload();
362 | }
363 | }
364 |
365 | render() {
366 | return (
367 |
368 |
387 |
388 |
392 |
393 |
397 |
398 | {/*
*/}
402 |
array.join('\n')}
408 | sift={(array) => array.filter((v) => v)}
409 | parse={(string) => string.split('\n')}
410 | />
411 |
412 | array.join('\n')}
418 | sift={(array) => array.filter((v) => v)}
419 | parse={(string) => string.split('\n')}
420 | />
421 |
422 |
428 |
429 |
435 |
436 |
442 | {localStorage['hide_announcement'] && (
443 |
444 |
445 |
已隐藏的公告
446 |
461 |
462 | )}
463 |
464 |
465 | 新功能建议或问题反馈请在
466 |
467 | GitHub
468 |
469 | 提出。
470 |
471 |
472 |
473 | );
474 | }
475 | }
476 |
--------------------------------------------------------------------------------
/src/Common.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PureComponent } from 'react';
2 | import { format_time, Time, TitleLine } from './old_infrastructure/widgets';
3 |
4 | import HtmlToReact from 'html-to-react';
5 |
6 | import './Common.css';
7 | import {
8 | // URL_PID_RE,
9 | URL_RE,
10 | PID_RE,
11 | NICKNAME_RE,
12 | split_text,
13 | } from './text_splitter';
14 |
15 | import renderMd from './Markdown';
16 |
17 | export { format_time, Time, TitleLine };
18 |
19 | // https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
20 | export function escape_regex(string) {
21 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
22 | }
23 |
24 | export function build_highlight_re(txt, split = ' ', option = 'gi') {
25 | return txt
26 | ? new RegExp(
27 | `(${txt
28 | .split(split)
29 | .filter((x) => !!x)
30 | .map(escape_regex)
31 | .join('|')})`,
32 | option,
33 | )
34 | : /^$/g;
35 | }
36 |
37 | export function ColoredSpan(props) {
38 | return (
39 |
46 | {props.children}
47 |
48 | );
49 | }
50 |
51 | function normalize_url(url) {
52 | return /^https?:\/\//.test(url) ? url : 'http://' + url;
53 | }
54 |
55 | export class HighlightedText extends PureComponent {
56 | render() {
57 | return (
58 |
59 | {this.props.parts.map((part, idx) => {
60 | let [rule, p] = part;
61 | return (
62 |
63 | {rule === 'url_pid' ? (
64 |
65 | /##
66 |
67 | ) : rule === 'url' ? (
68 |
69 | {p}
70 |
71 | ) : rule === 'pid' ? (
72 | {
75 | e.preventDefault();
76 | this.props.show_pid(p.substring(1));
77 | }}
78 | >
79 | {p}
80 |
81 | ) : rule === 'nickname' ? (
82 |
83 | {p}
84 |
85 | ) : rule === 'search' ? (
86 | {p}
87 | ) : (
88 | p
89 | )}
90 |
91 | );
92 | })}
93 |
94 | );
95 | }
96 | }
97 |
98 | // props: text, show_pid, color_picker
99 | export class HighlightedMarkdown extends Component {
100 | render() {
101 | const props = this.props;
102 | const processDefs = new HtmlToReact.ProcessNodeDefinitions(React);
103 | const processInstructions = [
104 | {
105 | shouldProcessNode: (node) => node.name === 'img', // disable images
106 | processNode(node, children, index) {
107 | return [图片]
;
108 | },
109 | },
110 | {
111 | shouldProcessNode: (node) => /^h[123456]$/.test(node.name),
112 | processNode(node, children, index) {
113 | let currentLevel = +node.name[1];
114 | if (currentLevel < 3) currentLevel = 3;
115 | const HeadingTag = `h${currentLevel}`;
116 | return {children};
117 | },
118 | },
119 | {
120 | shouldProcessNode: (node) => node.name === 'a',
121 | processNode(node, children, index) {
122 | return (
123 |
130 | {children}
131 |
132 |
133 | );
134 | },
135 | },
136 | {
137 | shouldProcessNode(node) {
138 | return (
139 | node.type === 'text' &&
140 | (!node.parent ||
141 | !node.parent.attribs ||
142 | node.parent.attribs['encoding'] !== 'application/x-tex')
143 | ); // pid, nickname, search
144 | },
145 | processNode(node, children, index) {
146 | const originalText = node.data;
147 | let hl_rules = [
148 | // ['url_pid', URL_PID_RE],
149 | ['url', URL_RE],
150 | ['pid', PID_RE],
151 | ['nickname', NICKNAME_RE],
152 | ];
153 | if (props.search_param) {
154 | hl_rules.push([
155 | 'search',
156 | build_highlight_re(props.search_param, ' ', 'gi'),
157 | ]);
158 | }
159 | const splitted = split_text(originalText, hl_rules);
160 |
161 | return (
162 |
163 | {splitted.map(([rule, p], idx) => {
164 | return (
165 |
166 | {rule === 'url_pid' ? (
167 |
168 | /##
169 |
170 | ) : rule === 'url' ? (
171 |
177 | {p}
178 |
179 |
180 | ) : rule === 'pid' ? (
181 | {
184 | e.preventDefault();
185 | props.show_pid(p.substring(1));
186 | }}
187 | >
188 | {p}
189 |
190 | ) : rule === 'nickname' ? (
191 |
192 | {p}
193 |
194 | ) : rule === 'search' ? (
195 | {p}
196 | ) : (
197 | p
198 | )}
199 |
200 | );
201 | })}
202 |
203 | );
204 | },
205 | },
206 | {
207 | shouldProcessNode: () => true,
208 | processNode: processDefs.processDefaultNode,
209 | },
210 | ];
211 | const parser = new HtmlToReact.Parser();
212 | if (
213 | props.author &&
214 | props.text.match(/^(?:#+ |\$\$|>|```|\t|\s*-|\s*\d+\.)/)
215 | ) {
216 | const renderedMarkdown = renderMd(props.text);
217 | return (
218 | <>
219 | {props.author}
220 | {parser.parseWithInstructions(
221 | renderedMarkdown,
222 | (node) => node.type !== 'script',
223 | processInstructions,
224 | ) || ''}
225 | >
226 | );
227 | } else {
228 | let rawMd = props.text;
229 | if (props.author) rawMd = props.author + ' ' + rawMd;
230 | const renderedMarkdown = renderMd(rawMd);
231 | return (
232 | parser.parseWithInstructions(
233 | renderedMarkdown,
234 | (node) => node.type !== 'script',
235 | processInstructions,
236 | ) || null
237 | );
238 | }
239 | }
240 | }
241 |
242 | window.TEXTAREA_BACKUP = {};
243 |
244 | export class SafeTextarea extends Component {
245 | constructor(props) {
246 | super(props);
247 | this.state = {
248 | text: '',
249 | };
250 | this.on_change_bound = this.on_change.bind(this);
251 | this.on_keydown_bound = this.on_keydown.bind(this);
252 | this.clear = this.clear.bind(this);
253 | this.area_ref = React.createRef();
254 | this.change_callback = props.on_change || (() => {});
255 | this.submit_callback = props.on_submit || (() => {});
256 | }
257 |
258 | componentDidMount() {
259 | this.setState(
260 | {
261 | text: window.TEXTAREA_BACKUP[this.props.id] || '',
262 | },
263 | () => {
264 | this.change_callback(this.state.text);
265 | },
266 | );
267 | }
268 |
269 | componentWillUnmount() {
270 | window.TEXTAREA_BACKUP[this.props.id] = this.state.text;
271 | this.change_callback(this.state.text);
272 | }
273 |
274 | on_change(event) {
275 | this.setState({
276 | text: event.target.value,
277 | });
278 | this.change_callback(event.target.value);
279 | }
280 | on_keydown(event) {
281 | if (event.key === 'Enter' && event.ctrlKey && !event.altKey) {
282 | event.preventDefault();
283 | this.submit_callback();
284 | }
285 | }
286 |
287 | clear() {
288 | this.setState({
289 | text: '',
290 | });
291 | }
292 | set(text) {
293 | this.change_callback(text);
294 | this.setState({
295 | text: text,
296 | });
297 | }
298 | get() {
299 | return this.state.text;
300 | }
301 | focus() {
302 | this.area_ref.current.focus();
303 | }
304 |
305 | render() {
306 | return (
307 |
313 | );
314 | }
315 | }
316 |
317 | let pwa_prompt_event = null;
318 | window.addEventListener('beforeinstallprompt', (e) => {
319 | console.log('pwa: received before install prompt');
320 | pwa_prompt_event = e;
321 | });
322 |
323 | export function PromotionBar() {
324 | let is_ios = /iPhone|iPad|iPod/i.test(window.navigator.userAgent);
325 | let is_installed =
326 | window.matchMedia('(display-mode: standalone)').matches ||
327 | window.navigator.standalone;
328 |
329 | if (is_installed) return null;
330 |
331 | if (is_ios)
332 | // noinspection JSConstructorReturnsPrimitive
333 | return !navigator.standalone ? (
334 |
335 |
336 | 用 Safari 把树洞 添加到主屏幕 更好用
337 |
338 | ) : null;
339 | // noinspection JSConstructorReturnsPrimitive
340 | else
341 | return pwa_prompt_event ? (
342 |
356 | ) : null;
357 | }
358 |
359 | export function BrowserWarningBar() {
360 | let cr_version = /Chrome\/(\d+)/.exec(navigator.userAgent);
361 | cr_version = cr_version ? cr_version[1] : 0;
362 | if (/MicroMessenger\/|QQ\//.test(navigator.userAgent))
363 | return (
364 |
365 | 您正在使用 QQ/微信 内嵌浏览器
366 |
367 | 建议使用系统浏览器打开,否则可能出现兼容问题
368 |
369 | );
370 | if (/Edge\/1/.test(navigator.userAgent))
371 | return (
372 |
373 | 您正在使用旧版 Microsoft Edge
374 |
375 | 建议使用新版 Edge,否则可能出现兼容问题
376 |
377 | );
378 | else if (cr_version > 1 && cr_version < 57)
379 | return (
380 |
381 | 您正在使用古老的 Chrome {cr_version}
382 |
383 | 建议使用新版浏览器,否则可能出现兼容问题
384 |
385 | );
386 | return null;
387 | }
388 |
389 | export class ClickHandler extends PureComponent {
390 | constructor(props) {
391 | super(props);
392 | this.state = {
393 | moved: true,
394 | init_y: 0,
395 | init_x: 0,
396 | };
397 | this.on_begin_bound = this.on_begin.bind(this);
398 | this.on_move_bound = this.on_move.bind(this);
399 | this.on_end_bound = this.on_end.bind(this);
400 |
401 | this.MOVE_THRESHOLD = 3;
402 | this.last_fire = 0;
403 | this.popup_anchor = document.getElementById('img_viewer');
404 | }
405 |
406 | on_begin(e) {
407 | //console.log('click',(e.touches?e.touches[0]:e).screenY,(e.touches?e.touches[0]:e).screenX);
408 | this.setState({
409 | moved: false,
410 | init_y: (e.touches ? e.touches[0] : e).screenY,
411 | init_x: (e.touches ? e.touches[0] : e).screenX,
412 | });
413 | }
414 | on_move(e) {
415 | if (!this.state.moved) {
416 | let mvmt =
417 | Math.abs((e.touches ? e.touches[0] : e).screenY - this.state.init_y) +
418 | Math.abs((e.touches ? e.touches[0] : e).screenX - this.state.init_x);
419 | //console.log('move',mvmt);
420 | if (mvmt > this.MOVE_THRESHOLD)
421 | this.setState({
422 | moved: true,
423 | });
424 | }
425 | }
426 | on_end(event) {
427 | //console.log('end');
428 | if (!this.state.moved) this.do_callback(event);
429 | this.setState({
430 | moved: true,
431 | });
432 | }
433 |
434 | do_callback(event) {
435 | if (this.last_fire + 100 > +new Date()) return;
436 | if (this.popup_anchor && this.popup_anchor.children.length !== 0) return;
437 | this.last_fire = +new Date();
438 | this.props.callback(event);
439 | }
440 |
441 | render() {
442 | return (
443 |
450 | {this.props.children}
451 |
452 | );
453 | }
454 | }
455 |
--------------------------------------------------------------------------------
/public/static/abcsx/icomoon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/src/login.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import './login.css';
5 |
6 | import { API_ROOT } from './old_infrastructure/const';
7 | import { API_VERSION_PARAM, get_json } from './old_infrastructure/functions';
8 |
9 | import {
10 | GoogleReCaptcha,
11 | GoogleReCaptchaProvider,
12 | } from 'react-google-recaptcha-v3';
13 | import ReCAPTCHA from 'react-google-recaptcha';
14 |
15 | import UAParser from 'ua-parser-js';
16 |
17 | const LOGIN_POPUP_ANCHOR_ID = 'pkuhelper_login_popup_anchor';
18 |
19 | class LoginPopupSelf extends Component {
20 | constructor(props) {
21 | super(props);
22 | this.state = {
23 | loading_status: 'idle',
24 | recaptcha_verified: false,
25 | phase: -1,
26 | // excluded_scopes: [],
27 | };
28 |
29 | this.ref = {
30 | username: React.createRef(),
31 | email_verification: React.createRef(),
32 | password: React.createRef(),
33 | password_confirm: React.createRef(),
34 |
35 | checkbox_terms: React.createRef(),
36 | checkbox_account: React.createRef(),
37 | };
38 |
39 | this.popup_anchor = document.getElementById(LOGIN_POPUP_ANCHOR_ID);
40 | if (!this.popup_anchor) {
41 | this.popup_anchor = document.createElement('div');
42 | this.popup_anchor.id = LOGIN_POPUP_ANCHOR_ID;
43 | document.body.appendChild(this.popup_anchor);
44 | }
45 | }
46 |
47 | next_step() {
48 | if (this.state.loading_status === 'loading') return;
49 | switch (this.state.phase) {
50 | case -1:
51 | this.verify_email('v3', () => {});
52 | break;
53 | case 0:
54 | this.do_login(this.props.token_callback);
55 | break;
56 | case 1:
57 | this.new_user_registration(this.props.token_callback);
58 | break;
59 | case 2:
60 | this.old_user_registration(this.props.token_callback);
61 | break;
62 | case 3:
63 | this.need_recaptcha();
64 | break;
65 | }
66 | }
67 |
68 | valid_registration() {
69 | if (
70 | !this.ref.checkbox_terms.current.checked ||
71 | !this.ref.checkbox_account.current.checked
72 | ) {
73 | alert('请同意条款与条件!');
74 | return 1;
75 | }
76 | if (this.ref.password.current.value.length < 8) {
77 | alert('密码太短,至少应包含8个字符!');
78 | return 2;
79 | }
80 | if (
81 | this.ref.password.current.value !==
82 | this.ref.password_confirm.current.value
83 | ) {
84 | alert('密码不一致!');
85 | return 3;
86 | }
87 | return 0;
88 | }
89 |
90 | async sha256(message) {
91 | // encode as UTF-8
92 | const msgBuffer = new TextEncoder().encode(message);
93 |
94 | // hash the message
95 | const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
96 |
97 | // convert ArrayBuffer to Array
98 | const hashArray = Array.from(new Uint8Array(hashBuffer));
99 |
100 | // convert bytes to hex string
101 | return hashArray.map((b) => ('00' + b.toString(16)).slice(-2)).join('');
102 | }
103 |
104 | async hashpassword(password) {
105 | let password_hashed = await this.sha256(password);
106 | password_hashed = await this.sha256(password_hashed);
107 | return password_hashed;
108 | }
109 |
110 | verify_email(version, failed_callback) {
111 | const old_token = new URL(location.href).searchParams.get('old_token');
112 | const email = this.ref.username.current.value;
113 | const recaptcha_version = version;
114 | const recaptcha_token = localStorage['recaptcha'];
115 | // VALIDATE EMAIL IN FRONT-END HERE
116 | const body = new URLSearchParams();
117 | Object.entries({
118 | email,
119 | old_token,
120 | recaptcha_version,
121 | recaptcha_token,
122 | }).forEach((param) => body.append(...param));
123 | this.setState(
124 | {
125 | loading_status: 'loading',
126 | },
127 | () => {
128 | fetch(API_ROOT + 'security/login/check_email?' + API_VERSION_PARAM(), {
129 | method: 'POST',
130 | body,
131 | })
132 | .then((res) => res.json())
133 | .then((json) => {
134 | // COMMENT NEXT LINE
135 | //json.code = 2;
136 | if (json.code < 0) throw new Error(json.msg);
137 | this.setState({
138 | loading_status: 'done',
139 | phase: json.code,
140 | });
141 | if (json.code === 3) failed_callback();
142 | })
143 | .catch((e) => {
144 | alert('邮箱检验失败\n' + e);
145 | this.setState({
146 | loading_status: 'done',
147 | });
148 | console.error(e);
149 | });
150 | },
151 | );
152 | }
153 |
154 | async do_login(set_token) {
155 | const email = this.ref.username.current.value;
156 | const password = this.ref.password.current.value;
157 | let password_hashed = await this.hashpassword(password);
158 | const device_info = UAParser(navigator.userAgent).browser.name;
159 | const body = new URLSearchParams();
160 | Object.entries({
161 | email,
162 | password_hashed,
163 | device_type: 0,
164 | device_info,
165 | }).forEach((param) => body.append(...param));
166 |
167 | this.setState(
168 | {
169 | loading_status: 'loading',
170 | },
171 | () => {
172 | fetch(API_ROOT + 'security/login/login?' + API_VERSION_PARAM(), {
173 | method: 'POST',
174 | body,
175 | })
176 | .then(get_json)
177 | .then((json) => {
178 | if (json.code !== 0) {
179 | if (json.msg) throw new Error(json.msg);
180 | throw new Error(JSON.stringify(json));
181 | }
182 |
183 | set_token(json.token);
184 | alert('登录成功');
185 | this.setState({
186 | loading_status: 'done',
187 | });
188 | this.props.on_close();
189 | })
190 | .catch((e) => {
191 | console.error(e);
192 | alert('登录失败\n' + e);
193 | this.setState({
194 | loading_status: 'done',
195 | });
196 | });
197 | },
198 | );
199 | }
200 |
201 | async new_user_registration(set_token) {
202 | if (this.valid_registration() !== 0) return;
203 | const email = this.ref.username.current.value;
204 | const valid_code = this.ref.email_verification.current.value;
205 | const password = this.ref.password.current.value;
206 | let password_hashed = await this.hashpassword(password);
207 | const device_info = UAParser(navigator.userAgent).browser.name;
208 | const body = new URLSearchParams();
209 | Object.entries({
210 | email,
211 | password_hashed,
212 | device_type: 0,
213 | device_info,
214 | valid_code,
215 | }).forEach((param) => body.append(...param));
216 | this.setState(
217 | {
218 | loading_status: 'loading',
219 | },
220 | () => {
221 | fetch(
222 | API_ROOT + 'security/login/create_account?' + API_VERSION_PARAM(),
223 | {
224 | method: 'POST',
225 | body,
226 | },
227 | )
228 | .then(get_json)
229 | .then((json) => {
230 | if (json.code !== 0) {
231 | if (json.msg) throw new Error(json.msg);
232 | throw new Error(JSON.stringify(json));
233 | }
234 |
235 | set_token(json.token);
236 | alert('登录成功');
237 | this.setState({
238 | loading_status: 'done',
239 | });
240 | this.props.on_close();
241 | })
242 | .catch((e) => {
243 | console.error(e);
244 | alert('登录失败\n' + e);
245 | this.setState({
246 | loading_status: 'done',
247 | });
248 | });
249 | },
250 | );
251 | }
252 |
253 | async old_user_registration(set_token) {
254 | if (this.valid_registration() !== 0) return;
255 | const email = this.ref.username.current.value;
256 | const old_token = new URL(location.href).searchParams.get('old_token');
257 | const password = this.ref.password.current.value;
258 | let password_hashed = await this.hashpassword(password);
259 | const device_info = UAParser(navigator.userAgent).browser.name;
260 | const body = new URLSearchParams();
261 | Object.entries({
262 | email,
263 | password_hashed,
264 | device_type: 0,
265 | device_info,
266 | old_token,
267 | }).forEach((param) => body.append(...param));
268 | this.setState(
269 | {
270 | loading_status: 'loading',
271 | },
272 | () => {
273 | fetch(
274 | API_ROOT + 'security/login/create_account?' + API_VERSION_PARAM(),
275 | {
276 | method: 'POST',
277 | body,
278 | },
279 | )
280 | .then(get_json)
281 | .then((json) => {
282 | if (json.code !== 0) {
283 | if (json.msg) throw new Error(json.msg);
284 | throw new Error(JSON.stringify(json));
285 | }
286 |
287 | set_token(json.token);
288 | alert('登录成功');
289 | this.setState({
290 | loading_status: 'done',
291 | });
292 | this.props.on_close();
293 | })
294 | .catch((e) => {
295 | console.error(e);
296 | alert('登录失败\n' + e);
297 | this.setState({
298 | loading_status: 'done',
299 | });
300 | });
301 | },
302 | );
303 | }
304 |
305 | need_recaptcha() {
306 | console.log(3);
307 | }
308 |
309 | render() {
310 | window.recaptchaOptions = {
311 | useRecaptchaNet: true,
312 | };
313 | return ReactDOM.createPortal(
314 |
318 |
319 |
320 |
321 | {this.state.phase === -1 && (
322 | <>
323 |
324 | 输入邮箱来登录 {process.env.REACT_APP_TITLE}
325 |
326 | >
327 | )}
328 |
329 |
343 |
344 | {this.state.phase === 0 && (
345 | <>
346 |
347 | 输入密码来登录 {process.env.REACT_APP_TITLE}
348 |
349 |
350 |
363 |
364 |
365 | {
367 | alert(
368 | '在树洞网页版的“账户”界面,可以注销账号之后重新注册树洞。',
369 | );
370 | }}
371 | >
372 | 忘记密码?
373 |
374 |
375 | >
376 | )}
377 | {this.state.phase === 1 && (
378 | <>
379 |
380 | {process.env.REACT_APP_TITLE} 新用户注册
381 |
382 |
383 |
391 |
392 | >
393 | )}
394 | {this.state.phase === 2 && (
395 | <>
396 |
397 | {process.env.REACT_APP_TITLE} 老用户注册
398 |
399 | >
400 | )}
401 | {(this.state.phase === 1 || this.state.phase === 2) && (
402 | <>
403 |
404 |
408 |
409 |
410 |
422 |
423 |
424 |
431 |
432 |
433 |
437 |
438 | >
439 | )}
440 | {this.state.phase === 3 && (
441 | <>
442 |
443 | 输入验证码 {process.env.REACT_APP_TITLE}
444 |
445 |
{
447 | this.verify_email('v2', () => {
448 | alert('reCAPTCHA风控系统校验失败');
449 | });
450 | }}
451 | >
452 | {(do_popup) => (
453 |
454 | {!this.state.recaptcha_verified && (
455 | {
457 | this.setState({
458 | recaptcha_verified: true,
459 | });
460 | console.log(token);
461 | localStorage['recaptcha'] = token;
462 | this.verify_email('v3', do_popup);
463 | }}
464 | />
465 | )}
466 |
467 | )}
468 |
469 | >
470 | )}
471 |
472 |
478 |
479 |
480 |
481 |
482 | ,
483 | this.popup_anchor,
484 | );
485 | }
486 | }
487 |
488 | export class LoginPopup extends Component {
489 | constructor(props) {
490 | super(props);
491 | this.state = {
492 | popup_show: false,
493 | };
494 | this.on_popup_bound = this.on_popup.bind(this);
495 | this.on_close_bound = this.on_close.bind(this);
496 | }
497 |
498 | on_popup() {
499 | this.setState({
500 | popup_show: true,
501 | });
502 | }
503 |
504 | on_close() {
505 | this.setState({
506 | popup_show: false,
507 | });
508 | }
509 |
510 | render() {
511 | return (
512 | <>
513 | {this.props.children(this.on_popup_bound)}
514 | {this.state.popup_show && (
515 |
519 | )}
520 | >
521 | );
522 | }
523 | }
524 |
525 | export class RecaptchaV2Popup extends Component {
526 | constructor(props, context) {
527 | super(props, context);
528 | this.onChange = this.onChange.bind(this);
529 | this.state = {
530 | popup_show: false,
531 | };
532 | this.on_popup_bound = this.on_popup.bind(this);
533 | this.on_close_bound = this.on_close.bind(this);
534 | }
535 |
536 | on_popup() {
537 | this.setState({
538 | popup_show: true,
539 | });
540 | }
541 |
542 | on_close() {
543 | this.setState({
544 | popup_show: false,
545 | });
546 | }
547 |
548 | componentDidMount() {
549 | if (this.captchaRef) {
550 | console.log('started, just a second...');
551 | this.captchaRef.reset();
552 | this.captchaRef.execute();
553 | }
554 | }
555 |
556 | onChange(recaptchaToken) {
557 | localStorage['recaptcha'] = recaptchaToken;
558 | this.setState({
559 | popup_show: false,
560 | });
561 | this.props.callback();
562 | }
563 |
564 | render() {
565 | return (
566 | <>
567 | {this.props.children(this.on_popup_bound)}
568 | {this.state.popup_show && (
569 |
570 |
571 |
572 |
573 | {
575 | this.captchaRef = el;
576 | }}
577 | sitekey={process.env.REACT_APP_RECAPTCHA_V2_KEY}
578 | // size={"compact"}
579 | onChange={this.onChange}
580 | />
581 |
582 |
583 |
584 |
585 |
586 |
587 |
588 | )}
589 | >
590 | );
591 | }
592 | }
593 |
--------------------------------------------------------------------------------
/src/infrastructure/widgets.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PureComponent } from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import TimeAgo from 'react-timeago';
5 | import chineseStrings from 'react-timeago/lib/language-strings/zh-CN';
6 | import buildFormatter from 'react-timeago/lib/formatters/buildFormatter';
7 |
8 | import './global.css';
9 | import './widgets.css';
10 |
11 | import appicon_hole from './appicon/hole.png';
12 | import appicon_imasugu from './appicon/imasugu.png';
13 | import appicon_imasugu_rev from './appicon/imasugu_rev.png';
14 | import appicon_syllabus from './appicon/syllabus.png';
15 | import appicon_score from './appicon/score.png';
16 | import appicon_course_survey from './appicon/course_survey.png';
17 | import appicon_dropdown from './appicon/dropdown.png';
18 | import appicon_dropdown_rev from './appicon/dropdown_rev.png';
19 | import appicon_homepage from './appicon/homepage.png';
20 | import { HOLE_API_ROOT } from './const';
21 | import { get_json, API_VERSION_PARAM } from './functions';
22 |
23 | import {
24 | GoogleReCaptchaProvider,
25 | GoogleReCaptcha,
26 | } from 'react-google-recaptcha-v3';
27 | import ReCAPTCHA from 'react-google-recaptcha';
28 |
29 | const LOGIN_POPUP_ANCHOR_ID = 'pkuhelper_login_popup_anchor';
30 |
31 | function pad2(x) {
32 | return x < 10 ? '0' + x : '' + x;
33 | }
34 | export function format_time(time) {
35 | return `${time.getMonth() + 1}-${pad2(
36 | time.getDate(),
37 | )} ${time.getHours()}:${pad2(time.getMinutes())}:${pad2(time.getSeconds())}`;
38 | }
39 | const chinese_format = buildFormatter(chineseStrings);
40 | export function Time(props) {
41 | const time = new Date(props.stamp * 1000);
42 | return (
43 |
44 |
52 |
53 | {!props.short ? format_time(time) : null}
54 |
55 | );
56 | }
57 |
58 | export function TitleLine(props) {
59 | return (
60 |
61 | {props.text}
62 |
63 | );
64 | }
65 |
66 | export function GlobalTitle(props) {
67 | return (
68 |
73 | );
74 | }
75 |
76 | const FALLBACK_APPS = {
77 | // id, text, url, icon_normal, icon_hover, new_tab
78 | bar: [
79 | ['hole', '树洞', '/hole', appicon_hole, null, false],
80 | [
81 | 'imasugu',
82 | '教室',
83 | '/spare_classroom',
84 | appicon_imasugu,
85 | appicon_imasugu_rev,
86 | false,
87 | ],
88 | ['syllabus', '课表', '/syllabus', appicon_syllabus, null, false],
89 | ['score', '成绩', '/my_score', appicon_score, null, false],
90 | ],
91 | dropdown: [
92 | [
93 | 'course_survey',
94 | '课程测评',
95 | 'https://courses.pinzhixiaoyuan.com/',
96 | appicon_course_survey,
97 | null,
98 | true,
99 | ],
100 | ['homepage', '客户端', '/', appicon_homepage, null, true],
101 | ],
102 | fix: {},
103 | };
104 | // const SWITCHER_DATA_VER='switcher_2';
105 | // const SWITCHER_DATA_URL=HOLE_API_ROOT+'web_static/appswitcher_items.json';
106 |
107 | // export class AppSwitcher extends Component {
108 | // constructor(props) {
109 | // super(props);
110 | // this.state={
111 | // apps: this.get_apps_from_localstorage(),
112 | // }
113 | // }
114 | //
115 | // get_apps_from_localstorage() {
116 | // let ret=FALLBACK_APPS;
117 | // if(localStorage['APPSWITCHER_ITEMS'])
118 | // try {
119 | // let content=JSON.parse(localStorage['APPSWITCHER_ITEMS'])[SWITCHER_DATA_VER];
120 | // if(!content || !content.bar)
121 | // throw new Error('content is empty');
122 | //
123 | // ret=content;
124 | // } catch(e) {
125 | // console.error('load appswitcher items from localstorage failed');
126 | // console.trace(e);
127 | // }
128 | //
129 | // return ret;
130 | // }
131 | //
132 | // check_fix() {
133 | // if(this.state.apps && this.state.apps.fix && this.state.apps.fix[this.props.appid])
134 | // setTimeout(()=>{
135 | // window.HOTFIX_CONTEXT={
136 | // build_info: process.env.REACT_APP_BUILD_INFO || '---',
137 | // build_env: process.env.NODE_ENV,
138 | // };
139 | // eval(this.state.apps.fix[this.props.appid]);
140 | // },1); // make it async so failures won't be critical
141 | // }
142 | //
143 | // componentDidMount() {
144 | // this.check_fix();
145 | // setTimeout(()=>{
146 | // fetch(SWITCHER_DATA_URL)
147 | // .then((res)=>{
148 | // if(!res.ok) throw Error(`网络错误 ${res.status} ${res.statusText}`);
149 | // return res.text();
150 | // })
151 | // .then((txt)=>{
152 | // if(txt!==localStorage['APPSWITCHER_ITEMS']) {
153 | // console.log('loaded new appswitcher items',txt);
154 | // localStorage['APPSWITCHER_ITEMS']=txt;
155 | //
156 | // this.setState({
157 | // apps: this.get_apps_from_localstorage(),
158 | // });
159 | // } else {
160 | // console.log('appswitcher items unchanged');
161 | // }
162 | // })
163 | // .catch((e)=>{
164 | // console.error('loading appswitcher items failed');
165 | // console.trace(e);
166 | // });
167 | // },500);
168 | // }
169 | //
170 | // componentDidUpdate(prevProps, prevState) {
171 | // if(this.state.apps!==prevState.apps)
172 | // this.check_fix();
173 | // }
174 | //
175 | // render() {
176 | // let cur_id=this.props.appid;
177 | //
178 | // function app_elem([id,title,url,icon_normal,icon_hover,new_tab],no_class=false,ref=null) {
179 | // return (
180 | //
182 | // {!!icon_normal && [
183 | //
,
184 | //
185 | // ]}
186 | // {title}
187 | //
188 | // );
189 | // }
190 | //
191 | // let dropdown_cur_app=null;
192 | // this.state.apps.dropdown.forEach((app)=>{
193 | // if(app[0]===cur_id)
194 | // dropdown_cur_app=app;
195 | // });
196 | //
197 | // //console.log(JSON.stringify(this.state.apps));
198 | //
199 | // return (
200 | //
201 | //
PKUHelper
202 | // {this.state.apps.bar.map((app)=>
203 | // app_elem(app)
204 | // )}
205 | // {!!this.state.apps.dropdown.length &&
206 | //
210 | //
211 | // {!!dropdown_cur_app ?
212 | // app_elem((()=>{
213 | // let [id,title,_url,icon_normal,icon_hover,_new_tab]=dropdown_cur_app;
214 | // return [id,title+'▾',null,icon_normal,icon_hover,false];
215 | // })(),true) :
216 | // app_elem(['-placeholder-elem','更多▾',null,appicon_dropdown,appicon_dropdown_rev,false],true)
217 | // }
218 | //
219 | // {this.state.apps.dropdown.map((app)=>{
220 | // let ref=React.createRef();
221 | // return (
222 | //
{
223 | // if(!e.target.closest('a') && ref.current)
224 | // ref.current.click();
225 | // }}>
226 | // {app_elem(app,true,ref)}
227 | //
228 | // );
229 | // })}
230 | //
231 | // }
232 | //
网页版
233 | //
234 | // );
235 | // }
236 | // }
237 |
238 | class RecaptchaV2Popup extends Component {
239 | constructor(props, context) {
240 | super(props, context);
241 | this.onChange = this.onChange.bind(this);
242 | this.state = {
243 | popup_show: false,
244 | };
245 | this.on_popup_bound = this.on_popup.bind(this);
246 | this.on_close_bound = this.on_close.bind(this);
247 | }
248 |
249 | on_popup() {
250 | this.setState({
251 | popup_show: true,
252 | });
253 | }
254 | on_close() {
255 | this.setState({
256 | popup_show: false,
257 | });
258 | }
259 |
260 | componentDidMount() {
261 | if (this.captchaRef) {
262 | console.log('started, just a second...');
263 | this.captchaRef.reset();
264 | this.captchaRef.execute();
265 | }
266 | }
267 | onChange(recaptchaToken) {
268 | localStorage['recaptcha'] = recaptchaToken;
269 | this.setState({
270 | popup_show: false,
271 | });
272 | this.props.callback();
273 | }
274 |
275 | render() {
276 | return (
277 | <>
278 | {this.props.children(this.on_popup_bound)}
279 | {this.state.popup_show && (
280 |
281 |
282 |
283 |
284 | {
286 | this.captchaRef = el;
287 | }}
288 | sitekey={process.env.REACT_APP_RECAPTCHA_V2_KEY}
289 | // size={"compact"}
290 | onChange={this.onChange}
291 | />
292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 | )}
300 | >
301 | );
302 | }
303 | }
304 |
305 | class LoginPopupSelf extends Component {
306 | constructor(props) {
307 | super(props);
308 | this.state = {
309 | loading_status: 'idle',
310 | recaptcha_verified: false,
311 | // excluded_scopes: [],
312 | };
313 | this.username_ref = React.createRef();
314 | this.password_ref = React.createRef();
315 | this.input_token_ref = React.createRef();
316 |
317 | this.popup_anchor = document.getElementById(LOGIN_POPUP_ANCHOR_ID);
318 | if (!this.popup_anchor) {
319 | this.popup_anchor = document.createElement('div');
320 | this.popup_anchor.id = LOGIN_POPUP_ANCHOR_ID;
321 | document.body.appendChild(this.popup_anchor);
322 | }
323 | }
324 |
325 | do_sendcode(type, do_popup, recaptcha_version) {
326 | if (!this.state.recaptcha_verified) {
327 | alert('reCAPTCHA风控系统正在评估您的浏览器安全状态,请稍后重试。');
328 | return;
329 | }
330 | if (this.state.loading_status === 'loading') return;
331 |
332 | this.setState(
333 | {
334 | loading_status: 'loading',
335 | },
336 | () => {
337 | fetch(
338 | HOLE_API_ROOT +
339 | 'security/login/send_code' +
340 | '?user=' +
341 | encodeURIComponent(this.username_ref.current.value) +
342 | '&code_type=' +
343 | encodeURIComponent(type) +
344 | '&recaptcha_version=' +
345 | encodeURIComponent(recaptcha_version) +
346 | '&recaptcha_token=' +
347 | localStorage['recaptcha'] +
348 | API_VERSION_PARAM(),
349 | {
350 | method: 'POST',
351 | headers: {
352 | 'Content-Type': 'application/json',
353 | },
354 | body: JSON.stringify({
355 | excluded_scopes: [],
356 | }),
357 | },
358 | )
359 | .then(get_json)
360 | .then((json) => {
361 | console.log(json);
362 | if (!json.success) throw new Error(JSON.stringify(json));
363 |
364 | alert(json.msg);
365 | this.setState({
366 | loading_status: 'done',
367 | });
368 | })
369 | .catch((e) => {
370 | console.error(e);
371 |
372 | if (e.toString().includes('风控系统校验失败')) {
373 | this.setState({
374 | loading_status: 'done',
375 | });
376 | do_popup();
377 | } else {
378 | alert('发送失败\n' + e);
379 | this.setState({
380 | loading_status: 'done',
381 | });
382 | }
383 | });
384 | },
385 | );
386 | }
387 |
388 | do_login(set_token) {
389 | if (this.state.loading_status === 'loading') return;
390 |
391 | this.setState(
392 | {
393 | loading_status: 'loading',
394 | },
395 | () => {
396 | fetch(
397 | HOLE_API_ROOT +
398 | 'security/login/login' +
399 | '?user=' +
400 | encodeURIComponent(this.username_ref.current.value) +
401 | '&valid_code=' +
402 | encodeURIComponent(this.password_ref.current.value) +
403 | API_VERSION_PARAM(),
404 | {
405 | method: 'POST',
406 | headers: {
407 | 'Content-Type': 'application/json',
408 | },
409 | body: JSON.stringify({
410 | excluded_scopes: [],
411 | }),
412 | },
413 | )
414 | .then(get_json)
415 | .then((json) => {
416 | if (json.code !== 0) {
417 | if (json.msg) throw new Error(json.msg);
418 | throw new Error(JSON.stringify(json));
419 | }
420 |
421 | set_token(json.user_token);
422 | alert(`登录成功`);
423 | this.setState({
424 | loading_status: 'done',
425 | });
426 | this.props.on_close();
427 | })
428 | .catch((e) => {
429 | console.error(e);
430 | alert('登录失败\n' + e);
431 | this.setState({
432 | loading_status: 'done',
433 | });
434 | });
435 | },
436 | );
437 | }
438 |
439 | do_input_token(set_token) {
440 | if (this.state.loading_status === 'loading') return;
441 |
442 | let token = this.input_token_ref.current.value;
443 | this.setState(
444 | {
445 | loading_status: 'loading',
446 | },
447 | () => {
448 | fetch(
449 | HOLE_API_ROOT +
450 | 'contents/system_msg?user_token=' +
451 | encodeURIComponent(token) +
452 | API_VERSION_PARAM(),
453 | )
454 | .then((res) => res.json())
455 | .then((json) => {
456 | if (json.error) throw new Error(json.error);
457 | if (json.result.length === 0)
458 | throw new Error('result check failed');
459 | this.setState({
460 | loading_status: 'done',
461 | });
462 | set_token(token);
463 | this.props.on_close();
464 | })
465 | .catch((e) => {
466 | alert('Token检验失败\n' + e);
467 | this.setState({
468 | loading_status: 'done',
469 | });
470 | console.error(e);
471 | });
472 | },
473 | );
474 | }
475 |
476 | // perm_alert() {
477 | // alert('如果你不需要 PKU Helper 的某项功能,可以取消相应权限。\n其中【状态信息】包括你的网费、校园卡余额等。\n该设置应用到你的【所有】设备,取消后如需再次启用相应功能需要重新登录。');
478 | // }
479 |
480 | render() {
481 | // let PERM_SCOPES=[
482 | // ['score','成绩查询'],
483 | // ['syllabus','课表查询'],
484 | // ['my_info','状态信息'],
485 | // ];
486 | window.recaptchaOptions = {
487 | useRecaptchaNet: true,
488 | };
489 | return ReactDOM.createPortal(
490 |
494 | {!this.state.recaptcha_verified && (
495 | {
497 | this.setState({
498 | recaptcha_verified: true,
499 | });
500 | console.log(token);
501 | localStorage['recaptcha'] = token;
502 | }}
503 | />
504 | )}
505 |
506 |
507 |
508 |
509 | 接收验证码来登录 {process.env.REACT_APP_TITLE}
510 |
511 |
512 |
521 |
522 | {/*this.do_sendcode('sms')}>*/}
523 | {/* 短信 */}
524 | {/**/}
525 | {/*/*/}
526 | {
528 | this.do_sendcode(
529 | 'mail',
530 | () => {
531 | alert('风控系统校验失败');
532 | },
533 | 'v2',
534 | );
535 | }}
536 | >
537 | {(do_popup) => (
538 | this.do_sendcode('mail', do_popup, 'v3')}
540 | >
541 | 发送邮件
542 |
543 | )}
544 |
545 |
546 |
547 |
548 |
552 |
560 |
561 |
562 |
563 | 从其他设备导入登录状态
564 |
565 |
566 |
567 |
574 |
575 |
576 |
577 | 出现问题?请及时联系管理团队
578 |
579 |
580 |
581 | 社区规范
582 |
583 |
584 | This site is protected by reCAPTCHA and the Google{' '}
585 | Privacy Policy{' '}
586 | and{' '}
587 | Terms of Service{' '}
588 | apply.
589 |
590 |
591 |
592 |
593 |
594 |
595 | ,
596 | this.popup_anchor,
597 | );
598 | }
599 | }
600 |
601 | export class LoginPopup extends Component {
602 | constructor(props) {
603 | super(props);
604 | this.state = {
605 | popup_show: false,
606 | };
607 | this.on_popup_bound = this.on_popup.bind(this);
608 | this.on_close_bound = this.on_close.bind(this);
609 | }
610 |
611 | on_popup() {
612 | this.setState({
613 | popup_show: true,
614 | });
615 | }
616 | on_close() {
617 | this.setState({
618 | popup_show: false,
619 | });
620 | }
621 |
622 | render() {
623 | return (
624 | <>
625 | {this.props.children(this.on_popup_bound)}
626 | {this.state.popup_show && (
627 |
631 | )}
632 | >
633 | );
634 | }
635 | }
636 |
--------------------------------------------------------------------------------