diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..bb571dd3 --- /dev/null +++ b/.babelrc @@ -0,0 +1,5 @@ +{ + "presets":[ + "es2015", "react" + ] +} diff --git a/.gitignore b/.gitignore index f5ea6d46..0c32bcfd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ db.sqlite3 /static /components node_modules/ +.idea/ + +# Staticfiles bundled with Webpack +naistenhelsinki/static/js/naistenhelsinki.js diff --git a/content/templatetags/content_tags.py b/content/templatetags/content_tags.py index 8a8f5f9c..ba1cf759 100644 --- a/content/templatetags/content_tags.py +++ b/content/templatetags/content_tags.py @@ -28,7 +28,7 @@ def has_menu_children(page): # The has_menu_children method is necessary because the bootstrap menu requires # a dropdown class to be applied to a parent @register.inclusion_tag('tags/top_menu.html', takes_context=True) -def top_menu(context, parent, calling_page=None): +def top_menu(context, parent, calling_page=None, site_title='Digitaalinen Helsinki'): menuitems = parent.get_children().live().in_menu() for menuitem in menuitems: menuitem.show_dropdown = has_menu_children(menuitem) @@ -45,6 +45,7 @@ def top_menu(context, parent, calling_page=None): return { 'calling_page': calling_page, 'menuitems': menuitems, + 'site_title': site_title, # required by the pageurl tag that we want to use within this template 'request': context['request'], } diff --git a/digihel/settings.py b/digihel/settings.py index e29733da..7f22aa87 100644 --- a/digihel/settings.py +++ b/digihel/settings.py @@ -40,6 +40,7 @@ 'feedback', 'search', 'events', + 'naistenhelsinki', 'wagtail.wagtailforms', 'wagtail.wagtailredirects', @@ -73,6 +74,7 @@ 'django.contrib.auth', 'django.contrib.sites', 'django.contrib.contenttypes', + 'django.contrib.gis', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', @@ -121,7 +123,7 @@ DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.postgresql', + 'ENGINE': 'django.contrib.gis.db.backends.postgis', 'NAME': 'digihel', 'USER': os.environ.get('DATABASE_USER', 'digihel'), } diff --git a/digihel/templates/tags/top_menu.html b/digihel/templates/tags/top_menu.html index d6ca6953..dcd4e65c 100644 --- a/digihel/templates/tags/top_menu.html +++ b/digihel/templates/tags/top_menu.html @@ -14,7 +14,7 @@ {# Link to home page #} -

Digitaalinen Helsinki

+

{{ site_title }}

