├── src ├── components │ ├── .gitkeep │ ├── DocumentRibbon │ │ ├── styles.css │ │ └── index.jsx │ ├── DocumentLabels │ │ ├── styles.css │ │ └── index.jsx │ ├── ColorSwatch │ │ ├── colors.json │ │ ├── styles.css │ │ └── index.jsx │ ├── AppBar │ │ ├── styles.css │ │ └── index.jsx │ ├── ProfilePanel │ │ ├── styles.css │ │ └── index.jsx │ ├── KeymapHelpModal │ │ ├── styles.css │ │ └── index.jsx │ ├── AppSignPanel │ │ ├── styles.css │ │ └── index.jsx │ ├── AppMenu │ │ ├── styles.css │ │ └── index.jsx │ ├── AppNotification │ │ ├── styles.css │ │ └── index.jsx │ ├── AppModal │ │ ├── styles.css │ │ └── index.jsx │ ├── DocumentTile │ │ ├── styles.css │ │ └── index.jsx │ ├── AppKeyboardHandlers │ │ └── index.jsx │ ├── SearchBarItem │ │ └── index.jsx │ ├── InfiniteGrid │ │ └── index.jsx │ ├── DocumentTitleModal │ │ └── index.jsx │ ├── DocumentContent │ │ └── index.jsx │ ├── DocumentUrlModal │ │ └── index.jsx │ └── DocumentContextMenu │ │ └── index.jsx ├── middlewares │ ├── .gitkeep │ └── Authentication.jsx ├── node_modules │ ├── api │ ├── store │ ├── views │ ├── helpers │ ├── layouts │ ├── components │ └── middlewares ├── views │ ├── SettingsView │ │ ├── BookmarkletTab │ │ │ ├── styles.css │ │ │ └── index.jsx │ │ ├── index.jsx │ │ ├── ApiKeyTab │ │ │ └── index.jsx │ │ └── ExportTab │ │ │ └── index.jsx │ ├── DocumentView │ │ └── styles.css │ ├── BookmarkletView │ │ └── styles.css │ ├── PublicDocumentsView │ │ └── index.jsx │ ├── SharedDocumentsView │ │ └── index.jsx │ ├── LabelDocumentsView │ │ └── index.jsx │ ├── GraveyardView │ │ └── index.jsx │ ├── AboutView │ │ └── index.jsx │ ├── PublicDocumentView │ │ └── index.jsx │ └── LabelView │ │ └── index.jsx ├── styles │ ├── _scroll.scss │ ├── _base.scss │ ├── main.scss │ ├── _bugfix.scss │ └── _readable.scss ├── api │ ├── profile.js │ ├── client.js │ ├── graveyard.js │ ├── webhook.js │ ├── sharing.js │ ├── label.js │ ├── export.js │ ├── document.js │ └── common │ │ └── AbstractApi.js ├── helpers │ ├── DateHelper.js │ └── AuthProvider.js ├── store │ ├── profile │ │ ├── index.js │ │ └── actions.js │ ├── modules │ │ ├── index.js │ │ ├── urlModal.js │ │ ├── titleModal.js │ │ ├── notification.js │ │ ├── layout.js │ │ ├── auth.js │ │ └── documents.js │ ├── label │ │ ├── index.js │ │ └── actions.js │ ├── reducers.js │ ├── webhook │ │ ├── index.js │ │ └── actions.js │ ├── client │ │ ├── index.js │ │ └── actions.js │ ├── labels │ │ ├── actions.js │ │ └── index.js │ ├── clients │ │ ├── actions.js │ │ └── index.js │ ├── webhooks │ │ ├── actions.js │ │ └── index.js │ ├── createStore.js │ ├── exports │ │ ├── index.js │ │ └── actions.js │ └── helper.js ├── layouts │ ├── RootLayout.jsx │ ├── PublicLayout.jsx │ ├── styles.css │ └── MainLayout.jsx ├── index.js ├── App.jsx └── Routes.jsx ├── .dockerignore ├── .gitignore ├── public ├── robots.txt ├── favicon.ico ├── icons │ ├── icon-48x48.png │ ├── icon-72x72.png │ ├── icon-96x96.png │ ├── icon-144x144.png │ └── icon-192x192.png ├── humans.txt ├── keycloak.json ├── manifest.json ├── index.html ├── bookmarklet.js └── logo.svg ├── screenshot.png ├── .eslintignore ├── etc └── default │ ├── dev.env │ └── staging.env ├── .env ├── makefiles ├── docker │ ├── cleanup.Makefile │ └── compose.Makefile └── help.Makefile ├── .gitlab-ci.yml ├── Dockerfile ├── .travis.yml ├── docker-compose.yml ├── .eslintrc ├── .babelrc ├── CHANGELOG.md ├── .editorconfig ├── package.json ├── Makefile ├── CONTRIBUTING.md └── README.md /src/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/middlewares/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/node_modules/api: -------------------------------------------------------------------------------- 1 | ../api -------------------------------------------------------------------------------- /src/node_modules/store: -------------------------------------------------------------------------------- 1 | ../store -------------------------------------------------------------------------------- /src/node_modules/views: -------------------------------------------------------------------------------- 1 | ../views -------------------------------------------------------------------------------- /src/node_modules/helpers: -------------------------------------------------------------------------------- 1 | ../helpers -------------------------------------------------------------------------------- /src/node_modules/layouts: -------------------------------------------------------------------------------- 1 | ../layouts -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | -------------------------------------------------------------------------------- /src/node_modules/components: -------------------------------------------------------------------------------- 1 | ../components -------------------------------------------------------------------------------- /src/node_modules/middlewares: -------------------------------------------------------------------------------- 1 | ../middlewares -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | node_modules 3 | build 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nunux-keeper/keeper-web-app/HEAD/screenshot.png -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage/** 2 | node_modules/** 3 | dist/** 4 | *.spec.js 5 | src/index.html 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nunux-keeper/keeper-web-app/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/icons/icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nunux-keeper/keeper-web-app/HEAD/public/icons/icon-48x48.png -------------------------------------------------------------------------------- /public/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nunux-keeper/keeper-web-app/HEAD/public/icons/icon-72x72.png -------------------------------------------------------------------------------- /public/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nunux-keeper/keeper-web-app/HEAD/public/icons/icon-96x96.png -------------------------------------------------------------------------------- /etc/default/dev.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_ROOT=https://api.nunux.org/keeper/v2 2 | REACT_APP_LOGIN_ROOT=https://login.nunux.org 3 | -------------------------------------------------------------------------------- /public/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nunux-keeper/keeper-web-app/HEAD/public/icons/icon-144x144.png -------------------------------------------------------------------------------- /public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nunux-keeper/keeper-web-app/HEAD/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /etc/default/staging.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_ROOT=https://api.nunux.org/keeper/v2 2 | REACT_APP_LOGIN_ROOT=https://login.nunux.org 3 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_ROOT=https://api.nunux.org/keeper/v2 2 | REACT_APP_LOGIN_ROOT=https://login.nunux.org 3 | APP_SRC_DIR=/usr/src/app 4 | -------------------------------------------------------------------------------- /src/components/DocumentRibbon/styles.css: -------------------------------------------------------------------------------- 1 | .DocumentTile .ui.blue.ribbon.label { 2 | position: absolute; 3 | top: 1.2em; 4 | left: -1.2em; 5 | } 6 | 7 | -------------------------------------------------------------------------------- /src/views/SettingsView/BookmarkletTab/styles.css: -------------------------------------------------------------------------------- 1 | .bookmarklet-link { 2 | background: #E4E4E4; 3 | color: #000; 4 | padding: 10px 15px; 5 | border: 1px dashed; 6 | cursor: move; 7 | } 8 | -------------------------------------------------------------------------------- /src/views/DocumentView/styles.css: -------------------------------------------------------------------------------- 1 | .modificationDate, .originLink { 2 | font-size: smaller; 3 | color: #808080; 4 | font-style: italic; 5 | } 6 | 7 | .modificationDate { 8 | float: right; 9 | } 10 | 11 | -------------------------------------------------------------------------------- /makefiles/docker/cleanup.Makefile: -------------------------------------------------------------------------------- 1 | .SILENT : 2 | 3 | # Remove dangling Docker images 4 | cleanup: 5 | echo "Removing dangling docker images..." 6 | -docker images -q --filter 'dangling=true' | xargs docker rmi 7 | .PHONY: cleanup 8 | 9 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: node:latest 2 | 3 | cache: 4 | paths: 5 | - node_modules/ 6 | 7 | pages: 8 | before_script: 9 | - npm install 10 | script: 11 | - npm run build 12 | artifacts: 13 | paths: 14 | - build 15 | only: 16 | - master -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Nunux Keeper web app. 2 | # 3 | # VERSION 2.0 4 | 5 | FROM node:6-onbuild 6 | 7 | MAINTAINER Nicolas Carlier 8 | 9 | # Ports 10 | EXPOSE 3000 11 | 12 | ENTRYPOINT ["/usr/local/bin/npm"] 13 | 14 | CMD ["start"] 15 | -------------------------------------------------------------------------------- /src/styles/_scroll.scss: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar { 2 | -webkit-appearance: none; 3 | background-color: rgba(0,0,0,.15); 4 | width: 8px; 5 | height: 8px; 6 | } 7 | 8 | ::-webkit-scrollbar-thumb { 9 | border-radius: 0; 10 | background-color: rgba(0,0,0,.4); 11 | } 12 | -------------------------------------------------------------------------------- /public/humans.txt: -------------------------------------------------------------------------------- 1 | # Check it out: http://humanstxt.org/ 2 | 3 | /* TEAM */ 4 | 5 | Author: Nicolas Carlier 6 | Site: http://ncarlier.github.io/ 7 | Twitter: @ncarlier 8 | From: Tours, Centre, France 9 | 10 | /* SITE */ 11 | Language: English 12 | Doctype: HTML5 13 | IDE: vim 14 | -------------------------------------------------------------------------------- /src/components/DocumentLabels/styles.css: -------------------------------------------------------------------------------- 1 | .DocumentLabels > a { 2 | color: grey; 3 | } 4 | .DocumentLabels .labels { 5 | display: inline-block; 6 | } 7 | .DocumentLabels .labels > .label { 8 | margin-right: 1em 0; 9 | } 10 | .DocumentLabels .icons { 11 | margin-right: .5em; 12 | } 13 | 14 | -------------------------------------------------------------------------------- /src/views/BookmarkletView/styles.css: -------------------------------------------------------------------------------- 1 | .bookmarklet .ui.top.menu { 2 | border-radius: 0; 3 | margin: 0; 4 | } 5 | .bookmarklet .dropzone { 6 | height: 160px; 7 | text-align: center; 8 | padding: 2em; 9 | } 10 | .bookmarklet .dropzone.over { 11 | background-color: blue; 12 | } 13 | 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "5.0" 5 | 6 | cache: 7 | directories: 8 | - node_modules 9 | 10 | install: 11 | - npm install 12 | 13 | script: 14 | - npm run test 15 | - NODE_ENV=development npm run deploy 16 | - NODE_ENV=staging npm run deploy 17 | - NODE_ENV=production npm run deploy 18 | -------------------------------------------------------------------------------- /src/components/ColorSwatch/colors.json: -------------------------------------------------------------------------------- 1 | [ 2 | "#1ABC9C", 3 | "#16A085", 4 | "#2ECC71", 5 | "#27AE60", 6 | "#3498DB", 7 | "#2980B9", 8 | "#9B59B6", 9 | "#8E44AD", 10 | "#34495E", 11 | "#2C3E50", 12 | "#F1C40F", 13 | "#F39C12", 14 | "#E67E22", 15 | "#D35400", 16 | "#E74C3C", 17 | "#C0392B", 18 | "#ECF0F1", 19 | "#BDC3C7", 20 | "#95A5A6", 21 | "#7F8C8D" 22 | ] -------------------------------------------------------------------------------- /src/views/PublicDocumentsView/index.jsx: -------------------------------------------------------------------------------- 1 | import { DocumentsView } from 'views/DocumentsView' 2 | 3 | export class PublicDocumentsView extends DocumentsView { 4 | constructor () { 5 | super() 6 | this.title = 'Public sharing' 7 | this.pub = true 8 | } 9 | 10 | get header () { 11 | return null 12 | } 13 | } 14 | 15 | export default DocumentsView.connect(PublicDocumentsView) 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.1" 2 | services: 3 | ####################################### 4 | # Web App 5 | ####################################### 6 | app: 7 | image: "ncarlier/keeper-web-app:latest" 8 | env_file: "etc/default/${ENV:-dev}.env" 9 | command: "${CMD:-start}" 10 | volumes: 11 | - ${PWD}:${APP_SRC_DIR:-/usr/src/app_src} 12 | ports: 13 | - "${PORT:-3000}:3000" 14 | -------------------------------------------------------------------------------- /src/components/ColorSwatch/styles.css: -------------------------------------------------------------------------------- 1 | .ColorSwatch button { 2 | width: 50px; 3 | height: 50px; 4 | border: none; 5 | transition: transform 0.1s; 6 | transform: translateZ(0); 7 | position: relative; 8 | outline: none; 9 | } 10 | 11 | .ColorSwatch .selected { 12 | transform: scale(1.1) !important; 13 | box-shadow: 0 2px 3px rgba(0, 0, 0, 0.3); 14 | border-radius: 3px; 15 | z-index: 10 !important; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/AppBar/styles.css: -------------------------------------------------------------------------------- 1 | 2 | #AppBar .item { 3 | border-right: 1px solid rgba(255, 255, 255, 0.2); 4 | } 5 | 6 | #AppBar > .item.title { 7 | flex: 0 1 auto; 8 | white-space: nowrap; 9 | overflow-x: overlay; 10 | } 11 | 12 | #AppBar > .right.menu { 13 | flex: 1 1 auto; 14 | justify-content: flex-end; 15 | margin-left: 0 !important; 16 | } 17 | 18 | #AppBar .item.search { 19 | flex: 1 0 auto; 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/api/profile.js: -------------------------------------------------------------------------------- 1 | import AbstractApi from 'api/common/AbstractApi' 2 | 3 | export class ProfileApi extends AbstractApi { 4 | get (query) { 5 | return this.fetch('/profiles/current', {query}) 6 | } 7 | 8 | update (update) { 9 | return this.fetch('/profiles/current', { 10 | method: 'put', 11 | body: JSON.stringify(update) 12 | }) 13 | } 14 | 15 | } 16 | 17 | const instance = new ProfileApi() 18 | export default instance 19 | -------------------------------------------------------------------------------- /makefiles/help.Makefile: -------------------------------------------------------------------------------- 1 | .SILENT: 2 | 3 | ## This help screen 4 | help: 5 | printf "Available targets:\n\n" 6 | awk '/^[a-zA-Z\-\_0-9]+:/ { \ 7 | helpMessage = match(lastLine, /^## (.*)/); \ 8 | if (helpMessage) { \ 9 | helpCommand = substr($$1, 0, index($$1, ":")); \ 10 | helpMessage = substr(lastLine, RSTART + 3, RLENGTH); \ 11 | printf "%-15s %s\n", helpCommand, helpMessage; \ 12 | } \ 13 | } \ 14 | { lastLine = $$0 }' $(MAKEFILE_LIST) 15 | .PHONY: help 16 | 17 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | # vim:ft=yaml: 2 | 3 | # So parent files don't get applied 4 | root: true 5 | 6 | env: 7 | browser: true 8 | 9 | extends: 10 | - standard 11 | - standard-react 12 | 13 | rules: 14 | semi: [2, never] 15 | 16 | parser: babel-eslint 17 | 18 | globals: 19 | __DEV__ : false 20 | __PROD__ : false 21 | __MOCK__ : false 22 | __DEBUG__ : false 23 | __DEBUG_NEW_WINDOW__ : false 24 | __BASENAME__ : false 25 | 26 | -------------------------------------------------------------------------------- /src/styles/_base.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Application Settings Go Here 3 | ------------------------------------ 4 | This file acts as a bundler for all variables/mixins/themes, so they 5 | can easily be swapped out without `core.scss` ever having to know. 6 | 7 | For example: 8 | 9 | @import './variables/colors' 10 | @import './variables/components' 11 | @import './themes/default' 12 | */ 13 | 14 | @import 'scroll'; 15 | @import 'vendor/normalize'; 16 | @import 'readable'; 17 | @import 'bugfix'; 18 | 19 | -------------------------------------------------------------------------------- /src/helpers/DateHelper.js: -------------------------------------------------------------------------------- 1 | 2 | export default class DateHelper { 3 | constructor (date) { 4 | this._date = date 5 | } 6 | 7 | static build (date = new Date()) { 8 | return new this(date) 9 | } 10 | 11 | addDays (days) { 12 | this._date.setDate(this._date.getDate() + days) 13 | return this 14 | } 15 | 16 | addHours (hours) { 17 | this._date.setHours(this._date.getHours() + hours) 18 | return this 19 | } 20 | 21 | get () { 22 | return this._date 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/ProfilePanel/styles.css: -------------------------------------------------------------------------------- 1 | .ProfilePanel { 2 | padding: 0 1em; 3 | } 4 | 5 | .ProfilePanel a { 6 | color: rgba(255,255,255,.5); 7 | float: right; 8 | margin-top: 0.5em; 9 | } 10 | 11 | .ProfilePanel a:hover { 12 | color: white; 13 | } 14 | 15 | .ProfilePanel > span { 16 | display: inline-block; 17 | vertical-align: middle; 18 | } 19 | 20 | .ProfilePanel > span strong { 21 | display: block; 22 | } 23 | 24 | .ProfilePanel > span small { 25 | color: rgba(255,255,255,.5); 26 | } 27 | 28 | -------------------------------------------------------------------------------- /src/store/profile/index.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | 3 | import { commonActionHandler } from 'store/helper' 4 | 5 | import { 6 | FETCH_PROFILE, 7 | UPDATE_PROFILE 8 | } from './actions' 9 | 10 | // ------------------------------------ 11 | // Reducer 12 | // ------------------------------------ 13 | export default handleActions({ 14 | [FETCH_PROFILE]: commonActionHandler, 15 | [UPDATE_PROFILE]: commonActionHandler 16 | }, { 17 | isProcessing: false, 18 | current: null, 19 | error: null 20 | }) 21 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | // NOTE: These options are overriden by the babel-loader configuration 2 | // for webpack, which can be found in ~/build/webpack-environments/_base 3 | // and ~/build/webpack-environments/production. 4 | // 5 | // Why? The react-transform-hmr plugin depends on HMR (and throws if 6 | // module.hot is disbled, so keeping it and related plugins contained 7 | // within webpack helps prevent unexpected errors. 8 | { 9 | "presets": ["es2015", "react", "stage-0"], 10 | "plugins": ["transform-runtime", "add-module-exports"] 11 | } 12 | -------------------------------------------------------------------------------- /public/keycloak.json: -------------------------------------------------------------------------------- 1 | { 2 | "realm": "nunux-keeper", 3 | "realm-public-key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC7kgrRHj3XvKylJmt4uoqXN/EU\nK8+GCuWEvWk2uaOoIJhsqqTsxR7Y4CLkdAQS05y3YJK/FT4wNIMmyMZGCCvZd6iO\nLA4u7DuoQs3/WTQfwQzu7ZLDK1xvo9Pk8wMZdAQ8uCYaeffNpVLOcSzX3v7mEoBa\nMOsTa4smYTcSs9/G3QIDAQAB\n-----END PUBLIC KEY-----\n", 4 | "auth-server-url": "https://login.nunux.org/auth", 5 | "ssl-required": "external", 6 | "resource": "nunux-keeper-app", 7 | "public-client": true, 8 | "enable-cors": true 9 | } 10 | -------------------------------------------------------------------------------- /src/store/modules/index.js: -------------------------------------------------------------------------------- 1 | import auth from './auth' 2 | import layout from './layout' 3 | import document from './document' 4 | import documents from './documents' 5 | import graveyard from './graveyard' 6 | import sharing from './sharing' 7 | import notification from './notification' 8 | import titleModal from './titleModal' 9 | import urlModal from './urlModal' 10 | 11 | export default { 12 | auth, 13 | layout, 14 | document, 15 | documents, 16 | graveyard, 17 | sharing, 18 | notification, 19 | titleModal, 20 | urlModal 21 | } 22 | -------------------------------------------------------------------------------- /src/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import 'base'; 2 | 3 | // Some best-practice CSS that's useful for most apps 4 | // Just remove them if they're not what you want 5 | html { 6 | box-sizing: border-box; 7 | font-family: 'Roboto', sans-serif; 8 | } 9 | 10 | html, 11 | body { 12 | margin: 0; 13 | padding: 0; 14 | min-width: inherit !important; 15 | } 16 | 17 | *, *:before, *:after { 18 | box-sizing: inherit; 19 | } 20 | 21 | img { 22 | max-width: 100%; 23 | } 24 | 25 | #welcome { 26 | height: inherit; 27 | h2 { 28 | color: #fff; 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 2.0.0 5 | ----- 6 | 7 | ### Features 8 | * Authentication delegated to external identity provider (with Keycloak) 9 | * Create text or HTML documents (from scratch or from an URL) 10 | * Edit online documents 11 | * Search and visualize documents (also in raw mode) 12 | * Create labels (name, color) 13 | * Classify documents with labels 14 | 15 | ### Improvements 16 | * Based on a complete new stack (ES6, React, Redux, Webpack) 17 | * New modern GUI thanks to Semantic UI 18 | * Better navigation with semantic routes and full history usage 19 | * Better Bookmarklet visual 20 | * Complete separation with the API backend (support full mocked setup) 21 | -------------------------------------------------------------------------------- /src/components/KeymapHelpModal/styles.css: -------------------------------------------------------------------------------- 1 | .keyboard-mappings { 2 | line-height: 1.5; 3 | } 4 | 5 | .keyboard-mappings tr td:first-child { 6 | padding-right: 10px; 7 | color: #586069; 8 | text-align: right; 9 | white-space: nowrap; 10 | } 11 | 12 | .keyboard-mappings kbd { 13 | display: inline-block; 14 | padding: 3px 5px; 15 | font: 11px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 16 | line-height: 10px; 17 | color: #444d56; 18 | vertical-align: middle; 19 | background-color: #fcfcfc; 20 | border: solid 1px #c6cbd1; 21 | border-bottom-color: #959da5; 22 | border-radius: 3px; 23 | box-shadow: inset 0 -1px 0 #959da5; 24 | margin-right: 0.2em; 25 | } 26 | -------------------------------------------------------------------------------- /src/layouts/RootLayout.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import { connect } from 'react-redux' 3 | import { actions as layoutActions } from 'store/modules/layout' 4 | 5 | export class RootLayout extends React.Component { 6 | static propTypes = { 7 | children: PropTypes.node, 8 | resize: PropTypes.func 9 | }; 10 | 11 | componentDidMount () { 12 | const { resize } = this.props 13 | window.addEventListener('resize', resize) 14 | } 15 | 16 | componentWillUnmount () { 17 | const { resize } = this.props 18 | window.removeEventListener('resize', resize) 19 | } 20 | 21 | render () { 22 | return (this.props.children) 23 | } 24 | } 25 | 26 | export default connect(null, layoutActions)(RootLayout) 27 | 28 | -------------------------------------------------------------------------------- /src/components/AppSignPanel/styles.css: -------------------------------------------------------------------------------- 1 | 2 | #AppSignPanel { 3 | position: absolute; 4 | top: 0!important; 5 | left: 0!important; 6 | width: 100%; 7 | height: 100%; 8 | text-align: center; 9 | vertical-align: middle; 10 | } 11 | 12 | #AppSignPanel.error { 13 | background-color: darkred; 14 | } 15 | 16 | #AppSignPanel.warn { 17 | background-color: orange; 18 | } 19 | 20 | #AppSignPanel.announcement { 21 | background-color: #21D03F; 22 | } 23 | 24 | #AppSignPanel > div { 25 | width: 100%; 26 | height: 100%; 27 | display: table; 28 | -webkit-user-select: text; 29 | } 30 | 31 | #AppSignPanel > div > div { 32 | display: table-cell; 33 | vertical-align: middle; 34 | color: #fff; 35 | } 36 | 37 | #AppSignPanel .ui.icon.header.normal { 38 | color: #cacaca; 39 | } 40 | -------------------------------------------------------------------------------- /src/api/client.js: -------------------------------------------------------------------------------- 1 | import AbstractApi from 'api/common/AbstractApi' 2 | 3 | export class ClientApi extends AbstractApi { 4 | all (params) { 5 | return this.fetch('/clients') 6 | } 7 | 8 | get (id) { 9 | return this.fetch(`/clients/${id}`) 10 | } 11 | 12 | create (client) { 13 | return this.fetch('/clients', { 14 | method: 'post', 15 | body: JSON.stringify(client) 16 | }) 17 | } 18 | 19 | update (client, update) { 20 | return this.fetch(`/clients/${client.id}`, { 21 | method: 'put', 22 | body: JSON.stringify(update) 23 | }) 24 | } 25 | 26 | remove (client) { 27 | return this.fetch(`/clients/${client.id}`, { 28 | method: 'delete' 29 | }) 30 | } 31 | } 32 | 33 | const instance = new ClientApi() 34 | export default instance 35 | -------------------------------------------------------------------------------- /src/api/graveyard.js: -------------------------------------------------------------------------------- 1 | import AbstractApi from 'api/common/AbstractApi' 2 | 3 | export class GraveyardApi extends AbstractApi { 4 | search (params) { 5 | const {from, size, order, label} = params 6 | let {q} = params 7 | if (label && q) { 8 | q = `labels:${label} AND ${q}` 9 | } else if (label) { 10 | q = `labels:${label}` 11 | } 12 | return this.fetch('/graveyard/documents', { 13 | query: {q, from, size, order} 14 | }) 15 | } 16 | 17 | empty () { 18 | return this.fetch('/graveyard/documents', { 19 | method: 'delete' 20 | }) 21 | } 22 | 23 | remove (doc) { 24 | return this.fetch(`/graveyard/documents/${doc.id}`, { 25 | method: 'delete' 26 | }) 27 | } 28 | } 29 | 30 | const instance = new GraveyardApi() 31 | export default instance 32 | -------------------------------------------------------------------------------- /src/api/webhook.js: -------------------------------------------------------------------------------- 1 | import AbstractApi from 'api/common/AbstractApi' 2 | 3 | export class WebhookApi extends AbstractApi { 4 | all (params) { 5 | return this.fetch('/webhooks') 6 | } 7 | 8 | get (id) { 9 | return this.fetch(`/webhooks/${id}`) 10 | } 11 | 12 | create (webhook) { 13 | return this.fetch('/webhooks', { 14 | method: 'post', 15 | body: JSON.stringify(webhook) 16 | }) 17 | } 18 | 19 | update (webhook, update) { 20 | return this.fetch(`/webhooks/${webhook.id}`, { 21 | method: 'put', 22 | body: JSON.stringify(update) 23 | }) 24 | } 25 | 26 | remove (webhook) { 27 | return this.fetch(`/webhooks/${webhook.id}`, { 28 | method: 'delete' 29 | }) 30 | } 31 | } 32 | 33 | const instance = new WebhookApi() 34 | export default instance 35 | -------------------------------------------------------------------------------- /src/components/AppMenu/styles.css: -------------------------------------------------------------------------------- 1 | 2 | #AppMenu { 3 | border: none; 4 | border-radius: 0; 5 | } 6 | 7 | #AppMenu .header { 8 | font-weight: normal; 9 | } 10 | 11 | #AppMenu > .header.item { 12 | background: #2185d0; 13 | color: rgba(255,255,255,.9); 14 | margin: 0; 15 | padding: 1em 0; 16 | border-radius: 0; 17 | } 18 | 19 | #AppMenu > .header.item h2 { 20 | width: 100%; 21 | color: rgba(255,255,255,.5); 22 | font-variant: small-caps; 23 | font-weight: bold; 24 | } 25 | 26 | #AppMenu .header > a { 27 | float: right; 28 | color: inherit; 29 | } 30 | 31 | #AppMenu .tags, 32 | #AppMenu .menu a > .icon, #AppMenu .menu a > .icons, 33 | #AppMenu > a.item > .icon, #AppMenu > a.item > .icons { 34 | margin: 0 .5em 0 0; 35 | float: none; 36 | font-size: 1.2em; 37 | width: 1em; 38 | } 39 | 40 | -------------------------------------------------------------------------------- /src/components/AppNotification/styles.css: -------------------------------------------------------------------------------- 1 | #AppNotification { 2 | color: #FFF; 3 | } 4 | 5 | #AppNotification.error { 6 | background-color: #8B0000; 7 | } 8 | 9 | #AppNotification.info { 10 | background-color: #000; 11 | } 12 | 13 | #AppNotification .header { 14 | color: #FFF; 15 | } 16 | 17 | #AppNotification .content { 18 | display: flex; 19 | } 20 | 21 | #AppNotification .content p { 22 | flex: 1 100%; 23 | } 24 | 25 | #AppNotification .content button { 26 | align-self: flex-end; 27 | } 28 | 29 | .fade-enter { 30 | opacity: 0.01; 31 | transition: opacity 1s ease-in; 32 | } 33 | 34 | .fade-enter.fade-enter-active { 35 | opacity: 1; 36 | } 37 | 38 | .fade-leave { 39 | opacity: 1; 40 | transition: opacity 1s ease-in; 41 | } 42 | 43 | .fade-leave.fade-leave-active { 44 | opacity: 0.01; 45 | } 46 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | function getParameterByName (name, url) { 6 | if (!url) { 7 | url = window.location.href 8 | } 9 | name = name.replace(/[\[\]]/g, '\\$&') 10 | const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)') 11 | const results = regex.exec(url) 12 | if (!results) return null 13 | if (!results[2]) return '' 14 | return decodeURIComponent(results[2].replace(/\+/g, ' ')) 15 | } 16 | 17 | // Redirect if require by the query params 18 | const redirect = getParameterByName('redirect') 19 | if (redirect) { 20 | console.log(`Redirecting to ${redirect} ...`) 21 | document.location.replace(redirect) 22 | } else { 23 | ReactDOM.render( 24 | , 25 | document.getElementById('root') 26 | ) 27 | } 28 | 29 | -------------------------------------------------------------------------------- /src/helpers/AuthProvider.js: -------------------------------------------------------------------------------- 1 | const keycloak = new window.Keycloak(process.env.PUBLIC_URL + '/keycloak.json') 2 | 3 | const facade = { 4 | init: () => { 5 | return new Promise((resolve, reject) => { 6 | keycloak 7 | .init({onLoad: 'check-sso'}) 8 | .success(resolve) 9 | .error(reject) 10 | }) 11 | }, 12 | updateToken: () => { 13 | return new Promise((resolve, reject) => { 14 | keycloak 15 | .updateToken(30) 16 | .success(resolve) 17 | .error(reject) 18 | }) 19 | }, 20 | getToken: () => keycloak.token, 21 | getAccountUrl: () => keycloak.createAccountUrl(), 22 | getLoginUrl: (params) => keycloak.createLoginUrl(params), 23 | getRealmUrl: () => `${keycloak.authServerUrl}/realms/${encodeURIComponent(keycloak.realm)}` 24 | } 25 | 26 | export default facade 27 | 28 | -------------------------------------------------------------------------------- /src/components/AppModal/styles.css: -------------------------------------------------------------------------------- 1 | .ReactModal__Overlay { 2 | -webkit-perspective: 600; 3 | perspective: 600; 4 | opacity: 0; 5 | z-index: 5; 6 | } 7 | 8 | .ReactModal__Overlay--after-open { 9 | opacity: 1; 10 | transition: opacity 150ms ease-out; 11 | } 12 | 13 | .ReactModal__Content { 14 | -webkit-transform: scale(0.5) rotateX(-30deg); 15 | transform: scale(0.5) rotateX(-30deg); 16 | } 17 | 18 | .ReactModal__Content--after-open { 19 | -webkit-transform: scale(1) rotateX(0deg); 20 | transform: scale(1) rotateX(0deg); 21 | transition: all 150ms ease-in; 22 | } 23 | 24 | .ReactModal__Overlay--before-close { 25 | opacity: 0; 26 | } 27 | 28 | .ReactModal__Content--before-close { 29 | -webkit-transform: scale(0.5) rotateX(30deg); 30 | transform: scale(0.5) rotateX(30deg); 31 | transition: all 150ms ease-in; 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/views/SharedDocumentsView/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Icon } from 'semantic-ui-react' 4 | 5 | import { DocumentsView } from 'views/DocumentsView' 6 | import AppSignPanel from 'components/AppSignPanel' 7 | 8 | export class SharedDocumentsView extends DocumentsView { 9 | 10 | constructor () { 11 | super() 12 | this.title = 'Sharing' 13 | this.tileContextMenuItems = 'detail' 14 | this.headerStyle = {backgroundColor: '#1678c2'} 15 | this.headerIcon = 'share alternate' 16 | } 17 | 18 | get headerAltButton () { 19 | return null 20 | } 21 | 22 | get noContent () { 23 | return ( 24 | 25 | 26 | No shared documents 27 | 28 | ) 29 | } 30 | } 31 | 32 | export default DocumentsView.connect(SharedDocumentsView) 33 | -------------------------------------------------------------------------------- /src/api/sharing.js: -------------------------------------------------------------------------------- 1 | import AbstractApi from 'api/common/AbstractApi' 2 | 3 | export class SharingApi extends AbstractApi { 4 | all (params) { 5 | return this.fetch('/sharing') 6 | } 7 | 8 | get (label) { 9 | return this.fetch(`/labels/${label.id}/sharing`) 10 | } 11 | 12 | create (label, sharing) { 13 | return this.fetch(`/labels/${label.id}/sharing`, { 14 | method: 'post', 15 | body: JSON.stringify(sharing) 16 | }) 17 | } 18 | 19 | update (label, update) { 20 | return this.fetch(`/labels/${label.id}/sharing`, { 21 | method: 'put', 22 | body: JSON.stringify(update) 23 | }) 24 | } 25 | 26 | remove (label) { 27 | return this.fetch(`/labels/${label.id}/sharing`, { 28 | method: 'delete' 29 | }) 30 | } 31 | } 32 | 33 | const instance = new SharingApi() 34 | export default instance 35 | -------------------------------------------------------------------------------- /src/store/label/index.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | 3 | import { commonActionHandler } from 'store/helper' 4 | 5 | import { 6 | FETCH_LABEL, 7 | CREATE_LABEL, 8 | UPDATE_LABEL, 9 | REMOVE_LABEL, 10 | RESTORE_LABEL, 11 | DISCARD_LABEL 12 | } from './actions' 13 | 14 | // -------------------------------------- 15 | // Reducer 16 | // -------------------------------------- 17 | export default handleActions({ 18 | [FETCH_LABEL]: commonActionHandler, 19 | [CREATE_LABEL]: commonActionHandler, 20 | [UPDATE_LABEL]: commonActionHandler, 21 | [REMOVE_LABEL]: commonActionHandler, 22 | [RESTORE_LABEL]: commonActionHandler, 23 | [DISCARD_LABEL]: (state, action) => { 24 | return Object.assign({}, state, { 25 | isProcessing: false, 26 | current: null 27 | }) 28 | } 29 | }, { 30 | isProcessing: false, 31 | current: null, 32 | error: null 33 | }) 34 | 35 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | # A special property that should be specified at the top of the file outside of 4 | # any sections. Set to true to stop .editor config file search on current file 5 | root = true 6 | 7 | [*] 8 | # Indentation style 9 | # Possible values - tab, space 10 | indent_style = space 11 | 12 | # Indentation size in single-spaced characters 13 | # Possible values - an integer, tab 14 | indent_size = 2 15 | 16 | # Line ending file format 17 | # Possible values - lf, crlf, cr 18 | end_of_line = lf 19 | 20 | # File character encoding 21 | # Possible values - latin1, utf-8, utf-16be, utf-16le 22 | charset = utf-8 23 | 24 | # Denotes whether to trim whitespace at the end of lines 25 | # Possible values - true, false 26 | trim_trailing_whitespace = true 27 | 28 | # Denotes whether file should end with a newline 29 | # Possible values - true, false 30 | insert_final_newline = true 31 | -------------------------------------------------------------------------------- /src/api/label.js: -------------------------------------------------------------------------------- 1 | import AbstractApi from 'api/common/AbstractApi' 2 | 3 | export class LabelApi extends AbstractApi { 4 | all (params) { 5 | return this.fetch('/labels') 6 | } 7 | 8 | get (id) { 9 | return this.fetch(`/labels/${id}`) 10 | } 11 | 12 | create (label) { 13 | return this.fetch('/labels', { 14 | method: 'post', 15 | body: JSON.stringify(label) 16 | }) 17 | } 18 | 19 | update (label, update) { 20 | return this.fetch(`/labels/${label.id}`, { 21 | method: 'put', 22 | body: JSON.stringify(update) 23 | }) 24 | } 25 | 26 | remove (label) { 27 | return this.fetch(`/labels/${label.id}`, { 28 | method: 'delete' 29 | }) 30 | } 31 | 32 | restore (label) { 33 | return this.fetch(`/graveyard/labels/${label.id}`, { 34 | method: 'put' 35 | }) 36 | } 37 | } 38 | 39 | const instance = new LabelApi() 40 | export default instance 41 | -------------------------------------------------------------------------------- /src/components/AppSignPanel/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | 3 | import './styles.css' 4 | 5 | export default class AppSignPanel extends React.Component { 6 | static propTypes = { 7 | children: PropTypes.node, 8 | level: PropTypes.string 9 | }; 10 | 11 | static defaultProps = { 12 | level: 'info' 13 | }; 14 | 15 | get titleClassName () { 16 | const { level } = this.props 17 | return (level === 'error' || level === 'warn') ? 'inverted' : 'normal' 18 | } 19 | 20 | render () { 21 | const { children, level } = this.props 22 | return ( 23 |
24 |
25 |
26 |

