├── .babelrc ├── .gitignore ├── README.md ├── config-overrides.js ├── docs ├── Javascript.png └── cnode-preview-compress.png ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json ├── robots.txt └── run_prettify.js ├── rename.sh ├── src ├── App.tsx ├── assets │ └── logo.svg ├── components │ ├── header │ │ └── index.tsx │ ├── image │ │ ├── index.tsx │ │ └── style.ts │ ├── loading │ │ └── index.tsx │ ├── scroll-list │ │ └── index.tsx │ ├── tabbar │ │ ├── index.tsx │ │ └── style.ts │ └── tag │ │ └── index.tsx ├── global.d.ts ├── hooks │ ├── useAsync.ts │ ├── useCache.ts │ ├── useInitPosition.ts │ └── useLoadMore.ts ├── index.tsx ├── layouts │ ├── base-layout │ │ ├── index.tsx │ │ └── style.ts │ └── index.ts ├── react-app-env.d.ts ├── service │ ├── base.ts │ └── cnode-sdk.ts ├── serviceWorker.js ├── style │ ├── common.ts │ ├── constants.ts │ └── global.ts ├── types │ └── index.ts ├── utils │ └── isEmpty.ts └── view │ ├── about │ └── index.tsx │ ├── article │ ├── code-prettify-sunburst.css │ ├── comment-panel │ │ ├── index.tsx │ │ └── style.tsx │ ├── comment │ │ ├── index.tsx │ │ └── style.tsx │ ├── index.tsx │ ├── info-bar │ │ ├── index.tsx │ │ └── style.ts │ └── style.tsx │ ├── not-found │ └── index.tsx │ ├── topic │ ├── card │ │ ├── card.tsx │ │ └── style.tsx │ └── index.tsx │ └── user │ ├── index.tsx │ └── style.tsx ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/plugin-proposal-optional-chaining"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #### Cnodejs based on react 2 | 3 | 此项目为复刻[cnode社区](https://cnodejs.org)制作的H5版本,采用react hooks全面改写,零外部UI组件依赖。可供入门react hooks进行研究。预览效果,可扫描文章下方的二维码。`PS:由于cnode服务器在海外,API请求延时有时会比较长。` 4 | 5 | #### Feature / 功能 6 | 7 | - [x] 骨架屏Loading动画(后续考虑使用自动化生成) 8 | - [x] 异步Hook`useAsync` 9 | - [x] 列表页无限滚动`useLoadMore`(采用`IntersationObserver`) 10 | - [ ] 列表页数据缓存策略 11 | - [x] markdown与代码高亮 12 | 13 | - [ ] 用户登录与保持 14 | - [ ] 点赞与收藏 15 | 16 | #### Design / 效果图 17 | 18 | ![preview](./docs/cnode-preview-compress.png) 19 | 20 | #### Preview / 预览 21 | 22 | ![](./docs/Javascript.png) -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | // const { 2 | // override, 3 | // addDecoratorsLegacy, 4 | // disableEsLint, 5 | // addWebpackAlias, 6 | // overrideDevServer 7 | // } = require("customize-cra") 8 | const path = require("path") 9 | 10 | module.exports = { 11 | webpack: config => { 12 | config.resolve.alias = { 13 | ...config.resolve.alias, 14 | '@': path.resolve(__dirname, 'src') 15 | } 16 | return config; 17 | } 18 | } -------------------------------------------------------------------------------- /docs/Javascript.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/impeiran/react-cnodejs/9544aa3f7b210bb3b71c85bb5d842b3185620c61/docs/Javascript.png -------------------------------------------------------------------------------- /docs/cnode-preview-compress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/impeiran/react-cnodejs/9544aa3f7b210bb3b71c85bb5d842b3185620c61/docs/cnode-preview-compress.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-cnodejs", 3 | "homepage": "/cnode", 4 | "version": "0.1.0", 5 | "private": true, 6 | "dependencies": { 7 | "@testing-library/jest-dom": "^4.2.4", 8 | "@testing-library/react": "^9.3.2", 9 | "@testing-library/user-event": "^7.1.2", 10 | "@types/jest": "^25.2.1", 11 | "@types/node": "^13.13.2", 12 | "@types/react": "^16.9.34", 13 | "@types/react-dom": "^16.9.6", 14 | "@types/react-router-dom": "^5.1.4", 15 | "@types/styled-components": "^5.1.0", 16 | "axios": "^0.19.2", 17 | "github-markdown-css": "^4.0.0", 18 | "intersection-observer": "^0.7.0", 19 | "prop-types": "^15.7.2", 20 | "react": "^16.13.0", 21 | "react-content-loader": "^5.0.2", 22 | "react-dom": "^16.13.0", 23 | "react-redux": "^7.2.0", 24 | "react-router-config": "^5.1.1", 25 | "react-router-dom": "^5.1.2", 26 | "react-scripts": "3.4.1", 27 | "styled-components": "^5.0.1", 28 | "timeago.js": "^4.0.2", 29 | "typescript": "^3.8.3" 30 | }, 31 | "scripts": { 32 | "start": "react-app-rewired start", 33 | "build": "react-app-rewired build", 34 | "test": "react-app-rewired test", 35 | "eject": "react-scripts eject" 36 | }, 37 | "eslintConfig": { 38 | "extends": "react-app" 39 | }, 40 | "browserslist": { 41 | "production": [ 42 | ">0.2%", 43 | "not dead", 44 | "not op_mini all" 45 | ], 46 | "development": [ 47 | "last 1 chrome version", 48 | "last 1 firefox version", 49 | "last 1 safari version" 50 | ] 51 | }, 52 | "devDependencies": { 53 | "@babel/helper-validator-identifier": "^7.9.5", 54 | "@babel/plugin-proposal-optional-chaining": "^7.8.3", 55 | "customize-cra": "^0.9.1", 56 | "react-app-rewired": "^2.1.5" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/impeiran/react-cnodejs/9544aa3f7b210bb3b71c85bb5d842b3185620c61/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 18 | 19 | 28 | cnodejs based on React 29 | 30 | 31 | 32 | 33 |
34 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/impeiran/react-cnodejs/9544aa3f7b210bb3b71c85bb5d842b3185620c61/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/impeiran/react-cnodejs/9544aa3f7b210bb3b71c85bb5d842b3185620c61/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/run_prettify.js: -------------------------------------------------------------------------------- 1 | !function(){/* 2 | 3 | Copyright (C) 2013 Google Inc. 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | Copyright (C) 2006 Google Inc. 18 | 19 | Licensed under the Apache License, Version 2.0 (the "License"); 20 | you may not use this file except in compliance with the License. 21 | You may obtain a copy of the License at 22 | 23 | http://www.apache.org/licenses/LICENSE-2.0 24 | 25 | Unless required by applicable law or agreed to in writing, software 26 | distributed under the License is distributed on an "AS IS" BASIS, 27 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 28 | See the License for the specific language governing permissions and 29 | limitations under the License. 30 | */ 31 | (function(){function aa(g){function r(){try{L.doScroll("left")}catch(ba){k.setTimeout(r,50);return}x("poll")}function x(r){if("readystatechange"!=r.type||"complete"==z.readyState)("load"==r.type?k:z)[B](n+r.type,x,!1),!l&&(l=!0)&&g.call(k,r.type||r)}var X=z.addEventListener,l=!1,E=!0,v=X?"addEventListener":"attachEvent",B=X?"removeEventListener":"detachEvent",n=X?"":"on";if("complete"==z.readyState)g.call(k,"lazy");else{if(z.createEventObject&&L.doScroll){try{E=!k.frameElement}catch(ba){}E&&r()}z[v](n+ 32 | "DOMContentLoaded",x,!1);z[v](n+"readystatechange",x,!1);k[v](n+"load",x,!1)}}function T(){U&&aa(function(){var g=M.length;ca(g?function(){for(var r=0;r=c?parseInt(e.substring(1),8):"u"===c||"x"===c?parseInt(e.substring(2),16):e.charCodeAt(1)}function f(e){if(32>e)return(16>e?"\\x0":"\\x")+e.toString(16);e=String.fromCharCode(e); 36 | return"\\"===e||"-"===e||"]"===e||"^"===e?"\\"+e:e}function c(e){var c=e.substring(1,e.length-1).match(RegExp("\\\\u[0-9A-Fa-f]{4}|\\\\x[0-9A-Fa-f]{2}|\\\\[0-3][0-7]{0,2}|\\\\[0-7]{1,2}|\\\\[\\s\\S]|-|[^-\\\\]","g"));e=[];var a="^"===c[0],b=["["];a&&b.push("^");for(var a=a?1:0,h=c.length;ap||122p||90p||122m[0]&&(m[1]+1>m[0]&&b.push("-"),b.push(f(m[1])));b.push("]");return b.join("")}function g(e){for(var a=e.source.match(RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)", 38 | "g")),b=a.length,d=[],h=0,m=0;h/,null])):d.push(["com",/^#[^\r\n]*/,null,"#"]));a.cStyleComments&&(f.push(["com",/^\/\/[^\r\n]*/,null]),f.push(["com",/^\/\*[\s\S]*?(?:\*\/|$)/, 45 | null]));if(c=a.regexLiterals){var g=(c=1|\\/=?|::?|<>?>?=?|,|;|\\?|@|\\[|~|{|\\^\\^?=?|\\|\\|?=?|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*("+("/(?=[^/*"+c+"])(?:[^/\\x5B\\x5C"+c+"]|\\x5C"+g+"|\\x5B(?:[^\\x5C\\x5D"+c+"]|\\x5C"+g+")*(?:\\x5D|$))+/")+")")])}(c=a.types)&&f.push(["typ",c]);c=(""+a.keywords).replace(/^ | $/g,"");c.length&&f.push(["kwd", 46 | new RegExp("^(?:"+c.replace(/[\s,]+/g,"|")+")\\b"),null]);d.push(["pln",/^\s+/,null," \r\n\t\u00a0"]);c="^.[^\\s\\w.$@'\"`/\\\\]*";a.regexLiterals&&(c+="(?!s*/)");f.push(["lit",/^@[a-z_$][a-z_$@0-9]*/i,null],["typ",/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],["pln",/^[a-z_$][a-z_$@0-9]*/i,null],["lit",/^(?:0x[a-f0-9]+|(?:\d(?:_\d+)*\d*(?:\.\d*)?|\.\d\+)(?:e[+\-]?\d+)?)[a-z]*/i,null,"0123456789"],["pln",/^\\[\s\S]?/,null],["pun",new RegExp(c),null]);return E(d,f)}function B(a,d,f){function c(a){var b= 47 | a.nodeType;if(1==b&&!r.test(a.className))if("br"===a.nodeName.toLowerCase())g(a),a.parentNode&&a.parentNode.removeChild(a);else for(a=a.firstChild;a;a=a.nextSibling)c(a);else if((3==b||4==b)&&f){var e=a.nodeValue,d=e.match(n);d&&(b=e.substring(0,d.index),a.nodeValue=b,(e=e.substring(d.index+d[0].length))&&a.parentNode.insertBefore(q.createTextNode(e),a.nextSibling),g(a),b||a.parentNode.removeChild(a))}}function g(a){function c(a,b){var e=b?a.cloneNode(!1):a,p=a.parentNode;if(p){var p=c(p,1),d=a.nextSibling; 48 | p.appendChild(e);for(var f=d;f;f=d)d=f.nextSibling,p.appendChild(f)}return e}for(;!a.nextSibling;)if(a=a.parentNode,!a)return;a=c(a.nextSibling,0);for(var e;(e=a.parentNode)&&1===e.nodeType;)a=e;b.push(a)}for(var r=/(?:^|\s)nocode(?:\s|$)/,n=/\r\n?|\n/,q=a.ownerDocument,k=q.createElement("li");a.firstChild;)k.appendChild(a.firstChild);for(var b=[k],t=0;t=+g[1],d=/\n/g,r=a.a,k=r.length,f=0,q=a.c,n=q.length,c=0,b=a.g,t=b.length,v=0;b[t]=k;var u,e;for(e=u=0;e=m&&(c+=2);f>=p&&(v+=2)}}finally{h&&(h.style.display=a)}}catch(y){Q.console&&console.log(y&&y.stack||y)}}var Q="undefined"!==typeof window?window:{},J=["break,continue,do,else,for,if,return,while"],K=[[J,"auto,case,char,const,default,double,enum,extern,float,goto,inline,int,long,register,restrict,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"], 52 | "catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"],R=[K,"alignas,alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,delegate,dynamic_cast,explicit,export,friend,generic,late_check,mutable,namespace,noexcept,noreturn,nullptr,property,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"],L=[K,"abstract,assert,boolean,byte,extends,finally,final,implements,import,instanceof,interface,null,native,package,strictfp,super,synchronized,throws,transient"], 53 | M=[K,"abstract,add,alias,as,ascending,async,await,base,bool,by,byte,checked,decimal,delegate,descending,dynamic,event,finally,fixed,foreach,from,get,global,group,implicit,in,interface,internal,into,is,join,let,lock,null,object,out,override,orderby,params,partial,readonly,ref,remove,sbyte,sealed,select,set,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,value,var,virtual,where,yield"],K=[K,"abstract,async,await,constructor,debugger,enum,eval,export,from,function,get,import,implements,instanceof,interface,let,null,of,set,undefined,var,with,yield,Infinity,NaN"], 54 | N=[J,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"],O=[J,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"],J=[J,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"],P=/^(DIR|FILE|array|vector|(de|priority_)?queue|(forward_)?list|stack|(const_)?(reverse_)?iterator|(unordered_)?(multi)?(set|map)|bitset|u?(int|float)\d*)\b/, 55 | S=/\S/,T=v({keywords:[R,M,L,K,"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END",N,O,J],hashComments:!0,cStyleComments:!0,multiLineStrings:!0,regexLiterals:!0}),V={};n(T,["default-code"]);n(E([],[["pln",/^[^]*(?:>|$)/],["com",/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["pun",/^(?:<[%?]|[%?]>)/],["lang-", 56 | /^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),"default-markup htm html mxml xhtml xml xsl".split(" "));n(E([["pln",/^[\s]+/,null," \t\r\n"],["atv",/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[["tag",/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],["atn",/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/], 57 | ["pun",/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);n(E([],[["atv",/^[\s\S]+/]]),["uq.val"]);n(v({keywords:R,hashComments:!0,cStyleComments:!0,types:P}),"c cc cpp cxx cyc m".split(" "));n(v({keywords:"null,true,false"}),["json"]);n(v({keywords:M,hashComments:!0,cStyleComments:!0, 58 | verbatimStrings:!0,types:P}),["cs"]);n(v({keywords:L,cStyleComments:!0}),["java"]);n(v({keywords:J,hashComments:!0,multiLineStrings:!0}),["bash","bsh","csh","sh"]);n(v({keywords:N,hashComments:!0,multiLineStrings:!0,tripleQuotedStrings:!0}),["cv","py","python"]);n(v({keywords:"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END",hashComments:!0,multiLineStrings:!0,regexLiterals:2}), 59 | ["perl","pl","pm"]);n(v({keywords:O,hashComments:!0,multiLineStrings:!0,regexLiterals:!0}),["rb","ruby"]);n(v({keywords:K,cStyleComments:!0,regexLiterals:!0}),["javascript","js","ts","typescript"]);n(v({keywords:"all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,throw,true,try,unless,until,when,while,yes",hashComments:3,cStyleComments:!0,multilineStrings:!0,tripleQuotedStrings:!0,regexLiterals:!0}),["coffee"]);n(E([],[["str",/^[\s\S]+/]]), 60 | ["regex"]);var U=Q.PR={createSimpleLexer:E,registerLangHandler:n,sourceDecorator:v,PR_ATTRIB_NAME:"atn",PR_ATTRIB_VALUE:"atv",PR_COMMENT:"com",PR_DECLARATION:"dec",PR_KEYWORD:"kwd",PR_LITERAL:"lit",PR_NOCODE:"nocode",PR_PLAIN:"pln",PR_PUNCTUATION:"pun",PR_SOURCE:"src",PR_STRING:"str",PR_TAG:"tag",PR_TYPE:"typ",prettyPrintOne:function(a,d,f){f=f||!1;d=d||null;var c=document.createElement("div");c.innerHTML="
"+a+"
";c=c.firstChild;f&&B(c,f,!0);H({j:d,m:f,h:c,l:1,a:null,i:null,c:null,g:null}); 61 | return c.innerHTML},prettyPrint:g=function(a,d){function f(){for(var c=Q.PR_SHOULD_USE_CONTINUATION?b.now()+250:Infinity;t { 9 | return ( 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | ) 20 | } 21 | 22 | 23 | export default App; 24 | -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 18 | 25 | 28 | 36 | 42 | 45 | 54 | 55 | -------------------------------------------------------------------------------- /src/components/header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { BG_DARK } from 'style/constants' 4 | 5 | const HeaderLayout = styled.header` 6 | display: flex; 7 | align-items: center; 8 | justify-content: space-around; 9 | position: relative; 10 | width: 100%; 11 | height: 50px; 12 | padding: 10px; 13 | background: ${BG_DARK}; 14 | ` 15 | 16 | const Logo = styled.img` 17 | position: absolute; 18 | top: 50%; 19 | left: 50%; 20 | width: 120px; 21 | transform: translate(-50%, -50%); 22 | ` 23 | interface Props { 24 | logo: string; 25 | } 26 | 27 | const Header: React.FC = (props: Props) => { 28 | return ( 29 | 30 | 31 | 32 | ) 33 | } 34 | 35 | export default React.memo(Header) -------------------------------------------------------------------------------- /src/components/image/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import ImageWrapper from './style' 3 | 4 | const defaultProps = { 5 | alt: '', 6 | width: 50, 7 | height: 50, 8 | radius: 8, 9 | onClick: (e: React.MouseEvent) => {} 10 | } 11 | 12 | export interface ImageProps extends Partial{ 13 | src: string; 14 | style?: React.CSSProperties 15 | } 16 | 17 | const Image: React.FC = (props: ImageProps) => { 18 | const { src, alt, width, height, radius, style = {}, onClick } = props 19 | 20 | const [status, setStatus] = useState('loading') 21 | 22 | return ( 23 | 31 | {alt} setStatus('complete')} 35 | onError={() => setStatus('error')} 36 | /> 37 | 38 | ) 39 | } 40 | 41 | Image.defaultProps = defaultProps 42 | 43 | export default React.memo(Image) 44 | -------------------------------------------------------------------------------- /src/components/image/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | interface WrapperProps { 4 | width?: number; 5 | height?: number; 6 | radius?: string | number; 7 | } 8 | 9 | const ImageWrapper = styled.div` 10 | position: relative; 11 | width: ${props => props.width}px; 12 | height: ${props => props.height}px; 13 | overflow: hidden; 14 | border-radius: ${props => typeof props.radius === 'number' ? props.radius + 'px' : props.radius}; 15 | 16 | img { 17 | position: absolute; 18 | top: 0; 19 | left: 0; 20 | width: 100%; 21 | height: 100%; 22 | opacity: 1; 23 | transition: opacity .5s; 24 | } 25 | 26 | &.image-loading, &.image-error { 27 | background: #ddd; 28 | 29 | img { 30 | opacity: 0; 31 | } 32 | } 33 | 34 | &.image-error:before { 35 | display: flex; 36 | align-items: center; 37 | justify-content: center; 38 | content: '!'; 39 | position: absolute; 40 | top: 50%; 41 | left: 50%; 42 | transform: translate(-50%, -50%); 43 | width: 40%; 44 | height: 40%; 45 | text-align: center; 46 | color: #aaa; 47 | font-weight: bold; 48 | border: 1px solid #aaa; 49 | border-radius: 50%; 50 | } 51 | ` 52 | 53 | export default ImageWrapper 54 | -------------------------------------------------------------------------------- /src/components/loading/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | 4 | interface LoadingProps { 5 | size?: number; 6 | text: string; 7 | } 8 | 9 | interface WrapperProps { 10 | size: number 11 | } 12 | 13 | const rotate = keyframes` 14 | from { transform: rotate(0deg);} 15 | to { transform: rotate(360deg);} 16 | `; 17 | 18 | const LoadingWrapper = styled.div` 19 | margin: 10px auto; 20 | width: ${props => props.size}px; 21 | height: ${props => props.size}px; 22 | border-radius: 50%; 23 | border: 4px solid rgba(0, 0, 0, 0.1); 24 | border-left-color: rgba(0, 0, 0, .3); 25 | animation: ${rotate} 1s linear infinite; 26 | ` 27 | 28 | const TextWrapper = styled.div` 29 | margin-bottom: 10px; 30 | text-align: center; 31 | ` 32 | 33 | const Loading: React.FC = (props: LoadingProps) => { 34 | return ( 35 | <> 36 | 37 | { 38 | props.text && { props.text } 39 | } 40 | 41 | 42 | ) 43 | } 44 | 45 | export default React.memo(Loading) 46 | -------------------------------------------------------------------------------- /src/components/scroll-list/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import React, { useEffect, useCallback, useRef } from 'react' 3 | import Loading from 'components/loading' 4 | import styled from 'styled-components' 5 | 6 | // polyfill 7 | import 'intersection-observer' 8 | 9 | export interface IProps { 10 | loading: boolean; 11 | completed: boolean; 12 | children: React.ReactNode; 13 | onLoad: () => void; 14 | } 15 | 16 | const TipWord = styled.div` 17 | margin: 10px auto; 18 | color: #333; 19 | text-align: center; 20 | ` 21 | 22 | const ScrollList: React.FC = (props: IProps) => { 23 | const { completed, onLoad, loading } = props 24 | 25 | // 触发命中观察的回调 26 | const hanlder = useCallback(entries => { 27 | if (completed) return 28 | if (entries[0].intersectionRatio > 0) { 29 | onLoad() 30 | } 31 | }, [completed, onLoad]) 32 | 33 | const observer: React.RefObject = useRef(new IntersectionObserver(hanlder)) 34 | const bottomEl: any = useRef() 35 | 36 | useEffect(() => { 37 | observer.current && observer.current.observe(bottomEl.current) 38 | 39 | return () => { 40 | observer.current && observer.current.unobserve(bottomEl.current) 41 | } 42 | }, []) 43 | 44 | return ( 45 |
46 | { props.children } 47 |
48 | { loading && !completed && } 49 | { !loading && completed && 加载完成} 50 |
51 |
52 | ) 53 | } 54 | 55 | export default React.memo(ScrollList) 56 | -------------------------------------------------------------------------------- /src/components/tabbar/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { NavLink } from 'react-router-dom' 3 | import TabbarWrapper, { TabbarItem } from './style' 4 | 5 | export interface Tabber { 6 | name: string; 7 | route: string; 8 | } 9 | 10 | interface IProps { 11 | value: Array; 12 | } 13 | 14 | const Tabbar: React.FC = (props: IProps) => { 15 | return ( 16 | 17 | { 18 | props.value.map(item => { 19 | return ( 20 | 21 | { item.name || '' } 22 | 23 | ) 24 | }) 25 | } 26 | 27 | ) 28 | } 29 | 30 | export default React.memo(Tabbar) 31 | -------------------------------------------------------------------------------- /src/components/tabbar/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { BG_DARK, COLOR_THEME } from 'style/constants' 3 | 4 | const TabbarWrapper = styled.ul` 5 | display: flex; 6 | width: 100%; 7 | background-color: ${BG_DARK}; 8 | overflow: auto; 9 | 10 | a { 11 | display: inline-block; 12 | flex: 1; 13 | text-align: center; 14 | color: hsla(0,0%,100%,.7); 15 | transition: all .3s; 16 | &.active { 17 | color: #fff; 18 | font-weight: bold; 19 | 20 | li { 21 | width: 100%; 22 | border-bottom: ${`4px solid ${COLOR_THEME}`} 23 | } 24 | } 25 | } 26 | ` 27 | 28 | export const TabbarItem = styled.li` 29 | display: inline-block; 30 | padding: 12px 16px; 31 | /* box-sizing: content-box; */ 32 | font-size: 14px; 33 | /* transition: border-bottom-color .3s; */ 34 | ` 35 | 36 | export default TabbarWrapper -------------------------------------------------------------------------------- /src/components/tag/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | import { 5 | COLOR_RED, 6 | COLOR_ORANGE_DEEP, 7 | COLOR_BLUE_PRIMARY, 8 | COLOR_GREEN_TEAL, 9 | COLOR_GREEN_OLIVE, 10 | COLOR_GREY_INFO 11 | } from 'style/constants' 12 | 13 | interface IProps { 14 | type: string 15 | } 16 | 17 | interface cate { 18 | text: string; 19 | color: string; 20 | } 21 | 22 | interface TagDict { 23 | [index: string]: cate; 24 | } 25 | 26 | const DICT: TagDict = { 27 | top: { text: '置顶', color: COLOR_RED }, 28 | good: { text: '精华', color: COLOR_ORANGE_DEEP }, 29 | share: { text: '分享', color: COLOR_GREEN_TEAL }, 30 | ask: { text: '问答', color: COLOR_BLUE_PRIMARY }, 31 | job: { text: '招聘', color: COLOR_GREEN_OLIVE }, 32 | default: { text: '话题', color: COLOR_GREY_INFO }, 33 | } 34 | 35 | const TagUI = styled.label<{ color: string }>` 36 | display: inline-block; 37 | padding: 7px 10px; 38 | color: #fff; 39 | font-size: 12px; 40 | font-weight: bold; 41 | background-color: ${props => props.color}; 42 | border-radius: 4px; 43 | ` 44 | 45 | const Tag: React.FC = (props: IProps) => { 46 | const type = props.type || 'default' 47 | return { DICT[type].text } 48 | } 49 | 50 | export default React.memo(Tag) 51 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare var window: Window; 2 | 3 | interface Window { 4 | PR: { 5 | prettyPrint(): void 6 | } 7 | } -------------------------------------------------------------------------------- /src/hooks/useAsync.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import { useEffect, useState, useCallback, useRef } from 'react' 3 | 4 | const noop = () => {} 5 | const defaultOption = { 6 | mannual: false, 7 | onSuccess: noop as SuccessHandler, 8 | onError: noop as ErrorHandler 9 | } 10 | 11 | export interface SuccessHandler { 12 | (res: any):T; 13 | } 14 | 15 | export interface ErrorHandler { 16 | (err: any): void; 17 | } 18 | 19 | export interface Option extends Partial{ 20 | onSuccess: SuccessHandler; 21 | onError: ErrorHandler; 22 | } 23 | 24 | export interface AsyncResult { 25 | loading: boolean; 26 | run: () => void; 27 | result: T | undefined; 28 | } 29 | 30 | /** 31 | * @param {Function} action should return a Promise 32 | * @param {Object} customOption 33 | */ 34 | const useAsync = ( 35 | action: () => Promise, 36 | customOption: object = {} 37 | ): AsyncResult => { 38 | let option: Option = Object.assign({}, defaultOption, customOption) 39 | 40 | const result = useRef() 41 | const [loading, setLoading] = useState(false) 42 | 43 | const run = useCallback(() => { 44 | setLoading(true) 45 | const ret: Promise = action() 46 | if (ret.then) { 47 | ret.then(res => { 48 | result.current = option.onSuccess(res) || res 49 | }) 50 | .catch(option.onError) 51 | .finally(() => setLoading(false)) 52 | } else { 53 | setLoading(false) 54 | } 55 | }, [action]) 56 | 57 | useEffect(() => { 58 | !option.mannual && run() 59 | }, []) 60 | 61 | return { 62 | loading, 63 | run, 64 | result: result.current, 65 | } 66 | } 67 | 68 | export default useAsync 69 | -------------------------------------------------------------------------------- /src/hooks/useCache.ts: -------------------------------------------------------------------------------- 1 | interface CacheData { 2 | expire: number; 3 | data: any; 4 | } 5 | 6 | let cacheMap: Map 7 | 8 | /** 9 | * 缓存数据 10 | * @param cacheKey 缓存的key值 11 | * @param defaultData 默认值 12 | * @param expire 缓存时间,默认为-1即无限期,若为正数,单位为ms 13 | */ 14 | const useCache = (cacheKey: string, defaultData?: T, expire: number = -1) => { 15 | cacheMap || (cacheMap = new Map()) 16 | 17 | // 设置缓存 18 | const setCache = (data: any) => { 19 | return expire !== -1 20 | ? cacheMap.set(cacheKey, { 21 | expire: Date.now() + expire, 22 | data 23 | }) 24 | : cacheMap.set(cacheKey, { data, expire }) 25 | } 26 | 27 | let result: K | T | undefined 28 | 29 | if (cacheMap.has(cacheKey)) { 30 | const current = cacheMap.get(cacheKey) 31 | 32 | if ( 33 | current && 34 | current?.expire !== -1 && 35 | current?.expire < Date.now() 36 | ) { 37 | // 缓存数据已过期 38 | cacheMap.delete(cacheKey) 39 | result = defaultData 40 | } else { 41 | // 命中缓存 42 | result = current?.data 43 | } 44 | } else { 45 | // 首次使用,返回默认值 46 | result = defaultData 47 | } 48 | 49 | return [result, setCache] 50 | } 51 | 52 | export default useCache -------------------------------------------------------------------------------- /src/hooks/useInitPosition.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import { useEffect } from 'react' 3 | 4 | function useInitPosition (...args: any): void { 5 | useEffect(() => { 6 | window.scrollTo.apply(null, args) 7 | }, []) 8 | } 9 | 10 | export default useInitPosition -------------------------------------------------------------------------------- /src/hooks/useLoadMore.ts: -------------------------------------------------------------------------------- 1 | import isEmpty from 'utils/isEmpty' 2 | /* eslint-disable react-hooks/exhaustive-deps */ 3 | import { useEffect, useRef, useCallback } from 'react' 4 | import useAsync from './useAsync' 5 | 6 | const defaultOption = { 7 | initPage: 1, 8 | initPageSize: 10 9 | } 10 | 11 | interface Option extends Partial { 12 | defaultResult?: { list: Array }; 13 | formatResult?(result: any): { list: Array }; 14 | isNoMore?(result: any): boolean; 15 | } 16 | 17 | /** 18 | * @param {Function} action should return a Promise 19 | * @param {Object} option 20 | * @param {Array} deps dependecies 21 | */ 22 | export default ( 23 | action: (res: any) => Promise, 24 | option: Option = defaultOption, 25 | deps: React.DependencyList = [] 26 | ) => { 27 | 28 | option = Object.assign({}, defaultOption, option || {}) 29 | 30 | const defaultList = option.defaultResult?.list || [] 31 | 32 | const infoRef = useRef({ 33 | completed: false, 34 | page: 1, 35 | list: [] as any[] 36 | }) 37 | 38 | 39 | const actionHandler = useCallback(() => { 40 | return action({ page: infoRef.current.page, pageSize: option.initPageSize }) 41 | }, [action]) 42 | 43 | const { loading, run } = useAsync(actionHandler, { 44 | mannual: true, 45 | onSuccess: (res: { list?: any }) => { 46 | const prevList = infoRef.current.list 47 | const currentPage = infoRef.current.page 48 | 49 | const resultList = option.formatResult 50 | ? option.formatResult({ 51 | response: res, 52 | page: currentPage 53 | }).list 54 | : res.list 55 | 56 | infoRef.current.list = currentPage !== 1 57 | ? prevList.concat(resultList) 58 | : resultList 59 | 60 | infoRef.current.completed = option.isNoMore ? option.isNoMore(res) : false 61 | } 62 | }) 63 | 64 | const loadMore = useCallback(() => { 65 | infoRef.current = { 66 | ...infoRef.current, 67 | page: infoRef.current.page + 1 68 | } 69 | run() 70 | },[]) 71 | 72 | useEffect(() => { 73 | infoRef.current = { 74 | page: 1, 75 | list: defaultList || [], 76 | completed: false 77 | } 78 | isEmpty(defaultList) && run() 79 | }, [...deps]) 80 | 81 | return { 82 | loading, 83 | loadMore, 84 | ...infoRef.current 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | // import * as serviceWorker from './serviceWorker'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | 8 | // If you want your app to work offline and load faster, you can change 9 | // unregister() to register() below. Note this comes with some pitfalls. 10 | // Learn more about service workers: https://bit.ly/CRA-PWA 11 | // serviceWorker.uregister(); 12 | -------------------------------------------------------------------------------- /src/layouts/base-layout/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Switch, Route, Redirect } from 'react-router-dom' 3 | 4 | import Header from 'components/header' 5 | import Tabbar, { Tabber } from 'components/tabbar' 6 | import Topic from 'view/topic' 7 | import Article from 'view/article' 8 | import User from 'view/user' 9 | import About from 'view/about' 10 | import NotFound from 'view/not-found' 11 | import Layout, { Fixed, Main } from './style' 12 | 13 | const navList: Tabber[] = [ 14 | { name: '全部', route: '/topic/all' }, 15 | { name: '精华', route: '/topic/good' }, 16 | { name: '分享', route: '/topic/share' }, 17 | { name: '问答', route: '/topic/ask' }, 18 | { name: '招聘', route: '/topic/job' }, 19 | { name: '关于', route: '/about' }, 20 | ] 21 | 22 | const BaseLayout = () => { 23 | return ( 24 | 25 | 26 |
27 | 28 | 29 | 30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
40 | 41 | ) 42 | } 43 | 44 | export default React.memo(BaseLayout) 45 | -------------------------------------------------------------------------------- /src/layouts/base-layout/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const Layout = styled.div` 4 | min-height: 100vh; 5 | padding-top: 92px; 6 | ` 7 | 8 | export const Fixed = styled.div` 9 | position: fixed; 10 | top: 0; 11 | left: 0; 12 | width: 100%; 13 | z-index: 100; 14 | overflow: hidden; 15 | ` 16 | 17 | export const Main = styled.main` 18 | padding: 15px; 19 | ` 20 | 21 | export default Layout -------------------------------------------------------------------------------- /src/layouts/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BaseLayout } from './base-layout' -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/service/base.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse, AxiosInstance, AxiosRequestConfig } from 'axios' 2 | 3 | export interface SuccessFormat { 4 | success?: boolean; 5 | data?: any; 6 | } 7 | 8 | const handleError = (err: any) => { 9 | let msg = err.message || err.msg || '' 10 | alert(`请求失败,请重新刷新页面尝试:${msg}`) 11 | } 12 | 13 | const handleResponse = (res: AxiosResponse, resolve: (res: any) => void) => { 14 | const response: SuccessFormat = res.data 15 | if (response.hasOwnProperty('success')) { 16 | if (response.success) { 17 | // const { success, ...rest } = response 18 | resolve(response) 19 | } else { 20 | handleError(response) 21 | } 22 | } else { 23 | handleError(response) 24 | } 25 | } 26 | 27 | class SDK { 28 | $http: AxiosInstance; 29 | 30 | constructor(config: AxiosRequestConfig) { 31 | this.$http = axios.create(config || {}) 32 | } 33 | 34 | get (url: string, params?: object): Promise { 35 | return new Promise((resolve, reject) => { 36 | this.$http.get(url, { params }) 37 | .then(res => handleResponse(res, resolve)) 38 | .catch(handleError) 39 | }) 40 | } 41 | 42 | post (url: string, data?: object): Promise { 43 | return new Promise((resolve, reject) => { 44 | this.$http.post(url, data) 45 | .then(res => handleResponse(res, resolve)) 46 | .catch(handleError) 47 | }) 48 | } 49 | } 50 | 51 | export default SDK -------------------------------------------------------------------------------- /src/service/cnode-sdk.ts: -------------------------------------------------------------------------------- 1 | import Service, { SuccessFormat } from './base' 2 | 3 | class CnodeSDK extends Service { 4 | constructor () { 5 | super({ 6 | baseURL: 'https://cnodejs.org/api/v1', 7 | timeout: 8000 8 | }) 9 | } 10 | 11 | /** 12 | * 获取分类列表 13 | * @param {String} tab 类型 14 | * @param {Number} page 页码 15 | * @param {Number} limit 每页数量 16 | */ 17 | getTopicsByTab (tab: string, page: number = 1, limit: number = 20): Promise { 18 | return this.get('/topics', { 19 | page: page, 20 | limit: limit, 21 | tab 22 | }) 23 | } 24 | 25 | /** 26 | * 获取话题的文章详情 27 | * @param {String} topicId 28 | */ 29 | getTopicDetail (topicId: string | number): Promise { 30 | return this.get(`/topic/${topicId}`) 31 | } 32 | 33 | /** 34 | * 获取用户详情页数据 35 | * @param {String} username 36 | */ 37 | getUserDetail (username: string): Promise { 38 | return this.get(`/user/${username}`) 39 | } 40 | 41 | /** 42 | * 获取用户收藏的文章 43 | * @param {String}} username 44 | */ 45 | getUserCollection (username: string): Promise { 46 | return this.get(`/topic_collect/${username}`) 47 | } 48 | } 49 | 50 | export default new CnodeSDK() 51 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' } 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/style/common.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | interface Option { 5 | component: React.FC; 6 | width?: string; 7 | lineClamp?: number; 8 | } 9 | 10 | export const ellipse = ({ 11 | component, 12 | width = 'auto', 13 | lineClamp = 1 14 | }: Option) => { 15 | let strTpl = ` 16 | overflow: hidden; 17 | text-overflow: ellipsis; 18 | width: ${width}; 19 | ` 20 | if (lineClamp > 1) { 21 | strTpl += ` 22 | display: -webkit-box !important; 23 | -webkit-line-clamp: ${lineClamp}; 24 | -webkit-box-orient: vertical; 25 | ` 26 | } else { 27 | strTpl += ` 28 | white-space: nowrap; 29 | ` 30 | } 31 | 32 | return styled(component) ` 33 | ${ strTpl } 34 | ` 35 | } -------------------------------------------------------------------------------- /src/style/constants.ts: -------------------------------------------------------------------------------- 1 | export const COLOR_THEME = '#80bd01' 2 | export const COLOR_RED = '#db2828' 3 | export const COLOR_ORANGE_DEEP = '#f2711c' 4 | export const COLOR_BLUE_PRIMARY = '#2185d0' 5 | export const COLOR_GREEN_TEAL = '#00b5ad' 6 | export const COLOR_GREEN_OLIVE = '#b5cc18' 7 | export const COLOR_GREY_INFO = '#767676' 8 | 9 | export const BG_DARK = 'rgb(50, 50, 50)' -------------------------------------------------------------------------------- /src/style/global.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from "styled-components"; 2 | 3 | export const GlobalStyle = createGlobalStyle` 4 | html, body, div, span, applet, object, iframe, 5 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 6 | a, abbr, acronym, address, big, cite, code, 7 | del, dfn, em, img, ins, kbd, q, s, samp, 8 | small, strike, strong, sub, sup, tt, var, 9 | b, u, i, center, 10 | dl, dt, dd, ol, ul, li, 11 | fieldset, form, label, legend, 12 | table, caption, tbody, tfoot, thead, tr, th, td, 13 | article, aside, canvas, details, embed, 14 | figure, figcaption, footer, header, hgroup, 15 | menu, nav, output, ruby, section, summary, 16 | time, mark, audio, video { 17 | margin: 0; 18 | padding: 0; 19 | box-sizing: border-box; 20 | border: 0; 21 | vertical-align: baseline; 22 | } 23 | /* HTML5 display-role reset for older browsers */ 24 | article, aside, details, figcaption, figure, 25 | footer, header, hgroup, menu, nav, section { 26 | display: block; 27 | } 28 | 29 | html, body{ 30 | font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif; 31 | color: #333; 32 | font-size: 14px; 33 | line-height: 1; 34 | background: #f2f3f4; 35 | } 36 | 37 | ol, ul { 38 | list-style: none; 39 | } 40 | blockquote, q { 41 | quotes: none; 42 | } 43 | 44 | blockquote:before, blockquote:after, 45 | q:before, q:after { 46 | content: ''; 47 | content: none; 48 | } 49 | 50 | table { 51 | border-collapse: collapse; 52 | border-spacing: 0; 53 | } 54 | 55 | a{ 56 | text-decoration: none; 57 | } 58 | 59 | h1, h2, h3, h4, h5, h6 { 60 | font-family: Lato,Helvetica Neue,Arial,Helvetica,sans-serif; 61 | font-weight: 700; 62 | } 63 | 64 | pre.prettyprint, code.prettyprint { 65 | background: rgb(50, 50, 50); 66 | } 67 | `; 68 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface Topic { 2 | id: string; 3 | tab: string; 4 | title: boolean; 5 | author_id: string; 6 | author: Author; 7 | content?: string; 8 | good?: boolean; 9 | top?: boolean; 10 | visit_count?: number; 11 | reply_count?: number; 12 | last_reply_at: string; 13 | create_at: string; 14 | } 15 | 16 | export interface Article extends Topic { 17 | is_collect: boolean; 18 | replies: Comment[]; 19 | } 20 | 21 | export interface ArticleLink { 22 | author: Author; 23 | id: string; 24 | last_reply_at: string; 25 | title: string; 26 | } 27 | 28 | export interface Author { 29 | avatar_url: string; 30 | loginname: string; 31 | } 32 | 33 | export interface Comment { 34 | id: string; 35 | content: string; 36 | author: Author; 37 | is_uped: boolean; 38 | create_at: string; 39 | reply_id?: string; 40 | ups?: Array; 41 | } -------------------------------------------------------------------------------- /src/utils/isEmpty.ts: -------------------------------------------------------------------------------- 1 | export default (target: any): boolean => { 2 | return [Object, Array].indexOf(( 3 | typeof target == 'number' ? target : target || {} 4 | ).constructor) > -1 && 5 | !Object.keys((target || {})).length 6 | } -------------------------------------------------------------------------------- /src/view/about/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const AboutWrapper = styled.div` 5 | color: #333; 6 | font-size: 14px; 7 | line-height: 1.4; 8 | 9 | p { 10 | margin-bottom: 15px; 11 | } 12 | ` 13 | 14 | const About = () => { 15 | return ( 16 | 17 |

CNode 社区为国内最大最具影响力的 Node.js 开源技术社区,致力于 Node.js 的技术研究。

18 |

CNode 社区由一批热爱 Node.js 技术的工程师发起,目前已经吸引了互联网各个公司的专业技术人员加入,我们非常欢迎更多对 Node.js 感兴趣的朋友。

19 |

CNode 的 SLA 保证是,一个9,即 90.000000%。

20 |

社区目前由 @alsotang 在维护,有问题请联系:https://github.com/alsotang

21 |

请关注我们的官方微博:http://weibo.com/cnodejs

22 |
23 | ) 24 | } 25 | 26 | export default About 27 | -------------------------------------------------------------------------------- /src/view/article/code-prettify-sunburst.css: -------------------------------------------------------------------------------- 1 | /* Pretty printing styles. Used with prettify.js. */ 2 | /* Vim sunburst theme by David Leibovic */ 3 | 4 | pre .str, code .str { color: #65B042; } /* string - green */ 5 | pre .kwd, code .kwd { color: #E28964; } /* keyword - dark pink */ 6 | pre .com, code .com { color: #AEAEAE; font-style: italic; } /* comment - gray */ 7 | pre .typ, code .typ { color: #89bdff; } /* type - light blue */ 8 | pre .lit, code .lit { color: #3387CC; } /* literal - blue */ 9 | pre .pun, code .pun { color: #fff; } /* punctuation - white */ 10 | pre .pln, code .pln { color: #fff; } /* plaintext - white */ 11 | pre .tag, code .tag { color: #89bdff; } /* html/xml tag - light blue */ 12 | pre .atn, code .atn { color: #bdb76b; } /* html/xml attribute name - khaki */ 13 | pre .atv, code .atv { color: #65B042; } /* html/xml attribute value - green */ 14 | pre .dec, code .dec { color: #3387CC; } /* decimal - blue */ 15 | 16 | pre.prettyprint, code.prettyprint { 17 | background-color: #000; 18 | border-radius: 8px; 19 | } 20 | 21 | pre.prettyprint { 22 | width: 95%; 23 | margin: 1em auto; 24 | padding: 1em; 25 | white-space: pre-wrap; 26 | } 27 | 28 | 29 | /* Specify class=linenums on a pre to get line numbering */ 30 | ol.linenums { margin-top: 0; margin-bottom: 0; color: #AEAEAE; } /* IE indents via margin-left */ 31 | li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8 { list-style-type: none } 32 | /* Alternate shading for lines */ 33 | li.L1,li.L3,li.L5,li.L7,li.L9 { } 34 | 35 | @media print { 36 | pre .str, code .str { color: #060; } 37 | pre .kwd, code .kwd { color: #006; font-weight: bold; } 38 | pre .com, code .com { color: #600; font-style: italic; } 39 | pre .typ, code .typ { color: #404; font-weight: bold; } 40 | pre .lit, code .lit { color: #044; } 41 | pre .pun, code .pun { color: #440; } 42 | pre .pln, code .pln { color: #000; } 43 | pre .tag, code .tag { color: #006; font-weight: bold; } 44 | pre .atn, code .atn { color: #404; } 45 | pre .atv, code .atv { color: #060; } 46 | } 47 | -------------------------------------------------------------------------------- /src/view/article/comment-panel/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Comment from '../comment' 3 | 4 | import { 5 | CommentPanelWrapper, 6 | Total 7 | } from './style' 8 | 9 | import { Comment as CommentType } from 'types' 10 | 11 | interface IProps { 12 | value?: CommentType[]; 13 | articleAuthor: string; 14 | } 15 | 16 | const CommentPanel: React.FC = (props: IProps) => { 17 | const { value, articleAuthor } = props 18 | 19 | return ( 20 | 21 | 共{value?.length || 0}条评论 22 | { 23 | value?.length 24 | ? value.map((item: CommentType, index: number) => { 25 | return ( 26 | 32 | ) 33 | }) 34 | : 35 |
暂无评论
36 |
37 | } 38 |
39 | ) 40 | } 41 | 42 | CommentPanel.defaultProps = { 43 | value: [] 44 | } 45 | 46 | export { SkeletonComment } from './style' 47 | export default CommentPanel 48 | -------------------------------------------------------------------------------- /src/view/article/comment-panel/style.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import ContentLoader from 'react-content-loader' 4 | 5 | const Total = styled.h3` 6 | margin: 10px 0; 7 | font-size: 16px; 8 | ` 9 | 10 | const CommentPanelWrapper = styled.div` 11 | .empty { 12 | margin: 10px 0; 13 | text-align: center; 14 | } 15 | border-top: 1px solid #ddd; 16 | ` 17 | 18 | export const SkeletonComment = (props: { num?: number }) => { 19 | return ( 20 | <> 21 | 22 | 23 | 24 | 25 | { 26 | Array.from({ length: props.num || 2 }, (v, i) => { 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ) 37 | }) 38 | } 39 | 40 | ) 41 | } 42 | 43 | export { 44 | CommentPanelWrapper, 45 | Total 46 | } -------------------------------------------------------------------------------- /src/view/article/comment/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useHistory, Link } from 'react-router-dom' 3 | import { Comment as CommentWrapper, CommentHeader, CommentInfoBar } from './style' 4 | import Image from 'components/image' 5 | import { format } from 'timeago.js' 6 | 7 | import { Comment as CommentType } from 'types' 8 | 9 | interface IProps { 10 | value?: CommentType; 11 | num?: number; 12 | articleAuthor?: string; 13 | children?: React.ReactElement; 14 | } 15 | 16 | const Comment: React.FC = (props: IProps) => { 17 | const { value, num, articleAuthor = '' } = props 18 | const history = useHistory() 19 | 20 | const visitUser = (e: React.MouseEvent, name: string) => { 21 | e.stopPropagation() 22 | history.push(`/user/${name}`) 23 | } 24 | 25 | return ( 26 | 27 | { 28 | props.children || (value && <> 29 | 30 | visitUser(e, value.author.loginname)} 34 | /> 35 | 36 | 37 |

38 | { value.author.loginname } 39 | { articleAuthor === value.author.loginname ? '(楼主)' : '' } 40 |

41 | 42 |
    43 |
  • { num }楼
  • 44 |
  • { format(value.create_at, 'zh_CN') }
  • 45 |
46 |
47 |
48 | 👍 49 | { value.ups?.length || 0} 50 |
51 |
52 |
53 | ) 54 | } 55 | 56 |
57 | ) 58 | } 59 | 60 | export default Comment 61 | -------------------------------------------------------------------------------- /src/view/article/comment/style.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | const Comment = styled.div` 4 | margin-bottom: 10px; 5 | padding-bottom: 8px; 6 | border-bottom: 1px solid #ddd; 7 | ` 8 | 9 | const CommentHeader = styled.div` 10 | display: flex; 11 | align-items: center; 12 | margin-bottom: 10px; 13 | width: 100%; 14 | 15 | .corner-icon { 16 | color: #888; 17 | &.has-like { 18 | color: #333; 19 | } 20 | } 21 | ` 22 | 23 | const CommentInfoBar = styled.div` 24 | flex: 1; 25 | margin: 0 10px; 26 | 27 | a { color: #333; } 28 | 29 | h3{ 30 | display: inline-block; 31 | margin-bottom: 4px; 32 | font-size: 14px; 33 | } 34 | 35 | ul { 36 | display: flex; 37 | } 38 | 39 | li { 40 | color: #aaa; 41 | font-size: 12px; 42 | &:not(:last-child) { 43 | margin-right: 10px; 44 | } 45 | } 46 | ` 47 | 48 | export { 49 | Comment, 50 | CommentHeader, 51 | CommentInfoBar 52 | } -------------------------------------------------------------------------------- /src/view/article/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import React from 'react' 3 | import { useLocation, useParams } from 'react-router-dom' 4 | 5 | import InfoBar from './info-bar' 6 | import CommentPanel, { SkeletonComment } from './comment-panel' 7 | 8 | import sdk from 'service/cnode-sdk' 9 | import isEmpty from 'utils/isEmpty' 10 | import useAsync from 'hooks/useAsync' 11 | import useInitPosition from 'hooks/useInitPosition' 12 | 13 | import ArticleWrapper, { Title, SkeletonMain } from './style' 14 | 15 | import { 16 | Article as ArticleType 17 | } from 'types' 18 | 19 | import 'github-markdown-css' 20 | import './code-prettify-sunburst.css' 21 | 22 | interface TopicDetailResult { 23 | data: ArticleType 24 | } 25 | 26 | const Article: React.FC = () => { 27 | const { id = '' } = useParams() 28 | 29 | let info = (useLocation().state) as ArticleType 30 | 31 | useInitPosition(0, 0) 32 | 33 | const { loading, result = { data: {} as ArticleType } } = useAsync(() => sdk.getTopicDetail(id)) 34 | 35 | info = isEmpty(result.data) || loading ? info : result.data 36 | 37 | // window.PR 代码高亮,使用的外部js文件 38 | Promise.resolve().then(() => window.PR?.prettyPrint()) 39 | 40 | return ( 41 | 42 | { 43 | info.hasOwnProperty('content') 44 | ? <> 45 | { info && info.title } 46 | 47 |
51 | 52 | : 53 | } 54 | { 55 | !loading 56 | ? 57 | : 58 | } 59 |
60 | ) 61 | } 62 | 63 | export default Article 64 | -------------------------------------------------------------------------------- /src/view/article/info-bar/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useHistory } from 'react-router-dom' 3 | import InfoBarWrapper from './style' 4 | import Image from 'components/image' 5 | import { format } from 'timeago.js' 6 | 7 | import { 8 | Article as ArticleType 9 | } from 'types' 10 | 11 | interface IProps { 12 | value: ArticleType 13 | } 14 | 15 | const InfoBar: React.FC = (props: IProps) => { 16 | const history = useHistory() 17 | 18 | const { 19 | author, 20 | visit_count, 21 | create_at 22 | } = (props.value || {}) 23 | 24 | const visitUser = (e: React.MouseEvent, name: string) => { 25 | e.stopPropagation() 26 | history.push(`/user/${name}`) 27 | } 28 | 29 | return ( 30 | 31 | visitUser(e, author.loginname)} 35 | /> 36 |
    37 |
  • { author?.loginname }
  • 38 |
  • { format(create_at, 'zh_CN') }
  • 39 |
  • { visit_count }次浏览
  • 40 |
41 |
42 | ) 43 | } 44 | 45 | 46 | export default React.memo(InfoBar) -------------------------------------------------------------------------------- /src/view/article/info-bar/style.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const InfoBarWrapper = styled.div` 4 | display: flex; 5 | align-items: center; 6 | margin: 8px 0; 7 | color: #aaa; 8 | font-size: 12px; 9 | 10 | ul { 11 | display: flex; 12 | align-items: center; 13 | margin: 0 8px; 14 | font-size: 12px; 15 | 16 | li:not(:last-child) { 17 | margin-right: 8px; 18 | } 19 | } 20 | ` 21 | 22 | export default InfoBarWrapper -------------------------------------------------------------------------------- /src/view/article/style.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from "styled-components" 3 | import ContentLoader from 'react-content-loader' 4 | 5 | const ArticleWrapper = styled.article` 6 | 7 | ` 8 | 9 | export const Title = styled.h2` 10 | margin-bottom: 6px; 11 | line-height: 1.2; 12 | font-size: 22px; 13 | ` 14 | 15 | export const SkeletonMain = () => { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ) 31 | } 32 | 33 | export default ArticleWrapper -------------------------------------------------------------------------------- /src/view/not-found/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { Link } from 'react-router-dom' 4 | 5 | const NotFoundWrapper = styled.section` 6 | padding: 20px; 7 | text-align: center; 8 | 9 | h2 { 10 | margin: 10px; 11 | font-size: 4em; 12 | } 13 | 14 | h5 { 15 | margin: 5px; 16 | } 17 | 18 | a { 19 | color: #333; 20 | text-decoration: underline; 21 | } 22 | ` 23 | 24 | const NotFoundPage = () => { 25 | return ( 26 | 27 |

404

28 |
Oops, it looks like u are lost.
29 | Back to home page. 30 |
31 | ) 32 | } 33 | 34 | export default NotFoundPage -------------------------------------------------------------------------------- /src/view/topic/card/card.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useHistory } from 'react-router-dom' 3 | import Image from 'components/image' 4 | import Tag from 'components/tag' 5 | import { format } from 'timeago.js' 6 | import CardWrapper, { CardHead, CardBody, Info, Time } from './style' 7 | 8 | import { Topic as TopicType } from 'types' 9 | 10 | interface IProps { 11 | data: TopicType; 12 | onClick?: (e: React.MouseEvent) => void; 13 | } 14 | 15 | const Card: React.FC = (props: IProps) => { 16 | const { data, onClick } = props 17 | const history = useHistory() 18 | 19 | const genTagType = () => { 20 | if (data.top) return 'top' 21 | if (data.good) return 'good' 22 | 23 | return data.tab 24 | } 25 | 26 | const visitUser = (e: React.MouseEvent, name: string) => { 27 | e.stopPropagation() 28 | history.push(`/user/${name}`) 29 | } 30 | 31 | return ( 32 | 33 | 34 | 35 |

{ data.title }

36 |
37 | 38 | 39 | visitUser(e, data.author.loginname)} 43 | /> 44 | 45 |
    46 |
  • 查看数:{data.visit_count}
  • 47 |
  • 回复数:{data.reply_count}
  • 48 |
49 | 50 |
51 |
52 |
53 | ) 54 | } 55 | 56 | export { createSkeleton } from './style' 57 | export default React.memo(Card) 58 | -------------------------------------------------------------------------------- /src/view/topic/card/style.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import ContentLoader from 'react-content-loader' 4 | 5 | const CardWrapper = styled.div` 6 | margin-bottom: 10px; 7 | padding-bottom: 10px; 8 | color: #333; 9 | font-size: 14px; 10 | border-bottom: 1px solid #ddd; 11 | ` 12 | 13 | export const CardHead = styled.div` 14 | display: flex; 15 | align-items: center; 16 | justify-content: space-between; 17 | margin-bottom: 10px; 18 | 19 | h4 { 20 | margin-left: 10px; 21 | flex: 1; 22 | line-height: 1.4; 23 | font-size: 14px; 24 | } 25 | ` 26 | 27 | export const CardBody = styled.div` 28 | display: flex; 29 | align-items: center; 30 | color: #888; 31 | ` 32 | 33 | export const Info = styled.div` 34 | display: flex; 35 | flex: 1; 36 | 37 | ul { 38 | margin-left: 10px; 39 | flex: 1; 40 | 41 | li { 42 | margin: 6px 0; 43 | color: #555; 44 | } 45 | } 46 | ` 47 | 48 | export const Time = styled.div` 49 | padding-top: 30px; 50 | ` 51 | 52 | const Skeleton = (option: any) => { 53 | return 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | } 62 | 63 | export const createSkeleton = (num = 1, option = {}) => { 64 | return Array.from({ length: num }, (v, i) => { 65 | return 66 | }) 67 | } 68 | 69 | export default CardWrapper 70 | -------------------------------------------------------------------------------- /src/view/topic/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo } from 'react' 2 | import { useParams, useHistory } from 'react-router-dom' 3 | import Card, { createSkeleton } from './card/card' 4 | import ScrollList from 'components/scroll-list' 5 | import useLoadMore from 'hooks/useLoadMore' 6 | import sdk from 'service/cnode-sdk' 7 | import isEmpty from 'utils/isEmpty' 8 | import { Topic as TopicType } from 'types' 9 | 10 | const PAGE_SIZE = 20 11 | 12 | const Skeleton = createSkeleton(5) 13 | 14 | const Topic = () => { 15 | const { tag = '' } = useParams() 16 | const history = useHistory() 17 | 18 | const getTopicsByTab = useCallback(info => { 19 | return sdk.getTopicsByTab(tag, info.page || 1, PAGE_SIZE) 20 | }, [tag]) 21 | 22 | const { list, loading, loadMore, completed } = useLoadMore(getTopicsByTab, { 23 | initPageSize: 20, 24 | formatResult: ({ response: { data = [] } = {} }) => ({ list: data }), 25 | isNoMore: ({ data }) => { 26 | return data && data.length > PAGE_SIZE 27 | } 28 | }, [tag]) 29 | 30 | const hasList = useMemo(() => !isEmpty(list), [list]) 31 | 32 | // 点击查看文章详情 33 | const visitArticle = (info: TopicType) => { 34 | history.push({ 35 | pathname: `/article/${info.id}`, 36 | state: info 37 | }) 38 | } 39 | 40 | return ( 41 | <> 42 | { 43 | hasList && 44 | 45 | { 46 | list.map((item: TopicType) => { 47 | return ( 48 | visitArticle(item)} /> 49 | ) 50 | }) 51 | } 52 | 53 | } 54 | { !hasList && Skeleton } 55 | 56 | ) 57 | } 58 | 59 | export default Topic -------------------------------------------------------------------------------- /src/view/user/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useParams, Link } from 'react-router-dom' 3 | 4 | import Image from 'components/image' 5 | import sdk from 'service/cnode-sdk' 6 | import isEmpty from 'utils/isEmpty' 7 | import useAsync from 'hooks/useAsync' 8 | import useInitPosition from 'hooks/useInitPosition' 9 | 10 | import { ArticleLink } from 'types' 11 | 12 | import { 13 | InfoPanel, 14 | InfoContent, 15 | ListPanel, 16 | ListItem, 17 | SkeletonInfo, 18 | SkeletonList 19 | } from './style' 20 | import { format } from 'timeago.js' 21 | 22 | interface UserDetail { 23 | avatar_url: string; 24 | create_at: string; 25 | githubUsername: string; 26 | loginname: string; 27 | recent_replies: ArticleLink[]; 28 | recent_topics: ArticleLink[]; 29 | score: number; 30 | } 31 | 32 | interface InfoProps { 33 | loginname?: string; 34 | avatar_url?: string; 35 | score?: number; 36 | create_at: string; 37 | } 38 | 39 | interface ListProps { 40 | title: string; 41 | value: ArticleLink[]; 42 | } 43 | 44 | const Info: React.FC<{ value: InfoProps | undefined }> = React.memo(props => { 45 | const info = props.value || ({} as InfoProps) 46 | return ( 47 | !isEmpty(info) 48 | ? 49 | 53 | 54 |

{ info?.loginname }

55 |
    56 |
  • 积分:{ info.score || 0 }
  • 57 |
  • 注册于 { format(info.create_at, 'zh_CN') }
  • 58 |
59 |
60 |
61 | : 62 | ) 63 | }) 64 | 65 | const List: React.FC = React.memo(props => { 66 | const { title, value } = props 67 | return ( 68 | !isEmpty(value) 69 | ? 70 |

{ title }

71 | { 72 | value.map(link => { 73 | return ( 74 | 75 | 76 | { link.title } 77 | 78 | 79 | { format(link.last_reply_at, 'zh_CN') } 80 | 81 | 82 | ) 83 | }) 84 | } 85 |
86 | : 87 | ) 88 | }) 89 | 90 | const User: React.FC<{}> = props => { 91 | const { name = '' } = useParams() 92 | 93 | let { 94 | result: infoResult 95 | } = useAsync<{ data: UserDetail }>(() => sdk.getUserDetail(name)) 96 | 97 | let { 98 | result: collectionResult 99 | } = useAsync<{ data: ArticleLink[] }>(() => sdk.getUserCollection(name)) 100 | 101 | useInitPosition(0, 0) 102 | 103 | const info = infoResult ? infoResult.data : ({} as UserDetail) 104 | const collection = collectionResult ? collectionResult.data : ([] as ArticleLink[]) 105 | 106 | return ( 107 |
108 | 109 | 110 | 111 | 112 |
113 | ) 114 | } 115 | 116 | export default User 117 | -------------------------------------------------------------------------------- /src/view/user/style.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from "styled-components" 3 | import { COLOR_THEME } from 'style/constants' 4 | import ContentLoader from 'react-content-loader' 5 | 6 | const InfoPanel = styled.div` 7 | display: flex; 8 | align-items: center; 9 | margin-bottom: 10px; 10 | ` 11 | 12 | const InfoContent = styled.div` 13 | margin: 0 10px; 14 | 15 | h3 { 16 | line-height: 20px; 17 | font-size: 16px; 18 | } 19 | 20 | li { 21 | line-height: 20px; 22 | color: #aaa; 23 | } 24 | ` 25 | 26 | const ListPanel = styled.div` 27 | margin-bottom: 10px; 28 | 29 | h3 { 30 | margin-bottom: 8px; 31 | padding-bottom: 8px; 32 | border-bottom: 1px solid #ddd; 33 | } 34 | ` 35 | 36 | const ListItem = styled.div` 37 | display: flex; 38 | align-items: center; 39 | line-height: 20px; 40 | 41 | a { 42 | display: inline-block; 43 | flex: 1; 44 | color: ${COLOR_THEME}; 45 | text-decoration: none; 46 | text-overflow: ellipsis; 47 | overflow: hidden; 48 | white-space: nowrap; 49 | } 50 | 51 | .create-at { 52 | display: inline-block; 53 | color: #aaa; 54 | } 55 | ` 56 | 57 | const SkeletonInfo = () => { 58 | return ( 59 | 60 | 61 | 62 | 63 | 64 | 65 | ) 66 | } 67 | 68 | const SkeletonList = () => { 69 | return ( 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | ) 78 | } 79 | 80 | export { 81 | InfoPanel, 82 | InfoContent, 83 | ListPanel, 84 | ListItem, 85 | SkeletonInfo, 86 | SkeletonList 87 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "baseUrl": "src", 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------