Another massive refactor.

nb/hide-http
Alex Kern 11 years ago
parent 3da3b2d443
commit 02d45ac81a

@ -1,5 +1,7 @@
# webdrop # webdrop
![XKCD 949](http://imgs.xkcd.com/comics/file_transfer.png)
Peer-to-peer file transfers in your browser Peer-to-peer file transfers in your browser
## Usage ## Usage

@ -4,7 +4,7 @@ export default alt.createActions(class DownloadActions {
constructor() { constructor() {
this.generateActions( this.generateActions(
'requestDownload', 'requestDownload',
'setDownloadInfo' 'beginDownload'
) )
} }
}) })

@ -1,9 +0,0 @@
import alt from '../alt';
export default alt.createActions(class PeerActions {
constructor() {
this.generateActions(
'peerConnected'
)
}
})

@ -1,2 +1,2 @@
var Alt = require('alt'); var Alt = require('alt');
module.exports = new Alt(); export default new Alt();

@ -1,70 +1,23 @@
import DownloadActions from '../actions/DownloadActions'; import FrozenHead from 'react-frozenhead';
import DownloadStore from '../stores/DownloadStore';
import DropZone from './DropZone';
import FileDescription from './FileDescription';
import PeerStore from '../stores/PeerStore';
import React from 'react'; import React from 'react';
import Tempalink from './Tempalink'; import { RouteHandler } from 'react-router';
import UploadActions from '../actions/UploadActions';
import UploadStore from '../stores/UploadStore';
function getState() {
return {
peerID: PeerStore.getPeerID(),
readyToUpload: UploadStore.getState().status.isUploading(),
uploadFile: UploadStore.getState().file,
uploadToken: UploadStore.getState().token,
downloadFile: DownloadStore.getState().file,
downloadToken: DownloadStore.getState().token,
readyToDownload: DownloadStore.getState().status.isReady()
};
}
export default class App extends React.Component { export default class App extends React.Component {
constructor() {
this.state = getState();
this._onChange = function() {
this.setState(getState());
}.bind(this);
}
componentDidMount() {
PeerStore.listen(this._onChange);
UploadStore.listen(this._onChange);
DownloadStore.listen(this._onChange);
}
componentDidUnmount() {
PeerStore.unlisten(this._onChange);
UploadStore.unlisten(this._onChange);
DownloadStore.unlisten(this._onChange);
}
uploadFile(file) {
UploadActions.uploadFile(file);
}
downloadFile() {
DownloadActions.requestDownload();
}
render() { render() {
if (this.state.readyToUpload) { return <html lang="en" data-bootstrap={this.props.data}>
return <div> <FrozenHead>
<FileDescription file={this.state.uploadFile} /> <meta charSet="utf-8" />
<Tempalink token={this.state.uploadToken} /> <title>WebDrop - Send Files, Easily</title>
</div>;
} else if (this.state.readyToDownload) { <link rel="stylesheet" href="/index.css" />
return <div> <script src="/app.js" />
<FileDescription file={this.state.downloadFile} /> </FrozenHead>
<button onClick={this.downloadFile.bind(this)}>Download</button>
</div>; <body>
} else { <RouteHandler />
return <DropZone onDrop={this.uploadFile.bind(this)} />; </body>
} </html>;
} }
} }

@ -0,0 +1,51 @@
// TODO: Rename this.
import React from 'react';
import classnames from 'classnames';
// Taken from StackOverflow
// http://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript
function formatSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1000;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
let i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toPrecision(3) + ' ' + sizes[i];
}
export default class Arrow extends React.Component {
render() {
let classes = classnames('arrow', {
'arrow-up': this.props.dir === 'up',
'arrow-down': this.props.dir === 'down',
'arrow-animated': this.props.animated
});
return <div className={classes}>
<div className="arrow-border" />
<div className="arrow-image">{this.props.dir === 'up' ? '^' : 'v'}</div>
{this.props.name === null ? null
: <div className="arrow-name">{this.props.name}</div>}
{this.props.size === null ? null
: <div className="arrow-size">{formatSize(this.props.size)}</div>}
</div>;
}
}
Arrow.propTypes = {
dir: React.PropTypes.oneOf(['up', 'down']).isRequired,
name: React.PropTypes.string,
size: React.PropTypes.number,
animated: React.PropTypes.bool
};
Arrow.defaultProps = {
name: null,
size: null,
animated: false
};

