
18 changed files with 3507 additions and 2668 deletions
@ -0,0 +1,55 @@ |
|||||||
|
{ |
||||||
|
"env": { |
||||||
|
"browser": true, |
||||||
|
"es6": true, |
||||||
|
"node": true |
||||||
|
}, |
||||||
|
"extends": [ |
||||||
|
"plugin:react/recommended", |
||||||
|
"plugin:prettier/recommended", |
||||||
|
"prettier/react" |
||||||
|
], |
||||||
|
"globals": { |
||||||
|
"Atomics": "readonly", |
||||||
|
"SharedArrayBuffer": "readonly", |
||||||
|
"React": true |
||||||
|
}, |
||||||
|
"parserOptions": { |
||||||
|
"ecmaFeatures": { |
||||||
|
"jsx": true |
||||||
|
}, |
||||||
|
"ecmaVersion": 2018, |
||||||
|
"sourceType": "module" |
||||||
|
}, |
||||||
|
"plugins": [ |
||||||
|
"prettier", |
||||||
|
"react" |
||||||
|
], |
||||||
|
"settings": { |
||||||
|
"react": { |
||||||
|
"version": "detect" |
||||||
|
} |
||||||
|
}, |
||||||
|
"ignorePatterns": [ |
||||||
|
"src/infrastructure/", |
||||||
|
"src/react-lazyload/" |
||||||
|
], |
||||||
|
"rules": { |
||||||
|
"prettier/prettier": "warn", |
||||||
|
"react/jsx-indent": [ |
||||||
|
"error", |
||||||
|
2, |
||||||
|
{ |
||||||
|
"indentLogicalExpressions": true |
||||||
|
} |
||||||
|
], |
||||||
|
"react/prop-types": "off", |
||||||
|
"react/jsx-no-target-blank": "off", |
||||||
|
"no-unused-vars": [ |
||||||
|
"warn", |
||||||
|
{ |
||||||
|
"args": "none" |
||||||
|
} |
||||||
|
] |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
module.exports = { |
||||||
|
trailingComma: 'all', |
||||||
|
tabWidth: 2, |
||||||
|
semi: true, |
||||||
|
singleQuote: true, |
||||||
|
endOfLine: 'auto', |
||||||
|
} |
@ -1,131 +1,151 @@ |
|||||||
import React, {Component} from 'react'; |
import React, { Component } from 'react'; |
||||||
import {Flow} from './Flows'; |
import { Flow } from './Flows'; |
||||||
import {Title} from './Title'; |
import { Title } from './Title'; |
||||||
import {Sidebar} from './Sidebar'; |
import { Sidebar } from './Sidebar'; |
||||||
import {PressureHelper} from './PressureHelper'; |
import { PressureHelper } from './PressureHelper'; |
||||||
import {TokenCtx} from './UserAction'; |
import { TokenCtx } from './UserAction'; |
||||||
import {load_config,bgimg_style} from './Config'; |
import { load_config, bgimg_style } from './Config'; |
||||||
import {listen_darkmode} from './infrastructure/functions'; |
import { listen_darkmode } from './infrastructure/functions'; |
||||||
import {LoginPopup, TitleLine} from './infrastructure/widgets'; |
import { LoginPopup, TitleLine } from './infrastructure/widgets'; |
||||||
|
|
||||||
const MAX_SIDEBAR_STACK_SIZE=10; |
const MAX_SIDEBAR_STACK_SIZE = 10; |
||||||
|
|
||||||
function DeprecatedAlert(props) { |
function DeprecatedAlert(props) { |
||||||
return ( |
return <div id="global-hint-container" style={{ display: 'none' }} />; |
||||||
<div id="global-hint-container" style={{display: 'none'}} /> |
|
||||||
); |
|
||||||
} |
} |
||||||
|
|
||||||
class App extends Component { |
class App extends Component { |
||||||
constructor(props) { |
constructor(props) { |
||||||
super(props); |
super(props); |
||||||
load_config(); |
load_config(); |
||||||
listen_darkmode({default: undefined, light: false, dark: true}[window.config.color_scheme]); |
listen_darkmode( |
||||||
this.state={ |
{ default: undefined, light: false, dark: true }[ |
||||||
sidebar_stack: [[null,null]], // list of [status, content]
|
window.config.color_scheme |
||||||
mode: 'list', // list, single, search, attention
|
], |
||||||
search_text: null, |
); |
||||||
flow_render_key: +new Date(), |
this.state = { |
||||||
token: localStorage['TOKEN']||null, |
sidebar_stack: [[null, null]], // list of [status, content]
|
||||||
}; |
mode: 'list', // list, single, search, attention
|
||||||
this.show_sidebar_bound=this.show_sidebar.bind(this); |
search_text: null, |
||||||
this.set_mode_bound=this.set_mode.bind(this); |
flow_render_key: +new Date(), |
||||||
this.on_pressure_bound=this.on_pressure.bind(this); |
token: localStorage['TOKEN'] || null, |
||||||
// a silly self-deceptive approach to ban guests, enough to fool those muggles
|
}; |
||||||
// document cookie 'pku_ip_flag=yes'
|
this.show_sidebar_bound = this.show_sidebar.bind(this); |
||||||
this.inthu_flag=window[atob('ZG9jdW1lbnQ')][atob('Y29va2ll')].indexOf(atob('dGh1X2lwX2ZsYWc9eWVz'))!==-1; |
this.set_mode_bound = this.set_mode.bind(this); |
||||||
} |
this.on_pressure_bound = this.on_pressure.bind(this); |
||||||
|
// a silly self-deceptive approach to ban guests, enough to fool those muggles
|
||||||
|
// document cookie 'pku_ip_flag=yes'
|
||||||
|
this.inthu_flag = |
||||||
|
window[atob('ZG9jdW1lbnQ')][atob('Y29va2ll')].indexOf( |
||||||
|
atob('dGh1X2lwX2ZsYWc9eWVz'), |
||||||
|
) !== -1; |
||||||
|
} |
||||||
|
|
||||||
static is_darkmode() { |
static is_darkmode() { |
||||||
if(window.config.color_scheme==='dark') return true; |
if (window.config.color_scheme === 'dark') return true; |
||||||
if(window.config.color_scheme==='light') return false; |
if (window.config.color_scheme === 'light') return false; |
||||||
else { // 'default'
|
else { |
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches; |
// 'default'
|
||||||
} |
return window.matchMedia('(prefers-color-scheme: dark)').matches; |
||||||
} |
} |
||||||
|
} |
||||||
|
|
||||||
on_pressure() { |
on_pressure() { |
||||||
if(this.state.sidebar_stack.length>1) |
if (this.state.sidebar_stack.length > 1) |
||||||
this.show_sidebar(null,null,'clear'); |
this.show_sidebar(null, null, 'clear'); |
||||||
else |
else this.set_mode('list', null); |
||||||
this.set_mode('list',null); |
} |
||||||
} |
|
||||||
|
|
||||||
show_sidebar(title,content,mode='push') { |
show_sidebar(title, content, mode = 'push') { |
||||||
this.setState((prevState)=>{ |
this.setState((prevState) => { |
||||||
let ns=prevState.sidebar_stack.slice(); |
let ns = prevState.sidebar_stack.slice(); |
||||||
if(mode==='push') { |
if (mode === 'push') { |
||||||
if(ns.length>MAX_SIDEBAR_STACK_SIZE) |
if (ns.length > MAX_SIDEBAR_STACK_SIZE) ns.splice(1, 1); |
||||||
ns.splice(1,1); |
ns = ns.concat([[title, content]]); |
||||||
ns=ns.concat([[title,content]]); |
} else if (mode === 'pop') { |
||||||
} else if(mode==='pop') { |
if (ns.length === 1) return; |
||||||
if(ns.length===1) return; |
ns.pop(); |
||||||
ns.pop(); |
} else if (mode === 'replace') { |
||||||
} else if(mode==='replace') { |
ns.pop(); |
||||||
ns.pop(); |
ns = ns.concat([[title, content]]); |
||||||
ns=ns.concat([[title,content]]); |
} else if (mode === 'clear') { |
||||||
} else if(mode==='clear') { |
ns = [[null, null]]; |
||||||
ns=[[null,null]]; |
} else throw new Error('bad show_sidebar mode'); |
||||||
} else |
return { |
||||||
throw new Error('bad show_sidebar mode'); |
sidebar_stack: ns, |
||||||
return { |
}; |
||||||
sidebar_stack: ns, |
}); |
||||||
}; |
} |
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
set_mode(mode,search_text) { |
set_mode(mode, search_text) { |
||||||
this.setState({ |
this.setState({ |
||||||
mode: mode, |
mode: mode, |
||||||
search_text: search_text, |
search_text: search_text, |
||||||
flow_render_key: +new Date(), |
flow_render_key: +new Date(), |
||||||
}); |
}); |
||||||
} |
} |
||||||
|
|
||||||
render() { |
render() { |
||||||
return ( |
return ( |
||||||
<TokenCtx.Provider value={{ |
<TokenCtx.Provider |
||||||
value: this.state.token, |
value={{ |
||||||
set_value: (x)=>{ |
value: this.state.token, |
||||||
localStorage['TOKEN']=x||''; |
set_value: (x) => { |
||||||
this.setState({ |
localStorage['TOKEN'] = x || ''; |
||||||
token: x, |
this.setState({ |
||||||
}); |
token: x, |
||||||
}, |
}); |
||||||
}}> |
}, |
||||||
<PressureHelper callback={this.on_pressure_bound} /> |
}} |
||||||
<div className="bg-img" style={bgimg_style()} /> |
> |
||||||
<Title show_sidebar={this.show_sidebar_bound} set_mode={this.set_mode_bound} /> |
<PressureHelper callback={this.on_pressure_bound} /> |
||||||
<TokenCtx.Consumer>{(token)=>( |
<div className="bg-img" style={bgimg_style()} /> |
||||||
<div className="left-container"> |
<Title |
||||||
<DeprecatedAlert token={token.value} /> |
show_sidebar={this.show_sidebar_bound} |
||||||
{!token.value && |
set_mode={this.set_mode_bound} |
||||||
<div className="flow-item-row aux-margin"> |
/> |
||||||
<div className="box box-tip"> |
<TokenCtx.Consumer> |
||||||
<p> |
{(token) => ( |
||||||
<LoginPopup token_callback={token.set_value}>{(do_popup)=>( |
<div className="left-container"> |
||||||
<a onClick={do_popup}> |
<DeprecatedAlert token={token.value} /> |
||||||
<span className="icon icon-login" /> |
{!token.value && ( |
||||||
登录到 T大树洞 |
<div className="flow-item-row aux-margin"> |
||||||
</a> |
<div className="box box-tip"> |
||||||
)}</LoginPopup> |
<p> |
||||||
</p> |
<LoginPopup token_callback={token.set_value}> |
||||||
</div> |
{(do_popup) => ( |
||||||
</div> |
<a onClick={do_popup}> |
||||||
} |
<span className="icon icon-login" /> |
||||||
{this.inthu_flag||token.value ? |
登录到 T大树洞 |
||||||
<Flow key={this.state.flow_render_key} show_sidebar={this.show_sidebar_bound} |
</a> |
||||||
mode={this.state.mode} search_text={this.state.search_text} token={token.value} |
)} |
||||||
/> : |
</LoginPopup> |
||||||
<TitleLine text="请登录后查看内容" /> |
</p> |
||||||
} |
</div> |
||||||
<br /> |
</div> |
||||||
</div> |
)} |
||||||
)}</TokenCtx.Consumer> |
{this.inthu_flag || token.value ? ( |
||||||
<Sidebar show_sidebar={this.show_sidebar_bound} stack={this.state.sidebar_stack} /> |
<Flow |
||||||
</TokenCtx.Provider> |
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} |
||||||
|
/> |
||||||
|
) : ( |
||||||
|
<TitleLine text="请登录后查看内容" /> |
||||||
|
)} |
||||||
|
<br /> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</TokenCtx.Consumer> |
||||||
|
<Sidebar |
||||||
|
show_sidebar={this.show_sidebar_bound} |
||||||
|
stack={this.state.sidebar_stack} |
||||||
|
/> |
||||||
|
</TokenCtx.Provider> |
||||||
|
); |
||||||
|
} |
||||||
} |
} |
||||||
|
|
||||||
export default App; |
export default App; |
||||||
|
@ -1,91 +1,92 @@ |
|||||||
import React, {Component} from 'react'; |
import React, { Component } from 'react'; |
||||||
import load from 'load-script'; |
import load from 'load-script'; |
||||||
|
|
||||||
window.audio_cache={}; |
window.audio_cache = {}; |
||||||
|
|
||||||
function load_amrnb() { |
function load_amrnb() { |
||||||
return new Promise((resolve,reject)=>{ |
return new Promise((resolve, reject) => { |
||||||
if(window.AMR) |
if (window.AMR) resolve(); |
||||||
resolve(); |
else |
||||||
else |
load('static/amr_all.min.js', (err) => { |
||||||
load('static/amr_all.min.js', (err)=>{ |
if (err) reject(err); |
||||||
if(err) |
else resolve(); |
||||||
reject(err); |
}); |
||||||
else |
}); |
||||||
resolve(); |
|
||||||
}); |
|
||||||
}); |
|
||||||
} |
} |
||||||
|
|
||||||
export class AudioWidget extends Component { |
export class AudioWidget extends Component { |
||||||
constructor(props) { |
constructor(props) { |
||||||
super(props); |
super(props); |
||||||
this.state={ |
this.state = { |
||||||
url: this.props.src, |
url: this.props.src, |
||||||
state: 'waiting', |
state: 'waiting', |
||||||
data: null, |
data: null, |
||||||
}; |
}; |
||||||
|
} |
||||||
|
|
||||||
|
load() { |
||||||
|
if (window.audio_cache[this.state.url]) { |
||||||
|
this.setState({ |
||||||
|
state: 'loaded', |
||||||
|
data: window.audio_cache[this.state.url], |
||||||
|
}); |
||||||
|
return; |
||||||
} |
} |
||||||
|
|
||||||
load() { |
console.log('fetching audio', this.state.url); |
||||||
if(window.audio_cache[this.state.url]) { |
this.setState({ |
||||||
this.setState({ |
state: 'loading', |
||||||
state: 'loaded', |
}); |
||||||
data: window.audio_cache[this.state.url], |
Promise.all([fetch(this.state.url), load_amrnb()]).then((res) => { |
||||||
}); |
res[0].blob().then((blob) => { |
||||||
|
const reader = new FileReader(); |
||||||
|
reader.onload = (event) => { |
||||||
|
const raw = new window.AMR().decode(event.target.result); |
||||||
|
if (!raw) { |
||||||
|
alert('audio decoding failed'); |
||||||
return; |
return; |
||||||
} |
} |
||||||
|
const wave = window.PCMData.encode({ |
||||||
console.log('fetching audio',this.state.url); |
sampleRate: 8000, |
||||||
this.setState({ |
channelCount: 1, |
||||||
state: 'loading', |
bytesPerSample: 2, |
||||||
}); |
data: raw, |
||||||
Promise.all([ |
}); |
||||||
fetch(this.state.url), |
const binary_wave = new Uint8Array(wave.length); |
||||||
load_amrnb(), |
for (let i = 0; i < wave.length; i++) |
||||||
]) |
binary_wave[i] = wave.charCodeAt(i); |
||||||
.then((res)=>{ |
|
||||||
res[0].blob().then((blob)=>{ |
|
||||||
const reader=new FileReader(); |
|
||||||
reader.onload=(event)=>{ |
|
||||||
const raw=new window.AMR().decode(event.target.result); |
|
||||||
if(!raw) { |
|
||||||
alert('audio decoding failed'); |
|
||||||
return; |
|
||||||
} |
|
||||||
const wave=window.PCMData.encode({ |
|
||||||
sampleRate: 8000, |
|
||||||
channelCount: 1, |
|
||||||
bytesPerSample: 2, |
|
||||||
data: raw |
|
||||||
}); |
|
||||||
const binary_wave=new Uint8Array(wave.length); |
|
||||||
for(let i=0;i<wave.length;i++) |
|
||||||
binary_wave[i]=wave.charCodeAt(i); |
|
||||||
|
|
||||||
const objurl=URL.createObjectURL(new Blob([binary_wave], {type: 'audio/wav'})); |
const objurl = URL.createObjectURL( |
||||||
window.audio_cache[this.state.url]=objurl; |
new Blob([binary_wave], { type: 'audio/wav' }), |
||||||
this.setState({ |
); |
||||||
state: 'loaded', |
window.audio_cache[this.state.url] = objurl; |
||||||
data: objurl, |
this.setState({ |
||||||
}); |
state: 'loaded', |
||||||
}; |
data: objurl, |
||||||
reader.readAsBinaryString(blob); |
}); |
||||||
}); |
}; |
||||||
this.setState({ |
reader.readAsBinaryString(blob); |
||||||
state: 'decoding', |
}); |
||||||
}); |
this.setState({ |
||||||
}); |
state: 'decoding', |
||||||
} |
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
render() { |
render() { |
||||||
if(this.state.state==='waiting') |
if (this.state.state === 'waiting') |
||||||
return (<p><a onClick={this.load.bind(this)}>加载音频</a></p>); |
return ( |
||||||
if(this.state.state==='loading') |
<p> |
||||||
return (<p>正在下载……</p>); |
<a onClick={this.load.bind(this)}>加载音频</a> |
||||||
else if(this.state.state==='decoding') |
</p> |
||||||
return (<p>正在解码……</p>); |
); |
||||||
else if(this.state.state==='loaded') |
if (this.state.state === 'loading') return <p>正在下载……</p>; |
||||||
return (<p><audio src={this.state.data} controls /></p>); |
else if (this.state.state === 'decoding') return <p>正在解码……</p>; |
||||||
} |
else if (this.state.state === 'loaded') |
||||||
} |
return ( |
||||||
|
<p> |
||||||
|
<audio src={this.state.data} controls /> |
||||||
|
</p> |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
@ -1,310 +1,415 @@ |
|||||||
import React, {Component, PureComponent} from 'react'; |
import React, { Component, PureComponent } from 'react'; |
||||||
import {format_time,Time,TitleLine} from './infrastructure/widgets'; |
import { format_time, Time, TitleLine } from './infrastructure/widgets'; |
||||||
import {THUHOLE_API_ROOT} from './flows_api'; |
import { THUHOLE_API_ROOT } from './flows_api'; |
||||||
|
|
||||||
import HtmlToReact from 'html-to-react' |
import HtmlToReact from 'html-to-react'; |
||||||
|
|
||||||
import './Common.css'; |
import './Common.css'; |
||||||
import { URL_PID_RE, URL_RE, PID_RE, NICKNAME_RE, split_text } from './text_splitter'; |
import { |
||||||
|
URL_PID_RE, |
||||||
|
URL_RE, |
||||||
|
PID_RE, |
||||||
|
NICKNAME_RE, |
||||||
|
split_text, |
||||||
|
} from './text_splitter'; |
||||||
|
|
||||||
import renderMd from './Markdown' |
import renderMd from './Markdown'; |
||||||
|
|
||||||
export {format_time,Time,TitleLine}; |
export { format_time, Time, TitleLine }; |
||||||
|
|
||||||
export const API_BASE=THUHOLE_API_ROOT+'services/thuhole'; |
export const API_BASE = THUHOLE_API_ROOT + 'services/thuhole'; |
||||||
|
|
||||||
// https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
|
// https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
|
||||||
function escape_regex(string) { |
function escape_regex(string) { |
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||||
} |
} |
||||||
|
|
||||||
export function build_highlight_re(txt,split,option='g') { |
export function build_highlight_re(txt, split, option = 'g') { |
||||||
return txt ? new RegExp(`(${txt.split(split).filter((x)=>!!x).map(escape_regex).join('|')})`,option) : /^$/g; |
return txt |
||||||
|
? new RegExp( |
||||||
|
`(${txt |
||||||
|
.split(split) |
||||||
|
.filter((x) => !!x) |
||||||
|
.map(escape_regex) |
||||||
|
.join('|')})`,
|
||||||
|
option, |
||||||
|
) |
||||||
|
: /^$/g; |
||||||
} |
} |
||||||
|
|
||||||
export function ColoredSpan(props) { |
export function ColoredSpan(props) { |
||||||
return ( |
return ( |
||||||
<span className="colored-span" style={{ |
<span |
||||||
'--coloredspan-bgcolor-light': props.colors[0], |
className="colored-span" |
||||||
'--coloredspan-bgcolor-dark': props.colors[1], |
style={{ |
||||||
}}>{props.children}</span> |
'--coloredspan-bgcolor-light': props.colors[0], |
||||||
) |
'--coloredspan-bgcolor-dark': props.colors[1], |
||||||
|
}} |
||||||
|
> |
||||||
|
{props.children} |
||||||
|
</span> |
||||||
|
); |
||||||
} |
} |
||||||
|
|
||||||
|
|
||||||
function normalize_url(url) { |
function normalize_url(url) { |
||||||
return /^https?:\/\//.test(url) ? url : 'http://'+url; |
return /^https?:\/\//.test(url) ? url : 'http://' + url; |
||||||
} |
} |
||||||
|
|
||||||
export class HighlightedText extends PureComponent { |
export class HighlightedText extends PureComponent { |
||||||
render() { |
render() { |
||||||
return ( |
return ( |
||||||
<pre> |
<pre> |
||||||
{this.props.parts.map((part,idx)=>{ |
{this.props.parts.map((part, idx) => { |
||||||
let [rule,p]=part; |
let [rule, p] = part; |
||||||
return ( |
return ( |
||||||
<span key={idx}>{ |
<span key={idx}> |
||||||
rule==='url_pid' ? <span className="url-pid-link" title={p}>/##</span> : |
{rule === 'url_pid' ? ( |
||||||
rule==='url' ? <a href={normalize_url(p)} target="_blank" rel="noopener">{p}</a> : |
<span className="url-pid-link" title={p}> |
||||||
rule==='pid' ? <a href={'#'+p} onClick={(e)=>{e.preventDefault(); this.props.show_pid(p.substring(1));}}>{p}</a> : |
/## |
||||||
rule==='nickname' ? <ColoredSpan colors={this.props.color_picker.get(p)}>{p}</ColoredSpan> : |
</span> |
||||||
rule==='search' ? <span className="search-query-highlight">{p}</span> : |
) : rule === 'url' ? ( |
||||||
p |
<a href={normalize_url(p)} target="_blank" rel="noopener"> |
||||||
}</span> |
{p} |
||||||
); |
</a> |
||||||
})} |
) : rule === 'pid' ? ( |
||||||
</pre> |
<a |
||||||
) |
href={'#' + p} |
||||||
} |
onClick={(e) => { |
||||||
|
e.preventDefault(); |
||||||
|
this.props.show_pid(p.substring(1)); |
||||||
|
}} |
||||||
|
> |
||||||
|
{p} |
||||||
|
</a> |
||||||
|
) : rule === 'nickname' ? ( |
||||||
|
<ColoredSpan colors={this.props.color_picker.get(p)}> |
||||||
|
{p} |
||||||
|
</ColoredSpan> |
||||||
|
) : rule === 'search' ? ( |
||||||
|
<span className="search-query-highlight">{p}</span> |
||||||
|
) : ( |
||||||
|
p |
||||||
|
)} |
||||||
|
</span> |
||||||
|
); |
||||||
|
})} |
||||||
|
</pre> |
||||||
|
); |
||||||
|
} |
||||||
} |
} |
||||||
|
|
||||||
// props: text, show_pid, color_picker
|
// props: text, show_pid, color_picker
|
||||||
export class HighlightedMarkdown extends Component { |
export class HighlightedMarkdown extends Component { |
||||||
render() { |
render() { |
||||||
const props = this.props |
const props = this.props; |
||||||
const processDefs = new HtmlToReact.ProcessNodeDefinitions(React) |
const processDefs = new HtmlToReact.ProcessNodeDefinitions(React); |
||||||
const processInstructions = [ |
const processInstructions = [ |
||||||
{ |
{ |
||||||
shouldProcessNode: (node) => node.name === 'img', // disable images
|
shouldProcessNode: (node) => node.name === 'img', // disable images
|
||||||
processNode (node, children, index) { |
processNode(node, children, index) { |
||||||
return (<div key={index}>[图片]</div>) |
return <div key={index}>[图片]</div>; |
||||||
} |
}, |
||||||
}, |
}, |
||||||
{ |
{ |
||||||
shouldProcessNode: (node) => (/^h[123456]$/.test(node.name)), |
shouldProcessNode: (node) => /^h[123456]$/.test(node.name), |
||||||
processNode (node, children, index) { |
processNode(node, children, index) { |
||||||
let currentLevel = +(node.name[1]) |
let currentLevel = +node.name[1]; |
||||||
if (currentLevel < 3) currentLevel = 3; |
if (currentLevel < 3) currentLevel = 3; |
||||||
const HeadingTag = `h${currentLevel}` |
const HeadingTag = `h${currentLevel}`; |
||||||
return ( |
return <HeadingTag key={index}>{children}</HeadingTag>; |
||||||
<HeadingTag key={index}>{children}</HeadingTag> |
}, |
||||||
) |
}, |
||||||
} |
{ |
||||||
}, |
shouldProcessNode: (node) => node.name === 'a', |
||||||
{ |
processNode(node, children, index) { |
||||||
shouldProcessNode: (node) => node.name === 'a', |
return ( |
||||||
processNode (node, children, index) { |
<a |
||||||
return ( |
href={normalize_url(node.attribs.href)} |
||||||
<a href={normalize_url(node.attribs.href)} target="_blank" rel="noopenner noreferrer" className="ext-link" key={index}> |
target="_blank" |
||||||
{children} |
rel="noopenner noreferrer" |
||||||
<span className="icon icon-new-tab" /> |
className="ext-link" |
||||||
</a> |
key={index} |
||||||
) |
> |
||||||
} |
{children} |
||||||
}, |
<span className="icon icon-new-tab" /> |
||||||
{ |
</a> |
||||||
shouldProcessNode (node) { |
); |
||||||
return node.type === 'text' && (!node.parent || !node.parent.attribs || node.parent.attribs['encoding'] != "application/x-tex") // pid, nickname, search
|
}, |
||||||
}, |
}, |
||||||
processNode (node, children, index) { |
{ |
||||||
const originalText = node.data |
shouldProcessNode(node) { |
||||||
const splitted = split_text(originalText, [ |
return ( |
||||||
['url_pid', URL_PID_RE], |
node.type === 'text' && |
||||||
['url',URL_RE], |
(!node.parent || |
||||||
['pid',PID_RE], |
!node.parent.attribs || |
||||||
['nickname',NICKNAME_RE], |
node.parent.attribs['encoding'] != 'application/x-tex') |
||||||
]) |
); // pid, nickname, search
|
||||||
|
}, |
||||||
|
processNode(node, children, index) { |
||||||
|
const originalText = node.data; |
||||||
|
const splitted = split_text(originalText, [ |
||||||
|
['url_pid', URL_PID_RE], |
||||||
|
['url', URL_RE], |
||||||
|
['pid', PID_RE], |
||||||
|
['nickname', NICKNAME_RE], |
||||||
|
]); |
||||||
|
|
||||||
return ( |
return ( |
||||||
<React.Fragment key={index}> |
<React.Fragment key={index}> |
||||||
{splitted.map(([rule, p], idx) => { |
{splitted.map(([rule, p], idx) => { |
||||||
return (<span key={idx}> |
return ( |
||||||
{ |
<span key={idx}> |
||||||
rule==='url_pid' ? <span className="url-pid-link" title={p}>/##</span> : |
{rule === 'url_pid' ? ( |
||||||
rule==='url' ? <a href={normalize_url(p)} className="ext-link" target="_blank" rel="noopener noreferrer"> |
<span className="url-pid-link" title={p}> |
||||||
{p} |
/## |
||||||
<span className="icon icon-new-tab" /> |
</span> |
||||||
</a> : |
) : rule === 'url' ? ( |
||||||
rule==='pid' ? <a href={'#'+p} onClick={(e)=>{e.preventDefault(); props.show_pid(p.substring(1));}}>{p}</a> : |
<a |
||||||
rule==='nickname' ? <ColoredSpan colors={props.color_picker.get(p)}>{p}</ColoredSpan> : |
href={normalize_url(p)} |
||||||
rule==='search' ? <span className="search-query-highlight">{p}</span> : |
className="ext-link" |
||||||
p} |
target="_blank" |
||||||
</span>) |
rel="noopener noreferrer" |
||||||
})} |
> |
||||||
</React.Fragment> |
{p} |
||||||
) |
<span className="icon icon-new-tab" /> |
||||||
} |
</a> |
||||||
}, |
) : rule === 'pid' ? ( |
||||||
{ |
<a |
||||||
shouldProcessNode: () => true, |
href={'#' + p} |
||||||
processNode: processDefs.processDefaultNode |
onClick={(e) => { |
||||||
} |
e.preventDefault(); |
||||||
] |
props.show_pid(p.substring(1)); |
||||||
const parser = new HtmlToReact.Parser() |
}} |
||||||
if (props.author && props.text.match(/^(?:#+ |>|```|\t|\s*-|\s*\d+\.)/)) { |
> |
||||||
const renderedMarkdown = renderMd(props.text) |
{p} |
||||||
return ( |
</a> |
||||||
<> |
) : rule === 'nickname' ? ( |
||||||
{props.author} |
<ColoredSpan colors={props.color_picker.get(p)}> |
||||||
{parser.parseWithInstructions(renderedMarkdown, node => node.type !== 'script', processInstructions) || ''} |
{p} |
||||||
</> |
</ColoredSpan> |
||||||
) |
) : rule === 'search' ? ( |
||||||
} else { |
<span className="search-query-highlight">{p}</span> |
||||||
let rawMd = props.text |
) : ( |
||||||
if (props.author) rawMd = props.author + ' ' + rawMd |
p |
||||||
const renderedMarkdown = renderMd(rawMd) |
)} |
||||||
return (parser.parseWithInstructions(renderedMarkdown, node => node.type !== 'script', processInstructions) || null) |
</span> |
||||||
} |
); |
||||||
|
})} |
||||||
|
</React.Fragment> |
||||||
|
); |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
shouldProcessNode: () => true, |
||||||
|
processNode: processDefs.processDefaultNode, |
||||||
|
}, |
||||||
|
]; |
||||||
|
const parser = new HtmlToReact.Parser(); |
||||||
|
if (props.author && props.text.match(/^(?:#+ |>|```|\t|\s*-|\s*\d+\.)/)) { |
||||||
|
const renderedMarkdown = renderMd(props.text); |
||||||
|
return ( |
||||||
|
<> |
||||||
|
{props.author} |
||||||
|
{parser.parseWithInstructions( |
||||||
|
renderedMarkdown, |
||||||
|
(node) => node.type !== 'script', |
||||||
|
processInstructions, |
||||||
|
) || ''} |
||||||
|
</> |
||||||
|
); |
||||||
|
} else { |
||||||
|
let rawMd = props.text; |
||||||
|
if (props.author) rawMd = props.author + ' ' + rawMd; |
||||||
|
const renderedMarkdown = renderMd(rawMd); |
||||||
|
return ( |
||||||
|
parser.parseWithInstructions( |
||||||
|
renderedMarkdown, |
||||||
|
(node) => node.type !== 'script', |
||||||
|
processInstructions, |
||||||
|
) || null |
||||||
|
); |
||||||
} |
} |
||||||
|
} |
||||||
} |
} |
||||||
|
|
||||||
window.TEXTAREA_BACKUP={}; |
window.TEXTAREA_BACKUP = {}; |
||||||
|
|
||||||
export class SafeTextarea extends Component { |
export class SafeTextarea extends Component { |
||||||
constructor(props) { |
constructor(props) { |
||||||
super(props); |
super(props); |
||||||
this.state={ |
this.state = { |
||||||
text: '', |
text: '', |
||||||
}; |
}; |
||||||
this.on_change_bound=this.on_change.bind(this); |
this.on_change_bound = this.on_change.bind(this); |
||||||
this.on_keydown_bound=this.on_keydown.bind(this); |
this.on_keydown_bound = this.on_keydown.bind(this); |
||||||
this.clear=this.clear.bind(this); |
this.clear = this.clear.bind(this); |
||||||
this.area_ref=React.createRef(); |
this.area_ref = React.createRef(); |
||||||
this.change_callback=props.on_change||(()=>{}); |
this.change_callback = props.on_change || (() => {}); |
||||||
this.submit_callback=props.on_submit||(()=>{}); |
this.submit_callback = props.on_submit || (() => {}); |
||||||
} |
} |
||||||
|
|
||||||
componentDidMount() { |
|
||||||
this.setState({ |
|
||||||
text: window.TEXTAREA_BACKUP[this.props.id]||'' |
|
||||||
},()=>{ |
|
||||||
this.change_callback(this.state.text); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
componentWillUnmount() { |
componentDidMount() { |
||||||
window.TEXTAREA_BACKUP[this.props.id]=this.state.text; |
this.setState( |
||||||
|
{ |
||||||
|
text: window.TEXTAREA_BACKUP[this.props.id] || '', |
||||||
|
}, |
||||||
|
() => { |
||||||
this.change_callback(this.state.text); |
this.change_callback(this.state.text); |
||||||
} |
}, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
on_change(event) { |
componentWillUnmount() { |
||||||
this.setState({ |
window.TEXTAREA_BACKUP[this.props.id] = this.state.text; |
||||||
text: event.target.value, |
this.change_callback(this.state.text); |
||||||
}); |
} |
||||||
this.change_callback(event.target.value); |
|
||||||
} |
|
||||||
on_keydown(event) { |
|
||||||
if(event.key==='Enter' && event.ctrlKey && !event.altKey) { |
|
||||||
event.preventDefault(); |
|
||||||
this.submit_callback(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
clear() { |
on_change(event) { |
||||||
this.setState({ |
this.setState({ |
||||||
text: '', |
text: event.target.value, |
||||||
}); |
}); |
||||||
} |
this.change_callback(event.target.value); |
||||||
set(text) { |
} |
||||||
this.change_callback(text); |
on_keydown(event) { |
||||||
this.setState({ |
if (event.key === 'Enter' && event.ctrlKey && !event.altKey) { |
||||||
text: text, |
event.preventDefault(); |
||||||
}); |
this.submit_callback(); |
||||||
} |
|
||||||
get() { |
|
||||||
return this.state.text; |
|
||||||
} |
|
||||||
focus() { |
|
||||||
this.area_ref.current.focus(); |
|
||||||
} |
} |
||||||
|
} |
||||||
|
|
||||||
render() { |
clear() { |
||||||
return ( |
this.setState({ |
||||||
<textarea ref={this.area_ref} onChange={this.on_change_bound} value={this.state.text} onKeyDown={this.on_keydown_bound} /> |
text: '', |
||||||
) |
}); |
||||||
} |
} |
||||||
|
set(text) { |
||||||
|
this.change_callback(text); |
||||||
|
this.setState({ |
||||||
|
text: text, |
||||||
|
}); |
||||||
|
} |
||||||
|
get() { |
||||||
|
return this.state.text; |
||||||
|
} |
||||||
|
focus() { |
||||||
|
this.area_ref.current.focus(); |
||||||
|
} |
||||||
|
|
||||||
|
render() { |
||||||
|
return ( |
||||||
|
<textarea |
||||||
|
ref={this.area_ref} |
||||||
|
onChange={this.on_change_bound} |
||||||
|
value={this.state.text} |
||||||
|
onKeyDown={this.on_keydown_bound} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
||||||
} |
} |
||||||
|
|
||||||
let pwa_prompt_event=null; |
let pwa_prompt_event = null; |
||||||
window.addEventListener('beforeinstallprompt', (e) => { |
window.addEventListener('beforeinstallprompt', (e) => { |
||||||
console.log('pwa: received before install prompt'); |
console.log('pwa: received before install prompt'); |
||||||
pwa_prompt_event=e; |
pwa_prompt_event = e; |
||||||
}); |
}); |
||||||
|
|
||||||
export function PromotionBar(props) { |
export function PromotionBar(props) { |
||||||
let is_ios=/iPhone|iPad|iPod/i.test(window.navigator.userAgent); |
let is_ios = /iPhone|iPad|iPod/i.test(window.navigator.userAgent); |
||||||
let is_installed=(window.matchMedia('(display-mode: standalone)').matches) || (window.navigator.standalone); |
let is_installed = |
||||||
|
window.matchMedia('(display-mode: standalone)').matches || |
||||||
|
window.navigator.standalone; |
||||||
|
|
||||||
if(is_installed) |
if (is_installed) return null; |
||||||
return null; |
|
||||||
|
|
||||||
if(is_ios) |
if (is_ios) |
||||||
// noinspection JSConstructorReturnsPrimitive
|
// noinspection JSConstructorReturnsPrimitive
|
||||||
return !navigator.standalone ? ( |
return !navigator.standalone ? ( |
||||||
<div className="box promotion-bar"> |
<div className="box promotion-bar"> |
||||||
<span className="icon icon-about" /> |
<span className="icon icon-about" /> |
||||||
用 Safari 把树洞 <b>添加到主屏幕</b> 更好用 |
用 Safari 把树洞 <b>添加到主屏幕</b> 更好用 |
||||||
</div> |
</div> |
||||||
) : null; |
) : null; |
||||||
else |
// noinspection JSConstructorReturnsPrimitive
|
||||||
// noinspection JSConstructorReturnsPrimitive
|
else |
||||||
return pwa_prompt_event ? ( |
return pwa_prompt_event ? ( |
||||||
<div className="box promotion-bar"> |
<div className="box promotion-bar"> |
||||||
<span className="icon icon-about" /> |
<span className="icon icon-about" /> |
||||||
把网页版树洞 <b><a onClick={()=>{ |
把网页版树洞{' '} |
||||||
if(pwa_prompt_event) |
<b> |
||||||
pwa_prompt_event.prompt(); |
<a |
||||||
}}>安装到桌面</a></b> 更好用 |
onClick={() => { |
||||||
</div> |
if (pwa_prompt_event) pwa_prompt_event.prompt(); |
||||||
) : null; |
}} |
||||||
|
> |
||||||
|
安装到桌面 |
||||||
|
</a> |
||||||
|
</b>{' '} |
||||||
|
更好用 |
||||||
|
</div> |
||||||
|
) : null; |
||||||
} |
} |
||||||
|
|
||||||
export class ClickHandler extends PureComponent { |
export class ClickHandler extends PureComponent { |
||||||
constructor(props) { |
constructor(props) { |
||||||
super(props); |
super(props); |
||||||
this.state={ |
this.state = { |
||||||
moved: true, |
moved: true, |
||||||
init_y: 0, |
init_y: 0, |
||||||
init_x: 0, |
init_x: 0, |
||||||
}; |
}; |
||||||
this.on_begin_bound=this.on_begin.bind(this); |
this.on_begin_bound = this.on_begin.bind(this); |
||||||
this.on_move_bound=this.on_move.bind(this); |
this.on_move_bound = this.on_move.bind(this); |
||||||
this.on_end_bound=this.on_end.bind(this); |
this.on_end_bound = this.on_end.bind(this); |
||||||
|
|
||||||
this.MOVE_THRESHOLD=3; |
this.MOVE_THRESHOLD = 3; |
||||||
this.last_fire=0; |
this.last_fire = 0; |
||||||
} |
} |
||||||
|
|
||||||
on_begin(e) { |
on_begin(e) { |
||||||
//console.log('click',(e.touches?e.touches[0]:e).screenY,(e.touches?e.touches[0]:e).screenX);
|
//console.log('click',(e.touches?e.touches[0]:e).screenY,(e.touches?e.touches[0]:e).screenX);
|
||||||
this.setState({ |
this.setState({ |
||||||
moved: false, |
moved: false, |
||||||
init_y: (e.touches?e.touches[0]:e).screenY, |
init_y: (e.touches ? e.touches[0] : e).screenY, |
||||||
init_x: (e.touches?e.touches[0]:e).screenX, |
init_x: (e.touches ? e.touches[0] : e).screenX, |
||||||
}); |
}); |
||||||
} |
} |
||||||
on_move(e) { |
on_move(e) { |
||||||
if(!this.state.moved) { |
if (!this.state.moved) { |
||||||
let mvmt=Math.abs((e.touches?e.touches[0]:e).screenY-this.state.init_y)+Math.abs((e.touches?e.touches[0]:e).screenX-this.state.init_x); |
let mvmt = |
||||||
//console.log('move',mvmt);
|
Math.abs((e.touches ? e.touches[0] : e).screenY - this.state.init_y) + |
||||||
if(mvmt>this.MOVE_THRESHOLD) |
Math.abs((e.touches ? e.touches[0] : e).screenX - this.state.init_x); |
||||||
this.setState({ |
//console.log('move',mvmt);
|
||||||
moved: true, |
if (mvmt > this.MOVE_THRESHOLD) |
||||||
}); |
|
||||||
} |
|
||||||
} |
|
||||||
on_end(event) { |
|
||||||
//console.log('end');
|
|
||||||
if(!this.state.moved) |
|
||||||
this.do_callback(event); |
|
||||||
this.setState({ |
this.setState({ |
||||||
moved: true, |
moved: true, |
||||||
}); |
}); |
||||||
} |
} |
||||||
|
} |
||||||
|
on_end(event) { |
||||||
|
//console.log('end');
|
||||||
|
if (!this.state.moved) this.do_callback(event); |
||||||
|
this.setState({ |
||||||
|
moved: true, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
do_callback(event) { |
do_callback(event) { |
||||||
if(this.last_fire+100>+new Date()) return; |
if (this.last_fire + 100 > +new Date()) return; |
||||||
this.last_fire=+new Date(); |
this.last_fire = +new Date(); |
||||||
this.props.callback(event); |
this.props.callback(event); |
||||||
} |
} |
||||||
|
|
||||||
render() { |
render() { |
||||||
return ( |
return ( |
||||||
<div onTouchStart={this.on_begin_bound} onMouseDown={this.on_begin_bound} |
<div |
||||||
onTouchMove={this.on_move_bound} onMouseMove={this.on_move_bound} |
onTouchStart={this.on_begin_bound} |
||||||
onClick={this.on_end_bound} > |
onMouseDown={this.on_begin_bound} |
||||||
{this.props.children} |
onTouchMove={this.on_move_bound} |
||||||
</div> |
onMouseMove={this.on_move_bound} |
||||||
) |
onClick={this.on_end_bound} |
||||||
} |
> |
||||||
|
{this.props.children} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
} |
} |
||||||
|
@ -1,255 +1,327 @@ |
|||||||
import React, {Component, PureComponent} from 'react'; |
import React, { Component, PureComponent } from 'react'; |
||||||
|
|
||||||
import './Config.css'; |
import './Config.css'; |
||||||
|
|
||||||
const BUILTIN_IMGS={ |
const BUILTIN_IMGS = { |
||||||
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/gbp.jpg': '寻觅繁星(默认)', |
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/gbp.jpg': |
||||||
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/eriri.jpg': '平成著名画师', |
'寻觅繁星(默认)', |
||||||
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/yurucamp.jpg': '露营天下第一', |
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/eriri.jpg': |
||||||
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/minecraft.jpg': '麦恩·库拉夫特', |
'平成著名画师', |
||||||
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/cyberpunk.jpg': '赛博城市', |
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/yurucamp.jpg': |
||||||
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/bj.jpg': '城市的星光', |
'露营天下第一', |
||||||
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/sif.jpg': '梦开始的地方', |
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/minecraft.jpg': |
||||||
|
'麦恩·库拉夫特', |
||||||
|
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/cyberpunk.jpg': |
||||||
|
'赛博城市', |
||||||
|
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/bj.jpg': |
||||||
|
'城市的星光', |
||||||
|
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/sif.jpg': |
||||||
|
'梦开始的地方', |
||||||
}; |
}; |
||||||
|
|
||||||
const DEFAULT_CONFIG={ |
const DEFAULT_CONFIG = { |
||||||
background_img: 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/gbp.jpg', |
background_img: |
||||||
background_color: '#113366', |
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/gbp.jpg', |
||||||
pressure: false, |
background_color: '#113366', |
||||||
easter_egg: true, |
pressure: false, |
||||||
color_scheme: 'default', |
easter_egg: true, |
||||||
fold: true |
color_scheme: 'default', |
||||||
|
fold: true, |
||||||
}; |
}; |
||||||
|
|
||||||
export function load_config() { |
export function load_config() { |
||||||
let config=Object.assign({},DEFAULT_CONFIG); |
let config = Object.assign({}, DEFAULT_CONFIG); |
||||||
let loaded_config; |
let loaded_config; |
||||||
try { |
try { |
||||||
loaded_config=JSON.parse(localStorage['hole_config']||'{}'); |
loaded_config = JSON.parse(localStorage['hole_config'] || '{}'); |
||||||
} catch(e) { |
} catch (e) { |
||||||
alert('设置加载失败,将重置为默认设置!\n'+e); |
alert('设置加载失败,将重置为默认设置!\n' + e); |
||||||
delete localStorage['hole_config']; |
delete localStorage['hole_config']; |
||||||
loaded_config={}; |
loaded_config = {}; |
||||||
} |
} |
||||||
|
|
||||||
// unrecognized configs are removed
|
// unrecognized configs are removed
|
||||||
Object.keys(loaded_config).forEach((key)=>{ |
Object.keys(loaded_config).forEach((key) => { |
||||||
if(config[key]!==undefined) |
if (config[key] !== undefined) config[key] = loaded_config[key]; |
||||||
config[key]=loaded_config[key]; |
}); |
||||||
}); |
|
||||||
|
|
||||||
console.log('config loaded',config); |
console.log('config loaded', config); |
||||||
window.config=config; |
window.config = config; |
||||||
} |
} |
||||||
export function save_config() { |
export function save_config() { |
||||||
localStorage['hole_config']=JSON.stringify(window.config); |
localStorage['hole_config'] = JSON.stringify(window.config); |
||||||
load_config(); |
load_config(); |
||||||
} |
} |
||||||
|
|
||||||
export function bgimg_style(img,color) { |
export function bgimg_style(img, color) { |
||||||
if(img===undefined) img=window.config.background_img; |
if (img === undefined) img = window.config.background_img; |
||||||
if(color===undefined) color=window.config.background_color; |
if (color === undefined) color = window.config.background_color; |
||||||
return { |
return { |
||||||
background: 'transparent center center', |
background: 'transparent center center', |
||||||
backgroundImage: img===null ? 'unset' : 'url("'+encodeURI(img)+'")', |
backgroundImage: img === null ? 'unset' : 'url("' + encodeURI(img) + '")', |
||||||
backgroundColor: color, |
backgroundColor: color, |
||||||
backgroundSize: 'cover', |
backgroundSize: 'cover', |
||||||
}; |
}; |
||||||
} |
} |
||||||
|
|
||||||
class ConfigBackground extends PureComponent { |
class ConfigBackground extends PureComponent { |
||||||
constructor(props) { |
constructor(props) { |
||||||
super(props); |
super(props); |
||||||
this.state={ |
this.state = { |
||||||
img: window.config.background_img, |
img: window.config.background_img, |
||||||
color: window.config.background_color, |
color: window.config.background_color, |
||||||
}; |
}; |
||||||
} |
} |
||||||
|
|
||||||
save_changes() { |
save_changes() { |
||||||
this.props.callback({ |
this.props.callback({ |
||||||
background_img: this.state.img, |
background_img: this.state.img, |
||||||
background_color: this.state.color, |
background_color: this.state.color, |
||||||
}); |
}); |
||||||
} |
} |
||||||
|
|
||||||
on_select(e) { |
on_select(e) { |
||||||
let value=e.target.value; |
let value = e.target.value; |
||||||
this.setState({ |
this.setState( |
||||||
img: value==='##other' ? '' : |
{ |
||||||
value==='##color' ? null : value, |
img: value === '##other' ? '' : value === '##color' ? null : value, |
||||||
},this.save_changes.bind(this)); |
}, |
||||||
} |
this.save_changes.bind(this), |
||||||
on_change_img(e) { |
); |
||||||
this.setState({ |
} |
||||||
img: e.target.value, |
on_change_img(e) { |
||||||
},this.save_changes.bind(this)); |
this.setState( |
||||||
} |
{ |
||||||
on_change_color(e) { |
img: e.target.value, |
||||||
this.setState({ |
}, |
||||||
color: e.target.value, |
this.save_changes.bind(this), |
||||||
},this.save_changes.bind(this)); |
); |
||||||
} |
} |
||||||
|
on_change_color(e) { |
||||||
|
this.setState( |
||||||
|
{ |
||||||
|
color: e.target.value, |
||||||
|
}, |
||||||
|
this.save_changes.bind(this), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
render() { |
render() { |
||||||
let img_select= this.state.img===null ? '##color' : |
let img_select = |
||||||
Object.keys(BUILTIN_IMGS).indexOf(this.state.img)===-1 ? '##other' : this.state.img; |
this.state.img === null |
||||||
return ( |
? '##color' |
||||||
<div> |
: Object.keys(BUILTIN_IMGS).indexOf(this.state.img) === -1 |
||||||
<p> |
? '##other' |
||||||
<b>背景图片:</b> |
: this.state.img; |
||||||
<select value={img_select} onChange={this.on_select.bind(this)}> |
return ( |
||||||
{Object.keys(BUILTIN_IMGS).map((key)=>( |
<div> |
||||||
<option key={key} value={key}>{BUILTIN_IMGS[key]}</option> |
<p> |
||||||
))} |
<b>背景图片:</b> |
||||||
<option value="##other">输入图片网址……</option> |
<select value={img_select} onChange={this.on_select.bind(this)}> |
||||||
<option value="##color">纯色背景……</option> |
{Object.keys(BUILTIN_IMGS).map((key) => ( |
||||||
</select> |
<option key={key} value={key}> |
||||||
|
{BUILTIN_IMGS[key]} |
||||||
{img_select==='##other' && |
</option> |
||||||
<input type="url" placeholder="图片网址" value={this.state.img} onChange={this.on_change_img.bind(this)} /> |
))} |
||||||
} |
<option value="##other">输入图片网址……</option> |
||||||
{img_select==='##color' && |
<option value="##color">纯色背景……</option> |
||||||
<input type="color" value={this.state.color} onChange={this.on_change_color.bind(this)} /> |
</select> |
||||||
} |
|
||||||
</p> |
{img_select === '##other' && ( |
||||||
<div className="bg-preview" style={bgimg_style(this.state.img,this.state.color)} /> |
<input |
||||||
</div> |
type="url" |
||||||
); |
placeholder="图片网址" |
||||||
} |
value={this.state.img} |
||||||
|
onChange={this.on_change_img.bind(this)} |
||||||
|
/> |
||||||
|
)} |
||||||
|
{img_select === '##color' && ( |
||||||
|
<input |
||||||
|
type="color" |
||||||
|
value={this.state.color} |
||||||
|
onChange={this.on_change_color.bind(this)} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</p> |
||||||
|
<div |
||||||
|
className="bg-preview" |
||||||
|
style={bgimg_style(this.state.img, this.state.color)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
} |
} |
||||||
|
|
||||||
class ConfigColorScheme extends PureComponent { |
class ConfigColorScheme extends PureComponent { |
||||||
constructor(props) { |
constructor(props) { |
||||||
super(props); |
super(props); |
||||||
this.state={ |
this.state = { |
||||||
color_scheme: window.config.color_scheme, |
color_scheme: window.config.color_scheme, |
||||||
}; |
}; |
||||||
} |
} |
||||||
|
|
||||||
save_changes() { |
save_changes() { |
||||||
this.props.callback({ |
this.props.callback({ |
||||||
color_scheme: this.state.color_scheme, |
color_scheme: this.state.color_scheme, |
||||||
}); |
}); |
||||||
} |
} |
||||||
|
|
||||||
on_select(e) { |
on_select(e) { |
||||||
let value=e.target.value; |
let value = e.target.value; |
||||||
this.setState({ |
this.setState( |
||||||
color_scheme: value, |
{ |
||||||
},this.save_changes.bind(this)); |
color_scheme: value, |
||||||
} |
}, |
||||||
|
this.save_changes.bind(this), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
render() { |
render() { |
||||||
return ( |
return ( |
||||||
<div> |
<div> |
||||||
<p> |
<p> |
||||||
<b>夜间模式:</b> |
<b>夜间模式:</b> |
||||||
<select value={this.state.color_scheme} onChange={this.on_select.bind(this)}> |
<select |
||||||
<option value="default">跟随系统</option> |
value={this.state.color_scheme} |
||||||
<option value="light">始终浅色模式</option> |
onChange={this.on_select.bind(this)} |
||||||
<option value="dark">始终深色模式</option> |
> |
||||||
</select> |
<option value="default">跟随系统</option> |
||||||
<small>#color_scheme</small> |
<option value="light">始终浅色模式</option> |
||||||
</p> |
<option value="dark">始终深色模式</option> |
||||||
<p> |
</select> |
||||||
选择浅色或深色模式,深色模式下将会调暗图片亮度 |
<small>#color_scheme</small> |
||||||
</p> |
</p> |
||||||
</div> |
<p>选择浅色或深色模式,深色模式下将会调暗图片亮度</p> |
||||||
) |
</div> |
||||||
} |
); |
||||||
|
} |
||||||
} |
} |
||||||
|
|
||||||
class ConfigSwitch extends PureComponent { |
class ConfigSwitch extends PureComponent { |
||||||
constructor(props) { |
constructor(props) { |
||||||
super(props); |
super(props); |
||||||
this.state={ |
this.state = { |
||||||
switch: window.config[this.props.id], |
switch: window.config[this.props.id], |
||||||
}; |
}; |
||||||
} |
} |
||||||
|
|
||||||
on_change(e) { |
on_change(e) { |
||||||
let val=e.target.checked; |
let val = e.target.checked; |
||||||
this.setState({ |
this.setState( |
||||||
switch: val, |
{ |
||||||
},()=>{ |
switch: val, |
||||||
this.props.callback({ |
}, |
||||||
[this.props.id]: val, |
() => { |
||||||
}); |
this.props.callback({ |
||||||
|
[this.props.id]: val, |
||||||
}); |
}); |
||||||
} |
}, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
render() { |
render() { |
||||||
return ( |
return ( |
||||||
<div> |
<div> |
||||||
<p> |
<p> |
||||||
<label> |
<label> |
||||||
<input name={'config-'+this.props.id} type="checkbox" checked={this.state.switch} onChange={this.on_change.bind(this)} /> |
<input |
||||||
<b>{this.props.name}</b> |
name={'config-' + this.props.id} |
||||||
<small>#{this.props.id}</small> |
type="checkbox" |
||||||
</label> |
checked={this.state.switch} |
||||||
</p> |
onChange={this.on_change.bind(this)} |
||||||
<p> |
/> |
||||||
{this.props.description} |
<b>{this.props.name}</b> |
||||||
</p> |
<small>#{this.props.id}</small> |
||||||
</div> |
</label> |
||||||
); |
</p> |
||||||
} |
<p>{this.props.description}</p> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
} |
} |
||||||
|
|
||||||
export class ConfigUI extends PureComponent { |
export class ConfigUI extends PureComponent { |
||||||
constructor(props) { |
constructor(props) { |
||||||
super(props); |
super(props); |
||||||
this.save_changes_bound=this.save_changes.bind(this); |
this.save_changes_bound = this.save_changes.bind(this); |
||||||
} |
} |
||||||
|
|
||||||
save_changes(chg) { |
save_changes(chg) { |
||||||
console.log(chg); |
console.log(chg); |
||||||
Object.keys(chg).forEach((key)=>{ |
Object.keys(chg).forEach((key) => { |
||||||
window.config[key]=chg[key]; |
window.config[key] = chg[key]; |
||||||
}); |
}); |
||||||
save_config(); |
save_config(); |
||||||
} |
} |
||||||
|
|
||||||
reset_settings() { |
reset_settings() { |
||||||
if(window.confirm('重置所有设置?')) { |
if (window.confirm('重置所有设置?')) { |
||||||
window.config={}; |
window.config = {}; |
||||||
save_config(); |
save_config(); |
||||||
window.location.reload(); |
window.location.reload(); |
||||||
} |
|
||||||
} |
} |
||||||
|
} |
||||||
|
|
||||||
render() { |
render() { |
||||||
return ( |
return ( |
||||||
<div> |
<div> |
||||||
<div className="box config-ui-header"> |
<div className="box config-ui-header"> |
||||||
<p>这些功能仍在测试,可能不稳定(<a onClick={this.reset_settings.bind(this)}>全部重置</a>)</p> |
<p> |
||||||
<p><b>修改设置后 <a onClick={()=>{window.location.reload()}}>刷新页面</a> 方可生效</b></p> |
这些功能仍在测试,可能不稳定( |
||||||
</div> |
<a onClick={this.reset_settings.bind(this)}>全部重置</a>) |
||||||
<div className="box"> |
</p> |
||||||
<ConfigBackground callback={this.save_changes_bound} /> |
<p> |
||||||
<hr /> |
<b> |
||||||
<ConfigColorScheme callback={this.save_changes_bound} /> |
修改设置后{' '} |
||||||
<hr /> |
<a |
||||||
<ConfigSwitch callback={this.save_changes_bound} id="pressure" name="快速返回" |
onClick={() => { |
||||||
description="短暂按住 Esc 键或重压屏幕(3D Touch)可以快速返回或者刷新树洞" |
window.location.reload(); |
||||||
/> |
}} |
||||||
<hr /> |
> |
||||||
<ConfigSwitch callback={this.save_changes_bound} id="easter_egg" name="允许彩蛋" |
刷新页面 |
||||||
description="在某些情况下显示彩蛋" |
</a>{' '} |
||||||
/> |
方可生效 |
||||||
<hr /> |
</b> |
||||||
<ConfigSwitch callback={this.save_changes_bound} id="fold" name="折叠树洞" |
</p> |
||||||
description="在时间线中折叠可能引起不适的树洞" |
</div> |
||||||
/> |
<div className="box"> |
||||||
<hr /> |
<ConfigBackground callback={this.save_changes_bound} /> |
||||||
<p> |
<hr /> |
||||||
新功能建议或问题反馈请在 |
<ConfigColorScheme callback={this.save_changes_bound} /> |
||||||
<a href="https://github.com/thuhole/thuhole-go-backend/issues" target="_blank">GitHub <span className="icon icon-github" /></a> |
<hr /> |
||||||
提出。 |
<ConfigSwitch |
||||||
</p> |
callback={this.save_changes_bound} |
||||||
</div> |
id="pressure" |
||||||
</div> |
name="快速返回" |
||||||
) |
description="短暂按住 Esc 键或重压屏幕(3D Touch)可以快速返回或者刷新树洞" |
||||||
} |
/> |
||||||
} |
<hr /> |
||||||
|
<ConfigSwitch |
||||||
|
callback={this.save_changes_bound} |
||||||
|
id="easter_egg" |
||||||
|
name="允许彩蛋" |
||||||
|
description="在某些情况下显示彩蛋" |
||||||
|
/> |
||||||
|
<hr /> |
||||||
|
<ConfigSwitch |
||||||
|
callback={this.save_changes_bound} |
||||||
|
id="fold" |
||||||
|
name="折叠树洞" |
||||||
|
description="在时间线中折叠可能引起不适的树洞" |
||||||
|
/> |
||||||
|
<hr /> |
||||||
|
<p> |
||||||
|
新功能建议或问题反馈请在 |
||||||
|
<a |
||||||
|
href="https://github.com/thuhole/thuhole-go-backend/issues" |
||||||
|
target="_blank" |
||||||
|
> |
||||||
|
GitHub <span className="icon icon-github" /> |
||||||
|
</a> |
||||||
|
提出。 |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
@ -1,29 +1,33 @@ |
|||||||
import MarkdownIt from 'markdown-it' |
import MarkdownIt from 'markdown-it'; |
||||||
import MarkdownItKaTeX from 'markdown-it-katex' |
import MarkdownItKaTeX from 'markdown-it-katex'; |
||||||
import hljs from 'highlight.js' |
import hljs from 'highlight.js'; |
||||||
import 'highlight.js/styles/atom-one-dark.css' |
import 'highlight.js/styles/atom-one-dark.css'; |
||||||
import './Markdown.css' |
import './Markdown.css'; |
||||||
|
|
||||||
import 'katex/dist/katex.min.css' |
import 'katex/dist/katex.min.css'; |
||||||
|
|
||||||
let md = new MarkdownIt({ |
let md = new MarkdownIt({ |
||||||
html: false, |
html: false, |
||||||
linkify: false, |
linkify: false, |
||||||
breaks: true, |
breaks: true, |
||||||
inline: true, |
inline: true, |
||||||
highlight (str, lang) { |
highlight(str, lang) { |
||||||
if (lang && hljs.getLanguage(lang)) { |
if (lang && hljs.getLanguage(lang)) { |
||||||
try { |
try { |
||||||
return '<pre class="hljs"><code>' + |
return ( |
||||||
hljs.highlight(lang, str, true).value + |
'<pre class="hljs"><code>' + |
||||||
'</code></pre>'; |
hljs.highlight(lang, str, true).value + |
||||||
|
'</code></pre>' |
||||||
|
); |
||||||
} catch (__) {} |
} catch (__) {} |
||||||
} |
} |
||||||
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>'; |
return ( |
||||||
} |
'<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>' |
||||||
|
); |
||||||
|
}, |
||||||
}).use(MarkdownItKaTeX, { |
}).use(MarkdownItKaTeX, { |
||||||
"throwOnError" : false, |
throwOnError: false, |
||||||
"errorColor" : "#aa0000" |
errorColor: '#aa0000', |
||||||
}) |
}); |
||||||
|
|
||||||
export default (text) => md.render(text) |
export default (text) => md.render(text); |
||||||
|
@ -1,65 +1,80 @@ |
|||||||
import React, {Component, PureComponent} from 'react'; |
import React, { Component, PureComponent } from 'react'; |
||||||
import {THUHOLE_API_ROOT, get_json, API_VERSION_PARAM} from './flows_api'; |
import { THUHOLE_API_ROOT, get_json, API_VERSION_PARAM } from './flows_api'; |
||||||
import {Time} from './Common'; |
import { Time } from './Common'; |
||||||
|
|
||||||
export class MessageViewer extends PureComponent { |
export class MessageViewer extends PureComponent { |
||||||
constructor(props) { |
constructor(props) { |
||||||
super(props); |
super(props); |
||||||
this.state={ |
this.state = { |
||||||
loading_status: 'idle', |
loading_status: 'idle', |
||||||
msg: [], |
msg: [], |
||||||
}; |
}; |
||||||
} |
} |
||||||
|
|
||||||
componentDidMount() { |
componentDidMount() { |
||||||
this.load(); |
this.load(); |
||||||
} |
} |
||||||
|
|
||||||
load() { |
load() { |
||||||
if(this.state.loading_status==='loading') return; |
if (this.state.loading_status === 'loading') return; |
||||||
this.setState({ |
this.setState( |
||||||
loading_status: 'loading', |
{ |
||||||
},()=>{ |
loading_status: 'loading', |
||||||
fetch(THUHOLE_API_ROOT+'api_xmcp/hole/system_msg?user_token='+encodeURIComponent(this.props.token)+API_VERSION_PARAM()) |
}, |
||||||
.then(get_json) |
() => { |
||||||
.then((json)=>{ |
fetch( |
||||||
if(json.error) |
THUHOLE_API_ROOT + |
||||||
throw new Error(json.error); |
'api_xmcp/hole/system_msg?user_token=' + |
||||||
else |
encodeURIComponent(this.props.token) + |
||||||
this.setState({ |
API_VERSION_PARAM(), |
||||||
loading_status: 'done', |
) |
||||||
msg: json.result, |
.then(get_json) |
||||||
}); |
.then((json) => { |
||||||
}) |
if (json.error) throw new Error(json.error); |
||||||
.catch((err)=>{ |
else |
||||||
console.error(err); |
this.setState({ |
||||||
alert(''+err); |
loading_status: 'done', |
||||||
this.setState({ |
msg: json.result, |
||||||
loading_status: 'failed', |
}); |
||||||
}); |
}) |
||||||
}) |
.catch((err) => { |
||||||
|
console.error(err); |
||||||
|
alert('' + err); |
||||||
|
this.setState({ |
||||||
|
loading_status: 'failed', |
||||||
|
}); |
||||||
|
}); |
||||||
|
}, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
}); |
render() { |
||||||
} |
if (this.state.loading_status === 'loading') |
||||||
|
return <p className="box box-tip">加载中……</p>; |
||||||
render() { |
else if (this.state.loading_status === 'failed') |
||||||
if(this.state.loading_status==='loading') |
return ( |
||||||
return (<p className="box box-tip">加载中……</p>); |
<div className="box box-tip"> |
||||||
else if(this.state.loading_status==='failed') |
<a |
||||||
return (<div className="box box-tip"><a onClick={()=>{this.load()}}>重新加载</a></div>); |
onClick={() => { |
||||||
else if(this.state.loading_status==='done') |
this.load(); |
||||||
return this.state.msg.map((msg)=>( |
}} |
||||||
<div className="box"> |
> |
||||||
<div className="box-header"> |
重新加载 |
||||||
<Time stamp={msg.timestamp} short={false} /> |
</a> |
||||||
<b>{msg.title}</b> |
</div> |
||||||
</div> |
); |
||||||
<div className="box-content"> |
else if (this.state.loading_status === 'done') |
||||||
<pre>{msg.content}</pre> |
return this.state.msg.map((msg) => ( |
||||||
</div> |
<div className="box"> |
||||||
</div> |
<div className="box-header"> |
||||||
)); |
<Time stamp={msg.timestamp} short={false} /> |
||||||
else |
<b>{msg.title}</b> |
||||||
return null; |
</div> |
||||||
} |
<div className="box-content"> |
||||||
} |
<pre>{msg.content}</pre> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)); |
||||||
|
else return null; |
||||||
|
} |
||||||
|
} |
||||||
|
@ -1,113 +1,120 @@ |
|||||||
import React, {Component} from 'react'; |
import React, { Component } from 'react'; |
||||||
import Pressure from 'pressure'; |
import Pressure from 'pressure'; |
||||||
|
|
||||||
import './PressureHelper.css'; |
import './PressureHelper.css'; |
||||||
|
|
||||||
const THRESHOLD=.4; |
const THRESHOLD = 0.4; |
||||||
const MULTIPLIER=25; |
const MULTIPLIER = 25; |
||||||
const BORDER_WIDTH=500; // also change css!
|
const BORDER_WIDTH = 500; // also change css!
|
||||||
|
|
||||||
export class PressureHelper extends Component { |
export class PressureHelper extends Component { |
||||||
constructor(props) { |
constructor(props) { |
||||||
super(props); |
super(props); |
||||||
this.state={ |
this.state = { |
||||||
level: 0, |
level: 0, |
||||||
fired: false, |
fired: false, |
||||||
}; |
}; |
||||||
this.callback=props.callback; |
this.callback = props.callback; |
||||||
this.esc_interval=null; |
this.esc_interval = null; |
||||||
} |
} |
||||||
|
|
||||||
do_fire() { |
do_fire() { |
||||||
if(this.esc_interval) { |
if (this.esc_interval) { |
||||||
clearInterval(this.esc_interval); |
clearInterval(this.esc_interval); |
||||||
this.esc_interval=null; |
this.esc_interval = null; |
||||||
} |
|
||||||
this.setState({ |
|
||||||
level: 1, |
|
||||||
fired: true, |
|
||||||
}); |
|
||||||
this.callback(); |
|
||||||
window.setTimeout(()=>{ |
|
||||||
this.setState({ |
|
||||||
level: 0, |
|
||||||
fired: false, |
|
||||||
}); |
|
||||||
},300); |
|
||||||
} |
} |
||||||
|
this.setState({ |
||||||
|
level: 1, |
||||||
|
fired: true, |
||||||
|
}); |
||||||
|
this.callback(); |
||||||
|
window.setTimeout(() => { |
||||||
|
this.setState({ |
||||||
|
level: 0, |
||||||
|
fired: false, |
||||||
|
}); |
||||||
|
}, 300); |
||||||
|
} |
||||||
|
|
||||||
componentDidMount() { |
componentDidMount() { |
||||||
if(window.config.pressure) { |
if (window.config.pressure) { |
||||||
Pressure.set(document.body, { |
Pressure.set( |
||||||
change: (force)=>{ |
document.body, |
||||||
if(!this.state.fired) { |
{ |
||||||
if(force>=.999) { |
change: (force) => { |
||||||
this.do_fire(); |
if (!this.state.fired) { |
||||||
} |
if (force >= 0.999) { |
||||||
else |
this.do_fire(); |
||||||
this.setState({ |
} else |
||||||
level: force, |
this.setState({ |
||||||
}); |
level: force, |
||||||
} |
}); |
||||||
}, |
} |
||||||
end: ()=>{ |
}, |
||||||
this.setState({ |
end: () => { |
||||||
level: 0, |
this.setState({ |
||||||
fired: false, |
level: 0, |
||||||
}); |
fired: false, |
||||||
}, |
|
||||||
}, { |
|
||||||
polyfill: false, |
|
||||||
only: 'touch', |
|
||||||
preventSelect: false, |
|
||||||
}); |
}); |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
polyfill: false, |
||||||
|
only: 'touch', |
||||||
|
preventSelect: false, |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
document.addEventListener('keydown',(e)=>{ |
document.addEventListener('keydown', (e) => { |
||||||
if(!e.repeat && e.key==='Escape') { |
if (!e.repeat && e.key === 'Escape') { |
||||||
if(this.esc_interval) |
if (this.esc_interval) clearInterval(this.esc_interval); |
||||||
clearInterval(this.esc_interval); |
this.setState( |
||||||
this.setState({ |
{ |
||||||
level: THRESHOLD/2, |
level: THRESHOLD / 2, |
||||||
},()=>{ |
}, |
||||||
this.esc_interval=setInterval(()=>{ |
() => { |
||||||
let new_level=this.state.level+.1; |
this.esc_interval = setInterval(() => { |
||||||
if(new_level>=.999) |
let new_level = this.state.level + 0.1; |
||||||
this.do_fire(); |
if (new_level >= 0.999) this.do_fire(); |
||||||
else |
else |
||||||
this.setState({ |
this.setState({ |
||||||
level: new_level, |
level: new_level, |
||||||
}); |
}); |
||||||
},30); |
}, 30); |
||||||
}); |
}, |
||||||
} |
); |
||||||
}); |
|
||||||
document.addEventListener('keyup',(e)=>{ |
|
||||||
if(e.key==='Escape') { |
|
||||||
if(this.esc_interval) { |
|
||||||
clearInterval(this.esc_interval); |
|
||||||
this.esc_interval=null; |
|
||||||
} |
|
||||||
this.setState({ |
|
||||||
level: 0, |
|
||||||
}); |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
} |
||||||
|
}); |
||||||
|
document.addEventListener('keyup', (e) => { |
||||||
|
if (e.key === 'Escape') { |
||||||
|
if (this.esc_interval) { |
||||||
|
clearInterval(this.esc_interval); |
||||||
|
this.esc_interval = null; |
||||||
|
} |
||||||
|
this.setState({ |
||||||
|
level: 0, |
||||||
|
}); |
||||||
|
} |
||||||
|
}); |
||||||
} |
} |
||||||
|
} |
||||||
|
|
||||||
render() { |
render() { |
||||||
const pad=MULTIPLIER*(this.state.level-THRESHOLD)-BORDER_WIDTH; |
const pad = MULTIPLIER * (this.state.level - THRESHOLD) - BORDER_WIDTH; |
||||||
return ( |
return ( |
||||||
<div className={ |
<div |
||||||
'pressure-box' |
className={ |
||||||
+(this.state.fired ? ' pressure-box-fired' : '') |
'pressure-box' + |
||||||
+(this.state.level<=.0001 ? ' pressure-box-empty' : '') |
(this.state.fired ? ' pressure-box-fired' : '') + |
||||||
} style={{ |
(this.state.level <= 0.0001 ? ' pressure-box-empty' : '') |
||||||
left: pad, |
} |
||||||
right: pad, |
style={{ |
||||||
top: pad, |
left: pad, |
||||||
bottom: pad, |
right: pad, |
||||||
}} /> |
top: pad, |
||||||
) |
bottom: pad, |
||||||
} |
}} |
||||||
} |
/> |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
@ -1,45 +1,66 @@ |
|||||||
import React, {Component, PureComponent} from 'react'; |
import React, { Component, PureComponent } from 'react'; |
||||||
import './Sidebar.css'; |
import './Sidebar.css'; |
||||||
|
|
||||||
export class Sidebar extends PureComponent { |
export class Sidebar extends PureComponent { |
||||||
constructor(props) { |
constructor(props) { |
||||||
super(props); |
super(props); |
||||||
this.sidebar_ref=React.createRef(); |
this.sidebar_ref = React.createRef(); |
||||||
this.do_close_bound=this.do_close.bind(this); |
this.do_close_bound = this.do_close.bind(this); |
||||||
this.do_back_bound=this.do_back.bind(this); |
this.do_back_bound = this.do_back.bind(this); |
||||||
} |
} |
||||||
|
|
||||||
componentDidUpdate(nextProps) { |
componentDidUpdate(nextProps) { |
||||||
if(this.props.stack!==nextProps.stack) { |
if (this.props.stack !== nextProps.stack) { |
||||||
//console.log('sidebar top');
|
//console.log('sidebar top');
|
||||||
if(this.sidebar_ref.current) |
if (this.sidebar_ref.current) this.sidebar_ref.current.scrollTop = 0; |
||||||
this.sidebar_ref.current.scrollTop=0; |
|
||||||
} |
|
||||||
} |
} |
||||||
|
} |
||||||
|
|
||||||
do_close() { |
do_close() { |
||||||
this.props.show_sidebar(null,null,'clear'); |
this.props.show_sidebar(null, null, 'clear'); |
||||||
} |
} |
||||||
do_back() { |
do_back() { |
||||||
this.props.show_sidebar(null,null,'pop'); |
this.props.show_sidebar(null, null, 'pop'); |
||||||
} |
} |
||||||
|
|
||||||
render() { |
render() { |
||||||
let [cur_title,cur_content]=this.props.stack[this.props.stack.length-1]; |
let [cur_title, cur_content] = this.props.stack[ |
||||||
return ( |
this.props.stack.length - 1 |
||||||
<div className={'sidebar-container '+(cur_title!==null ? 'sidebar-on' : 'sidebar-off')}> |
]; |
||||||
<div className="sidebar-shadow" onClick={this.do_back_bound} onTouchEnd={(e)=>{e.preventDefault();e.target.click();}} /> |
return ( |
||||||
<div ref={this.sidebar_ref} className="sidebar"> |
<div |
||||||
{cur_content} |
className={ |
||||||
</div> |
'sidebar-container ' + |
||||||
<div className="sidebar-title"> |
(cur_title !== null ? 'sidebar-on' : 'sidebar-off') |
||||||
<a className="no-underline" onClick={this.do_close_bound}> <span className="icon icon-close" /> </a> |
} |
||||||
{this.props.stack.length>2 && |
> |
||||||
<a className="no-underline" onClick={this.do_back_bound}> <span className="icon icon-back" /> </a> |
<div |
||||||
} |
className="sidebar-shadow" |
||||||
{cur_title} |
onClick={this.do_back_bound} |
||||||
</div> |
onTouchEnd={(e) => { |
||||||
</div> |
e.preventDefault(); |
||||||
); |
e.target.click(); |
||||||
} |
}} |
||||||
} |
/> |
||||||
|
<div ref={this.sidebar_ref} className="sidebar"> |
||||||
|
{cur_content} |
||||||
|
</div> |
||||||
|
<div className="sidebar-title"> |
||||||
|
<a className="no-underline" onClick={this.do_close_bound}> |
||||||
|
|
||||||
|
<span className="icon icon-close" /> |
||||||
|
|
||||||
|
</a> |
||||||
|
{this.props.stack.length > 2 && ( |
||||||
|
<a className="no-underline" onClick={this.do_back_bound}> |
||||||
|
|
||||||
|
<span className="icon icon-back" /> |
||||||
|
|
||||||
|
</a> |
||||||
|
)} |
||||||
|
{cur_title} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
@ -1,143 +1,186 @@ |
|||||||
import React, {Component, PureComponent} from 'react'; |
import React, { Component, PureComponent } from 'react'; |
||||||
// import {AppSwitcher} from './infrastructure/widgets';
|
// import {AppSwitcher} from './infrastructure/widgets';
|
||||||
import {InfoSidebar, PostForm} from './UserAction'; |
import { InfoSidebar, PostForm } from './UserAction'; |
||||||
import {TokenCtx} from './UserAction'; |
import { TokenCtx } from './UserAction'; |
||||||
|
|
||||||
import './Title.css'; |
import './Title.css'; |
||||||
|
|
||||||
const flag_re=/^\/\/setflag ([a-zA-Z0-9_]+)=(.*)$/; |
const flag_re = /^\/\/setflag ([a-zA-Z0-9_]+)=(.*)$/; |
||||||
|
|
||||||
class ControlBar extends PureComponent { |
class ControlBar extends PureComponent { |
||||||
constructor(props) { |
constructor(props) { |
||||||
super(props); |
super(props); |
||||||
this.state={ |
this.state = { |
||||||
search_text: '', |
search_text: '', |
||||||
}; |
}; |
||||||
this.set_mode=props.set_mode; |
this.set_mode = props.set_mode; |
||||||
|
|
||||||
this.on_change_bound=this.on_change.bind(this); |
this.on_change_bound = this.on_change.bind(this); |
||||||
this.on_keypress_bound=this.on_keypress.bind(this); |
this.on_keypress_bound = this.on_keypress.bind(this); |
||||||
this.do_refresh_bound=this.do_refresh.bind(this); |
this.do_refresh_bound = this.do_refresh.bind(this); |
||||||
this.do_attention_bound=this.do_attention.bind(this); |
this.do_attention_bound = this.do_attention.bind(this); |
||||||
} |
} |
||||||
|
|
||||||
componentDidMount() { |
|
||||||
if(window.location.hash) { |
|
||||||
let text=decodeURIComponent(window.location.hash).substr(1); |
|
||||||
if(text.lastIndexOf('?')!==-1) |
|
||||||
text=text.substr(0,text.lastIndexOf('?')); // fuck wechat '#param?nsukey=...'
|
|
||||||
this.setState({ |
|
||||||
search_text: text, |
|
||||||
}, ()=>{ |
|
||||||
this.on_keypress({key: 'Enter'}); |
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
on_change(event) { |
componentDidMount() { |
||||||
this.setState({ |
if (window.location.hash) { |
||||||
search_text: event.target.value, |
let text = decodeURIComponent(window.location.hash).substr(1); |
||||||
}); |
if (text.lastIndexOf('?') !== -1) |
||||||
|
text = text.substr(0, text.lastIndexOf('?')); // fuck wechat '#param?nsukey=...'
|
||||||
|
this.setState( |
||||||
|
{ |
||||||
|
search_text: text, |
||||||
|
}, |
||||||
|
() => { |
||||||
|
this.on_keypress({ key: 'Enter' }); |
||||||
|
}, |
||||||
|
); |
||||||
} |
} |
||||||
|
} |
||||||
|
|
||||||
on_keypress(event) { |
on_change(event) { |
||||||
if(event.key==='Enter') { |
this.setState({ |
||||||
let flag_res=flag_re.exec(this.state.search_text); |
search_text: event.target.value, |
||||||
if(flag_res) { |
}); |
||||||
if(flag_res[2]) { |
} |
||||||
localStorage[flag_res[1]]=flag_res[2]; |
|
||||||
alert('Set Flag '+flag_res[1]+'='+flag_res[2]+'\nYou may need to refresh this webpage.'); |
|
||||||
} else { |
|
||||||
delete localStorage[flag_res[1]]; |
|
||||||
alert('Clear Flag '+flag_res[1]+'\nYou may need to refresh this webpage.'); |
|
||||||
} |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
const mode=this.state.search_text.startsWith('#') ? 'single' : 'search'; |
on_keypress(event) { |
||||||
this.set_mode(mode,this.state.search_text||''); |
if (event.key === 'Enter') { |
||||||
|
let flag_res = flag_re.exec(this.state.search_text); |
||||||
|
if (flag_res) { |
||||||
|
if (flag_res[2]) { |
||||||
|
localStorage[flag_res[1]] = flag_res[2]; |
||||||
|
alert( |
||||||
|
'Set Flag ' + |
||||||
|
flag_res[1] + |
||||||
|
'=' + |
||||||
|
flag_res[2] + |
||||||
|
'\nYou may need to refresh this webpage.', |
||||||
|
); |
||||||
|
} else { |
||||||
|
delete localStorage[flag_res[1]]; |
||||||
|
alert( |
||||||
|
'Clear Flag ' + |
||||||
|
flag_res[1] + |
||||||
|
'\nYou may need to refresh this webpage.', |
||||||
|
); |
||||||
} |
} |
||||||
} |
return; |
||||||
|
} |
||||||
|
|
||||||
do_refresh() { |
const mode = this.state.search_text.startsWith('#') ? 'single' : 'search'; |
||||||
window.scrollTo(0,0); |
this.set_mode(mode, this.state.search_text || ''); |
||||||
this.setState({ |
|
||||||
search_text: '', |
|
||||||
}); |
|
||||||
this.set_mode('list',null); |
|
||||||
} |
} |
||||||
|
} |
||||||
|
|
||||||
do_attention() { |
do_refresh() { |
||||||
window.scrollTo(0,0); |
window.scrollTo(0, 0); |
||||||
this.setState({ |
this.setState({ |
||||||
search_text: '', |
search_text: '', |
||||||
}); |
}); |
||||||
this.set_mode('attention',null); |
this.set_mode('list', null); |
||||||
} |
} |
||||||
|
|
||||||
render() { |
do_attention() { |
||||||
return ( |
window.scrollTo(0, 0); |
||||||
<TokenCtx.Consumer>{({value: token})=>( |
this.setState({ |
||||||
<div className="control-bar"> |
search_text: '', |
||||||
<a className="no-underline control-btn" onClick={this.do_refresh_bound}> |
}); |
||||||
<span className="icon icon-refresh" /> |
this.set_mode('attention', null); |
||||||
<span className="control-btn-label">最新</span> |
} |
||||||
</a> |
|
||||||
{!!token && |
render() { |
||||||
<a className="no-underline control-btn" onClick={this.do_attention_bound}> |
return ( |
||||||
<span className="icon icon-attention" /> |
<TokenCtx.Consumer> |
||||||
<span className="control-btn-label">关注</span> |
{({ value: token }) => ( |
||||||
</a> |
<div className="control-bar"> |
||||||
} |
<a |
||||||
<input className="control-search" value={this.state.search_text} placeholder="搜索 或 #树洞号" |
className="no-underline control-btn" |
||||||
onChange={this.on_change_bound} onKeyPress={this.on_keypress_bound} |
onClick={this.do_refresh_bound} |
||||||
/> |
> |
||||||
<a className="no-underline control-btn" onClick={()=>{ |
<span className="icon icon-refresh" /> |
||||||
this.props.show_sidebar( |
<span className="control-btn-label">最新</span> |
||||||
'T大树洞', |
</a> |
||||||
<InfoSidebar show_sidebar={this.props.show_sidebar} /> |
{!!token && ( |
||||||
) |
<a |
||||||
}}> |
className="no-underline control-btn" |
||||||
<span className={'icon icon-'+(token ? 'about' : 'login')} /> |
onClick={this.do_attention_bound} |
||||||
<span className="control-btn-label">{token ? '账户' : '登录'}</span> |
> |
||||||
</a> |
<span className="icon icon-attention" /> |
||||||
{!!token && |
<span className="control-btn-label">关注</span> |
||||||
<a className="no-underline control-btn" onClick={()=>{ |
</a> |
||||||
this.props.show_sidebar( |
)} |
||||||
'发表树洞', |
<input |
||||||
<PostForm token={token} on_complete={()=>{ |
className="control-search" |
||||||
this.props.show_sidebar(null,null); |
value={this.state.search_text} |
||||||
this.do_refresh(); |
placeholder="搜索 或 #树洞号" |
||||||
}} /> |
onChange={this.on_change_bound} |
||||||
) |
onKeyPress={this.on_keypress_bound} |
||||||
}}> |
/> |
||||||
<span className="icon icon-plus" /> |
<a |
||||||
<span className="control-btn-label">发表</span> |
className="no-underline control-btn" |
||||||
</a> |
onClick={() => { |
||||||
} |
this.props.show_sidebar( |
||||||
</div> |
'T大树洞', |
||||||
)}</TokenCtx.Consumer> |
<InfoSidebar show_sidebar={this.props.show_sidebar} />, |
||||||
) |
); |
||||||
} |
}} |
||||||
|
> |
||||||
|
<span className={'icon icon-' + (token ? 'about' : 'login')} /> |
||||||
|
<span className="control-btn-label"> |
||||||
|
{token ? '账户' : '登录'} |
||||||
|
</span> |
||||||
|
</a> |
||||||
|
{!!token && ( |
||||||
|
<a |
||||||
|
className="no-underline control-btn" |
||||||
|
onClick={() => { |
||||||
|
this.props.show_sidebar( |
||||||
|
'发表树洞', |
||||||
|
<PostForm |
||||||
|
token={token} |
||||||
|
on_complete={() => { |
||||||
|
this.props.show_sidebar(null, null); |
||||||
|
this.do_refresh(); |
||||||
|
}} |
||||||
|
/>, |
||||||
|
); |
||||||
|
}} |
||||||
|
> |
||||||
|
<span className="icon icon-plus" /> |
||||||
|
<span className="control-btn-label">发表</span> |
||||||
|
</a> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</TokenCtx.Consumer> |
||||||
|
); |
||||||
|
} |
||||||
} |
} |
||||||
|
|
||||||
export function Title(props) { |
export function Title(props) { |
||||||
return ( |
return ( |
||||||
<div className="title-bar"> |
<div className="title-bar"> |
||||||
{/* <AppSwitcher appid="hole" /> */} |
{/* <AppSwitcher appid="hole" /> */} |
||||||
<div className="aux-margin"> |
<div className="aux-margin"> |
||||||
<div className="title"> |
<div className="title"> |
||||||
<p className="centered-line"> |
<p className="centered-line"> |
||||||
<span onClick={()=>props.show_sidebar( |
<span |
||||||
'T大树洞', |
onClick={() => |
||||||
<InfoSidebar show_sidebar={props.show_sidebar} /> |
props.show_sidebar( |
||||||
)}> |
'T大树洞', |
||||||
T大树洞 |
<InfoSidebar show_sidebar={props.show_sidebar} />, |
||||||
</span> |
) |
||||||
</p> |
} |
||||||
</div> |
> |
||||||
<ControlBar show_sidebar={props.show_sidebar} set_mode={props.set_mode} /> |
T大树洞 |
||||||
</div> |
</span> |
||||||
|
</p> |
||||||
</div> |
</div> |
||||||
) |
<ControlBar |
||||||
} |
show_sidebar={props.show_sidebar} |
||||||
|
set_mode={props.set_mode} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
@ -1,172 +1,173 @@ |
|||||||
const HOLE_CACHE_DB_NAME='hole_cache_db'; |
const HOLE_CACHE_DB_NAME = 'hole_cache_db'; |
||||||
const CACHE_DB_VER=1; |
const CACHE_DB_VER = 1; |
||||||
const MAINTENANCE_STEP=150; |
const MAINTENANCE_STEP = 150; |
||||||
const MAINTENANCE_COUNT=1000; |
const MAINTENANCE_COUNT = 1000; |
||||||
|
|
||||||
const ENC_KEY=42; |
const ENC_KEY = 42; |
||||||
|
|
||||||
class Cache { |
class Cache { |
||||||
constructor() { |
constructor() { |
||||||
this.db=null; |
this.db = null; |
||||||
this.added_items_since_maintenance=0; |
this.added_items_since_maintenance = 0; |
||||||
this.encrypt=this.encrypt.bind(this); |
this.encrypt = this.encrypt.bind(this); |
||||||
this.decrypt=this.decrypt.bind(this); |
this.decrypt = this.decrypt.bind(this); |
||||||
const open_req=indexedDB.open(HOLE_CACHE_DB_NAME,CACHE_DB_VER); |
const open_req = indexedDB.open(HOLE_CACHE_DB_NAME, CACHE_DB_VER); |
||||||
open_req.onerror=console.error.bind(console); |
open_req.onerror = console.error.bind(console); |
||||||
open_req.onupgradeneeded=(event)=>{ |
open_req.onupgradeneeded = (event) => { |
||||||
console.log('comment cache db upgrade'); |
console.log('comment cache db upgrade'); |
||||||
const db=event.target.result; |
const db = event.target.result; |
||||||
const store=db.createObjectStore('comment',{ |
const store = db.createObjectStore('comment', { |
||||||
keyPath: 'pid', |
keyPath: 'pid', |
||||||
}); |
}); |
||||||
store.createIndex('last_access','last_access',{unique: false}); |
store.createIndex('last_access', 'last_access', { unique: false }); |
||||||
}; |
}; |
||||||
open_req.onsuccess=(event)=>{ |
open_req.onsuccess = (event) => { |
||||||
console.log('comment cache db loaded'); |
console.log('comment cache db loaded'); |
||||||
this.db=event.target.result; |
this.db = event.target.result; |
||||||
setTimeout(this.maintenance.bind(this),1); |
setTimeout(this.maintenance.bind(this), 1); |
||||||
}; |
}; |
||||||
} |
} |
||||||
|
|
||||||
// use window.hole_cache.encrypt() only after cache is loaded!
|
// use window.hole_cache.encrypt() only after cache is loaded!
|
||||||
encrypt(pid,data) { |
encrypt(pid, data) { |
||||||
let s=JSON.stringify(data); |
let s = JSON.stringify(data); |
||||||
let o=''; |
let o = ''; |
||||||
for(let i=0,key=(ENC_KEY^pid)%128;i<s.length;i++) { |
for (let i = 0, key = (ENC_KEY ^ pid) % 128; i < s.length; i++) { |
||||||
let c=s.charCodeAt(i); |
let c = s.charCodeAt(i); |
||||||
let new_key=(key^(c/2))%128; |
let new_key = (key ^ (c / 2)) % 128; |
||||||
o+=String.fromCharCode(key^s.charCodeAt(i)); |
o += String.fromCharCode(key ^ s.charCodeAt(i)); |
||||||
key=new_key; |
key = new_key; |
||||||
} |
|
||||||
return o; |
|
||||||
} |
} |
||||||
|
return o; |
||||||
|
} |
||||||
|
|
||||||
// use window.hole_cache.decrypt() only after cache is loaded!
|
// use window.hole_cache.decrypt() only after cache is loaded!
|
||||||
decrypt(pid,s) { |
decrypt(pid, s) { |
||||||
let o=''; |
let o = ''; |
||||||
if(typeof(s)!==typeof('str')) |
if (typeof s !== typeof 'str') return null; |
||||||
return null; |
|
||||||
|
|
||||||
for(let i=0,key=(ENC_KEY^pid)%128;i<s.length;i++) { |
for (let i = 0, key = (ENC_KEY ^ pid) % 128; i < s.length; i++) { |
||||||
let c=key^s.charCodeAt(i); |
let c = key ^ s.charCodeAt(i); |
||||||
o+=String.fromCharCode(c); |
o += String.fromCharCode(c); |
||||||
key=(key^(c/2))%128; |
key = (key ^ (c / 2)) % 128; |
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
return JSON.parse(o); |
|
||||||
} catch(e) { |
|
||||||
console.error('decrypt failed'); |
|
||||||
console.trace(e); |
|
||||||
return null; |
|
||||||
} |
|
||||||
} |
} |
||||||
|
|
||||||
get(pid,target_version) { |
try { |
||||||
pid=parseInt(pid); |
return JSON.parse(o); |
||||||
return new Promise((resolve,reject)=>{ |
} catch (e) { |
||||||
if(!this.db) |
console.error('decrypt failed'); |
||||||
return resolve(null); |
console.trace(e); |
||||||
const tx=this.db.transaction(['comment'],'readwrite'); |
return null; |
||||||
const store=tx.objectStore('comment'); |
|
||||||
const get_req=store.get(pid); |
|
||||||
get_req.onsuccess=()=>{ |
|
||||||
let res=get_req.result; |
|
||||||
if(!res || !res.data_str) { |
|
||||||
//console.log('comment cache miss '+pid);
|
|
||||||
resolve(null); |
|
||||||
} else if(target_version===res.version) { // hit
|
|
||||||
console.log('comment cache hit',pid); |
|
||||||
res.last_access=(+new Date()); |
|
||||||
store.put(res); |
|
||||||
let data=this.decrypt(pid,res.data_str); |
|
||||||
resolve(data); // obj or null
|
|
||||||
} else { // expired
|
|
||||||
console.log('comment cache expired',pid,': ver',res.version,'target',target_version); |
|
||||||
store.delete(pid); |
|
||||||
resolve(null); |
|
||||||
} |
|
||||||
}; |
|
||||||
get_req.onerror=(e)=>{ |
|
||||||
console.warn('comment cache indexeddb open failed'); |
|
||||||
console.error(e); |
|
||||||
resolve(null); |
|
||||||
}; |
|
||||||
}); |
|
||||||
} |
} |
||||||
|
} |
||||||
|
|
||||||
put(pid,target_version,data) { |
get(pid, target_version) { |
||||||
pid=parseInt(pid); |
pid = parseInt(pid); |
||||||
return new Promise((resolve,reject)=>{ |
return new Promise((resolve, reject) => { |
||||||
if(!this.db) |
if (!this.db) return resolve(null); |
||||||
return resolve(); |
const tx = this.db.transaction(['comment'], 'readwrite'); |
||||||
const tx=this.db.transaction(['comment'],'readwrite'); |
const store = tx.objectStore('comment'); |
||||||
const store=tx.objectStore('comment'); |
const get_req = store.get(pid); |
||||||
store.put({ |
get_req.onsuccess = () => { |
||||||
pid: pid, |
let res = get_req.result; |
||||||
version: target_version, |
if (!res || !res.data_str) { |
||||||
data_str: this.encrypt(pid,data), |
//console.log('comment cache miss '+pid);
|
||||||
last_access: +new Date(), |
resolve(null); |
||||||
}); |
} else if (target_version === res.version) { |
||||||
if(++this.added_items_since_maintenance===MAINTENANCE_STEP) |
// hit
|
||||||
setTimeout(this.maintenance.bind(this),1); |
console.log('comment cache hit', pid); |
||||||
}); |
res.last_access = +new Date(); |
||||||
} |
store.put(res); |
||||||
|
let data = this.decrypt(pid, res.data_str); |
||||||
|
resolve(data); // obj or null
|
||||||
|
} else { |
||||||
|
// expired
|
||||||
|
console.log( |
||||||
|
'comment cache expired', |
||||||
|
pid, |
||||||
|
': ver', |
||||||
|
res.version, |
||||||
|
'target', |
||||||
|
target_version, |
||||||
|
); |
||||||
|
store.delete(pid); |
||||||
|
resolve(null); |
||||||
|
} |
||||||
|
}; |
||||||
|
get_req.onerror = (e) => { |
||||||
|
console.warn('comment cache indexeddb open failed'); |
||||||
|
console.error(e); |
||||||
|
resolve(null); |
||||||
|
}; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
delete(pid) { |
put(pid, target_version, data) { |
||||||
pid=parseInt(pid); |
pid = parseInt(pid); |
||||||
return new Promise((resolve,reject)=>{ |
return new Promise((resolve, reject) => { |
||||||
if(!this.db) |
if (!this.db) return resolve(); |
||||||
return resolve(); |
const tx = this.db.transaction(['comment'], 'readwrite'); |
||||||
const tx=this.db.transaction(['comment'],'readwrite'); |
const store = tx.objectStore('comment'); |
||||||
const store=tx.objectStore('comment'); |
store.put({ |
||||||
let req=store.delete(pid); |
pid: pid, |
||||||
//console.log('comment cache delete',pid);
|
version: target_version, |
||||||
req.onerror=()=>{ |
data_str: this.encrypt(pid, data), |
||||||
console.warn('comment cache delete failed ',pid); |
last_access: +new Date(), |
||||||
return resolve(); |
}); |
||||||
}; |
if (++this.added_items_since_maintenance === MAINTENANCE_STEP) |
||||||
req.onsuccess=()=>resolve(); |
setTimeout(this.maintenance.bind(this), 1); |
||||||
}); |
}); |
||||||
} |
} |
||||||
|
|
||||||
maintenance() { |
delete(pid) { |
||||||
if(!this.db) |
pid = parseInt(pid); |
||||||
return; |
return new Promise((resolve, reject) => { |
||||||
const tx=this.db.transaction(['comment'],'readwrite'); |
if (!this.db) return resolve(); |
||||||
const store=tx.objectStore('comment'); |
const tx = this.db.transaction(['comment'], 'readwrite'); |
||||||
let count_req=store.count(); |
const store = tx.objectStore('comment'); |
||||||
count_req.onsuccess=()=>{ |
let req = store.delete(pid); |
||||||
let count=count_req.result; |
//console.log('comment cache delete',pid);
|
||||||
if(count>MAINTENANCE_COUNT) { |
req.onerror = () => { |
||||||
console.log('comment cache db maintenance',count); |
console.warn('comment cache delete failed ', pid); |
||||||
store.index('last_access').openKeyCursor().onsuccess=(e)=>{ |
return resolve(); |
||||||
let cur=e.target.result; |
}; |
||||||
if(cur) { |
req.onsuccess = () => resolve(); |
||||||
//console.log('maintenance: delete',cur);
|
}); |
||||||
store.delete(cur.primaryKey); |
} |
||||||
if(--count>MAINTENANCE_COUNT) |
|
||||||
cur.continue(); |
maintenance() { |
||||||
} |
if (!this.db) return; |
||||||
}; |
const tx = this.db.transaction(['comment'], 'readwrite'); |
||||||
} else { |
const store = tx.objectStore('comment'); |
||||||
console.log('comment cache db no need to maintenance',count); |
let count_req = store.count(); |
||||||
} |
count_req.onsuccess = () => { |
||||||
this.added_items_since_maintenance=0; |
let count = count_req.result; |
||||||
|
if (count > MAINTENANCE_COUNT) { |
||||||
|
console.log('comment cache db maintenance', count); |
||||||
|
store.index('last_access').openKeyCursor().onsuccess = (e) => { |
||||||
|
let cur = e.target.result; |
||||||
|
if (cur) { |
||||||
|
//console.log('maintenance: delete',cur);
|
||||||
|
store.delete(cur.primaryKey); |
||||||
|
if (--count > MAINTENANCE_COUNT) cur.continue(); |
||||||
|
} |
||||||
}; |
}; |
||||||
count_req.onerror=console.error.bind(console); |
} else { |
||||||
} |
console.log('comment cache db no need to maintenance', count); |
||||||
|
} |
||||||
|
this.added_items_since_maintenance = 0; |
||||||
|
}; |
||||||
|
count_req.onerror = console.error.bind(console); |
||||||
|
} |
||||||
|
|
||||||
clear() { |
clear() { |
||||||
if(!this.db) |
if (!this.db) return; |
||||||
return; |
indexedDB.deleteDatabase(HOLE_CACHE_DB_NAME); |
||||||
indexedDB.deleteDatabase(HOLE_CACHE_DB_NAME); |
console.log('delete comment cache db'); |
||||||
console.log('delete comment cache db'); |
} |
||||||
} |
} |
||||||
}; |
|
||||||
|
|
||||||
export function cache() { |
export function cache() { |
||||||
if(!window.hole_cache) |
if (!window.hole_cache) window.hole_cache = new Cache(); |
||||||
window.hole_cache=new Cache(); |
return window.hole_cache; |
||||||
return window.hole_cache; |
} |
||||||
} |
|
||||||
|
@ -1,26 +1,25 @@ |
|||||||
// https://martin.ankerl.com/2009/12/09/how-to-create-random-colors-programmatically/
|
// https://martin.ankerl.com/2009/12/09/how-to-create-random-colors-programmatically/
|
||||||
|
|
||||||
const golden_ratio_conjugate=0.618033988749895; |
const golden_ratio_conjugate = 0.618033988749895; |
||||||
|
|
||||||
export class ColorPicker { |
export class ColorPicker { |
||||||
constructor() { |
constructor() { |
||||||
this.names={}; |
this.names = {}; |
||||||
this.current_h=Math.random(); |
this.current_h = Math.random(); |
||||||
} |
} |
||||||
|
|
||||||
get(name) { |
get(name) { |
||||||
name=name.toLowerCase(); |
name = name.toLowerCase(); |
||||||
if(name==='洞主') |
if (name === '洞主') return ['hsl(0,0%,97%)', 'hsl(0,0%,16%)']; |
||||||
return ['hsl(0,0%,97%)','hsl(0,0%,16%)']; |
|
||||||
|
|
||||||
if(!this.names[name]) { |
if (!this.names[name]) { |
||||||
this.current_h+=golden_ratio_conjugate; |
this.current_h += golden_ratio_conjugate; |
||||||
this.current_h%=1; |
this.current_h %= 1; |
||||||
this.names[name]=[ |
this.names[name] = [ |
||||||
`hsl(${this.current_h*360}, 50%, 90%)`, |
`hsl(${this.current_h * 360}, 50%, 90%)`, |
||||||
`hsl(${this.current_h*360}, 60%, 20%)`, |
`hsl(${this.current_h * 360}, 60%, 20%)`, |
||||||
]; |
]; |
||||||
} |
|
||||||
return this.names[name]; |
|
||||||
} |
} |
||||||
} |
return this.names[name]; |
||||||
|
} |
||||||
|
} |
||||||
|
@ -1,183 +1,182 @@ |
|||||||
import {get_json, API_VERSION_PARAM} from './infrastructure/functions'; |
import { get_json, API_VERSION_PARAM } from './infrastructure/functions'; |
||||||
import {THUHOLE_API_ROOT} from './infrastructure/const'; |
import { THUHOLE_API_ROOT } from './infrastructure/const'; |
||||||
import {API_BASE} from './Common'; |
import { API_BASE } from './Common'; |
||||||
import {cache} from './cache'; |
import { cache } from './cache'; |
||||||
|
|
||||||
export {THUHOLE_API_ROOT, API_VERSION_PARAM}; |
export { THUHOLE_API_ROOT, API_VERSION_PARAM }; |
||||||
|
|
||||||
export function token_param(token) { |
export function token_param(token) { |
||||||
return API_VERSION_PARAM()+(token ? ('&user_token='+token) : ''); |
return API_VERSION_PARAM() + (token ? '&user_token=' + token : ''); |
||||||
} |
} |
||||||
|
|
||||||
export {get_json}; |
export { get_json }; |
||||||
|
|
||||||
const SEARCH_PAGESIZE=50; |
const SEARCH_PAGESIZE = 50; |
||||||
|
|
||||||
export const API={ |
export const API = { |
||||||
load_replies: (pid,token,color_picker,cache_version)=>{ |
load_replies: (pid, token, color_picker, cache_version) => { |
||||||
pid=parseInt(pid); |
pid = parseInt(pid); |
||||||
return fetch( |
return fetch( |
||||||
API_BASE+'/api.php?action=getcomment'+ |
API_BASE + |
||||||
'&pid='+pid+ |
'/api.php?action=getcomment' + |
||||||
token_param(token) |
'&pid=' + |
||||||
) |
pid + |
||||||
.then(get_json) |
token_param(token), |
||||||
.then((json)=>{ |
) |
||||||
if(json.code!==0) { |
.then(get_json) |
||||||
if(json.msg) throw new Error(json.msg); |
.then((json) => { |
||||||
else throw new Error(JSON.stringify(json)); |
if (json.code !== 0) { |
||||||
} |
if (json.msg) throw new Error(json.msg); |
||||||
|
else throw new Error(JSON.stringify(json)); |
||||||
cache().delete(pid).then(()=>{ |
} |
||||||
cache().put(pid,cache_version,json); |
|
||||||
}); |
cache() |
||||||
|
.delete(pid) |
||||||
// also change load_replies_with_cache!
|
.then(() => { |
||||||
json.data=json.data |
cache().put(pid, cache_version, json); |
||||||
.sort((a,b)=>{ |
}); |
||||||
return parseInt(a.cid,10)-parseInt(b.cid,10); |
|
||||||
}) |
// also change load_replies_with_cache!
|
||||||
.map((info)=>{ |
json.data = json.data |
||||||
info._display_color=color_picker.get(info.name); |
.sort((a, b) => { |
||||||
info.variant={}; |
return parseInt(a.cid, 10) - parseInt(b.cid, 10); |
||||||
return info; |
}) |
||||||
}); |
.map((info) => { |
||||||
|
info._display_color = color_picker.get(info.name); |
||||||
return json; |
info.variant = {}; |
||||||
|
return info; |
||||||
|
}); |
||||||
|
|
||||||
|
return json; |
||||||
|
}); |
||||||
|
}, |
||||||
|
|
||||||
|
load_replies_with_cache: (pid, token, color_picker, cache_version) => { |
||||||
|
pid = parseInt(pid); |
||||||
|
return cache() |
||||||
|
.get(pid, cache_version) |
||||||
|
.then((json) => { |
||||||
|
if (json) { |
||||||
|
// also change load_replies!
|
||||||
|
json.data = json.data |
||||||
|
.sort((a, b) => { |
||||||
|
return parseInt(a.cid, 10) - parseInt(b.cid, 10); |
||||||
|
}) |
||||||
|
.map((info) => { |
||||||
|
info._display_color = color_picker.get(info.name); |
||||||
|
info.variant = {}; |
||||||
|
return info; |
||||||
}); |
}); |
||||||
}, |
|
||||||
|
return json; |
||||||
load_replies_with_cache: (pid,token,color_picker,cache_version)=> { |
} else return API.load_replies(pid, token, color_picker, cache_version); |
||||||
pid=parseInt(pid); |
}); |
||||||
return cache().get(pid,cache_version) |
}, |
||||||
.then((json)=>{ |
|
||||||
if(json) { |
set_attention: (pid, attention, token) => { |
||||||
// also change load_replies!
|
let data = new URLSearchParams(); |
||||||
json.data=json.data |
data.append('user_token', token); |
||||||
.sort((a,b)=>{ |
data.append('pid', pid); |
||||||
return parseInt(a.cid,10)-parseInt(b.cid,10); |
data.append('switch', attention ? '1' : '0'); |
||||||
}) |
return fetch(API_BASE + '/api.php?action=attention' + token_param(token), { |
||||||
.map((info)=>{ |
method: 'POST', |
||||||
info._display_color=color_picker.get(info.name); |
headers: { |
||||||
info.variant={}; |
'Content-Type': 'application/x-www-form-urlencoded', |
||||||
return info; |
}, |
||||||
}); |
body: data, |
||||||
|
}) |
||||||
return json; |
.then(get_json) |
||||||
} |
.then((json) => { |
||||||
else |
cache().delete(pid); |
||||||
return API.load_replies(pid,token,color_picker,cache_version); |
if (json.code !== 0) { |
||||||
}); |
if (json.msg && json.msg === '已经关注过了') { |
||||||
}, |
} else { |
||||||
|
if (json.msg) alert(json.msg); |
||||||
set_attention: (pid,attention,token)=>{ |
throw new Error(JSON.stringify(json)); |
||||||
let data=new URLSearchParams(); |
} |
||||||
data.append('user_token',token); |
} |
||||||
data.append('pid',pid); |
return json; |
||||||
data.append('switch',attention ? '1' : '0'); |
}); |
||||||
return fetch(API_BASE+'/api.php?action=attention'+token_param(token), { |
}, |
||||||
method: 'POST', |
|
||||||
headers: { |
report: (pid, reason, token) => { |
||||||
'Content-Type': 'application/x-www-form-urlencoded', |
let data = new URLSearchParams(); |
||||||
}, |
data.append('user_token', token); |
||||||
body: data, |
data.append('pid', pid); |
||||||
}) |
data.append('reason', reason); |
||||||
.then(get_json) |
return fetch(API_BASE + '/api.php?action=report' + token_param(token), { |
||||||
.then((json)=>{ |
method: 'POST', |
||||||
cache().delete(pid); |
headers: { |
||||||
if(json.code!==0) { |
'Content-Type': 'application/x-www-form-urlencoded', |
||||||
if(json.msg && json.msg==='已经关注过了') {} |
}, |
||||||
else { |
body: data, |
||||||
if(json.msg) alert(json.msg); |
}) |
||||||
throw new Error(JSON.stringify(json)); |
.then(get_json) |
||||||
} |
.then((json) => { |
||||||
} |
if (json.code !== 0) { |
||||||
return json; |
if (json.msg) alert(json.msg); |
||||||
}); |
throw new Error(JSON.stringify(json)); |
||||||
}, |
} |
||||||
|
return json; |
||||||
report: (pid,reason,token)=>{ |
}); |
||||||
let data=new URLSearchParams(); |
}, |
||||||
data.append('user_token',token); |
|
||||||
data.append('pid',pid); |
get_list: (page, token) => { |
||||||
data.append('reason',reason); |
return fetch( |
||||||
return fetch(API_BASE+'/api.php?action=report'+token_param(token), { |
API_BASE + '/api.php?action=getlist' + '&p=' + page + token_param(token), |
||||||
method: 'POST', |
) |
||||||
headers: { |
.then(get_json) |
||||||
'Content-Type': 'application/x-www-form-urlencoded', |
.then((json) => { |
||||||
}, |
if (json.code !== 0) throw new Error(JSON.stringify(json)); |
||||||
body: data, |
return json; |
||||||
}) |
}); |
||||||
.then(get_json) |
}, |
||||||
.then((json)=>{ |
|
||||||
if(json.code!==0) { |
get_search: (page, keyword, token) => { |
||||||
if(json.msg) alert(json.msg); |
return fetch( |
||||||
throw new Error(JSON.stringify(json)); |
API_BASE + |
||||||
} |
'/api.php?action=search' + |
||||||
return json; |
'&pagesize=' + |
||||||
}); |
SEARCH_PAGESIZE + |
||||||
}, |
'&page=' + |
||||||
|
page + |
||||||
get_list: (page,token)=>{ |
'&keywords=' + |
||||||
return fetch( |
encodeURIComponent(keyword) + |
||||||
API_BASE+'/api.php?action=getlist'+ |
token_param(token), |
||||||
'&p='+page+ |
) |
||||||
token_param(token) |
.then(get_json) |
||||||
) |
.then((json) => { |
||||||
.then(get_json) |
if (json.code !== 0) { |
||||||
.then((json)=>{ |
if (json.msg) throw new Error(json.msg); |
||||||
if(json.code!==0) |
throw new Error(JSON.stringify(json)); |
||||||
throw new Error(JSON.stringify(json)); |
} |
||||||
return json; |
return json; |
||||||
}); |
}); |
||||||
}, |
}, |
||||||
|
|
||||||
get_search: (page,keyword,token)=>{ |
get_single: (pid, token) => { |
||||||
return fetch( |
return fetch( |
||||||
API_BASE+'/api.php?action=search'+ |
API_BASE + '/api.php?action=getone' + '&pid=' + pid + token_param(token), |
||||||
'&pagesize='+SEARCH_PAGESIZE+ |
) |
||||||
'&page='+page+ |
.then(get_json) |
||||||
'&keywords='+encodeURIComponent(keyword)+ |
.then((json) => { |
||||||
token_param(token) |
if (json.code !== 0) { |
||||||
) |
if (json.msg) throw new Error(json.msg); |
||||||
.then(get_json) |
else throw new Error(JSON.stringify(json)); |
||||||
.then((json)=>{ |
} |
||||||
if(json.code!==0) { |
return json; |
||||||
if(json.msg) throw new Error(json.msg); |
}); |
||||||
throw new Error(JSON.stringify(json)); |
}, |
||||||
} |
|
||||||
return json; |
get_attention: (token) => { |
||||||
}); |
return fetch(API_BASE + '/api.php?action=getattention' + token_param(token)) |
||||||
}, |
.then(get_json) |
||||||
|
.then((json) => { |
||||||
get_single: (pid,token)=>{ |
if (json.code !== 0) { |
||||||
return fetch( |
if (json.msg) throw new Error(json.msg); |
||||||
API_BASE+'/api.php?action=getone'+ |
throw new Error(JSON.stringify(json)); |
||||||
'&pid='+pid+ |
} |
||||||
token_param(token) |
return json; |
||||||
) |
}); |
||||||
.then(get_json) |
}, |
||||||
.then((json)=>{ |
}; |
||||||
if(json.code!==0) { |
|
||||||
if(json.msg) throw new Error(json.msg); |
|
||||||
else throw new Error(JSON.stringify(json)); |
|
||||||
} |
|
||||||
return json; |
|
||||||
}); |
|
||||||
}, |
|
||||||
|
|
||||||
get_attention: (token)=>{ |
|
||||||
return fetch( |
|
||||||
API_BASE+'/api.php?action=getattention'+ |
|
||||||
token_param(token) |
|
||||||
) |
|
||||||
.then(get_json) |
|
||||||
.then((json)=>{ |
|
||||||
if(json.code!==0) { |
|
||||||
if(json.msg) throw new Error(json.msg); |
|
||||||
throw new Error(JSON.stringify(json)); |
|
||||||
} |
|
||||||
return json; |
|
||||||
}); |
|
||||||
}, |
|
||||||
}; |
|
||||||
|
@ -1,34 +1,34 @@ |
|||||||
// regexp should match the WHOLE segmented part
|
// regexp should match the WHOLE segmented part
|
||||||
// export const PID_RE=/(^|[^\d\u20e3\ufe0e\ufe0f])([2-9]\d{4,5}|1\d{4,6})(?![\d\u20e3\ufe0e\ufe0f])/g;
|
// export const PID_RE=/(^|[^\d\u20e3\ufe0e\ufe0f])([2-9]\d{4,5}|1\d{4,6})(?![\d\u20e3\ufe0e\ufe0f])/g;
|
||||||
export const PID_RE=/(^|[^\d\u20e3\ufe0e\ufe0f])(#\d{1,7})(?![\d\u20e3\ufe0e\ufe0f])/g; |
export const PID_RE = /(^|[^\d\u20e3\ufe0e\ufe0f])(#\d{1,7})(?![\d\u20e3\ufe0e\ufe0f])/g; |
||||||
// TODO: fix this re
|
// TODO: fix this re
|
||||||
// export const URL_PID_RE=/((?:https?:\/\/)?thuhole\.com\/?#(?:#|%23)([2-9]\d{4,5}|1\d{4,6}))(?!\d|\u20e3|\ufe0e|\ufe0f)/g;
|
// export const URL_PID_RE=/((?:https?:\/\/)?thuhole\.com\/?#(?:#|%23)([2-9]\d{4,5}|1\d{4,6}))(?!\d|\u20e3|\ufe0e|\ufe0f)/g;
|
||||||
export const URL_PID_RE=/((?:https?:\/\/)?thuhole\.com\/?#(?:#|%23)(\d{1,7}))(?!\d|\u20e3|\ufe0e|\ufe0f)/g; |
export const URL_PID_RE = /((?:https?:\/\/)?thuhole\.com\/?#(?:#|%23)(\d{1,7}))(?!\d|\u20e3|\ufe0e|\ufe0f)/g; |
||||||
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; |
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; |
||||||
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; |
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; |
||||||
|
|
||||||
export function split_text(txt,rules) { |
export function split_text(txt, rules) { |
||||||
// rules: [['name',/regex/],...]
|
// rules: [['name',/regex/],...]
|
||||||
// return: [['name','part'],[null,'part'],...]
|
// return: [['name','part'],[null,'part'],...]
|
||||||
|
|
||||||
txt=[[null,txt]]; |
txt = [[null, txt]]; |
||||||
rules.forEach((rule)=>{ |
rules.forEach((rule) => { |
||||||
let [name,regex]=rule; |
let [name, regex] = rule; |
||||||
txt=[].concat.apply([],txt.map((part)=>{ |
txt = [].concat.apply( |
||||||
let [rule,content]=part; |
[], |
||||||
if(rule) // already tagged by previous rules
|
txt.map((part) => { |
||||||
return [part]; |
let [rule, content] = part; |
||||||
else { |
if (rule) |
||||||
return content |
// already tagged by previous rules
|
||||||
.split(regex) |
return [part]; |
||||||
.map((seg)=>( |
else { |
||||||
regex.test(seg) ? [name,seg] : [null,seg] |
return content |
||||||
)) |
.split(regex) |
||||||
.filter(([name,seg])=>( |
.map((seg) => (regex.test(seg) ? [name, seg] : [null, seg])) |
||||||
name!==null || seg |
.filter(([name, seg]) => name !== null || seg); |
||||||
)); |
} |
||||||
} |
}), |
||||||
})); |
); |
||||||
}); |
}); |
||||||
return txt; |
return txt; |
||||||
} |
} |
||||||
|
Loading…
Reference in new issue