Наша цель, написать offline-first приложение — SPA которое загружается и сохраняет полную функциональность в отсутствии интернет-соединения. В первой части повествования мы научились пользоваться браузерной базой данных. Сегодня мы настроим синхронизацию с серверной бд и подключим авторизацию. В результате мы получим возможность редактировать наши данные на разных устройствах даже в оффлайне с последующей синхронизацией при появлении соединения.
CouchDB
Да, на сервере нам потребуется именно эта база данных. В настоящий момент активно разрабатывается Pouchdb-Server, который на базе LevelDB имитирует API CouchDB. Hoodie по умолчанию работает с ним, это сделано с целью упрощения установки для новичков. Но он сыр даже для целей разработки. Возможно, мне просто повезло, но я потратил 3 дня впервые пытаясь завести Hoodie и натыкаясь на странные ошибки, 3 дня issues и pull-request-ов. И на грани разочарования решил-таки установить нормальную CouchDB и все мои проблемы кончились. Поэтому я предлагаю вам сразу поставить последнюю, разве что вы тоже хотите предварительно внести посильный вклад в opensource.
В большинстве дистрибутивов CouchDB ставится штатными средствами.
Вот инструкция которой пользовался я. Однако, база постоянно падала пока я не удалил /etc/init.d/coucdb и не отдал её под надзор supervisord-а, вот конфиг последнего:
[program:couchdb] user=couchdb environment=HOME=/usr/local/var/lib/couchdb command=/usr/local/bin/couchdb autorestart=true stdout_logfile=NONE stderr_logfile=NONE
Поставив базу создаём админа:
curl -X PUT $HOST/_config/admins/username -d '"password"'
И включаем CORS:
npm install -g add-cors-to-couchdb add-cors-to-couchdb -u username -p password
Теперь остаётся лишь немного поправить команду для запуска сервера в package.json:
"server": "hoodie --port 8000 --dbUrl 'http://username:password@127.0.0.1:5984'"
Надеюсь, у вас всё получилось :)
Авторизация
В AppBar-е у нас будет иконка авторизации со контекстным меню. Поэтому мы вынесем его в отдельную компоненту и будем использовать её в App.js вместо AppBar:
import NavBar from './NavBar' <NavBar account={hoodie.account} />
Туда мы передаём hoodie.account который предоставляет нам API для авторизации:
hoodie.account.SignUp({username, password)}hoodie.account.SignIn({username, password)}hoodie.account.SingOut()
И события на которые можно подписаться:
hoodie.account.on('signin', callback)hoodie.account.on('signout', callback)
А вот и сама компонента:
import React from 'react' import AppBar from 'material-ui/AppBar' import FlatButton from 'material-ui/FlatButton' import IconButton from 'material-ui/IconButton' import IconMenu from 'material-ui/IconMenu' import MenuItem from 'material-ui/MenuItem' import KeyIcon from 'material-ui/svg-icons/communication/vpn-key' import AccountIcon from 'material-ui/svg-icons/action/account-circle' import AuthDialog from './AuthDialog' export default class NavBar extends React.Component { constructor(props) { super(props) this.state = { isSignedIn: this.props.account.isSignedIn(), openedDialog: null } } signOutCallback = () => this.setState({isSignedIn: false}) signInCallback = () => this.setState({isSignedIn: true}) componentDidMount() { this.props.account.on('signout', this.signOutCallback) this.props.account.on('signin', this.signInCallback) } componentWillUnmount() { this.props.account.off('signout', this.signOutCallback) this.props.account.off('signin', this.signInCallback) } render () { let authMenu; if (this.state.isSignedIn) { authMenu = ( <IconMenu iconButtonElement={<IconButton><AccountIcon /></IconButton>} targetOrigin={{horizontal: 'right', vertical: 'top'}} anchorOrigin={{horizontal: 'right', vertical: 'top'}} > <MenuItem primaryText="Sign Out" onTouchTap={() => this.props.account.signOut()} /> </IconMenu> ) } else { authMenu = ( <IconMenu iconButtonElement={<IconButton><KeyIcon /></IconButton>} targetOrigin={{horizontal: 'right', vertical: 'top'}} anchorOrigin={{horizontal: 'right', vertical: 'top'}} > <MenuItem primaryText="Sign Up" onTouchTap={() => this.setState({openedDialog: 'signup'})} /> <MenuItem primaryText="Sign In" onTouchTap={() => this.setState({openedDialog: 'signin'})} /> </IconMenu> ) } return ( <div> <AppBar title="Action Loop" showMenuIconButton={false} iconElementRight={authMenu} /> <AuthDialog account={this.props.account} action={this.state.openedDialog} handleClose={() => this.setState({openedDialog: null})} /> </div> ) } }
В state у нас лежит текущее состояние авторизации для отрисовки иконки и меню. И открытый в данный момент диалог (регистрация, вход или null — если всё закрыто). В componentDidMount мы подписываемся на события входа и выхода. И в render отображаем нужную иконку в соответствии с состоянием авторизации. Осталось нарисовать диалог авторизации:
import React from 'react'; import Dialog from 'material-ui/Dialog'; import FlatButton from 'material-ui/FlatButton'; import TextField from 'material-ui/TextField'; export default class AuthDialog extends React.Component { constructor(props) { super(props); this.state = { username: '', password: '', }; } handleConfirm = () => { const account = this.props.account; const username = this.state.username.trim(); const password = this.state.password.trim(); if (!username || !password) { return; } if (this.props.action == 'signup') { account.signUp({username, password}) .then(() => { return account.signIn({username, password}) }) .catch(console.error) } else { account.signIn({username, password}) .catch(console.error) } this.props.handleClose(); this.clearState(); } handleCancel = () => { this.props.handleClose(); this.clearState(); } handleSubmit = (e) => { e.preventDefault(); this.handleConfirm(); } clearState = () => { this.setState({ username: '', password: '' }) } render () { const buttons = [ <FlatButton label="Cancel" primary={true} onTouchTap={this.handleCancel} />, <FlatButton label="Submit" type="submit" primary={true} keyboardFocused={true} onTouchTap={this.handleConfirm} /> ]; return ( <div> <Dialog title={this.props.action == 'signup' ? 'Sign Up' : 'Sign In'} actions={buttons} modal={false} open={this.props.action !== null} onRequestClose={this.handleCancel} contentStyle={{maxWidth: 400}} > <form onSubmit={this.handleSubmit}> <TextField name="username" floatingLabelText="Username" onChange={(e) => this.setState({username: e.target.value})} /> <TextField name="password" floatingLabelText="Password" onChange={(e) => this.setState({password: e.target.value})} /> </form> </Dialog> </div> ); } }
Диалоги регистрации и входа у нас имеют идентичные поля формы, поэтому мы объединим их в один. Логика компоненты элементарна: в handleConfirm мы либо входим либо сначала регистрируемся, а затем входим.
Осталось перезагрузить сами loop-ы при авторизации. Добавим реакцию на события в App.js:
componentDidMount() { hoodie.store.on('change', this.loadLoops); hoodie.account.on('signin', this.loadLoops) hoodie.account.on('signout', this.loadLoops) } componentWillUnmount() { hoodie.store.off('change', this.loadLoops); hoodie.account.off('signin', this.loadLoops) hoodie.account.off('signout', this.loadLoops) }
Конец
Итак, авторизация готова. Наибольшим вызовом этой части была, вероятно, установка CouchDB. Теперь наше приложение сохранит свою функциональность при разрыве соединения, а при появлении синхронизируется. Однако, если совсем закрыть сайт, открыть его без интернета не получится. Мы исправим это в следующей, финальной части.
» Код этой части доступен тут: https://github.com/imbolc/action-loop под тегом part2.
