├── src ├── @types │ └── .gitkeep ├── assets │ ├── scss │ │ ├── CV.scss │ │ ├── app.scss │ │ ├── about.scss │ │ ├── iconfont │ │ │ └── iconfont.css │ │ ├── header.scss │ │ ├── index.scss │ │ ├── common │ │ │ ├── reset.scss │ │ │ └── common.scss │ │ ├── detail.scss │ │ ├── topic.scss │ │ ├── user.scss │ │ └── github-markdown.css │ ├── .DS_Store │ └── images │ │ ├── .DS_Store │ │ ├── index.png │ │ ├── logo.png │ │ ├── loading.gif │ │ └── components │ │ ├── user.png │ │ ├── go_icon.png │ │ ├── nav_icon.png │ │ ├── go_next_icon.png │ │ └── login_icon.png ├── constants │ ├── userinfo.ts │ ├── counter.ts │ └── auth.ts ├── interfaces │ ├── autho.d.ts │ ├── auth.d.ts │ ├── store.d.ts │ ├── index.ts │ ├── topic.d.ts │ ├── member.d.ts │ ├── node.d.ts │ └── thread.d.ts ├── actions │ ├── index.ts │ └── auth.ts ├── reducers │ ├── index.ts │ └── auth.ts ├── components │ ├── loading2 │ │ ├── index.module.scss │ │ └── index.tsx │ ├── backtotop │ │ ├── index.module.scss │ │ └── index.tsx │ ├── topics │ │ └── index.tsx │ ├── loading │ │ ├── index.module.scss │ │ └── index.tsx │ ├── menu │ │ ├── index.module.scss │ │ └── index.tsx │ ├── topic │ │ ├── index.module.scss │ │ └── index.tsx │ ├── activityIndicator │ │ ├── index.tsx │ │ └── index.module.scss │ ├── link │ │ └── index.tsx │ ├── layout │ │ └── index.tsx │ ├── user-info │ │ └── index.tsx │ ├── drawer │ │ └── index.jsx │ ├── header │ │ └── index.tsx │ └── reply │ │ └── index.tsx ├── hoc │ ├── redirect.ts │ └── router.tsx ├── utils │ ├── store │ │ └── index.ts │ └── request.ts ├── store │ ├── index.ts │ └── with-redux-store.tsx ├── ui │ ├── index.module.scss │ └── index.tsx └── libs │ └── utils.tsx ├── next-env.d.ts ├── public └── favicon.ico ├── docs └── ssr-deploy-flow.png ├── nodemon.json ├── sls.js ├── postcss.config.js ├── tsconfig.server.json ├── layer └── serverless.yml ├── .env.example ├── server ├── cache │ └── index.ts └── index.ts ├── pages ├── _app.tsx ├── add │ ├── index.module.scss │ └── index.tsx ├── login │ ├── index.module.scss │ └── index.tsx ├── about │ └── index.tsx ├── index │ └── index.tsx ├── message │ └── index.tsx ├── user │ └── index.tsx └── topic │ └── index.tsx ├── next.config.js ├── .babelrc ├── LICENSE ├── .gitignore ├── serverless.yml ├── README.md ├── tsconfig.json └── package.json /src/@types/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/scss/CV.scss: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/constants/userinfo.ts: -------------------------------------------------------------------------------- 1 | export const GET = 'GET' 2 | export const SET = 'SET' 3 | -------------------------------------------------------------------------------- /src/constants/counter.ts: -------------------------------------------------------------------------------- 1 | export const ADD = 'ADD' 2 | export const MINUS = 'MINUS' 3 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverless-plus/serverless-cnode/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverless-plus/serverless-cnode/HEAD/src/assets/.DS_Store -------------------------------------------------------------------------------- /src/interfaces/autho.d.ts: -------------------------------------------------------------------------------- 1 | export interface IAuthor { 2 | avatar_url: string; 3 | loginname: string; 4 | } 5 | -------------------------------------------------------------------------------- /docs/ssr-deploy-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverless-plus/serverless-cnode/HEAD/docs/ssr-deploy-flow.png -------------------------------------------------------------------------------- /src/assets/images/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverless-plus/serverless-cnode/HEAD/src/assets/images/.DS_Store -------------------------------------------------------------------------------- /src/assets/images/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverless-plus/serverless-cnode/HEAD/src/assets/images/index.png -------------------------------------------------------------------------------- /src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverless-plus/serverless-cnode/HEAD/src/assets/images/logo.png -------------------------------------------------------------------------------- /src/actions/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | auth, 3 | logout, 4 | setAuthRedirectPath, 5 | authCheckState 6 | } from './auth'; 7 | -------------------------------------------------------------------------------- /src/assets/images/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverless-plus/serverless-cnode/HEAD/src/assets/images/loading.gif -------------------------------------------------------------------------------- /src/assets/images/components/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverless-plus/serverless-cnode/HEAD/src/assets/images/components/user.png -------------------------------------------------------------------------------- /src/assets/images/components/go_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverless-plus/serverless-cnode/HEAD/src/assets/images/components/go_icon.png -------------------------------------------------------------------------------- /src/assets/images/components/nav_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverless-plus/serverless-cnode/HEAD/src/assets/images/components/nav_icon.png -------------------------------------------------------------------------------- /src/interfaces/auth.d.ts: -------------------------------------------------------------------------------- 1 | export interface IAuth { 2 | avatar_url: string; 3 | loginname: string; 4 | userId: string; 5 | token: string; 6 | } 7 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["server", "public"], 3 | "exec": "ts-node --project tsconfig.server.json server/index.ts", 4 | "ext": "js ts" 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/images/components/go_next_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverless-plus/serverless-cnode/HEAD/src/assets/images/components/go_next_icon.png -------------------------------------------------------------------------------- /src/assets/images/components/login_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverless-plus/serverless-cnode/HEAD/src/assets/images/components/login_icon.png -------------------------------------------------------------------------------- /src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import auth from "./auth"; 3 | 4 | export default combineReducers({ 5 | auth 6 | }); 7 | -------------------------------------------------------------------------------- /src/components/loading2/index.module.scss: -------------------------------------------------------------------------------- 1 | .loading2 { 2 | width: 100%; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | } 7 | -------------------------------------------------------------------------------- /src/interfaces/store.d.ts: -------------------------------------------------------------------------------- 1 | export interface IStore { 2 | removeItem: Function; 3 | getItem: Function; 4 | setItem: Function; 5 | clear: Function; 6 | } 7 | -------------------------------------------------------------------------------- /sls.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'production'; 2 | process.env.SERVERLESS = true; 3 | 4 | const createServer = require('./dist') 5 | 6 | module.exports = createServer 7 | -------------------------------------------------------------------------------- /src/components/backtotop/index.module.scss: -------------------------------------------------------------------------------- 1 | .icon-top { 2 | position: fixed; 3 | right: 10px; 4 | bottom: 80px; 5 | font-size: 48PX; 6 | z-index: 9999; 7 | color: #42b983; 8 | } 9 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | 'postcss-pxtransform': { 5 | platform: 'h5', 6 | designWidth: 750, 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from '@interfaces/auth'; 2 | export * from '@interfaces/autho'; 3 | export * from '@interfaces/member'; 4 | export * from '@interfaces/node'; 5 | export * from '@interfaces/store'; 6 | export * from '@interfaces/thread'; 7 | export * from '@interfaces/topic'; 8 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "dist", 6 | "target": "es2017", 7 | "isolatedModules": false, 8 | "noEmit": false 9 | }, 10 | "include": ["server/**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /layer/serverless.yml: -------------------------------------------------------------------------------- 1 | org: serverless-cnode 2 | app: serverless-cnode 3 | stage: dev 4 | component: layer 5 | name: serverless-cnode-layer 6 | 7 | inputs: 8 | region: ${env:REGION} 9 | name: ${name} 10 | src: ../node_modules 11 | runtimes: 12 | - Nodejs10.15 13 | - Nodejs12.16 14 | -------------------------------------------------------------------------------- /src/assets/scss/app.scss: -------------------------------------------------------------------------------- 1 | @import 'common/reset.scss'; 2 | @import 'common/common.scss'; 3 | @import 'index.scss'; 4 | @import 'header.scss'; 5 | @import 'about.scss'; 6 | @import 'topic.scss'; 7 | @import 'user.scss'; 8 | @import 'detail.scss'; 9 | @import 'iconfont/iconfont.css'; 10 | @import 'github-markdown.css'; -------------------------------------------------------------------------------- /src/interfaces/topic.d.ts: -------------------------------------------------------------------------------- 1 | import { IAuthor } from './autho'; 2 | export interface ITopic { 3 | id: string; 4 | title: string; 5 | tab: string; 6 | good: boolean; 7 | top: boolean; 8 | author: IAuthor; 9 | reply_count: number; 10 | visit_count: number; 11 | create_at: string; 12 | last_reply_at: string; 13 | } 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 腾讯云授权密钥 2 | TENCENT_SECRET_ID=xxx 3 | TENCENT_SECRET_KEY=xxx 4 | 5 | # 部署地区 6 | REGION=ap-guangzhou 7 | 8 | # 静态资源上传 COS 桶名称 9 | BUCKET=serverless-cnode 10 | 11 | # API 网关自定义域名 和 证书 ID 12 | APIGW_CUSTOM_DOMAIN=cnode.yuga.chat 13 | APIGW_CUSTOM_DOMAIN_CERTID=xxx 14 | 15 | # CDN 域名,证书 ID 16 | CDN_DOMAIN=static.cnode.yuga.chat 17 | CDN_DOMAIN_CERTID=xxx -------------------------------------------------------------------------------- /src/hoc/redirect.ts: -------------------------------------------------------------------------------- 1 | import Router from 'next/router'; 2 | 3 | export default (context, target) => { 4 | if (context.res) { 5 | // server 6 | // 303: "See other" 7 | context.res.writeHead(303, { Location: target }); 8 | context.res.end(); 9 | } else { 10 | // In the browser, we just pretend like this never even happened ;) 11 | Router.replace(target); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/interfaces/member.d.ts: -------------------------------------------------------------------------------- 1 | export interface IMember { 2 | username: string; 3 | website?: null; 4 | github?: null; 5 | psn?: null; 6 | avatar_normal: string; 7 | bio?: null; 8 | url: string; 9 | tagline?: null; 10 | twitter?: null; 11 | created: number; 12 | avatar_large: string; 13 | avatar_mini: string; 14 | location?: null; 15 | btc?: null; 16 | id: number; 17 | } 18 | -------------------------------------------------------------------------------- /src/interfaces/node.d.ts: -------------------------------------------------------------------------------- 1 | export interface INode { 2 | avatar_large: string; 3 | name: string; 4 | avatar_normal: string; 5 | title: string; 6 | url: string; 7 | topics: number; 8 | footer: string; 9 | header: string; 10 | title_alternative: string; 11 | avatar_mini: string; 12 | stars: number; 13 | root: boolean; 14 | id: number; 15 | parent_node_name: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/interfaces/thread.d.ts: -------------------------------------------------------------------------------- 1 | import { INode } from "./node"; 2 | import { IMember } from "./member"; 3 | 4 | export interface IThread { 5 | node: INode; 6 | member: IMember; 7 | last_reply_by: string; 8 | last_touched: number; 9 | title: string; 10 | url: string; 11 | created: number; 12 | content: string; 13 | content_rendered: string; 14 | last_modified: number; 15 | replies: number; 16 | id: number; 17 | } 18 | -------------------------------------------------------------------------------- /src/assets/scss/about.scss: -------------------------------------------------------------------------------- 1 | .about-info { 2 | height: 100%; 3 | padding: 100px 15px; 4 | line-height: 1.5; 5 | background: #f7f7f7; 6 | 7 | dt { 8 | @extend .title; 9 | padding: 1em 0; 10 | font-weight: bold; 11 | } 12 | dd { 13 | padding-bottom: 15px; 14 | font-size: $font-content; 15 | border-bottom: $border; 16 | } 17 | 18 | a { 19 | color: #42b983; 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /server/cache/index.ts: -------------------------------------------------------------------------------- 1 | import LRU from 'lru-cache'; 2 | // github.com/isaacs/node-lru-cache 3 | 4 | const options = { 5 | max: 500, 6 | length: function (n, key) { 7 | return n * 2 + key.length; 8 | }, 9 | maxAge: 1000 * 60 * 60, 10 | }, 11 | cache = new LRU(options); // sets just the max size 12 | 13 | export default cache; 14 | 15 | // export const set = (key, value, maxAge?) => { 16 | // return cache.set(key, value, maxAge); 17 | // }; 18 | 19 | // export const get = (key) => { 20 | // return cache.get(key); 21 | // }; 22 | -------------------------------------------------------------------------------- /src/components/loading2/index.tsx: -------------------------------------------------------------------------------- 1 | import { View } from '@ui'; 2 | import { Component } from 'react'; 3 | import ActivityIndicator from '@components/activityIndicator'; 4 | 5 | import styles from './index.module.scss'; 6 | 7 | export default class Loading extends Component<{ 8 | height: string; 9 | }> { 10 | render() { 11 | const { height = '8rem' } = this.props; 12 | return ( 13 | 14 | 15 | 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/store/index.ts: -------------------------------------------------------------------------------- 1 | import { IStore } from '@interfaces'; 2 | const isServer = typeof window === 'undefined'; 3 | 4 | class Store implements IStore { 5 | removeItem(key) { 6 | return localStorage.removeItem(key); 7 | } 8 | getItem(key) { 9 | if (isServer) { 10 | return '{}'; 11 | } 12 | return localStorage.getItem(key); 13 | } 14 | setItem(key, value) { 15 | return localStorage.setItem(key, value); 16 | } 17 | clear() { 18 | return localStorage.clear(); 19 | } 20 | } 21 | 22 | const instance = new Store(); 23 | export default instance; 24 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import thunkMiddleware from 'redux-thunk'; 3 | import { createLogger } from 'redux-logger'; 4 | import rootReducer from '../reducers'; 5 | const isServer = typeof window === 'undefined'; 6 | 7 | let middlewares = [thunkMiddleware]; 8 | if (isServer) { 9 | middlewares = middlewares.concat([createLogger()]); 10 | } 11 | export const initializeStore = (initialState = {}) => { 12 | const store = createStore( 13 | rootReducer, 14 | initialState, 15 | applyMiddleware(...middlewares), 16 | ); 17 | return store; 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/topics/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { View } from '@ui'; 3 | import { Topic } from '@components/topic'; 4 | import { ITopic } from '@interfaces/topic'; 5 | 6 | interface IProps { 7 | topics: ITopic[]; 8 | } 9 | 10 | class TopicsList extends Component { 11 | static defaultProps = { 12 | topics: [], 13 | }; 14 | 15 | render() { 16 | const { topics } = this.props; 17 | const element = topics.map((topic) => { 18 | return ; 19 | }); 20 | 21 | return {element}; 22 | } 23 | } 24 | 25 | export { TopicsList }; 26 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import App from 'next/app'; 2 | import React from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import { Store } from 'redux'; 5 | import { AppProps } from 'next/app'; 6 | import withReduxStore from '@store/with-redux-store'; 7 | 8 | import '@assets/scss/app.scss'; 9 | 10 | type APageProps = { 11 | reduxStore: Store; 12 | props: any; 13 | }; 14 | 15 | type MyAppProps = AppProps & APageProps; 16 | 17 | // @ts-ignore 18 | class MyApp extends App { 19 | render() { 20 | // @ts-ignore 21 | const { Component, pageProps, reduxStore } = this.props; 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | } 29 | 30 | export default withReduxStore(MyApp); 31 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 3 | const withPlugins = require('next-compose-plugins'); 4 | 5 | const isProd = process.env.NODE_ENV === 'production'; 6 | 7 | // if not use CDN, change to your cos access domain 8 | const STATIC_URL = `https://static.cnode.yuga.chat`; 9 | 10 | 11 | const config = withPlugins([], { 12 | env: { 13 | STATIC_URL: isProd 14 | ? STATIC_URL 15 | : `http://localhost:${parseInt(process.env.PORT, 10) || 8000}`, 16 | }, 17 | assetPrefix: isProd ? STATIC_URL : '', 18 | webpack(config, options) { 19 | config.plugins = config.plugins || []; 20 | if (options.isServer) config.plugins.push(new ForkTsCheckerWebpackPlugin()); 21 | 22 | return config; 23 | }, 24 | }); 25 | 26 | module.exports = config; 27 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [ 4 | [ 5 | "module-resolver", 6 | { 7 | "root": ["./"], 8 | "alias": { 9 | "@assets": "./src/assets", 10 | "@components": "./src/components", 11 | "@ui": "./src/ui", 12 | "@hoc": "./src/hoc", 13 | "@utils": "./src/utils", 14 | "@libs": "./src/libs", 15 | "@interfaces": "./src/interfaces", 16 | "@actions": "./src/actions", 17 | "@store": "./src/store", 18 | "@constants": "./src/constants" 19 | } 20 | } 21 | ], 22 | [ 23 | "@babel/plugin-proposal-decorators", 24 | { 25 | "decoratorsBeforeExport": true 26 | } 27 | ], 28 | ["@babel/plugin-proposal-class-properties"], 29 | ["@babel/plugin-proposal-object-rest-spread"] 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /src/components/loading/index.module.scss: -------------------------------------------------------------------------------- 1 | @keyframes loading { 2 | 0% { 3 | transform: rotate(0deg); 4 | } 5 | 6 | 100% { 7 | transform: rotate(360deg); 8 | } 9 | } 10 | 11 | .at-loading { 12 | display: inline-block; 13 | position: relative; 14 | width: 18PX; 15 | height: 18PX; 16 | 17 | &__ring { 18 | box-sizing: border-box; 19 | display: block; 20 | position: absolute; 21 | width: 18PX; 22 | height: 18PX; 23 | margin: 1PX; 24 | border: 1PX solid #fff; 25 | border-radius: 50%; 26 | animation: loading 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 27 | border-color: #fff transparent transparent transparent; 28 | 29 | &:nth-child(1) { 30 | animation-delay: -0.45s; 31 | } 32 | 33 | &:nth-child(2) { 34 | animation-delay: -0.3s; 35 | } 36 | 37 | &:nth-child(3) { 38 | animation-delay: -0.15s; 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import * as utils from '../libs/utils'; 3 | 4 | export const post = async (options, data?) => { 5 | if (typeof options == 'string') { 6 | options = { 7 | url: options, 8 | }; 9 | } 10 | if (utils.isObject(data)) { 11 | options.data = data; 12 | } 13 | return await axios.request({ 14 | header: { 15 | 'Content-Type': 'application/x-www-form-urlencoded', 16 | Accept: 'application/json', 17 | }, 18 | ...options, 19 | data: utils.param(options.data), 20 | method: 'post', 21 | }); 22 | }; 23 | 24 | export const get = async (options, data?) => { 25 | if (typeof options == 'string') { 26 | options = { 27 | url: options, 28 | }; 29 | } 30 | if (utils.isObject(data)) { 31 | options.data = data; 32 | } 33 | return await axios.get(options.url, { ...options, params: options.data }); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/menu/index.module.scss: -------------------------------------------------------------------------------- 1 | .nav-list { 2 | position: fixed; 3 | top: 0; 4 | bottom: 0; 5 | left: -460px; 6 | width: 460px; 7 | background-color: #fff; 8 | color: #313131; 9 | transition: all .3s ease; 10 | z-index: 99; 11 | 12 | &.show { 13 | transform: translateX(460px); 14 | } 15 | } 16 | 17 | /*侧边栏列表*/ 18 | .list-ul { 19 | margin: 0 24px; 20 | border-top: 1px solid #d4d4d4; 21 | overflow: hidden; 22 | padding-top: 9%; 23 | 24 | .item { 25 | display: block; 26 | // font-size: 14px; 27 | font-weight: 200; 28 | padding: 9% 0; 29 | text-align: left; 30 | text-indent: 1px; 31 | // line-height: 15px; 32 | color: #313131; 33 | font-weight: 700; 34 | 35 | &:last-child { 36 | margin-bottom: 50px; 37 | } 38 | 39 | &:before { 40 | color: #2c3e50; 41 | } 42 | } 43 | 44 | .line { 45 | border-top: 1px solid #d4d4d4; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/constants/auth.ts: -------------------------------------------------------------------------------- 1 | export const ADD_INGREDIENT = "ADD_INGREDIENT"; 2 | export const REMOVE_INGREDIENT = "REMOVE_INGREDIENT"; 3 | export const SET_INGREDIENTS = "SET_INGREDIENTS"; 4 | export const FETCH_INGREDIENTS_FAILED = "FETCH_INGREDIENTS_FAILED"; 5 | 6 | export const PURCHASE_BURGER_START = "PURCHASE_BURGER_START"; 7 | export const PURCHASE_BURGER_SUCCESS = "PURCHASE_BURGER_SUCCESS"; 8 | export const PURCHASE_BURGER_FAIL = "PURCHASE_BURGER_FAIL"; 9 | export const PURCHASE_INIT = "PURCHASE_INIT"; 10 | 11 | export const FETCH_ORDERS_START = "FETCH_ORDERS_START"; 12 | export const FETCH_ORDERS_SUCCESS = "FETCH_ORDERS_SUCCESS"; 13 | export const FETCH_ORDERS_FAIL = "FETCH_ORDERS_FAIL"; 14 | 15 | export const AUTH_START = "AUTH_START"; 16 | export const AUTH_SUCCESS = "AUTH_SUCCESS"; 17 | export const AUTH_FAIL = "AUTH_FAIL"; 18 | export const AUTH_LOGOUT = "AUTH_LOGOUT"; 19 | 20 | export const SET_AUTH_REDIRECT_PATH = "SET_AUTH_REDIRECT_PATH"; 21 | -------------------------------------------------------------------------------- /pages/add/index.module.scss: -------------------------------------------------------------------------------- 1 | .add-container { 2 | margin-top: 100px; 3 | background-color: #fff; 4 | } 5 | 6 | .line { 7 | padding: 10px 15px; 8 | border-bottom: solid 1px #d4d4d4; 9 | font-size: 30px; 10 | } 11 | 12 | .add-btn { 13 | color: #fff; 14 | background-color: #80bd01; 15 | padding: 10px 25px; 16 | border-radius: 5px; 17 | vertical-align: middle; 18 | display: inline; 19 | } 20 | 21 | .add-tab { 22 | display: inline-block; 23 | width: calc(100% - 220px); 24 | min-width: 50%; 25 | background: transparent; 26 | padding: 3px; 27 | margin: 3px 6px; 28 | vertical-align: middle; 29 | border: 1px solid #222; 30 | } 31 | 32 | .add-title { 33 | font-size: 16px; 34 | border: none; 35 | width: 100%; 36 | background: transparent; 37 | height: 25px; 38 | } 39 | 40 | .err { 41 | border: solid 1px red; 42 | } 43 | 44 | .add-content { 45 | margin: 15px 2%; 46 | width: 96%; 47 | border-color: #d4d4d4; 48 | color: #000; 49 | } 50 | 51 | .err { 52 | border: solid 1px red; 53 | } 54 | -------------------------------------------------------------------------------- /src/components/topic/index.module.scss: -------------------------------------------------------------------------------- 1 | .topic { 2 | display: flex; 3 | padding: 15px; 4 | border-bottom: 1px solid #f1f1f1; 5 | flex-direction: column; 6 | } 7 | 8 | .info { 9 | display: flex; 10 | // height: 65px; 11 | // margin-bottom: 5px; 12 | // margin-top: 5px; 13 | 14 | .author { 15 | font-size: 28px; 16 | } 17 | 18 | .replies { 19 | font-size: 20px; 20 | color: darkgray; 21 | } 22 | } 23 | 24 | .bold { 25 | font-size: 32px; 26 | font-weight: bold; 27 | } 28 | 29 | .avatar { 30 | max-width: 65px; 31 | max-height: 65px; 32 | border-radius: 50%; 33 | margin-right: 10px; 34 | } 35 | 36 | .middle { 37 | flex: 1; 38 | display: flex; 39 | margin-left: 10px; 40 | flex-direction: column; 41 | } 42 | 43 | .mr10 { 44 | margin-right: 10px; 45 | } 46 | 47 | .node { 48 | float: right; 49 | 50 | .tag { 51 | font-size: 24px; 52 | padding: 5px 15px; 53 | float: right; 54 | background-color: #eeeeee; 55 | border-radius: 5px; 56 | } 57 | } 58 | 59 | .title { 60 | margin-top: 10px; 61 | font-size: 48px; 62 | } 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Serverless Plus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pages/login/index.module.scss: -------------------------------------------------------------------------------- 1 | .page-body { 2 | margin-top: 100px; 3 | padding: 50px 15px; 4 | min-height: 400px; 5 | background-color: #fff; 6 | .label { 7 | display: inline-block; 8 | width: 100%; 9 | margin-top: 15px; 10 | position: relative; 11 | left: 0; 12 | top: 0; 13 | 14 | .txt { 15 | padding: 12px 0; 16 | border: none; 17 | border-bottom: 1px solid #4fc08d; 18 | background-color: transparent; 19 | width: 100%; 20 | font-size: 24px; 21 | color: #313131; 22 | } 23 | 24 | .button { 25 | display: inline-block; 26 | width: 99%; 27 | height: 80px; 28 | line-height: 80px; 29 | border-radius: 3px; 30 | color: #fff; 31 | font-size: 32px; 32 | background-color: #4fc08d; 33 | border: none; 34 | border-bottom: 4px solid #3aa373; 35 | text-align: center; 36 | vertical-align: middle; 37 | } 38 | 39 | .file { 40 | position: absolute; 41 | top: 0; 42 | left: 0; 43 | height: 42px; 44 | width: 48%; 45 | outline: medium none; 46 | opacity: 0; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | dist 64 | -------------------------------------------------------------------------------- /src/components/activityIndicator/index.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import classNames from 'classnames'; 3 | import Loading from '@components/loading'; 4 | import { View, Text } from '@ui'; 5 | 6 | import './index.module.scss'; 7 | 8 | interface Iprops { 9 | size?: number; 10 | mode?: 'center' | 'normal'; 11 | color?: string; 12 | content?: string; 13 | className?: string; 14 | } 15 | 16 | export default class ActivityIndicator extends Component { 17 | static defaultProps = { 18 | size: 24, 19 | color: '#6190E8', 20 | }; 21 | render() { 22 | const { color, size, mode, content } = this.props; 23 | 24 | const rootClass = classNames( 25 | 'at-activity-indicator', 26 | { 27 | 'at-activity-indicator--center': mode === 'center', 28 | }, 29 | this.props.className, 30 | ); 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | {content && ( 38 | {content} 39 | )} 40 | 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/components/activityIndicator/index.module.scss: -------------------------------------------------------------------------------- 1 | $line-height-base: 1; // 单行 2 | $hd: 2; // 基本单位 3 | $color-grey-2: #999; 4 | $spacing-h-lg: 12px * $hd; 5 | /** 6 | * 元素居中定位 7 | */ 8 | @mixin absolute-center($pos: absolute) { 9 | position: $pos; 10 | top: 50%; 11 | left: 50%; 12 | transform: translate(-50%, -50%); 13 | } 14 | 15 | /* Flex Item */ 16 | @mixin flex($fg: 1, $fs: null, $fb: null) { 17 | flex: $fg $fs $fb; 18 | -webkit-box-flex: $fg; 19 | } 20 | 21 | @mixin flex-order($n) { 22 | order: $n; 23 | -webkit-box-ordinal-group: $n; 24 | } 25 | 26 | @mixin align-self($value: auto) { 27 | align-self: $value; 28 | } 29 | 30 | @mixin display-flex { 31 | display: flex; 32 | } 33 | 34 | .at-activity-indicator { 35 | @include display-flex(); 36 | 37 | line-height: $line-height-base; 38 | 39 | &--center { 40 | @include absolute-center; 41 | } 42 | 43 | &__body { 44 | @include flex(0, 0, auto); 45 | 46 | line-height: 0; 47 | } 48 | 49 | &__content { 50 | @include flex(0, 0, auto); 51 | @include align-self(center); 52 | 53 | font-size: 28px; 54 | margin-left: $spacing-h-lg; 55 | color: $color-grey-2; 56 | } 57 | } -------------------------------------------------------------------------------- /pages/about/index.tsx: -------------------------------------------------------------------------------- 1 | // import { ComponentClass } from 'react' 2 | import React, { Component } from 'react'; 3 | import { ScrollView } from '@ui'; 4 | import Header from '@components/header'; 5 | import Layout from '@components/layout'; 6 | 7 | class About extends Component { 8 | render() { 9 | return ( 10 | 11 |
12 | 13 |
关于项目
14 |
15 | 使用 Next.js + TypeScript 开发,并且基于 Serverless 部署的 cnode 16 | 客户端 17 |
18 |
源码地址
19 |
20 | 21 | https://github.com/serverless-plus/serverless-cnode 22 | 23 |
24 |
意见反馈
25 |
26 | 27 | 发表意见或者提需求 28 | 29 |
30 |
当前版本
31 |
V0.0.1
32 |
33 |
34 | ); 35 | } 36 | } 37 | 38 | export default About; 39 | -------------------------------------------------------------------------------- /src/components/link/index.tsx: -------------------------------------------------------------------------------- 1 | import { View } from '@ui'; 2 | import React, { Component } from 'react'; 3 | import { withRouter } from 'next/router'; 4 | 5 | import * as utils from '@libs/utils'; 6 | 7 | interface IProps { 8 | to: { 9 | url: string; 10 | params?: object; 11 | }; 12 | style?: object; 13 | className?: string; 14 | children?: React.ReactNode; 15 | } 16 | interface PageState {} 17 | 18 | class Link extends Component { 19 | static defaultProps = { 20 | to: { 21 | url: '', 22 | params: {}, 23 | }, 24 | style: {}, 25 | className: '', 26 | children: '', 27 | }; 28 | 29 | goTo = ({ url, params }) => { 30 | const href = url + (params ? '?' + utils.param(params) : ''); 31 | // Router.push(href, as); 32 | window.location.href = href; 33 | return false; 34 | }; 35 | render() { 36 | const { className, style, to, children, ...rest } = this.props; 37 | const withpointer = { ...style, cursor: 'pointer' }; 38 | return ( 39 | 44 | {children} 45 | 46 | ); 47 | } 48 | } 49 | 50 | export default withRouter(Link as React.ComponentType); 51 | -------------------------------------------------------------------------------- /src/components/loading/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { View } from '@ui'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import styles from './index.module.scss'; 6 | 7 | export default class Loading extends Component<{ 8 | size; 9 | color; 10 | }> { 11 | static defaultProps = { 12 | size: '18', 13 | color: '#fff', 14 | }; 15 | 16 | static propTypes = { 17 | size: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 18 | color: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 19 | }; 20 | 21 | render() { 22 | const { color, size } = this.props; 23 | const sizeStyle = { 24 | width: `${size}px`, 25 | height: `${size}px`, 26 | }; 27 | const colorStyle = { 28 | border: `1px solid ${color}`, 29 | borderColor: `${color} transparent transparent transparent`, 30 | }; 31 | const ringStyle = Object.assign({}, colorStyle, sizeStyle); 32 | 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/assets/scss/iconfont/iconfont.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'iconfont'; 3 | src: url('//at.alicdn.com/t/font_1449145493_7316074.eot'); /* IE9*/ 4 | src: url('//at.alicdn.com/t/font_1449145493_7316074.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ 5 | url('//at.alicdn.com/t/font_1449145493_7316074.woff') format('woff'), /* chrome、firefox */ 6 | url('//at.alicdn.com/t/font_1449145493_7316074.ttf') format('truetype'), /* chrome、firefox、opera、Safari, Android, iOS 4.2+*/ 7 | url('//at.alicdn.com/t/font_1449145493_7316074.svg#iconfont') format('svg'); /* iOS 4.1- */ 8 | } 9 | 10 | 11 | .iconfont { 12 | font-family:"iconfont" !important; 13 | font-size: 32px; 14 | font-style:normal; 15 | -webkit-font-smoothing: antialiased; 16 | -webkit-text-stroke-width: 0.2px; 17 | -moz-osx-font-smoothing: grayscale; 18 | } 19 | .icon-tianjia:before { content: "\e60d"; margin-right: 30px; } 20 | .icon-fenxiang:before { content: "\e600"; margin-right: 30px; } 21 | .icon-shezhi:before { content: "\e601"; margin-right: 30px;} 22 | .icon-wenda:before { content: "\e602"; margin-right: 30px;} 23 | .icon-hao:before { content: "\e603"; margin-right: 30px;} 24 | .icon-quanbu:before { content: "\e604"; margin-right: 30px;} 25 | .icon-zhaopin:before { content: "\e605"; margin-right: 30px;} 26 | .icon-xiaoxi:before { content: "\e606"; margin-right: 30px;} 27 | .icon-about:before { content: "\e607"; margin-right: 30px;} 28 | -------------------------------------------------------------------------------- /src/components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import { View } from '@ui'; 3 | import Head from 'next/head'; 4 | 5 | export default class Layout extends Component<{ 6 | className?: string; 7 | title?: string; 8 | }> { 9 | render() { 10 | const { children, className, title } = this.props; 11 | const clsName = className || 'flex-wrp'; 12 | 13 | const staticMarkup = `!function(x){function w() { var v, u, t, tes, s = x.document, r = s.documentElement, a = r.getBoundingClientRect().width; if (!v && !u) { var n = !!x.navigator.appVersion.match(/AppleWebKit.*Mobile.*/); v = x.devicePixelRatio; tes = x.devicePixelRatio; v = n ? v : 1, u = 1 / v } if (a >= 640) { r.style.fontSize = "40px" } else { if (a <= 320) { r.style.fontSize = "20px" } else { r.style.fontSize = a / 320 * 20 + "px" } } }x.addEventListener("resize",function(){w()});w()}(window);`; 14 | 15 | return ( 16 | 17 | 18 | 19 | 23 | {title ? {title} : ''} 24 | 25 | 26 | 27 | {children} 28 | 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | org: serverless-cnode 2 | app: serverless-cnode 3 | stage: dev 4 | component: nextjs 5 | name: serverless-cnode 6 | 7 | inputs: 8 | src: 9 | dist: ./ 10 | hook: npm run build:sls 11 | exclude: 12 | - .env 13 | - '.git/**' 14 | - 'docs/**' 15 | - '.next/cache/**' 16 | - 'node_modules/**' 17 | region: ${env:REGION} 18 | runtime: Nodejs10.15 19 | functionName: serverless-cnode 20 | layers: 21 | - name: ${output:${stage}:${app}:${name}-layer.name} 22 | version: ${output:${stage}:${app}:${name}-layer.version} 23 | functionConf: 24 | timeout: 10 25 | environment: 26 | variables: 27 | NODE_ENV: production 28 | SERVERLESS: true 29 | apigatewayConf: 30 | protocols: 31 | - http 32 | - https 33 | environment: release 34 | enableCORS: true 35 | customDomains: 36 | - domain: ${env:APIGW_CUSTOM_DOMAIN} 37 | certificateId: ${env:APIGW_CUSTOM_DOMAIN_CERTID} 38 | isDefaultMapping: false 39 | pathMappingSet: 40 | - path: / 41 | environment: release 42 | protocols: 43 | - http 44 | - https 45 | staticConf: 46 | cosConf: 47 | bucket: ${env:BUCKET} 48 | cdnConf: 49 | # after you deploy CDN once, just set onlyRefresh to true for refresh CDN cache 50 | onlyRefresh: true 51 | domain: ${env:CDN_DOMAIN} 52 | https: 53 | certId: ${env:CDN_DOMAIN_CERTID} 54 | -------------------------------------------------------------------------------- /src/ui/index.module.scss: -------------------------------------------------------------------------------- 1 | .taro-img { 2 | display: inline-block; 3 | overflow: hidden; 4 | position: relative; 5 | font-size: 0; 6 | width: 320px; 7 | height: 240px; 8 | 9 | &.taro-img__widthfix { 10 | height: 100%; 11 | } 12 | 13 | &__mode { 14 | &-scaletofill { 15 | width: 100%; 16 | height: 100%; 17 | } 18 | 19 | &-aspectfit { 20 | max-width: 100%; 21 | max-height: 100%; 22 | } 23 | 24 | &-aspectfill { 25 | min-width: 100%; 26 | height: 100%; 27 | } 28 | 29 | &-widthfix { 30 | width: 100%; 31 | } 32 | 33 | &-top { 34 | width: 100%; 35 | } 36 | 37 | &-bottom { 38 | width: 100%; 39 | position: absolute; 40 | bottom: 0; 41 | } 42 | 43 | &-left { 44 | height: 100%; 45 | } 46 | 47 | &-right { 48 | position: absolute; 49 | height: 100%; 50 | right: 0; 51 | } 52 | 53 | &-topleft {} 54 | 55 | &-topright { 56 | position: absolute; 57 | right: 0; 58 | } 59 | 60 | &-bottomleft { 61 | position: absolute; 62 | bottom: 0; 63 | } 64 | 65 | &-bottomright { 66 | position: absolute; 67 | right: 0; 68 | bottom: 0; 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serverless Cnode 2 | 3 | [在线预览](https://cnode.yuga.chat) 4 | 5 | 使用 Next.js + TypeScript 开发,并且基于 Serverless 部署的 cnode 客户端 6 | 7 | ## 流程图 8 | 9 | ![Deploy Flow](./docs/ssr-deploy-flow.png) 10 | 11 | ## 功能 12 | 13 | - [x] Typescript 14 | - [x] Next.js 15 | - [x] 自定义 Express Server 16 | - [x] LRU Render Cache 17 | - [x] 基于 Serverless Next.js 组件部署 18 | - [x] **静态资源分离,自动部署到 COS** 19 | - [x] **自动为静态 COS 配置 CDN** 20 | - [x] **node_modules 基于层部署,大大提高部署效率** 21 | 22 | ## 本地开发 23 | 24 | ```bash 25 | $ npm install 26 | 27 | $ npm run dev 28 | ``` 29 | 30 | ## 构建 31 | 32 | ```bash 33 | $ npm run build 34 | ``` 35 | 36 | ## 配置 37 | 38 | 在部署到 Serverless 前,将 `.env.example` 重命名为 `.env`,并请完成如下配置: 39 | 40 | ```dotenv 41 | # 腾讯云授权密钥 42 | TENCENT_APP_ID=xxx 43 | TENCENT_SECRET_ID=xxx 44 | TENCENT_SECRET_KEY=xxx 45 | 46 | # 部署地区 47 | REGION=ap-guangzhou 48 | 49 | # 静态资源上传 COS 桶名称 50 | BUCKET=serverless-cnode 51 | 52 | # API 网关自定义域名 和 证书 ID 53 | APIGW_CUSTOM_DOMAIN=cnode.yuga.chat 54 | APIGW_CUSTOM_DOMAIN_CERTID=xxx 55 | 56 | # CDN 域名,证书 ID 57 | CDN_DOMAIN=static.cnode.yuga.chat 58 | CDN_DOMAIN_CERTID=xxx 59 | ``` 60 | 61 | > 注意:如果不需要使用 CDN,直接使用 COS 自动生成的域名,也是可以的,只需要删除 62 | > `serverless.yml` 中的 `cdnConf` 即可。 63 | 64 | ## 部署 65 | 66 | 此项目会先将 `node_modules` 部署到 67 | [层](https://cloud.tencent.com/document/product/583/40159),然后在部署项目代码, 68 | 这样下次部署项目时,如果 `node_modules` 没有修改,我们就不需要部署庞大的 69 | `node_modules` 文件夹了。 70 | 71 | 1. 部署层: 72 | 73 | ```bash 74 | $ npm run deploy:layer 75 | ``` 76 | 77 | > 注意:如果项目 `node_modules` 没有变更,就不需要执行此命令。 78 | 79 | 2. 部署业务代码: 80 | 81 | ```bash 82 | $ npm run deploy 83 | ``` 84 | 85 | ## License 86 | 87 | MIT 88 | -------------------------------------------------------------------------------- /src/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React from 'react'; 3 | 4 | import styles from './index.module.scss'; 5 | 6 | export const View = (props) => { 7 | const { children, className, ...reset } = props; 8 | return ( 9 |
10 | {children} 11 |
12 | ); 13 | }; 14 | 15 | export const ScrollView = (props) => { 16 | const { children, className, ...reset } = props; 17 | return ( 18 |
19 | {children} 20 |
21 | ); 22 | }; 23 | 24 | export const Text = (props) => { 25 | const { children, className, ...reset } = props; 26 | return ( 27 | 28 | {children} 29 | 30 | ); 31 | }; 32 | 33 | interface Imageprops { 34 | src: string; 35 | mode?: 'widthFix' | 'scaleToFill'; 36 | onLoad?: React.ReactEventHandler; 37 | onError?: React.ReactEventHandler; 38 | style?: any; 39 | className?: string; 40 | } 41 | 42 | export const Image = ({ 43 | className, 44 | src, 45 | style, 46 | mode, 47 | onLoad, 48 | onError, 49 | ...reset 50 | }: Imageprops) => { 51 | const cls = classNames( 52 | styles['taro-img'], 53 | mode === 'widthFix' && style['taro-img__widthfix'], 54 | className, 55 | ); 56 | const imgCls = 57 | 'taro-img__mode-' + 58 | (mode || 'scaleToFill').toLowerCase().replace(/\s/g, ''); 59 | 60 | return ( 61 |
62 | 68 |
69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /src/store/with-redux-store.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "react"; 2 | import { initializeStore } from './' 3 | const isServer = typeof window === 'undefined' 4 | const __NEXT_REDUX_STORE__ = '__NEXT_REDUX_STORE__' 5 | 6 | function getOrCreateStore(initialState = {}) { 7 | // Always make a new store if server, otherwise state is shared between requests 8 | if (isServer) { 9 | return initializeStore(initialState) 10 | } 11 | 12 | // Create store if unavailable on the client and set it on the window object 13 | if (!window[__NEXT_REDUX_STORE__]) { 14 | window[__NEXT_REDUX_STORE__] = initializeStore(initialState) 15 | } 16 | return window[__NEXT_REDUX_STORE__] 17 | } 18 | 19 | 20 | 21 | export default (RApp): React.ReactNode => { 22 | return class AppWithRedux extends Component { 23 | static async getInitialProps(appContext) { 24 | // Get or Create the store with `undefined` as initialState 25 | // This allows you to set a custom default initialState 26 | const reduxStore = getOrCreateStore(); 27 | 28 | // Provide the store to getInitialProps of pages 29 | appContext.ctx.reduxStore = reduxStore; 30 | 31 | let appProps = {}; 32 | if (typeof RApp.getInitialProps === "function") { 33 | appProps = await RApp.getInitialProps.call(RApp, appContext); 34 | } 35 | 36 | return { ...appProps, initialReduxState: reduxStore.getState() }; 37 | } 38 | constructor(props) { 39 | super(props); 40 | // @ts-ignore 41 | this.reduxStore = getOrCreateStore(props.initialReduxState); 42 | } 43 | render() { 44 | // @ts-ignore 45 | return ; 46 | } 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "jsx": "preserve", 8 | "allowJs": true, 9 | "experimentalDecorators": true, 10 | "allowSyntheticDefaultImports": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "removeComments": false, 14 | "preserveConstEnums": true, 15 | "sourceMap": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "strict": false, 19 | "noEmit": true, 20 | "esModuleInterop": true, 21 | "resolveJsonModule": true, 22 | "isolatedModules": true, 23 | "baseUrl": ".", 24 | "paths": { 25 | "@assets": ["src/assets"], 26 | "@assets/*": ["src/assets/*"], 27 | "@components": ["src/components"], 28 | "@components/*": ["src/components/*"], 29 | "@ui": ["src/ui"], 30 | "@ui/*": ["src/ui/*"], 31 | "@hoc": ["src/hoc"], 32 | "@hoc/*": ["src/hoc/*"], 33 | "@utils": ["src/utils"], 34 | "@utils/*": ["src/utils/*"], 35 | "@libs": ["src/libs"], 36 | "@libs/*": ["src/libs/*"], 37 | "@interfaces": ["src/interfaces"], 38 | "@interfaces/*": ["src/interfaces/*"], 39 | "@actions": ["src/actions"], 40 | "@actions/*": ["src/actions/*"], 41 | "@store": ["src/store"], 42 | "@store/*": ["src/store/*"], 43 | "@constants": ["src/constants"], 44 | "@constants/*": ["src/constants/*"] 45 | } 46 | }, 47 | "exclude": [ 48 | "node_modules", 49 | "dist", 50 | ".next", 51 | "out", 52 | "next.config.js", 53 | ".babelrc", 54 | "bundles", 55 | "coverage", 56 | "test/*" 57 | ], 58 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 59 | } 60 | -------------------------------------------------------------------------------- /src/reducers/auth.ts: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../constants/auth'; 2 | import { updateObject } from '../libs/utils'; 3 | 4 | const initialState = { 5 | // token: null, 6 | // userId: null, 7 | error: null, 8 | loading: false, 9 | loginname: null, 10 | avatar_url: null, 11 | userId: null, 12 | token: null, 13 | authRedirectPath: '/', 14 | }; 15 | // @ts-ignore 16 | const authStart = (state, action) => { 17 | return updateObject(state, { error: null, loading: true }); 18 | }; 19 | 20 | const authSuccess = (state, action) => { 21 | return updateObject(state, { 22 | token: action.token, 23 | userId: action.userId, 24 | loginname: action.loginname, 25 | avatar_url: action.avatar_url, 26 | error: null, 27 | loading: false, 28 | }); 29 | }; 30 | 31 | const authFail = (state, action) => { 32 | return updateObject(state, { 33 | error: action.error, 34 | loading: false, 35 | }); 36 | }; 37 | // @ts-ignore 38 | const authLogout = (state, action) => { 39 | return updateObject(state, { 40 | loginname: null, 41 | avatar_url: null, 42 | userId: null, 43 | token: null, 44 | }); 45 | }; 46 | 47 | const setAuthRedirectPath = (state, action) => { 48 | return updateObject(state, { authRedirectPath: action.path }); 49 | }; 50 | 51 | const reducer = (state = initialState, action) => { 52 | switch (action.type) { 53 | case actionTypes.AUTH_START: 54 | return authStart(state, action); 55 | case actionTypes.AUTH_SUCCESS: 56 | return authSuccess(state, action); 57 | case actionTypes.AUTH_FAIL: 58 | return authFail(state, action); 59 | case actionTypes.AUTH_LOGOUT: 60 | return authLogout(state, action); 61 | case actionTypes.SET_AUTH_REDIRECT_PATH: 62 | return setAuthRedirectPath(state, action); 63 | default: 64 | return state; 65 | } 66 | }; 67 | 68 | export default reducer; 69 | -------------------------------------------------------------------------------- /src/components/backtotop/index.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentClass } from 'react'; 2 | import React, { Component } from 'react'; 3 | import { View } from '@ui'; 4 | import { throttle } from 'throttle-debounce'; 5 | import * as utils from '@libs/utils'; 6 | 7 | import styles from './index.module.scss'; 8 | 9 | type PageOwnProps = {}; 10 | 11 | type PageState = {}; 12 | 13 | type IProps = PageOwnProps; 14 | 15 | interface BackTop { 16 | props: IProps; 17 | scrollbinding: () => void; 18 | } 19 | 20 | class BackTop extends Component { 21 | componentScrollBox; 22 | constructor(props) { 23 | super(props); 24 | if (utils.getEnv() == 'WEB') { 25 | this.componentScrollBox = document.documentElement; 26 | } 27 | } 28 | state = { 29 | show: false, 30 | }; 31 | 32 | componentWillUnmount() { 33 | if (utils.getEnv() == 'WEB') { 34 | window.removeEventListener('scroll', this.scrollbinding); 35 | } 36 | } 37 | 38 | componentDidMount() { 39 | if (utils.getEnv() == 'WEB') { 40 | this.scrollbinding = throttle(300, this.handleScroll); 41 | window.addEventListener('scroll', this.scrollbinding); 42 | } 43 | } 44 | // web 45 | handleScroll = () => { 46 | const scrollTop = this.componentScrollBox.scrollTop; 47 | const show = scrollTop >= 0.5 * document.body.clientHeight; 48 | this.setState({ 49 | show: show, 50 | }); 51 | }; 52 | goTop = () => { 53 | if (utils.getEnv() == 'WEB') { 54 | this.componentScrollBox.scrollTop = 0; 55 | } 56 | }; 57 | render() { 58 | const { show } = this.state; 59 | return ( 60 | 61 | {show ? ( 62 | 65 |  66 | 67 | ) : ( 68 | '' 69 | )} 70 | 71 | ); 72 | } 73 | } 74 | 75 | export default BackTop as ComponentClass; 76 | -------------------------------------------------------------------------------- /src/components/user-info/index.tsx: -------------------------------------------------------------------------------- 1 | // import { ComponentClass } from 'react' 2 | import React, { Component } from 'react'; 3 | import { View, Image, Text } from '@ui'; 4 | import Link from '@components/link'; 5 | import { connect } from 'react-redux'; 6 | import * as actions from '@actions/auth'; 7 | import { IAuth } from '@interfaces/auth'; 8 | 9 | type PageStateProps = { 10 | userInfo: IAuth; 11 | }; 12 | 13 | type PageDispatchProps = { 14 | authCheckState: () => void; 15 | }; 16 | 17 | type PageOwnProps = {}; 18 | 19 | type PageState = {}; 20 | 21 | type IProps = PageStateProps & PageDispatchProps & PageOwnProps; 22 | 23 | class UserInfo extends Component { 24 | componentWillMount() { 25 | this.props.authCheckState(); 26 | } 27 | render() { 28 | const userInfo = this.props.userInfo; 29 | return ( 30 | 31 | {!userInfo.loginname ? ( 32 | 33 | 34 | 登录 35 | 36 | 37 | ) : ( 38 | 41 | 42 | {userInfo.avatar_url ? ( 43 | 44 | ) : ( 45 | '' 46 | )} 47 | 48 | 49 | {userInfo.loginname ? {userInfo.loginname} : ''} 50 | 51 | 52 | )} 53 | 54 | ); 55 | } 56 | } 57 | 58 | export default connect( 59 | ({ auth }) => ({ 60 | userInfo: auth, 61 | }), 62 | (dispatch: Function) => ({ 63 | authCheckState() { 64 | dispatch(actions.authCheckState()); 65 | }, 66 | }), 67 | )(UserInfo); 68 | -------------------------------------------------------------------------------- /src/components/topic/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { View, Text, Image } from '@ui'; 3 | import Link from '@components/link'; 4 | 5 | // import api from '../lib/utils/api' 6 | import * as utils from '@libs/utils'; 7 | import { ITopic } from '@interfaces/topic'; 8 | 9 | import styles from './index.module.scss'; 10 | 11 | interface IProps { 12 | topic: ITopic; 13 | key: string; 14 | } 15 | 16 | class Topic extends Component { 17 | getTabInfo(tab, good, top, isClass) { 18 | return utils.getTabInfo(tab, good, top, isClass); 19 | } 20 | render() { 21 | const { 22 | title, 23 | tab, 24 | good, 25 | top, 26 | author, 27 | visit_count, 28 | reply_count, 29 | create_at, 30 | last_reply_at, 31 | id, 32 | } = this.props.topic; 33 | 34 | const classnames = 'stitle ' + this.getTabInfo(tab, good, top, true); 35 | const tit = this.getTabInfo(tab, good, top, false); 36 | return ( 37 | 42 | 43 | 44 | 45 | 46 | 47 | {author.loginname} 48 | {reply_count > 0 ? ( 49 | 50 | {reply_count}/ {visit_count} 51 | 52 | ) : ( 53 | '' 54 | )} 55 | 56 | 57 | 58 | {utils.getLastTimeStr(create_at, true)} 59 | 60 | 61 | {utils.getLastTimeStr(last_reply_at, true)} 62 | 63 | 64 | 65 |
66 | 67 | ); 68 | } 69 | } 70 | 71 | export { Topic }; 72 | -------------------------------------------------------------------------------- /src/assets/scss/header.scss: -------------------------------------------------------------------------------- 1 | #hd { 2 | border-bottom: 1px solid #e8e8e8; 3 | &.fix-header { 4 | width: 100%; 5 | background-color: rgba(255, 255, 255, 0.95); 6 | position: fixed; 7 | top: 0; 8 | left: 0; 9 | transition: all 0.3s ease; 10 | box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); 11 | z-index: 6; 12 | } 13 | &.no-fix { 14 | width: 100%; 15 | background-color: #fff; 16 | overflow: hidden; 17 | } 18 | &.show { 19 | transform: translateX(460px); 20 | } 21 | } 22 | .nv-toolbar { 23 | width: 100%; 24 | height: 100px; 25 | display: flex; 26 | align-items: center; 27 | 28 | .toolbar-nav { 29 | width: 82px; 30 | height: 100px; 31 | position: absolute; 32 | background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAAgBAMAAACMSheAAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAD1BMVEUAAAAxMTExMTExMTEAAADLnhNSAAAAA3RSTlMAvdLDwKfTAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEgAACxIB0t1+/AAAABtJREFUKM9jEDZGB0YM2MRGAQPDaFgRD4gMKwBpixXx/+q05AAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAxNS0xMC0xMFQxMDowNzoxOSswODowMDBkz6cAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMTUtMTAtMTBUMTA6MDc6MTkrMDg6MDBBOXcbAAAAAElFTkSuQmCC') 33 | no-repeat center center; 34 | background-size: 38px 32px; 35 | margin: 0; 36 | z-index: 1; 37 | top: 0; 38 | left: 0; 39 | } 40 | 41 | & > span { 42 | display: block; 43 | text-align: center; 44 | height: 100%; 45 | line-height: 100px; 46 | font-size: 38px; 47 | width: 100%; 48 | position: relative; 49 | z-index: 0; 50 | } 51 | .num { 52 | background-color: #80bd01; 53 | color: #fff; 54 | width: 20px; 55 | height: 20px; 56 | line-height: 20px; 57 | vertical-align: middle; 58 | text-align: center; 59 | border-radius: 50%; 60 | position: absolute; 61 | right: 10px; 62 | top: 10px; 63 | z-index: 10; 64 | } 65 | .add-icon { 66 | color: #42b983; 67 | position: absolute; 68 | right: 15px; 69 | top: 15px; 70 | z-index: 10; 71 | font-size: 38px; 72 | padding: 5px 15px; 73 | border-radius: 5px; 74 | } 75 | } 76 | .scroll-hide { 77 | height: 100%; 78 | overflow: hidden; 79 | } 80 | -------------------------------------------------------------------------------- /src/components/menu/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { View } from '@ui'; 3 | import UserInfo from '@components/user-info'; 4 | import Drawer from '@components/drawer'; 5 | import Link from '@components/link'; 6 | 7 | import styles from './index.module.scss'; 8 | 9 | interface IProps { 10 | showMenu: boolean; 11 | pageType: string; 12 | nickName: string; 13 | profileUrl: string; 14 | } 15 | 16 | class NvMenu extends Component { 17 | render() { 18 | const { showMenu } = this.props; 19 | return ( 20 | 23 | 24 | 25 | 26 | 29 | 全部 30 | 31 | 34 | 精华 35 | 36 | 39 | 分享 40 | 41 | 44 | 问答 45 | 46 | 49 | 招聘 50 | 51 | 54 | 消息 55 | 56 | 59 | 关于 60 | 61 | 62 | 63 | 64 | ); 65 | } 66 | } 67 | 68 | export default NvMenu; 69 | -------------------------------------------------------------------------------- /pages/login/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { View } from '@ui'; 3 | import Header from '@components/header/index'; 4 | import { withUser } from '@hoc/router'; 5 | import * as utils from '@libs/utils'; 6 | import Layout from '@components/layout'; 7 | 8 | import styles from './index.module.scss'; 9 | 10 | type PageStateProps = {}; 11 | 12 | type PageDispatchProps = { 13 | authLogin: (token) => Promise; 14 | }; 15 | 16 | type PageOwnProps = {}; 17 | 18 | type IProps = PageStateProps & PageDispatchProps & PageOwnProps; 19 | 20 | interface PageState { 21 | token: any; 22 | err: any; 23 | } 24 | 25 | class Login extends Component { 26 | state = { 27 | token: '', 28 | err: { 29 | isHiddenIcon: true, 30 | iconSize: 36, 31 | iconType: 'error', 32 | iconColor: '#f00', 33 | text: '', 34 | }, 35 | }; 36 | 37 | showMessage(message) { 38 | utils.showToast({ title: message }); 39 | } 40 | logon = () => { 41 | if (this.state.token === '') { 42 | this.showMessage('令牌格式错误,应为36位UUID字符串'); 43 | return false; 44 | } 45 | this.props.authLogin(this.state.token).then(() => { 46 | utils.navigateTo({ url: '/' }); 47 | }); 48 | }; 49 | handleChange(e) { 50 | this.setState({ token: e.target.value }); 51 | } 52 | render() { 53 | const { token } = this.state; 54 | console.log('token', token); 55 | 56 | return ( 57 | 58 |
59 | 60 | 61 | 69 | 70 | 71 | 72 | 登录 73 | 74 | 75 | 76 | 77 | ); 78 | } 79 | } 80 | 81 | export default withUser(Login, true); 82 | -------------------------------------------------------------------------------- /src/assets/scss/index.scss: -------------------------------------------------------------------------------- 1 | #brim-mask { 2 | pointer-events: none; 3 | } 4 | 5 | #page { 6 | padding-top: 100px; 7 | background-color: #fff; 8 | transition: all .3s ease; 9 | // overflow-x:hidden; 10 | } 11 | 12 | .show-menu { 13 | transform: translateX($gap*40); 14 | } 15 | 16 | .page-cover { 17 | position: fixed; 18 | top: 0; 19 | right: 0; 20 | bottom: 0; 21 | left: 0; 22 | background: rgba(0, 0, 0, .4); 23 | z-index: 7; 24 | } 25 | 26 | 27 | /*模块入口样式*/ 28 | 29 | .posts-list { 30 | background-color: $white; 31 | 32 | .topic { 33 | padding: 20px $padding; 34 | border-bottom: $border; 35 | } 36 | 37 | .stitle { 38 | @extend .title; 39 | 40 | &:before { 41 | content: attr(title); 42 | margin-right: 10PX; 43 | margin-top: -3px; 44 | @extend .tag; 45 | color: $white; 46 | } 47 | 48 | &.top:before { 49 | background: #E74C3C; 50 | } 51 | 52 | &.ask:before { 53 | background: #3498DB; 54 | } 55 | 56 | &.good:before { 57 | background: #E67E22; 58 | } 59 | 60 | &.job:before { 61 | background: #9B59B6; 62 | } 63 | 64 | &.share:before { 65 | background: #1ABC9C; 66 | } 67 | } 68 | 69 | .content { 70 | padding-top: 10Px; 71 | display: flex; 72 | } 73 | 74 | .avatar { 75 | @extend .user-avatar; 76 | } 77 | 78 | .info { 79 | display: block; 80 | width: 100%; 81 | flex: 1; 82 | 83 | div { 84 | padding: 3px 0; 85 | display: flex; 86 | color: $text; 87 | font-size: $font-info; 88 | 89 | &:first-child { 90 | font-size: $font-content; 91 | } 92 | 93 | .name, 94 | .time:first-child { 95 | display: block; 96 | width: 100%; 97 | flex: 1; 98 | } 99 | 100 | b { 101 | color: #42b983; 102 | } 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /src/assets/scss/common/reset.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Global Reset of all HTML Elements 3 | * 4 | * Resetting all of our HTML Elements ensures a smoother 5 | * visual transition between browsers. If you don't believe me, 6 | * try temporarily commenting out this block of code, then go 7 | * and look at Mozilla versus Safari, both good browsers with 8 | * a good implementation of CSS. The thing is, all browser CSS 9 | * defaults are different and at the end of the day if visual 10 | * consistency is what we're shooting for, then we need to 11 | * make sure we're resetting all spacing elements. 12 | * 13 | */ 14 | html, body { 15 | height: 100%; 16 | border: 0; 17 | font-family: "Helvetica-Neue", "Helvetica", Arial, sans-serif; 18 | line-height: 1.5; 19 | margin: 0; 20 | padding: 0; 21 | box-sizing: border-box; 22 | } 23 | 24 | div, span, object, iframe, img, table, caption, thead, tbody, 25 | tfoot, tr, tr, td, article, aside, canvas, details, figure, hgroup, menu, 26 | nav, footer, header, section, summary, mark, audio, video { 27 | border: 0; 28 | margin: 0; 29 | padding: 0; 30 | box-sizing: border-box; 31 | } 32 | 33 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, address, cit, code, 34 | del, dfn, em, ins, q, samp, small, strong, sub, sup, b, i, hr, dl, dt, dd, 35 | ol, ul, li, fieldset, legend, label { 36 | border: 0; 37 | font-size: 100%; 38 | vertical-align: baseline; 39 | margin: 0; 40 | padding: 0; 41 | box-sizing: border-box; 42 | } 43 | 44 | article, aside, canvas, figure, figure img, figcaption, hgroup, 45 | footer, header, nav, section, audio, video { 46 | display: block; 47 | box-sizing: border-box; 48 | } 49 | 50 | table { 51 | border-collapse: separate; 52 | border-spacing: 0; 53 | caption, th, td { 54 | text-align: left; 55 | vertical-align: middle; 56 | } 57 | } 58 | li{ 59 | list-style: none; 60 | } 61 | a{ 62 | text-decoration: none; 63 | } 64 | a img { 65 | border: 0; 66 | } 67 | 68 | :focus { 69 | outline: 0; 70 | } 71 | 72 | 73 | textarea{ 74 | resize: none; 75 | appearance: none; 76 | box-sizing: border-box; 77 | } 78 | input{ 79 | appearance: none; 80 | box-sizing: border-box; 81 | } 82 | select { 83 | appearance: none; 84 | background-color: #fff; 85 | box-sizing: border-box; 86 | } 87 | -------------------------------------------------------------------------------- /src/components/drawer/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { View } from '@ui'; 3 | 4 | export default class Drawer extends Component { 5 | constructor() { 6 | super(...arguments); 7 | this.state = { animShow: false }; 8 | if (this.props.show) this.animShow(); 9 | } 10 | // onItemClick (index, e) { 11 | // this.props.onItemClick && this.props.onItemClick(index) 12 | // this.animHide(e, index) 13 | // } 14 | 15 | onHide() { 16 | this.setState({ show: false }); 17 | this.props.onClose && this.props.onClose(); 18 | } 19 | 20 | animHide() { 21 | this.setState({ 22 | animShow: false, 23 | }); 24 | this.props.onStartHide && this.props.onStartHide(); 25 | setTimeout(() => { 26 | this.onHide(...arguments); 27 | }, 300); 28 | } 29 | 30 | animShow() { 31 | this.setState({ show: true }); 32 | setTimeout(() => { 33 | this.setState({ 34 | animShow: true, 35 | }); 36 | }, 200); 37 | } 38 | 39 | onMaskClick() { 40 | this.animHide(...arguments); 41 | } 42 | 43 | componentWillReceiveProps(props) { 44 | const { show } = props; 45 | if (show !== this.props.show) { 46 | if (show) this.animShow(); 47 | else this.animHide(...arguments); 48 | } 49 | } 50 | 51 | render() { 52 | const { 53 | mask, 54 | width, 55 | right, 56 | // items, 57 | } = this.props; 58 | const { animShow, show } = this.state; 59 | let rootClassName = ['at-drawer']; 60 | 61 | const maskStyle = { 62 | display: mask ? 'block' : 'none', 63 | opacity: animShow ? 1 : 0, 64 | }; 65 | const listStyle = { 66 | // width, 67 | // transition: animShow ? 'all 225ms cubic-bezier(0, 0, 0.2, 1)' : 'all 195ms cubic-bezier(0.4, 0, 0.6, 1)', 68 | }; 69 | if (right) rootClassName.push('at-drawer--right'); 70 | else rootClassName.push('at-drawer--left'); 71 | 72 | if (animShow) rootClassName.push('at-drawer--show'); 73 | rootClassName = rootClassName.filter((str) => str !== ''); 74 | 75 | return show ? ( 76 | 77 | 81 | 82 | {this.props.children} 83 | 84 | 85 | ) : ( 86 |
87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/hoc/router.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import * as actions from '../actions/auth'; 4 | import { IAuth } from '../interfaces/auth'; 5 | import * as utils from '@libs/utils'; 6 | import redirect from './redirect'; 7 | 8 | type PageStateProps = { 9 | userInfo: IAuth; 10 | }; 11 | 12 | type PageDispatchProps = { 13 | authLogin: (token) => void; 14 | authCheckState: () => void; 15 | }; 16 | 17 | type PageOwnProps = {}; 18 | 19 | type PageState = {}; 20 | 21 | type IProps = PageStateProps & PageDispatchProps & PageOwnProps; 22 | 23 | function withUser(WrappedComponent, allowNologin = false) { 24 | class WithUserHOC extends Component { 25 | static async getInitialProps(context) { 26 | const { reduxStore, query, req } = context; 27 | const log = reduxStore.dispatch(actions.authCheckState()); 28 | if (!(allowNologin || log)) { 29 | // Already signed in? No need to continue. 30 | // Throw them back to the main page 31 | redirect(context, '/login'); 32 | return {}; 33 | } 34 | let appProps = {}; 35 | if (typeof WrappedComponent.getInitialProps === 'function') { 36 | appProps = await WrappedComponent.getInitialProps.call( 37 | WrappedComponent, 38 | context, 39 | ); 40 | } 41 | return { 42 | ...appProps, 43 | query, 44 | isServer: !!req, 45 | }; 46 | } 47 | 48 | isSuperRender() { 49 | const props = this.props; 50 | return allowNologin || (props.userInfo && props.userInfo.userId); 51 | } 52 | // refer https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Redirect.js 53 | perform() { 54 | if (!this.isSuperRender()) { 55 | utils.redirectTo({ url: '/login' }); 56 | } 57 | } 58 | componentDidMount() { 59 | this.perform(); 60 | } 61 | render() { 62 | return ; 63 | // if (this.isSuperRender()) { 64 | // return ; 65 | // } else { 66 | // return null; 67 | // } 68 | } 69 | } 70 | return connect( 71 | ({ auth }) => ({ userInfo: auth }), 72 | (dispatch: Function) => ({ 73 | authLogin: (token) => dispatch(actions.auth(token)), 74 | authCheckState: () => dispatch(actions.authCheckState()), 75 | }), 76 | )(WithUserHOC); 77 | } 78 | 79 | export { Component, withUser }; 80 | -------------------------------------------------------------------------------- /src/components/header/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import classNames from 'classnames'; 3 | import { View, Text } from '@ui'; 4 | import NvMenu from '../menu'; 5 | import Link from '../link'; 6 | 7 | type IProps = { 8 | pageType: string; 9 | fixHead: boolean; 10 | messageCount?: number; 11 | scrollTop?: number; 12 | needAdd?: boolean; 13 | showMenu?: boolean; 14 | }; 15 | 16 | interface IState { 17 | nickname: string; 18 | profileimgurl: string; 19 | show: boolean; 20 | } 21 | 22 | class Header extends Component { 23 | static defaultProps = { 24 | messageCount: 0, 25 | scrollTop: 0, 26 | needAdd: false, 27 | showMenu: true, 28 | }; 29 | 30 | state = { 31 | nickname: '', 32 | profileimgurl: '', 33 | show: false, 34 | }; 35 | 36 | openMenu = () => { 37 | this.setState({ 38 | show: !this.state.show, 39 | }); 40 | }; 41 | showMenus = () => { 42 | this.setState({ 43 | show: !this.state.show, 44 | }); 45 | }; 46 | 47 | render() { 48 | const { show, nickname, profileimgurl } = this.state; 49 | const { needAdd, pageType, fixHead, messageCount } = this.props; 50 | const classnames = classNames({ 51 | show: show && fixHead, 52 | 'fix-header': fixHead, 53 | 'no-fix': !fixHead, 54 | }); 55 | return ( 56 | 57 | {show && fixHead ? ( 58 | 59 | 60 | 61 | ) : ( 62 | '' 63 | )} 64 | 65 | 66 | {fixHead ? ( 67 | 68 | ) : ( 69 | '' 70 | )} 71 | {pageType} 72 | {messageCount > 0 ? ( 73 | {messageCount} 74 | ) : ( 75 | '' 76 | )} 77 | {(needAdd && !messageCount) || messageCount <= 0 ? ( 78 | 79 |  80 | 81 | ) : ( 82 | '' 83 | )} 84 | 85 | 86 | 92 | {/* {fixHead ? "" : ""} */} 93 | 94 | ); 95 | } 96 | } 97 | 98 | export default Header; 99 | -------------------------------------------------------------------------------- /src/actions/auth.ts: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '@constants/auth'; 2 | import { post } from '@utils/request'; 3 | import store from '@utils/store'; 4 | 5 | export const authStart = () => { 6 | return { 7 | type: actionTypes.AUTH_START, 8 | }; 9 | }; 10 | 11 | export const authSuccess = (user) => { 12 | return { type: actionTypes.AUTH_SUCCESS, ...user }; 13 | }; 14 | 15 | export const authFail = (error) => { 16 | return { 17 | type: actionTypes.AUTH_FAIL, 18 | error: error, 19 | }; 20 | }; 21 | 22 | export const logout = () => { 23 | store.removeItem('user'); 24 | // store.removeItem('expirationDate'); 25 | // store.removeItem('userId'); 26 | return { 27 | type: actionTypes.AUTH_LOGOUT, 28 | }; 29 | }; 30 | 31 | export const checkAuthTimeout = (expirationTime) => { 32 | return (dispatch) => { 33 | setTimeout(() => { 34 | dispatch(logout()); 35 | }, expirationTime * 1000); 36 | }; 37 | }; 38 | 39 | export const auth = (accesstoken) => { 40 | return async (dispatch) => { 41 | dispatch(authStart()); 42 | const userInfo = { accesstoken: accesstoken }; 43 | try { 44 | const response = await post({ 45 | url: 'https://cnodejs.org/api/v1/accesstoken', 46 | data: userInfo, 47 | }); 48 | 49 | if (response.data && response.data.success) { 50 | let res = response.data; 51 | let user = { 52 | loginname: res.loginname, 53 | avatar_url: res.avatar_url, 54 | userId: res.id, 55 | token: accesstoken, 56 | }; 57 | // const expirationDate = new Date(new Date().getTime() + response.data.expiresIn * 1000); 58 | store.setItem('user', JSON.stringify(user)); 59 | dispatch(authSuccess(user)); 60 | // store.setItem("expirationDate", expirationDate); 61 | // store.setItem("userId", response.data.localId); 62 | // dispatch(checkAuthTimeout(response.data.expiresIn)); 63 | } 64 | } catch (err) { 65 | dispatch(authFail(err.response.data.error)); 66 | } 67 | }; 68 | }; 69 | 70 | export const setAuthRedirectPath = (path) => { 71 | return { 72 | type: actionTypes.SET_AUTH_REDIRECT_PATH, 73 | path: path, 74 | }; 75 | }; 76 | 77 | export const authCheckState = () => { 78 | return (dispatch) => { 79 | const token = store.getItem('user'); 80 | if (!token) { 81 | // dispatch(logout()); 82 | } else { 83 | // const expirationDate = new Date(store.getItem("expirationDate")); 84 | // && expirationDate <= new Date() 85 | if (false) { 86 | dispatch(logout()); 87 | } else { 88 | // const userId = store.getItem("userId"); 89 | dispatch(authSuccess(JSON.parse(token))); 90 | // dispatch(checkAuthTimeout((expirationDate.getTime() - new Date().getTime()) / 1000)); 91 | } 92 | } 93 | }; 94 | }; 95 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import Next from 'next'; 3 | import Express from 'express'; 4 | import Cache from './cache'; 5 | 6 | const dev = process.env.NODE_ENV !== 'production'; 7 | const port = parseInt(process.env.PORT, 10) || 8000; 8 | 9 | const app = Next({ dev }); 10 | const handle = app.getRequestHandler(); 11 | 12 | function getCacheKey(req) { 13 | return `${req.url}`; 14 | } 15 | async function cacheRender(req, res) { 16 | const key = getCacheKey(req); 17 | // reder /list to / 18 | // const reqPath = req.path === '/list' ? '/' : req.path; 19 | const reqPath = req.path; 20 | if (Cache.has(key)) { 21 | res.setHeader('X-Cache', 'HIT'); 22 | res.send(Cache.get(key)); 23 | return; 24 | } 25 | 26 | try { 27 | const html = await app.renderToHTML(req, res, reqPath, req.query); 28 | if (res.statusCode !== 200) { 29 | res.send(html); 30 | return; 31 | } else { 32 | Cache.set(key, html); 33 | res.setHeader('X-Cache', 'MISS'); 34 | res.send(html); 35 | } 36 | } catch (err) { 37 | res.statusCode = 500; 38 | app.renderError(err, req, res, reqPath, req.query); 39 | } 40 | } 41 | 42 | // not report route for custom monitor 43 | const noReportRoutes = ['/_next', '/static']; 44 | 45 | async function startServer() { 46 | await app.prepare(); 47 | 48 | const server = Express(); 49 | server.use(Express.static(join(__dirname, '../public/static'))); 50 | 51 | server.get('/', async (req, res) => { 52 | return cacheRender(req, res); 53 | }); 54 | 55 | server.get('/user', async (req, res) => { 56 | return cacheRender(req, res); 57 | }); 58 | 59 | server.get('/about', async (req, res) => { 60 | return cacheRender(req, res); 61 | }); 62 | 63 | server.get('/login', async (req, res) => { 64 | return handle(req, res); 65 | }); 66 | 67 | server.get('/add', async (req, res) => { 68 | return handle(req, res); 69 | }); 70 | server.get('/message', async (req, res) => { 71 | return handle(req, res); 72 | }); 73 | 74 | server.get('*', (req, res) => { 75 | noReportRoutes.forEach((route) => { 76 | if (req.path.indexOf(route) === 0) { 77 | req['__SLS_NO_REPORT__'] = true; 78 | } 79 | }); 80 | return handle(req, res); 81 | }); 82 | 83 | // define binary type for response 84 | // if includes, will return base64 encoded, very useful for images 85 | server['binaryTypes'] = ['*/*']; 86 | 87 | return server; 88 | } 89 | 90 | if (process.env.SERVERLESS) { 91 | module.exports = startServer; 92 | } else { 93 | try { 94 | startServer().then((server) => { 95 | server.listen(port, () => { 96 | console.log(`> Ready on http://localhost:${port}`); 97 | }); 98 | }); 99 | } catch (e) { 100 | throw e; 101 | } 102 | } 103 | 104 | process.on('unhandledRejection', (e) => { 105 | throw e; 106 | }); 107 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-cnode", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "dev": "nodemon", 6 | "build": "rimraf .next && cross-env NODE_ENV=production next build && tsc --project tsconfig.server.json", 7 | "build:sls": "rimraf .next && cross-env NODE_ENV=production SERVERLESS=true next build && tsc --project tsconfig.server.json", 8 | "start": "cross-env NODE_ENV=production node ./dist/index.js", 9 | "deploy": "serverless deploy", 10 | "deploy:layer": "serverless deploy --target=./layer" 11 | }, 12 | "keywords": [ 13 | "next.js", 14 | "cnode" 15 | ], 16 | "license": "MIT", 17 | "dependencies": { 18 | "axios": "^0.19.2", 19 | "classnames": "^2.2.6", 20 | "express": "^4.17.1", 21 | "immutability-helper": "^3.1.1", 22 | "koa": "^2.12.1", 23 | "koa-router": "^9.0.1", 24 | "lodash": "^4.17.19", 25 | "lru-cache": "^5.1.1", 26 | "markdown": "^0.5.0", 27 | "next": "^9.4.4", 28 | "prop-types": "^15.7.2", 29 | "react": "^16.13.1", 30 | "react-dom": "^16.13.1", 31 | "react-redux": "^7.2.0", 32 | "redux": "^4.0.5", 33 | "redux-logger": "^3.0.6", 34 | "redux-thunk": "^2.3.0", 35 | "throttle-debounce": "^2.2.1", 36 | "timeago.js": "^4.0.2" 37 | }, 38 | "devDependencies": { 39 | "@babel/core": "^7.10.2", 40 | "@babel/generator": "^7.10.2", 41 | "@babel/plugin-proposal-class-properties": "^7.10.1", 42 | "@babel/plugin-proposal-decorators": "^7.10.1", 43 | "@babel/plugin-proposal-object-rest-spread": "^7.10.1", 44 | "@types/koa": "^2.11.3", 45 | "@types/koa-router": "^7.4.1", 46 | "@types/lru-cache": "^5.1.0", 47 | "@types/next": "^9.0.0", 48 | "@types/react": "^16.9.38", 49 | "@zeit/next-css": "^1.0.1", 50 | "@zeit/next-sass": "^1.0.1", 51 | "@zeit/next-typescript": "^1.1.1", 52 | "autoprefixer": "^9.8.0", 53 | "babel-loader": "^8.1.0", 54 | "babel-plugin-module-resolver": "^4.0.0", 55 | "babel-plugin-transform-assets": "^1.0.2", 56 | "cross-env": "^7.0.2", 57 | "css-loader": "^3.6.0", 58 | "dotenv-webpack": "^1.8.0", 59 | "file-loader": "^6.0.0", 60 | "fork-ts-checker-webpack-plugin": "^5.0.1", 61 | "next-compose-plugins": "^2.2.0", 62 | "next-images": "^1.4.0", 63 | "next-optimized-images": "^2.6.1", 64 | "node-sass": "^4.14.1", 65 | "nodemon": "^2.0.4", 66 | "postcss-loader": "^3.0.0", 67 | "postcss-pxtransform": "^2.2.9", 68 | "rimraf": "^3.0.2", 69 | "sass-loader": "^8.0.2", 70 | "ts-node": "^8.10.2", 71 | "tslint": "^6.1.2", 72 | "typescript": "^3.9.5", 73 | "url-loader": "^4.1.0" 74 | }, 75 | "description": "> A cnodejs client using next.js", 76 | "repository": { 77 | "type": "git", 78 | "url": "git+https://github.com/serverless-plus/serverless-cnode.git" 79 | }, 80 | "author": "yugasun", 81 | "bugs": { 82 | "url": "https://github.com/serverless-plus/serverless-cnode/issues" 83 | }, 84 | "homepage": "https://github.com/serverless-plus/serverless-cnode#readme" 85 | } 86 | -------------------------------------------------------------------------------- /src/assets/scss/detail.scss: -------------------------------------------------------------------------------- 1 | .page { 2 | background-color: $colorfff; 3 | height: 100%; 4 | padding: $gap*3 0; 5 | 6 | .reply_num { 7 | margin-top: $gap*4; 8 | background-color: $colore7; 9 | padding: $gap*2 0 $gap*2 $gap*2; 10 | border-top: solid 1px $colord4; 11 | border-bottom: solid 1px $colord4; 12 | } 13 | .reply-list { 14 | width: 100%; 15 | margin-top: $gap*3; 16 | .ul { 17 | width: 100%; 18 | list-style: none; 19 | padding-left: 0; 20 | .li { 21 | width: 100%; 22 | list-style: none; 23 | border-bottom: solid 1px $colord4; 24 | &:last-child { 25 | border-bottom: none; 26 | } 27 | .uped { 28 | color: $color80; 29 | } 30 | .icon { 31 | font-size: 26PX; 32 | } 33 | } 34 | } 35 | } 36 | .topic_content { 37 | margin: 0 $gap*2; 38 | } 39 | .reply { 40 | width: 100%; 41 | margin-top: $gap*3; 42 | border-top: solid 1px $colord4; 43 | .text { 44 | margin: $gap*3 2%; 45 | width: 96%; 46 | border-color: $colord4; 47 | color: #000; 48 | } 49 | .btn { 50 | display: inline-block; 51 | border-radius: $gap; 52 | text-align: center; 53 | font-size: 16px; 54 | margin: 0 2% $gap*3; 55 | width: 96%; 56 | background-color: $color80; 57 | padding: $gap*2 0; 58 | color: $colorfff; 59 | } 60 | .err { 61 | border-color: red; 62 | } 63 | } 64 | .message { 65 | border-bottom: solid 1px $colord4; 66 | padding: $gap*2 0; 67 | } 68 | .tabs { 69 | width: 100%; 70 | // height: $gap* 8.2; 71 | // margin-top: $gap* 9; 72 | list-style: none; 73 | display: flex; 74 | // position: relative; 75 | // margin-bottom: -1px; 76 | .item { 77 | width: 50%; 78 | padding: $gap*2.3 0; 79 | flex: 0 1 auto; 80 | font-size: 16PX; 81 | text-align: center; 82 | font-weight: bold; 83 | border-bottom: solid 1px $colord4; 84 | 85 | } 86 | .read { 87 | font-size:$gap*5; 88 | color: $color80; 89 | position:absolute; 90 | right:$gap*1; 91 | top:$gap*1.5; 92 | font-weight:bold; 93 | } 94 | .br { 95 | border-right: solid 1px $colord4; 96 | } 97 | .selected { 98 | color: $red; 99 | border-bottom: solid 2px $red; 100 | } 101 | } 102 | .icon-empty { 103 | font-size: $gap*25; 104 | display: block; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/assets/scss/topic.scss: -------------------------------------------------------------------------------- 1 | .topic-title { 2 | @extend .title-nooverflow; 3 | padding: $gap; 4 | margin:$padding; 5 | color: $title; 6 | background-color:$colorf0; 7 | border-radius:$gap; 8 | font-weight: 700; 9 | } 10 | 11 | .author-info { 12 | display: flex; 13 | align-items: center; 14 | padding: 0 $padding; 15 | color: $text; 16 | font-size: 12px; 17 | .col { 18 | display: block; 19 | width: 100%; 20 | flex: 1; 21 | } 22 | .avatar { 23 | display: block; 24 | width: 40Px; 25 | height: 40Px; 26 | margin-right: $padding; 27 | border-radius: 50%; 28 | } 29 | .right{ 30 | text-align: right; 31 | } 32 | span, time { 33 | display: block; 34 | padding: 5px 0; 35 | } 36 | .tag { 37 | @extend .tag; 38 | color: #ffffff; 39 | &.top { 40 | background: #E74C3C; 41 | } 42 | &.ask { 43 | background: #3498DB; 44 | } 45 | &.good { 46 | background: #E67E22; 47 | } 48 | &.job { 49 | background: #9B59B6; 50 | } 51 | &.share { 52 | background: #1ABC9C; 53 | } 54 | } 55 | } 56 | .topic-content { 57 | padding: $padding; 58 | margin-top: $padding; 59 | background: #ffffff; 60 | border-bottom: solid 1px $colord4; 61 | .from{ 62 | color:$red; 63 | } 64 | } 65 | .topic-reply { 66 | @extend .title; 67 | padding: $padding; 68 | border-bottom: solid 1px $colord4; 69 | strong { 70 | color: #42b983; 71 | } 72 | } 73 | .reply_num { 74 | margin-top: $gap*4; 75 | background-color: $colore7; 76 | padding: $gap*2 0 $gap*2 $gap*2; 77 | border-top: solid 1px $colord4; 78 | border-bottom: solid 1px $colord4; 79 | } 80 | .reply-list { 81 | width: 100%; 82 | margin-top: $gap*3; 83 | .ul { 84 | width: 100%; 85 | list-style: none; 86 | padding-left: 0; 87 | .li { 88 | width: 100%; 89 | list-style: none; 90 | border-bottom: solid 1px $colord4; 91 | &:last-child { 92 | border-bottom: none; 93 | } 94 | .uped { 95 | color: $color80; 96 | } 97 | .icon { 98 | font-size: 26PX; 99 | } 100 | .from{ 101 | color:$red; 102 | } 103 | .language-javascript{ 104 | background-color:$colorf0; 105 | overflow-x:auto; 106 | } 107 | } 108 | } 109 | } 110 | 111 | /* 回复框样式 */ 112 | 113 | .reply { 114 | margin: 0 $padding; 115 | textarea { 116 | display: block; 117 | width: 100%; 118 | flex: 1; 119 | border: $border; 120 | background-color: #fff; 121 | font-size: 14px; 122 | padding: $padding; 123 | color: #313131; 124 | } 125 | .button { 126 | display: inline-block; 127 | width: 100%; 128 | height: 80px; 129 | margin: $padding 0; 130 | line-height: 80px; 131 | color: #fff; 132 | font-size: 32px; 133 | background-color: #4fc08d; 134 | border: none; 135 | border-bottom: 4px solid #3aa373; 136 | text-align: center; 137 | vertical-align: middle; 138 | } 139 | } 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /src/assets/scss/user.scss: -------------------------------------------------------------------------------- 1 | /*侧边栏用户信息区域*/ 2 | .user-info { 3 | padding: 15px; 4 | font-size: 24px; 5 | color: #313131; 6 | } 7 | 8 | /*未登录*/ 9 | .login-no { 10 | overflow: hidden; 11 | margin: 8px 9px; 12 | & > .login { 13 | float: right; 14 | height: 24px; 15 | line-height: 24px; 16 | padding-left: 34px; 17 | position: relative; 18 | &:before { 19 | width: 24px; 20 | height: 24px; 21 | content: ''; 22 | position: absolute; 23 | left: 0; 24 | top: 0; 25 | } 26 | } 27 | .login { 28 | float: left; 29 | &:before { 30 | background: url('../images/components/login_icon.png') no-repeat left 31 | center; 32 | background-size: 24px 24px; 33 | } 34 | } 35 | } 36 | 37 | /*已登录*/ 38 | .login-yes { 39 | height: 100%; 40 | background: url('../images/components/go_next_icon.png') no-repeat right 41 | center; 42 | background-size: 6px 10px; 43 | overflow: hidden; 44 | position: relative; 45 | .avertar { 46 | width: 40px; 47 | height: 40px; 48 | background: url('../images/components/user.png') no-repeat center center; 49 | background-size: 40px 40px; 50 | border-radius: 20px; 51 | overflow: hidden; 52 | float: left; 53 | & > img { 54 | width: 40px; 55 | height: 40px; 56 | display: block; 57 | } 58 | } 59 | .info { 60 | margin-left: 10px; 61 | overflow: hidden; 62 | float: left; 63 | } 64 | p { 65 | width: 85px; 66 | overflow: hidden; 67 | white-space: nowrap; 68 | text-overflow: ellipsis; 69 | font-size: 12px; 70 | line-height: 12px; 71 | line-height: 40px; 72 | &.lh20 { 73 | line-height: 20px; 74 | } 75 | } 76 | &:after { 77 | display: block; 78 | background: url('../images/components/go_icon.png') no-repeat center right; 79 | background-size: 7px 7px; 80 | } 81 | } 82 | 83 | .userinfo { 84 | margin-top: 100px; 85 | width: 100%; 86 | background-color: $colore7; 87 | text-align: center; 88 | // height: 180px; 89 | .u-img { 90 | width: 100px; 91 | height: 100px; 92 | border-radius: 50%; 93 | margin-top: $gap * 3; 94 | display: inline-block; 95 | background: url('../images/components/user.png') no-repeat center center; 96 | background-size: 100px 100px; 97 | } 98 | .u-name { 99 | color: #000; 100 | } 101 | .u-bottom { 102 | background-color: $colore7; 103 | width: 100%; 104 | margin-top: 20px; 105 | padding-bottom: 20px; 106 | display: flex; 107 | .u-time { 108 | width: 50%; 109 | padding-left: $gap * 2; 110 | justify-items: center; 111 | align-items: center; 112 | justify-content: center; 113 | } 114 | 115 | .u-score { 116 | width: 50%; 117 | text-align: right; 118 | padding-right: $gap * 2; 119 | color: $color80; 120 | justify-items: center; 121 | align-items: center; 122 | justify-content: center; 123 | } 124 | } 125 | } 126 | 127 | .message { 128 | background-color: #fff; 129 | padding: $gap; 130 | border-bottom: solid 1px $colord4; 131 | } 132 | 133 | .user-tabs { 134 | width: 100%; 135 | // height: $gap*8.2; 136 | list-style: none; 137 | 138 | display: flex; 139 | // position: relative; 140 | .item { 141 | width: 50%; 142 | padding: $gap * 2.3 0; 143 | flex: 0 1 auto; 144 | font-size: 32px; 145 | text-align: center; 146 | font-weight: bold; 147 | border-bottom: solid 1px $colord4; 148 | } 149 | .read { 150 | font-size: $gap * 5; 151 | color: $color80; 152 | position: absolute; 153 | right: $gap * 1; 154 | top: $gap * 1.5; 155 | font-weight: bold; 156 | } 157 | .br { 158 | border-right: solid 1px $colord4; 159 | } 160 | .selected { 161 | color: $red; 162 | border-bottom: solid 2px $red; 163 | } 164 | } 165 | 166 | .topics { 167 | .icon-empty { 168 | font-size: $gap * 25; 169 | color: $colord4; 170 | display: block; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/components/reply/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { View } from '@ui'; 3 | import * as utils from '@libs/utils'; 4 | import { withUser } from '@hoc/router'; 5 | import classNames from 'classnames'; 6 | import update from 'immutability-helper'; 7 | import { post } from '@utils/request'; 8 | 9 | const markdown = require('markdown').markdown; 10 | 11 | interface updateRepliesFunc { 12 | (func: any): any; 13 | } 14 | 15 | type Iprops = { 16 | userInfo; 17 | topic; 18 | topicId; 19 | replyId; 20 | replyTo?; 21 | show; 22 | updateReplies: updateRepliesFunc; 23 | onClose: () => void; 24 | }; 25 | 26 | type PageState = { 27 | hasErr; 28 | content; 29 | author_txt; 30 | }; 31 | 32 | // props: ['topic', 'replyId', 'topicId', 'replyTo', 'show'], 33 | class Reply extends Component { 34 | state = { 35 | hasErr: false, 36 | content: '', 37 | author_txt: 38 | '\n\n 来自拉风的 [React-cnode](https://github.com/icai/taro-cnode)', 39 | }; 40 | 41 | handleChange = (e) => { 42 | this.setState({ 43 | content: e.target.value, 44 | }); 45 | }; 46 | componentDidMount() { 47 | if (this.props.replyTo) { 48 | this.setState({ 49 | content: `@${this.props.replyTo}`, 50 | }); 51 | } 52 | } 53 | addReply() { 54 | const { content, author_txt } = this.state; 55 | const { userInfo, topicId, replyId, show, updateReplies } = this.props; 56 | if (!content) { 57 | this.setState({ hasErr: true }); 58 | } else { 59 | let time = new Date(); 60 | let linkUsers = utils.linkUsers(content); 61 | let htmlText = markdown.toHTML(linkUsers) + author_txt; 62 | let replyContent = utils.getContentHtml(htmlText); 63 | let postData: any = { 64 | accesstoken: userInfo.token, 65 | content: content + author_txt, 66 | }; 67 | if (replyId) { 68 | postData.reply_id = replyId; 69 | } 70 | 71 | post({ 72 | data: postData, 73 | url: `https://cnodejs.org/api/v1/topic/${topicId}/replies`, 74 | }) 75 | .then((resp) => { 76 | let res = resp.data; 77 | if (res.success) { 78 | updateReplies && 79 | updateReplies((topic, context) => { 80 | const newreplies = update(topic.replies, { 81 | $push: [ 82 | { 83 | id: res.reply_id, 84 | author: { 85 | loginname: userInfo.loginname, 86 | avatar_url: userInfo.avatar_url, 87 | }, 88 | content: replyContent, 89 | ups: [], 90 | create_at: time, 91 | }, 92 | ], 93 | }); 94 | topic.replies = newreplies; 95 | context.setState({ topic: topic }); 96 | }); 97 | this.setState({ content: '' }); 98 | if (show) { 99 | this.props.onClose(); 100 | } 101 | } else { 102 | utils.showToast({ title: res.error_msg }); 103 | } 104 | }) 105 | .catch((resp) => { 106 | console.info(resp); 107 | }); 108 | } 109 | } 110 | 111 | render() { 112 | const { hasErr } = this.state; 113 | return ( 114 | 115 | 125 | 126 | 确定 127 | 128 | 129 | ); 130 | } 131 | } 132 | 133 | // #region 导出注意 134 | // 135 | // 经过上面的声明后需要将导出的 React.Component 子类修改为子类本身的 props 属性 136 | // 这样在使用这个子类时 Ts 才不会提示缺少 JSX 类型参数错误 137 | // 138 | // #endregion 139 | 140 | export default withUser(Reply); // as ComponentClass; 141 | -------------------------------------------------------------------------------- /pages/index/index.tsx: -------------------------------------------------------------------------------- 1 | // import { ComponentClass } from 'react' 2 | import React, { Component } from 'react'; 3 | 4 | import { View } from '@ui'; 5 | import { TopicsList } from '@components/topics'; 6 | import Header from '@components/header'; 7 | import { throttle } from 'throttle-debounce'; 8 | import { ITopic } from '@interfaces/topic'; 9 | // import BackTop from "@components/backtotop"; 10 | // import update from "immutability-helper"; 11 | import { get } from '@utils/request'; 12 | import Loading from '@components/loading2'; 13 | 14 | // import Link from "next/link"; 15 | import { withRouter } from 'next/router'; 16 | import Head from 'next/head'; 17 | import Layout from '@components/layout'; 18 | 19 | // type IProps = {}; 20 | interface IProps { 21 | state: IState; 22 | router: { 23 | query: any; 24 | }; 25 | } 26 | 27 | type TsearchKey = { 28 | page: number; 29 | limit: number; 30 | tab: string; 31 | mdrender: boolean; 32 | }; 33 | 34 | interface IState { 35 | scroll: boolean; 36 | loading: boolean; 37 | topics: ITopic[]; 38 | searchKey: TsearchKey; 39 | } 40 | 41 | class List extends Component { 42 | componentScrollBox; 43 | throttledScrollHandler; 44 | 45 | constructor(props) { 46 | super(props); 47 | console.log(props); 48 | this.state = props.state; 49 | } 50 | static getInitialProps({ query: { tab } }) { 51 | return { 52 | state: { 53 | scroll: true, 54 | topics: [], 55 | index: {}, 56 | loading: true, 57 | searchDataStr: '', 58 | searchKey: { 59 | page: 1, 60 | limit: 20, 61 | tab: tab, 62 | mdrender: true, 63 | }, 64 | }, 65 | }; 66 | } 67 | 68 | index = {}; 69 | 70 | componentWillUnmount() { 71 | window.removeEventListener('scroll', this.throttledScrollHandler); 72 | } 73 | componentDidMount() { 74 | this.componentScrollBox = document.documentElement; 75 | this.throttledScrollHandler = throttle(300, this.getScrollData); 76 | const { router } = this.props; 77 | if (router.query && this.props.router.query.tab) { 78 | this.setState( 79 | (prevState) => { 80 | searchKey: Object.assign(prevState.searchKey, { 81 | tab: this.props.router.query.tab, 82 | }); 83 | }, 84 | () => { 85 | this.getTopics(); 86 | }, 87 | ); 88 | } else { 89 | this.getTopics(); 90 | } 91 | 92 | window.addEventListener('scroll', this.throttledScrollHandler); 93 | } 94 | getScrollData = () => { 95 | if (this.state.scroll) { 96 | let totalheight = 97 | document.documentElement.clientHeight + 98 | document.documentElement.scrollTop; 99 | if (document.documentElement.scrollHeight <= totalheight + 200) { 100 | this.onReachBottom(); 101 | } 102 | } 103 | }; 104 | getTitleStr(tab) { 105 | let str = ''; 106 | switch (tab) { 107 | case 'share': 108 | str = '分享'; 109 | break; 110 | case 'ask': 111 | str = '问答'; 112 | break; 113 | case 'job': 114 | str = '招聘'; 115 | break; 116 | case 'good': 117 | str = '精华'; 118 | break; 119 | default: 120 | str = '全部'; 121 | break; 122 | } 123 | return str; 124 | } 125 | 126 | async getTopics() { 127 | let params = this.state.searchKey; 128 | try { 129 | const res = await get({ 130 | url: 'https://cnodejs.org/api/v1/topics', 131 | data: params, 132 | }); 133 | let data = res.data; 134 | this.setState({ 135 | scroll: true, 136 | loading: false, 137 | }); 138 | if (data && data.data) { 139 | this.mergeTopics(data.data); 140 | } 141 | } catch (error) { 142 | // utils.showToast({ 143 | // title: "载入远程数据错误" 144 | // }); 145 | } 146 | } 147 | mergeTopics = (topics) => { 148 | this.setState({ topics: [...this.state.topics, ...topics] }); 149 | }; 150 | onReachBottom = () => { 151 | if (this.state.scroll) { 152 | this.setState( 153 | (prevState) => ({ 154 | scroll: false, 155 | loading: true, 156 | searchKey: { 157 | ...prevState.searchKey, 158 | page: prevState.searchKey.page + 1, 159 | }, 160 | }), 161 | () => { 162 | this.getTopics(); 163 | }, 164 | ); 165 | } 166 | }; 167 | 168 | render() { 169 | const { searchKey, topics, loading } = this.state || this.props.state; 170 | return ( 171 | 172 | 173 | 首页 174 | 175 |
180 | 181 | 182 | 183 | 184 | {loading && searchKey.page > 1 && } 185 | 186 | {/* */} 187 | 188 | ); 189 | } 190 | } 191 | 192 | export default withRouter(List); 193 | -------------------------------------------------------------------------------- /pages/add/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { withUser } from '@hoc/router'; 3 | import { View } from '@ui'; 4 | import Header from '@components/header'; 5 | import * as utils from '@libs/utils'; 6 | import { post } from '@utils/request'; 7 | import Head from 'next/head'; 8 | import Layout from '@components/layout'; 9 | import { IAuth } from '@interfaces/auth'; 10 | 11 | import styles from './index.module.scss'; 12 | 13 | type PageStateProps = { 14 | userInfo: IAuth; 15 | state: PageState; 16 | }; 17 | 18 | type PageDispatchProps = { 19 | authCheckState: () => void; 20 | }; 21 | 22 | type PageOwnProps = {}; 23 | interface PageState { 24 | topic?: any; 25 | err: string; 26 | authorTxt: string; 27 | selectorIndex: number; 28 | selector: any[]; 29 | } 30 | 31 | type IProps = PageStateProps & PageDispatchProps & PageOwnProps; 32 | 33 | class Add extends Component { 34 | constructor(props) { 35 | super(props); 36 | this.state = props.state; 37 | } 38 | static getInitialProps() { 39 | return { 40 | state: { 41 | topic: { 42 | tab: 'share', 43 | title: '', 44 | content: '', 45 | }, 46 | selectorIndex: 0, 47 | selector: [ 48 | { 49 | name: '分享', 50 | value: 'share', 51 | }, 52 | { 53 | name: '问答', 54 | value: 'ask', 55 | }, 56 | { 57 | name: '招聘', 58 | value: 'job', 59 | }, 60 | ], 61 | err: '', 62 | authorTxt: 63 | '\n\nfrom [serverless-cnode](https://github.com/serverless-plus/serverless-cnode)', 64 | }, 65 | }; 66 | } 67 | 68 | async addTopic() { 69 | let title = utils.trim(this.state.topic.title); 70 | let contents = utils.trim(this.state.topic.content); 71 | if (!title || title.length < 10) { 72 | this.setState({ 73 | err: 'title', 74 | }); 75 | return false; 76 | } 77 | if (!contents) { 78 | this.setState({ 79 | err: 'content', 80 | }); 81 | return false; 82 | } 83 | let postData = { 84 | ...this.state.topic, 85 | content: this.state.topic.content + this.state.authorTxt, 86 | accesstoken: this.props.userInfo.token, 87 | }; 88 | 89 | try { 90 | const resp = await post({ 91 | data: postData, 92 | url: 'https://cnodejs.org/api/v1/topics', 93 | }); 94 | let res = resp.data; 95 | if (res.success) { 96 | utils.navigateTo({ url: '/' }); 97 | } else { 98 | utils.showToast({ title: res.error_msg }); 99 | } 100 | } catch (err) { 101 | console.info(err); 102 | } 103 | } 104 | handleTopicTabChange = (e) => { 105 | this.setState((prevState) => ({ 106 | topic: { 107 | ...prevState.topic, 108 | tab: prevState.selector[e.detail.value]['value'], 109 | }, 110 | selectorIndex: e.detail.value, 111 | })); 112 | }; 113 | handleTopicContentChange = (e) => { 114 | this.setState((prevState) => ({ 115 | topic: { 116 | ...prevState.topic, 117 | content: e.target.value, 118 | }, 119 | })); 120 | }; 121 | handleTopicChange = (e) => { 122 | this.setState((prevState) => ({ 123 | topic: { 124 | ...prevState.topic, 125 | title: e, 126 | }, 127 | })); 128 | }; 129 | render() { 130 | const { err } = this.state || this.props.state; 131 | return ( 132 | 133 | 134 | 发表 135 | 136 |
137 | 138 | 139 | 选择分类: 140 | 148 | 151 | 发布 152 | 153 | 154 | 155 | 165 | 166 |