@ -0,0 +1,64 @@
// TODO: Flesh out this page further.
import Arrow from './Arrow';
import DownloadActions from '../actions/DownloadActions';
import DownloadStore from '../stores/DownloadStore';
import React from 'react';
import peer from '../peer';
function formatProgress(dec) {
return (dec * 100).toPrecision(3) + "%";
}
export default class DownloadPage extends React.Component {
constructor() {
this.state = DownloadStore.getState();
this._onChange = () => {
this.setState(DownloadStore.getState());
};
this._onConnection = (conn) => {
DownloadActions.beginDownload(conn);
};
}
componentDidMount() {
DownloadStore.listen(this._onChange);
peer.on('connection', this._onConnection);
}
componentDidUnmount() {
DownloadStore.unlisten(this._onChange);
peer.removeListener('connection', this._onConnection);
}
downloadFile() {
DownloadActions.requestDownload();
}
render() {
switch (this.state.status) {
case 'ready':
return <div className="download-page">
<Arrow dir="down" name={this.state.name} size={this.state.size} />
<button onClick={this.downloadFile.bind(this)}>Download</button>
<span>Progress: {formatProgress(this.state.progress)}</span>
</div>;
case 'downloading':
return <div className="download-page">
<Arrow dir="down" name={this.state.name} size={this.state.size} animated />
<span>Progress: {formatProgress(this.state.progress)}</span>
</div>;
case 'done':
return <div className="download-page">
<Arrow dir="down" name={this.state.name} size={this.state.size} />
<span>Progress: {formatProgress(this.state.progress)}</span>
</div>;
}
}
}

@ -1,24 +1,43 @@
import React from 'react'; import React from 'react';
import classnames from 'classnames';
export default class DropZone extends React.Component { export default class DropZone extends React.Component {
dragOver(e) { constructor() {
this.state = { focus: false };
}
onDragEnter() {
this.setState({ focus: true });
}
onDragLeave() {
this.setState({ focus: false });
}
onDragOver(e) {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
} }
drop(e) { onDrop(e) {
e.preventDefault(); e.preventDefault();
var file = e.dataTransfer.files[0]; this.setState({ focus: false });
this.props.onDrop(file);
let file = e.dataTransfer.files[0];
if (this.props.onDrop) this.props.onDrop(file);
} }
render() { render() {
return <div className="drop-zone" let classes = classnames('drop-zone', {
onDragOver={this.dragOver.bind(this)} 'drop-zone-focus': this.state.focus
onDrop={this.drop.bind(this)}> });
Drop a file here.
</div>; return <div className={classes}
onDragEnter={this.onDragEnter.bind(this)}
onDragLeave={this.onDragLeave.bind(this)}
onDragOver={this.onDragOver.bind(this)}
onDrop={this.onDrop.bind(this)} />;
} }
} }

@ -0,0 +1,9 @@
import React from 'react';
export default class NotFoundPage extends React.Component {
render() {
return <h1>Not Found</h1>;
}
}

@ -2,9 +2,19 @@ import React from 'react';
export default class Tempalink extends React.Component { export default class Tempalink extends React.Component {
onClick() {
this.refs.input.getDOMNode().setSelectionRange(0, 9999);
}
render() { render() {
var url = window.location.origin + '/d/' + this.props.token; var url = window.location.origin + '/d/' + this.props.token;
return <a href={url} className="tempalink">{url}</a>; return <input
className="tempalink"
onClick={this.onClick.bind(this)}
readOnly
ref="input"
type="text"
value={url} />;
} }
} }