diff --git a/digihel/urls.py b/digihel/urls.py index b2a207c8..246a8c39 100644 --- a/digihel/urls.py +++ b/digihel/urls.py @@ -4,6 +4,7 @@ from wagtail.wagtailadmin import urls as wagtailadmin_urls from wagtail.wagtailcore import urls as wagtail_urls from wagtail.wagtaildocs import urls as wagtaildocs_urls +from naistenhelsinki.views import places from digi.views import sitemap_view from events.views import event_data @@ -26,6 +27,7 @@ url(r'^palaute/$', FeedbackView.as_view(), name='post_feedback'), # client endpoints for external API data + url(r'^place_data/', places), url(r'^event_data/', event_data), url(r'', include(wagtail_urls)), diff --git a/naistenhelsinki/__init__.py b/naistenhelsinki/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/naistenhelsinki/apps.py b/naistenhelsinki/apps.py new file mode 100644 index 00000000..19b45999 --- /dev/null +++ b/naistenhelsinki/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class NaistenhelsinkiConfig(AppConfig): + name = 'naistenhelsinki' diff --git a/naistenhelsinki/migrations/0001_initial.py b/naistenhelsinki/migrations/0001_initial.py new file mode 100644 index 00000000..b3545a92 --- /dev/null +++ b/naistenhelsinki/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2017-09-26 08:11 +from __future__ import unicode_literals + +import django.contrib.gis.db.models.fields +import django.contrib.gis.geos.point +from django.db import migrations, models +import django.db.models.deletion +import wagtail.wagtailcore.blocks +import wagtail.wagtailcore.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('wagtailimages', '0019_delete_filter'), + ('wagtailcore', '0039_collectionviewrestriction'), + ] + + operations = [ + migrations.CreateModel( + name='Place', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), + ('sort_order', models.IntegerField(blank=True, editable=False, null=True)), + ('description', wagtail.wagtailcore.fields.RichTextField(blank=True, verbose_name='kuvaus')), + ('location', django.contrib.gis.db.models.fields.PointField(blank=True, default=django.contrib.gis.geos.point.Point(24.945831, 60.192059), null=True, srid=4326, verbose_name='paikka')), + ('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.Image')), + ], + options={ + 'ordering': ['sort_order'], + 'abstract': False, + }, + bases=('wagtailcore.page', models.Model), + ), + migrations.CreateModel( + name='PlaceListPage', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page',), + ), + migrations.CreateModel( + name='PlaceMapPage', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), + ('body', wagtail.wagtailcore.fields.StreamField((('heading', wagtail.wagtailcore.blocks.CharBlock(classname='full title')), ('paragraph', wagtail.wagtailcore.blocks.RichTextBlock())))), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page',), + ), + ] diff --git a/naistenhelsinki/migrations/__init__.py b/naistenhelsinki/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/naistenhelsinki/models.py b/naistenhelsinki/models.py new file mode 100644 index 00000000..0f1ff97a --- /dev/null +++ b/naistenhelsinki/models.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +from django.conf import settings +from django.contrib.gis.forms.widgets import OSMWidget +from django.contrib.gis.geos.point import Point +from django.db import models +from wagtail.wagtailcore import blocks +from wagtail.wagtailcore.models import Page, Orderable +from wagtail.wagtailcore.fields import RichTextField, StreamField +from wagtail.wagtailadmin.edit_handlers import FieldPanel, StreamFieldPanel +from wagtail.wagtailimages.edit_handlers import ImageChooserPanel +from wagtail.wagtailsearch import index +from django.contrib.gis.db import models as geomodels + +HELSINKI = Point(24.945831, 60.192059) + + +class PlaceMapPage(Page): + body = StreamField([ + ('heading', blocks.CharBlock(classname="full title")), + ('paragraph', blocks.RichTextBlock()), + ]) + + content_panels = Page.content_panels + [ + StreamFieldPanel('body') + ] + + search_fields = Page.search_fields + [ + index.SearchField('body') + ] + + +class Place(Orderable, Page): + description = RichTextField("kuvaus", blank=True) + image = models.ForeignKey( + 'wagtailimages.Image', + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='+', + ) + location = geomodels.PointField( + "paikka", + null=True, + blank=True, + default=HELSINKI, + ) + + search_fields = Page.search_fields + [ + index.SearchField('description'), + ] + + content_panels = Page.content_panels + [ + ImageChooserPanel('image'), + FieldPanel('description', classname="full"), + FieldPanel('location', classname="full", widget=OSMWidget()) + ] + + @property + def modal_title(self): + return self.title + + @property + def image_url(self): + if not self.image: + return None + file_path = self.image.get_rendition('fill-900x500').file + return '{url_prefix}{file_path}'.format( + url_prefix=settings.MEDIA_URL, + file_path=file_path, + ) + + +class PlaceListPage(Page): + + subpage_types = ['naistenhelsinki.Place'] + + def places(self): + return Place.objects.live() diff --git a/naistenhelsinki/serializers.py b/naistenhelsinki/serializers.py new file mode 100644 index 00000000..5b2723c1 --- /dev/null +++ b/naistenhelsinki/serializers.py @@ -0,0 +1,20 @@ +from django.contrib.gis.serializers.geojson import Serializer as GeoJsonSerializer + + +class PlaceSerializer(GeoJsonSerializer): + """ + A GeoJson serializer that can serialize property function values. + """ + + def serialize_property(self, obj): + model = type(obj) + for field in self.selected_fields: + if hasattr(model, field) and type(getattr(model, field)) == property: + self.handle_property(obj, field) + + def handle_property(self, obj, field): + self._current[field] = getattr(obj, field) + + def end_object(self, obj): + self.serialize_property(obj) + super(GeoJsonSerializer, self).end_object(obj) diff --git a/naistenhelsinki/static/css/naistenhelsinki.scss b/naistenhelsinki/static/css/naistenhelsinki.scss new file mode 100644 index 00000000..800e3231 --- /dev/null +++ b/naistenhelsinki/static/css/naistenhelsinki.scss @@ -0,0 +1,56 @@ +$modal-gray: #e5e5e5; + +.leaflet-container { + height: 600px; + width: 100%; + + .number-icon { + text-align: center; + vertical-align: middle; + color: white; + line-height: 22px; + background: red; + -moz-border-radius: 50%; + -webkit-border-radius: 50%; + border-radius: 50%; + } +} + +.ReactModal__Overlay { + z-index: 9999; +} + +.ReactModal__Content { + @media (max-width: 500px) { + top: 15px !important; + left: 15px !important; + bottom: 15px !important; + right: 15px !important; + } +} + +.nh-modal-header { + position: relative; + padding: 0; + margin-bottom: 15px; + border-bottom: 1px solid $modal-gray; + + h1 { + margin-bottom: 15px; + } + + .btn.close-modal { + float: right; + font-size: 30px; + line-height: 30px; + } +} + +.nh-modal-image { + background: $modal-gray; + margin-bottom: 15px; + + img { + margin: 0 auto; + } +} diff --git a/naistenhelsinki/static_src/js/naistenhelsinki/App.js b/naistenhelsinki/static_src/js/naistenhelsinki/App.js new file mode 100644 index 00000000..ef45789c --- /dev/null +++ b/naistenhelsinki/static_src/js/naistenhelsinki/App.js @@ -0,0 +1,37 @@ +import React, { Component } from 'react'; + +import Map from './components/Map.jsx'; + + +const fetchPlaces = (f) => { + fetch('/place_data/').then((response) => { + // Convert to JSON + return response.json(); + }).then((data) => { + f(data); + }); +}; + + +export default class App extends Component { + constructor(props) { + super(props); + this.state = { places: false }; + } + + get_data() { + fetchPlaces((data) => this.setState({ places: data })); + } + + componentDidMount() { + this.get_data(); + } + + render() { + return ( +
+ +
+ ); + } +} diff --git a/naistenhelsinki/static_src/js/naistenhelsinki/components/LocationMarker.jsx b/naistenhelsinki/static_src/js/naistenhelsinki/components/LocationMarker.jsx new file mode 100644 index 00000000..e6c0be4d --- /dev/null +++ b/naistenhelsinki/static_src/js/naistenhelsinki/components/LocationMarker.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Marker } from 'react-leaflet'; +import L from 'leaflet'; + + +function getIcon(content) { + return L.divIcon({ + className: "number-icon", + iconSize: [22, 22], + iconAnchor: [11, 11], // Position offset by half of the width and height + popupAnchor: [0, -5], + html: content + }); +} + + +export default function LocationMarker(props) { + const { placeFeature, iconNumber, onClick } = props; + + const icon = getIcon(iconNumber); + const position = placeFeature.geometry.coordinates.reverse(); + + return ( + + ); +} + + +LocationMarker.propTypes = { + placeFeature: PropTypes.object, + iconNumber: PropTypes.number, + onClick: PropTypes.func, +}; diff --git a/naistenhelsinki/static_src/js/naistenhelsinki/components/Map.jsx b/naistenhelsinki/static_src/js/naistenhelsinki/components/Map.jsx new file mode 100644 index 00000000..7a411cf9 --- /dev/null +++ b/naistenhelsinki/static_src/js/naistenhelsinki/components/Map.jsx @@ -0,0 +1,80 @@ +import React, { Component } from 'react'; +import { Map as LeafletMap, TileLayer } from 'react-leaflet'; + +import PlaceModal from './PlaceModal.jsx'; +import LocationMarker from './LocationMarker.jsx'; + + +export default class Map extends Component { + constructor(props) { + super(props); + this.state = { + selectedPlace: null, + }; + this.position = [60.172059, 24.945831]; // Center of Helsinki + this.bounds = [ + [59.9, 24.59], // SouthWest corner + [60.43, 25.3], // NorthEast corner + ]; + } + + onDeselectPlace() { + this.setState({ + selectedPlace: null, + }); + } + + onSelectPlace(placeFeature) { + this.setState({ + selectedPlace: placeFeature, + }); + } + + getMarkers() { + const places = this.props.places; + + return places.features.map((placeFeature, index) => { + if (!placeFeature.geometry) return null; + + const iconNumber = index + 1; + + return ( + this.onSelectPlace(placeFeature)} + /> + ); + }); + } + + getModal() { + if (!this.state.selectedPlace) return null; + + return ( + this.onDeselectPlace()} + /> + ); + } + + render() { + if (!this.props.places) return null; + + return ( +
+ + + {this.getMarkers()} + + {this.getModal()} +
+ ); + } +} diff --git a/naistenhelsinki/static_src/js/naistenhelsinki/components/PlaceModal.jsx b/naistenhelsinki/static_src/js/naistenhelsinki/components/PlaceModal.jsx new file mode 100644 index 00000000..4e6b6d61 --- /dev/null +++ b/naistenhelsinki/static_src/js/naistenhelsinki/components/PlaceModal.jsx @@ -0,0 +1,55 @@ +import React, { Component } from 'react'; +import Modal from 'react-modal'; + + +export default class PlaceModal extends Component { + constructor(props) { + super(props); + this.customStyles = { + content: { + top: '30px', + left: '30px', + bottom: '30px', + right: '30px', + padding: '30px', + }, + }; + } + + getImage() { + const imageUrl = this.props.placeFeature.properties.image_url; + if (!imageUrl) return null; + + return ( +
+ +
+ ); + } + + render() { + const placeFeature = this.props.placeFeature; + + return ( + +
+ +

{placeFeature.properties.modal_title}

+
+ {this.getImage()} +
+ + ); + } +} diff --git a/naistenhelsinki/static_src/js/naistenhelsinki/index.js b/naistenhelsinki/static_src/js/naistenhelsinki/index.js new file mode 100644 index 00000000..af08b3d2 --- /dev/null +++ b/naistenhelsinki/static_src/js/naistenhelsinki/index.js @@ -0,0 +1,7 @@ +// This is a fork of https://github.com/jussiarpalahti/react-geoview/ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +ReactDOM.render(, document.getElementById('root')); diff --git a/naistenhelsinki/templates/naistenhelsinki/includes/top_menu.html b/naistenhelsinki/templates/naistenhelsinki/includes/top_menu.html new file mode 100644 index 00000000..48613b3d --- /dev/null +++ b/naistenhelsinki/templates/naistenhelsinki/includes/top_menu.html @@ -0,0 +1,6 @@ +{% load content_tags %} + +{% block menu %} + {% get_site_root as site_root %} + {% top_menu parent=site_root calling_page=self site_title="Naisten Helsinki" %} +{% endblock %} diff --git a/naistenhelsinki/templates/naistenhelsinki/place_list_page.html b/naistenhelsinki/templates/naistenhelsinki/place_list_page.html new file mode 100644 index 00000000..a88231f8 --- /dev/null +++ b/naistenhelsinki/templates/naistenhelsinki/place_list_page.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% load wagtailimages_tags wagtailcore_tags %} + +{% block menu %} + {% include "naistenhelsinki/includes/top_menu.html" %} +{% endblock %} + +{% block content %} +
+
+
+

