Preliminary map display, tweaks and testing necessary

pull/43/head
Piero Toffanin 2016-11-09 16:13:43 -05:00
rodzic 1e13d4e874
commit 37b54e634d
27 zmienionych plików z 1067 dodań i 55 usunięć

Wyświetl plik

@ -1,7 +1,12 @@
import os
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpResponse
from wsgiref.util import FileWrapper
from rest_framework import status, serializers, viewsets, filters, exceptions, permissions, parsers from rest_framework import status, serializers, viewsets, filters, exceptions, permissions, parsers
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.decorators import detail_route from rest_framework.decorators import detail_route
from rest_framework.views import APIView
from app import models, scheduler from app import models, scheduler
from nodeodm.models import ProcessingNode from nodeodm.models import ProcessingNode
@ -23,6 +28,21 @@ class TaskSerializer(serializers.ModelSerializer):
model = models.Task model = models.Task
exclude = ('processing_lock', 'console_output', ) exclude = ('processing_lock', 'console_output', )
def get_and_check_project(request, project_pk, perms=('view_project',)):
'''
Retrieves a project and raises an exeption if the current user
has no access to it.
'''
try:
project = models.Project.objects.get(pk=project_pk)
for perm in perms:
if not request.user.has_perm(perm, project): raise ObjectDoesNotExist()
except ObjectDoesNotExist:
raise exceptions.NotFound()
return project
class TaskViewSet(viewsets.ViewSet): class TaskViewSet(viewsets.ViewSet):
""" """
A task represents a set of images and other input to be sent to a processing node. A task represents a set of images and other input to be sent to a processing node.
@ -36,22 +56,8 @@ class TaskViewSet(viewsets.ViewSet):
parser_classes = (parsers.MultiPartParser, parsers.JSONParser, parsers.FormParser, ) parser_classes = (parsers.MultiPartParser, parsers.JSONParser, parsers.FormParser, )
ordering_fields = '__all__' ordering_fields = '__all__'
@staticmethod
def get_and_check_project(request, project_pk, perms = ('view_project', )):
'''
Retrieves a project and raises an exeption if the current user
has no access to it.
'''
try:
project = models.Project.objects.get(pk=project_pk)
for perm in perms:
if not request.user.has_perm(perm, project): raise ObjectDoesNotExist()
except ObjectDoesNotExist:
raise exceptions.NotFound()
return project
def set_pending_action(self, pending_action, request, pk=None, project_pk=None, perms=('change_project', )): def set_pending_action(self, pending_action, request, pk=None, project_pk=None, perms=('change_project', )):
self.get_and_check_project(request, project_pk, perms) get_and_check_project(request, project_pk, perms)
try: try:
task = self.queryset.get(pk=pk, project=project_pk) task = self.queryset.get(pk=pk, project=project_pk)
except ObjectDoesNotExist: except ObjectDoesNotExist:
@ -85,7 +91,7 @@ class TaskViewSet(viewsets.ViewSet):
An optional "line" query param can be passed to retrieve An optional "line" query param can be passed to retrieve
only the output starting from a certain line number. only the output starting from a certain line number.
""" """
self.get_and_check_project(request, project_pk) get_and_check_project(request, project_pk)
try: try:
task = self.queryset.get(pk=pk, project=project_pk) task = self.queryset.get(pk=pk, project=project_pk)
except ObjectDoesNotExist: except ObjectDoesNotExist:
@ -96,14 +102,14 @@ class TaskViewSet(viewsets.ViewSet):
return Response('\n'.join(output.split('\n')[line_num:])) return Response('\n'.join(output.split('\n')[line_num:]))
def list(self, request, project_pk=None): def list(self, request, project_pk=None):
self.get_and_check_project(request, project_pk) get_and_check_project(request, project_pk)
tasks = self.queryset.filter(project=project_pk) tasks = self.queryset.filter(project=project_pk)
tasks = filters.OrderingFilter().filter_queryset(self.request, tasks, self) tasks = filters.OrderingFilter().filter_queryset(self.request, tasks, self)
serializer = TaskSerializer(tasks, many=True) serializer = TaskSerializer(tasks, many=True)
return Response(serializer.data) return Response(serializer.data)
def retrieve(self, request, pk=None, project_pk=None): def retrieve(self, request, pk=None, project_pk=None):
self.get_and_check_project(request, project_pk) get_and_check_project(request, project_pk)
try: try:
task = self.queryset.get(pk=pk, project=project_pk) task = self.queryset.get(pk=pk, project=project_pk)
except ObjectDoesNotExist: except ObjectDoesNotExist:
@ -113,7 +119,7 @@ class TaskViewSet(viewsets.ViewSet):
return Response(serializer.data) return Response(serializer.data)
def create(self, request, project_pk=None): def create(self, request, project_pk=None):
project = self.get_and_check_project(request, project_pk, ('change_project', )) project = get_and_check_project(request, project_pk, ('change_project', ))
# MultiValueDict in, flat array of files out # MultiValueDict in, flat array of files out
files = [file for filesList in map( files = [file for filesList in map(
@ -128,7 +134,7 @@ class TaskViewSet(viewsets.ViewSet):
raise exceptions.ValidationError(detail="Cannot create task, input provided is not valid.") raise exceptions.ValidationError(detail="Cannot create task, input provided is not valid.")
def update(self, request, pk=None, project_pk=None, partial=False): def update(self, request, pk=None, project_pk=None, partial=False):
self.get_and_check_project(request, project_pk, ('change_project', )) get_and_check_project(request, project_pk, ('change_project', ))
try: try:
task = self.queryset.get(pk=pk, project=project_pk) task = self.queryset.get(pk=pk, project=project_pk)
except ObjectDoesNotExist: except ObjectDoesNotExist:
@ -145,4 +151,51 @@ class TaskViewSet(viewsets.ViewSet):
def partial_update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs):
kwargs['partial'] = True kwargs['partial'] = True
return self.update(request, *args, **kwargs) return self.update(request, *args, **kwargs)
class TaskTilesBase(APIView):
queryset = models.Task.objects.all()
def get_and_check_task(self, request, pk, project_pk):
get_and_check_project(request, project_pk)
try:
task = self.queryset.get(pk=pk, project=project_pk)
except ObjectDoesNotExist:
raise exceptions.NotFound()
return task
class TaskTiles(TaskTilesBase):
def get(self, request, pk=None, project_pk=None, z="", x="", y=""):
"""
Returns a prerendered orthophoto tile for a task
"""
task = self.get_and_check_task(request, pk, project_pk)
tile_path = task.get_tile_path(z, x, y)
if os.path.isfile(tile_path):
tile = open(tile_path, "rb")
return HttpResponse(FileWrapper(tile), content_type="image/png")
else:
raise exceptions.NotFound()
class TaskTilesJson(TaskTilesBase):
def get(self, request, pk=None, project_pk=None):
"""
Returns a tiles.json file for consumption by a client
"""
task = self.get_and_check_task(request, pk, project_pk)
json = {
'tilejson': '2.1.0',
'name': task.name,
'version': '1.0.0',
'scheme': 'tms',
'tiles': [
'/api/projects/{}/tasks/{}/tiles/{{z}}/{{x}}/{{y}}.png'.format(task.project.id, task.id)
],
'minzoom': 0,
'maxzoom': 22,
'bounds': task.orthophoto.extent
}
return Response(json)

Wyświetl plik

@ -1,6 +1,6 @@
from django.conf.urls import url, include from django.conf.urls import url, include
from .projects import ProjectViewSet from .projects import ProjectViewSet
from .tasks import TaskViewSet from .tasks import TaskViewSet, TaskTiles, TaskTilesJson
from .processingnodes import ProcessingNodeViewSet from .processingnodes import ProcessingNodeViewSet
from rest_framework_nested import routers from rest_framework_nested import routers
@ -15,5 +15,8 @@ urlpatterns = [
url(r'^', include(router.urls)), url(r'^', include(router.urls)),
url(r'^', include(tasks_router.urls)), url(r'^', include(tasks_router.urls)),
url(r'projects/(?P<project_pk>[^/.]+)/tasks/(?P<pk>[^/.]+)/tiles/(?P<z>[\d]+)/(?P<x>[\d]+)/(?P<y>[\d]+)\.png$', TaskTiles.as_view()),
url(r'projects/(?P<project_pk>[^/.]+)/tasks/(?P<pk>[^/.]+)/tiles\.json$', TaskTilesJson.as_view()),
url(r'^auth/', include('rest_framework.urls')), url(r'^auth/', include('rest_framework.urls')),
] ]

Wyświetl plik

@ -17,13 +17,18 @@ from nodeodm.exceptions import ProcessingException
from django.db import transaction from django.db import transaction
from nodeodm import status_codes from nodeodm import status_codes
from webodm import settings from webodm import settings
import logging import logging, zipfile, shutil
logger = logging.getLogger('app.logger') logger = logging.getLogger('app.logger')
def task_directory_path(taskId, projectId):
return 'project/{0}/task/{1}/'.format(projectId, taskId)
def assets_directory_path(taskId, projectId, filename): def assets_directory_path(taskId, projectId, filename):
# files will be uploaded to MEDIA_ROOT/project/<id>/task/<id>/<filename> # files will be uploaded to MEDIA_ROOT/project/<id>/task/<id>/<filename>
return 'project/{0}/task/{1}/{2}'.format(projectId, taskId, filename) return '{0}{1}'.format(task_directory_path(taskId, projectId), filename)
class Project(models.Model): class Project(models.Model):
@ -127,6 +132,13 @@ class Task(models.Model):
self.full_clean() self.full_clean()
super(Task, self).save(*args, **kwargs) super(Task, self).save(*args, **kwargs)
def media_path(self, path):
"""
Get a path relative to the media directory of this task
"""
return os.path.join(settings.MEDIA_ROOT,
assets_directory_path(self.id, self.project.id, path))
@staticmethod @staticmethod
def create_from_images(images, project): def create_from_images(images, project):
''' '''
@ -265,8 +277,7 @@ class Task(models.Model):
if self.status == status_codes.COMPLETED: if self.status == status_codes.COMPLETED:
try: try:
orthophoto_stream = self.processing_node.download_task_asset(self.uuid, "orthophoto.tif") orthophoto_stream = self.processing_node.download_task_asset(self.uuid, "orthophoto.tif")
orthophoto_path = os.path.join(settings.MEDIA_ROOT, orthophoto_path = self.media_path("orthophoto.tif")
assets_directory_path(self.id, self.project.id, "orthophoto.tif"))
# Save to disk original photo # Save to disk original photo
with open(orthophoto_path, 'wb') as fd: with open(orthophoto_path, 'wb') as fd:
@ -276,12 +287,19 @@ class Task(models.Model):
# Add to database another copy # Add to database another copy
self.orthophoto = GDALRaster(orthophoto_path, write=True) self.orthophoto = GDALRaster(orthophoto_path, write=True)
# TODO: # Download tiles
# 1. Download tiles tiles_zip_stream = self.processing_node.download_task_asset(self.uuid, "orthophoto_tiles.zip")
# 2. Extract from zip tiles_zip_path = self.media_path("orthophoto_tiles.zip")
# 3. Add onDelete method to cleanup stuff with open(tiles_zip_path, 'wb') as fd:
# 4. Add tile map API for chunk in tiles_zip_stream.iter_content(4096):
# 5. Create map view fd.write(chunk)
# Extract from zip
with zipfile.ZipFile(tiles_zip_path, "r") as zip_h:
zip_h.extractall(self.media_path(""))
# Delete zip archive
os.remove(tiles_zip_path)
self.save() self.save()
except ProcessingException as e: except ProcessingException as e:
@ -295,6 +313,21 @@ class Task(models.Model):
except ProcessingException as e: except ProcessingException as e:
self.set_failure(str(e)) self.set_failure(str(e))
def get_tile_path(self, z, x, y):
return self.media_path(os.path.join("orthophoto_tiles", z, x, "{}.png".format(y)))
def delete(self, using=None, keep_parents=False):
directory_to_delete = os.path.join(settings.MEDIA_ROOT,
task_directory_path(self.id, self.project.id))
super(Task, self).delete(using, keep_parents)
# Remove files related to this task
try:
shutil.rmtree(directory_to_delete)
except FileNotFoundError as e:
logger.warn(e)
def set_failure(self, error_message): def set_failure(self, error_message):
logger.error("{} ERROR: {}".format(self, error_message)) logger.error("{} ERROR: {}".format(self, error_message))

Wyświetl plik

@ -0,0 +1,43 @@
import React from 'react';
import './css/MapView.scss';
import Map from './components/Map';
class MapView extends React.Component {
static defaultProps = {
task: "",
project: ""
}
static propTypes() {
return {
// task id to display, if empty display all for a particular project
task: React.PropTypes.string,
// project id to display, if empty display all
project: React.PropTypes.string
};
}
constructor(props){
super(props);
this.tileJSON = "";
if (this.props.project === ""){
this.tileJSON = "/api/projects/tiles.json"
throw new Error("TODO: not built yet");
}else if (this.props.task === ""){
this.tileJSON = `/api/projects/${this.props.project}/tasks/tiles.json`;
throw new Error("TODO: not built yet");
}else{
this.tileJSON = `/api/projects/${this.props.project}/tasks/${this.props.task}/tiles.json`;
}
}
render(){
return (<div className="map-view">
<Map tileJSON={this.tileJSON} showBackground={true}/>
</div>);
}
}
export default MapView;

Wyświetl plik

@ -16,7 +16,7 @@ class ErrorMessage extends React.Component {
} }
render(){ render(){
if (this.state.error !== ""){ if (this.state.error){
return ( return (
<div className="alert alert-warning alert-dismissible"> <div className="alert alert-warning alert-dismissible">
<button type="button" className="close" aria-label="Close" onClick={this.close}><span aria-hidden="true">&times;</span></button> <button type="button" className="close" aria-label="Close" onClick={this.close}><span aria-hidden="true">&times;</span></button>

Wyświetl plik

@ -0,0 +1,151 @@
import React from 'react';
import '../css/Map.scss';
import '../vendor/leaflet/leaflet.css';
import Leaflet from '../vendor/leaflet/leaflet';
import $ from 'jquery';
import ErrorMessage from './ErrorMessage';
class Map extends React.Component {
static defaultProps = {
bounds: [[-85.05112877980659, -180], [85.0511287798066, 180]],
maxzoom: 18,
minzoom: 0,
scheme: 'tms',
showBackground: false,
showControls: true,
url: "",
error: ""
}
static propTypes() {
return {
bounds: React.PropTypes.array,
maxzoom: React.PropTypes.integer,
minzoom: React.PropTypes.integer,
scheme: React.PropTypes.string, // either 'tms' or 'xyz'
showBackground: React.PropTypes.boolean,
showControls: React.PropTypes.boolean,
tileJSON: React.PropTypes.string,
url: React.PropTypes.string
};
}
constructor(props) {
super(props);
this.state = {
bounds: this.props.bounds,
maxzoom: this.props.maxzoom,
minzoom: this.props.minzoom,
opacity: 100
};
this.updateOpacity = this.updateOpacity.bind(this);
}
componentDidMount() {
const { showBackground, tileJSON } = this.props;
const { bounds, maxzoom, minzoom, scheme, url } = this.state;
// TODO: https, other basemaps selection
let backgroundTileLayer = Leaflet.tileLayer('http://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
}
);
if (tileJSON != null) {
this.tileJsonRequest = $.getJSON(tileJSON)
.done(info => {
const bounds = [info.bounds.slice(0, 2).reverse(), info.bounds.slice(2, 4).reverse()];
this.setState({
bounds,
maxzoom: info.maxzoom,
minzoom: info.minzoom,
scheme: info.scheme || 'xyz',
url: info.tiles[0]
});
})
.fail((_, __, err) => this.setState({error: err.message}));
}
const layers = [];
if (showBackground) {
layers.push(backgroundTileLayer);
}
if (url != null) {
this.imageryLayer = Leaflet.tileLayer(url, {
minZoom: minzoom,
maxZoom: maxzoom,
tms: scheme === 'tms'
});
layers.push(this.imageryLayer);
}
this.leaflet = Leaflet.map(this.container, {
scrollWheelZoom: true,
layers
});
this.leaflet.fitBounds(bounds);
Leaflet.control.scale({
maxWidth: 250,
}).addTo(this.leaflet);
this.leaflet.attributionControl.setPrefix("");
}
componentDidUpdate() {
const { bounds, maxzoom, minzoom, opacity, scheme, url } = this.state;
if (!this.imageryLayer) {
this.imageryLayer = Leaflet.tileLayer(url, {
minZoom: minzoom,
maxZoom: maxzoom,
tms: scheme === 'tms'
}).addTo(this.leaflet);
this.leaflet.fitBounds(bounds);
}
this.imageryLayer.setOpacity(opacity / 100);
}
componentWillUnmount() {
this.leaflet.remove();
if (this.tileJsonRequest) this.tileJsonRequest.abort();
}
updateOpacity(evt) {
this.setState({
opacity: evt.target.value,
});
}
render() {
const { opacity, error } = this.state;
return (
<div style={{height: "100%"}}>
<ErrorMessage message={error} />
<div
style={{height: "100%"}}
ref={(domNode) => (this.container = domNode)}
/>
{this.props.showControls ?
<div className="row">
<div className="col-md-3">
Layer opacity: <input type="range" step="1" value={opacity} onChange={this.updateOpacity} />
</div>
</div>
: ""}
</div>
);
}
}
export default Map;

Wyświetl plik

@ -23,6 +23,8 @@ class ProjectListItem extends React.Component {
this.closeUploadError = this.closeUploadError.bind(this); this.closeUploadError = this.closeUploadError.bind(this);
this.cancelUpload = this.cancelUpload.bind(this); this.cancelUpload = this.cancelUpload.bind(this);
this.handleTaskSaved = this.handleTaskSaved.bind(this); this.handleTaskSaved = this.handleTaskSaved.bind(this);
this.viewMap = this.viewMap.bind(this);
} }
componentWillUnmount(){ componentWillUnmount(){
@ -190,6 +192,10 @@ class ProjectListItem extends React.Component {
} }
} }
viewMap(){
location.href = `/map/?project=${this.props.data.id}`;
}
render() { render() {
return ( return (
<li className="project-list-item list-group-item" <li className="project-list-item list-group-item"
@ -212,8 +218,8 @@ class ProjectListItem extends React.Component {
Cancel Upload Cancel Upload
</button> </button>
<button type="button" className="btn btn-default btn-sm"> <button type="button" className="btn btn-default btn-sm" onClick={this.viewMap}>
<i className="fa fa-globe"></i> Map View <i className="fa fa-globe"></i> View Map
</button> </button>
<button type="button" className="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown"> <button type="button" className="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
<span className="caret"></span> <span className="caret"></span>

Wyświetl plik

@ -13,6 +13,7 @@ class TaskList extends React.Component {
}; };
this.refresh = this.refresh.bind(this); this.refresh = this.refresh.bind(this);
this.retry = this.retry.bind(this);
this.deleteTask = this.deleteTask.bind(this); this.deleteTask = this.deleteTask.bind(this);
} }
@ -24,6 +25,11 @@ class TaskList extends React.Component {
this.loadTaskList(); this.loadTaskList();
} }
retry(){
this.setState({error: "", loading: true});
this.refresh();
}
loadTaskList(){ loadTaskList(){
this.taskListRequest = this.taskListRequest =
$.getJSON(this.props.source, json => { $.getJSON(this.props.source, json => {
@ -33,7 +39,7 @@ class TaskList extends React.Component {
}) })
.fail((jqXHR, textStatus, errorThrown) => { .fail((jqXHR, textStatus, errorThrown) => {
this.setState({ this.setState({
error: `Could not load projects list: ${textStatus}`, error: `Could not load task list: ${textStatus}`,
}); });
}) })
.always(() => { .always(() => {
@ -58,7 +64,7 @@ class TaskList extends React.Component {
if (this.state.loading){ if (this.state.loading){
message = (<span>Loading... <i className="fa fa-refresh fa-spin fa-fw"></i></span>); message = (<span>Loading... <i className="fa fa-refresh fa-spin fa-fw"></i></span>);
}else if (this.state.error){ }else if (this.state.error){
message = (<span>Could not get tasks: {this.state.error}. <a href="javascript:void(0);" onClick={this.refresh}>Try again</a></span>); message = (<span>Error: {this.state.error}. <a href="javascript:void(0);" onClick={this.retry}>Try again</a></span>);
}else if (this.state.tasks.length === 0){ }else if (this.state.tasks.length === 0){
message = (<span>This project has no tasks. Create one by uploading some images!</span>); message = (<span>This project has no tasks. Create one by uploading some images!</span>);
} }

Wyświetl plik

@ -170,12 +170,13 @@ class TaskListItem extends React.Component {
} }
render() { render() {
let name = this.state.task.name !== null ? this.state.task.name : `Task #${this.state.task.id}`; const task = this.state.task;
const name = task.name !== null ? task.name : `Task #${task.id}`;
let status = statusCodes.description(this.state.task.status); let status = statusCodes.description(task.status);
if (status === "") status = "Uploading images"; if (status === "") status = "Uploading images";
if (!this.state.task.processing_node) status = ""; if (!task.processing_node) status = "";
if (this.state.task.pending_action !== null) status = pendingActions.description(this.state.task.pending_action); if (task.pending_action !== null) status = pendingActions.description(task.pending_action);
let expanded = ""; let expanded = "";
if (this.state.expanded){ if (this.state.expanded){
@ -186,13 +187,19 @@ class TaskListItem extends React.Component {
}); });
}; };
if ([statusCodes.QUEUED, statusCodes.RUNNING, null].indexOf(this.state.task.status) !== -1 && if (task.status === statusCodes.COMPLETED){
this.state.task.processing_node){ addActionButton(" View Orthophoto", "btn-primary", "fa fa-globe", () => {
location.href = `/map/?project=${task.project}&task=${task.id}`;
});
}
if ([statusCodes.QUEUED, statusCodes.RUNNING, null].indexOf(task.status) !== -1 &&
task.processing_node){
addActionButton("Cancel", "btn-primary", "glyphicon glyphicon-remove-circle", this.genActionApiCall("cancel")); addActionButton("Cancel", "btn-primary", "glyphicon glyphicon-remove-circle", this.genActionApiCall("cancel"));
} }
if ([statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(this.state.task.status) !== -1 && if ([statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(task.status) !== -1 &&
this.state.task.processing_node){ task.processing_node){
addActionButton("Restart", "btn-primary", "glyphicon glyphicon-remove-circle", this.genActionApiCall("restart", { addActionButton("Restart", "btn-primary", "glyphicon glyphicon-remove-circle", this.genActionApiCall("restart", {
success: () => { success: () => {
if (this.console) this.console.clear(); if (this.console) this.console.clear();
@ -214,7 +221,7 @@ class TaskListItem extends React.Component {
actionButtons = (<div className="action-buttons"> actionButtons = (<div className="action-buttons">
{actionButtons.map(button => { {actionButtons.map(button => {
return ( return (
<button key={button.label} type="button" className={"btn btn-sm " + button.className} onClick={button.onClick} disabled={this.state.actionButtonsDisabled || !!this.state.task.pending_action}> <button key={button.label} type="button" className={"btn btn-sm " + button.className} onClick={button.onClick} disabled={this.state.actionButtonsDisabled || !!task.pending_action}>
<i className={button.icon}></i> <i className={button.icon}></i>
{button.label} {button.label}
</button> </button>
@ -227,13 +234,13 @@ class TaskListItem extends React.Component {
<div className="row"> <div className="row">
<div className="col-md-4 no-padding"> <div className="col-md-4 no-padding">
<div className="labels"> <div className="labels">
<strong>Created on: </strong> {(new Date(this.state.task.created_at)).toLocaleString()}<br/> <strong>Created on: </strong> {(new Date(task.created_at)).toLocaleString()}<br/>
</div> </div>
<div className="labels"> <div className="labels">
<strong>Status: </strong> {status}<br/> <strong>Status: </strong> {status}<br/>
</div> </div>
<div className="labels"> <div className="labels">
<strong>Options: </strong> {this.optionsToList(this.state.task.options)}<br/> <strong>Options: </strong> {this.optionsToList(task.options)}<br/>
</div> </div>
{/* TODO: List of images? */} {/* TODO: List of images? */}
</div> </div>
@ -260,15 +267,15 @@ class TaskListItem extends React.Component {
} }
let statusLabel = ""; let statusLabel = "";
let statusIcon = statusCodes.icon(this.state.task.status); let statusIcon = statusCodes.icon(task.status);
if (this.state.task.last_error){ if (task.last_error){
statusLabel = getStatusLabel(this.state.task.last_error, "error"); statusLabel = getStatusLabel(task.last_error, "error");
}else if (!this.state.task.processing_node){ }else if (!task.processing_node){
statusLabel = getStatusLabel("Processing node not set"); statusLabel = getStatusLabel("Processing node not set");
statusIcon = "fa fa-hourglass-3"; statusIcon = "fa fa-hourglass-3";
}else{ }else{
statusLabel = getStatusLabel(status, this.state.task.status == 40 ? "done" : ""); statusLabel = getStatusLabel(status, task.status == 40 ? "done" : "");
} }
return ( return (
@ -278,7 +285,7 @@ class TaskListItem extends React.Component {
<i onClick={this.toggleExpanded} className={"clickable fa " + (this.state.expanded ? "fa-minus-square-o" : " fa-plus-square-o")}></i> <a href="javascript:void(0);" onClick={this.toggleExpanded}>{name}</a> <i onClick={this.toggleExpanded} className={"clickable fa " + (this.state.expanded ? "fa-minus-square-o" : " fa-plus-square-o")}></i> <a href="javascript:void(0);" onClick={this.toggleExpanded}>{name}</a>
</div> </div>
<div className="col-md-1 details"> <div className="col-md-1 details">
<i className="fa fa-image"></i> {this.state.task.images_count} <i className="fa fa-image"></i> {task.images_count}
</div> </div>
<div className="col-md-2 details"> <div className="col-md-2 details">
<i className="fa fa-clock-o"></i> {this.hoursMinutesSecs(this.state.time)} <i className="fa fa-clock-o"></i> {this.hoursMinutesSecs(this.state.time)}

Wyświetl plik

@ -0,0 +1,2 @@
.map{
}

Wyświetl plik

@ -0,0 +1,3 @@
.map-view{
height: 500px;
}

Wyświetl plik

@ -3,6 +3,7 @@ import './django/csrf';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import Dashboard from './Dashboard'; import Dashboard from './Dashboard';
import MapView from './MapView';
import Console from './Console'; import Console from './Console';
import $ from 'jquery'; import $ from 'jquery';
@ -10,6 +11,12 @@ $("[data-dashboard]").each(function(){
ReactDOM.render(<Dashboard/>, $(this).get(0)); ReactDOM.render(<Dashboard/>, $(this).get(0));
}); });
$("[data-mapview]").each(function(){
let props = $(this).data();
delete(props.mapview);
ReactDOM.render(<MapView {...props}/>, $(this).get(0));
});
$("[data-console]").each(function(){ $("[data-console]").each(function(){
ReactDOM.render(<Console ReactDOM.render(<Console
lang={$(this).data("console-lang")} lang={$(this).data("console-lang")}

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.2 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 696 B

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 2.5 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.4 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 618 B

Wyświetl plik

@ -0,0 +1,623 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-map-pane svg,
.leaflet-map-pane canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg,
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer {
max-width: none !important;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-drag {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-tile {
will-change: opacity;
}
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
-o-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
-o-transition: -o-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
-o-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline: 0;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-container a.leaflet-active {
outline: 2px solid orange;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a,
.leaflet-bar a:hover {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-control-zoom-out {
font-size: 20px;
}
.leaflet-touch .leaflet-control-zoom-in {
font-size: 22px;
}
.leaflet-touch .leaflet-control-zoom-out {
font-size: 24px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path {
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.7);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover {
text-decoration: underline;
}
.leaflet-container .leaflet-control-attribution,
.leaflet-container .leaflet-control-scale {
font-size: 11px;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: #fff;
background: rgba(255, 255, 255, 0.5);
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 19px;
line-height: 1.4;
}
.leaflet-popup-content p {
margin: 18px 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
-o-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
padding: 4px 4px 0 0;
border: none;
text-align: center;
width: 18px;
height: 14px;
font: 16px/14px Tahoma, Verdana, sans-serif;
color: #c3c3c3;
text-decoration: none;
font-weight: bold;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover {
color: #999;
}
.leaflet-popup-scrolled {
overflow: auto;
border-bottom: 1px solid #ddd;
border-top: 1px solid #ddd;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-popup-tip-container {
margin-top: -1px;
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-clickable {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}

File diff suppressed because one or more lines are too long

Wyświetl plik

@ -0,0 +1,15 @@
{% extends "app/logged_in_base.html" %}
{% load i18n %}
{% load render_bundle from webpack_loader %}
{% block content %}
<h3>{{title}}</h3>
<div data-mapview
{% for key, value in params %}
data-{{key}}="{{value}}"
{% endfor %}
></div>
{% render_bundle 'main' %}
{% endblock %}

Wyświetl plik

@ -179,6 +179,13 @@ class TestApi(BootTestCase):
self.assertTrue(task.last_error is None) self.assertTrue(task.last_error is None)
self.assertTrue(task.pending_action == task.PendingActions.REMOVE) self.assertTrue(task.pending_action == task.PendingActions.REMOVE)
# TODO test:
# - tiles.json requests
# - task creation via file upload
# - scheduler processing steps
# - tiles API urls (permissions, 404s)
def test_processingnodes(self): def test_processingnodes(self):
client = APIClient() client = APIClient()

Wyświetl plik

@ -84,6 +84,9 @@ class TestApp(BootTestCase):
res = c.get('/processingnode/abc/') res = c.get('/processingnode/abc/')
self.assertTrue(res.status_code == 404) self.assertTrue(res.status_code == 404)
# TODO:
# - test /map/ urls
def test_default_group(self): def test_default_group(self):
# It exists # It exists
self.assertTrue(Group.objects.filter(name='Default').count() == 1) self.assertTrue(Group.objects.filter(name='Default').count() == 1)

Wyświetl plik

@ -6,6 +6,7 @@ from webodm import settings
urlpatterns = [ urlpatterns = [
url(r'^$', views.index, name='index'), url(r'^$', views.index, name='index'),
url(r'^dashboard/$', views.dashboard, name='dashboard'), url(r'^dashboard/$', views.dashboard, name='dashboard'),
url(r'^map/$', views.map, name='map'),
url(r'^processingnode/([\d]+)/$', views.processing_node, name='processing_node'), url(r'^processingnode/([\d]+)/$', views.processing_node, name='processing_node'),
url(r'^api/', include("app.api.urls")), url(r'^api/', include("app.api.urls")),

Wyświetl plik

@ -1,3 +1,4 @@
from django.http import Http404
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.http import HttpResponse from django.http import HttpResponse
from nodeodm.models import ProcessingNode from nodeodm.models import ProcessingNode
@ -23,6 +24,34 @@ def dashboard(request):
'no_processingnodes': no_processingnodes, 'no_processingnodes': no_processingnodes,
'no_tasks': no_tasks}) 'no_tasks': no_tasks})
@login_required
def map(request):
project_id = request.GET.get('project', '')
task_id = request.GET.get('task', '')
title = _("Map")
if project_id != '':
project = get_object_or_404(Project, pk=int(project_id))
if not request.user.has_perm('projects.view_project', project):
raise Http404()
if task_id != '':
task = get_object_or_404(Task, pk=int(task_id), project=project)
title = task.name
else:
title = project.name
return render(request, 'app/map.html', {
'title': title,
'params': {
'task': request.GET.get('task', ''),
'project': request.GET.get('project', '')
}.items()
})
@login_required @login_required
def processing_node(request, processing_node_id): def processing_node(request, processing_node_id):
pn = get_object_or_404(ProcessingNode, pk=processing_node_id) pn = get_object_or_404(ProcessingNode, pk=processing_node_id)

Wyświetl plik

@ -22,6 +22,8 @@
"dependencies": { "dependencies": {
"babel-core": "^6.17.0", "babel-core": "^6.17.0",
"babel-loader": "^6.2.5", "babel-loader": "^6.2.5",
"babel-plugin-syntax-class-properties": "^6.13.0",
"babel-plugin-transform-class-properties": "^6.18.0",
"babel-preset-es2015": "^6.16.0", "babel-preset-es2015": "^6.16.0",
"babel-preset-react": "^6.16.0", "babel-preset-react": "^6.16.0",
"css-loader": "^0.25.0", "css-loader": "^0.25.0",
@ -34,6 +36,7 @@
"react-hot-loader": "^3.0.0-beta.5", "react-hot-loader": "^3.0.0-beta.5",
"sass-loader": "^4.0.2", "sass-loader": "^4.0.2",
"style-loader": "^0.13.1", "style-loader": "^0.13.1",
"url-loader": "^0.5.7",
"webpack": "^1.13.2", "webpack": "^1.13.2",
"webpack-bundle-tracker": "0.0.93", "webpack-bundle-tracker": "0.0.93",
"webpack-dev-server": "^1.16.2", "webpack-dev-server": "^1.16.2",

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 22 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.1 MiB

Wyświetl plik

@ -36,13 +36,21 @@ module.exports = {
exclude: /(node_modules|bower_components)/, exclude: /(node_modules|bower_components)/,
loader: 'babel-loader', loader: 'babel-loader',
query: { query: {
// plugins: ['react-hot-loader/babel'], "plugins": [
'syntax-class-properties',
'transform-class-properties'
// 'react-hot-loader/babel'
],
presets: ['es2015', 'react'] presets: ['es2015', 'react']
} }
}, },
{ {
test: /\.s?css$/, test: /\.s?css$/,
loader: ExtractTextPlugin.extract('css!sass') loader: ExtractTextPlugin.extract('css!sass')
},
{
test: /\.(png|jpg|jpeg|svg)/,
loader: "url-loader?limit=100000"
} }
] ]
}, },