@ -0,0 +1,73 @@
import Arrow from './Arrow';
import DropZone from './DropZone';
import React from 'react';
import Tempalink from './Tempalink';
import UploadActions from '../actions/UploadActions';
import UploadStore from '../stores/UploadStore';
import socket from '../socket';
export default class UploadPage extends React.Component {
constructor() {
this.state = UploadStore.getState();
this._onChange = () => {
this.setState(UploadStore.getState());
};
this._onDownload = (peerID) => {
UploadActions.sendToDownloader(peerID);
};
}
componentDidMount() {
UploadStore.listen(this._onChange);
socket.on('download', this._onDownload);
}
componentDidUnmount() {
UploadStore.unlisten(this._onChange);
socket.removeListener('download', this._onDownload);
}
uploadFile(file) {
UploadActions.uploadFile(file);
}
render() {
switch (this.state.status) {
case 'ready':
return <div className="upload-page">
<DropZone onDrop={this.uploadFile.bind(this)} />
<Arrow dir="up" />
<h1>WebDrop</h1>
<p>The easiest way to send someone a file.</p>
<p>Drag the file into this window to get started.</p>
</div>;
case 'processing':
return <div className="upload-page">
<Arrow dir="up" animated />
<h1>WebDrop</h1>
<p>Processing...</p>
</div>;
case 'uploading':
return <div className="upload-page">
<Arrow dir="up" animated {...this.state.file} />
<Tempalink token={this.state.token} />
<p>Send someone this link to download.</p>
<p>This link will work as long as this page is open.</p>
<div className="data">
<div className="datum"><strong>In Progress:</strong> {this.state.inProgress}</div>
<div className="datum"><strong>Completed:</strong> {this.state.completed}</div>
</div>
</div>;
}
}
}

@ -0,0 +1,37 @@
import Arrow from './Arrow';
import React from 'react';
import UploadActions from '../actions/UploadActions';
export default class UploadPage extends React.Component {
uploadFile(file) {
UploadActions.uploadFile(file);
}
render() {
switch (this.props.status) {
case 'ready':
return <div>
<DropZone onDrop={this.uploadFile.bind(this)} />
<Arrow dir="up" />
</div>;
break;
case 'processing':
return <div>
<Arrow dir="up" animated />
<FileDescription file={this.props.file} />
</div>;
break;
case 'uploading':
return <div>
<Arrow dir="up" animated />
<FileDescription file={this.props.file} />
<Temaplink token={this.props.token} />
</div>;
break;
}
}
}

@ -1,12 +1,11 @@
import App from './components/App';
import React from 'react'; import React from 'react';
import DownloadActions from './actions/DownloadActions'; import ReactRouter from 'react-router';
import routes from './routes';
import alt from './alt';
if (window.WebDrop) DownloadActions.setDownloadInfo({ let bootstrap = document.documentElement.getAttribute('data-bootstrap');
token: window.WebDrop.token, alt.bootstrap(bootstrap);
name: window.WebDrop.metadata.name,
size: window.WebDrop.metadata.size,
type: window.WebDrop.metadata.type
})
React.render(<App />, document.getElementById('app')); ReactRouter.run(routes, ReactRouter.HistoryLocation, function (Handler) {
React.render(<Handler data={bootstrap} />, document);
});

@ -0,0 +1,16 @@
import uuid from 'node-uuid';
let id = uuid.v4();
if (typeof window === 'undefined') {
var peer = { id: id };
} else {
let Peer = require('peerjs');
var peer = new Peer(id, {
host: window.location.hostname,
port: window.location.port,
path: '/peer'
});
}
export default peer;

@ -0,0 +1,15 @@
import React from 'react';
import { Route, DefaultRoute, NotFoundRoute, RouteHandler } from 'react-router';
import App from './components/App';
import DownloadPage from './components/DownloadPage';
import UploadPage from './components/UploadPage';
import NotFoundPage from './components/NotFoundPage';
export default (
<Route handler={App}>
<DefaultRoute handler={UploadPage} />
<Route name="download" path="d/:token" handler={DownloadPage} />
<NotFoundRoute handler={NotFoundPage} />
</Route>
);

@ -1,8 +1,8 @@
import io from 'socket.io-client'; if (typeof window === 'undefined') {
import UploadActions from './actions/UploadActions'; var socket = {};
} else {
let io = require('socket.io-client');
var socket = io.connect();
}
var socket = module.exports = io.connect(); export default socket;
socket.on('download', function (peerID) {
UploadActions.sendToDownloader(peerID);
});

