├── 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 |
18 |

Frontend Exception

19 |

Please report this bug to developers.

20 |
21 |
{'' + this.state.error}
22 |
23 |
{this.state.error.stack || '(no stack info)'}
24 |
25 | ); 26 | else return this.props.children; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Markdown.js: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it'; 2 | // import MarkdownItKaTeX from 'markdown-it-katex'; 3 | import hljs from 'highlight.js'; 4 | import 'highlight.js/styles/atom-one-dark.css'; 5 | import './Markdown.css'; 6 | 7 | import 'katex/dist/katex.min.css'; 8 | 9 | let md = new MarkdownIt({ 10 | html: false, 11 | linkify: false, 12 | breaks: true, 13 | inline: true, 14 | highlight(str, lang) { 15 | if (lang && hljs.getLanguage(lang)) { 16 | try { 17 | return ( 18 | '
' +
19 |           hljs.highlight(lang, str, true).value +
20 |           '
' 21 | ); 22 | } catch (__) {} 23 | } 24 | return ( 25 | '
' + md.utils.escapeHtml(str) + '
' 26 | ); 27 | }, 28 | // }).use(MarkdownItKaTeX, { 29 | // throwOnError: false, 30 | // errorColor: '#aa0000', 31 | }); 32 | 33 | export default (text) => md.render(text); 34 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # BEGIN: DO NOT EDIT THESE 2 | REACT_APP_VERSION=$npm_package_version 3 | EXTEND_ESLINT=true 4 | # END 5 | 6 | # EDIT CONFIGS BELOW 7 | REACT_APP_TITLE=未名树洞 8 | REACT_APP_WEBSITE_URL=pkuhollow.com 9 | REACT_APP_RECAPTCHA_V3_KEY=6Lc1W_cZAAAAAOOgLwaFIIRFe09TpxkPbDFMwQrc 10 | REACT_APP_RECAPTCHA_V2_KEY=6LeElfcZAAAAADa9Q0AaiQ-LHIsSRumv5tos5XSR 11 | REACT_APP_API_ROOT=https://api.pkuhollow.com/v3/ 12 | REACT_APP_IMG_BASE_URL=https://img.pkuhollow.com/ 13 | REACT_APP_IMG_BASE_BAK_URL=https://img2.pkuhollow.com/ 14 | REACT_APP_FOLD_TAGS=性相关,政治相关,NSFW,刷屏,引战,未经证实的传闻,令人不适,重复内容,举报较多 15 | REACT_APP_SENDABLE_TAGS=性相关,政治相关,NSFW,刷屏,引战,未经证实的传闻,令人不适,重复内容 16 | REACT_APP_REPORTABLE_TAGS=性相关,政治相关,NSFW,刷屏,引战,未经证实的传闻,令人不适 17 | REACT_APP_CONTACT_EMAIL=contact@pkuhollow.com 18 | REACT_APP_RULES_URL=https://terms.pkuhollow.com/rules.html 19 | REACT_APP_TOS_URL=https://terms.pkuhollow.com/tos.html 20 | REACT_APP_PRIVACY_URL=https://terms.pkuhollow.com/privacy.html 21 | REACT_APP_GITHUB_ISSUES_URL=https://github.com/pkuhollow/nameless/issues 22 | REACT_APP_COPYRIGHT_STRING=pkuhollow 23 | REACT_APP_GITHUB_USER=pkuhollow 24 | REACT_APP_GITHUB_WEB_URL=https://github.com/pkuhollow/webhole 25 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "plugin:react/recommended", 9 | "plugin:prettier/recommended", 10 | "prettier/react" 11 | ], 12 | "globals": { 13 | "Atomics": "readonly", 14 | "SharedArrayBuffer": "readonly", 15 | "React": true 16 | }, 17 | "parserOptions": { 18 | "ecmaFeatures": { 19 | "jsx": true 20 | }, 21 | "ecmaVersion": 2018, 22 | "sourceType": "module" 23 | }, 24 | "plugins": [ 25 | "prettier", 26 | "react" 27 | ], 28 | "settings": { 29 | "react": { 30 | "version": "detect" 31 | } 32 | }, 33 | "ignorePatterns": [ 34 | "src/infrastructure/", 35 | "src/react-lazyload/" 36 | ], 37 | "rules": { 38 | "prettier/prettier": "warn", 39 | "react/jsx-indent": [ 40 | "error", 41 | 2, 42 | { 43 | "indentLogicalExpressions": true 44 | } 45 | ], 46 | "react/prop-types": "off", 47 | "react/jsx-no-target-blank": "off", 48 | "no-unused-vars": [ 49 | "warn", 50 | { 51 | "args": "none" 52 | } 53 | ] 54 | } 55 | } -------------------------------------------------------------------------------- /src/infrastructure/functions.js: -------------------------------------------------------------------------------- 1 | export function get_json(res) { 2 | if(!res.ok) throw Error(`网络错误 ${res.status} ${res.statusText}`); 3 | return ( 4 | res 5 | .text() 6 | .then((t)=>{ 7 | try { 8 | return JSON.parse(t); 9 | } catch(e) { 10 | console.error('json parse error'); 11 | console.trace(e); 12 | console.log(t); 13 | throw new SyntaxError('JSON Parse Error '+t.substr(0,50)); 14 | } 15 | }) 16 | ); 17 | } 18 | 19 | export function listen_darkmode(override) { // override: true/false/undefined 20 | function update_color_scheme() { 21 | if(override===undefined ? window.matchMedia('(prefers-color-scheme: dark)').matches : override) 22 | document.body.classList.add('root-dark-mode'); 23 | else 24 | document.body.classList.remove('root-dark-mode'); 25 | } 26 | 27 | update_color_scheme(); 28 | window.matchMedia('(prefers-color-scheme: dark)').addListener(()=>{ 29 | update_color_scheme(); 30 | }); 31 | } 32 | 33 | export function API_VERSION_PARAM() { 34 | return '&jsapiver='+encodeURIComponent((process.env.REACT_APP_BUILD_INFO||'null')+'-'+(Math.floor(+new Date()/7200000)*2)); 35 | } -------------------------------------------------------------------------------- /src/old_infrastructure/functions.js: -------------------------------------------------------------------------------- 1 | export function get_json(res) { 2 | if(!res.ok) throw Error(`网络错误 ${res.status} ${res.statusText}`); 3 | return ( 4 | res 5 | .text() 6 | .then((t)=>{ 7 | try { 8 | return JSON.parse(t); 9 | } catch(e) { 10 | console.error('json parse error'); 11 | console.trace(e); 12 | console.log(t); 13 | throw new SyntaxError('JSON Parse Error '+t.substr(0,50)); 14 | } 15 | }) 16 | ); 17 | } 18 | 19 | export function listen_darkmode(override) { // override: true/false/undefined 20 | function update_color_scheme() { 21 | if(override===undefined ? window.matchMedia('(prefers-color-scheme: dark)').matches : override) 22 | document.body.classList.add('root-dark-mode'); 23 | else 24 | document.body.classList.remove('root-dark-mode'); 25 | } 26 | 27 | update_color_scheme(); 28 | window.matchMedia('(prefers-color-scheme: dark)').addListener(()=>{ 29 | update_color_scheme(); 30 | }); 31 | } 32 | 33 | export function API_VERSION_PARAM() { 34 | return '&device=0&v='+encodeURIComponent((process.env.REACT_APP_BUILD_INFO||'null')+'-'+(Math.floor(+new Date()/7200000)*2)); 35 | } -------------------------------------------------------------------------------- /src/Common.css: -------------------------------------------------------------------------------- 1 | .clickable { 2 | cursor: pointer; 3 | } 4 | 5 | .bg-img { 6 | position: fixed; 7 | z-index: -1; 8 | top: 0; 9 | left: 0; 10 | width: 100%; 11 | height: 100%; 12 | } 13 | 14 | .root-dark-mode .bg-img { 15 | opacity: .65; 16 | } 17 | 18 | .black-outline { 19 | text-shadow: /* also change .flow-item-row-with-prompt:hover::before */ 20 | -1px -1px 0 rgba(0,0,0,.6), 21 | 0 -1px 0 rgba(0,0,0,.6), 22 | 1px -1px 0 rgba(0,0,0,.6), 23 | -1px 1px 0 rgba(0,0,0,.6), 24 | 0 1px 0 rgba(0,0,0,.6), 25 | 1px 1px 0 rgba(0,0,0,.6); 26 | } 27 | 28 | .search-query-highlight { 29 | border-bottom: 1px solid black; 30 | font-weight: bold; 31 | } 32 | 33 | .root-dark-mode .search-query-highlight { 34 | border-bottom: 1px solid white; 35 | } 36 | 37 | .url-pid-link { 38 | opacity: .6; 39 | } 40 | 41 | :root { 42 | --coloredspan-bgcolor-light: white; 43 | --coloredspan-bgcolor-dark: black; 44 | } 45 | 46 | .colored-span { 47 | background-color: var(--coloredspan-bgcolor-light); 48 | } 49 | 50 | .root-dark-mode .colored-span { 51 | background-color: var(--coloredspan-bgcolor-dark); 52 | } 53 | 54 | .icon+label { 55 | font-size: .9em; 56 | vertical-align: .05em; 57 | cursor: inherit; 58 | padding: 0 .1rem; 59 | margin-left: .15rem; 60 | } 61 | -------------------------------------------------------------------------------- /src/infrastructure/elevator.js: -------------------------------------------------------------------------------- 1 | const DUMP_VER='dump_v1'; 2 | 3 | function dump() { 4 | return JSON.stringify({ 5 | _dump_ver: DUMP_VER, 6 | token: localStorage['TOKEN']||null, 7 | hole_config: localStorage['hole_config']||null, 8 | }); 9 | } 10 | function load(s) { 11 | console.log('elevator: loading',s); 12 | let obj=JSON.parse(s); 13 | if(obj._dump_ver!==DUMP_VER) { 14 | console.error('elevator: loading version mismatch, current',DUMP_VER,'param',obj._dump_ver); 15 | return; 16 | } 17 | if(localStorage['TOKEN']===undefined && obj.token) { 18 | console.log('replace token'); 19 | localStorage['TOKEN']=obj.token; 20 | } 21 | if(localStorage['hole_config']===undefined && obj.hole_config) { 22 | console.log('replace hole config'); 23 | localStorage['hole_config']=obj.hole_config; 24 | } 25 | } 26 | 27 | export function elevate() { 28 | // load 29 | // '?foo=fo&bar=ba' -> [["foo","fo"],["bar","ba"]] 30 | let params=window.location.search.substr(1).split('&').map((kv)=>kv.split('=')); 31 | params.forEach((kv)=>{ 32 | if(kv.length===2 && kv[0]==='_elevator_data') { 33 | load(decodeURIComponent(kv[1])); 34 | let url=new URL(window.location.href); 35 | url.search=''; 36 | window.history.replaceState('','',url.href); 37 | } 38 | }); 39 | 40 | // dump 41 | if(window.location.protocol==='http:' && window.location.hostname==='pkuhelper.pku.edu.cn') { 42 | let url=new URL(window.location.href); 43 | url.protocol='https:'; 44 | url.search='?_elevator_data='+encodeURIComponent(dump()); 45 | window.location.replace(url.href); 46 | } 47 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webhole", 3 | "version": "3.0.6", 4 | "private": true, 5 | "dependencies": { 6 | "copy-to-clipboard": "^3.3.1", 7 | "detect-browser": "^5.2.0", 8 | "fix-orientation": "^1.1.0", 9 | "gh-pages": "^3.1.0", 10 | "highlight.js": "^10.4.0", 11 | "html-to-react": "^1.4.5", 12 | "load-script": "^1.0.0", 13 | "markdown-it": "^12.0.2", 14 | "markdown-it-katex": "^2.0.3", 15 | "nanoid": "^3.1.20", 16 | "pressure": "^2.2.0", 17 | "pubsub-js": "^1.9.3", 18 | "react": "^16.14.0", 19 | "react-dom": "^16.14.0", 20 | "react-google-recaptcha": "^2.1.0", 21 | "react-google-recaptcha-v3": "^1.7.0", 22 | "react-imageslides": "^2.1.1", 23 | "react-scripts": "^3.4.4", 24 | "react-timeago": "^4.4.0", 25 | "react-transition-group": "^4.4.1", 26 | "ua-parser-js": "^0.7.24" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "echo 'skipped react-scripts test --env=jsdom'", 32 | "predeploy": "", 33 | "deploy": "gh-pages -b $DEPLOY_BRANCH -d build -m $(TZ=Asia/Shanghai date +\"%y%m%d%H%M%S\")", 34 | "eject": "react-scripts eject", 35 | "lint": "eslint --fix src/*.js" 36 | }, 37 | "homepage": ".", 38 | "browserslist": { 39 | "production": [ 40 | ">0.2%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 1 chrome version", 46 | "last 1 firefox version", 47 | "last 1 safari version" 48 | ] 49 | }, 50 | "devDependencies": { 51 | "eslint": "^6.8.0", 52 | "eslint-config-prettier": "^6.15.0", 53 | "eslint-plugin-import": "^2.22.1", 54 | "eslint-plugin-node": "^11.1.0", 55 | "eslint-plugin-prettier": "^3.1.3", 56 | "eslint-plugin-promise": "^4.2.1", 57 | "eslint-plugin-react": "^7.21.5", 58 | "eslint-plugin-standard": "^4.1.0", 59 | "prettier": "^2.2.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Title.css: -------------------------------------------------------------------------------- 1 | .title-bar { 2 | z-index: 10; 3 | position: sticky; 4 | top: -4em; 5 | left: 0; 6 | width: 100%; 7 | height: 7em; 8 | background-color: rgba(255,255,255,.8); 9 | box-shadow: 0 0 25px rgba(0,0,0,.4); 10 | margin-bottom: 1em; 11 | backdrop-filter: blur(5px); 12 | } 13 | 14 | .root-dark-mode .title-bar { 15 | background-color: hsla(0,0%,12%,.8); 16 | box-shadow: 0 0 5px rgba(255,255,255,.1); 17 | } 18 | 19 | .control-bar { 20 | display: flex; 21 | margin-top: .5em; 22 | line-height: 2em; 23 | } 24 | 25 | .control-btn { 26 | flex: 0 0 4.5em; 27 | text-align: center; 28 | color: black; 29 | border-radius: 5px; 30 | } 31 | .control-btn:hover { 32 | background-color: #666666; 33 | color: white; 34 | } 35 | .control-btn-label { 36 | margin-left: .25rem; 37 | font-size: .9em; 38 | vertical-align: .05em; 39 | } 40 | @media screen and (max-width: 900px) { 41 | .control-btn { 42 | flex: 0 0 2.5em; 43 | } 44 | .control-btn-label { 45 | display: none; 46 | } 47 | .control-search { 48 | padding: 0 .5em; 49 | } 50 | } 51 | 52 | .root-dark-mode .control-btn { 53 | color: var(--foreground-dark); 54 | opacity: .9; 55 | } 56 | .root-dark-mode .control-btn:hover { 57 | color: var(--foreground-dark); 58 | opacity: 1; 59 | } 60 | 61 | .control-search { 62 | flex: auto; 63 | color: black; 64 | background-color: rgba(255,255,255,.3) !important; 65 | margin: 0 .5em; 66 | min-width: 8em; 67 | } 68 | 69 | .control-search:focus { 70 | background-color: white !important; 71 | } 72 | 73 | .root-dark-mode .control-search { 74 | background-color: hsla(0,0%,35%,.6) !important; 75 | color: var(--foreground-dark); 76 | } 77 | .root-dark-mode .control-search:focus { 78 | background-color: hsl(0,0%,80%) !important; 79 | color: black !important; 80 | } 81 | 82 | .list-menu { 83 | text-align: center; 84 | } 85 | 86 | .help-desc-box p { 87 | margin: .5em; 88 | } -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-size: cover; 3 | user-select: none; 4 | background-color: #333; 5 | } 6 | 7 | body.root-dark-mode { 8 | background-color: black; 9 | } 10 | 11 | html::-webkit-scrollbar { 12 | display: none; 13 | } 14 | html { 15 | scrollbar-width: none; 16 | -ms-overflow-style: none; 17 | } 18 | 19 | :root { 20 | --var-link-color: #00c; 21 | } 22 | .root-dark-mode .left-container, .root-dark-mode .sidebar, .root-dark-mode .sidebar-title, .root-dark-mode .balance-popover { 23 | --var-link-color: #9bf; 24 | } 25 | 26 | a { 27 | color: var(--var-link-color); 28 | } 29 | a:not(.no-underline):hover { 30 | border-bottom: 1px solid var(--var-link-color); 31 | margin-bottom: -1px; 32 | } 33 | 34 | input, textarea { 35 | border-radius: 5px; 36 | border: 1px solid black; 37 | outline: none; 38 | margin: 0; 39 | } 40 | input { 41 | padding: 0 1em; 42 | line-height: 2em; 43 | } 44 | 45 | audio { 46 | vertical-align: middle; 47 | } 48 | 49 | button, .button { 50 | color: black; 51 | background-color: rgba(235,235,235,.5); 52 | border-radius: 5px; 53 | text-align: center; 54 | border: 1px solid black; 55 | line-height: 2em; 56 | margin: 0 .5rem; 57 | } 58 | 59 | .root-dark-mode button, .root-dark-mode .button { 60 | background-color: hsl(0,0%,30%); 61 | color: var(--foreground-dark); 62 | } 63 | 64 | button:hover, .button:hover { 65 | background-color: rgba(255,255,255,.7); 66 | } 67 | 68 | .root-dark-mode button:hover, .root-dark-mode .button:hover { 69 | background-color: hsl(0,0%,40%); 70 | } 71 | 72 | button:disabled, .button:disabled { 73 | background-color: rgba(128,128,128,.5); 74 | } 75 | 76 | .root-dark-mode button:disabled, .root-dark-mode .button:disabled { 77 | background-color: hsl(0,0%,20%); 78 | color: hsl(0,0%,60%); 79 | } 80 | 81 | .root-dark-mode input:not([type=file]), .root-dark-mode textarea { 82 | background-color: hsl(0,0%,30%); 83 | color: var(--foreground-dark); 84 | } 85 | .root-dark-mode input:not([type=file])::placeholder { 86 | color: var(--foreground-dark); 87 | } -------------------------------------------------------------------------------- /src/Sidebar.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import './Sidebar.css'; 3 | 4 | export class Sidebar extends PureComponent { 5 | constructor(props) { 6 | super(props); 7 | // this.sidebar_ref = React.createRef(); 8 | this.do_close_bound = this.do_close.bind(this); 9 | this.do_back_bound = this.do_back.bind(this); 10 | } 11 | 12 | do_close() { 13 | this.props.show_sidebar(null, null, 'clear'); 14 | } 15 | do_back() { 16 | this.props.show_sidebar(null, null, 'pop'); 17 | } 18 | 19 | render() { 20 | // hide old contents to remember state 21 | let contents = this.props.stack.map( 22 | ({ 1: content }, i) => 23 | content && ( 24 |
33 | {content} 34 |
35 | ), 36 | ); 37 | let cur_title = this.props.stack[this.props.stack.length - 1][0]; 38 | return ( 39 |
45 |
{ 49 | e.preventDefault(); 50 | e.target.click(); 51 | }} 52 | /> 53 |
{contents}
54 |
55 | 56 |   57 | 58 |   59 | 60 | {this.props.stack.length > 2 && ( 61 | 62 |   63 | 64 |   65 | 66 | )} 67 | {cur_title} 68 |
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 |
56 | { 58 | this.load(); 59 | }} 60 | > 61 | 重新加载 62 | 63 |
64 | ); 65 | else if (this.state.loading_status === 'done') 66 | return this.state.msg.map((msg) => ( 67 |
68 |
69 |
72 |
73 |
{msg.content}
74 |
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 |

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 |
112 | 116 | 117 | 最新 118 | 119 | {!!token && ( 120 | 124 | 125 | 关注 126 | 127 | )} 128 | 132 | 133 | 热榜 134 | 135 | 144 | { 147 | this.props.show_sidebar( 148 | process.env.REACT_APP_TITLE, 149 | , 150 | ); 151 | }} 152 | > 153 | 154 | 155 | {token ? '账户' : '登录'} 156 | 157 | 158 | {!!token && ( 159 | { 162 | this.props.show_sidebar( 163 | '发表树洞', 164 | { 169 | this.props.show_sidebar(null, null, 'clear'); 170 | this.do_refresh(); 171 | }} 172 | />, 173 | ); 174 | }} 175 | > 176 | 177 | 发表 178 | 179 | )} 180 |
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 | <TokenCtx.Consumer> 139 | {(token) => ( 140 | <div className="left-container"> 141 | <DeprecatedAlert token={token.value} /> 142 | {!token.value && ( 143 | <div className="flow-item-row aux-margin"> 144 | <div className="box box-tip"> 145 | <p> 146 | <LoginPopup token_callback={token.set_value}> 147 | {(do_popup) => ( 148 | <a onClick={do_popup}> 149 | <span className="icon icon-login" /> 150 |  登录到 {process.env.REACT_APP_TITLE} 151 | </a> 152 | )} 153 | </LoginPopup> 154 | </p> 155 | </div> 156 | <div className="box box-tip"> 157 | <p> 158 | <h3>公告栏</h3> 159 | <HighlightedMarkdown 160 | text="祝大家在未名树洞玩得开心!" 161 | /> 162 | <hr/> 163 | <HighlightedMarkdown 164 | text="[什么是未名树洞?](https://www.pkuhollow.com/)" 165 | /> 166 | </p> 167 | </div> 168 | </div> 169 | )} 170 | {needShowSuicidePrompt(this.state.search_text) && 171 | !this.state.override_suicide && ( 172 | <div className="flow-item-row"> 173 | <div className="flow-item box box-tip"> 174 | <p style={{ textAlign: 'left' }}>需要帮助?</p> 175 | <p style={{ textAlign: 'left' }}> 176 | 北京24小时心理援助热线: 177 | <a href="tel:01082951332">010-8295-1332</a> 178 | </p> 179 | <p style={{ textAlign: 'left' }}> 180 | 希望24小时热线: 181 | <a href="tel:4001619995">400-161-9995</a> 182 | </p> 183 | <hr /> 184 | <p> 185 | <button 186 | onClick={() => { 187 | window.location.href = 188 | 'https://www.zhihu.com/question/25082178/answer/106073121'; 189 | }} 190 | > 191 | 了解更多 192 | </button> 193 |       194 | <button 195 | onClick={() => { 196 | this.setState({ 197 | override_suicide: true, 198 | }); 199 | }} 200 | > 201 | 展示结果 202 | </button> 203 | </p> 204 | </div> 205 | </div> 206 | )} 207 | {this.inthu_flag || token.value ? ( 208 | (this.state.override_suicide || 209 | !needShowSuicidePrompt(this.state.search_text)) && ( 210 | <SwitchTransition mode="out-in"> 211 | <CSSTransition 212 | key={this.state.flow_render_key} 213 | timeout={100} 214 | classNames="flows-anim" 215 | > 216 | <Flow 217 | key={this.state.flow_render_key} 218 | show_sidebar={this.show_sidebar_bound} 219 | mode={this.state.mode} 220 | search_text={this.state.search_text} 221 | token={token.value} 222 | /> 223 | </CSSTransition> 224 | </SwitchTransition> 225 | ) 226 | ) : ( 227 | <TitleLine text="请登录后查看内容" /> 228 | )} 229 | <br /> 230 | </div> 231 | )} 232 | </TokenCtx.Consumer> 233 | <Sidebar 234 | show_sidebar={this.show_sidebar_bound} 235 | stack={this.state.sidebar_stack} 236 | /> 237 | </TokenCtx.Provider> 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 | <GoogleReCaptchaProvider 168 | reCaptchaKey={process.env.REACT_APP_RECAPTCHA_V3_KEY} 169 | useRecaptchaNet={true} 170 | > 171 | <div> 172 | <div className="treehollow-login-popup-shadow" /> 173 | <div className="treehollow-login-popup margin-popup"> 174 | {this.state.phase === -1 && ( 175 | <> 176 | <p> 177 | <b>输入邮箱来注销账户/找回密码</b> 178 | </p> 179 | </> 180 | )} 181 | <p style={this.state.phase === -1 ? {} : { display: 'none' }}> 182 | <label> 183 | 邮箱  184 | <input 185 | ref={this.ref.username} 186 | type="email" 187 | autoFocus={true} 188 | defaultValue="@mails.tsinghua.edu.cn" 189 | onKeyDown={(event) => { 190 | if (event.key === 'Enter') { 191 | this.next_step(); 192 | } 193 | }} 194 | /> 195 | </label> 196 | </p> 197 | {this.state.phase === 1 && ( 198 | <> 199 | <p> 200 | <b>{process.env.REACT_APP_TITLE} 注销账户</b> 201 | </p> 202 | <p> 203 | <label> 204 | 邮箱验证码  205 | <input 206 | ref={this.ref.email_verification} 207 | type="tel" 208 | autoFocus={true} 209 | /> 210 | </label> 211 | </p> 212 | <p> 213 | Nonce:  214 | <label> 215 | <input ref={this.ref.nonce} autoFocus={true} /> 216 | </label> 217 | </p> 218 | <p> 219 | 注:Nonce是注册树洞时欢迎邮件中的“找回密码口令”,形如xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx。 220 | </p> 221 | <p> 222 | <label> 223 | <input type="checkbox" ref={this.ref.checkbox_account} /> 224 | 我已经了解了注销账户后,原账户的发帖、评论将保留;注销账户后可以重新注册树洞,但原账户的关注列表、评论区昵称等关联数据将丢失。 225 | </label> 226 | </p> 227 | </> 228 | )} 229 | {this.state.phase === 3 && ( 230 | <> 231 | <p> 232 | <b>输入验证码 {process.env.REACT_APP_TITLE}</b> 233 | </p> 234 | <RecaptchaV2Popup 235 | callback={() => { 236 | this.verify_email('v2', () => { 237 | alert('reCAPTCHA风控系统校验失败'); 238 | }); 239 | }} 240 | > 241 | {(do_popup) => ( 242 | <p> 243 | {!this.state.recaptcha_verified && ( 244 | <GoogleReCaptcha 245 | onVerify={(token) => { 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 | </p> 256 | )} 257 | </RecaptchaV2Popup> 258 | </> 259 | )} 260 | <p> 261 | <button 262 | onClick={this.next_step.bind(this)} 263 | disabled={this.state.loading_status === 'loading'} 264 | > 265 | 下一步 266 | </button> 267 | <button onClick={this.props.on_close}>取消</button> 268 | </p> 269 | </div> 270 | </div> 271 | </GoogleReCaptchaProvider>, 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 | <UnregisterPopupSelf on_close={this.on_close_bound} /> 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 | <div> 121 | <p> 122 | <b>背景图片:</b> 123 | <select 124 | className="config-select" 125 | value={img_select} 126 | onChange={this.on_select.bind(this)} 127 | > 128 | {Object.keys(BUILTIN_IMGS).map((key) => ( 129 | <option key={key} value={key}> 130 | {BUILTIN_IMGS[key]} 131 | </option> 132 | ))} 133 | <option value="##other">输入图片网址……</option> 134 | <option value="##color">纯色背景……</option> 135 | </select> 136 |   137 | <small>#background_img</small>  138 | {img_select === '##other' && ( 139 | <input 140 | type="url" 141 | placeholder="图片网址" 142 | value={this.state.img} 143 | onChange={this.on_change_img.bind(this)} 144 | /> 145 | )} 146 | {img_select === '##color' && ( 147 | <input 148 | type="color" 149 | value={this.state.color} 150 | onChange={this.on_change_color.bind(this)} 151 | /> 152 | )} 153 | </p> 154 | <div 155 | className="bg-preview" 156 | style={bgimg_style(this.state.img, this.state.color)} 157 | /> 158 | </div> 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 | <div> 190 | <p> 191 | <b>夜间模式:</b> 192 | <select 193 | className="config-select" 194 | value={this.state.color_scheme} 195 | onChange={this.on_select.bind(this)} 196 | > 197 | <option value="default">跟随系统</option> 198 | <option value="light">始终浅色模式</option> 199 | <option value="dark">始终深色模式</option> 200 | </select> 201 |  <small>#color_scheme</small> 202 | </p> 203 | <p className="config-description"> 204 | 选择浅色或深色模式,深色模式下将会调暗图片亮度 205 | </p> 206 | </div> 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 | <div> 238 | <label> 239 | <p> 240 | <b>{this.props.name}</b> <small>#{this.props.id}</small> 241 | </p> 242 | <p className="config-description">{this.props.description}</p> 243 | <textarea 244 | name={'config-' + this.props.id} 245 | id={`config-textarea-${this.props.id}`} 246 | className="config-textarea" 247 | value={this.props.display(this.state[this.props.id])} 248 | onChange={this.on_change.bind(this)} 249 | /> 250 | </label> 251 | </div> 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 | <div> 284 | <p> 285 | {' '} 286 | <b>设置屏蔽词 </b> 287 | </p> 288 | <p> 289 | <textarea 290 | className="block-words" 291 | value={this.state.block_words.join('\n')} 292 | onChange={this.on_change.bind(this)} 293 | /> 294 | </p> 295 | </div> 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 | <div> 325 | <p> 326 | <label> 327 | <input 328 | name={'config-' + this.props.id} 329 | type="checkbox" 330 | checked={this.state.switch} 331 | onChange={this.on_change.bind(this)} 332 | /> 333 |  <b>{this.props.name}</b> 334 |  <small>#{this.props.id}</small> 335 | </label> 336 | </p> 337 | <p className="config-description">{this.props.description}</p> 338 | </div> 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 | <div> 368 | <div className="box config-ui-header"> 369 | <p> 370 | 这些功能仍在测试,可能不稳定( 371 | <a onClick={this.reset_settings.bind(this)}>全部重置</a>) 372 | </p> 373 | <p> 374 | <b> 375 | 修改设置后{' '} 376 | <a 377 | onClick={() => { 378 | window.location.reload(); 379 | }} 380 | > 381 | 刷新页面 382 | </a>{' '} 383 | 方可生效 384 | </b> 385 | </p> 386 | </div> 387 | <div className="box"> 388 | <ConfigBackground 389 | id="background" 390 | callback={this.save_changes_bound} 391 | /> 392 | <hr /> 393 | <ConfigColorScheme 394 | id="color-scheme" 395 | callback={this.save_changes_bound} 396 | /> 397 | <hr /> 398 | {/* <ConfigBlockWords 399 | id="block-words" 400 | callback={this.save_changes_bound} 401 | /> */} 402 | <ConfigTextArea 403 | id="hidden_tags" 404 | callback={this.save_changes_bound} 405 | name="设置屏蔽标签" 406 | description={'带有屏蔽标签的树洞会被完全隐藏,每行写一个屏蔽标签'} 407 | display={(array) => array.join('\n')} 408 | sift={(array) => array.filter((v) => v)} 409 | parse={(string) => string.split('\n')} 410 | /> 411 | <hr /> 412 | <ConfigTextArea 413 | id="block_words" 414 | callback={this.save_changes_bound} 415 | name="设置屏蔽词" 416 | description={'包含屏蔽词的树洞会被折叠,每行写一个屏蔽词'} 417 | display={(array) => array.join('\n')} 418 | sift={(array) => array.filter((v) => v)} 419 | parse={(string) => string.split('\n')} 420 | /> 421 | <hr /> 422 | <ConfigSwitch 423 | callback={this.save_changes_bound} 424 | id="pressure" 425 | name="快速返回" 426 | description="短暂按住 Esc 键或重压屏幕(3D Touch)可以快速返回或者刷新树洞" 427 | /> 428 | <hr /> 429 | <ConfigSwitch 430 | callback={this.save_changes_bound} 431 | id="easter_egg" 432 | name="允许彩蛋" 433 | description="在某些情况下显示彩蛋" 434 | /> 435 | <hr /> 436 | <ConfigSwitch 437 | callback={this.save_changes_bound} 438 | id="fold" 439 | name="折叠树洞" 440 | description="在时间线中折叠可能引起不适的树洞" 441 | /> 442 | {localStorage['hide_announcement'] && ( 443 | <div> 444 | <hr /> 445 | <p>已隐藏的公告</p> 446 | <div className="box flow-item box-announcement"> 447 | <HighlightedMarkdown 448 | text={localStorage['hide_announcement']} 449 | color_picker={this.color_picker} 450 | show_pid={() => {}} 451 | /> 452 | <a 453 | onClick={() => { 454 | delete localStorage['hide_announcement']; 455 | alert('已取消隐藏公告'); 456 | }} 457 | > 458 | [取消隐藏] 459 | </a> 460 | </div> 461 | </div> 462 | )} 463 | <hr /> 464 | <p> 465 | 新功能建议或问题反馈请在  466 | <a href={process.env.REACT_APP_GITHUB_ISSUES_URL} target="_blank"> 467 | GitHub <span className="icon icon-github" /> 468 | </a> 469 |  提出。 470 | </p> 471 | </div> 472 | </div> 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 | <span 40 | className="colored-span" 41 | style={{ 42 | '--coloredspan-bgcolor-light': props.colors[0], 43 | '--coloredspan-bgcolor-dark': props.colors[1], 44 | }} 45 | > 46 | {props.children} 47 | </span> 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 | <pre> 59 | {this.props.parts.map((part, idx) => { 60 | let [rule, p] = part; 61 | return ( 62 | <span key={idx}> 63 | {rule === 'url_pid' ? ( 64 | <span className="url-pid-link" title={p}> 65 | /## 66 | </span> 67 | ) : rule === 'url' ? ( 68 | <a href={normalize_url(p)} target="_blank" rel="noopener"> 69 | {p} 70 | </a> 71 | ) : rule === 'pid' ? ( 72 | <a 73 | href={'#' + p} 74 | onClick={(e) => { 75 | e.preventDefault(); 76 | this.props.show_pid(p.substring(1)); 77 | }} 78 | > 79 | {p} 80 | </a> 81 | ) : rule === 'nickname' ? ( 82 | <ColoredSpan colors={this.props.color_picker.get(p)}> 83 | {p} 84 | </ColoredSpan> 85 | ) : rule === 'search' ? ( 86 | <span className="search-query-highlight">{p}</span> 87 | ) : ( 88 | p 89 | )} 90 | </span> 91 | ); 92 | })} 93 | </pre> 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 <div key={index}>[图片]</div>; 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 <HeadingTag key={index}>{children}</HeadingTag>; 117 | }, 118 | }, 119 | { 120 | shouldProcessNode: (node) => node.name === 'a', 121 | processNode(node, children, index) { 122 | return ( 123 | <a 124 | href={normalize_url(node.attribs.href)} 125 | target="_blank" 126 | rel="noopenner noreferrer" 127 | className="ext-link" 128 | key={index} 129 | > 130 | {children} 131 | <span className="icon icon-new-tab" /> 132 | </a> 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 | <React.Fragment key={index}> 163 | {splitted.map(([rule, p], idx) => { 164 | return ( 165 | <span key={idx}> 166 | {rule === 'url_pid' ? ( 167 | <span className="url-pid-link" title={p}> 168 | /## 169 | </span> 170 | ) : rule === 'url' ? ( 171 | <a 172 | href={normalize_url(p)} 173 | className="ext-link" 174 | target="_blank" 175 | rel="noopener noreferrer" 176 | > 177 | {p} 178 | <span className="icon icon-new-tab" /> 179 | </a> 180 | ) : rule === 'pid' ? ( 181 | <a 182 | href={'#' + p} 183 | onClick={(e) => { 184 | e.preventDefault(); 185 | props.show_pid(p.substring(1)); 186 | }} 187 | > 188 | {p} 189 | </a> 190 | ) : rule === 'nickname' ? ( 191 | <ColoredSpan colors={props.color_picker.get(p)}> 192 | {p} 193 | </ColoredSpan> 194 | ) : rule === 'search' ? ( 195 | <span className="search-query-highlight">{p}</span> 196 | ) : ( 197 | p 198 | )} 199 | </span> 200 | ); 201 | })} 202 | </React.Fragment> 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 | <textarea 308 | ref={this.area_ref} 309 | onChange={this.on_change_bound} 310 | value={this.state.text} 311 | onKeyDown={this.on_keydown_bound} 312 | /> 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 | <div className="box promotion-bar"> 335 | <span className="icon icon-about" /> 336 |   用 Safari 把树洞 <b>添加到主屏幕</b> 更好用 337 | </div> 338 | ) : null; 339 | // noinspection JSConstructorReturnsPrimitive 340 | else 341 | return pwa_prompt_event ? ( 342 | <div className="box promotion-bar"> 343 | <span className="icon icon-about" /> 344 |   把网页版树洞{' '} 345 | <b> 346 | <a 347 | onClick={() => { 348 | if (pwa_prompt_event) pwa_prompt_event.prompt(); 349 | }} 350 | > 351 | 安装到桌面 352 | </a> 353 | </b>{' '} 354 | 更好用 355 | </div> 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 | <div className="box box-tip box-warning"> 365 | <b>您正在使用 QQ/微信 内嵌浏览器</b> 366 | <br /> 367 | 建议使用系统浏览器打开,否则可能出现兼容问题 368 | </div> 369 | ); 370 | if (/Edge\/1/.test(navigator.userAgent)) 371 | return ( 372 | <div className="box box-tip box-warning"> 373 | <b>您正在使用旧版 Microsoft Edge</b> 374 | <br /> 375 | 建议使用新版 Edge,否则可能出现兼容问题 376 | </div> 377 | ); 378 | else if (cr_version > 1 && cr_version < 57) 379 | return ( 380 | <div className="box box-tip box-warning"> 381 | <b>您正在使用古老的 Chrome {cr_version}</b> 382 | <br /> 383 | 建议使用新版浏览器,否则可能出现兼容问题 384 | </div> 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 | <div 444 | onTouchStart={this.on_begin_bound} 445 | onMouseDown={this.on_begin_bound} 446 | onTouchMove={this.on_move_bound} 447 | onMouseMove={this.on_move_bound} 448 | onClick={this.on_end_bound} 449 | > 450 | {this.props.children} 451 | </div> 452 | ); 453 | } 454 | } 455 | -------------------------------------------------------------------------------- /public/static/abcsx/icomoon.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" standalone="no"?> 2 | <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" > 3 | <svg xmlns="http://www.w3.org/2000/svg"> 4 | <metadata>Generated by IcoMoon</metadata> 5 | <defs> 6 | <font id="icomoon" horiz-adv-x="1024"> 7 | <font-face units-per-em="1024" ascent="960" descent="-64" /> 8 | <missing-glyph horiz-adv-x="1024" /> 9 | <glyph unicode=" " horiz-adv-x="512" d="" /> 10 | <glyph unicode="" glyph-name="send" horiz-adv-x="1025" d="M1008 944.571c12-8.571 17.714-22.286 15.429-36.571l-146.286-877.714c-1.714-10.857-8.571-20-18.286-25.714-5.143-2.857-11.429-4.571-17.714-4.571-4.571 0-9.143 1.143-13.714 2.857l-258.857 105.714-138.286-168.571c-6.857-8.571-17.143-13.143-28-13.143-4 0-8.571 0.571-12.571 2.286-14.286 5.143-24 18.857-24 34.286v199.429l493.714 605.143-610.857-528.571-225.714 92.571c-13.143 5.143-21.714 17.143-22.857 31.429-0.571 13.714 6.286 26.857 18.286 33.714l950.857 548.571c5.714 3.429 12 5.143 18.286 5.143 7.429 0 14.857-2.286 20.571-6.286z" /> 11 | <glyph unicode="" glyph-name="image" d="M959.884 832c0.040-0.034 0.082-0.076 0.116-0.116v-767.77c-0.034-0.040-0.076-0.082-0.116-0.116h-895.77c-0.040 0.034-0.082 0.076-0.114 0.116v767.772c0.034 0.040 0.076 0.082 0.114 0.114h895.77zM960 896h-896c-35.2 0-64-28.8-64-64v-768c0-35.2 28.8-64 64-64h896c35.2 0 64 28.8 64 64v768c0 35.2-28.8 64-64 64v0zM832 672c0-53.020-42.98-96-96-96s-96 42.98-96 96 42.98 96 96 96 96-42.98 96-96zM896 128h-768v128l224 384 256-320h64l224 192z" /> 12 | <glyph unicode="" glyph-name="textfile" d="M917.806 730.924c-22.212 30.292-53.174 65.7-87.178 99.704s-69.412 64.964-99.704 87.178c-51.574 37.82-76.592 42.194-90.924 42.194h-496c-44.112 0-80-35.888-80-80v-864c0-44.112 35.888-80 80-80h736c44.112 0 80 35.888 80 80v624c0 14.332-4.372 39.35-42.194 90.924zM785.374 785.374c30.7-30.7 54.8-58.398 72.58-81.374h-153.954v153.946c22.984-17.78 50.678-41.878 81.374-72.572zM896 16c0-8.672-7.328-16-16-16h-736c-8.672 0-16 7.328-16 16v864c0 8.672 7.328 16 16 16 0 0 495.956 0.002 496 0v-224c0-17.672 14.326-32 32-32h224v-624zM736 128h-448c-17.672 0-32 14.326-32 32s14.328 32 32 32h448c17.674 0 32-14.326 32-32s-14.326-32-32-32zM736 256h-448c-17.672 0-32 14.326-32 32s14.328 32 32 32h448c17.674 0 32-14.326 32-32s-14.326-32-32-32zM736 384h-448c-17.672 0-32 14.326-32 32s14.328 32 32 32h448c17.674 0 32-14.326 32-32s-14.326-32-32-32z" /> 13 | <glyph unicode="" glyph-name="history" horiz-adv-x="1088" d="M640 896c247.424 0 448-200.576 448-448s-200.576-448-448-448v96c94.024 0 182.418 36.614 248.902 103.098s103.098 154.878 103.098 248.902c0 94.022-36.614 182.418-103.098 248.902s-154.878 103.098-248.902 103.098c-94.022 0-182.418-36.614-248.902-103.098-51.14-51.138-84.582-115.246-97.306-184.902h186.208l-224-256-224 256h164.57c31.060 217.102 217.738 384 443.43 384zM832 512v-128h-256v320h128v-192z" /> 14 | <glyph unicode="" glyph-name="reply" d="M512 896c282.77 0 512-186.25 512-416 0-229.752-229.23-416-512-416-27.156 0-53.81 1.734-79.824 5.044-109.978-109.978-241.25-129.7-368.176-132.596v26.916c68.536 33.578 128 94.74 128 164.636 0 9.754-0.758 19.33-2.164 28.696-115.796 76.264-189.836 192.754-189.836 323.304 0 229.75 229.23 416 512 416z" /> 15 | <glyph unicode="" glyph-name="quote" d="M225 512c123.712 0 224-100.29 224-224 0-123.712-100.288-224-224-224s-224 100.288-224 224l-1 32c0 247.424 200.576 448 448 448v-128c-85.474 0-165.834-33.286-226.274-93.726-11.634-11.636-22.252-24.016-31.83-37.020 11.438 1.8 23.16 2.746 35.104 2.746zM801 512c123.71 0 224-100.29 224-224 0-123.712-100.29-224-224-224s-224 100.288-224 224l-1 32c0 247.424 200.576 448 448 448v-128c-85.474 0-165.834-33.286-226.274-93.726-11.636-11.636-22.254-24.016-31.832-37.020 11.44 1.8 23.16 2.746 35.106 2.746z" /> 16 | <glyph unicode="" glyph-name="loading" d="M728.992 448c137.754 87.334 231.008 255.208 231.008 448 0 21.676-1.192 43.034-3.478 64h-889.042c-2.29-20.968-3.48-42.326-3.48-64 0-192.792 93.254-360.666 231.006-448-137.752-87.334-231.006-255.208-231.006-448 0-21.676 1.19-43.034 3.478-64h889.042c2.288 20.966 3.478 42.324 3.478 64 0.002 192.792-93.252 360.666-231.006 448zM160 0c0 186.912 80.162 345.414 224 397.708v100.586c-143.838 52.29-224 210.792-224 397.706v0h704c0-186.914-80.162-345.416-224-397.706v-100.586c143.838-52.294 224-210.796 224-397.708h-704zM619.626 290.406c-71.654 40.644-75.608 93.368-75.626 125.366v64.228c0 31.994 3.804 84.914 75.744 125.664 38.504 22.364 71.808 56.348 97.048 98.336h-409.582c25.266-42.032 58.612-76.042 97.166-98.406 71.654-40.644 75.606-93.366 75.626-125.366v-64.228c0-31.992-3.804-84.914-75.744-125.664-72.622-42.18-126.738-125.684-143.090-226.336h501.67c-16.364 100.708-70.53 184.248-143.212 226.406z" /> 17 | <glyph unicode="" glyph-name="login" d="M704 960c-176.73 0-320-143.268-320-320 0-20.026 1.858-39.616 5.376-58.624l-389.376-389.376v-192c0-35.346 28.654-64 64-64h64v64h128v128h128v128h128l83.042 83.042c34.010-12.316 70.696-19.042 108.958-19.042 176.73 0 320 143.268 320 320s-143.27 320-320 320zM799.874 639.874c-53.020 0-96 42.98-96 96s42.98 96 96 96 96-42.98 96-96-42.98-96-96-96z" /> 18 | <glyph unicode="" glyph-name="settings" d="M933.79 349.75c-53.726 93.054-21.416 212.304 72.152 266.488l-100.626 174.292c-28.75-16.854-62.176-26.518-97.846-26.518-107.536 0-194.708 87.746-194.708 195.99h-201.258c0.266-33.41-8.074-67.282-25.958-98.252-53.724-93.056-173.156-124.702-266.862-70.758l-100.624-174.292c28.97-16.472 54.050-40.588 71.886-71.478 53.638-92.908 21.512-211.92-71.708-266.224l100.626-174.292c28.65 16.696 61.916 26.254 97.4 26.254 107.196 0 194.144-87.192 194.7-194.958h201.254c-0.086 33.074 8.272 66.57 25.966 97.218 53.636 92.906 172.776 124.594 266.414 71.012l100.626 174.29c-28.78 16.466-53.692 40.498-71.434 71.228zM512 240.668c-114.508 0-207.336 92.824-207.336 207.334 0 114.508 92.826 207.334 207.336 207.334 114.508 0 207.332-92.826 207.332-207.334-0.002-114.51-92.824-207.334-207.332-207.334z" /> 19 | <glyph unicode="" glyph-name="stats" d="M128 64h896v-128h-1024v1024h128zM288 128c-53.020 0-96 42.98-96 96s42.98 96 96 96c2.828 0 5.622-0.148 8.388-0.386l103.192 171.986c-9.84 15.070-15.58 33.062-15.58 52.402 0 53.020 42.98 96 96 96s96-42.98 96-96c0-19.342-5.74-37.332-15.58-52.402l103.192-171.986c2.766 0.238 5.56 0.386 8.388 0.386 2.136 0 4.248-0.094 6.35-0.23l170.356 298.122c-10.536 15.408-16.706 34.036-16.706 54.11 0 53.020 42.98 96 96 96s96-42.98 96-96c0-53.020-42.98-96-96-96-2.14 0-4.248 0.094-6.35 0.232l-170.356-298.124c10.536-15.406 16.706-34.036 16.706-54.11 0-53.020-42.98-96-96-96s-96 42.98-96 96c0 19.34 5.74 37.332 15.578 52.402l-103.19 171.984c-2.766-0.238-5.56-0.386-8.388-0.386s-5.622 0.146-8.388 0.386l-103.192-171.986c9.84-15.068 15.58-33.060 15.58-52.4 0-53.020-42.98-96-96-96z" /> 20 | <glyph unicode="" glyph-name="fire" d="M321.008-64c-68.246 142.008-31.902 223.378 20.55 300.044 57.44 83.956 72.244 167.066 72.244 167.066s45.154-58.7 27.092-150.508c79.772 88.8 94.824 230.28 82.782 284.464 180.314-126.012 257.376-398.856 153.522-601.066 552.372 312.532 137.398 780.172 65.154 832.85 24.082-52.676 28.648-141.85-20-185.126-82.352 312.276-285.972 376.276-285.972 376.276 24.082-161.044-87.296-337.144-194.696-468.73-3.774 64.216-7.782 108.528-41.55 169.98-7.58-116.656-96.732-211.748-120.874-328.628-32.702-158.286 24.496-274.18 241.748-396.622z" /> 21 | <glyph unicode="" glyph-name="locate" d="M1024 512h-100.924c-27.64 178.24-168.836 319.436-347.076 347.076v100.924h-128v-100.924c-178.24-27.64-319.436-168.836-347.076-347.076h-100.924v-128h100.924c27.64-178.24 168.836-319.436 347.076-347.076v-100.924h128v100.924c178.24 27.64 319.436 168.836 347.076 347.076h100.924v128zM792.822 512h-99.762c-19.284 54.55-62.51 97.778-117.060 117.060v99.762c107.514-24.49 192.332-109.31 216.822-216.822zM512 384c-35.346 0-64 28.654-64 64s28.654 64 64 64c35.346 0 64-28.654 64-64s-28.654-64-64-64zM448 728.822v-99.762c-54.55-19.282-97.778-62.51-117.060-117.060h-99.762c24.49 107.512 109.31 192.332 216.822 216.822zM231.178 384h99.762c19.282-54.55 62.51-97.778 117.060-117.060v-99.762c-107.512 24.49-192.332 109.308-216.822 216.822zM576 167.178v99.762c54.55 19.284 97.778 62.51 117.060 117.060h99.762c-24.49-107.514-109.308-192.332-216.822-216.822z" /> 22 | <glyph unicode="" glyph-name="upload" d="M892.268 573.51c2.444 11.11 3.732 22.648 3.732 34.49 0 88.366-71.634 160-160 160-14.222 0-28.014-1.868-41.132-5.352-24.798 77.352-97.29 133.352-182.868 133.352-87.348 0-161.054-58.336-184.326-138.17-22.742 6.622-46.792 10.17-71.674 10.17-141.384 0-256-114.616-256-256 0-141.388 114.616-256 256-256h128v-192h256v192h224c88.366 0 160 71.632 160 160 0 78.72-56.854 144.162-131.732 157.51zM576 320v-192h-128v192h-160l224 224 224-224h-160z" /> 23 | <glyph unicode="" glyph-name="flag" d="M0 960h128v-1024h-128v1024zM832 316.998c82.624 0 154.57 19.984 192 49.5v512c-37.43-29.518-109.376-49.502-192-49.502s-154.57 19.984-192 49.502v-512c37.43-29.516 109.376-49.5 192-49.5zM608 927.472c-46.906 19.94-115.52 32.528-192 32.528-96.396 0-180.334-19.984-224-49.502v-512c43.666 29.518 127.604 49.502 224 49.502 76.48 0 145.094-12.588 192-32.528v512z" /> 24 | <glyph unicode="" glyph-name="eye" d="M512 768c-223.318 0-416.882-130.042-512-320 95.118-189.958 288.682-320 512-320 223.312 0 416.876 130.042 512 320-95.116 189.958-288.688 320-512 320zM764.45 598.296c60.162-38.374 111.142-89.774 149.434-150.296-38.292-60.522-89.274-111.922-149.436-150.296-75.594-48.218-162.89-73.704-252.448-73.704-89.56 0-176.858 25.486-252.452 73.704-60.158 38.372-111.138 89.772-149.432 150.296 38.292 60.524 89.274 111.924 149.434 150.296 3.918 2.5 7.876 4.922 11.86 7.3-9.96-27.328-15.41-56.822-15.41-87.596 0-141.382 114.616-256 256-256 141.382 0 256 114.618 256 256 0 30.774-5.452 60.268-15.408 87.598 3.978-2.378 7.938-4.802 11.858-7.302v0zM512 544c0-53.020-42.98-96-96-96s-96 42.98-96 96 42.98 96 96 96 96-42.982 96-96z" /> 25 | <glyph unicode="" glyph-name="eye-blocked" d="M945.942 945.942c-18.746 18.744-49.136 18.744-67.882 0l-202.164-202.164c-51.938 15.754-106.948 24.222-163.896 24.222-223.318 0-416.882-130.042-512-320 41.122-82.124 100.648-153.040 173.022-207.096l-158.962-158.962c-18.746-18.746-18.746-49.136 0-67.882 9.372-9.374 21.656-14.060 33.94-14.060s24.568 4.686 33.942 14.058l864 864c18.744 18.746 18.744 49.138 0 67.884zM416 640c42.24 0 78.082-27.294 90.92-65.196l-121.724-121.724c-37.902 12.838-65.196 48.68-65.196 90.92 0 53.020 42.98 96 96 96zM110.116 448c38.292 60.524 89.274 111.924 149.434 150.296 3.918 2.5 7.876 4.922 11.862 7.3-9.962-27.328-15.412-56.822-15.412-87.596 0-54.89 17.286-105.738 46.7-147.418l-60.924-60.924c-52.446 36.842-97.202 83.882-131.66 138.342zM768 518c0 27.166-4.256 53.334-12.102 77.898l-321.808-321.808c24.568-7.842 50.742-12.090 77.91-12.090 141.382 0 256 114.618 256 256zM830.026 670.026l-69.362-69.362c1.264-0.786 2.53-1.568 3.786-2.368 60.162-38.374 111.142-89.774 149.434-150.296-38.292-60.522-89.274-111.922-149.436-150.296-75.594-48.218-162.89-73.704-252.448-73.704-38.664 0-76.902 4.76-113.962 14.040l-76.894-76.894c59.718-21.462 123.95-33.146 190.856-33.146 223.31 0 416.876 130.042 512 320-45.022 89.916-112.118 166.396-193.974 222.026z" /> 26 | <glyph unicode="" glyph-name="attention" d="M256 832v-896l320 320 320-320v896zM768 960h-640v-896l64 64v768h576z" /> 27 | <glyph unicode="" glyph-name="star" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538zM512 206.502l-223.462-117.48 42.676 248.83-180.786 176.222 249.84 36.304 111.732 226.396 111.736-226.396 249.836-36.304-180.788-176.222 42.678-248.83-223.462 117.48z" /> 28 | <glyph unicode="" glyph-name="star-ok" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538z" /> 29 | <glyph unicode="" glyph-name="plus" d="M992 576h-352v352c0 17.672-14.328 32-32 32h-192c-17.672 0-32-14.328-32-32v-352h-352c-17.672 0-32-14.328-32-32v-192c0-17.672 14.328-32 32-32h352v-352c0-17.672 14.328-32 32-32h192c17.672 0 32 14.328 32 32v352h352c17.672 0 32 14.328 32 32v192c0 17.672-14.328 32-32 32z" /> 30 | <glyph unicode="" glyph-name="about" d="M448 656c0 26.4 21.6 48 48 48h32c26.4 0 48-21.6 48-48v-32c0-26.4-21.6-48-48-48h-32c-26.4 0-48 21.6-48 48v32zM640 192h-256v64h64v192h-64v64h192v-256h64zM512 960c-282.77 0-512-229.23-512-512s229.23-512 512-512 512 229.23 512 512-229.23 512-512 512zM512 32c-229.75 0-416 186.25-416 416s186.25 416 416 416 416-186.25 416-416-186.25-416-416-416z" /> 31 | <glyph unicode="" glyph-name="close" d="M512 960c-282.77 0-512-229.23-512-512s229.23-512 512-512 512 229.23 512 512-229.23 512-512 512zM512 32c-229.75 0-416 186.25-416 416s186.25 416 416 416 416-186.25 416-416-186.25-416-416-416zM672 704l-160-160-160 160-96-96 160-160-160-160 96-96 160 160 160-160 96 96-160 160 160 160z" /> 32 | <glyph unicode="" glyph-name="logout" d="M768 320v128h-320v128h320v128l192-192zM704 384v-256h-320v-192l-384 192v832h704v-320h-64v256h-512l256-128v-576h256v192z" /> 33 | <glyph unicode="" glyph-name="refresh" d="M889.68 793.68c-93.608 102.216-228.154 166.32-377.68 166.32-282.77 0-512-229.23-512-512h96c0 229.75 186.25 416 416 416 123.020 0 233.542-53.418 309.696-138.306l-149.696-149.694h352v352l-134.32-134.32zM928 448c0-229.75-186.25-416-416-416-123.020 0-233.542 53.418-309.694 138.306l149.694 149.694h-352v-352l134.32 134.32c93.608-102.216 228.154-166.32 377.68-166.32 282.77 0 512 229.23 512 512h-96z" /> 34 | <glyph unicode="" glyph-name="forward" d="M512 960c-282.77 0-512-229.23-512-512s229.23-512 512-512 512 229.23 512 512-229.23 512-512 512zM512 32c-229.75 0-416 186.25-416 416s186.25 416 416 416 416-186.25 416-416-186.25-416-416-416zM354.744 253.256l90.512-90.512 285.254 285.256-285.256 285.254-90.508-90.508 194.744-194.746z" /> 35 | <glyph unicode="" glyph-name="back" d="M512-64c282.77 0 512 229.23 512 512s-229.23 512-512 512-512-229.23-512-512 229.23-512 512-512zM512 864c229.75 0 416-186.25 416-416s-186.25-416-416-416-416 186.25-416 416 186.25 416 416 416zM669.256 642.744l-90.512 90.512-285.254-285.256 285.256-285.254 90.508 90.508-194.744 194.746z" /> 36 | <glyph unicode="" glyph-name="order-rev" d="M704 448v-384h64v384h160l-192 192-192-192zM64 768h96v-64h-96v64zM192 768h96v-64h-96v64zM320 768h64v-96h-64v96zM64 544h64v-96h-64v96zM160 512h96v-64h-96v64zM288 512h96v-64h-96v64zM64 672h64v-96h-64v96zM320 640h64v-96h-64v96zM320 256v-192h-192v192h192zM384 320h-320v-320h320v320z" /> 37 | <glyph unicode="" glyph-name="order-rev-down" d="M768 256v384h-64v-384h-160l192-192 192 192zM320 704v-192h-192v192h192zM384 768h-320v-320h320v320zM64 320h96v-64h-96v64zM192 320h96v-64h-96v64zM320 320h64v-96h-64v96zM64 96h64v-96h-64v96zM160 64h96v-64h-96v64zM288 64h96v-64h-96v64zM64 224h64v-96h-64v96zM320 192h64v-96h-64v96z" /> 38 | <glyph unicode="" glyph-name="new-tab" d="M192 896v-768h768v768h-768zM896 192h-640v640h640v-640zM128 64v672l-64 64v-800h800l-64 64h-672zM352 704l160-160-192-192 96-96 192 192 160-160v416z" /> 39 | <glyph unicode="" glyph-name="github" d="M512.008 947.358c-282.738 0-512.008-229.218-512.008-511.998 0-226.214 146.704-418.132 350.136-485.836 25.586-4.738 34.992 11.11 34.992 24.632 0 12.204-0.48 52.542-0.696 95.324-142.448-30.976-172.504 60.41-172.504 60.41-23.282 59.176-56.848 74.916-56.848 74.916-46.452 31.778 3.51 31.124 3.51 31.124 51.4-3.61 78.476-52.766 78.476-52.766 45.672-78.27 119.776-55.64 149.004-42.558 4.588 33.086 17.852 55.68 32.506 68.464-113.73 12.942-233.276 56.85-233.276 253.032 0 55.898 20.004 101.574 52.76 137.428-5.316 12.9-22.854 64.972 4.952 135.5 0 0 43.006 13.752 140.84-52.49 40.836 11.348 84.636 17.036 128.154 17.234 43.502-0.198 87.336-5.886 128.256-17.234 97.734 66.244 140.656 52.49 140.656 52.49 27.872-70.528 10.35-122.6 5.036-135.5 32.82-35.856 52.694-81.532 52.694-137.428 0-196.654-119.778-239.95-233.79-252.624 18.364-15.89 34.724-47.046 34.724-94.812 0-68.508-0.596-123.644-0.596-140.508 0-13.628 9.222-29.594 35.172-24.566 203.322 67.776 349.842 259.626 349.842 485.768 0 282.78-229.234 511.998-511.992 511.998z" /> 40 | </font></defs></svg> -------------------------------------------------------------------------------- /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 | <GoogleReCaptchaProvider 315 | reCaptchaKey={process.env.REACT_APP_RECAPTCHA_V3_KEY} 316 | useRecaptchaNet={true} 317 | > 318 | <div> 319 | <div className="treehollow-login-popup-shadow" /> 320 | <div className="treehollow-login-popup margin-popup"> 321 | {this.state.phase === -1 && ( 322 | <> 323 | <p> 324 | <b>输入邮箱来登录 {process.env.REACT_APP_TITLE}</b> 325 | </p> 326 | </> 327 | )} 328 | <p style={this.state.phase === -1 ? {} : { display: 'none' }}> 329 | <label> 330 | 邮箱  331 | <input 332 | ref={this.ref.username} 333 | type="email" 334 | autoFocus={true} 335 | defaultValue="@pku.edu.cn" 336 | onKeyDown={(event) => { 337 | if (event.key === 'Enter') { 338 | this.next_step(); 339 | } 340 | }} 341 | /> 342 | </label> 343 | </p> 344 | {this.state.phase === 0 && ( 345 | <> 346 | <p> 347 | <b>输入密码来登录 {process.env.REACT_APP_TITLE}</b> 348 | </p> 349 | <p> 350 | <label> 351 | 密码  352 | <input 353 | ref={this.ref.password} 354 | type="password" 355 | autoFocus={true} 356 | onKeyDown={(event) => { 357 | if (event.key === 'Enter') { 358 | this.next_step(); 359 | } 360 | }} 361 | /> 362 | </label> 363 | </p> 364 | <p> 365 | <a 366 | onClick={() => { 367 | alert( 368 | '在树洞网页版的“账户”界面,可以注销账号之后重新注册树洞。', 369 | ); 370 | }} 371 | > 372 | 忘记密码? 373 | </a> 374 | </p> 375 | </> 376 | )} 377 | {this.state.phase === 1 && ( 378 | <> 379 | <p> 380 | <b>{process.env.REACT_APP_TITLE} 新用户注册</b> 381 | </p> 382 | <p> 383 | <label> 384 | 邮箱验证码  385 | <input 386 | ref={this.ref.email_verification} 387 | type="tel" 388 | autoFocus={true} 389 | /> 390 | </label> 391 | </p> 392 | </> 393 | )} 394 | {this.state.phase === 2 && ( 395 | <> 396 | <p> 397 | <b>{process.env.REACT_APP_TITLE} 老用户注册</b> 398 | </p> 399 | </> 400 | )} 401 | {(this.state.phase === 1 || this.state.phase === 2) && ( 402 | <> 403 | <p> 404 | <label> 405 | 密码  406 | <input ref={this.ref.password} type="password" /> 407 | </label> 408 | </p> 409 | <p> 410 | <label> 411 | 密码确认  412 | <input 413 | ref={this.ref.password_confirm} 414 | type="password" 415 | onKeyDown={(event) => { 416 | if (event.key === 'Enter') { 417 | this.next_step(); 418 | } 419 | }} 420 | /> 421 | </label> 422 | </p> 423 | <p> 424 | <label> 425 | <input type="checkbox" ref={this.ref.checkbox_terms} /> 426 | 我已经阅读并同意了 427 | <a href={process.env.REACT_APP_TOS_URL}>服务协议</a>、 428 | <a href={process.env.REACT_APP_PRIVACY_URL}>隐私政策</a>和 429 | <a href={process.env.REACT_APP_RULES_URL}>社区规范</a>。 430 | </label> 431 | </p> 432 | <p> 433 | <label> 434 | <input type="checkbox" ref={this.ref.checkbox_account} /> 435 | 我已经了解了用户的个人信息会通过设定的密码加密,如果忘记密码会很难找回账户。 436 | </label> 437 | </p> 438 | </> 439 | )} 440 | {this.state.phase === 3 && ( 441 | <> 442 | <p> 443 | <b>输入验证码 {process.env.REACT_APP_TITLE}</b> 444 | </p> 445 | <RecaptchaV2Popup 446 | callback={() => { 447 | this.verify_email('v2', () => { 448 | alert('reCAPTCHA风控系统校验失败'); 449 | }); 450 | }} 451 | > 452 | {(do_popup) => ( 453 | <p> 454 | {!this.state.recaptcha_verified && ( 455 | <GoogleReCaptcha 456 | onVerify={(token) => { 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 | </p> 467 | )} 468 | </RecaptchaV2Popup> 469 | </> 470 | )} 471 | <p> 472 | <button 473 | onClick={this.next_step.bind(this)} 474 | disabled={this.state.loading_status === 'loading'} 475 | > 476 | 下一步 477 | </button> 478 | <button onClick={this.props.on_close}>取消</button> 479 | </p> 480 | </div> 481 | </div> 482 | </GoogleReCaptchaProvider>, 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 | <LoginPopupSelf 516 | token_callback={this.props.token_callback} 517 | on_close={this.on_close_bound} 518 | /> 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 | <div> 570 | <div className="treehollow-login-popup-shadow" /> 571 | <div className="treehollow-login-popup"> 572 | <div className="g-recaptcha"> 573 | <ReCAPTCHA 574 | ref={(el) => { 575 | this.captchaRef = el; 576 | }} 577 | sitekey={process.env.REACT_APP_RECAPTCHA_V2_KEY} 578 | // size={"compact"} 579 | onChange={this.onChange} 580 | /> 581 | </div> 582 | 583 | <p> 584 | <button onClick={this.on_close_bound}>取消</button> 585 | </p> 586 | </div> 587 | </div> 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 | <span className={'time-str'}> 44 | <TimeAgo 45 | date={time} 46 | formatter={chinese_format} 47 | title={time.toLocaleString('zh-CN', { 48 | timeZone: 'Asia/Shanghai', 49 | hour12: false, 50 | })} 51 | /> 52 |   53 | {!props.short ? format_time(time) : null} 54 | </span> 55 | ); 56 | } 57 | 58 | export function TitleLine(props) { 59 | return ( 60 | <p className="centered-line title-line aux-margin"> 61 | <span className="black-outline">{props.text}</span> 62 | </p> 63 | ); 64 | } 65 | 66 | export function GlobalTitle(props) { 67 | return ( 68 | <div className="aux-margin"> 69 | <div className="title"> 70 | <p className="centered-line">{props.text}</p> 71 | </div> 72 | </div> 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 | // <a ref={ref} key={id} className={no_class ? null : ('app-switcher-item'+(id===cur_id ? ' app-switcher-item-current' : ''))} 181 | // href={url} target={new_tab ? '_blank' : '_self'}> 182 | // {!!icon_normal && [ 183 | // <img key="normal" src={icon_normal} className="app-switcher-logo-normal" />, 184 | // <img key="hover" src={icon_hover||icon_normal} className="app-switcher-logo-hover" /> 185 | // ]} 186 | // <span>{title}</span> 187 | // </a> 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 | // <div className="app-switcher"> 201 | // <span className="app-switcher-desc app-switcher-left">PKUHelper</span> 202 | // {this.state.apps.bar.map((app)=> 203 | // app_elem(app) 204 | // )} 205 | // {!!this.state.apps.dropdown.length && 206 | // <div className={ 207 | // 'app-switcher-item app-switcher-dropdown ' 208 | // +(dropdown_cur_app ? ' app-switcher-item-current' : '') 209 | // }> 210 | // <p className="app-switcher-dropdown-title"> 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 | // </p> 219 | // {this.state.apps.dropdown.map((app)=>{ 220 | // let ref=React.createRef(); 221 | // return ( 222 | // <p key={app[0]} className="app-switcher-dropdown-item" onClick={(e)=>{ 223 | // if(!e.target.closest('a') && ref.current) 224 | // ref.current.click(); 225 | // }}> 226 | // {app_elem(app,true,ref)} 227 | // </p> 228 | // ); 229 | // })} 230 | // </div> 231 | // } 232 | // <span className="app-switcher-desc app-switcher-right">网页版</span> 233 | // </div> 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 | <div> 281 | <div className="login-popup-shadow" /> 282 | <div className="login-popup"> 283 | <div className="g-recaptcha"> 284 | <ReCAPTCHA 285 | ref={(el) => { 286 | this.captchaRef = el; 287 | }} 288 | sitekey={process.env.REACT_APP_RECAPTCHA_V2_KEY} 289 | // size={"compact"} 290 | onChange={this.onChange} 291 | /> 292 | </div> 293 | 294 | <p> 295 | <button onClick={this.on_close_bound}>取消</button> 296 | </p> 297 | </div> 298 | </div> 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 | <GoogleReCaptchaProvider 491 | reCaptchaKey={process.env.REACT_APP_RECAPTCHA_V3_KEY} 492 | useRecaptchaNet={true} 493 | > 494 | {!this.state.recaptcha_verified && ( 495 | <GoogleReCaptcha 496 | onVerify={(token) => { 497 | this.setState({ 498 | recaptcha_verified: true, 499 | }); 500 | console.log(token); 501 | localStorage['recaptcha'] = token; 502 | }} 503 | /> 504 | )} 505 | <div> 506 | <div className="login-popup-shadow" /> 507 | <div className="login-popup"> 508 | <p> 509 | <b>接收验证码来登录 {process.env.REACT_APP_TITLE}</b> 510 | </p> 511 | <p> 512 | <label> 513 |  邮箱  514 | <input 515 | ref={this.username_ref} 516 | type="email" 517 | autoFocus={true} 518 | defaultValue={process.env.REACT_APP_DEFAULT_EMAIL_SUFFIX} 519 | /> 520 | </label> 521 | <span className="login-type"> 522 | {/*<a onClick={(e)=>this.do_sendcode('sms')}>*/} 523 | {/*  短信 */} 524 | {/*</a>*/} 525 | {/*/*/} 526 | <RecaptchaV2Popup 527 | callback={() => { 528 | this.do_sendcode( 529 | 'mail', 530 | () => { 531 | alert('风控系统校验失败'); 532 | }, 533 | 'v2', 534 | ); 535 | }} 536 | > 537 | {(do_popup) => ( 538 | <a 539 | onClick={(e) => this.do_sendcode('mail', do_popup, 'v3')} 540 | > 541 |  发送邮件  542 | </a> 543 | )} 544 | </RecaptchaV2Popup> 545 | </span> 546 | </p> 547 | <p> 548 | <label> 549 | 验证码  550 | <input ref={this.password_ref} type="tel" /> 551 | </label> 552 | <button 553 | type="button" 554 | disabled={this.state.loading_status === 'loading'} 555 | //disabled={true} 556 | onClick={(e) => this.do_login(this.props.token_callback)} 557 | > 558 | 登录 559 | </button> 560 | </p> 561 | <hr /> 562 | <p> 563 | <b>从其他设备导入登录状态</b> 564 | </p> 565 | <p> 566 | <input ref={this.input_token_ref} placeholder="User Token" /> 567 | <button 568 | type="button" 569 | disabled={this.state.loading_status === 'loading'} 570 | onClick={(e) => this.do_input_token(this.props.token_callback)} 571 | > 572 | 导入 573 | </button> 574 | </p> 575 | <hr /> 576 | <p style={{ fontSize: 14 }}> 577 | 出现问题?请及时联系<a href={"mailto:" + process.env.REACT_APP_CONTACT_EMAIL} rel="_blank">管理团队</a> 578 | </p> 579 | <hr /> 580 | <p style={{ fontSize: 11 }}> 581 | <a href={process.env.REACT_APP_RULES_URL} rel="_blank">社区规范</a> 582 | </p> 583 | <p style={{ fontSize: 11 }}> 584 | This site is protected by reCAPTCHA and the Google{' '} 585 | <a href="https://policies.google.com/privacy">Privacy Policy</a>{' '} 586 | and{' '} 587 | <a href="https://policies.google.com/terms">Terms of Service</a>{' '} 588 | apply. 589 | </p> 590 | <p> 591 | <button onClick={this.props.on_close}>取消</button> 592 | </p> 593 | </div> 594 | </div> 595 | </GoogleReCaptchaProvider>, 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 | <LoginPopupSelf 628 | token_callback={this.props.token_callback} 629 | on_close={this.on_close_bound} 630 | /> 631 | )} 632 | </> 633 | ); 634 | } 635 | } 636 | --------------------------------------------------------------------------------