├── setupTests.js ├── public └── static │ ├── logo.png │ ├── icon │ ├── reload.svg │ ├── ios.svg │ ├── android.svg │ ├── app.svg │ ├── html.svg │ ├── upload.svg │ ├── image.svg │ ├── upload_active.svg │ ├── timeline.svg │ ├── timeline_active.svg │ ├── github.svg │ ├── video.svg │ ├── home.svg │ ├── home_active.svg │ ├── expand.svg │ ├── welfare.svg │ └── blind.svg │ └── css │ ├── nprogress.css │ └── nprogress.mobile.css ├── .travis.yml ├── config └── jest │ └── cssTransform.js ├── next.config.js ├── .babelrc ├── components ├── ListSpin.js ├── ActiveLink.js ├── mobile │ ├── Layout.js │ ├── CardItem.js │ ├── MenuBar.js │ └── ScrollList.js ├── MasonryList.js ├── MainList.js ├── NormalList.js └── Layout.js ├── store └── index.js ├── __tests__ └── components │ └── ListSpin.test.js ├── .gitignore ├── jest.config.js ├── pages ├── index.js ├── topics │ └── [name].js ├── day.js ├── 404.js ├── timeline.js ├── m │ ├── topics │ │ └── [name].js │ ├── day.js │ ├── search.js │ ├── index.js │ ├── timeline.js │ └── upload.js ├── search.js └── upload.js ├── utils └── index.js ├── LICENSE ├── package.json ├── server └── index.js └── README.md /setupTests.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect' 2 | -------------------------------------------------------------------------------- /public/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OrangeXC/gank/HEAD/public/static/logo.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 12 4 | install: 5 | - yarn 6 | script: 7 | - yarn test:ci 8 | after_success: 9 | - codecov 10 | -------------------------------------------------------------------------------- /config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | process() { 3 | return 'module.exports = {};' 4 | }, 5 | getCacheKey() { 6 | return 'cssTransform' 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withOffline = require('next-offline') 2 | const withImages = require('next-images') 3 | 4 | const nextConfig = { 5 | cssModules: true 6 | } 7 | 8 | module.exports = withOffline(withImages(nextConfig)) 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [ 4 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 5 | ["@babel/plugin-proposal-class-properties", { "loose": true }], 6 | ["import", { "libraryName": "antd-mobile" }] 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /components/ListSpin.js: -------------------------------------------------------------------------------- 1 | import { Spin } from 'antd' 2 | 3 | const spinStyle = { 4 | textAlign: 'center', 5 | marginBottom: '20px', 6 | padding: '30px 50px' 7 | } 8 | 9 | const ListSpin = () => ( 10 |
11 | 12 |
13 | ) 14 | 15 | export default ListSpin 16 | -------------------------------------------------------------------------------- /store/index.js: -------------------------------------------------------------------------------- 1 | import { action, observable } from 'mobx' 2 | 3 | class Store { 4 | @observable list = [] 5 | 6 | constructor (list) { 7 | this.list = list 8 | } 9 | 10 | @action loadMoreList = moreList => { 11 | this.list = this.list.concat(moreList) 12 | } 13 | } 14 | 15 | export const initStore = list => new Store(list) 16 | -------------------------------------------------------------------------------- /__tests__/components/ListSpin.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { render } from '@testing-library/react' 6 | import ListSpin from '../../components/ListSpin' 7 | 8 | describe('ListSpin', () => { 9 | it('child', () => { 10 | const { container } = render() 11 | 12 | expect(container.children[0]).toHaveStyle({ 13 | textAlign: 'center', 14 | marginBottom: '20px', 15 | padding: '30px 50px' 16 | }) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | setupFilesAfterEnv: ['/setupTests.js'], 4 | testPathIgnorePatterns: ['/node_modules/', '/.next/'], 5 | transform: { 6 | '^.+\\.(js|jsx|ts|tsx)$': '/node_modules/babel-jest', 7 | '^.+\\.css$': '/config/jest/cssTransform.js', 8 | }, 9 | transformIgnorePatterns: [ 10 | '/node_modules/', 11 | '^.+\\.module\\.(css|sass|scss)$', 12 | ], 13 | moduleNameMapper: { 14 | '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy' 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /public/static/icon/reload.svg: -------------------------------------------------------------------------------- 1 | Share Icons Copy 2 | -------------------------------------------------------------------------------- /components/ActiveLink.js: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | 3 | const ActiveLink = ({ children, href }) => { 4 | const router = useRouter() 5 | const active = router.asPath === href 6 | const className = active 7 | ? 'ant-menu-item-selected ant-menu-item' 8 | : 'ant-menu-item' 9 | 10 | const handleClick = e => { 11 | e.preventDefault() 12 | router.push(href).then(() => window.scrollTo(0, 0)) 13 | } 14 | 15 | return ( 16 |
  • 24 | {children} 25 |
  • 26 | ) 27 | } 28 | 29 | export default ActiveLink 30 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import { initStore } from '../store' 2 | import { Provider } from 'mobx-react' 3 | import MainList from '../components/MainList' 4 | import { apiBaseUrl, getPageTitle, getInitList } from '../utils' 5 | 6 | function HomePage ({ title, apiUrl, initList }) { 7 | const store = initStore(initList) 8 | 9 | return ( 10 | 11 | 12 | 13 | ) 14 | } 15 | 16 | export async function getServerSideProps () { 17 | const title = getPageTitle() 18 | const apiUrl = `${apiBaseUrl}data/all/20` 19 | 20 | const initList = await getInitList(apiUrl) 21 | 22 | return { 23 | props: { 24 | title, 25 | apiUrl, 26 | initList 27 | } 28 | } 29 | } 30 | 31 | export default HomePage 32 | -------------------------------------------------------------------------------- /pages/topics/[name].js: -------------------------------------------------------------------------------- 1 | import { initStore } from '../../store' 2 | import { Provider } from 'mobx-react' 3 | import MainList from '../../components/MainList' 4 | import { 5 | apiBaseUrl, getPageTitle, getInitList 6 | } from '../../utils' 7 | 8 | function TopicPage ({ title, apiUrl, initList }) { 9 | const store = initStore(initList) 10 | 11 | return ( 12 | 13 | 14 | 15 | ) 16 | } 17 | 18 | export async function getServerSideProps ({ params }) { 19 | const title = getPageTitle(params.name) 20 | const apiUrl = `${apiBaseUrl}data/${encodeURIComponent(title)}/20` 21 | 22 | const initList = await getInitList(apiUrl) 23 | 24 | return { 25 | props: { 26 | title, 27 | apiUrl, 28 | initList 29 | } 30 | } 31 | } 32 | 33 | export default TopicPage 34 | -------------------------------------------------------------------------------- /public/static/icon/ios.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/static/icon/android.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /utils/index.js: -------------------------------------------------------------------------------- 1 | export const apiBaseUrl = 'https://gank.io/api/' 2 | 3 | const topics = [{ 4 | title: '首页', 5 | name: '' 6 | }, { 7 | title: '前端', 8 | name: 'fe' 9 | }, { 10 | title: 'Android', 11 | name: 'android' 12 | }, { 13 | title: 'iOS', 14 | name: 'ios' 15 | }, { 16 | title: 'App', 17 | name: 'app' 18 | }, { 19 | title: '拓展资源', 20 | name: 'expand' 21 | }, { 22 | title: '休息视频', 23 | name: 'videos' 24 | }, { 25 | title: '瞎推荐', 26 | name: 'blind' 27 | }, { 28 | title: '福利', 29 | name: 'welfare' 30 | }] 31 | 32 | export const getInitList = async (apiUrl) => { 33 | const res = await fetch(`${apiUrl}/1`) 34 | const json = await res.json() 35 | 36 | return json.results 37 | } 38 | 39 | export const getPageTitle = (topic = '') => { 40 | return topics.find(({ name }) => name === topic).title 41 | } 42 | 43 | export const getPageTopic = (name) => { 44 | return topics.find(({ title }) => title === name).name 45 | } 46 | -------------------------------------------------------------------------------- /public/static/icon/app.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/day.js: -------------------------------------------------------------------------------- 1 | import Layout from '../components/Layout' 2 | import NormalList from '../components/NormalList' 3 | import { apiBaseUrl } from '../utils' 4 | import { Tabs } from 'antd' 5 | 6 | function DayPage ({ list, category }) { 7 | return ( 8 | 9 | 10 | { 11 | category.map(item => 12 | 13 | 14 | 15 | ) 16 | } 17 | 18 | 19 | ) 20 | } 21 | 22 | export async function getServerSideProps ({ query }) { 23 | const { date } = query 24 | const year = date.slice(0, 4) 25 | const month = date.slice(5, 7) 26 | const day = date.slice(8, 10) 27 | 28 | const res = await fetch(`${apiBaseUrl}day/${year}/${month}/${day}`) 29 | const { results, category } = await res.json() 30 | 31 | return { 32 | props: { 33 | list: results, 34 | category 35 | } 36 | } 37 | } 38 | 39 | export default DayPage 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present, OrangeXC 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/404.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import Link from 'next/link' 3 | import { Result, Button } from 'antd' 4 | import pkg from '../package.json' 5 | 6 | function NotFoundPage() { 7 | return ( 8 |
    9 | 10 | 404 - Gank 11 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 27 | 28 | 29 | } 30 | /> 31 |
    32 | ) 33 | } 34 | 35 | export default NotFoundPage 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gank", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "node server", 6 | "build": "next build", 7 | "start": "cross-env NODE_ENV=production node server", 8 | "test": "jest --watch", 9 | "test:ci": "jest --ci", 10 | "heroku-postbuild": "next build" 11 | }, 12 | "dependencies": { 13 | "antd": "4.16.13", 14 | "antd-mobile": "2.3.4", 15 | "isomorphic-fetch": "3.0.0", 16 | "mobx": "6.3.3", 17 | "mobx-react": "7.2.0", 18 | "next": "11.1.2", 19 | "next-images": "1.8.1", 20 | "next-offline": "5.0.5", 21 | "nprogress": "0.2.0", 22 | "rc-form": "2.4.12", 23 | "react": "17.0.2", 24 | "react-dom": "17.0.2", 25 | "react-masonry-component": "6.3.0" 26 | }, 27 | "devDependencies": { 28 | "@babel/plugin-proposal-class-properties": "7.14.5", 29 | "@babel/plugin-proposal-decorators": "7.15.4", 30 | "@testing-library/jest-dom": "5.14.1", 31 | "@testing-library/react": "12.1.0", 32 | "babel-jest": "27.2.0", 33 | "babel-plugin-import": "1.13.3", 34 | "codecov": "3.8.3", 35 | "cross-env": "7.0.3", 36 | "identity-obj-proxy": "3.0.0", 37 | "immutable": "4.0.0-rc.15", 38 | "jest": "27.2.0", 39 | "react-test-renderer": "17.0.2" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /public/static/icon/html.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/mobile/Layout.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import Router from 'next/router' 3 | import NProgress from 'nprogress' 4 | import { LocaleProvider } from 'antd-mobile' 5 | import enUS from 'antd-mobile/lib/locale-provider/en_US' 6 | import 'isomorphic-fetch' 7 | import pkg from '../../package.json' 8 | 9 | Router.onRouteChangeStart = () => NProgress.start() 10 | Router.onRouteChangeComplete = () => NProgress.done() 11 | Router.onRouteChangeError = () => NProgress.done() 12 | 13 | const Layout = ({ children }) => ( 14 |
    15 | 16 | 17 | Gank 18 | 19 | 20 | 21 | 22 | 23 | 24 | {children} 25 | 26 | 33 |
    34 | ) 35 | 36 | export default Layout 37 | -------------------------------------------------------------------------------- /public/static/icon/upload.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/static/icon/image.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/static/icon/upload_active.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/static/icon/timeline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/static/icon/timeline_active.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/static/icon/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/static/icon/video.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const port = parseInt(process.env.PORT, 10) || 3000 2 | const dev = process.env.NODE_ENV !== 'production' 3 | 4 | const { createServer } = require('http') 5 | const { join } = require('path') 6 | const { parse } = require('url') 7 | const next = require('next') 8 | const mobxReact = require('mobx-react') 9 | 10 | const app = next({ dev }) 11 | const handle = app.getRequestHandler() 12 | 13 | mobxReact.enableStaticRendering(true) 14 | 15 | app.prepare().then(() => { 16 | createServer((req, res) => { 17 | const parsedUrl = parse(req.url, true) 18 | const { pathname, query } = parsedUrl 19 | 20 | const ua = req.headers['user-agent'] 21 | 22 | if (pathname === '/service-worker.js') { 23 | const filePath = join(__dirname, '.next', pathname) 24 | 25 | app.serveStatic(req, res, filePath) 26 | } else if (pathname.startsWith('/static')) { 27 | handle(req, res, parsedUrl) 28 | } else if (/Mobile/i.test(ua) && !pathname.startsWith('/m')) { 29 | const mobilePathname = pathname === '/' ? '/m' : `/m${pathname}` 30 | 31 | app.render(req, res, mobilePathname, query) 32 | } else if (/Mobile/i.test(ua) && pathname === '/m/') { 33 | app.render(req, res, '/m', query) 34 | } else if (!/Mobile/i.test(ua) && pathname.startsWith('/m/')) { 35 | app.render(req, res, pathname.slice(2), query) 36 | } else if (!/Mobile/i.test(ua) && pathname.startsWith('/m')) { 37 | app.render(req, res, '/', query) 38 | } else { 39 | handle(req, res, parsedUrl) 40 | } 41 | }).listen(port, err => { 42 | if (err) throw err 43 | 44 | console.log(`> Ready on http://localhost:${port}`) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

    gank logo

    2 | 3 |

    4 | Build Status 5 | Coverage Status 6 | GitHub License 7 |

    8 | 9 | ## Introduction 10 | 11 | next.js(react ssr repo) and gank api 12 | 13 | ## Features 14 | 15 | * Use react SSR framework [next.js](https://github.com/zeit/next.js) 16 | * UI ant design [antd](https://ant.design/) 17 | * Mobile UI ant design [antd-mobile](https://mobile.ant.design/index-cn) 18 | * Support PWA with [next-offline](https://github.com/hanford/next-offline) 19 | * Progress bar [nprogress](http://ricostacruz.com/nprogress/) 20 | * State management [mobx](https://mobx.js.org/) 21 | * Fetch Api instead ajax 22 | 23 | ## Pages 24 | 25 | * Home page (all types list) 26 | * Front end page 27 | * Android page 28 | * iOS page 29 | * App page 30 | * Expand page 31 | * Free videos page 32 | * Welfare images page 33 | * Timeline page 34 | * Post gank form page 35 | * Search gank page 36 | 37 | ## Develop 38 | 39 | ``` bash 40 | # install dependencies 41 | $ yarn 42 | 43 | # serve with hot reload at localhost:3000 44 | $ yarn dev 45 | 46 | # build for production and launch server 47 | $ yarn build 48 | $ yarn start 49 | ``` 50 | 51 | ## License 52 | 53 | Gank is [MIT licensed](https://github.com/OrangeXC/gank/blob/master/LICENSE). 54 | -------------------------------------------------------------------------------- /public/static/icon/home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/static/icon/home_active.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/timeline.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'next/link' 3 | import { Tabs, Card, Col, Row } from 'antd' 4 | import Layout from '../components/Layout' 5 | import { apiBaseUrl } from '../utils' 6 | 7 | const { TabPane } = Tabs 8 | 9 | function TimeLinePage ({ timeline, years }) { 10 | return ( 11 | 12 | 13 | { 14 | years.map(year => 15 | 16 | 17 | { 18 | timeline 19 | .filter(item => item.includes(year)) 20 | .map(item => cards(item)) 21 | } 22 | 23 | 24 | ) 25 | } 26 | 27 | 28 | ) 29 | } 30 | 31 | function cards (item) { 32 | return ( 33 | 34 |
    35 | 39 | More 40 | 41 | } 42 | bodyStyle={{ padding: 0 }}> 43 | 44 |
    45 | 46 | ) 47 | } 48 | 49 | 50 | export async function getServerSideProps () { 51 | const res = await fetch(`${apiBaseUrl}day/history`) 52 | const { results } = await res.json() 53 | 54 | let years = [] 55 | 56 | results.forEach(element => { 57 | if (years.indexOf(element.slice(0, 4)) === -1) years.push(element.slice(0, 4)) 58 | }) 59 | 60 | return { 61 | props: { 62 | timeline: results, 63 | years 64 | } 65 | } 66 | } 67 | 68 | export default TimeLinePage 69 | -------------------------------------------------------------------------------- /pages/m/topics/[name].js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import Router from 'next/router' 3 | import { NavBar, Icon } from 'antd-mobile' 4 | import { apiBaseUrl, getPageTitle, getInitList } from '../../../utils' 5 | import Layout from '../../../components/mobile/Layout' 6 | import ScrollList from '../../../components/mobile/ScrollList' 7 | 8 | class MobileTopicPage extends Component { 9 | constructor (props) { 10 | super(props) 11 | 12 | this.state = { 13 | listHeight: 1000 14 | } 15 | } 16 | 17 | componentDidMount () { 18 | const height = document.documentElement.clientHeight - 45 19 | 20 | this.setState({ 21 | listHeight: height 22 | }) 23 | } 24 | 25 | render () { 26 | const { 27 | title, 28 | apiUrl, 29 | initList 30 | } = this.props 31 | 32 | return ( 33 | 34 |
    35 | } 38 | onLeftClick={() => Router.push('/m')} 39 | > 40 | {title} 41 | 42 | 47 |
    48 |
    49 | ) 50 | } 51 | } 52 | 53 | export async function getServerSideProps ({ params }) { 54 | const topic = params.name || '' 55 | const title = getPageTitle(topic) 56 | const apiUrl = `${apiBaseUrl}data/${encodeURIComponent(title)}/20` 57 | 58 | const initList = await getInitList(apiUrl) 59 | 60 | return { 61 | props: { 62 | title, 63 | apiUrl, 64 | initList 65 | } 66 | } 67 | } 68 | 69 | export default MobileTopicPage 70 | -------------------------------------------------------------------------------- /public/static/css/nprogress.css: -------------------------------------------------------------------------------- 1 | /* Make clicks pass-through */ 2 | #nprogress { 3 | pointer-events: none; 4 | } 5 | 6 | #nprogress .bar { 7 | background: #29d; 8 | 9 | position: fixed; 10 | z-index: 1031; 11 | top: 0; 12 | left: 0; 13 | 14 | width: 100%; 15 | height: 2px; 16 | } 17 | 18 | /* Fancy blur effect */ 19 | #nprogress .peg { 20 | display: block; 21 | position: absolute; 22 | right: 0px; 23 | width: 100px; 24 | height: 100%; 25 | box-shadow: 0 0 10px #29d, 0 0 5px #29d; 26 | opacity: 1.0; 27 | 28 | -webkit-transform: rotate(3deg) translate(0px, -4px); 29 | -ms-transform: rotate(3deg) translate(0px, -4px); 30 | transform: rotate(3deg) translate(0px, -4px); 31 | } 32 | 33 | /* Remove these to get rid of the spinner */ 34 | #nprogress .spinner { 35 | display: block; 36 | position: fixed; 37 | z-index: 1031; 38 | top: 20px; 39 | right: 15px; 40 | } 41 | 42 | #nprogress .spinner-icon { 43 | width: 18px; 44 | height: 18px; 45 | box-sizing: border-box; 46 | 47 | border: solid 2px transparent; 48 | border-top-color: #29d; 49 | border-left-color: #29d; 50 | border-radius: 50%; 51 | 52 | -webkit-animation: nprogress-spinner 400ms linear infinite; 53 | animation: nprogress-spinner 400ms linear infinite; 54 | } 55 | 56 | .nprogress-custom-parent { 57 | overflow: hidden; 58 | position: relative; 59 | } 60 | 61 | .nprogress-custom-parent #nprogress .spinner, 62 | .nprogress-custom-parent #nprogress .bar { 63 | position: absolute; 64 | } 65 | 66 | @-webkit-keyframes nprogress-spinner { 67 | 0% { -webkit-transform: rotate(0deg); } 68 | 100% { -webkit-transform: rotate(360deg); } 69 | } 70 | @keyframes nprogress-spinner { 71 | 0% { transform: rotate(0deg); } 72 | 100% { transform: rotate(360deg); } 73 | } 74 | -------------------------------------------------------------------------------- /public/static/css/nprogress.mobile.css: -------------------------------------------------------------------------------- 1 | /* Make clicks pass-through */ 2 | #nprogress { 3 | pointer-events: none; 4 | } 5 | 6 | #nprogress .bar { 7 | background: #29d; 8 | 9 | position: fixed; 10 | z-index: 1031; 11 | top: 0; 12 | left: 0; 13 | 14 | width: 100%; 15 | height: 2px; 16 | } 17 | 18 | /* Fancy blur effect */ 19 | #nprogress .peg { 20 | display: block; 21 | position: absolute; 22 | right: 0px; 23 | width: 100px; 24 | height: 100%; 25 | box-shadow: 0 0 10px #29d, 0 0 5px #29d; 26 | opacity: 1.0; 27 | 28 | -webkit-transform: rotate(3deg) translate(0px, -4px); 29 | -ms-transform: rotate(3deg) translate(0px, -4px); 30 | transform: rotate(3deg) translate(0px, -4px); 31 | } 32 | 33 | /* Remove these to get rid of the spinner */ 34 | #nprogress .spinner { 35 | display: block; 36 | position: fixed; 37 | z-index: 1031; 38 | top: 13px; 39 | right: 45px; 40 | } 41 | 42 | #nprogress .spinner-icon { 43 | width: 18px; 44 | height: 18px; 45 | box-sizing: border-box; 46 | 47 | border: solid 2px transparent; 48 | border-top-color: #29d; 49 | border-left-color: #29d; 50 | border-radius: 50%; 51 | 52 | -webkit-animation: nprogress-spinner 400ms linear infinite; 53 | animation: nprogress-spinner 400ms linear infinite; 54 | } 55 | 56 | .nprogress-custom-parent { 57 | overflow: hidden; 58 | position: relative; 59 | } 60 | 61 | .nprogress-custom-parent #nprogress .spinner, 62 | .nprogress-custom-parent #nprogress .bar { 63 | position: absolute; 64 | } 65 | 66 | @-webkit-keyframes nprogress-spinner { 67 | 0% { -webkit-transform: rotate(0deg); } 68 | 100% { -webkit-transform: rotate(360deg); } 69 | } 70 | @keyframes nprogress-spinner { 71 | 0% { transform: rotate(0deg); } 72 | 100% { transform: rotate(360deg); } 73 | } 74 | -------------------------------------------------------------------------------- /components/mobile/CardItem.js: -------------------------------------------------------------------------------- 1 | import { Card, Tag, Flex } from 'antd-mobile' 2 | 3 | const flexItemStyle = { 4 | flex: '0 0 auto', 5 | width: 100, 6 | textAlign: 'center' 7 | } 8 | 9 | const flexItemImageStyle = { 10 | maxWidth: 100, 11 | maxHeight: 100 12 | } 13 | 14 | const CardItem = rowData => ( 15 |
    16 | { 17 | rowData.type !== '福利' ? ( 18 | {window.open(rowData.url)}}> 19 | 20 | 21 | { 22 | rowData.images && 23 | 24 | e.target.src='/static/icon/image.svg'} 28 | /> 29 | 30 | } 31 | 32 |
    {rowData.desc}
    33 |
    34 |
    35 |
    36 | 39 | {rowData.who ? rowData.who : ''} 40 | {rowData.publishedAt.slice(0, 10)} 41 |
    42 | } 43 | extra={{rowData.type}} 44 | /> 45 | 46 | ) : ( 47 | 48 | 49 | e.target.src='/static/icon/image.svg'} 53 | /> 54 | 55 | {rowData.publishedAt.slice(0, 10)}} /> 56 | 57 | ) 58 | } 59 | 60 | ) 61 | 62 | export default CardItem 63 | -------------------------------------------------------------------------------- /pages/search.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import { Input, message } from 'antd' 3 | import Layout from '../components/Layout' 4 | import NormalList from '../components/NormalList' 5 | import ListSpin from '../components/ListSpin' 6 | import { apiBaseUrl } from '../utils' 7 | 8 | const { Search } = Input 9 | 10 | const searchStyle = { 11 | marginBottom: '50px' 12 | } 13 | 14 | class SearchPage extends Component { 15 | constructor () { 16 | super() 17 | 18 | this.state = { 19 | list: [], 20 | searching: false 21 | } 22 | } 23 | 24 | handleLoadingShow () { 25 | this.setState((preState) => ({ 26 | searching: !preState.searching 27 | })) 28 | } 29 | 30 | async handleSearch (val) { 31 | if (!val) { 32 | this.setState({ 33 | list: [] 34 | }) 35 | 36 | message.info(`搜索内容不能为空`) 37 | 38 | return 39 | } 40 | 41 | this.handleLoadingShow() 42 | 43 | const url = `${apiBaseUrl}search/query/${val}/category/all/count/50/page/1` 44 | const res = await fetch(url) 45 | const { count, results } = await res.json() 46 | 47 | if (count) { 48 | this.setState({ 49 | list: results 50 | }) 51 | } else { 52 | this.setState({ 53 | list: [] 54 | }) 55 | 56 | message.warning(`未找到关键字为(${val})的数据`) 57 | } 58 | 59 | this.handleLoadingShow() 60 | } 61 | 62 | render () { 63 | return ( 64 | 65 | this.handleSearch(value)} 70 | /> 71 | 72 | { 73 | this.state.searching 74 | ? 75 | : 76 | } 77 | 78 | ) 79 | } 80 | } 81 | 82 | export default SearchPage 83 | -------------------------------------------------------------------------------- /public/static/icon/expand.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/static/icon/welfare.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/m/day.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Router from 'next/router' 3 | import { NavBar, Icon, Tabs, WhiteSpace } from 'antd-mobile' 4 | import Layout from '../../components/mobile/Layout' 5 | import CardItem from '../../components/mobile/CardItem' 6 | import { apiBaseUrl } from '../../utils' 7 | 8 | export default class MobileDayPage extends React.Component { 9 | static async getInitialProps ({ query }) { 10 | const { date } = query 11 | const year = date.slice(0, 4) 12 | const month = date.slice(5, 7) 13 | const day = date.slice(8, 10) 14 | 15 | const res = await fetch(`${apiBaseUrl}day/${year}/${month}/${day}`) 16 | const json = await res.json() 17 | 18 | const tabs = json.category.map(item => { 19 | let listItem = { 20 | title: item, 21 | list: json.results[item] 22 | } 23 | 24 | return listItem 25 | }) 26 | 27 | return { 28 | date, 29 | tabs 30 | } 31 | } 32 | 33 | constructor (props) { 34 | super(props) 35 | 36 | this.state = { 37 | listHeight: 300 38 | } 39 | } 40 | 41 | componentDidMount () { 42 | const height = document.documentElement.clientHeight - 88.5 43 | 44 | this.setState({ 45 | listHeight: height 46 | }) 47 | } 48 | 49 | renderContent = tab => 50 | (
    51 | {tab.list.map(item => 52 |
    53 | {CardItem(item)} 54 | 55 |
    56 | )} 57 |
    ) 58 | 59 | render () { 60 | const { 61 | date, 62 | tabs, 63 | language 64 | } = this.props 65 | 66 | return ( 67 | 68 |
    69 | } 72 | onLeftClick={() => Router.push('/m/timeline')} 73 | > 74 | {date} 75 | 76 | 77 | {this.renderContent} 78 | 79 |
    80 |
    81 | ) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /components/MasonryList.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import { Card, Modal } from 'antd' 3 | import Masonry from 'react-masonry-component' 4 | 5 | class MasonryList extends Component { 6 | constructor (props) { 7 | super(props) 8 | 9 | this.state = { 10 | showImages: false, 11 | dialogImageVisible: false, 12 | largeImage: '' 13 | } 14 | } 15 | 16 | componentDidMount () { 17 | this.handleLayoutComplete() 18 | } 19 | 20 | handleLayoutComplete () { 21 | this.setState({ 22 | showImages: true 23 | }) 24 | } 25 | 26 | handleLargeImageShow (url) { 27 | this.setState({ 28 | dialogImageVisible: true, 29 | largeImage: url 30 | }) 31 | } 32 | 33 | handleLargeImageHide () { 34 | this.setState({ 35 | dialogImageVisible: false 36 | }) 37 | } 38 | 39 | render () { 40 | const wrapStyle = { 41 | opacity: this.state.showImages ? '1' : '0', 42 | transition: 'opacity 0.5s linear' 43 | } 44 | 45 | return ( 46 |
    47 | 48 | { 49 | this.props.list.map(element => 50 |
    this.handleLargeImageShow(element.url)} 54 | > 55 | e.target.src='/static/icon/image.svg'} />} 57 | hoverable 58 | > 59 | 62 | 63 |
    64 | ) 65 | } 66 |
    67 | 68 | this.handleLargeImageHide()} 74 | > 75 | 76 | 77 |
    78 | ) 79 | } 80 | } 81 | 82 | export default MasonryList 83 | -------------------------------------------------------------------------------- /components/mobile/MenuBar.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import Router from 'next/router' 3 | import { TabBar } from 'antd-mobile' 4 | 5 | const tabs = [{ 6 | title: '主页', 7 | name: 'home' 8 | }, { 9 | title: '时间轴', 10 | name: 'timeline' 11 | }, { 12 | title: '发布', 13 | name: 'upload' 14 | }] 15 | 16 | const tabData = tabs.map(({ title, name }) => ({ 17 | title, 18 | icon:
    , 23 | selectedIcon:
    , 28 | link: name === 'home' ? '/m' : `/m/${name}` 29 | })) 30 | 31 | class MenuBar extends Component { 32 | constructor (props) { 33 | super(props) 34 | 35 | this.state = { 36 | tabHeight: '100vh' 37 | } 38 | } 39 | 40 | async componentDidMount () { 41 | const height = `${document.documentElement.clientHeight}px` 42 | 43 | this.setState({ 44 | tabHeight: height 45 | }) 46 | } 47 | 48 | render () { 49 | const { 50 | pathname, 51 | children 52 | } = this.props 53 | 54 | return ( 55 |
    56 | 57 | { 58 | tabData.map(({ title, icon, selectedIcon, link }) => ( 59 | Router.push(link).then(() => window.scrollTo(0, 0))} 66 | > 67 | {children} 68 | 69 | )) 70 | } 71 | 72 | 81 |
    82 | ) 83 | } 84 | } 85 | 86 | export default MenuBar 87 | -------------------------------------------------------------------------------- /components/MainList.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import Layout from './Layout' 3 | import NormalList from './NormalList' 4 | import MasonryList from './MasonryList' 5 | import ListSpin from './ListSpin' 6 | import { Alert } from 'antd' 7 | import { inject, observer } from 'mobx-react' 8 | 9 | @inject('store') 10 | @observer 11 | class MainList extends Component { 12 | constructor (props) { 13 | super(props) 14 | 15 | this.state = { 16 | currentPage: 1, 17 | hasMore: true 18 | } 19 | 20 | this.handleScroll = this.handleScroll.bind(this) 21 | this.handleLoadMore = this.handleLoadMore.bind(this) 22 | } 23 | 24 | componentDidMount () { 25 | window.addEventListener('scroll', this.handleScroll) 26 | } 27 | 28 | componentWillUnmount () { 29 | window.removeEventListener('scroll', this.handleScroll) 30 | } 31 | 32 | handleScroll () { 33 | const scrollTop = Math.max(document.documentElement.scrollTop, document.body.scrollTop) 34 | 35 | if ( 36 | document.documentElement.offsetHeight + scrollTop > 37 | document.documentElement.scrollHeight - 50 38 | ) { 39 | this.handleLoadMore() 40 | } 41 | } 42 | 43 | async handleLoadMore () { 44 | this.setState((prevState) => ({ 45 | currentPage: prevState.currentPage + 1 46 | })) 47 | 48 | const currentPage = this.state.currentPage 49 | const apiUrl = this.props.apiUrl 50 | 51 | const res = await fetch(`${apiUrl}/${currentPage}`) 52 | const json = await res.json() 53 | 54 | if (Array.isArray(json.results) && json.results.length) { 55 | this.props.store.loadMoreList(json.results) 56 | } else { 57 | this.setState({ 58 | hasMore: false 59 | }) 60 | } 61 | } 62 | 63 | render () { 64 | const { list } = this.props.store 65 | 66 | return ( 67 | 68 | { 69 | this.props.title === '福利' 70 | ? 71 | : 72 | } 73 | 74 | { 75 | this.state.hasMore 76 | ? 77 | : 78 | } 79 | 80 | ) 81 | } 82 | } 83 | 84 | export default MainList 85 | -------------------------------------------------------------------------------- /components/mobile/ScrollList.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import { ListView, Icon } from 'antd-mobile' 3 | import CardItem from './CardItem' 4 | 5 | class ScrollList extends Component { 6 | constructor (props) { 7 | super(props) 8 | 9 | const dataSource = new ListView.DataSource({ 10 | rowHasChanged: (row1, row2) => row1 !== row2, 11 | }).cloneWithRows(props.initList) 12 | 13 | this.state = { 14 | rData: [], 15 | dataSource, 16 | pageIndex: 1, 17 | isLoading: false 18 | } 19 | } 20 | 21 | onEndReached = async () => { 22 | if (this.state.isLoading && !this.state.hasMore) { 23 | return 24 | } 25 | 26 | this.setState((prevState) => ({ 27 | isLoading: true, 28 | pageIndex: prevState.pageIndex + 1 29 | })) 30 | 31 | await this.getData(this.state.pageIndex) 32 | 33 | this.setState({ 34 | dataSource: this.state.dataSource.cloneWithRows(this.state.rData), 35 | isLoading: false 36 | }) 37 | } 38 | 39 | async getData (pageIndex = 0) { 40 | const res = await fetch(`${this.props.apiUrl}/${pageIndex}`) 41 | const json = await res.json() 42 | 43 | this.setState((prevState) => ({ 44 | rData: pageIndex === 2 45 | ? this.props.initList.concat(prevState.rData).concat(json.results) 46 | : prevState.rData.concat(json.results) 47 | })) 48 | } 49 | 50 | render () { 51 | const separator = (sectionId, rowId) => ( 52 |
    61 | ) 62 | 63 | return ( 64 | (
    67 | {this.state.isLoading ? : '滑动加载更多!'} 68 |
    )} 69 | renderRow={CardItem} 70 | renderSeparator={separator} 71 | style={{ 72 | height: this.props.listHeight, 73 | overflow: 'auto' 74 | }} 75 | pageSize={10} 76 | scrollRenderAheadDistance={500} 77 | onEndReached={this.onEndReached} 78 | onEndReachedThreshold={10} 79 | /> 80 | ) 81 | } 82 | } 83 | 84 | export default ScrollList 85 | -------------------------------------------------------------------------------- /components/NormalList.js: -------------------------------------------------------------------------------- 1 | import { List } from 'antd' 2 | import { 3 | UserOutlined, ClockCircleOutlined, TagOutlined 4 | } from '@ant-design/icons' 5 | import Router from 'next/router' 6 | import { getPageTopic } from '../utils' 7 | 8 | const { Item } = List 9 | 10 | const IconText = ({ icon, text, href = '' }) => ( 11 | href && Router.push(href).then(() => window.scrollTo(0, 0))}> 12 | {icon} 13 | {text} 14 | 15 | ) 16 | 17 | const listImageWrapStyle = { 18 | width: 272, 19 | height: 168, 20 | textAlign: 'center', 21 | lineHeight: '168px', 22 | backgroundColor: 'rgb(234, 237, 242)' 23 | } 24 | 25 | const listImageStyle = { 26 | maxWidth: '100%', 27 | maxHeight: '100%' 28 | } 29 | 30 | const NormalList = ({ list }) => ( 31 |
    32 | ( 38 | } text={item.who ? item.who : '未知'} />, 42 | } 44 | text={item.publishedAt.slice(0, 10)} 45 | href={`/day?date=${item.publishedAt.slice(0, 10)}`} 46 | />, 47 | } 49 | text={item.type} 50 | href={`/topics/${getPageTopic(item.type)}`} 51 | /> 52 | ]} 53 | extra={item.images && 54 |
    55 | cover e.target.src='/static/icon/image.svg'} 60 | /> 61 |
    62 | } 63 | > 64 |

    65 | {item.desc} 66 |

    67 |
    68 | )} 69 | /> 70 | 71 | 82 |
    83 | ) 84 | 85 | export default NormalList 86 | -------------------------------------------------------------------------------- /pages/m/search.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import Router from 'next/router' 3 | import { 4 | NavBar, SearchBar, Icon, WhiteSpace, Toast 5 | } from 'antd-mobile' 6 | import CardItem from '../../components/mobile/CardItem' 7 | import Layout from '../../components/mobile/Layout' 8 | import { apiBaseUrl } from '../../utils' 9 | 10 | class MobileSearchPage extends Component { 11 | constructor (props) { 12 | super(props) 13 | 14 | this.state = { 15 | value: '', 16 | list: [], 17 | listHeight: 1000 18 | } 19 | } 20 | 21 | componentDidMount() { 22 | this.autoFocusInst.focus() 23 | 24 | const height = document.documentElement.clientHeight - 89 25 | 26 | this.setState({ 27 | listHeight: height 28 | }) 29 | } 30 | 31 | async handleSearch (val) { 32 | if (!val) { 33 | this.setState(() => ({ 34 | list: [] 35 | })) 36 | 37 | Toast.fail('搜索内容不能为空!', 2) 38 | 39 | return 40 | } 41 | 42 | Toast.loading('Loading...', 10) 43 | 44 | const url = `${apiBaseUrl}search/query/${val}/category/all/count/50/page/1` 45 | const res = await fetch(url) 46 | const { count, results } = await res.json() 47 | 48 | if (count) { 49 | this.setState(() => ({ 50 | list: results 51 | })) 52 | 53 | Toast.hide() 54 | } else { 55 | this.setState(() => ({ 56 | list: [] 57 | })) 58 | 59 | await Toast.fail(`未找到关键字为(${val})的数据`, 2) 60 | } 61 | } 62 | 63 | render () { 64 | const { language } = this.props 65 | 66 | return ( 67 | 68 |
    69 | } 72 | onLeftClick={() => Router.push('/m')} 73 | > 74 | 搜索 75 | 76 | this.autoFocusInst = ref} 79 | onSubmit={value => this.handleSearch(value)} 80 | /> 81 |
    82 | { 83 | this.state.list.map((item, index) => 84 |
    85 | {CardItem(item)} 86 | 87 |
    88 | ) 89 | } 90 |
    91 |
    92 |
    93 | ) 94 | } 95 | } 96 | 97 | export async function getServerSideProps ({ req }) { 98 | const language = req 99 | ? req.headers['accept-language'] 100 | : navigator.language 101 | 102 | return { 103 | props: { 104 | language 105 | } 106 | } 107 | } 108 | 109 | export default MobileSearchPage 110 | -------------------------------------------------------------------------------- /public/static/icon/blind.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/m/index.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { withRouter } from 'next/router' 4 | import { 5 | WhiteSpace, NavBar, Icon, Grid 6 | } from 'antd-mobile' 7 | import Layout from '../../components/mobile/Layout' 8 | import MenuBar from '../../components/mobile/MenuBar' 9 | import ScrollList from '../../components/mobile/ScrollList' 10 | import { apiBaseUrl, getInitList } from '../../utils' 11 | 12 | const gridMenu = [ 13 | { 14 | icon: '/static/icon/html.svg', 15 | text: '前端', 16 | link: '/m/topics/fe' 17 | }, { 18 | icon: '/static/icon/android.svg', 19 | text: '安卓', 20 | link: '/m/topics/android' 21 | }, { 22 | icon: '/static/icon/ios.svg', 23 | text: 'iOS', 24 | link: '/m/topics/ios' 25 | }, { 26 | icon: '/static/icon/app.svg', 27 | text: 'App', 28 | link: '/m/topics/app' 29 | }, { 30 | icon: '/static/icon/expand.svg', 31 | text: '拓展资源', 32 | link: '/m/topics/expand' 33 | }, { 34 | icon: '/static/icon/video.svg', 35 | text: '休息视频', 36 | link: '/m/topics/videos' 37 | }, { 38 | icon: '/static/icon/blind.svg', 39 | text: '瞎推荐', 40 | link: '/m/topics/blind' 41 | }, { 42 | icon: '/static/icon/welfare.svg', 43 | text: '福利', 44 | link: '/m/topics/welfare' 45 | } 46 | ] 47 | 48 | class MobileHome extends Component { 49 | constructor (props) { 50 | super(props) 51 | 52 | this.router = props.router 53 | this.state = { 54 | listHeight: 1000 55 | } 56 | } 57 | 58 | componentDidMount () { 59 | const height = document.documentElement.clientHeight - 113 - 60 | ReactDOM.findDOMNode(this.grid).getBoundingClientRect().height 61 | 62 | this.setState({ 63 | listHeight: height 64 | }) 65 | } 66 | 67 | render () { 68 | const { 69 | initList, 70 | apiUrl 71 | } = this.props 72 | 73 | return ( 74 | 75 | 78 | } 81 | onLeftClick={() => window.open('https://github.com/OrangeXC/gank')} 82 | rightContent={ 84 | this.router 85 | .push('/m/search') 86 | .then(() => window.scrollTo(0, 0))} 87 | type='search' 88 | />} 89 | > 90 | 主页 91 | 92 | 93 | this.grid = el} 95 | data={gridMenu} 96 | hasLine={false} 97 | onClick={(el) => this.router.push(el.link)} 98 | /> 99 | 100 | 105 | 106 | 107 | 108 | ) 109 | } 110 | } 111 | 112 | export async function getServerSideProps () { 113 | const apiUrl = `${apiBaseUrl}data/all/20` 114 | 115 | const initList = await getInitList(apiUrl) 116 | 117 | return { 118 | props: { 119 | apiUrl, 120 | initList 121 | } 122 | } 123 | } 124 | 125 | export default withRouter(MobileHome) 126 | -------------------------------------------------------------------------------- /components/Layout.js: -------------------------------------------------------------------------------- 1 | import Router from 'next/router' 2 | import NProgress from 'nprogress' 3 | import Head from 'next/head' 4 | import Link from 'next/link' 5 | import { 6 | Layout, Menu, Button, BackTop 7 | } from 'antd' 8 | import { 9 | GithubOutlined, SearchOutlined, UploadOutlined 10 | } from '@ant-design/icons' 11 | import 'isomorphic-fetch' 12 | import ActiveLink from './ActiveLink' 13 | import pkg from '../package.json' 14 | 15 | Router.onRouteChangeStart = () => NProgress.start() 16 | Router.onRouteChangeComplete = () => NProgress.done() 17 | Router.onRouteChangeError = () => NProgress.done() 18 | 19 | const { Header, Content, Footer } = Layout 20 | 21 | const headerStyle = { 22 | position: 'fixed', 23 | top: '0', 24 | left: '0', 25 | width: '100%', 26 | zIndex: 10, 27 | minWidth: 1140 28 | } 29 | const headerMenuStyle = { 30 | lineHeight: '64px', 31 | float: 'left' 32 | } 33 | const contentStyle = { 34 | width: 1140, 35 | padding: '80px 50px 64px', 36 | margin: '0 auto', 37 | minHeight: `calc(100vh - 69px)` 38 | } 39 | 40 | const LayoutPage = ({ children, title = '主页' }) => ( 41 |
    42 | 43 | {title} - Gank 44 | 45 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |
    56 |
    57 | 62 | 主页 63 | 前端 64 | Android 65 | iOS 66 | App 67 | 拓展资源 68 | 休息视频 69 | 瞎推荐 70 | 福利 71 | 时间轴 72 | 73 |
    74 | 75 |
    84 |
    85 | 86 | 87 | { children } 88 | 89 | 90 |
    91 | Gank ©2017-{ new Date().getFullYear() } use gank api Paword by Next 92 | 93 | 94 | 95 |
    96 | 97 | 98 | 99 | 114 |
    115 | ) 116 | 117 | export default LayoutPage 118 | -------------------------------------------------------------------------------- /pages/m/timeline.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import Link from 'next/link' 3 | import { withRouter } from 'next/router' 4 | import { NavBar, List, ListView } from 'antd-mobile' 5 | import Layout from '../../components/mobile/Layout' 6 | import MenuBar from '../../components/mobile/MenuBar' 7 | import { apiBaseUrl } from '../../utils' 8 | 9 | const { Item } = List 10 | 11 | class MobileTimeLinePage extends Component { 12 | constructor (props) { 13 | super(props) 14 | 15 | const dataSource = new ListView.DataSource({ 16 | rowHasChanged: (row1, row2) => row1 !== row2, 17 | }).cloneWithRows(props.timeline) 18 | 19 | this.state = { 20 | dataSource, 21 | listHeight: 1000 22 | } 23 | } 24 | 25 | async componentDidMount () { 26 | const height = document.documentElement.clientHeight - 95 27 | 28 | this.setState({ 29 | listHeight: height 30 | }) 31 | } 32 | 33 | render () { 34 | const { 35 | router: { pathname } 36 | } = this.props 37 | 38 | const { 39 | dataSource, 40 | listHeight 41 | } = this.state 42 | 43 | return ( 44 |
    45 | 46 | 49 | 52 | 时间轴 53 | 54 | ( 57 | 60 | {rowData} 61 | 62 | )} 63 | style={{ 64 | height: listHeight, 65 | overflow: 'scroll' 66 | }} 67 | initialListSize={30} 68 | pageSize={30} 69 | /> 70 | 71 | 72 | 73 | 148 |
    149 | ) 150 | } 151 | } 152 | 153 | export async function getServerSideProps () { 154 | const res = await fetch(`${apiBaseUrl}day/history`) 155 | const json = await res.json() 156 | 157 | return { 158 | props: { 159 | timeline: json.results 160 | } 161 | } 162 | } 163 | 164 | export default withRouter(MobileTimeLinePage) 165 | -------------------------------------------------------------------------------- /pages/upload.js: -------------------------------------------------------------------------------- 1 | import { Component, createRef } from 'react' 2 | import { 3 | Form, Input, Select, Switch, Button, message, Alert 4 | } from 'antd' 5 | import Layout from '../components/Layout' 6 | import { apiBaseUrl } from '../utils' 7 | 8 | const FormItem = Form.Item 9 | const Option = Select.Option 10 | 11 | class UploadForm extends Component { 12 | constructor () { 13 | super() 14 | 15 | this.state = { 16 | submitLoading: false 17 | } 18 | 19 | this.formRef = createRef() 20 | this.onFinish = this.onFinish.bind(this) 21 | this.onFinishFailed = this.onFinishFailed.bind(this) 22 | } 23 | 24 | async onFinish (values) { 25 | this.setState({ submitLoading: true }) 26 | 27 | let strList = [] 28 | 29 | Object.keys(values).forEach(item => { 30 | strList.push(`${item}=${values[item]}`) 31 | }) 32 | 33 | const res = await fetch(`${apiBaseUrl}add2gank`, { 34 | method: "POST", 35 | headers: { 36 | 'Content-Type': 'application/x-www-form-urlencoded' 37 | }, 38 | body: strList.join('&') 39 | }) 40 | 41 | const json = await res.json() 42 | 43 | if (json.error) { 44 | message.error(json.msg) 45 | } else { 46 | message.success(json.msg) 47 | 48 | this.formRef.current.resetFields() 49 | } 50 | 51 | this.setState({ submitLoading: false }) 52 | } 53 | 54 | onFinishFailed = ({ errorFields }) => { 55 | this.formRef.current.scrollToField(errorFields[0].name) 56 | } 57 | 58 | async checkUrl (rule, value) { 59 | if (value && !this.validUrl(value)) { 60 | throw new Error('请输入正确的url地址!') 61 | } 62 | } 63 | 64 | validUrl (str) { 65 | const pattern = /^(https?:\/\/)?((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3}))(\:\d+)?(\/[-a-z\d%_.~+]*)*(\?[;&a-z\d%_.~+=-]*)?(\#[-a-z\d_]*)?$/i 66 | 67 | return !!pattern.test(str) 68 | } 69 | 70 | render () { 71 | const layout = { 72 | labelCol: { 73 | xs: { span: 24 }, 74 | sm: { span: 6 }, 75 | }, 76 | wrapperCol: { 77 | xs: { span: 24 }, 78 | sm: { span: 14 }, 79 | } 80 | } 81 | 82 | const tailLayout = { 83 | wrapperCol: { 84 | xs: { 85 | span: 24, 86 | offset: 0, 87 | }, 88 | sm: { 89 | span: 14, 90 | offset: 6, 91 | } 92 | } 93 | } 94 | 95 | const types = ['Android', 'iOS', '休息视频', '福利', '拓展资源', '前端', '瞎推荐', 'App'] 96 | 97 | return ( 98 | 99 | 106 |
    116 | 127 | 128 | 129 | 138 | 139 | 140 | 149 | 150 | 151 | 158 | 161 | 162 | 167 | 168 | 169 | 170 | 177 | 178 |
    179 |
    180 | ) 181 | } 182 | } 183 | 184 | export default UploadForm 185 | -------------------------------------------------------------------------------- /pages/m/upload.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | NavBar, NoticeBar, List, InputItem, Picker, 4 | Switch, Button, Toast, WingBlank, WhiteSpace 5 | } from 'antd-mobile' 6 | import { createForm } from 'rc-form' 7 | import MenuBar from '../../components/mobile/MenuBar' 8 | import Layout from '../../components/mobile/Layout' 9 | import { apiBaseUrl } from '../../utils' 10 | 11 | const { Item } = List 12 | const types = [ 13 | 'Android', 'iOS', '休息视频', '福利', 14 | '拓展资源', '前端', '瞎推荐', 'App' 15 | ].map(item => ({ 16 | label: item, 17 | value: item 18 | })) 19 | 20 | class MobileUploadForm extends React.Component { 21 | constructor (props) { 22 | super(props) 23 | 24 | this.state = { 25 | submitLoading: false 26 | } 27 | 28 | this.onReset = this.onReset.bind(this) 29 | this.onSubmit = this.onSubmit.bind(this) 30 | } 31 | 32 | onSubmit () { 33 | this.props.form.validateFields({ force: true }, async (error) => { 34 | if (error) { 35 | Toast.fail('验证失败', 2) 36 | 37 | return 38 | } 39 | 40 | this.setState({ 41 | submitLoading: true 42 | }) 43 | 44 | const values = this.props.form.getFieldsValue() 45 | 46 | let strList = [] 47 | 48 | Object.keys(values).forEach(item => { 49 | if (item === 'type') { 50 | strList.push(`${item}=${values[item][0]}`) 51 | } else { 52 | strList.push(`${item}=${values[item]}`) 53 | } 54 | }) 55 | 56 | const res = await fetch(`${apiBaseUrl}add2gank`, { 57 | method: "POST", 58 | headers: { 59 | 'Content-Type': 'application/x-www-form-urlencoded' 60 | }, 61 | body: strList.join('&') 62 | }) 63 | 64 | const data = await res.json() 65 | 66 | if (data.error) { 67 | Toast.fail(data.msg, 2) 68 | } else { 69 | Toast.success(data.msg, 2) 70 | 71 | this.onReset() 72 | } 73 | 74 | this.setState({ 75 | submitLoading: false 76 | }) 77 | }) 78 | } 79 | 80 | onReset () { 81 | this.props.form.resetFields() 82 | } 83 | 84 | validateUrl (rule, value, callback) { 85 | if (value && this.checkUrl(value)) { 86 | callback() 87 | } else { 88 | callback(new Error('请输入正确的链接')) 89 | } 90 | } 91 | 92 | checkUrl (str) { 93 | const pattern = /^(https?:\/\/)?((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3}))(\:\d+)?(\/[-a-z\d%_.~+]*)*(\?[;&a-z\d%_.~+=-]*)?(\#[-a-z\d_]*)?$/i 94 | 95 | if (pattern.test(str)) { 96 | return true 97 | } 98 | 99 | return false 100 | } 101 | 102 | render () { 103 | const { 104 | url: { pathname } 105 | } = this.props 106 | const { getFieldProps, getFieldError } = this.props.form 107 | 108 | return ( 109 | 110 | 113 | 116 | 发布 117 | 118 | 119 | 本项目 api 由 gank(干货集中营)提供,本着对 api 提供者负责的原则,此提交表单默认是 debug 模式,非正式提交(但走的是真实接口,可控制台查看),如果您认为你的链接的确是高质量的干活,手动将 “测试数据” 设置为否即可,并且欢迎您提供优质的干货分享给大家. 120 | 121 | 122 |
    123 | 124 | { 138 | Toast.fail(getFieldError('url').join('、'), 2) 139 | }} 140 | placeholder="请输入分享链接" 141 | > 142 | 链接 143 | 144 | { 156 | Toast.fail(getFieldError('desc').join('、'), 2) 157 | }} 158 | placeholder="请输入标题" 159 | > 160 | 标题 161 | 162 | { 174 | Toast.fail(getFieldError('who').join('、'), 2) 175 | }} 176 | placeholder="请输入昵称" 177 | > 178 | 昵称 179 | 180 | 187 | 类型 188 | 189 | } 194 | > 195 | 测试数据 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 |
    205 |
    206 |
    207 | ) 208 | } 209 | } 210 | 211 | export default createForm()(MobileUploadForm) 212 | --------------------------------------------------------------------------------