@ -1,81 +1,64 @@
import DownloadActions from '../actions/DownloadActions'; import DownloadActions from '../actions/DownloadActions';
import DownloadFile from '../DownloadFile'; import DownloadFile from '../DownloadFile';
import PeerActions from '../actions/PeerActions'; import peer from '../peer';
import PeerStore from './PeerStore';
import Status from '../Status';
import alt from '../alt'; import alt from '../alt';
import socket from '../socket'; import socket from '../socket';
class DownloadStatus extends Status {
constructor() {
super([
'offline',
'ready',
'requesting',
'downloading',
'cancelled',
'done'
]);
}
}
export default alt.createStore(class DownloadStore { export default alt.createStore(class DownloadStore {
constructor() { constructor() {
this.bindActions(DownloadActions); this.bindActions(DownloadActions);
this.bindActions(PeerActions);
this.status = 'offline';
this.token = null; this.token = null;
this.file = null; this.file = null;
this.status = new DownloadStatus(); this.progress = 0;
}
onSetDownloadInfo(info) {
if (!this.status.isOffline()) return;
this.status.set('ready');
this.token = info.token; this.on('bootstrap', () => {
this.file = new DownloadFile(info.name, info.size, info.type); if (this.file && !(this.file instanceof DownloadFile))
this.file = new DownloadFile(this.file.name, this.file.size, this.file.type);
});
} }
onRequestDownload() { onRequestDownload() {
if (!this.status.isReady()) return; if (this.status !== 'ready') return;
this.status.set('requesting'); this.status = 'requesting';
socket.emit('download', { socket.emit('download', {
peerID: PeerStore.getPeerID(), peerID: peer.id,
token: this.token token: this.token
}); });
} }
onPeerConnected(conn) { onBeginDownload(conn) {
if (!this.status.isRequesting()) return; if (this.status !== 'requesting') return;
this.status.set('downloading'); this.status = 'downloading';
let chunkSize = conn.metadata.chunkSize; let chunkSize = conn.metadata.chunkSize;
let i = 0; let i = 0;
conn.on('data', (data) => { conn.on('data', (data) => {
if (!this.status.isDownloading()) return; if (this.status !== 'downloading') return;
this.file.addPacket(data); this.file.addPacket(data);
i++; i++;
if (this.file.isComplete()) { if (this.file.isComplete()) {
this.status.set('done'); this.setState({ status: 'done', progress: 1 });
this.file.download(); this.file.download();
conn.close(); conn.close();
} else if (i % chunkSize === 0) { } else {
conn.send('more'); this.setState({ progress: this.file.getProgress() });
if (i % chunkSize === 0) conn.send('more');
} }
}); });
conn.on('close', () => { conn.on('close', () => {
if (!this.status.isDownloading()) return; if (this.status !== 'downloading') return;
this.status.set('cancelled'); this.setState({ status: 'cancelled', progress: 0 });
this.file.clearPackets(); this.file.clearPackets();
}); });
} }
}) }, 'DownloadStore')

@ -1,30 +0,0 @@
import Peer from 'peerjs';
import PeerActions from '../actions/PeerActions';
import alt from '../alt';
import uuid from 'node-uuid';
let id = uuid.v4();
let peer = new Peer(id, {
host: window.location.hostname,
port: window.location.port,
path: '/peer'
});
peer.on('connection', (conn) => {
PeerActions.peerConnected(conn);
});
export default alt.createStore(class PeerStore {
static connect(peerID, metadata) {
return peer.connect(peerID, {
reliable: true,
metadata: metadata
});
}
static getPeerID() {
return id;
}
})