27 | {children} 28 |

29 |
30 |
31 |
32 | ) 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /src/store/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import { routerReducer as router } from 'react-router-redux' 3 | import syncReducers from './modules' 4 | 5 | import profile from './profile' 6 | import label from './label' 7 | import labels from './labels' 8 | import webhook from './webhook' 9 | import webhooks from './webhooks' 10 | import client from './client' 11 | import clients from './clients' 12 | import exports from './exports' 13 | 14 | export const makeRootReducer = (asyncReducers) => { 15 | return combineReducers({ 16 | router, 17 | ...syncReducers, 18 | profile, 19 | label, 20 | labels, 21 | webhook, 22 | webhooks, 23 | client, 24 | clients, 25 | exports, 26 | ...asyncReducers 27 | }) 28 | } 29 | 30 | export const injectReducer = (store, { key, reducer }) => { 31 | store.asyncReducers[key] = reducer 32 | store.replaceReducer(makeRootReducer(store.asyncReducers)) 33 | } 34 | 35 | export default makeRootReducer 36 | -------------------------------------------------------------------------------- /src/views/LabelDocumentsView/index.jsx: -------------------------------------------------------------------------------- 1 | import { DocumentsView } from 'views/DocumentsView' 2 | 3 | export class LabelDocumentsView extends DocumentsView { 4 | constructor () { 5 | super() 6 | this.contextMenuItems = 'refresh,order,divider,editLabel,shareLabel,divider,deleteLabel' 7 | this.headerIcon = 'tag' 8 | } 9 | 10 | get label () { 11 | const { label } = this.props 12 | return label.current ? label.current : {label: 'Undefined'} 13 | } 14 | 15 | set title (title) { 16 | this._title = title 17 | } 18 | 19 | get title () { 20 | return this.label.label 21 | } 22 | 23 | set headerStyle (style) { 24 | this._headerStyle = style 25 | } 26 | 27 | get headerStyle () { 28 | return {backgroundColor: this.label.color} 29 | } 30 | 31 | get creatDocumentLink () { 32 | const link = super.creatDocumentLink() 33 | link.query = { 34 | labels: [this.label.id] 35 | } 36 | } 37 | } 38 | 39 | export default DocumentsView.connect(LabelDocumentsView) 40 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Nunux Keeper", 3 | "short_name": "Keeper", 4 | "description": "Your personal content curation service", 5 | "developer": { 6 | "name": "Nicolas Carlier", 7 | "url": "http://ncarlier.github.io" 8 | }, 9 | "icons": [ 10 | { 11 | "src": "icons/icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "icons/icon-72x72.png", 17 | "sizes": "72x72", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "icons/icon-96x96.png", 22 | "sizes": "96x96", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "icons/icon-144x144.png", 27 | "sizes": "144x144", 28 | "type": "image/png" 29 | }, 30 | { 31 | "src": "icons/icon-192x192.png", 32 | "sizes": "192x192", 33 | "type": "image/png" 34 | } 35 | ], 36 | "lang": "en_US", 37 | "display": "standalone", 38 | "background_color": "#ececec", 39 | "theme_color": "#2185d0" 40 | } 41 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Nunux Keeper 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |
Loading...
18 |
19 |
20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/store/modules/urlModal.js: -------------------------------------------------------------------------------- 1 | import { createAction, handleActions } from 'redux-actions' 2 | 3 | // ------------------------------------ 4 | // Constants 5 | // ------------------------------------ 6 | export const SHOW_URL_MODAL = 'SHOW_URL_MODAL' 7 | export const HIDE_URL_MODAL = 'HIDE_URL_MODAL' 8 | 9 | // ------------------------------------ 10 | // Actions 11 | // ------------------------------------ 12 | export const showUrlModal = createAction(SHOW_URL_MODAL) 13 | export const hideUrlModal = createAction(HIDE_URL_MODAL) 14 | 15 | export const actions = { 16 | showUrlModal, 17 | hideUrlModal 18 | } 19 | 20 | // ------------------------------------ 21 | // Reducer 22 | // ------------------------------------ 23 | export default handleActions({ 24 | [SHOW_URL_MODAL]: (state) => { 25 | return Object.assign({}, state, { 26 | open: true 27 | }) 28 | }, 29 | [HIDE_URL_MODAL]: (state) => { 30 | return Object.assign({}, state, { 31 | open: false 32 | }) 33 | } 34 | }, { 35 | open: false 36 | }) 37 | -------------------------------------------------------------------------------- /src/layouts/PublicLayout.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | 3 | import './styles.css' 4 | 5 | const basename = process.env.PUBLIC_URL || '' 6 | 7 | export default class PublicLayout extends React.Component { 8 | static propTypes = { 9 | children: PropTypes.node 10 | }; 11 | 12 | render () { 13 | const { children } = this.props 14 | 15 | return ( 16 | 30 | ) 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/store/webhook/index.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | 3 | import { commonActionHandler } from 'store/helper' 4 | 5 | import { 6 | FETCH_WEBHOOK, 7 | CREATE_WEBHOOK, 8 | UPDATE_WEBHOOK, 9 | REMOVE_WEBHOOK, 10 | RESET_WEBHOOK 11 | } from './actions' 12 | 13 | const defaultState = { 14 | isProcessing: false, 15 | current: { 16 | id: null, 17 | url: '', 18 | secret: '', 19 | events: [], 20 | labels: [] 21 | }, 22 | error: null 23 | } 24 | 25 | // -------------------------------------- 26 | // Reducer 27 | // -------------------------------------- 28 | export default handleActions({ 29 | [FETCH_WEBHOOK]: commonActionHandler, 30 | [CREATE_WEBHOOK]: commonActionHandler, 31 | [UPDATE_WEBHOOK]: commonActionHandler, 32 | [REMOVE_WEBHOOK]: commonActionHandler, 33 | [RESET_WEBHOOK]: (state, action) => { 34 | if (state.isProcessing) { 35 | console.warn('Unable to reset webhook state. An action is pending...') 36 | return state 37 | } 38 | return Object.assign({}, defaultState) 39 | } 40 | }, defaultState) 41 | 42 | -------------------------------------------------------------------------------- /src/store/modules/titleModal.js: -------------------------------------------------------------------------------- 1 | import { createAction, handleActions } from 'redux-actions' 2 | 3 | // ------------------------------------ 4 | // Constants 5 | // ------------------------------------ 6 | export const SHOW_TITLE_MODAL = 'SHOW_TITLE_MODAL' 7 | export const HIDE_TITLE_MODAL = 'HIDE_TITLE_MODAL' 8 | 9 | // ------------------------------------ 10 | // Actions 11 | // ------------------------------------ 12 | export const showTitleModal = createAction(SHOW_TITLE_MODAL, (doc) => doc) 13 | export const hideTitleModal = createAction(HIDE_TITLE_MODAL) 14 | 15 | export const actions = { 16 | showTitleModal, 17 | hideTitleModal 18 | } 19 | 20 | // ------------------------------------ 21 | // Reducer 22 | // ------------------------------------ 23 | export default handleActions({ 24 | [SHOW_TITLE_MODAL]: (state, action) => { 25 | return Object.assign({}, state, { 26 | doc: action.payload, 27 | open: true 28 | }) 29 | }, 30 | [HIDE_TITLE_MODAL]: (state, action) => { 31 | return Object.assign({}, state, { 32 | open: false 33 | }) 34 | } 35 | }, { 36 | open: false, 37 | doc: null 38 | }) 39 | -------------------------------------------------------------------------------- /src/components/DocumentTile/styles.css: -------------------------------------------------------------------------------- 1 | .DocumentTile .contextual-menu { 2 | position: absolute; 3 | top: 1.2em; 4 | right: 1.2em; 5 | } 6 | 7 | .DocumentTile .header, 8 | .DocumentTile .meta { 9 | display: block; 10 | white-space: nowrap; 11 | overflow: hidden; 12 | text-overflow: ellipsis; 13 | } 14 | 15 | .DocumentTile .illustration > .image span { 16 | background: #cccccc; 17 | text-align: center; 18 | font-weight: bold; 19 | color: gray; 20 | line-height: 200px; 21 | display: block; 22 | } 23 | 24 | .DocumentTile .illustration > .image img { 25 | max-height: 200px; 26 | object-fit: cover; 27 | } 28 | 29 | img.broken { 30 | height: 200px !important; 31 | } 32 | 33 | img.broken:before { 34 | content: " "; 35 | display: block; 36 | position: absolute; 37 | left: 0; 38 | height: 100%; 39 | width: 100%; 40 | background-color: #ccc; 41 | } 42 | 43 | img.broken:after { 44 | content: "\F127" " Broken illustration"; 45 | font-weight: bold; 46 | font-family: Icons; 47 | color: grey; 48 | position: absolute; 49 | top: calc(50% - 0.5em); 50 | left: 0; 51 | width: 100%; 52 | text-align: center; 53 | } 54 | 55 | -------------------------------------------------------------------------------- /src/store/client/index.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | 3 | import { commonActionHandler } from 'store/helper' 4 | 5 | import { 6 | FETCH_CLIENT, 7 | CREATE_CLIENT, 8 | UPDATE_CLIENT, 9 | REMOVE_CLIENT, 10 | RESET_CLIENT 11 | } from './actions' 12 | 13 | const defaultState = { 14 | isProcessing: false, 15 | current: { 16 | id: null, 17 | name: '', 18 | clientId: '', 19 | clientSecret: '', 20 | redirectUris: [], 21 | webOrigins: [], 22 | cdate: null, 23 | mdate: null 24 | }, 25 | error: null 26 | } 27 | 28 | // -------------------------------------- 29 | // Reducer 30 | // -------------------------------------- 31 | export default handleActions({ 32 | [FETCH_CLIENT]: commonActionHandler, 33 | [CREATE_CLIENT]: commonActionHandler, 34 | [UPDATE_CLIENT]: commonActionHandler, 35 | [REMOVE_CLIENT]: commonActionHandler, 36 | [RESET_CLIENT]: (state, action) => { 37 | if (state.isProcessing) { 38 | console.warn('Unable to reset client state. An action is pending...') 39 | return state 40 | } 41 | return Object.assign({}, defaultState) 42 | } 43 | }, defaultState) 44 | 45 | -------------------------------------------------------------------------------- /src/api/export.js: -------------------------------------------------------------------------------- 1 | import AbstractApi from 'api/common/AbstractApi' 2 | 3 | export class ExportApi extends AbstractApi { 4 | 5 | getStatus (onProgress) { 6 | return this.sse('/exports/status', {}) 7 | .then(source => { 8 | return new Promise((resolve, reject) => { 9 | source.addEventListener('progress', evt => { 10 | // console.log('EventSource data:', evt.data) 11 | onProgress(evt.data) 12 | }, false) 13 | source.addEventListener('complete', evt => { 14 | source.close() 15 | return resolve(evt.data) 16 | }, false) 17 | source.addEventListener('error', (evt) => { 18 | console.log('EventSource error:', evt) 19 | source.close() 20 | return reject(evt.data || 'Unable to get export status') 21 | }, false) 22 | }) 23 | }) 24 | } 25 | 26 | schedule () { 27 | return this.fetch('/exports', { 28 | method: 'post' 29 | }) 30 | } 31 | 32 | getDownloadUrl () { 33 | return this.resolveUrl('/exports') 34 | } 35 | } 36 | 37 | const instance = new ExportApi() 38 | export default instance 39 | -------------------------------------------------------------------------------- /src/store/labels/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | createRequestAction, 3 | createSuccessAction, 4 | createFailureAction, 5 | dispatchAction 6 | } from 'store/helper' 7 | 8 | import LabelApi from 'api/label' 9 | 10 | // -------------------------------------- 11 | // Constants 12 | // -------------------------------------- 13 | export const FETCH_LABELS = 'FETCH_LABELS' 14 | 15 | // -------------------------------------- 16 | // Fetch labels actions 17 | // -------------------------------------- 18 | const fetchLabelsRequest = createRequestAction(FETCH_LABELS) 19 | const fetchLabelsFailure = createFailureAction(FETCH_LABELS) 20 | const fetchLabelsSuccess = createSuccessAction(FETCH_LABELS) 21 | 22 | export const fetchLabels = () => { 23 | return (dispatch, getState) => { 24 | const { labels } = getState() 25 | if (labels.isProcessing) { 26 | console.warn('Unable to fetch labels. An action is pending...') 27 | return Promise.resolve(null) 28 | } 29 | console.debug('Fetching labels...') 30 | dispatch(fetchLabelsRequest()) 31 | return LabelApi.all() 32 | .then( 33 | res => dispatchAction(dispatch, fetchLabelsSuccess(res)), 34 | err => dispatchAction(dispatch, fetchLabelsFailure(err)) 35 | ) 36 | } 37 | } 38 | 39 | const actions = { 40 | fetchLabels 41 | } 42 | 43 | export default actions 44 | 45 | -------------------------------------------------------------------------------- /src/store/clients/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | createRequestAction, 3 | createSuccessAction, 4 | createFailureAction, 5 | dispatchAction 6 | } from 'store/helper' 7 | 8 | import ClientApi from 'api/client' 9 | 10 | // -------------------------------------- 11 | // Constants 12 | // -------------------------------------- 13 | export const FETCH_CLIENTS = 'FETCH_CLIENTS' 14 | 15 | // -------------------------------------- 16 | // Fetch clients actions 17 | // -------------------------------------- 18 | const fetchClientsRequest = createRequestAction(FETCH_CLIENTS) 19 | const fetchClientsFailure = createFailureAction(FETCH_CLIENTS) 20 | const fetchClientsSuccess = createSuccessAction(FETCH_CLIENTS) 21 | 22 | export const fetchClients = () => { 23 | return (dispatch, getState) => { 24 | const { clients } = getState() 25 | if (clients.isProcessing) { 26 | console.warn('Unable to fetch clients. An action is pending...') 27 | return Promise.resolve(null) 28 | } 29 | console.debug('Fetching clients...') 30 | dispatch(fetchClientsRequest()) 31 | return ClientApi.all() 32 | .then( 33 | res => dispatchAction(dispatch, fetchClientsSuccess(res)), 34 | err => dispatchAction(dispatch, fetchClientsFailure(err)) 35 | ) 36 | } 37 | } 38 | 39 | const actions = { 40 | fetchClients 41 | } 42 | 43 | export default actions 44 | 45 | -------------------------------------------------------------------------------- /src/store/webhooks/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | createRequestAction, 3 | createSuccessAction, 4 | createFailureAction, 5 | dispatchAction 6 | } from 'store/helper' 7 | 8 | import WebhookApi from 'api/webhook' 9 | 10 | // -------------------------------------- 11 | // Constants 12 | // -------------------------------------- 13 | export const FETCH_WEBHOOKS = 'FETCH_WEBHOOKS' 14 | 15 | // -------------------------------------- 16 | // Fetch webhooks actions 17 | // -------------------------------------- 18 | const fetchWebhooksRequest = createRequestAction(FETCH_WEBHOOKS) 19 | const fetchWebhooksFailure = createFailureAction(FETCH_WEBHOOKS) 20 | const fetchWebhooksSuccess = createSuccessAction(FETCH_WEBHOOKS) 21 | 22 | export const fetchWebhooks = () => { 23 | return (dispatch, getState) => { 24 | const { webhooks } = getState() 25 | if (webhooks.isProcessing) { 26 | console.warn('Unable to fetch webhooks. An action is pending...') 27 | return Promise.resolve(null) 28 | } 29 | console.debug('Fetching webhooks...') 30 | dispatch(fetchWebhooksRequest()) 31 | return WebhookApi.all() 32 | .then( 33 | res => dispatchAction(dispatch, fetchWebhooksSuccess(res)), 34 | err => dispatchAction(dispatch, fetchWebhooksFailure(err)) 35 | ) 36 | } 37 | } 38 | 39 | const actions = { 40 | fetchWebhooks 41 | } 42 | 43 | export default actions 44 | 45 | -------------------------------------------------------------------------------- /src/components/ProfilePanel/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import { connect } from 'react-redux' 3 | import { Image, Icon } from 'semantic-ui-react' 4 | import { default as Timeago } from 'timeago.js' 5 | 6 | import authProvider from 'helpers/AuthProvider' 7 | 8 | import './styles.css' 9 | 10 | class ProfilePanel extends React.Component { 11 | static propTypes = { 12 | profile: PropTypes.object.isRequired 13 | }; 14 | 15 | render () { 16 | const { current } = this.props.profile 17 | if (current) { 18 | const gravatar = `https://www.gravatar.com/avatar/${current.hash}` 19 | const memberAgo = new Timeago().format(current.date) 20 | const accountUrl = authProvider.getAccountUrl() 21 | return ( 22 |
23 | You on Gravatar 24 | 25 | {current.name} 26 | Member {memberAgo} 27 | 28 | 31 | 32 | 33 |
34 | ) 35 | } 36 | return
37 | } 38 | } 39 | 40 | const mapStateToProps = (state) => ({ 41 | profile: state.profile 42 | }) 43 | 44 | export default connect(mapStateToProps)(ProfilePanel) 45 | -------------------------------------------------------------------------------- /src/styles/_bugfix.scss: -------------------------------------------------------------------------------- 1 | // Workaround for the following bug: 2 | // https://github.com/Semantic-Org/Semantic-UI/issues/2851 3 | body.dimmable.undetached.dimmed { 4 | .pushable { 5 | transform: none; 6 | } 7 | .pusher { 8 | position: static; 9 | } 10 | } 11 | 12 | // Workaround for the following bug: 13 | // https://github.com/Semantic-Org/Semantic-UI-React/pull/659 14 | .ui.menu .item.hack { 15 | > i.dropdown.icon { 16 | margin: 0; 17 | font-family: Icons; 18 | } 19 | } 20 | .ui.top.right.dropdown.hack { 21 | > i.dropdown.icon { 22 | display: none; 23 | } 24 | } 25 | 26 | .ui.top.right.dropdown.hack, 27 | .ui.menu .item.hack { 28 | &.plus > i.dropdown.icon { 29 | &:before { 30 | content: "\f067"; 31 | } 32 | } 33 | &.ellipsis-v > i.dropdown.icon { 34 | &:before { 35 | content: "\f142"; 36 | } 37 | } 38 | &.visible .menu.transition { 39 | display: block!important; 40 | visibility: visible!important; 41 | } 42 | } 43 | 44 | .ui.form .fields { 45 | margin: 0 !important; 46 | } 47 | 48 | .ui.label .icons { 49 | margin: 0 .75em 0 0; 50 | } 51 | 52 | @media screen and (max-width: 640px) { 53 | .ui.attached.tabular.menu { 54 | overflow-x: auto; 55 | overflow-y: hidden; 56 | } 57 | } 58 | 59 | // Workaround for modals that are not correctly centered 60 | .ui.page.modals.dimmer.transition.visible.active { 61 | display: flex!important; 62 | } 63 | -------------------------------------------------------------------------------- /src/store/modules/notification.js: -------------------------------------------------------------------------------- 1 | import { createAction, handleActions } from 'redux-actions' 2 | 3 | // ------------------------------------ 4 | // Constants 5 | // ------------------------------------ 6 | export const SHOW_NOTIFICATION = 'SHOW_NOTIFICATION' 7 | export const HIDE_NOTIFICATION = 'HIDE_NOTIFICATION' 8 | 9 | // ------------------------------------ 10 | // Actions 11 | // ------------------------------------ 12 | export const showNotification = createAction(SHOW_NOTIFICATION, (notification = {level: 'info'}) => notification) 13 | export const hideNotification = createAction(HIDE_NOTIFICATION) 14 | 15 | export const actions = { 16 | showNotification, 17 | hideNotification 18 | } 19 | 20 | // ------------------------------------ 21 | // Reducer 22 | // ------------------------------------ 23 | export default handleActions({ 24 | [SHOW_NOTIFICATION]: (state, {payload}) => { 25 | const {header, message, level, actionLabel, actionFn, visible} = Object.assign({ 26 | level: 'info', 27 | visible: true 28 | }, payload) 29 | return Object.assign({}, state, { 30 | header, message, level, actionLabel, actionFn, visible 31 | }) 32 | }, 33 | [HIDE_NOTIFICATION]: (state) => { 34 | return Object.assign({}, state, { 35 | visible: false 36 | }) 37 | } 38 | }, { 39 | visible: false, 40 | header: null, 41 | message: null, 42 | level: null, 43 | actionLabel: null, 44 | actionFn: null 45 | }) 46 | -------------------------------------------------------------------------------- /src/store/createStore.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, compose, createStore } from 'redux' 2 | import { routerMiddleware } from 'react-router-redux' 3 | import thunk from 'redux-thunk' 4 | import makeRootReducer from './reducers' 5 | 6 | const __DEBUG__ = process.env.REACT_APP_DEBUG === 'true' 7 | 8 | export default (initialState = {}, history) => { 9 | // ====================================================== 10 | // Middleware Configuration 11 | // ====================================================== 12 | const middleware = [thunk, routerMiddleware(history)] 13 | 14 | // ====================================================== 15 | // Store Enhancers 16 | // ====================================================== 17 | const enhancers = [] 18 | if (__DEBUG__) { 19 | const devToolsExtension = window.devToolsExtension 20 | if (typeof devToolsExtension === 'function') { 21 | enhancers.push(devToolsExtension()) 22 | } 23 | } 24 | 25 | // ====================================================== 26 | // Store Instantiation and HMR Setup 27 | // ====================================================== 28 | const store = createStore( 29 | makeRootReducer(), 30 | initialState, 31 | compose( 32 | applyMiddleware(...middleware), 33 | ...enhancers 34 | ) 35 | ) 36 | store.asyncReducers = {} 37 | 38 | if (module.hot) { 39 | module.hot.accept('./reducers', () => { 40 | const reducers = require('./reducers').default 41 | store.replaceReducer(reducers) 42 | }) 43 | } 44 | 45 | return store 46 | } 47 | -------------------------------------------------------------------------------- /src/views/SettingsView/BookmarkletTab/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import './styles.css' 4 | 5 | import { Header, Divider, Segment } from 'semantic-ui-react' 6 | 7 | export default class BookmarkletTab extends React.Component { 8 | 9 | handleClick () { 10 | alert('Don\'t click on me! But drag and drop me to your toolbar.') 11 | } 12 | 13 | baseUrl () { 14 | const { origin, pathname } = document.location 15 | const basePath = pathname.replace('/settings/bookmarklet', '') 16 | return origin + basePath 17 | } 18 | 19 | render () { 20 | return ( 21 |
22 |
The bookmarklet
23 | 24 |