{{ page.title }}

+
+
+
+ + {% for place in page.places %} +
+
+

{{ place.title }}

+ {% image place.image fill-560x420 %} +

{{ place.description|richtext }}

+
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/naistenhelsinki/templates/naistenhelsinki/place_map_page.html b/naistenhelsinki/templates/naistenhelsinki/place_map_page.html new file mode 100644 index 00000000..b808a01f --- /dev/null +++ b/naistenhelsinki/templates/naistenhelsinki/place_map_page.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% load compress static %} + +{% block extra_css %} + + {% compress css %} + + {% endcompress %} +{% endblock %} + +{% block body_class %}place-map-page{% endblock %} + +{% block menu %} + {% include "naistenhelsinki/includes/top_menu.html" %} +{% endblock %} + +{% block content %} +
+
+

{{ page.title }}

+
+
+
+
+ {% for block in page.body %} + {% if block.block_type == 'heading' %} +

{{ block.value }}

+ {% else %} +
+ {{ block.value }} +
+ {% endif %} + {% endfor %} +
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/naistenhelsinki/views.py b/naistenhelsinki/views.py new file mode 100644 index 00000000..40452e33 --- /dev/null +++ b/naistenhelsinki/views.py @@ -0,0 +1,15 @@ +from django.http.response import HttpResponse + +from .serializers import PlaceSerializer +from .models import Place + + +def places(request): + return HttpResponse( + PlaceSerializer().serialize( + queryset=Place.objects.live(), + geometry_field='location', + fields=('pk', 'modal_title', 'description', 'image_url'), + ), + content_type="application/json" + ) diff --git a/naistenhelsinki/webpack.config.js b/naistenhelsinki/webpack.config.js new file mode 100644 index 00000000..bef7e5a1 --- /dev/null +++ b/naistenhelsinki/webpack.config.js @@ -0,0 +1,15 @@ +const path = require('path'); + +module.exports = { + entry: path.join(__dirname, '/static_src/js/naistenhelsinki/index.js'), + output: { + path: path.join(__dirname, '/static/js/'), + filename: 'naistenhelsinki.js' + }, + module: { + loaders: [ + { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ }, + { test: /\.jsx$/, loader: 'babel-loader', exclude: /node_modules/ } + ] + } +}; diff --git a/package.json b/package.json index b60a7205..ed5583ed 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,26 @@ "hel-bootstrap-3": "^1.0.0-beta.4", "jquery": "^2.2.4", "jquery-match-height": "^0.7.2", + "leaflet": "^1.2.0", "moment": "^2.18.1", - "postcss-cli": "^2.6.0" + "postcss-cli": "^2.6.0", + "prop-types": "^15.5.10", + "react": "^15.6.1", + "react-dom": "^15.6.1", + "react-leaflet": "^1.6.6", + "react-modal": "^2.3.2" + }, + "devDependencies": { + "babel": "^6.23.0", + "babel-core": "^6.26.0", + "babel-loader": "^7.1.2", + "babel-preset-es2015": "^6.24.1", + "babel-preset-react": "^6.24.1", + "webpack": "^3.6.0" }, - "devDependencies": {}, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "build-nh": "webpack --config naistenhelsinki/webpack.config.js --optimize-minimize --target='web'", + "watch-nh": "webpack --config naistenhelsinki/webpack.config.js --optimize-minimize --target='web' --watch --display-error-details" }, "author": "Juha Yrjölä ", "license": "MIT"