diff --git a/public/_redirects b/public/_redirects index 7e17922c..ac0520bf 100644 --- a/public/_redirects +++ b/public/_redirects @@ -1,2 +1,3 @@ -/api_proxy/* http://www.pkuhelper.com:10301/services/pkuhole/:splat 200 -/audio_proxy/* http://www.pkuhelper.com:10301/services/pkuhole/audios/:splat 200 \ No newline at end of file +/api_proxy/* http://www.pkuhelper.com/services/pkuhole/:splat 200 +/audio_proxy/* http://www.pkuhelper.com/services/pkuhole/audios/:splat 200 +/login_proxy/* http://www.pkuhelper.com/services/login/:splat 200 \ No newline at end of file diff --git a/public/index.html b/public/index.html index 4a9aa3cb..bccb22a1 100644 --- a/public/index.html +++ b/public/index.html @@ -6,6 +6,8 @@ + + diff --git a/public/static/fonts_1/icomoon.css b/public/static/fonts_1/icomoon.css new file mode 100644 index 00000000..b391fb6d --- /dev/null +++ b/public/static/fonts_1/icomoon.css @@ -0,0 +1,49 @@ +@font-face { + font-family: 'icomoon'; + src: + url('icomoon.ttf?4yzqd4') format('truetype'), + url('icomoon.woff?4yzqd4') format('woff'), + url('icomoon.svg?4yzqd4#icomoon') format('svg'); + font-weight: normal; + font-style: normal; +} + +.icon { + /* use !important to prevent issues with browser extensions that change fonts */ + /*noinspection CssNoGenericFontName*/ + font-family: 'icomoon' !important; + speak: none; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + + /* Better Font Rendering =========== */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.icon-reply:before { + content: "\e96b"; +} +.icon-login-ok:before { + content: "\e975"; +} +.icon-login:before { + content: "\e98d"; +} +.icon-attention:before { + content: "\e9d3"; +} +.icon-star:before { + content: "\e9d7"; +} +.icon-star-ok:before { + content: "\e9d9"; +} +.icon-help:before { + content: "\ea09"; +} +.icon-refresh:before { + content: "\ea2e"; +} diff --git a/public/static/fonts_1/icomoon.svg b/public/static/fonts_1/icomoon.svg new file mode 100644 index 00000000..14934e90 --- /dev/null +++ b/public/static/fonts_1/icomoon.svg @@ -0,0 +1,18 @@ + + + +Generated by IcoMoon + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/static/fonts_1/icomoon.ttf b/public/static/fonts_1/icomoon.ttf new file mode 100644 index 00000000..af51e902 Binary files /dev/null and b/public/static/fonts_1/icomoon.ttf differ diff --git a/public/static/fonts_1/icomoon.woff b/public/static/fonts_1/icomoon.woff new file mode 100644 index 00000000..f43f6ec5 Binary files /dev/null and b/public/static/fonts_1/icomoon.woff differ diff --git a/src/App.js b/src/App.js index f6950965..0ffab404 100644 --- a/src/App.js +++ b/src/App.js @@ -2,6 +2,7 @@ import React, {Component} from 'react'; import {Flow} from './Flows'; import {Title} from './Title'; import {Sidebar} from './Sidebar'; +import {TokenCtx} from './UserAction'; class App extends Component { constructor(props) { @@ -9,9 +10,10 @@ class App extends Component { this.state={ sidebar_title: null, sidebar_content: null, - mode: 'list', // list, single, search + mode: 'list', // list, single, search, attention search_text: null, flow_render_key: +new Date(), + token: localStorage['TOKEN']||null, }; this.show_sidebar_bound=this.show_sidebar.bind(this); this.set_mode_bound=this.set_mode.bind(this); @@ -34,23 +36,35 @@ class App extends Component { render() { return ( -
-
- - <div className="left-container"> - <Flow key={this.state.flow_render_key} show_sidebar={this.show_sidebar_bound} - mode={this.state.mode} search_text={this.state.search_text} - /> - <br /> - </div> - <Sidebar do_close={()=>{ + <TokenCtx.Provider value={{ + value: this.state.token, + set_value: (x)=>{ + localStorage['TOKEN']=x||''; this.setState({ - sidebar_content: null, + token: x, }); - }} content={this.state.sidebar_content} title={this.state.sidebar_title} /> - </div> + }, + }}> + <div> + <div className="bg-img" style={{ + backgroundImage: 'url('+(localStorage['REPLACE_ERIRI_WITH_URL'] || 'static/eriri_bg.jpg')+')' + }} /> + <Title show_sidebar={this.show_sidebar_bound} set_mode={this.set_mode_bound} /> + <div className="left-container"> + <TokenCtx.Consumer>{(token)=>( + <Flow key={this.state.flow_render_key} show_sidebar={this.show_sidebar_bound} + mode={this.state.mode} search_text={this.state.search_text} token={token.value} + /> + )}</TokenCtx.Consumer> + <br /> + </div> + <Sidebar do_close={()=>{ + this.setState({ + sidebar_content: null, + }); + }} content={this.state.sidebar_content} title={this.state.sidebar_title} /> + </div> + </TokenCtx.Provider> ); } } diff --git a/src/Common.css b/src/Common.css index eac22c50..0f3bb043 100644 --- a/src/Common.css +++ b/src/Common.css @@ -15,12 +15,12 @@ } .centered-line::before { - right: 0.5em; + right: 1em; margin-left: -50%; } .centered-line::after { - left: 0.5em; + left: 1em; margin-right: -50%; } diff --git a/src/Flows.css b/src/Flows.css index 013a9944..a5d26283 100644 --- a/src/Flows.css +++ b/src/Flows.css @@ -112,7 +112,6 @@ p.img img { } .box-id { - font-family: Consolas, Courier, monospace; opacity: .6; } diff --git a/src/Flows.js b/src/Flows.js index 446dc150..783609c7 100644 --- a/src/Flows.js +++ b/src/Flows.js @@ -4,10 +4,11 @@ import {Time, TitleLine, HighlightedText} from './Common.js'; import './Flows.css'; import LazyLoad from 'react-lazyload'; import {AudioWidget} from './AudioWidget.js'; +import {TokenCtx} from './UserAction'; const IMAGE_BASE='http://www.pkuhelper.com/services/pkuhole/images/'; const AUDIO_BASE='/audio_proxy/'; -const API_BASE=window.location.protocol==='https:' ? '/api_proxy' : 'http://www.pkuhelper.com:10301/services/pkuhole'; +const API_BASE=window.location.protocol==='https:' ? '/api_proxy' : 'http://www.pkuhelper.com/services/pkuhole'; const SEARCH_PAGESIZE=50; const CLICKABLE_TAGS={a: true, audio: true}; @@ -21,7 +22,7 @@ function Reply(props) { backgroundColor: props.info._display_color, } : null}> <div className="box-header"> - <span className="box-id">#{props.info.cid}</span>  + <code className="box-id">#{props.info.cid}</code>  <Time stamp={props.info.timestamp} /> </div> <HighlightedText text={props.info.text} color_picker={props.color_picker} /> @@ -34,9 +35,19 @@ function FlowItem(props) { <div className="flow-item box"> {parseInt(props.info.pid,10)>window.LATEST_POST_ID && <div className="flow-item-dot" /> } <div className="box-header"> - {!!parseInt(props.info.likenum,10) && <span className="box-header-badge">{props.info.likenum}★</span>} - {!!parseInt(props.info.reply,10) && <span className="box-header-badge">{props.info.reply}回复</span>} - <span className="box-id">#{props.info.pid}</span>  + {!!parseInt(props.info.likenum,10) && + <span className="box-header-badge"> + {props.info.likenum}  + <span className={'icon icon-'+(props.attention ? 'star-ok' : 'star')} /> + </span> + } + {!!parseInt(props.info.reply,10) && + <span className="box-header-badge"> + {props.info.reply}  + <span className="icon icon-reply" /> + </span> + } + <code className="box-id">#{props.info.pid}</code>  <Time stamp={props.info.timestamp} /> </div> <HighlightedText text={props.info.text} color_picker={props.color_picker} /> @@ -53,6 +64,7 @@ class FlowItemRow extends PureComponent { replies: [], reply_status: 'done', info: props.info, + attention: false, }; this.color_picker=new ColorPicker(); } @@ -68,11 +80,16 @@ class FlowItemRow extends PureComponent { this.setState({ reply_status: 'loading', }); - fetch(API_BASE+'/api.php?action=getcomment&pid='+this.state.info.pid) + const token_param=this.props.token ? '&token='+this.props.token : ''; + fetch( + API_BASE+'/api.php?action=getcomment'+ + '&pid='+this.state.info.pid+ + token_param + ) .then((res)=>res.json()) .then((json)=>{ if(json.code!==0) - throw new Error(json.code); + throw new Error(json); const replies=json.data .sort((a,b)=>{ return parseInt(a.timestamp,10)-parseInt(b.timestamp,10); @@ -86,6 +103,7 @@ class FlowItemRow extends PureComponent { info: Object.assign({}, prev.info, { reply: ''+replies.length, }), + attention: !!json.attention, reply_status: 'done', }),callback); }) @@ -106,9 +124,9 @@ class FlowItemRow extends PureComponent { <a onClick={()=>{ this.props.show_sidebar('帖子详情',<p className="box box-tip">加载中……</p>); this.load_replies(this.show_sidebar); - }}>更新回复</a> + }}>刷新回复</a> </div> - <FlowItem info={this.state.info} color_picker={this.color_picker} /> + <FlowItem info={this.state.info} color_picker={this.color_picker} attention={this.state.attention} /> {this.state.replies.map((reply)=>( <LazyLoad offset={500} height="5em" overflow={true} once={true}> <Reply key={reply.cid} info={reply} color_picker={this.color_picker} /> @@ -125,7 +143,7 @@ class FlowItemRow extends PureComponent { if(!CLICKABLE_TAGS[event.target.tagName.toLowerCase()]) this.show_sidebar(); }}> - <FlowItem info={this.state.info} color_picker={this.color_picker} /> + <FlowItem info={this.state.info} color_picker={this.color_picker} attention={this.state.attention} /> <div className="flow-reply-row"> {this.state.reply_status==='loading' && <div className="box box-tip">加载中</div>} {this.state.reply_status==='failed' && @@ -145,14 +163,16 @@ class FlowItemRow extends PureComponent { function FlowChunk(props) { return ( - <div className="flow-chunk"> - <TitleLine text={props.title} /> - {props.list.map((info)=>( - <LazyLoad key={info.pid} offset={500} height="15em" once={true} > - <FlowItemRow info={info} show_sidebar={props.show_sidebar} /> - </LazyLoad> - ))} - </div> + <TokenCtx.Consumer>{({value: token})=>( + <div className="flow-chunk"> + <TitleLine text={props.title} /> + {props.list.map((info)=>( + <LazyLoad key={info.pid} offset={500} height="15em" once={true} > + <FlowItemRow info={info} show_sidebar={props.show_sidebar} token={token} /> + </LazyLoad> + ))} + </div> + )}</TokenCtx.Consumer> ); } @@ -171,16 +191,30 @@ export class Flow extends PureComponent { } load_page(page) { + const failed=(err)=>{ + console.trace(err); + this.setState((prev,props)=>({ + loaded_pages: prev.loaded_pages-1, + loading_status: 'failed', + })); + }; + + const token_param=this.props.token ? '&token='+this.props.token : ''; + if(page>this.state.loaded_pages+1) throw new Error('bad page'); if(page===this.state.loaded_pages+1) { console.log('fetching page',page); if(this.state.mode==='list') { - fetch(API_BASE+'/api.php?action=getlist&p='+page) + fetch( + API_BASE+'/api.php?action=getlist'+ + '&p='+page+ + token_param + ) .then((res)=>res.json()) .then((json)=>{ if(json.code!==0) - throw new Error(json.code); + throw new Error(json); json.data.forEach((x)=>{ if(parseInt(x.pid,10)>(parseInt(localStorage['_LATEST_POST_ID'],10)||0)) localStorage['_LATEST_POST_ID']=x.pid; @@ -196,23 +230,18 @@ export class Flow extends PureComponent { loading_status: 'done', })); }) - .catch((err)=>{ - console.trace(err); - this.setState((prev,props)=>({ - loaded_pages: prev.loaded_pages-1, - loading_status: 'failed', - })); - }); + .catch(failed); } else if(this.state.mode==='search') { fetch( API_BASE+'/api.php?action=search'+ '&pagesize='+SEARCH_PAGESIZE*page+ - '&keywords='+encodeURIComponent(this.state.search_param) + '&keywords='+encodeURIComponent(this.state.search_param)+ + token_param ) .then((res)=>res.json()) .then((json)=>{ if(json.code!==0) - throw new Error(json.code); + throw new Error(json); const finished=json.data.length<SEARCH_PAGESIZE; this.setState({ chunks: [{ @@ -223,23 +252,18 @@ export class Flow extends PureComponent { loading_status: 'done', }); }) - .catch((err)=>{ - console.trace(err); - this.setState((prev,props)=>({ - loaded_pages: prev.loaded_pages-1, - loading_status: 'failed', - })); - }); + .catch(failed); } else if(this.state.mode==='single') { const pid=parseInt(this.state.search_param.substr(1),10); fetch( API_BASE+'/api.php?action=getone'+ - '&pid='+pid + '&pid='+pid+ + token_param ) .then((res)=>res.json()) .then((json)=>{ if(json.code!==0) - throw new Error(json.code); + throw new Error(json); this.setState({ chunks: [{ title: 'PID = '+pid, @@ -249,13 +273,26 @@ export class Flow extends PureComponent { loading_status: 'done', }); }) - .catch((err)=>{ - console.trace(err); - this.setState((prev,props)=>({ - loaded_pages: prev.loaded_pages-1, - loading_status: 'failed', - })); - }); + .catch(failed); + } else if(this.state.mode==='attention') { + fetch( + API_BASE+'/api.php?action=getattention'+ + token_param + ) + .then((res)=>res.json()) + .then((json)=>{ + if(json.code!==0) + throw new Error(json); + this.setState({ + chunks: [{ + title: 'Attention List', + data: json.data, + }], + mode: 'attention_finished', + loading_status: 'done', + }); + }) + .catch(failed); } else { console.log('nothing to load'); return; diff --git a/src/Sidebar.css b/src/Sidebar.css index e8eca078..9e69f0ed 100644 --- a/src/Sidebar.css +++ b/src/Sidebar.css @@ -41,10 +41,11 @@ } @media screen and (max-width: 600px) { .sidebar { - width: calc(100% - 50px); + width: calc(100% - 25px); + padding: 1em .5em; } .sidebar-on .sidebar { - left: 50px; + left: 25px; } } diff --git a/src/Title.css b/src/Title.css index 804564b5..9a766832 100644 --- a/src/Title.css +++ b/src/Title.css @@ -10,13 +10,9 @@ margin-bottom: 1em; } -.title-bar a { - padding: 0 .5em; -} - .title { - font-size: 2em; - line-height: 3em; + font-size: 1.5em; + line-height: 4em; text-align: center; } @@ -26,13 +22,10 @@ line-height: 2em; } -.control-bar .refresh-btn { - flex: 0 0 100px; - color: black; - background-color: rgba(255,255,255,.5); - border-radius: 5px; +.control-btn { + flex: 0 0 2em; text-align: center; - border: 1px solid black; + color: black; } .control-bar input { flex: auto; diff --git a/src/Title.js b/src/Title.js index 9a19646a..a924b94d 100644 --- a/src/Title.js +++ b/src/Title.js @@ -1,4 +1,7 @@ import React, {Component, PureComponent} from 'react'; +import {LoginForm} from './UserAction'; +import {TokenCtx} from './UserAction'; + import './Title.css'; const HELP_TEXT=( @@ -10,7 +13,7 @@ const HELP_TEXT=( <li>在搜索框输入 #472865 等可以查看指定 ID 的树洞</li> <li>新的帖子会在左上角显示一个圆点</li> <li>请注意:使用 HTTPS 访问本站可能会<b>大幅减慢</b>加载速度</li> - <li>自定义背景图片请修改 localStorage['REPLACE_ERIRI_WITH_URL']</li> + <li>自定义背景图片请修改 <code>localStorage['REPLACE_ERIRI_WITH_URL']</code></li> </ul> <p>使用本网站时,您需要了解并同意:</p> <ul> @@ -50,6 +53,7 @@ class ControlBar extends PureComponent { this.on_change_bound=this.on_change.bind(this); this.on_keypress_bound=this.on_keypress.bind(this); this.do_refresh_bound=this.do_refresh.bind(this); + this.do_attention_bound=this.do_attention.bind(this); } componentDidMount() { @@ -69,8 +73,10 @@ class ControlBar extends PureComponent { } on_keypress(event) { - if(event.key==='Enter') - this.set_mode('search',this.state.search_text||null); + if(event.key==='Enter') { + const mode=this.state.search_text.startsWith('#') ? 'single' : 'search'; + this.set_mode(mode,this.state.search_text||null); + } } do_refresh() { @@ -81,20 +87,40 @@ class ControlBar extends PureComponent { this.set_mode('list',null); } + do_attention() { + window.scrollTo(0,0); + this.setState({ + search_text: '', + }); + this.set_mode('attention',null); + } + render() { return ( - <div className="control-bar"> - <a className="refresh-btn" onClick={this.do_refresh_bound}>最新树洞</a> -   - <input value={this.state.search_text} placeholder="搜索 或 #PID" - onChange={this.on_change_bound} onKeyPress={this.on_keypress_bound} - /> -   - <a onClick={()=>{this.props.show_sidebar( - '关于 P大树洞(非官方) 网页版', - HELP_TEXT - )}}>Help</a> - </div> + <TokenCtx.Consumer>{({value: token})=>( + <div className="control-bar"> + <a className="control-btn" onClick={this.do_refresh_bound}> + <span className="icon icon-refresh" /> + </a> + {!!token && + <a className="control-btn" onClick={this.do_attention_bound}> + <span className="icon icon-attention" /> + </a> + } + <input value={this.state.search_text} placeholder="搜索 或 #PID" + onChange={this.on_change_bound} onKeyPress={this.on_keypress_bound} + /> + <a className="control-btn" onClick={()=>{this.props.show_sidebar('登录',<LoginForm />)}}> + <span className={'icon icon-'+(token ? 'login-ok' : 'login')} /> + </a> + <a className="control-btn" onClick={()=>{this.props.show_sidebar( + '关于 P大树洞(非官方) 网页版', + HELP_TEXT + )}}> + <span className="icon icon-help" /> + </a> + </div> + )}</TokenCtx.Consumer> ) } } diff --git a/src/UserAction.css b/src/UserAction.css new file mode 100644 index 00000000..09b7e93c --- /dev/null +++ b/src/UserAction.css @@ -0,0 +1,8 @@ +.login-form form p { + margin: 1em 0; + text-align: center; +} + +.login-form button { + min-width: 100px; +} \ No newline at end of file diff --git a/src/UserAction.js b/src/UserAction.js new file mode 100644 index 00000000..702e11a9 --- /dev/null +++ b/src/UserAction.js @@ -0,0 +1,92 @@ +import React, {Component, PureComponent} from 'react'; + +import './UserAction.css'; + +const LOGIN_BASE=window.location.protocol==='https:' ? '/login_proxy' : 'http://www.pkuhelper.com/services/login'; + +export const TokenCtx=React.createContext({ + value: null, + set_value: ()=>{}, +}); + +export class LoginForm extends Component { + constructor(props) { + super(props); + this.state={ + loading_status: 'done', + }; + + this.username_ref=React.createRef(); + this.password_ref=React.createRef(); + } + + do_login(event,set_token) { + event.preventDefault(); + this.setState({ + loading_status: 'loading', + }); + let data=new URLSearchParams(); + data.append('uid', this.username_ref.current.value); + data.append('password', this.password_ref.current.value); + fetch(LOGIN_BASE+'/login.php?platform=hole_xmcp_ml', { + method: 'POST', + body: data, + }) + .then((res)=>res.json()) + .then((json)=>{ + if(json.code!==0) + throw new Error(json); + + set_token(json.token); + alert(`成功以 ${json.name} 的身份登录`); + this.setState({ + loading_status: 'done', + }); + }) + .catch((e)=>{ + alert('登录失败'); + this.setState({ + loading_status: 'done', + }); + console.trace(e); + }); + } + + render() { + return ( + <TokenCtx.Consumer>{(token)=> + <div className="login-form"> + <form onSubmit={(e)=>this.do_login(e,token.set_value)} className="box"> + <p>Token: <code>{token.value||'(null)'}</code></p> + <p> + <label> + 学号: + <input ref={this.username_ref} type="tel" /> + </label> + </p> + <p> + <label> + 密码: + <input ref={this.password_ref} type="password" /> + </label> + </p> + <p> + {this.state.loading_status==='loading' ? + <button disabled="disbled">正在登录……</button> : + <button type="submit">登录</button> + } + <button type="button" onClick={()=>{token.set_value(null);}}>退出</button> + </p> + </form> + <div className="box"> + <ul> + <li>我们不会记录您的密码和个人信息。</li> + <li><b>请勿泄露 Token</b>,它代表您的登录状态,与您的账户唯一对应且泄露后无法重置。</li> + <li>如果您不愿输入密码,可以直接修改 <code>localStorage['TOKEN']</code></li> + </ul> + </div> + </div> + }</TokenCtx.Consumer> + ) + } +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index ccf653ab..97ae8856 100644 --- a/src/index.css +++ b/src/index.css @@ -31,6 +31,7 @@ input { border-radius: 5px; border: 1px solid black; outline: none; + line-height: 2em; } audio { @@ -40,4 +41,22 @@ audio { pre { white-space: pre-wrap; font-family: '微软雅黑', 'Microsoft YaHei', sans-serif; +} + +button, .button { + color: black; + background-color: rgba(255,255,255,.5); + border-radius: 5px; + text-align: center; + border: 1px solid black; + line-height: 2em; + margin: 0 .5em; +} + +button:disabled, .button:disabled { + background-color: rgba(128,128,128,.5); +} + +code { + font-family: Consolas, Courier, monospace; } \ No newline at end of file