├── .editorconfig ├── .env ├── .eslintrc ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── .umirc.js ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── mock └── .gitkeep ├── nginx.conf ├── now.json ├── package.json ├── public └── favicon.ico ├── src ├── components │ ├── Picture.less │ ├── PictureUploader.js │ ├── fileTable │ │ ├── CopyIcon.js │ │ ├── FileLink.js │ │ └── index.js │ └── operation │ │ ├── CopyButton.js │ │ ├── DeleteButton.js │ │ ├── Filter.js │ │ └── index.js ├── dva.js ├── global.less ├── icons.js ├── layouts │ ├── Footer.js │ ├── Footer.less │ ├── Header.js │ ├── index.js │ └── index.less ├── models │ ├── dashboard.js │ └── uploadlist.js ├── pages │ ├── about │ │ ├── index.js │ │ └── index.less │ ├── dashboard │ │ └── index.js │ ├── document.ejs │ └── index.js ├── services │ └── filelist.js └── utils │ ├── index.js │ ├── localForage.js │ └── request.js ├── tests └── dashboard.spec.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | BROWSER=none 2 | 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-umi", 3 | "rules": { 4 | "semi": ["error", "never"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /npm-debug.log* 6 | /yarn-error.log 7 | /yarn.lock 8 | /package-lock.json 9 | 10 | # production 11 | /dist 12 | 13 | # misc 14 | .DS_Store 15 | 16 | # umi 17 | .umi 18 | .umi-production 19 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.md 2 | **/*.svg 3 | **/*.ejs 4 | **/*.html 5 | package.json 6 | .umi 7 | .umi-production 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "printWidth": 100, 6 | "overrides": [ 7 | { 8 | "files": ".prettierrc", 9 | "options": { "parser": "json" } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "10" 5 | 6 | script: 7 | - yarn lint 8 | - yarn test 9 | - yarn build 10 | 11 | cache: 12 | yarn: true 13 | directories: 14 | - node_modules 15 | -------------------------------------------------------------------------------- /.umirc.js: -------------------------------------------------------------------------------- 1 | // ref: https://umijs.org/config/ 2 | import path from 'path' 3 | 4 | export default { 5 | proxy: { 6 | '/api': { 7 | target: 'https://sm.ms/api', 8 | changeOrigin: true, 9 | pathRewrite: { '^/api': '' }, 10 | }, 11 | }, 12 | hash: true, 13 | extraBabelPlugins: ['lodash'], 14 | alias: { 15 | '@ant-design/icons/lib/dist$': path.resolve(__dirname, './src/icons.js'), 16 | }, 17 | plugins: [ 18 | // ref: https://umijs.org/plugin/umi-plugin-react.html 19 | [ 20 | 'umi-plugin-react', 21 | { 22 | antd: true, 23 | dva: true, 24 | dynamicImport: true, 25 | title: 'smms', 26 | dll: true, 27 | pwa: false, 28 | routes: { 29 | exclude: [], 30 | }, 31 | hardSource: true, 32 | }, 33 | ], 34 | ], 35 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // 将设置放入此文件中以覆盖默认值和用户设置。 2 | { 3 | "files.associations": { 4 | "*.js": "javascriptreact" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10.10.0 as builder 2 | 3 | LABEL maintainer="Raincal " 4 | 5 | WORKDIR /app 6 | 7 | COPY . /app 8 | 9 | RUN yarn 10 | 11 | RUN yarn build 12 | 13 | FROM nginx:1.15.3-alpine 14 | 15 | COPY --from=builder /app/dist /usr/share/nginx/html 16 | COPY --from=builder /app/nginx.conf /etc/nginx 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Raincal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SMMS 2 | 3 | [![Build Status](https://travis-ci.org/Raincal/smms.svg?branch=master)](https://travis-ci.org/Raincal/smms) 4 | [![Maintainability](https://api.codeclimate.com/v1/badges/6d9102fdae772dc0e75a/maintainability)](https://codeclimate.com/github/Raincal/smms/maintainability) 5 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) 6 | 7 | ## Development 8 | 9 | ### Installation 10 | 11 | ```bash 12 | $ yarn 13 | ``` 14 | 15 | ### Start 16 | 17 | ```bash 18 | $ yarn start 19 | ``` 20 | 21 | ### Build for production 22 | 23 | ```bash 24 | $ yarn build 25 | ``` 26 | 27 | #### Configure nginx 28 | 29 | ``` 30 | location ^~/api/ { 31 | proxy_pass https://sm.ms/api/; 32 | } 33 | ``` 34 | -------------------------------------------------------------------------------- /mock/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raincal/smms/2aa3217ce89b91ed57fa27c647e8c202179225d0/mock/.gitkeep -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | 2 | user nginx; 3 | worker_processes 1; 4 | 5 | error_log /var/log/nginx/error.log warn; 6 | pid /var/run/nginx.pid; 7 | 8 | 9 | events { 10 | worker_connections 1024; 11 | } 12 | 13 | 14 | http { 15 | server { 16 | listen 80; 17 | server_name localhost; 18 | index index.html; 19 | root /usr/share/nginx/html; 20 | location / { 21 | try_files $uri $uri/ /index.html; 22 | } 23 | location ^~/api/ { 24 | proxy_pass https://sm.ms/api/; 25 | } 26 | } 27 | 28 | include /etc/nginx/mime.types; 29 | default_type application/octet-stream; 30 | 31 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 32 | '$status $body_bytes_sent "$http_referer" ' 33 | '"$http_user_agent" "$http_x_forwarded_for"'; 34 | 35 | access_log /var/log/nginx/access.log main; 36 | 37 | sendfile on; 38 | #tcp_nopush on; 39 | 40 | keepalive_timeout 65; 41 | 42 | gzip on; 43 | gzip_min_length 1k; 44 | gzip_buffers 4 16k; 45 | gzip_http_version 1.1; 46 | gzip_comp_level 6; 47 | gzip_types text/plain application/x-javascript application/javascript text/css application/xml text/javascript application/x-httpd-php; 48 | 49 | include /etc/nginx/conf.d/*.conf; 50 | } 51 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "public": true, 3 | "type": "docker", 4 | "alias": "smms" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smms", 3 | "version": "2.0.0", 4 | "author": { 5 | "name": "Raincal", 6 | "email": "cyj94228@gmail.com" 7 | }, 8 | "repository": { 9 | "url": "https://github.com/Raincal/smms" 10 | }, 11 | "license": "MIT", 12 | "private": true, 13 | "scripts": { 14 | "start": "umi dev", 15 | "build": "umi build", 16 | "test": "umi test", 17 | "lint": "eslint --ext .js src tests", 18 | "precommit": "lint-staged" 19 | }, 20 | "dependencies": { 21 | "localforage": "1.7.2", 22 | "react-copy-to-clipboard": "^5.0.1", 23 | "redux-persist": "5.10.0" 24 | }, 25 | "devDependencies": { 26 | "babel-eslint": "^9.0.0", 27 | "babel-plugin-lodash": "^3.3.4", 28 | "eslint": "^5.4.0", 29 | "eslint-config-umi": "^0.1.5", 30 | "eslint-plugin-flowtype": "^2.50.0", 31 | "eslint-plugin-import": "^2.14.0", 32 | "eslint-plugin-jsx-a11y": "^5.1.1", 33 | "eslint-plugin-react": "^7.11.1", 34 | "husky": "^0.14.3", 35 | "lint-staged": "^7.2.2", 36 | "umi": "^2.0.0-0", 37 | "umi-plugin-react": "^1.0.0-0" 38 | }, 39 | "lint-staged": { 40 | "*.{js,jsx}": [ 41 | "eslint --fix", 42 | "git add" 43 | ] 44 | }, 45 | "engines": { 46 | "node": ">=8.0.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raincal/smms/2aa3217ce89b91ed57fa27c647e8c202179225d0/public/favicon.ico -------------------------------------------------------------------------------- /src/components/Picture.less: -------------------------------------------------------------------------------- 1 | :global { 2 | .ant-upload-list-item { 3 | float: left; 4 | width: 200px; 5 | margin-right: 8px; 6 | 7 | a { 8 | text-decoration: none; 9 | } 10 | } 11 | .ant-upload-animate-enter { 12 | animation-name: uploadAnimateInlineIn; 13 | } 14 | .ant-upload-animate-leave { 15 | animation-name: uploadAnimateInlineOut; 16 | } 17 | } -------------------------------------------------------------------------------- /src/components/PictureUploader.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Upload, Icon, message } from 'antd' 3 | 4 | import './Picture.less' 5 | 6 | const Dragger = Upload.Dragger 7 | 8 | const props = { 9 | name: 'smfile', 10 | multiple: true, 11 | accept: 'image/*', 12 | showUploadList: { 13 | showRemoveIcon: false, 14 | }, 15 | listType: 'picture', 16 | action: '/api/upload', 17 | headers: { 18 | 'X-Requested-With': null, 19 | }, 20 | beforeUpload(file) { 21 | const isPic = /^(?:image\/jpe?g|image\/png|image\/gif|image\/bmp)$/i.test( 22 | file.type 23 | ), 24 | isSmall = file.size < 1024 * 1024 * 5, 25 | isPass = isPic && isSmall 26 | 27 | if (!isPic) { 28 | message.error('File not supported!', 3) 29 | } else if (!isSmall) { 30 | message.error('Your picture is larger than 5MB!', 3) 31 | } 32 | 33 | return isPass 34 | }, 35 | } 36 | 37 | class PictureUploader extends Component { 38 | state = { 39 | fileList: this.props.uploadlist, 40 | } 41 | 42 | handleChange = ({ file, fileList }) => { 43 | const response = file.response 44 | fileList = fileList.slice(-100) 45 | this.setState({ fileList }) 46 | if (response) { 47 | if (response.code === 'success') { 48 | const { uid, name } = file 49 | this.props.dispatch({ 50 | type: 'uploadlist/upload', 51 | payload: { 52 | data: { 53 | ...response.data, 54 | filename: name, 55 | visible: true, 56 | uid, 57 | name, 58 | }, 59 | }, 60 | }) 61 | message.success(`${file.name} file uploaded successfully.`, 3) 62 | } else if (response.status === 'error') { 63 | message.error(`${file.name} file upload failed.`, 3) 64 | } else { 65 | message.error('Server Error.', 3) 66 | } 67 | } 68 | } 69 | 70 | render() { 71 | const { fileList } = this.state 72 | return ( 73 |
74 | 75 |

76 | 77 |

78 |

79 | Click or drag file to this area to upload 80 |

81 |

82 | 5 MB max per file. 10 files max per request. 83 |

84 |
85 |
86 | ) 87 | } 88 | } 89 | 90 | export default PictureUploader 91 | -------------------------------------------------------------------------------- /src/components/fileTable/CopyIcon.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Icon, Tooltip } from 'antd' 3 | import CopyToClipboard from 'react-copy-to-clipboard' 4 | 5 | class CopyIcon extends Component { 6 | state = { 7 | copied: false, 8 | title: this.props.title, 9 | } 10 | 11 | handleVisibleChange = () => { 12 | this.setState({ 13 | copied: false, 14 | title: this.props.title, 15 | }) 16 | } 17 | 18 | handleCopy = () => { 19 | this.setState({ 20 | copied: true, 21 | title: 'Copied', 22 | }) 23 | } 24 | 25 | render() { 26 | const { text, placement, type } = this.props 27 | const { title, copied } = this.state 28 | return ( 29 | 34 | 35 | 45 | 46 | 47 | ) 48 | } 49 | } 50 | 51 | export default CopyIcon 52 | -------------------------------------------------------------------------------- /src/components/fileTable/FileLink.js: -------------------------------------------------------------------------------- 1 | import CopyIcon from './CopyIcon' 2 | 3 | const PREFIX = 'https://i.loli.net' 4 | 5 | const FileLink = ({ text, record }) => ( 6 |
7 | 8 | {text.slice(1)} 9 | 10 | 16 | 22 |
23 | ) 24 | 25 | export default FileLink 26 | -------------------------------------------------------------------------------- /src/components/fileTable/index.js: -------------------------------------------------------------------------------- 1 | import format from 'date-fns/format' 2 | import { Table, Popconfirm, Button, Icon } from 'antd' 3 | 4 | import FileLink from './FileLink' 5 | 6 | import { formatBytes } from '../../utils' 7 | 8 | const FileTable = ({ 9 | loading, 10 | queryList, 11 | selectedRowKeys, 12 | onSelectChange, 13 | onDelete, 14 | filteredFilelist, 15 | }) => { 16 | const columns = [ 17 | { 18 | title: 'Filename', 19 | dataIndex: 'filename', 20 | key: 'filename', 21 | }, 22 | { 23 | title: 'Url ( Domain: i.loli.net )', 24 | dataIndex: 'path', 25 | key: 'url', 26 | render: (text, record) => , 27 | }, 28 | { 29 | title: 'Pixels', 30 | dataIndex: 'pixels', 31 | key: 'pixels', 32 | render: (text, record) =>
{`${record.width}x${record.height}`}
, 33 | }, 34 | { 35 | title: 'Size', 36 | dataIndex: 'size', 37 | key: 'size', 38 | render: text =>
{formatBytes(text)}
, 39 | }, 40 | { 41 | title: 'Time Uploaded', 42 | dataIndex: 'timestamp', 43 | key: 'timestamp', 44 | render: text =>
{format(text * 1000, 'YYYY-MM-DD HH:mm:ss')}
, 45 | }, 46 | { 47 | title: 'Operation', 48 | render: (text, record) => ( 49 | onDelete(record.hash, record.filename)}> 52 | 53 | 54 | ), 55 | }, 56 | ] 57 | 58 | const rowSelection = { 59 | selectedRowKeys, 60 | onChange: onSelectChange, 61 | } 62 | 63 | return ( 64 | record.hash} 69 | dataSource={filteredFilelist(queryList)} 70 | columns={columns} 71 | locale={{ 72 | emptyText: ( 73 | 74 | No Data 75 | 76 | ), 77 | }} 78 | /> 79 | ) 80 | } 81 | 82 | export default FileTable 83 | -------------------------------------------------------------------------------- /src/components/operation/CopyButton.js: -------------------------------------------------------------------------------- 1 | import { Button, message } from 'antd' 2 | import CopyToClipboard from 'react-copy-to-clipboard' 3 | 4 | const styles = { 5 | marginLeft: 8, 6 | } 7 | 8 | const CopyButton = ({ hasSelected, links, type, children }) => { 9 | let str = '' 10 | links.map(link => { 11 | link = type === 'markdown' ? `![smms](${link})` : link 12 | str += `${link}\n` 13 | return str 14 | }) 15 | 16 | const onCopy = () => { 17 | message.success('Copy successful!') 18 | } 19 | 20 | return ( 21 | 22 | 23 | 24 | ) 25 | } 26 | 27 | export default CopyButton 28 | -------------------------------------------------------------------------------- /src/components/operation/DeleteButton.js: -------------------------------------------------------------------------------- 1 | import { Button, Modal } from 'antd' 2 | 3 | const confirm = Modal.confirm 4 | 5 | const styles = { 6 | marginLeft: 8, 7 | } 8 | 9 | const DeleteButton = ({ 10 | hasSelected, 11 | onSelectedDelete, 12 | selectedRowKeys, 13 | selectedRows, 14 | }) => { 15 | const showConfirm = () => { 16 | confirm({ 17 | title: 'Want to delete these images?', 18 | okText: 'OK', 19 | cancelText: 'Cancel', 20 | onOk() { 21 | onSelectedDelete(selectedRowKeys, selectedRows) 22 | }, 23 | }) 24 | } 25 | 26 | return ( 27 | 34 | ) 35 | } 36 | 37 | export default DeleteButton 38 | -------------------------------------------------------------------------------- /src/components/operation/Filter.js: -------------------------------------------------------------------------------- 1 | import { Form, Input } from 'antd' 2 | 3 | const Search = Input.Search 4 | 5 | const Filter = ({ 6 | onFilterChange, 7 | form: { getFieldDecorator, getFieldsValue }, 8 | }) => { 9 | const handleSubmit = () => { 10 | const fields = getFieldsValue() 11 | onFilterChange(fields) 12 | } 13 | 14 | return ( 15 |
16 | {getFieldDecorator('filename', { initialValue: '' })( 17 | 18 | )} 19 |
20 | ) 21 | } 22 | 23 | export default Form.create()(Filter) 24 | -------------------------------------------------------------------------------- /src/components/operation/index.js: -------------------------------------------------------------------------------- 1 | import { Row, Col } from 'antd' 2 | 3 | import CopyButton from './CopyButton' 4 | import DeleteButton from './DeleteButton' 5 | import Filter from './Filter' 6 | 7 | const ColProps = { 8 | xs: 24, 9 | sm: 24, 10 | style: { 11 | marginBottom: 16, 12 | }, 13 | } 14 | 15 | const Operation = ({ 16 | selectedRowKeys, 17 | selectedRows, 18 | onSelectedDelete, 19 | onFilterChange, 20 | }) => { 21 | const hasSelected = selectedRowKeys.length > 0 22 | 23 | const links = selectedRows.map(item => item.url) 24 | 25 | const deleteButtonProps = { 26 | hasSelected, 27 | selectedRowKeys, 28 | selectedRows, 29 | onSelectedDelete, 30 | } 31 | 32 | const copyButtonProps = { 33 | hasSelected, 34 | links, 35 | } 36 | 37 | const filterProps = { 38 | onFilterChange, 39 | } 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | Copy Link 50 | 51 | 52 | Copy Markdown 53 | 54 | 55 | {hasSelected ? `${selectedRowKeys.length} images selected` : ''} 56 | 57 | 58 | 59 | ) 60 | } 61 | export default Operation 62 | -------------------------------------------------------------------------------- /src/dva.js: -------------------------------------------------------------------------------- 1 | import { message } from 'antd' 2 | import localForage from 'localforage' 3 | import { createPersistoid, persistReducer, persistStore } from 'redux-persist' 4 | import storage from 'redux-persist/lib/storage' 5 | 6 | import { config as localForageConfig } from './utils/localForage' 7 | 8 | localForage.config({ 9 | ...localForageConfig, 10 | }) 11 | 12 | const persistConfig = { 13 | key: 'dashboard', 14 | keyPrefix: 'smms:', 15 | storage: localForage, 16 | whitelist: ['dashboard'], 17 | } 18 | 19 | const persistEnhancer = () => createStore => (reducer, initialState, enhancer) => { 20 | const store = createStore(persistReducer(persistConfig, reducer), initialState, enhancer) 21 | const persist = persistStore(store) 22 | 23 | let persistoid = createPersistoid({ 24 | key: 'uploadlist', 25 | keyPrefix: 'smms:', 26 | storage, 27 | whitelist: ['uploadlist'], 28 | }) 29 | 30 | store.subscribe(() => { 31 | persistoid.update(store.getState()) 32 | }) 33 | 34 | return { ...store, persist } 35 | } 36 | 37 | export function config() { 38 | return { 39 | onError(err) { 40 | err.preventDefault() 41 | message.error(err.message) 42 | }, 43 | extraEnhancers: [persistEnhancer()], 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/global.less: -------------------------------------------------------------------------------- 1 | html, body, #root { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | } 8 | -------------------------------------------------------------------------------- /src/icons.js: -------------------------------------------------------------------------------- 1 | //https://github.com/ant-design/ant-design/issues/12011#issuecomment-420038579 2 | export { default as AppstoreOutline } from '@ant-design/icons/lib/outline/AppstoreOutline' 3 | export { default as CheckOutline } from '@ant-design/icons/lib/outline/CheckOutline' 4 | export { default as CopyOutline } from '@ant-design/icons/lib/outline/CopyOutline' 5 | export { default as DashboardOutline } from '@ant-design/icons/lib/outline/DashboardOutline' 6 | export { default as FrownOutline } from '@ant-design/icons/lib/outline/FrownOutline' 7 | export { default as GithubOutline } from '@ant-design/icons/lib/outline/GithubOutline' 8 | export { default as HomeOutline } from '@ant-design/icons/lib/outline/HomeOutline' 9 | export { default as InboxOutline } from '@ant-design/icons/lib/outline/InboxOutline' 10 | export { default as LinkOutline } from '@ant-design/icons/lib/outline/LinkOutline' 11 | -------------------------------------------------------------------------------- /src/layouts/Footer.js: -------------------------------------------------------------------------------- 1 | import styles from './Footer.less' 2 | 3 | const Footer = () =>
© 2017-2018 ♥ Raincal
4 | 5 | export default Footer 6 | -------------------------------------------------------------------------------- /src/layouts/Footer.less: -------------------------------------------------------------------------------- 1 | .footer { 2 | height: 48px; 3 | line-height: 48px; 4 | text-align: center; 5 | color: #999; 6 | } 7 | -------------------------------------------------------------------------------- /src/layouts/Header.js: -------------------------------------------------------------------------------- 1 | import { Menu, Icon } from 'antd' 2 | import Link from 'umi/link' 3 | 4 | const MENUS = [ 5 | { path: '/', icon: 'home', text: 'Home' }, 6 | { path: '/dashboard', icon: 'dashboard', text: 'Dashboard' }, 7 | { path: '/about', icon: 'appstore-o', text: 'About' }, 8 | { path: '/github', icon: 'github', text: 'Source Code' }, 9 | ] 10 | 11 | const Header = ({ location }) => ( 12 | 13 | {MENUS.map(menu => ( 14 | 15 | {menu.path === '/github' ? ( 16 | 17 | 18 | {menu.text} 19 | 20 | ) : ( 21 | 22 | 23 | {menu.text} 24 | 25 | )} 26 | 27 | ))} 28 | 29 | ) 30 | 31 | export default Header 32 | -------------------------------------------------------------------------------- /src/layouts/index.js: -------------------------------------------------------------------------------- 1 | import withRouter from 'umi/withRouter' 2 | 3 | import Header from './Header' 4 | import Footer from './Footer' 5 | 6 | import styles from './index.less' 7 | 8 | const BasicLayout = ({ children, location }) => { 9 | return ( 10 |
11 |
12 |
{children}
13 |
14 |
15 | ) 16 | } 17 | 18 | export default withRouter(BasicLayout) 19 | -------------------------------------------------------------------------------- /src/layouts/index.less: -------------------------------------------------------------------------------- 1 | .layout { 2 | display: flex; 3 | flex-flow: column; 4 | min-height: 100vh; 5 | } 6 | 7 | .content { 8 | flex: 1; 9 | } 10 | -------------------------------------------------------------------------------- /src/models/dashboard.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash/fp' 2 | import { message } from 'antd' 3 | 4 | import * as fileService from '../services/filelist' 5 | import { loadState } from '../utils/localForage' 6 | import { parseHTML } from '../utils' 7 | 8 | export default { 9 | state: { 10 | filelist: [], 11 | queryList: [], 12 | selectedRowKeys: [], 13 | selectedRows: [], 14 | }, 15 | 16 | reducers: { 17 | save(state, { payload: { data } }) { 18 | return { 19 | ...state, 20 | filelist: _.compose( 21 | _.uniqBy('hash'), 22 | _.orderBy(['timestamp'], ['desc']) 23 | )(data), 24 | } 25 | }, 26 | 27 | query(state, { payload: { query } }) { 28 | const filename = query.filename || '' 29 | return { 30 | ...state, 31 | queryList: _.filter( 32 | item => 33 | item.filename.toUpperCase().indexOf(filename.toUpperCase()) > -1 34 | )(state.filelist), 35 | } 36 | }, 37 | 38 | remove(state, { payload: { hash } }) { 39 | const _setVisible = item => { 40 | if (item.hash === hash) { 41 | item.visible = false 42 | } 43 | return item 44 | } 45 | return { 46 | ...state, 47 | filelist: _.map(_setVisible)(state.filelist), 48 | } 49 | }, 50 | 51 | selectChange(state, { payload: { selectedRowKeys, selectedRows } }) { 52 | return { 53 | ...state, 54 | selectedRowKeys, 55 | selectedRows, 56 | } 57 | }, 58 | 59 | resetSelect(state) { 60 | return { 61 | ...state, 62 | selectedRowKeys: [], 63 | selectedRows: [], 64 | } 65 | }, 66 | }, 67 | 68 | effects: { 69 | fetch: [ 70 | function*({ payload: { query } }, { call, put }) { 71 | const { dashboard } = yield loadState('smms:dashboard') 72 | const filelist = JSON.parse(dashboard).filelist || [] 73 | const { data: { data } } = yield call(fileService.fetch) 74 | const responseData = data || [] 75 | yield put({ 76 | type: 'save', 77 | payload: { 78 | data: [...filelist, ...responseData], 79 | }, 80 | }) 81 | yield put({ 82 | type: 'query', 83 | payload: { 84 | query, 85 | }, 86 | }) 87 | }, 88 | { type: 'throttle', ms: 1000 }, 89 | ], 90 | 91 | *delete({ payload: { hash, filename } }, { call, put }) { 92 | const { data } = yield call(fileService.remove, hash) 93 | const { code } = parseHTML(data) 94 | if (code === 'success') { 95 | yield put({ type: 'remove', payload: { hash } }) 96 | yield put({ type: 'uploadlist/remove', payload: { hash } }) 97 | } else { 98 | message.error(`${filename} delete failed!`) 99 | } 100 | }, 101 | 102 | *selectedDelete({ payload: { selectedRowKeys, selectedRows } }, { put }) { 103 | yield _.map(function* _delete(item) { 104 | const { hash, filename } = item 105 | yield put({ 106 | type: 'delete', 107 | payload: { hash, filename }, 108 | }) 109 | })(selectedRows) 110 | yield put({ type: 'resetSelect' }) 111 | }, 112 | }, 113 | 114 | subscriptions: { 115 | setup({ dispatch, history }) { 116 | return history.listen(({ pathname, query }) => { 117 | if (pathname === '/dashboard') { 118 | dispatch({ 119 | type: 'fetch', 120 | payload: { 121 | query, 122 | }, 123 | }) 124 | } 125 | }) 126 | }, 127 | }, 128 | } 129 | -------------------------------------------------------------------------------- /src/models/uploadlist.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash/fp' 2 | import { loadLocalState } from '../utils/localForage' 3 | 4 | export default { 5 | state: [], 6 | 7 | reducers: { 8 | init(state, { payload: { data } }) { 9 | return data 10 | }, 11 | 12 | save(state, { payload: { data } }) { 13 | return [...state, ...data] 14 | }, 15 | 16 | remove(state, { payload: { hash } }) { 17 | return _.filter(item => item.hash !== hash)(state) 18 | }, 19 | }, 20 | 21 | effects: { 22 | *upload({ payload: { data } }, { put }) { 23 | yield put({ 24 | type: 'save', 25 | payload: { 26 | data: [data], 27 | }, 28 | }) 29 | }, 30 | }, 31 | 32 | subscriptions: { 33 | setup({ dispatch, history }) { 34 | return history.listen(({ pathname }) => { 35 | if (pathname === '/' || '/dashboard') { 36 | let { uploadlist } = loadLocalState('smms:uploadlist') 37 | uploadlist = typeof uploadlist === 'string' ? JSON.parse(uploadlist) : [] 38 | dispatch({ 39 | type: 'init', 40 | payload: { 41 | data: uploadlist, 42 | }, 43 | }) 44 | } 45 | }) 46 | }, 47 | }, 48 | } 49 | -------------------------------------------------------------------------------- /src/pages/about/index.js: -------------------------------------------------------------------------------- 1 | import styles from './index.less' 2 | 3 | const About = () => ( 4 |
5 |

本站基于 SM.MS API 制作

6 |

在法律允许范围内,请随意使用本图床。

7 |

严禁上传及分享如下类型的图片:

8 |
    9 |
  • 含有色情、暴力、宣扬恐怖主义的图片
  • 10 |
  • 侵犯版权、未经授权的图片
  • 11 |
  • 其他违反中华人民共和国法律的图片
  • 12 |
  • 其他违反香港法律的图片
  • 13 |
14 |
15 | ) 16 | 17 | export default About 18 | -------------------------------------------------------------------------------- /src/pages/about/index.less: -------------------------------------------------------------------------------- 1 | .about { 2 | display: flex; 3 | flex-flow: column; 4 | justify-content: center; 5 | align-items: center; 6 | line-height: 2; 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/dashboard/index.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'dva' 2 | import _ from 'lodash/fp' 3 | import router from 'umi/router' 4 | 5 | import FileTable from '../../components/fileTable' 6 | import Operation from '../../components/operation' 7 | 8 | 9 | const Dashboard = ({ location, dispatch, dashboard, loading }) => { 10 | const { queryList, selectedRowKeys, selectedRows } = dashboard 11 | const filelistProps = { 12 | dispatch, 13 | queryList, 14 | selectedRowKeys, 15 | selectedRows, 16 | loading: loading.models.dashboard, 17 | onDelete(hash, filename) { 18 | dispatch({ 19 | type: 'dashboard/delete', 20 | payload: { hash, filename }, 21 | }) 22 | }, 23 | onSelectChange(selectedRowKeys, selectedRows) { 24 | dispatch({ 25 | type: 'dashboard/selectChange', 26 | payload: { selectedRowKeys, selectedRows }, 27 | }) 28 | }, 29 | filteredFilelist(list) { 30 | return _.filter(item => item.visible !== false)(list) 31 | }, 32 | } 33 | 34 | const operationProps = { 35 | selectedRowKeys, 36 | selectedRows, 37 | onSelectedDelete(selectedRowKeys, selectedRows) { 38 | dispatch({ 39 | type: 'dashboard/selectedDelete', 40 | payload: { selectedRowKeys, selectedRows }, 41 | }) 42 | }, 43 | onFilterChange(fields) { 44 | router.push({ 45 | pathname: location.pathname, 46 | query: fields, 47 | }) 48 | }, 49 | } 50 | 51 | return ( 52 | <> 53 | 54 | 55 | 56 | ) 57 | } 58 | 59 | export default connect(({ dashboard, loading }) => ({ dashboard, loading }))(Dashboard) 60 | -------------------------------------------------------------------------------- /src/pages/document.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= context.title %> 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'dva' 2 | 3 | import PictureUploader from '../components/PictureUploader' 4 | 5 | export default connect(({ uploadlist }) => ({ uploadlist }))(PictureUploader) 6 | -------------------------------------------------------------------------------- /src/services/filelist.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash/fp' 2 | import request, { getStatus } from '../utils/request' 3 | 4 | export function fetch() { 5 | return request('/api/list') 6 | } 7 | 8 | export function remove(hash) { 9 | return request(`/api/delete/${hash}`) 10 | } 11 | 12 | export function checkExist(data) { 13 | return _.map(_mappingVisible)(data) 14 | } 15 | 16 | function _mappingVisible(item) { 17 | if (item.visible === undefined) { 18 | getStatus(item.url).then(code => { 19 | if (code === 200) { 20 | item.visible = true 21 | } else if (code === 404) { 22 | item.visible = false 23 | } 24 | return item 25 | }) 26 | } 27 | return item 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export const parseHTML = data => { 2 | let code 3 | if (typeof data === 'string') { 4 | const wrapper = document.createElement('div') 5 | wrapper.innerHTML = data 6 | const result = wrapper.getElementsByClassName('bs-callout')[0].textContent 7 | code = 8 | result && 9 | (result === 'File delete success.' || result === 'File already deleted.') 10 | ? 'success' 11 | : 'fail' 12 | } else code = 'fail' 13 | return { 14 | code, 15 | } 16 | } 17 | 18 | export const formatBytes = (bytes, decimals) => { 19 | if (bytes === 0) return '0 Bytes' 20 | const k = 1000, 21 | dm = decimals || 2, 22 | sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'], 23 | i = Math.floor(Math.log(bytes) / Math.log(k)) 24 | 25 | return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}` 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/localForage.js: -------------------------------------------------------------------------------- 1 | import localForage from 'localforage' 2 | 3 | export const config = { 4 | name: 'SMMS', 5 | storeName: 'smms', 6 | driver: localForage.INDEXEDDB, 7 | } 8 | 9 | // load state from localForage 10 | export const loadState = item => { 11 | return localForage.getItem(item).then(str => (str ? JSON.parse(str) : {})) 12 | } 13 | 14 | // load state from localStorage 15 | export const loadLocalState = item => { 16 | try { 17 | const serializedState = localStorage.getItem(item) 18 | if (serializedState === null) { 19 | return {} 20 | } 21 | return JSON.parse(serializedState) 22 | } catch (err) { 23 | return {} 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/request.js: -------------------------------------------------------------------------------- 1 | import fetch from 'dva/fetch' 2 | 3 | function parseJSON(response) { 4 | const contentType = response.headers.get('content-type') 5 | if (contentType && contentType.indexOf('application/json') !== -1) { 6 | return response.json() 7 | } else if (contentType && contentType.indexOf('text/html') !== -1) { 8 | return response.text() 9 | } 10 | } 11 | 12 | function checkStatus(response) { 13 | if (response.status >= 200 && response.status < 300) { 14 | return response 15 | } 16 | 17 | const error = new Error(response.statusText) 18 | error.response = response 19 | throw error 20 | } 21 | 22 | export function getStatus(url) { 23 | return fetch(url) 24 | .then(response => { 25 | return response.status 26 | }) 27 | .catch(err => { 28 | if (err === 'TypeError: Failed to fetch') { 29 | return 404 30 | } 31 | }) 32 | } 33 | 34 | /** 35 | * Requests a URL, returning a promise. 36 | * 37 | * @param {string} url The URL we want to request 38 | * @param {object} [options] The options we want to pass to "fetch" 39 | * @return {object} An object containing either "data" or "err" 40 | */ 41 | export default function request(url, options) { 42 | return fetch(url, options) 43 | .then(checkStatus) 44 | .then(parseJSON) 45 | .then(data => ({ data })) 46 | .catch(err => ({ err })) 47 | } 48 | -------------------------------------------------------------------------------- /tests/dashboard.spec.js: -------------------------------------------------------------------------------- 1 | import { effects } from 'dva/saga' 2 | 3 | import dashboard from '../src/models/dashboard' 4 | import * as fileService from '../src/services/filelist' 5 | 6 | describe('Dashboard Model', () => { 7 | it('loads', () => { 8 | expect(dashboard).toBeDefined() 9 | }) 10 | 11 | describe('reducer', () => { 12 | it('save should work', () => { 13 | const reducers = dashboard.reducers 14 | const reducer = reducers.save 15 | const state = { 16 | filelist: [], 17 | } 18 | expect( 19 | reducer(state, { 20 | payload: { 21 | data: [{ filename: 'smms' }], 22 | }, 23 | }) 24 | ).toEqual({ filelist: [{ filename: 'smms' }] }) 25 | }) 26 | }) 27 | 28 | describe('effects', () => { 29 | it('delete should work', () => { 30 | const { call, put } = effects 31 | const sagas = dashboard.effects 32 | const saga = sagas.delete 33 | 34 | const hash = 'QMusNXY7PJ1KIfj' 35 | const generator = saga( 36 | { payload: { hash, filename: 'smmstest.jpeg' } }, 37 | { call, put } 38 | ) 39 | 40 | const next = generator.next() 41 | expect(next.value).toEqual(call(fileService.remove, hash)) 42 | }) 43 | }) 44 | }) 45 | --------------------------------------------------------------------------------