25 | A bookmarklet is a small software application stored as a bookmark in a web browser, 26 | which typically allows a user to interact with the currently loaded web page in some way: 27 | In our case save the page as a document. 28 |

29 | 30 | Drag and drop the link bellow in your toolbar:  31 | 37 | Keep This! 38 | 39 | 40 |
41 | ) 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /src/components/ColorSwatch/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | 3 | import './styles.css' 4 | 5 | import colors from './colors.json' 6 | 7 | export default class ColorSwatch extends React.Component { 8 | static propTypes = { 9 | colors: PropTypes.array, 10 | selected: PropTypes.number, 11 | value: PropTypes.string, 12 | onColorChange: PropTypes.func.isRequired 13 | }; 14 | 15 | static defaultProps = { 16 | colors: colors, 17 | value: colors[0] 18 | }; 19 | 20 | constructor (props) { 21 | super(props) 22 | const {colors, value} = props 23 | let selected = 0 24 | for (const c in colors) { 25 | if (colors[c].toUpperCase() === value.toUpperCase()) { 26 | selected = parseInt(c, 10) 27 | break 28 | } 29 | } 30 | 31 | this.state = { 32 | selected: selected 33 | } 34 | } 35 | 36 | handleClick (index) { 37 | return (e) => { 38 | e.preventDefault() 39 | this.setState({selected: index}) 40 | this.props.onColorChange(this.props.colors[index]) 41 | } 42 | } 43 | 44 | buildSwatch (color, i) { 45 | const className = i === this.state.selected ? 'selected' : null 46 | return ( 47 | 105 | 106 | 107 | ) 108 | } 109 | } 110 | 111 | -------------------------------------------------------------------------------- /src/components/AppNotification/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import { connect } from 'react-redux' 3 | import { bindActionCreators } from 'redux' 4 | import { actions as notificationActions } from 'store/modules/notification' 5 | import { Message, Button } from 'semantic-ui-react' 6 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group' 7 | 8 | import './styles.css' 9 | 10 | export class AppNotification extends React.Component { 11 | static propTypes = { 12 | layout: PropTypes.object.isRequired, 13 | notification: PropTypes.object.isRequired, 14 | hideNotification: PropTypes.func.isRequired 15 | }; 16 | 17 | constructor () { 18 | super() 19 | this.handleAction = this.handleAction.bind(this) 20 | this.handleClose = this.handleClose.bind(this) 21 | this.timeout = null 22 | } 23 | 24 | componentDidUpdate (prevProps, prevState) { 25 | const { level, visible } = this.props.notification 26 | if (visible !== prevProps.notification.visible) { 27 | if (this.timeout) { 28 | clearTimeout(this.timeout) 29 | this.timeout = null 30 | } 31 | if (level !== 'error' && visible) { 32 | this.timeout = setTimeout(this.handleClose, 5000) 33 | } 34 | } 35 | } 36 | 37 | get header () { 38 | const { header } = this.props.notification 39 | if (header) { 40 | return ( 41 | {header} 42 | ) 43 | } 44 | } 45 | 46 | get actionButton () { 47 | const { actionLabel } = this.props.notification 48 | if (actionLabel) { 49 | return ( 50 | 80 | 83 | 84 | 85 | ) 86 | } 87 | 88 | handleChange (event, {name, value}) { 89 | this.setState({[name]: value}) 90 | } 91 | 92 | handleClose () { 93 | const { actions } = this.props 94 | actions.titleModal.hideTitleModal() 95 | } 96 | 97 | handleSubmit (e) { 98 | e.preventDefault() 99 | if (!this.isValidTitle) { 100 | return false 101 | } 102 | const { actions, modal } = this.props 103 | actions.document.updateDocument(modal.doc, this.state) 104 | .then(() => this.handleClose(), (err) => { 105 | this.setState({err}) 106 | }) 107 | } 108 | } 109 | 110 | const mapStateToProps = (state) => ({ 111 | modal: state.titleModal, 112 | doc: state.document 113 | }) 114 | 115 | const mapActionsToProps = (dispatch) => (bindActions({ 116 | document: DocumentActions, 117 | titleModal: TitleModalActions 118 | }, dispatch)) 119 | 120 | export default connect(mapStateToProps, mapActionsToProps)(DocumentTitleModal) 121 | -------------------------------------------------------------------------------- /src/views/PublicDocumentView/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import { connect } from 'react-redux' 3 | import { Dimmer, Loader } from 'semantic-ui-react' 4 | 5 | import DocumentContent from 'components/DocumentContent' 6 | 7 | import { bindActions } from 'store/helper' 8 | 9 | import { routerActions as RouterActions } from 'react-router-redux' 10 | 11 | import * as NProgress from 'nprogress' 12 | 13 | export class PublicDocumentView extends React.Component { 14 | static propTypes = { 15 | actions: PropTypes.object.isRequired, 16 | document: PropTypes.object.isRequired, 17 | location: PropTypes.object.isRequired 18 | }; 19 | 20 | constructor (props) { 21 | super(props) 22 | this.redirectBack = this.redirectBack.bind(this) 23 | } 24 | 25 | componentWillReceiveProps (nextProps) { 26 | // if no document found then redirect... 27 | if ( 28 | !nextProps.document.isFetching && 29 | !nextProps.document.isProcessing && 30 | !nextProps.document.current 31 | ) { 32 | console.debug('No more document. Redirecting...') 33 | this.redirectBack() 34 | } 35 | } 36 | 37 | componentDidUpdate (prevProps) { 38 | const {isProcessing} = this.props.document 39 | const {isProcessing: wasProcessing} = prevProps.document 40 | if (!wasProcessing && isProcessing) { 41 | NProgress.start() 42 | } else if (wasProcessing && !isProcessing) { 43 | NProgress.done() 44 | } 45 | document.title = this.title 46 | } 47 | 48 | redirectBack () { 49 | const {actions, location} = this.props 50 | const url = location.pathname 51 | const to = url.substr(0, url.lastIndexOf('/')) 52 | actions.router.push(to) 53 | } 54 | 55 | get originLink () { 56 | const { current: doc } = this.props.document 57 | if (doc.origin) { 58 | return ( 59 | 60 | Origin: {doc.origin} 61 | 62 | ) 63 | } 64 | } 65 | 66 | get modificationDate () { 67 | const { current: doc } = this.props.document 68 | if (doc.date) { 69 | const date = String(doc.date) 70 | return ( 71 | 72 | Last modification: {date} 73 | 74 | ) 75 | } 76 | } 77 | 78 | get title () { 79 | const { isFetching, current: doc } = this.props.document 80 | return isFetching || !doc ? 'Document' : doc.title 81 | } 82 | 83 | get header () { 84 | return ( 85 |