@ -1,72 +1,79 @@
import PeerStore from './PeerStore';
import Status from '../Status';
import UploadActions from '../actions/UploadActions'; import UploadActions from '../actions/UploadActions';
import UploadFile from '../UploadFile'; import UploadFile from '../UploadFile';
import alt from '../alt'; import alt from '../alt';
import peer from '../peer';
import socket from '../socket'; import socket from '../socket';
const chunkSize = 32; const chunkSize = 32;
class UploadStatus extends Status {
constructor() {
super([
'ready',
'processing',
'uploading'
]);
}
}
export default alt.createStore(class UploadStore { export default alt.createStore(class UploadStore {
constructor() { constructor() {
this.bindActions(UploadActions); this.bindActions(UploadActions);
this.status = new UploadStatus(); this.status = 'ready';
this.token = null; this.token = null;
this.file = null; this.file = null;
this.downloaders = [];
this.inProgress = 0;
this.completed = 0;
} }
onUploadFile(file) { onUploadFile(file) {
if (!this.status.isReady()) return; if (this.status !== 'ready') return;
this.status.set('processing'); this.status = 'processing';
this.file = new UploadFile(file); this.file = new UploadFile(file);
socket.emit('upload', { socket.emit('upload', {
name: this.file.name, name: this.file.name,
size: this.file.size, size: this.file.size,
type: this.file.type type: this.file.type
}, (token) => { }, (token) => {
this.status.set('uploading'); this.setState({
this.token = token; status: 'uploading',
this.emitChange(); token: token
});
}); });
} }
onSendToDownloader(peerID) { onSendToDownloader(peerID) {
if (!this.status.isUploading()) return; if (this.status !== 'uploading') return;
this.downloaders.push(peerID); // TODO
let conn = PeerStore.connect(peerID, { let conn = peer.connect(peerID, {
chunkSize: chunkSize reliable: true,
metadata: { chunkSize: chunkSize }
}); });
let complete = false;
let totalPackets = this.file.countPackets(); let totalPackets = this.file.countPackets();
let i = 0; let i = 0;
let sendNextChunk = () => { let sendNextChunk = () => {
if (complete) return;
for (let j = 0; i < totalPackets && j < chunkSize; i++, j++) { for (let j = 0; i < totalPackets && j < chunkSize; i++, j++) {
let packet = this.file.getPacket(i); let packet = this.file.getPacket(i);
conn.send(packet); conn.send(packet);
} }
if (i === totalPackets) complete = true;
} }
conn.on('open', () => { sendNextChunk(); }); conn.on('open', () => {
this.setState({ inProgress: this.inProgress + 1 });
sendNextChunk();
});
conn.on('data', (data) => { conn.on('data', (data) => {
if (data === 'more') sendNextChunk(); if (data === 'more') sendNextChunk();
}); });
conn.on('close', () => {
this.setState({
inProgress: this.inProgress - 1,
completed: this.completed + (complete ? 1 : 0)
});
});
} }
}) }, 'UploadStore')

@ -4,7 +4,7 @@
"description": "Peer-to-peer file transfers in your browser", "description": "Peer-to-peer file transfers in your browser",
"main": "server/index.js", "main": "server/index.js",
"scripts": { "scripts": {
"start": "node server/index.js", "start": "./node_modules/.bin/babel-node server/index.js",
"build": "./node_modules/.bin/browserify -d -o static/app.js -t babelify client/index.js", "build": "./node_modules/.bin/browserify -d -o static/app.js -t babelify client/index.js",
"watch": "./node_modules/.bin/watchify -d -o static/app.js -t babelify client/index.js" "watch": "./node_modules/.bin/watchify -d -o static/app.js -t babelify client/index.js"
}, },
@ -19,19 +19,23 @@
}, },
"homepage": "https://github.com/kern/webdrop", "homepage": "https://github.com/kern/webdrop",
"dependencies": { "dependencies": {
"babel": "^4.7.16",
"bases": "^0.2.1", "bases": "^0.2.1",
"classnames": "^1.2.0",
"ejs": "^2.3.1", "ejs": "^2.3.1",
"express": "^4.12.0", "express": "^4.12.0",
"peer": "^0.2.8", "peer": "^0.2.8",
"react": "^0.13.0",
"react-frozenhead": "^0.3.0",
"react-router": "^0.13.1",
"socket.io": "^1.3.5" "socket.io": "^1.3.5"
}, },
"devDependencies": { "devDependencies": {
"alt": "^0.14.4", "alt": "^0.14.4",
"babelify": "^5.0.4", "babelify": "^5.0.4",
"browserify": "^9.0.3",
"node-uuid": "^1.4.3", "node-uuid": "^1.4.3",
"peerjs": "^0.3.14", "peerjs": "^0.3.14",
"browserify": "^9.0.3",
"react": "^0.13.0",
"socket.io-client": "^1.3.5", "socket.io-client": "^1.3.5",
"watchify": "^2.4.0" "watchify": "^2.4.0"
}, },

