diff --git a/.gitmodules b/.gitmodules
index 59e10ee..c8bff63 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,7 +1,3 @@
[submodule "src/react-lazyload"]
path = src/react-lazyload
url = https://github.com/xmcp/react-lazyload
-
-[submodule "src/infrastructure"]
- path = src/infrastructure
- url = https://github.com/thuhole/infrastructure
diff --git a/src/infrastructure b/src/infrastructure
deleted file mode 160000
index e3c39ff..0000000
--- a/src/infrastructure
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit e3c39ff4e3efaad45e16796d9eb511345d4cbcf4
diff --git a/src/infrastructure/appicon/course_survey.png b/src/infrastructure/appicon/course_survey.png
new file mode 100644
index 0000000..a2ea2e3
Binary files /dev/null and b/src/infrastructure/appicon/course_survey.png differ
diff --git a/src/infrastructure/appicon/dropdown.png b/src/infrastructure/appicon/dropdown.png
new file mode 100644
index 0000000..0ac094a
Binary files /dev/null and b/src/infrastructure/appicon/dropdown.png differ
diff --git a/src/infrastructure/appicon/dropdown_rev.png b/src/infrastructure/appicon/dropdown_rev.png
new file mode 100644
index 0000000..ad14288
Binary files /dev/null and b/src/infrastructure/appicon/dropdown_rev.png differ
diff --git a/src/infrastructure/appicon/hole.png b/src/infrastructure/appicon/hole.png
new file mode 100644
index 0000000..3089e7b
Binary files /dev/null and b/src/infrastructure/appicon/hole.png differ
diff --git a/src/infrastructure/appicon/homepage.png b/src/infrastructure/appicon/homepage.png
new file mode 100644
index 0000000..c5cd092
Binary files /dev/null and b/src/infrastructure/appicon/homepage.png differ
diff --git a/src/infrastructure/appicon/imasugu.png b/src/infrastructure/appicon/imasugu.png
new file mode 100644
index 0000000..43c0bd7
Binary files /dev/null and b/src/infrastructure/appicon/imasugu.png differ
diff --git a/src/infrastructure/appicon/imasugu_rev.png b/src/infrastructure/appicon/imasugu_rev.png
new file mode 100644
index 0000000..dc2e505
Binary files /dev/null and b/src/infrastructure/appicon/imasugu_rev.png differ
diff --git a/src/infrastructure/appicon/score.png b/src/infrastructure/appicon/score.png
new file mode 100644
index 0000000..105a4c1
Binary files /dev/null and b/src/infrastructure/appicon/score.png differ
diff --git a/src/infrastructure/appicon/syllabus.png b/src/infrastructure/appicon/syllabus.png
new file mode 100644
index 0000000..4ea1557
Binary files /dev/null and b/src/infrastructure/appicon/syllabus.png differ
diff --git a/src/infrastructure/const.js b/src/infrastructure/const.js
new file mode 100644
index 0000000..b8bc1b1
--- /dev/null
+++ b/src/infrastructure/const.js
@@ -0,0 +1,2 @@
+// export const THUHOLE_API_ROOT='//localhost:5001/';
+export const THUHOLE_API_ROOT = 'https://thuhole.com/'
diff --git a/src/infrastructure/elevator.js b/src/infrastructure/elevator.js
new file mode 100644
index 0000000..0ee93a4
--- /dev/null
+++ b/src/infrastructure/elevator.js
@@ -0,0 +1,47 @@
+const DUMP_VER='dump_v1';
+
+function dump() {
+ return JSON.stringify({
+ _dump_ver: DUMP_VER,
+ token: localStorage['TOKEN']||null,
+ hole_config: localStorage['hole_config']||null,
+ });
+}
+function load(s) {
+ console.log('elevator: loading',s);
+ let obj=JSON.parse(s);
+ if(obj._dump_ver!==DUMP_VER) {
+ console.error('elevator: loading version mismatch, current',DUMP_VER,'param',obj._dump_ver);
+ return;
+ }
+ if(localStorage['TOKEN']===undefined && obj.token) {
+ console.log('replace token');
+ localStorage['TOKEN']=obj.token;
+ }
+ if(localStorage['hole_config']===undefined && obj.hole_config) {
+ console.log('replace hole config');
+ localStorage['hole_config']=obj.hole_config;
+ }
+}
+
+export function elevate() {
+ // load
+ // '?foo=fo&bar=ba' -> [["foo","fo"],["bar","ba"]]
+ let params=window.location.search.substr(1).split('&').map((kv)=>kv.split('='));
+ params.forEach((kv)=>{
+ if(kv.length===2 && kv[0]==='_elevator_data') {
+ load(decodeURIComponent(kv[1]));
+ let url=new URL(window.location.href);
+ url.search='';
+ window.history.replaceState('','',url.href);
+ }
+ });
+
+ // dump
+ if(window.location.protocol==='http:' && window.location.hostname==='pkuhelper.pku.edu.cn') {
+ let url=new URL(window.location.href);
+ url.protocol='https:';
+ url.search='?_elevator_data='+encodeURIComponent(dump());
+ window.location.replace(url.href);
+ }
+}
\ No newline at end of file
diff --git a/src/infrastructure/functions.js b/src/infrastructure/functions.js
new file mode 100644
index 0000000..842ddaa
--- /dev/null
+++ b/src/infrastructure/functions.js
@@ -0,0 +1,35 @@
+export function get_json(res) {
+ if(!res.ok) throw Error(`网络错误 ${res.status} ${res.statusText}`);
+ return (
+ res
+ .text()
+ .then((t)=>{
+ try {
+ return JSON.parse(t);
+ } catch(e) {
+ console.error('json parse error');
+ console.trace(e);
+ console.log(t);
+ throw new SyntaxError('JSON Parse Error '+t.substr(0,50));
+ }
+ })
+ );
+}
+
+export function listen_darkmode(override) { // override: true/false/undefined
+ function update_color_scheme() {
+ if(override===undefined ? window.matchMedia('(prefers-color-scheme: dark)').matches : override)
+ document.body.classList.add('root-dark-mode');
+ else
+ document.body.classList.remove('root-dark-mode');
+ }
+
+ update_color_scheme();
+ window.matchMedia('(prefers-color-scheme: dark)').addListener(()=>{
+ update_color_scheme();
+ });
+}
+
+export function API_VERSION_PARAM() {
+ return '&PKUHelperAPI=3.0&jsapiver='+encodeURIComponent((process.env.REACT_APP_BUILD_INFO||'null')+'-'+(Math.floor(+new Date()/7200000)*2));
+}
\ No newline at end of file
diff --git a/src/infrastructure/global.css b/src/infrastructure/global.css
new file mode 100644
index 0000000..6be140f
--- /dev/null
+++ b/src/infrastructure/global.css
@@ -0,0 +1,37 @@
+:root {
+ --foreground-dark: hsl(0,0%,93%);
+}
+
+body {
+ margin: 0;
+ padding: 0;
+ overflow-x: hidden;
+ text-size-adjust: 100%;
+}
+
+body, textarea, pre {
+ font-family: 'Segoe UI', '微软雅黑', 'Microsoft YaHei', sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+ word-wrap: break-word;
+ -webkit-overflow-scrolling: touch;
+}
+
+p, pre {
+ margin: 0;
+}
+
+a {
+ text-decoration: none;
+ cursor: pointer;
+}
+
+pre {
+ white-space: pre-line;
+}
+
+code {
+ font-family: Consolas, Courier, monospace;
+}
\ No newline at end of file
diff --git a/src/infrastructure/widgets.css b/src/infrastructure/widgets.css
new file mode 100644
index 0000000..b439cca
--- /dev/null
+++ b/src/infrastructure/widgets.css
@@ -0,0 +1,301 @@
+.centered-line {
+ overflow: hidden;
+ text-align: center;
+}
+
+.centered-line::before,
+.centered-line::after {
+ background-color: #000;
+ content: "";
+ display: inline-block;
+ height: 1px;
+ position: relative;
+ vertical-align: middle;
+ width: 50%;
+}
+
+.root-dark-mode .centered-line {
+ color: var(--foreground-dark);
+}
+.root-dark-mode .centered-line::before, .root-dark-mode .centered-line::after {
+ background-color: var(--foreground-dark);
+}
+
+.centered-line::before {
+ right: 1em;
+ margin-left: -50%;
+}
+
+.centered-line::after {
+ left: 1em;
+ margin-right: -50%;
+}
+
+.title-line {
+ color: #fff;
+ margin-top: 1em;
+}
+.title-line::before,
+.title-line::after {
+ background-color: #fff;
+ box-shadow: 0 1px 1px #000;
+}
+
+.root-dark-mode .title-line {
+ color: var(--foreground-dark);
+}
+.root-dark-mode .title-line::before, .root-dark-mode .title-line::after {
+ background-color: var(--foreground-dark);
+}
+
+.app-switcher {
+ display: flex;
+ height: 2em;
+ text-align: center;
+ margin: 0 .1em;
+ user-select: none;
+}
+.app-switcher-desc {
+ margin: 0 .5em;
+ flex: 1 1 0;
+ opacity: .5;
+ height: 2em;
+ line-height: 2rem;
+ font-size: .8em;
+}
+
+.root-dark-mode .app-switcher-desc {
+ color: var(--foreground-dark);
+}
+
+@media screen and (max-width: 570px) {
+ .app-switcher-desc {
+ flex: 1 1 0;
+ display: none;
+ }
+ .app-switcher-item {
+ flex: 1 1 0 !important;
+ padding: 0 !important;
+ }
+ .app-switcher-dropdown-title {
+ padding-left: 0 !important;
+ padding-right: 0 !important;
+ text-align: center !important;
+ }
+ .app-switcher-dropdown-item {
+ margin-left: -2em !important;
+ margin-right: 0 !important;
+ }
+}
+
+.app-switcher a:hover { /* reset underline from /hole style */
+ border-bottom: unset;
+ margin-bottom: unset;
+}
+
+.app-switcher-desc a {
+ color: unset;
+}
+
+.app-switcher-left {
+ text-align: right;
+}
+.app-switcher-right {
+ text-align: left;
+}
+.app-switcher-item {
+ flex: 0 0 auto;
+ border-radius: 3px;
+ height: 1.6em;
+ line-height: 1.6em;
+ margin: .2em .1em;
+ padding: 0 .45em;
+}
+a.app-switcher-item, .app-switcher-item a {
+ transition: unset; /* override ant design */
+ color: black;
+}
+.app-switcher-item img {
+ width: 1.2rem;
+ height: 1.2rem;
+ position: relative;
+ top: .2rem;
+ vertical-align: unset; /* override ant design */
+}
+.app-switcher-item span:not(:empty) {
+ margin-left: .2rem;
+}
+.app-switcher-logo-hover {
+ margin-left: -1.2rem;
+}
+
+.app-switcher-item:hover {
+ background-color: black;
+ color: white !important;
+}
+.app-switcher-item:hover a {
+ color: white !important;
+}
+.app-switcher-item-current {
+ background-color: rgba(0,0,0,.4);
+ text-shadow: 0 0 5px rgba(0,0,0,.5);
+ color: white !important;
+}
+.app-switcher-item-current a {
+ color: white !important;
+}
+
+.root-dark-mode .app-switcher-item, .root-dark-mode .app-switcher-dropdown-title a {
+ color: var(--foreground-dark);
+}
+.root-dark-mode .app-switcher-item:hover, .root-dark-mode .app-switcher-item-current, .root-dark-mode .app-switcher-dropdown-title:hover a {
+ background-color: #555;
+ color: var(--foreground-dark);
+}
+
+.app-switcher-item:hover .app-switcher-logo-normal, .app-switcher-item-current .app-switcher-logo-normal {
+ opacity: 0;
+}
+.app-switcher-item:not(.app-switcher-item-current):not(:hover) .app-switcher-logo-hover {
+ opacity: 0;
+}
+
+.root-dark-mode .app-switcher-logo-normal {
+ opacity: 0 !important;
+}
+.root-dark-mode .app-switcher-logo-hover {
+ opacity: 1 !important;
+}
+
+.app-switcher-dropdown {
+ padding: 0;
+ text-align: left;
+}
+
+.app-switcher-dropdown:not(:hover) {
+ max-height: 1.6rem;
+ overflow: hidden;
+}
+
+.app-switcher-dropdown-item {
+ background-color: hsla(0,0%,35%,.9);
+ padding: .125em .25em;
+ margin-left: -.75em;
+ margin-right: -.75em;
+ position: relative;
+ z-index: 10;
+ cursor: pointer;
+}
+.app-switcher-dropdown-item:hover {
+ background-color: rgba(0,0,0,.9);
+}
+.app-switcher-dropdown-item:nth-child(2) {
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+}
+.app-switcher-dropdown-item:last-child {
+ border-bottom-left-radius: 3px;
+ border-bottom-right-radius: 3px;
+}
+
+.app-switcher-dropdown-title {
+ padding-bottom: .2em;
+ padding-left: .5em;
+ padding-right: .25em;
+}
+.app-switcher-dropdown-title a {
+ cursor: unset;
+}
+
+.thuhole-login-popup {
+ font-size: 1rem;
+ background-color: #f7f7f7;
+ color: black;
+ position: fixed;
+ left: 50%;
+ top: 50%;
+ width: 320px;
+ z-index: 114515;
+ transform: translateX(-50%) translateY(-50%);
+ border-radius: 5px;
+}
+.thuhole-login-popup a {
+ color: #00c;
+}
+.thuhole-login-popup p {
+ margin: .75em 0;
+ text-align: center;
+}
+/* override ant design */
+.thuhole-login-popup input, .thuhole-login-popup button {
+ font-size: .85em;
+ vertical-align: middle;
+}
+.thuhole-login-popup input:not([type="checkbox"]) {
+ width: 8rem;
+ border-radius: 5px;
+ border: 1px solid black;
+ outline: none;
+ margin: 0;
+ padding: 0 .5em;
+ line-height: 2em;
+}
+.thuhole-login-popup button {
+ width: 6rem;
+ color: black;
+ background-color: rgba(235,235,235,.5);
+ border-radius: 5px;
+ text-align: center;
+ border: 1px solid black;
+ line-height: 2em;
+ margin: 0 .5rem;
+}
+.thuhole-login-popup button:hover {
+ background-color: rgba(255,255,255,.7);
+}
+.thuhole-login-popup button:disabled {
+ background-color: rgba(128,128,128,.5);
+}
+.thuhole-login-type {
+ display: inline-block;
+ width: 6rem;
+ margin: 0 .5rem;
+}
+.thuhole-login-popup-shadow {
+ opacity: .5;
+ background-color: black;
+ position: fixed;
+ left: 0;
+ top: 0;
+ height: 100%;
+ width: 100%;
+ z-index: 114514;
+}
+
+.thuhole-login-popup label.perm-item {
+ font-size: .8em;
+ vertical-align: .1rem;
+ margin-left: .5rem;
+}
+
+.aux-margin {
+ width: calc(100% - 2 * 50px);
+ margin: 0 50px;
+}
+@media screen and (max-width: 1300px) {
+ .aux-margin {
+ width: calc(100% - 2 * 10px);
+ margin: 0 10px;
+ }
+}
+
+.title {
+ font-size: 1.5em;
+ height: 4rem;
+ padding-top: 1rem;
+ text-align: center;
+}
+
+.time-str {
+ color: #999999;
+}
\ No newline at end of file
diff --git a/src/infrastructure/widgets.js b/src/infrastructure/widgets.js
new file mode 100644
index 0000000..2ca90fc
--- /dev/null
+++ b/src/infrastructure/widgets.js
@@ -0,0 +1,472 @@
+import React, {Component, PureComponent} from 'react';
+import ReactDOM from 'react-dom';
+
+import TimeAgo from 'react-timeago';
+import chineseStrings from 'react-timeago/lib/language-strings/zh-CN';
+import buildFormatter from 'react-timeago/lib/formatters/buildFormatter';
+
+import './global.css';
+import './widgets.css';
+
+import appicon_hole from './appicon/hole.png';
+import appicon_imasugu from './appicon/imasugu.png';
+import appicon_imasugu_rev from './appicon/imasugu_rev.png';
+import appicon_syllabus from './appicon/syllabus.png';
+import appicon_score from './appicon/score.png';
+import appicon_course_survey from './appicon/course_survey.png';
+import appicon_dropdown from './appicon/dropdown.png';
+import appicon_dropdown_rev from './appicon/dropdown_rev.png';
+import appicon_homepage from './appicon/homepage.png';
+import {THUHOLE_API_ROOT} from './const';
+import {get_json, API_VERSION_PARAM} from './functions';
+
+import {
+ GoogleReCaptchaProvider,
+ GoogleReCaptcha
+} from 'react-google-recaptcha-v3';
+
+const LOGIN_POPUP_ANCHOR_ID='pkuhelper_login_popup_anchor';
+
+function pad2(x) {
+ return x<10 ? '0'+x : ''+x;
+}
+export function format_time(time) {
+ return `${time.getMonth()+1}-${pad2(time.getDate())} ${time.getHours()}:${pad2(time.getMinutes())}:${pad2(time.getSeconds())}`;
+}
+const chinese_format=buildFormatter(chineseStrings);
+export function Time(props) {
+ const time=new Date(props.stamp*1000);
+ return (
+
+
+ {props.text} +
+ ) +} + +export function GlobalTitle(props) { + return ( +{props.text}
++// {!!dropdown_cur_app ? +// app_elem((()=>{ +// let [id,title,_url,icon_normal,icon_hover,_new_tab]=dropdown_cur_app; +// return [id,title+'▾',null,icon_normal,icon_hover,false]; +// })(),true) : +// app_elem(['-placeholder-elem','更多▾',null,appicon_dropdown,appicon_dropdown_rev,false],true) +// } +//
+// {this.state.apps.dropdown.map((app)=>{ +// let ref=React.createRef(); +// return ( +//{ +// if(!e.target.closest('a') && ref.current) +// ref.current.click(); +// }}> +// {app_elem(app,true,ref)} +//
+// ); +// })} +//+ 接收验证码来登录 T大树洞 +
++ + + {/*this.do_sendcode('sms')}>*/} + {/* 短信 */} + {/**/} + {/*/*/} + this.do_sendcode('mail')}> + 发送邮件 + + +
++ + +
++ 从其他设备导入登录状态 +
++ + +
++ This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply. +
++ +
+