{this.title}

86 | ) 87 | } 88 | 89 | get document () { 90 | const { isFetching, isProcessing, current: doc } = this.props.document 91 | if (doc && !isFetching && !isProcessing) { 92 | return ( 93 |
94 | {this.originLink} 95 | 96 | {this.modificationDate} 97 |
98 | ) 99 | } 100 | } 101 | 102 | render () { 103 | const { isFetching, isProcessing } = this.props.document 104 | return ( 105 |
106 | {this.header} 107 | 108 | 109 | Loading 110 | 111 | {this.document} 112 | 113 |
114 | ) 115 | } 116 | } 117 | 118 | const mapStateToProps = (state) => ({ 119 | document: state.document, 120 | location: state.router.locationBeforeTransitions 121 | }) 122 | 123 | const mapActionsToProps = (dispatch) => (bindActions({ 124 | router: RouterActions 125 | }, dispatch)) 126 | 127 | export default connect(mapStateToProps, mapActionsToProps)(PublicDocumentView) 128 | 129 | -------------------------------------------------------------------------------- /src/Routes.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route, IndexRedirect, Redirect } from 'react-router' 3 | 4 | import RootLayout from 'layouts/RootLayout' 5 | import MainLayout from 'layouts/MainLayout' 6 | import PublicLayout from 'layouts/PublicLayout' 7 | import LabelView from 'views/LabelView' 8 | import ShareLabelView from 'views/ShareLabelView' 9 | import DocumentView from 'views/DocumentView' 10 | import LabelDocumentsView from 'views/LabelDocumentsView' 11 | import SharedDocumentsView from 'views/SharedDocumentsView' 12 | import PublicDocumentsView from 'views/PublicDocumentsView' 13 | import PublicDocumentView from 'views/PublicDocumentView' 14 | import DocumentsView from 'views/DocumentsView' 15 | import BookmarkletView from 'views/BookmarkletView' 16 | import GraveyardView from 'views/GraveyardView' 17 | import SettingsView from 'views/SettingsView' 18 | import WebhookView from 'views/WebhookView' 19 | import ApiClientView from 'views/ApiClientView' 20 | import SharingListView from 'views/SharingListView' 21 | import AboutView from 'views/AboutView' 22 | 23 | import { requireAuthentication } from 'middlewares/Authentication' 24 | 25 | import { 26 | createNewDocument, 27 | fetchDocument, 28 | fetchDocuments, 29 | fetchLabel, 30 | fetchLabelAndSharing, 31 | fetchLabelAndDocument, 32 | fetchLabelAndDocuments, 33 | fetchSharedDocuments, 34 | fetchSharedDocument, 35 | fetchPublicDocuments, 36 | fetchPublicDocument, 37 | fetchSharing, 38 | fetchGraveyard 39 | } from 'middlewares/Context' 40 | 41 | const WebhookCreateView = props => 42 | const WebhookEditView = props => 43 | 44 | const ApiClientCreateView = props => 45 | const ApiClientEditView = props => 46 | 47 | export default (store) => ( 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | ) 79 | -------------------------------------------------------------------------------- /src/components/DocumentLabels/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import { connect } from 'react-redux' 3 | import { bindActionCreators } from 'redux' 4 | import { Link } from 'react-router' 5 | import { Dropdown, Label, Icon } from 'semantic-ui-react' 6 | 7 | import { actions as documentActions } from 'store/modules/document' 8 | 9 | import './styles.css' 10 | 11 | export class DocumentLabels extends React.Component { 12 | static propTypes = { 13 | doc: PropTypes.object.isRequired, 14 | editable: PropTypes.bool, 15 | labels: PropTypes.object.isRequired, 16 | updateDocument: PropTypes.func.isRequired 17 | }; 18 | 19 | static defaultProps = { 20 | editable: false 21 | }; 22 | 23 | constructor (props) { 24 | super(props) 25 | this.handleChange = this.handleChange.bind(this) 26 | this.toggleEditable = this.toggleEditable.bind(this) 27 | this.state = { 28 | editable: props.editable 29 | } 30 | } 31 | 32 | renderViewMode () { 33 | const { doc } = this.props 34 | const labels = doc.labels || [] 35 | const $labels = labels.map((id) => { 36 | const l = this.resolveLabel(id) 37 | if (!l) { 38 | return null 39 | } 40 | const color = {color: l.color} 41 | const key = `label-${doc.id}-${l.id}` 42 | const to = {pathname: `/labels/${l.id}`} 43 | return ( 44 | 48 | ) 49 | }) 50 | 51 | return ( 52 |
53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | {$labels} 61 | 62 |
63 | ) 64 | } 65 | 66 | renderEditMode () { 67 | const { labels, doc } = this.props 68 | const value = doc.labels ? doc.labels : [] 69 | const options = labels.current.labels.map((l) => { 70 | const color = {color: l.color} 71 | return { 72 | text: l.label, 73 | value: l.id, 74 | color: l.color, 75 | content:
{l.label}
76 | } 77 | }) 78 | const renderLabel = (label, index, props) => ({ 79 | content: label.text, 80 | icon: 81 | }) 82 | 83 | return ( 84 | 94 | ) 95 | } 96 | 97 | render () { 98 | const { editable } = this.state 99 | return editable ? this.renderEditMode() : this.renderViewMode() 100 | } 101 | 102 | toggleEditable () { 103 | const { editable } = this.props 104 | this.setState({editable: !editable}) 105 | } 106 | 107 | handleChange (event, {value}) { 108 | const { updateDocument, doc } = this.props 109 | const payload = { 110 | labels: value 111 | } 112 | updateDocument(doc, payload) 113 | } 114 | 115 | resolveLabel (id) { 116 | const { labels } = this.props.labels.current 117 | return labels ? labels.find((l) => l.id === id) : null 118 | } 119 | } 120 | 121 | const mapStateToProps = (state) => ({ 122 | labels: state.labels 123 | }) 124 | 125 | const mapDispatchToProps = (dispatch) => ( 126 | bindActionCreators(Object.assign({}, documentActions), dispatch) 127 | ) 128 | 129 | export default connect(mapStateToProps, mapDispatchToProps)(DocumentLabels) 130 | -------------------------------------------------------------------------------- /src/api/common/AbstractApi.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-fetch' 2 | import authProvider from 'helpers/AuthProvider' 3 | import 'event-source-polyfill' 4 | 5 | export default class AbstractApi { 6 | constructor () { 7 | this.firstCall = true 8 | this.apiRoot = process.env.REACT_APP_API_ROOT 9 | } 10 | 11 | buildQueryString (query) { 12 | if (query) { 13 | const params = Object.keys(query).reduce((acc, key) => { 14 | if (query.hasOwnProperty(key) && query[key] != null) { 15 | acc.push( 16 | encodeURIComponent(key) + '=' + encodeURIComponent(query[key]) 17 | ) 18 | } 19 | return acc 20 | }, []) 21 | return params.length ? '?' + params.join('&') : '' 22 | } else { 23 | return '' 24 | } 25 | } 26 | 27 | resolveUrl (url, query) { 28 | return this.apiRoot + url + this.buildQueryString(query) 29 | } 30 | 31 | sse (url, params) { 32 | params = Object.assign({ 33 | headers: { 34 | Accept: 'application/json' 35 | }, 36 | credentials: 'include' 37 | }, params) 38 | const {headers, query} = params 39 | let authz = Promise.resolve() 40 | if (params.credentials !== 'none') { 41 | authz = authProvider.updateToken().then((updated) => { 42 | if (updated || this.firstCall) { 43 | // Token was updated or it's the first API call. 44 | // Authorization header is set in order to update the API cookie. 45 | headers['Authorization'] = `Bearer ${authProvider.getToken()}` 46 | this.firstCall = false 47 | } 48 | return Promise.resolve() 49 | }, (err) => { 50 | // Fatal error from keycloak server. Mainly due to CORS. 51 | // Forced to reload the page. 52 | // FIXME Find a better way to handle Keycloak errors. 53 | console.error('Fatal error when updating the token', err) 54 | location.reload() 55 | }) 56 | } 57 | 58 | const _url = this.resolveUrl(url, query) 59 | return authz.then(() => { 60 | const source = new EventSource(_url, {headers, withCredentials: params.credentials !== 'none'}) 61 | return Promise.resolve(source) 62 | }) 63 | } 64 | 65 | fetch (url, params) { 66 | params = Object.assign({ 67 | method: 'get', 68 | headers: { 69 | Accept: 'application/json' 70 | }, 71 | credentials: 'include' 72 | }, params) 73 | const {method, body, headers, query} = params 74 | let {credentials} = params 75 | if (method === 'post' || method === 'put' || method === 'patch') { 76 | headers['Content-Type'] = 'application/json' 77 | } 78 | 79 | let authz = Promise.resolve() 80 | if (credentials !== 'none') { 81 | authz = authProvider.updateToken().then((updated) => { 82 | if (updated || this.firstCall) { 83 | // Token was updated or it's the first API call. 84 | // Authorization header is set in order to update the API cookie. 85 | headers['Authorization'] = `Bearer ${authProvider.getToken()}` 86 | this.firstCall = false 87 | } 88 | return Promise.resolve() 89 | }, (err) => { 90 | // Fatal error from keycloak server. Mainly due to CORS. 91 | // Forced to reload the page. 92 | // FIXME Find a better way to handle Keycloak errors. 93 | console.error('Fatal error when updating the token', err) 94 | location.reload() 95 | }) 96 | } else { 97 | credentials = undefined 98 | } 99 | 100 | const _url = this.resolveUrl(url, query) 101 | return authz.then(() => fetch(_url, {method, body, headers, credentials})) 102 | .then(response => { 103 | if (response.status === 204 || response.status === 205) { 104 | return Promise.resolve() 105 | } else if (response.status >= 200 && response.status < 300) { 106 | return response.json() 107 | } else { 108 | return response.json().then(err => Promise.reject(err)) 109 | } 110 | }) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/layouts/MainLayout.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import { connect } from 'react-redux' 3 | import { bindActions } from 'store/helper' 4 | 5 | import AppMenu from 'components/AppMenu' 6 | import AppModal from 'components/AppModal' 7 | import AppNotification from 'components/AppNotification' 8 | import AppKeyboardHandlers from 'components/AppKeyboardHandlers' 9 | import DocumentTitleModal from 'components/DocumentTitleModal' 10 | import DocumentUrlModal from 'components/DocumentUrlModal' 11 | import KeymapHelpModal from 'components/KeymapHelpModal' 12 | 13 | import { actions as layoutActions } from 'store/modules/layout' 14 | 15 | import { Sizes } from 'store/modules/layout' 16 | import { Sidebar } from 'semantic-ui-react' 17 | 18 | import './styles.css' 19 | 20 | export class MainLayout extends React.Component { 21 | static propTypes = { 22 | children: PropTypes.node, 23 | location: PropTypes.object, 24 | layout: PropTypes.object.isRequired, 25 | actions: PropTypes.object.isRequired 26 | }; 27 | 28 | constructor () { 29 | super() 30 | this.handleDimmerClick = this.handleDimmerClick.bind(this) 31 | } 32 | 33 | componentWillReceiveProps (nextProps) { 34 | if (nextProps.location !== this.props.location) { 35 | // if we changed routes... 36 | if ( 37 | nextProps.location.state && 38 | nextProps.location.state.modal 39 | ) { 40 | // save the old children (just like animation) 41 | this.previousChildren = this.props.children 42 | } else { 43 | this.previousChildren = null 44 | } 45 | } 46 | } 47 | 48 | shouldComponentUpdate (nextProps, nextState) { 49 | return nextProps.location !== this.props.location || 50 | nextProps.layout !== this.props.layout 51 | } 52 | 53 | handleDimmerClick (event) { 54 | // console.log(event) 55 | const { actions, layout } = this.props 56 | if (layout.size < Sizes.LARGE && layout.sidebar.visible) { 57 | actions.layout.toggleSidebar() 58 | } 59 | } 60 | 61 | renderModal () { 62 | if (this.previousChildren) { 63 | const { location, children } = this.props 64 | return ( 65 | 66 | {children} 67 | 68 | ) 69 | } 70 | } 71 | 72 | renderMobileLayout () { 73 | const { children, layout } = this.props 74 | 75 | return ( 76 | 77 | 78 | 79 | 80 | 81 |
82 | {this.previousChildren || children} 83 | {this.renderModal()} 84 | 85 | 86 | 87 | 88 |
89 |
90 |
91 | ) 92 | } 93 | 94 | renderDesktopLayout () { 95 | const { children } = this.props 96 | 97 | return ( 98 |
99 | 100 |
101 | {this.previousChildren || children} 102 | 103 | 104 | 105 | 106 |
107 | {this.renderModal()} 108 |
109 | ) 110 | } 111 | 112 | render () { 113 | const { layout } = this.props 114 | return ( 115 | 116 | {layout.size < Sizes.LARGE ? this.renderMobileLayout() : this.renderDesktopLayout()} 117 | 118 | ) 119 | } 120 | } 121 | 122 | const mapStateToProps = (state) => ({ 123 | location: state.router.locationBeforeTransitions, 124 | layout: state.layout 125 | }) 126 | 127 | const mapActionsToProps = (dispatch) => (bindActions({ 128 | layout: layoutActions 129 | }, dispatch)) 130 | 131 | export default connect(mapStateToProps, mapActionsToProps)(MainLayout) 132 | 133 | -------------------------------------------------------------------------------- /src/components/DocumentContent/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { connect } from 'react-redux' 4 | import { bindActionCreators } from 'redux' 5 | import { Form } from 'semantic-ui-react' 6 | import TinyMCE from 'react-tinymce' 7 | 8 | import { actions as documentActions } from 'store/modules/document' 9 | 10 | const API_ROOT = process.env.REACT_APP_API_ROOT 11 | 12 | export class DocumentContent extends React.Component { 13 | static propTypes = { 14 | doc: PropTypes.object.isRequired, 15 | editable: PropTypes.bool, 16 | pub: PropTypes.bool, 17 | updateDocument: PropTypes.func.isRequired 18 | }; 19 | 20 | static defaultProps = { 21 | editable: false, 22 | pub: false 23 | }; 24 | 25 | constructor (props) { 26 | super(props) 27 | this.handleEditorChange = this.handleEditorChange.bind(this) 28 | this.handleContentChange = this.handleContentChange.bind(this) 29 | this.state = { 30 | content: props.doc.content 31 | } 32 | } 33 | 34 | componentDidMount () { 35 | this.filterImgDataRefAttr() 36 | this.filterImgSrcSetAttr() 37 | } 38 | 39 | componentDidUpdate () { 40 | this.filterImgDataRefAttr() 41 | this.filterImgSrcSetAttr() 42 | } 43 | 44 | filterImgDataRefAttr () { 45 | const { doc, pub } = this.props 46 | // Filtering images ref attributes... 47 | const $this = ReactDOM.findDOMNode(this) 48 | $this.querySelectorAll('img[data-ref]').forEach((el) => { 49 | const key = el.dataset.ref 50 | const type = pub ? 'public' : 'sharing' 51 | const src = doc.sharing 52 | ? `${API_ROOT}/${type}/${doc.sharing}/${doc.id}/files/${key}` 53 | : `${API_ROOT}/documents/${doc.id}/files/${key}` 54 | el.src = src 55 | }) 56 | } 57 | 58 | filterImgSrcSetAttr () { 59 | // Filtering images srcset attributes... 60 | const $this = ReactDOM.findDOMNode(this) 61 | $this.querySelectorAll('img[srcset]').forEach((el) => { 62 | el.removeAttribute('srcset') 63 | }) 64 | } 65 | 66 | handleEditorChange (e) { 67 | const { updateDocument, doc } = this.props 68 | updateDocument(doc, {content: e.target.getContent()}) 69 | } 70 | 71 | handleContentChange (e) { 72 | const { updateDocument, doc } = this.props 73 | updateDocument(doc, {content: e.target.value}) 74 | } 75 | 76 | renderEditMode () { 77 | const { doc } = this.props 78 | const { content } = this.state 79 | if (doc.contentType.match(/^text\/html/)) { 80 | const config = { 81 | inline: true, 82 | plugins: 'link image code', 83 | toolbar: 'undo redo | bold italic | alignleft aligncenter alignright | code', 84 | extended_valid_elements: 'img[class|src|border=0|alt|title|hspace|vspace|width|height|align|name|data-src|app-src]' 85 | } 86 | return ( 87 | 92 | ) 93 | } else { 94 | return ( 95 |
96 | 97 | 101 | 102 |
103 | ) 104 | } 105 | } 106 | 107 | renderViewMode () { 108 | const { doc } = this.props 109 | if (doc.contentType.match(/^text\/html/)) { 110 | return ( 111 |
116 | ) 117 | } else { 118 | return ( 119 |
{ doc.content }
120 | ) 121 | } 122 | } 123 | 124 | render () { 125 | const { editable } = this.props 126 | return editable ? this.renderEditMode() : this.renderViewMode() 127 | } 128 | } 129 | 130 | const mapDispatchToProps = (dispatch) => ( 131 | bindActionCreators(Object.assign({}, documentActions), dispatch) 132 | ) 133 | 134 | export default connect(null, mapDispatchToProps)(DocumentContent) 135 | -------------------------------------------------------------------------------- /src/store/client/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | createRequestAction, 3 | createSuccessAction, 4 | createFailureAction, 5 | dispatchAction 6 | } from 'store/helper' 7 | 8 | import ClientApi from 'api/client' 9 | 10 | // -------------------------------------- 11 | // Constants 12 | // -------------------------------------- 13 | export const FETCH_CLIENT = 'FETCH_CLIENT' 14 | export const CREATE_CLIENT = 'CREATE_CLIENT' 15 | export const UPDATE_CLIENT = 'UPDATE_CLIENT' 16 | export const REMOVE_CLIENT = 'REMOVE_CLIENT' 17 | export const RESET_CLIENT = 'RESET_CLIENT' 18 | 19 | // -------------------------------------- 20 | // Fetch client actions 21 | // -------------------------------------- 22 | const fetchClientRequest = createRequestAction(FETCH_CLIENT) 23 | const fetchClientFailure = createFailureAction(FETCH_CLIENT) 24 | const fetchClientSuccess = createSuccessAction(FETCH_CLIENT) 25 | 26 | export const fetchClient = (id) => { 27 | return (dispatch, getState) => { 28 | const { client } = getState() 29 | if (client.isProcessing) { 30 | console.warn('Unable to fetch client. An action is pending...') 31 | return Promise.resolve(null) 32 | } 33 | console.debug('Fetching client:', id) 34 | dispatch(fetchClientRequest()) 35 | return ClientApi.get(id) 36 | .then( 37 | res => dispatchAction(dispatch, fetchClientSuccess(res)), 38 | err => dispatchAction(dispatch, fetchClientFailure(err)) 39 | ) 40 | } 41 | } 42 | 43 | // -------------------------------------- 44 | // Create client actions 45 | // -------------------------------------- 46 | const createClientRequest = createRequestAction(CREATE_CLIENT) 47 | const createClientFailure = createFailureAction(CREATE_CLIENT) 48 | const createClientSuccess = createSuccessAction(CREATE_CLIENT) 49 | 50 | export const createClient = (client) => { 51 | return (dispatch, getState) => { 52 | console.debug('Creating client:', client) 53 | dispatch(createClientRequest()) 54 | return ClientApi.create(client) 55 | .then( 56 | res => dispatchAction(dispatch, createClientSuccess(res)), 57 | err => dispatchAction(dispatch, createClientFailure(err)) 58 | ) 59 | } 60 | } 61 | 62 | // -------------------------------------- 63 | // Update client actions 64 | // -------------------------------------- 65 | const updateClientRequest = createRequestAction(UPDATE_CLIENT) 66 | const updateClientFailure = createFailureAction(UPDATE_CLIENT) 67 | const updateClientSuccess = createSuccessAction(UPDATE_CLIENT) 68 | 69 | export const updateClient = (update) => { 70 | return (dispatch, getState) => { 71 | const { client } = getState() 72 | if (client.isProcessing) { 73 | console.warn('Unable to update client. An action is pending...') 74 | return Promise.resolve(null) 75 | } 76 | console.debug('Updating client:', client.current) 77 | dispatch(updateClientRequest()) 78 | return ClientApi.update(client.current, update) 79 | .then( 80 | res => dispatchAction(dispatch, updateClientSuccess(res)), 81 | err => dispatchAction(dispatch, updateClientFailure(err)) 82 | ) 83 | } 84 | } 85 | 86 | // -------------------------------------- 87 | // Remove client actions 88 | // -------------------------------------- 89 | const removeClientRequest = createRequestAction(REMOVE_CLIENT) 90 | const removeClientFailure = createFailureAction(REMOVE_CLIENT) 91 | const removeClientSuccess = createSuccessAction(REMOVE_CLIENT) 92 | 93 | export const removeClient = (client) => { 94 | return (dispatch, getState) => { 95 | console.debug('Removing client:', client.id) 96 | dispatch(removeClientRequest()) 97 | return ClientApi.remove(client) 98 | .then( 99 | res => dispatchAction(dispatch, removeClientSuccess(client)), 100 | err => dispatchAction(dispatch, removeClientFailure(err)) 101 | ) 102 | } 103 | } 104 | 105 | // -------------------------------------- 106 | // Reset client action 107 | // -------------------------------------- 108 | const resetClient = createRequestAction(RESET_CLIENT) 109 | 110 | const actions = { 111 | fetchClient, 112 | createClient, 113 | updateClient, 114 | removeClient, 115 | resetClient 116 | } 117 | 118 | export default actions 119 | 120 | -------------------------------------------------------------------------------- /src/components/DocumentTile/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import { Link } from 'react-router' 3 | import { Card, Button, Dropdown } from 'semantic-ui-react' 4 | 5 | import DocumentRibbon from 'components/DocumentRibbon' 6 | import DocumentLabels from 'components/DocumentLabels' 7 | import DocumentContextMenu from 'components/DocumentContextMenu' 8 | 9 | import './styles.css' 10 | 11 | const API_ROOT = process.env.REACT_APP_API_ROOT 12 | 13 | export default class DocumentTile extends React.Component { 14 | static propTypes = { 15 | base: PropTypes.object.isRequired, 16 | value: PropTypes.object.isRequired, 17 | sharing: PropTypes.string, 18 | menu: PropTypes.string, 19 | pub: PropTypes.bool.isRequired 20 | }; 21 | 22 | get contextualMenu () { 23 | const {value: doc, pub} = this.props 24 | if (pub) { 25 | return null 26 | } 27 | const menu = doc.ghost ? 'restore,destroy' : this.props.menu 28 | const trigger = 91 | 94 | 95 | 96 | ) 97 | } 98 | 99 | handleChange (event, {name, value}) { 100 | this.setState({[name]: value}) 101 | } 102 | 103 | handleClose () { 104 | const { actions } = this.props 105 | actions.urlModal.hideUrlModal() 106 | } 107 | 108 | handleSubmit (e) { 109 | e.preventDefault() 110 | if (!this.isValidUrl) { 111 | return false 112 | } 113 | 114 | const {url, method} = this.state 115 | const u = method !== 'default' ? `${method}+${url}` : url 116 | 117 | const {actions} = this.props 118 | actions.document.createDocument({origin: u}) 119 | .then((doc) => { 120 | actions.router.push({ 121 | pathname: `/document/${doc.id}` 122 | // FIXME When modal documents view is crushed by the create view 123 | // state: { modal: true, returnTo: location } 124 | }) 125 | actions.urlModal.hideUrlModal() 126 | }, (err) => { 127 | this.setState({err}) 128 | }) 129 | } 130 | } 131 | 132 | const mapStateToProps = (state) => ({ 133 | location: state.router.locationBeforeTransitions, 134 | modal: state.urlModal, 135 | doc: state.document 136 | }) 137 | 138 | const mapActionsToProps = (dispatch) => (bindActions({ 139 | document: DocumentActions, 140 | router: RouterActions, 141 | urlModal: UrlModalActions 142 | }, dispatch)) 143 | 144 | export default connect(mapStateToProps, mapActionsToProps)(DocumentUrlModal) 145 | -------------------------------------------------------------------------------- /src/views/SettingsView/ApiKeyTab/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import { bindActions } from 'store/helper' 5 | import ProfileActions from 'store/profile/actions' 6 | import { actions as NotificationActions } from 'store/modules/notification' 7 | 8 | import { Modal, Message, Icon, Input, Button, Header, Divider, Segment } from 'semantic-ui-react' 9 | 10 | const API_ROOT = process.env.REACT_APP_API_ROOT 11 | const _parts = API_ROOT.split('://') 12 | const API_KEY_URL = `${_parts[0]}://api:KEY@${_parts[1]}` 13 | 14 | class ApiKeyTab extends React.Component { 15 | static propTypes = { 16 | actions: PropTypes.object.isRequired, 17 | profile: PropTypes.object 18 | }; 19 | 20 | state = { modalOpen: false }; 21 | 22 | handleOpen = () => this.setState({ modalOpen: true }); 23 | 24 | handleClose = () => this.setState({ modalOpen: false }); 25 | 26 | handleRef = (c) => { 27 | this.inputRef = c 28 | }; 29 | 30 | handleCopy = () => { 31 | this.inputRef.inputRef.select() 32 | document.execCommand('copy') 33 | }; 34 | 35 | handleGenerateApiKey = () => { 36 | this.handleClose() 37 | const { actions } = this.props 38 | actions.profile.updateProfile({resetApiKey: true}).then((profile) => { 39 | }).catch((err) => { 40 | actions.notification.showNotification({ 41 | header: 'Unable to generate API key', 42 | message: err.error, 43 | level: 'error' 44 | }) 45 | }) 46 | }; 47 | 48 | get apiKey () { 49 | const profile = this.props.profile.current 50 | 51 | if (profile && profile.apiKey) { 52 | return ( 53 | 54 | 55 | 56 | API key generated with success 57 | 58 | 63 |