@ -1,44 +1,58 @@
var DownloadFile = require('../client/DownloadFile');
var React = require('react');
var ReactRouter = require('react-router');
var Upload = require('./Upload'); var Upload = require('./Upload');
var alt = require('../client/alt');
var clientRoutes = require('../client/routes');
var express = require('express'); var express = require('express');
var router = module.exports = new express.Router(); var routes = module.exports = new express.Router();
router.get('/', function (req, res) { routes.use(express.static(__dirname + '/../static'));
res.render('index');
});
router.get('/d/:token', function (req, res, next) { routes.get('/d/:token', function (req, res, next) {
var uploader = Upload.find(req.params.token); var uploader = Upload.find(req.params.token);
if (uploader) { if (uploader) {
res.render('download', { res.locals.data = {
token: uploader.token, DownloadStore: {
meta: uploader.metadata status: 'ready',
}); token: uploader.token,
} else { file: uploader.metadata
var err = new Error('Unknown token'); }
err.status = 404; };
next(err);
} }
}); next();
router.use(express.static(__dirname + '/../static')); });
router.use(function (req, res, next) { routes.use(function (req, res, next) {
var err = new Error('Not Found'); var err = new Error('Not Found');
err.status = 404; err.status = 404;
next(err); next(err);
}); });
router.use(function (err, req, res, next) { routes.use(function (err, req, res, next) {
// TODO: Get these error pages working with isomorphic react.
var status = err.status || 500; var status = err.status || 500;
var message = err.message || ''; var message = err.message || '';
res.status(status).render('error', { res.status(status);
status: status, next();
message: message
});
routes.use(function (req, res) {
alt.bootstrap(JSON.stringify(res.locals.data || {}));
ReactRouter.run(clientRoutes, req.url, function (Handler) {
var html = React.renderToString(<Handler data={alt.takeSnapshot()} />);
alt.recycle();
res.write('<!DOCTYPE html>');
res.end(html);
}); });
}); });

File diff suppressed because one or more lines are too long

@ -1,10 +1,142 @@
#drop_zone { * { box-sizing: border-box; }
border: 1.5px solid #bbb;
-moz-border-radius: 5px; .upload-page {
-webkit-border-radius: 5px; margin: 100px auto 0;
border-radius: 5px; width: 300px;
padding: 25px; }
text-align: center;
font: 20pt 'Helvetica-Light'; .drop-zone {
color: #bbb; position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: table;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0);
}
.drop-zone-focus {
background: rgba(0, 0, 0, 0.5);
}
.drop-zone-focus:after {
color: white;
content: 'DROP TO UPLOAD';
display: table-cell;
font: 24px/40px sans-serif;
text-align: center;
text-shadow: 0 1px #333;
vertical-align: middle;
}
.arrow {
position: relative;
z-index: -1;
width: 300px;
height: 300px;
}
.arrow .arrow-border {
position: absolute;
width: 100%;
height: 100%;
border: 20px dotted #CCC;
border-radius: 100%;
-webkit-transition: -webkit-transform 1s;
-moz-transition: -moz-transform 1s;
transition: transform 1s;
}
.arrow.arrow-animated .arrow-border {
-webkit-animation: rotate 2s infinite linear;
-moz-animation: rotate 2s infinite linear;
animation: rotate 2s infinite linear;
}
.arrow .arrow-image {
position: absolute;
top: 60px;
left: 80px;
right: 80px;
bottom: 100px;
background: red;
}
.arrow .arrow-name {
bottom: 60px;
font: bold 18px/20px sans-serif;
left: 60px;
overflow: hidden;
position: absolute;
right: 60px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
color: #333;
}
.arrow .arrow-size {
bottom: 40px;
font: italic 12px/20px sans-serif;
left: 60px;
position: absolute;
right: 60px;
text-align: center;
color: #777;
}
.tempalink {
border-radius: 3px;
border: 1px solid #EEE;
color: #333;
font: 18px/1 sans-serif;
height: 40px;
margin: 60px 0 0;
padding: 10px;
text-align: center;
width: 100%;
}
h1 {
color: #333;
font: bold 32px/40px sans-serif;
text-align: center;
margin: 60px 0 10px;
}
p {
color: #777;
font: 14px/20px sans-serif;
text-align: center;
margin: 10px 0;
}
.data {
color: #777;
font: 14px/20px sans-serif;
text-align: center;
overflow: hidden;
}
.data > .datum {
float: left;
width: 50%;
}
@-webkit-keyframes rotate {
from { -webkit-transform: rotate(0deg); }
to { -webkit-transform: rotate(360deg); }
}
@-moz-keyframes rotate {
from { -moz-transform: rotate(0deg); }
to { -moz-transform: rotate(360deg); }
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
} }

Loading…
Cancel
Save