64 | Please save somewhere this API key because we will never be able to show it again. 65 |

66 |
67 |
68 | ) 69 | } 70 | } 71 | 72 | render () { 73 | return ( 74 |
75 |
API key
76 | 77 |

78 | To fully access the API you have to use an OpenID Connect client and claim a valid access token. 79 | It's the standard way to interact with the API.
80 | But if you want something a bit simpler you have the possibility to use an API key. 81 | You only have to use this key as a basic password to acces the API. 82 |

83 |

84 | Ex: curl {API_KEY_URL}/documents 85 |

86 |

87 | An API key is not something secure. It's why you only have a limited acces to the API:
88 | You can only make POST or GET actions onto 89 | the /documents API. 90 |

91 | 92 | Regenerate API key} 94 | open={this.state.modalOpen} 95 | onClose={this.handleClose}> 96 | Regenerate API key 97 | 98 | 99 |

Are you sure you want to generate a new API key?

100 |

Previous key will be revoked.

101 |
102 |
103 | 104 | 107 | 110 | 111 |
112 | {this.apiKey} 113 |
114 |
115 | ) 116 | } 117 | } 118 | 119 | const mapStateToProps = (state) => ({ 120 | profile: state.profile 121 | }) 122 | 123 | const mapActionsToProps = (dispatch) => (bindActions({ 124 | notification: NotificationActions, 125 | profile: ProfileActions 126 | }, dispatch)) 127 | 128 | export default connect(mapStateToProps, mapActionsToProps)(ApiKeyTab) 129 | -------------------------------------------------------------------------------- /src/views/SettingsView/ExportTab/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import { bindActions } from 'store/helper' 5 | import ExportsActions from 'store/exports/actions' 6 | import ProfileActions from 'store/profile/actions' 7 | import ExportApi from 'api/export' 8 | 9 | import { Icon, Button, Progress, Statistic, Header, Divider, Message, Loader } from 'semantic-ui-react' 10 | 11 | export class ExportTab extends React.Component { 12 | static propTypes = { 13 | actions: PropTypes.object.isRequired, 14 | exports: PropTypes.object, 15 | profile: PropTypes.object 16 | }; 17 | 18 | componentDidMount () { 19 | const { actions } = this.props 20 | actions.exports.getExportStatus() 21 | actions.profile.fetchProfile(true) 22 | } 23 | 24 | handleScheduleAnExport = () => { 25 | const { actions } = this.props 26 | actions.exports.scheduleExport().then(actions.exports.getExportStatus) 27 | }; 28 | 29 | get exportWidget () { 30 | const {progress} = this.props.exports 31 | if (progress < 0) { 32 | return this.renderNoExportAvailable() 33 | } else if (progress >= 0 && progress < 100) { 34 | return this.renderExportInProgress() 35 | } else { 36 | return this.renderExportAvailable() 37 | } 38 | } 39 | 40 | get statistics () { 41 | const {isProcessing, current, error} = this.props.profile 42 | if (isProcessing) { 43 | return () 44 | } else if (error) { 45 | return ( 46 | 47 | Unable to get statistics 48 |

{error.toString()}

49 |
50 | ) 51 | } else if (current != null) { 52 | const usage = Math.ceil(current.storageUsage / 1048576) 53 | return ( 54 | 55 | 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | } 63 | 64 | renderNoExportAvailable () { 65 | const {error} = this.props.exports 66 | const txt = error ? error.toString() : 'No export available' 67 | return ( 68 | 69 | {txt} 70 |

71 | You can schedule an export. Depending the number of document this can take a while. 72 |

73 |

74 | 77 |

78 |
79 | ) 80 | } 81 | 82 | renderExportInProgress () { 83 | const {progress, exported, total, error} = this.props.exports 84 | 85 | const txt = error ? error.toString() : `${exported} / ${total}` 86 | return ( 87 | 88 | 89 | Export in progress 90 | 91 |

92 |

Exporting documents...

93 | 94 | { txt } 95 | 96 |
97 | ) 98 | } 99 | 100 | renderExportAvailable () { 101 | const downloadUrl = ExportApi.getDownloadUrl() 102 | return ( 103 | 104 | 105 | Export available 106 | 107 |

Export ready to download.

108 | 111 | 114 |
115 | ) 116 | } 117 | 118 | render () { 119 | return ( 120 |
121 |
Usage and Export
122 | 123 |

124 | Export all your documents in order to re-import them into another 125 | Nunux Keeper instance or to simply create a backup. 126 |

127 |

Here your current usage:

128 | { this.statistics } 129 | { this.exportWidget } 130 |
131 | ) 132 | } 133 | } 134 | 135 | const mapStateToProps = (state) => ({ 136 | exports: state.exports, 137 | profile: state.profile 138 | }) 139 | 140 | const mapActionsToProps = (dispatch) => (bindActions({ 141 | exports: ExportsActions, 142 | profile: ProfileActions 143 | }, dispatch)) 144 | 145 | export default connect(mapStateToProps, mapActionsToProps)(ExportTab) 146 | 147 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 60 | 63 | 66 | 74 | 75 | 81 | 86 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /src/store/label/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | createRequestAction, 3 | createSuccessAction, 4 | createFailureAction, 5 | dispatchAction 6 | } from 'store/helper' 7 | 8 | import LabelApi from 'api/label' 9 | 10 | // -------------------------------------- 11 | // Constants 12 | // -------------------------------------- 13 | export const FETCH_LABEL = 'FETCH_LABEL' 14 | export const CREATE_LABEL = 'CREATE_LABEL' 15 | export const UPDATE_LABEL = 'UPDATE_LABEL' 16 | export const REMOVE_LABEL = 'REMOVE_LABEL' 17 | export const RESTORE_LABEL = 'RESTORE_LABEL' 18 | export const DISCARD_LABEL = 'DISCARD_LABEL' 19 | 20 | // -------------------------------------- 21 | // Fetch label actions 22 | // -------------------------------------- 23 | const fetchLabelRequest = createRequestAction(FETCH_LABEL) 24 | const fetchLabelFailure = createFailureAction(FETCH_LABEL) 25 | const fetchLabelSuccess = createSuccessAction(FETCH_LABEL) 26 | 27 | export const fetchLabel = (id) => { 28 | return (dispatch, getState) => { 29 | const {label: l} = getState() 30 | if (l.isProcessing) { 31 | console.warn('Unable to fetch label. An action is pending...') 32 | return Promise.resolve(null) 33 | } 34 | console.debug('Fetching label:', id) 35 | dispatch(fetchLabelRequest()) 36 | return LabelApi.get(id) 37 | .then( 38 | res => dispatchAction(dispatch, fetchLabelSuccess(res)), 39 | err => dispatchAction(dispatch, fetchLabelFailure(err)) 40 | ) 41 | } 42 | } 43 | 44 | // -------------------------------------- 45 | // Create label actions 46 | // -------------------------------------- 47 | const createLabelRequest = createRequestAction(CREATE_LABEL) 48 | const createLabelFailure = createFailureAction(CREATE_LABEL) 49 | const createLabelSuccess = createSuccessAction(CREATE_LABEL) 50 | 51 | export const createLabel = (label) => { 52 | return (dispatch, getState) => { 53 | console.debug('Creating label:', label) 54 | dispatch(createLabelRequest()) 55 | return LabelApi.create(label) 56 | .then( 57 | res => dispatchAction(dispatch, createLabelSuccess(res)), 58 | err => dispatchAction(dispatch, createLabelFailure(err)) 59 | ) 60 | } 61 | } 62 | 63 | // -------------------------------------- 64 | // Update label actions 65 | // -------------------------------------- 66 | const updateLabelRequest = createRequestAction(UPDATE_LABEL) 67 | const updateLabelFailure = createFailureAction(UPDATE_LABEL) 68 | const updateLabelSuccess = createSuccessAction(UPDATE_LABEL) 69 | 70 | export const updateLabel = (label, payload) => { 71 | return (dispatch, getState) => { 72 | const {label: l} = getState() 73 | if (l.isProcessing) { 74 | console.warn('Unable to update label. An action is pending...') 75 | return Promise.resolve(null) 76 | } 77 | console.debug('Updating label:', label) 78 | dispatch(updateLabelRequest()) 79 | return LabelApi.update(label, payload) 80 | .then( 81 | res => dispatchAction(dispatch, updateLabelSuccess(res)), 82 | err => dispatchAction(dispatch, updateLabelFailure(err)) 83 | ) 84 | } 85 | } 86 | 87 | // -------------------------------------- 88 | // Remove label actions 89 | // -------------------------------------- 90 | const removeLabelRequest = createRequestAction(REMOVE_LABEL) 91 | const removeLabelFailure = createFailureAction(REMOVE_LABEL) 92 | const removeLabelSuccess = createSuccessAction(REMOVE_LABEL) 93 | 94 | export const removeLabel = (label) => { 95 | return (dispatch, getState) => { 96 | console.debug('Removing label:', label.id) 97 | dispatch(removeLabelRequest()) 98 | return LabelApi.remove(label) 99 | .then( 100 | res => dispatchAction(dispatch, removeLabelSuccess(label)), 101 | err => dispatchAction(dispatch, removeLabelFailure(err)) 102 | ) 103 | } 104 | } 105 | 106 | // -------------------------------------- 107 | // Restore label actions 108 | // -------------------------------------- 109 | const restoreLabelRequest = createRequestAction(RESTORE_LABEL) 110 | const restoreLabelFailure = createFailureAction(RESTORE_LABEL) 111 | const restoreLabelSuccess = createSuccessAction(RESTORE_LABEL) 112 | 113 | export const restoreRemovedLabel = () => { 114 | return (dispatch, getState) => { 115 | const {labels} = getState() 116 | if (!labels.removed) { 117 | return Promise.reject({error: 'No label to restore.'}) 118 | } 119 | console.debug('Restoring label:', labels.removed.id) 120 | dispatch(restoreLabelRequest()) 121 | return LabelApi.restore(labels.removed) 122 | .then( 123 | res => dispatchAction(dispatch, restoreLabelSuccess(res)), 124 | err => dispatchAction(dispatch, restoreLabelFailure(err)) 125 | ) 126 | } 127 | } 128 | 129 | // -------------------------------------- 130 | // Discard label actions 131 | // -------------------------------------- 132 | const discardLabel = createRequestAction(DISCARD_LABEL) 133 | 134 | const actions = { 135 | fetchLabel, 136 | createLabel, 137 | updateLabel, 138 | removeLabel, 139 | restoreRemovedLabel, 140 | discardLabel 141 | } 142 | 143 | export default actions 144 | 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NUNUX Keeper Web App 2 | 3 | > Your personal content curation service. 4 | 5 | Nunux Keeper allow you to collect, organize, and display web documents. 6 | 7 | **This project is the official web frontend.** 8 | 9 | ![Screenshot](screenshot.png) 10 | 11 | ## Table of Contents 12 | 1. [Requirements](#requirements) 13 | 1. [Features](#features) 14 | 1. [Installation](#installation) 15 | 1. [Development server](#development-server) 16 | 1. [Other commands](#other-commands) 17 | 1. [Under the hood](#under-the-hood) 18 | 1. [Structure](#structure) 19 | 20 | ## Requirements 21 | 22 | Docker OR Node `^5.0.0` 23 | 24 | ## Features 25 | 26 | * Welcome page 27 | * Login with external identity provider (Google, Twitter, etc.) 28 | * Manage labels to organize documents 29 | * Create document from scratch or from a remote location 30 | * Create document from another website thanks to the bookmarklet 31 | * Search documents with a powerful search engine 32 | * Share documents 33 | 34 | ## Configuration 35 | 36 | Basic project configuration can be found in `etc/dev.env`. Here you'll be able 37 | to redefine some parameters: 38 | 39 | * REACT_APP_API_ROOT: Nunux Keeper API endpoint 40 | * REACT_APP_DEBUG: Activate debug mode 41 | 42 | ## Installation 43 | 44 | > Note that this project is "only" the web front end of the backend API of Linux 45 | > Keeper. If you want to use your own API server you have to install first this 46 | > project: [keeper-core-api](https://github.com/nunux-keeper/keeper-core-api) 47 | 48 | Once configured for your needs (see section above), you can build the static 49 | Web App into the directory of your choice: 50 | 51 | ```bash 52 | $ git clone https://github.com/nunux-keeper/keeper-web-app.git 53 | $ cd keeper-web-app 54 | $ make install DEPLOY_DIR=/var/www/html 55 | ``` 56 | 57 | Then, you can serve this directory with your favorite HTTP server. 58 | 59 | ## Development server 60 | 61 | With Node: 62 | 63 | ```shell 64 | $ git clone https://github.com/nunux-keeper/keeper-web-app.git 65 | $ cd keeper-web-app 66 | $ npm install # Install Node modules listed in ./package.json (may take a 67 | # while the first time) 68 | $ npm start # Compile and launch 69 | ``` 70 | 71 | Or with Docker: 72 | 73 | ```shell 74 | $ git clone https://github.com/nunux-keeper/keeper-web-app.git 75 | $ cd keeper-web-app 76 | $ make build start # Build Docker image and start it 77 | ``` 78 | 79 | ## Other commands 80 | 81 | Here's a brief summary of available Docker commands: 82 | 83 | * `make help` - Show available commands. 84 | * `make build` - Build Docker image. 85 | * `make test` - Start container with tests. 86 | * `make start` - Start container in foreground. 87 | * `make deploy` - Start container in background. 88 | * `make undeploy` - Stop container in background. 89 | * `make logs` - View container logs. 90 | * `make install` - Install generated site into the deployment directory. 91 | 92 | Here's a brief summary of available NPM commands: 93 | 94 | * `npm start` - Start development server. 95 | * `npm run build` - Compiles the application to disk (`~/build`). 96 | * `npm run test` - Runs unit tests. 97 | * `npm run build-css`- Runs SASS to generate CSS file. 98 | 99 | ## Under the hood 100 | 101 | * [React](https://github.com/facebook/react) 102 | * [Redux](http://redux.js.org/) 103 | * [React Router](https://github.com/ReactTraining/react-router) 104 | * [React Create App](https://github.com/facebookincubator/create-react-app) 105 | * [Sass](http://sass-lang.com/) 106 | * [ESLint](http://eslint.org) 107 | 108 | 109 | ## Structure 110 | 111 | Here the folder structure: 112 | 113 | ``` 114 | . 115 | ├── build # Builded website 116 | ├── etc # Configuration 117 | ├── makefiles # Makefiles 118 | ├── public # Static files to serve as is 119 | ├── src # Application source code 120 | │ ├── api # Backend API connector (real and mock) 121 | │ ├── components # App components 122 | │ ├── layouts # Components that dictate major page structure 123 | │ ├── middlewares # Components that provide context and AuthN 124 | │ ├── store # Redux store 125 | │ │ └── modules # Redux modules 126 | │ ├── styles # Application-wide styles 127 | │ ├── views # Components that live at a route 128 | │ ├── App.js # Application bootstrap and rendering 129 | │ ├── Routes.js # Application routes 130 | │ └── index.js # Application entry point 131 | └── test # Unit tests 132 | ``` 133 | 134 | ---------------------------------------------------------------------- 135 | 136 | NUNUX Keeper 137 | 138 | Copyright (c) 2017 Nicolas CARLIER (https://github.com/ncarlier) 139 | 140 | This program is free software: you can redistribute it and/or modify 141 | it under the terms of the GNU General Public License as published by 142 | the Free Software Foundation, either version 3 of the License. 143 | 144 | This program is distributed in the hope that it will be useful, 145 | but WITHOUT ANY WARRANTY; without even the implied warranty of 146 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 147 | GNU General Public License for more details. 148 | 149 | You should have received a copy of the GNU General Public License 150 | along with this program. If not, see . 151 | 152 | ---------------------------------------------------------------------- 153 | -------------------------------------------------------------------------------- /src/components/AppMenu/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import { connect } from 'react-redux' 3 | import { bindActions } from 'store/helper' 4 | import { Menu, Header, Icon } from 'semantic-ui-react' 5 | import { Link } from 'react-router' 6 | 7 | import labelsActions from 'store/labels/actions' 8 | import { actions as layoutActions } from 'store/modules/layout' 9 | 10 | import { Sizes } from 'store/modules/layout' 11 | 12 | import ProfilePanel from 'components/ProfilePanel' 13 | 14 | import './styles.css' 15 | 16 | export class AppMenu extends React.Component { 17 | static propTypes = { 18 | actions: PropTypes.object.isRequired, 19 | labels: PropTypes.object.isRequired, 20 | layout: PropTypes.object.isRequired, 21 | location: PropTypes.object.isRequired 22 | }; 23 | 24 | constructor () { 25 | super() 26 | this.handleItemClick = this.handleItemClick.bind(this) 27 | } 28 | 29 | componentDidMount () { 30 | const { actions } = this.props 31 | actions.labels.fetchLabels() 32 | } 33 | 34 | get spinner () { 35 | const { isProcessing } = this.props.labels 36 | if (isProcessing) { 37 | return ( 38 |
39 |
40 |
41 | ) 42 | } 43 | } 44 | 45 | get labels () { 46 | const { isProcessing, current } = this.props.labels 47 | if (isProcessing && current.labels.length === 0) { 48 | return ( 49 | 50 | 51 | 52 | ) 53 | } 54 | return current.labels.map( 55 | (label) => 62 | {this.getLabelIcon(label)} 63 | {label.label} 64 | 65 | ) 66 | } 67 | 68 | getLabelIcon (label) { 69 | if (label.sharing) { 70 | return ( 71 | 72 | 73 | 74 | 75 | ) 76 | } else { 77 | return ( 78 | 79 | ) 80 | } 81 | } 82 | 83 | handleItemClick (event) { 84 | // console.log(event) 85 | const { actions, layout } = this.props 86 | if (layout.size < Sizes.LARGE) { 87 | actions.layout.toggleSidebar() 88 | } 89 | } 90 | 91 | render () { 92 | const { 93 | location 94 | } = this.props 95 | 96 | return ( 97 | 98 | 99 |
100 | 101 | Nunux Keeper 102 |
103 | 104 |
105 | 110 | 111 | Documents 112 | 113 | 114 | 115 | 116 | Labels 117 | 121 | 122 | 123 | 124 | 125 | {this.labels} 126 | 127 | 128 | 133 | 134 | Sharing 135 | 136 | 141 | 142 | Trash 143 | 144 | 149 | 150 | Settings 151 | 152 | 157 | 158 | About 159 | 160 |
161 | ) 162 | } 163 | } 164 | 165 | const mapStateToProps = (state) => ({ 166 | location: state.router.locationBeforeTransitions, 167 | labels: state.labels, 168 | layout: state.layout 169 | }) 170 | 171 | const mapActionsToProps = (dispatch) => (bindActions({ 172 | labels: labelsActions, 173 | layout: layoutActions 174 | }, dispatch)) 175 | 176 | export default connect(mapStateToProps, mapActionsToProps)(AppMenu) 177 | -------------------------------------------------------------------------------- /src/components/DocumentContextMenu/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import { connect } from 'react-redux' 3 | import { Link } from 'react-router' 4 | import { Dropdown } from 'semantic-ui-react' 5 | 6 | import { bindActions } from 'store/helper' 7 | 8 | import { actions as DocumentActions } from 'store/modules/document' 9 | import { actions as GraveyardActions } from 'store/modules/graveyard' 10 | import { actions as NotificationActions } from 'store/modules/notification' 11 | import { actions as TitleModalActions } from 'store/modules/titleModal' 12 | 13 | const API_ROOT = process.env.REACT_APP_API_ROOT 14 | 15 | export class DocumentContextMenu extends React.Component { 16 | static propTypes = { 17 | actions: PropTypes.object.isRequired, 18 | location: PropTypes.object.isRequired, 19 | doc: PropTypes.object.isRequired, 20 | items: PropTypes.string, 21 | direction: PropTypes.string 22 | }; 23 | 24 | static defaultProps = { 25 | direction: 'right' 26 | }; 27 | 28 | constructor (props) { 29 | super(props) 30 | this.handleDestroy = this.handleDestroy.bind(this) 31 | this.handleRestore = this.handleRestore.bind(this) 32 | this.handleRemove = this.handleRemove.bind(this) 33 | this.handleUndoRemove = this.handleUndoRemove.bind(this) 34 | this.handleEditTitle = this.handleEditTitle.bind(this) 35 | } 36 | 37 | key (name) { 38 | const doc = this.props.doc 39 | return `menu-${name}-${doc.id}` 40 | } 41 | 42 | get detailMenuItem () { 43 | const { pathname } = this.props.location 44 | const doc = this.props.doc 45 | return ( 46 | 53 | ) 54 | } 55 | 56 | get rawMenuItem () { 57 | const doc = this.props.doc 58 | return ( 59 | 67 | ) 68 | } 69 | 70 | get editTitleMenuItem () { 71 | return ( 72 | 78 | ) 79 | } 80 | 81 | get editMenuItem () { 82 | const { actions } = this.props 83 | return ( 84 | 90 | ) 91 | } 92 | 93 | get deleteMenuItem () { 94 | return ( 95 | 101 | ) 102 | } 103 | 104 | get restoreMenuItem () { 105 | return ( 106 | 112 | ) 113 | } 114 | 115 | get destroyMenuItem () { 116 | return ( 117 | 123 | ) 124 | } 125 | 126 | get dividerMenuItem () { 127 | return () 128 | } 129 | 130 | get menu () { 131 | const { items } = this.props 132 | return items.split(',').map((item) => { 133 | return this[item + 'MenuItem'] 134 | }) 135 | } 136 | 137 | render () { 138 | return ( 139 | 140 | {this.menu} 141 | 142 | ) 143 | } 144 | 145 | handleUndoRemove () { 146 | const { actions } = this.props 147 | actions.document.restoreRemovedDocument().then(() => { 148 | actions.notification.showNotification({ 149 | level: 'info', 150 | header: 'Document restored' 151 | }) 152 | }) 153 | } 154 | 155 | handleRemove () { 156 | const {doc, actions} = this.props 157 | actions.document.removeDocument(doc).then(() => { 158 | actions.notification.showNotification({ 159 | level: 'info', 160 | header: 'Document moved to trash', 161 | actionLabel: 'undo', 162 | actionFn: () => this.handleUndoRemove() 163 | }) 164 | }) 165 | } 166 | 167 | handleRestore () { 168 | const { doc, actions } = this.props 169 | actions.document.restoreDocument(doc).then(() => { 170 | actions.notification.showNotification({ 171 | level: 'info', 172 | header: 'Document restored from trash' 173 | }) 174 | }) 175 | } 176 | 177 | handleDestroy () { 178 | const { doc, actions } = this.props 179 | actions.graveyard.removeGhost(doc).then(() => { 180 | actions.notification.showNotification({ 181 | level: 'info', 182 | header: 'Document completely removed' 183 | }) 184 | }) 185 | } 186 | 187 | handleEditTitle () { 188 | const { doc, actions } = this.props 189 | actions.titleModal.showTitleModal(doc) 190 | } 191 | } 192 | 193 | const mapStateToProps = (state) => ({ 194 | location: state.router.locationBeforeTransitions 195 | }) 196 | 197 | const mapActionsToProps = (dispatch) => (bindActions({ 198 | notification: NotificationActions, 199 | graveyard: GraveyardActions, 200 | document: DocumentActions, 201 | titleModal: TitleModalActions 202 | }, dispatch)) 203 | 204 | export default connect(mapStateToProps, mapActionsToProps)(DocumentContextMenu) 205 | -------------------------------------------------------------------------------- /src/views/LabelView/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import { connect } from 'react-redux' 3 | import { Form, Button } from 'semantic-ui-react' 4 | 5 | import { bindActions } from 'store/helper' 6 | 7 | import { routerActions as RouterActions } from 'react-router-redux' 8 | import LabelActions from 'store/label/actions' 9 | import { actions as NotificationActions } from 'store/modules/notification' 10 | 11 | import ColorSwatch from 'components/ColorSwatch' 12 | import AppBar from 'components/AppBar' 13 | 14 | export class LabelView extends React.Component { 15 | static propTypes = { 16 | actions: PropTypes.object.isRequired, 17 | label: PropTypes.object, 18 | location: PropTypes.object.isRequired 19 | }; 20 | 21 | constructor (props) { 22 | super(props) 23 | if (this.isCreateForm) { 24 | this.state = { 25 | label: '', 26 | color: '#8E44AD' 27 | } 28 | } else { 29 | this.state = {...props.label.current} 30 | } 31 | this.handleSubmit = this.handleSubmit.bind(this) 32 | this.handleCancel = this.handleCancel.bind(this) 33 | this.handleChange = this.handleChange.bind(this) 34 | this.handleColorChoose = this.handleColorChoose.bind(this) 35 | } 36 | 37 | componentWillReceiveProps (nextProps) { 38 | if ( 39 | !nextProps.label.isProcessing && 40 | nextProps.label.current 41 | ) { 42 | this.setState(nextProps.label.current) 43 | } 44 | } 45 | 46 | componentDidUpdate (prevProps) { 47 | document.title = this.title 48 | } 49 | 50 | get title () { 51 | if (this.isCreateForm) { 52 | return 'New label' 53 | } else if (this.props.label.current) { 54 | const { current: {label} } = this.props.label 55 | return `Edit label: ${label}` 56 | } 57 | } 58 | 59 | get isCreateForm () { 60 | const pathname = this.props.location.pathname 61 | return pathname === '/labels/create' 62 | } 63 | 64 | get isModalDisplayed () { 65 | const routerState = this.props.location.state 66 | return routerState && routerState.modal 67 | } 68 | 69 | get header () { 70 | return ( 71 | 75 | ) 76 | } 77 | 78 | get isValidLabel () { 79 | const { label } = this.state 80 | return label !== '' 81 | } 82 | 83 | get labelForm () { 84 | const { color, label } = this.state 85 | const { isProcessing } = this.props.label 86 | const loading = isProcessing 87 | const disabled = !this.isValidLabel 88 | return ( 89 |
90 | 101 | 102 | 103 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | ) 118 | } 119 | 120 | handleSubmit (e) { 121 | e.preventDefault() 122 | if (!this.isValidLabel) { 123 | return false 124 | } 125 | const { actions } = this.props 126 | if (this.isCreateForm) { 127 | actions.label.createLabel(this.state).then((label) => { 128 | actions.router.push(`/labels/${label.id}`) 129 | actions.notification.showNotification({message: 'Label created'}) 130 | }).catch((err) => { 131 | actions.notification.showNotification({ 132 | header: 'Unable to create label', 133 | message: err.error, 134 | level: 'error' 135 | }) 136 | }) 137 | } else { 138 | const { current } = this.props.label 139 | actions.label.updateLabel(current, this.state).then((label) => { 140 | actions.router.push(`/labels/${label.id}`) 141 | actions.notification.showNotification({message: 'Label updated'}) 142 | }).catch((err) => { 143 | actions.notification.showNotification({ 144 | header: 'Unable to update label', 145 | message: err.error, 146 | level: 'error' 147 | }) 148 | }) 149 | } 150 | return false 151 | } 152 | 153 | handleCancel (e) { 154 | e.preventDefault() 155 | const { actions, location: loc } = this.props 156 | const { state } = loc 157 | if (state && state.returnTo) { 158 | actions.router.push(state.returnTo) 159 | } else { 160 | actions.router.push('/documents') 161 | } 162 | return false 163 | } 164 | 165 | handleChange (event) { 166 | this.setState({[event.target.name]: event.target.value}) 167 | } 168 | 169 | handleColorChoose (color) { 170 | this.setState({color}) 171 | } 172 | 173 | render () { 174 | return ( 175 |
176 | {this.header} 177 |
178 | {this.labelForm} 179 |
180 |
181 | ) 182 | } 183 | } 184 | 185 | const mapStateToProps = (state) => ({ 186 | location: state.router.locationBeforeTransitions, 187 | label: state.label 188 | }) 189 | 190 | const mapActionsToProps = (dispatch) => (bindActions({ 191 | notification: NotificationActions, 192 | label: LabelActions, 193 | router: RouterActions 194 | }, dispatch)) 195 | 196 | export default connect(mapStateToProps, mapActionsToProps)(LabelView) 197 | -------------------------------------------------------------------------------- /src/store/modules/documents.js: -------------------------------------------------------------------------------- 1 | import { createAction, handleActions } from 'redux-actions' 2 | import DocumentApi from 'api/document' 3 | import { errorHandler, payloadResponse } from 'store/helper' 4 | 5 | // ------------------------------------ 6 | // Constants 7 | // ------------------------------------ 8 | export const FETCH_DOCUMENTS = 'FETCH_DOCUMENTS' 9 | export const CREATE_DOCUMENT = 'CREATE_DOCUMENT' 10 | export const UPDATE_DOCUMENT = 'UPDATE_DOCUMENT' 11 | export const REMOVE_DOCUMENT = 'REMOVE_DOCUMENT' 12 | export const RESTORE_DOCUMENT = 'RESTORE_DOCUMENT' 13 | 14 | // ------------------------------------ 15 | // Actions 16 | // ------------------------------------ 17 | export const fetchDocumentsRequest = createAction(FETCH_DOCUMENTS, (params) => { 18 | return {params} 19 | }) 20 | export const fetchDocumentsFailure = createAction(FETCH_DOCUMENTS, errorHandler) 21 | export const fetchDocumentsSuccess = createAction(FETCH_DOCUMENTS, (res) => { 22 | console.debug('Documents fetched:', res.total) 23 | return {response: {total: res.total, items: res.hits}} 24 | }) 25 | 26 | export const fetchDocuments = (params = {from: 0, size: 20}, type = 'user') => { 27 | params = Object.assign({ 28 | from: 0, 29 | size: 20, 30 | order: 'desc' 31 | }, params) 32 | return (dispatch, getState) => { 33 | const {documents} = getState() 34 | if (documents.isFetching || documents.isProcessing) { 35 | console.warn(`Unable to fetch ${type} documents. An action is pending...`) 36 | return Promise.resolve(null) 37 | } else if (documents.hasMore || params.from === 0) { 38 | console.debug(`Fetching ${type} documents:`, params) 39 | dispatch(fetchDocumentsRequest(params)) 40 | let fetched 41 | switch (true) { 42 | case type === 'shared': 43 | fetched = DocumentApi.searchShared(params) 44 | break 45 | case type === 'public': 46 | fetched = DocumentApi.searchPublic(params) 47 | break 48 | default: 49 | fetched = DocumentApi.search(params) 50 | } 51 | return fetched.then((res) => dispatch(fetchDocumentsSuccess(res))) 52 | .catch((err) => dispatch(fetchDocumentsFailure(err))) 53 | .then(payloadResponse) 54 | } else { 55 | console.warn(`Unable to fetch ${type} documents. No more documents`, params) 56 | return Promise.resolve(null) 57 | } 58 | } 59 | } 60 | 61 | export const actions = { 62 | fetchDocuments, 63 | fetchSharedDocuments: (params) => fetchDocuments(params, 'shared'), 64 | fetchPublicDocuments: (params) => fetchDocuments(params, 'public') 65 | } 66 | 67 | // ------------------------------------ 68 | // Reducer 69 | // ------------------------------------ 70 | export default handleActions({ 71 | [FETCH_DOCUMENTS]: (state, action) => { 72 | const update = { 73 | isProcessing: action.payload.params != null, 74 | isFetching: action.payload.params != null, 75 | error: null 76 | } 77 | const {error, response, params} = action.payload 78 | if (error) { 79 | update.error = error 80 | } else if (response) { 81 | const {items, total} = response 82 | update.total = total 83 | update.items = state.items.concat(items) 84 | update.hasMore = total > update.items.length 85 | } else if (params) { 86 | update.params = params 87 | if (params.from === 0) { 88 | update.items = [] 89 | update.total = 0 90 | update.hasMore = false 91 | } 92 | } 93 | return Object.assign({}, state, update) 94 | }, 95 | [CREATE_DOCUMENT]: (state, action) => { 96 | const {response} = action.payload || {} 97 | if (response) { 98 | // Add created document to the list 99 | const update = { 100 | items: [response, ...state.items], 101 | total: state.total + 1 102 | } 103 | return Object.assign({}, state, update) 104 | } 105 | return state 106 | }, 107 | [UPDATE_DOCUMENT]: (state, action) => { 108 | const {response} = action.payload || {} 109 | if (response) { 110 | // Update document into the list (if present) 111 | const doc = response 112 | const update = {} 113 | let updated = false 114 | update.items = state.items.reduce((acc, item, index) => { 115 | if (item.id === doc.id) { 116 | item.title = doc.title 117 | item.labels = doc.labels 118 | updated = true 119 | } 120 | acc.push(item) 121 | return acc 122 | }, []) 123 | if (updated) { 124 | return Object.assign({}, state, update) 125 | } 126 | } 127 | return state 128 | }, 129 | [REMOVE_DOCUMENT]: (state, action) => { 130 | const {response} = action.payload || {} 131 | if (response) { 132 | // Remove document from the list (if present) 133 | const doc = response 134 | const update = {} 135 | update.items = state.items.reduce((acc, item, index) => { 136 | if (item.id === doc.id) { 137 | update.removedIndex = index 138 | update.removed = item 139 | update.total = state.total - 1 140 | } else { 141 | acc.push(item) 142 | } 143 | return acc 144 | }, []) 145 | if (update.removed) { 146 | return Object.assign({}, state, update) 147 | } 148 | } 149 | return state 150 | }, 151 | [RESTORE_DOCUMENT]: (state, action) => { 152 | const {response} = action.payload || {} 153 | if (response) { 154 | // Restore document into the list 155 | const idx = state.removedIndex || 0 156 | const update = {} 157 | update.items = state.items.slice() 158 | update.items.splice(idx, 0, response) 159 | update.total = state.total + 1 160 | update.removed = null 161 | update.removedIndex = null 162 | return Object.assign({}, state, update) 163 | } 164 | return state 165 | } 166 | }, { 167 | isFetching: false, 168 | isProcessing: false, 169 | hasMore: true, 170 | removed: null, 171 | removedIndex: null, 172 | params: null, 173 | items: [], 174 | total: null, 175 | error: null 176 | }) 177 | --------------------------------------------------------------------------------