Add points of interest

Do a bunch of refactoring in the process.  Current display on homepage
is a little shonky but that can be improved when there is something
using it.
This commit is contained in:
Anna Sidwell 2018-10-13 01:04:44 -04:00
parent f2823445a4
commit acfbb513de
13 changed files with 632 additions and 472 deletions

View File

@ -11,7 +11,7 @@ from leaflet.forms.widgets import LeafletWidget
from apps.files.models import File, ImageFile from apps.files.models import File, ImageFile
from .models import CaseStudy, SpatialRefSys from .models import CaseStudy, SpatialRefSys, PointOfInterest
from .widgets import JSONFileListWidget from .widgets import JSONFileListWidget
@ -19,6 +19,40 @@ class MinimumZoomWidget(LeafletWidget):
geometry_field_class = 'MinimumZoomField' geometry_field_class = 'MinimumZoomField'
class PointOfInterest(forms.models.ModelForm):
def __init__(self, *args, **kwargs):
super(PointOfInterest, self).__init__(*args, **kwargs)
self.helper = FormHelper(self)
self.helper.form_id = 'case-study-form'
self.helper.form_class = 'form-horizontal'
self.helper.form_method = 'post'
self.helper.form_action = 'add'
self.helper.label_class = 'col-lg-2'
self.helper.field_class = 'col-lg-10'
self.helper.include_media = False
self.helper.form_action = reverse('point-of-interest-form')
self.helper.add_input(Submit('submit', _('Submit'), css_class='btn-success center-block'))
class Meta:
model = PointOfInterest
widgets = {
'location': MinimumZoomWidget(attrs={
'settings_overrides': {
'SCALE': False
}
}),
}
fields = [
'title',
'location',
'synopsis',
'link',
]
class BaseCaseStudyForm(forms.models.ModelForm): class BaseCaseStudyForm(forms.models.ModelForm):
"""Base form class for the CaseStudy model.""" """Base form class for the CaseStudy model."""

View File

@ -1015,6 +1015,14 @@ class CaseStudy(models.Model):
class PointOfInterestQuerySet(models.QuerySet):
def approved(self):
return self.filter(
approved=True
)
class PointOfInterest(models.Model): class PointOfInterest(models.Model):
class Meta: class Meta:
verbose_name_plural = 'points of interest' verbose_name_plural = 'points of interest'
@ -1022,6 +1030,8 @@ class PointOfInterest(models.Model):
def __str__(self): def __str__(self):
return self.title return self.title
objects = PointOfInterestQuerySet.as_manager()
author = models.ForeignKey( author = models.ForeignKey(
User, User,
models.SET_NULL, models.SET_NULL,

View File

@ -4,71 +4,6 @@
{% block stylesheets %} {% block stylesheets %}
{{ block.super }} {{ block.super }}
{% leaflet_css %} {% leaflet_css %}
<style> html, body, #main { width: 100; height:100%; } </style>
<style>
.savebutton {
position: fixed;
bottom: 0;
padding: 8px 15px;
z-index: 9999;
background-color: hsla(111, 25%, 84%, 1);
border-top-right-radius: 5px;
border-top-left-radius: 5px;
}
.savebutton--icon { margin-left: 15px; margin-right: 2px; }
.savebutton--icon-failed { color: #d9534f; }
.savebutton--details { font-style: italic; }
.draftprompt { display: none; }
.draftprompt--delete { margin-left: 10px; }
.draftprompt--details { margin-left: 10px; font-weight: bold; }
.zoomhelptext {
font-size: 14px;
background-color: rgba(255, 255, 255, 80%);
padding: 2px 10px;
border: 1px solid #ccc;
font-weight: bold;
}
.filewidget--input { position: absolute; left: -1000px; }
.filewidget--list {
list-style-type: none;
padding-left: 0;
margin-left: 0;
margin-top: 12px;
}
.filewidget--file {
border: 1px solid #aaa;
display: inline-block;
padding: 4px 8px;
width: 34em;
background-color: #eee;
border-radius: 4px;
cursor: default;
}
.filewidget--file--icon {
margin-right: 8px;
}
.filewidget--file--actions {
float: right;
}
.filewidget--file--actions a {
color: black;
cursor: pointer;
}
.filewidget--file--actions a:hover {
color: red;
}
</style>
{% endblock %} {% endblock %}
{% block page_title %}{% trans "Submit a Case Study" %} - {{ block.super }}{% endblock %} {% block page_title %}{% trans "Submit a Case Study" %} - {{ block.super }}{% endblock %}
@ -98,80 +33,10 @@
{% block scripts %} {% block scripts %}
{{ form.media }} {{ form.media }}
{% leaflet_js %} {% leaflet_js %}
<script> <script src="{% static 'js/map_minzoom.js' %}"></script>
ZoomHelpText = L.Control.extend({ <script src="{% static 'map/plugins/FormSaver.js' %}"></script>
options: { <script src="{% static 'map/plugins/jquery.dirtyforms.min.js' %}"></script>
position: 'bottomleft' <script src="{% static 'js/form_casestudy_drafts.js' %}"></script>
},
onAdd: function (map) {
return L.DomUtil.create('div', 'zoomhelptext')
},
setContent: function (content) {
this.getContainer().innerHTML = content
}
})
// See GeometryField source (static/leaflet/leaflet.forms.js) to override more stuff...
MinimumZoomField = L.GeometryField.extend({
zoomLevelTooLow: function() {
if (this._controlsShown !== false) {
this._map.removeControl(this._drawControl)
this._controlsShown = false
}
this._zoomHelpText.setContent(
"{% trans "Please zoom in further to place a marker on the map." %}"
)
},
zoomLevelOk: function() {
if (this._controlsShown !== true) {
this._map.addControl(this._drawControl)
this._controlsShown = true
}
this._zoomHelpText.setContent(
"{% trans "Please use the marker tool on the left to select a location." %}"
)
},
addTo: function (map) {
// super()
L.GeometryField.prototype.addTo.call(this, map)
this._controlsShown = true
this._map = map
// Add a help text control
this._zoomHelpText = new ZoomHelpText().addTo(map)
// Only allow editing past a certain zoom level (see #56)
// Remove the edit controls
this.zoomLevelTooLow()
// Enable or disable depending on zoom level
map.addEventListener('zoomend', evt => {
if (map.getZoom() >= 13) {
this.zoomLevelOk()
} else {
this.zoomLevelTooLow()
}
})
// Respond to underlying text field changing
const textarea = document.getElementById(this.options.fieldid)
textarea.addEventListener('change', evt => {
this.load()
})
const triggerChange = evt => {
document.getElementById('case-study-form').dispatchEvent(new Event('dirty'))
}
map.on(L.Draw.Event.CREATED, triggerChange)
map.on(L.Draw.Event.EDITED, triggerChange)
map.on(L.Draw.Event.DELETED, triggerChange)
},
})
</script>
<!-- Conditional logic for hiding and un-hiding fields. --> <!-- Conditional logic for hiding and un-hiding fields. -->
<script> <script>
@ -318,263 +183,15 @@
}); });
</script> </script>
<script>
<script src="{% static 'map/plugins/FormSaver.js' %}"></script>
<script src="{% static 'map/plugins/jquery.dirtyforms.min.js' %}"></script>
<script>
"use strict";
//
// UI ELEMENTS
//
class SaveButton {
constructor(div) {
this.root = div
this.root.className = "savebutton"
this.root.style.display = "block"
this.element = {
root: this.root,
button: this.root.querySelector('.savebutton--button'),
icon: this.root.querySelector('.savebutton--icon'),
details: this.root.querySelector('.savebutton--details')
}
this.switchStateInitial()
}
changeState(data) {
var data = data || {}
this.element.button.innerText = data.buttonText || "Save"
this.element.button.disabled = data.buttonClickable === false ? true : false
this.element.icon.className = 'savebutton--icon ' + (data.iconClasses || "")
this.element.details.innerText = data.detailsText || ""
}
switchStateInitial() {
this.changeState({
buttonClickable: false
})
}
switchStateUnsaved() {
this.changeState({
detailsText: "{% trans "You have unsaved changes. Click here to save a draft, which you can access next time you are here." %}"
})
}
switchStateSaving() {
this.changeState({
buttonText: "{% trans "Saving..." %}",
iconClasses: "fa fa-spinner fa-spin"
})
}
switchStateSaveSuccess() {
this.changeState({
buttonText: "{% trans "Saved" %}",
detailsText: "{% trans "Saved successfully." %}",
iconClasses: "fa fa-check",
buttonClickable: false
})
}
switchStateSaveFailed(reason = "") {
this.changeState({
detailsText: "{% trans "Save failed! " %}" + reason,
iconClasses: "fa fa-exclamation-triangle savebutton--icon-failed"
})
}
}
class DraftPrompt {
constructor(opts) {
this.restoreDraft = opts.restoreFn
this.deleteDraft = opts.deleteFn
this.root = opts.root
this.element = {
root: this.root,
restore: this.root.querySelector('.draftprompt--restore'),
delete: this.root.querySelector('.draftprompt--delete'),
details: this.root.querySelector('.draftprompt--details')
}
// Restore should restore, then hide the prompt
this.element.restore.addEventListener('click', () => {
this.restoreDraft()
this.switchStateHidden()
})
// Delete button will delete the draft
this.element.delete.addEventListener('click', () => {
if (window.confirm("{% trans 'Are you sure you want to delete your draft?' %}")) {
this.switchStateDeleting()
this.deleteDraft().then(ok => ok ?
this.switchStateHidden() :
this.switchStateDeleteFailed()
)
}
})
this.switchStateShown()
}
changeState(data) {
var data = data || {}
this.element.root.style.display = data.visible ? "block" : "none"
this.element.details.innerHTML = data.detailsText || ""
}
switchStateHidden() {
this.changeState({
visible: false
})
}
switchStateShown() {
this.changeState({
visible: true
})
}
switchStateDeleting() {
this.changeState({
visible: true,
details: '<i classs="fa fa-spinner fa-spin"></i>'
})
}
switchStateDeleteFailed() {
this.changeState({
details: '{% trans "Delete failed!" %}'
})
}
}
//
// API UTILITIES
//
function apiGetDraft() {
return fetch('/en-gb/case-study/draft', {
method: 'GET',
credentials: "same-origin",
headers: {
"X-CSRFToken": jQuery("[name=csrfmiddlewaretoken]").val(),
}
})
}
function apiPutDraft(formSaver) {
if (!formSaver) {
throw new Error("apiPutDraft: parameter not provided")
}
return fetch('/en-gb/case-study/draft', {
method: 'PUT',
credentials: "same-origin",
headers: {
"X-CSRFToken": jQuery("[name=csrfmiddlewaretoken]").val(),
"Accept": "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify({ version: 1, data: formSaver.serialise() })
})
}
function apiDeleteDraft() {
return fetch('/en-gb/case-study/draft', {
method: 'DELETE',
credentials: "same-origin",
headers: {
"X-CSRFToken": jQuery("[name=csrfmiddlewaretoken]").val(),
}
})
}
//
// GNARLY BITS TYING API & UI STUFF TOGETHER
//
function showDraftPrompt(formSaver, serialisedForm) {
let prompt = new DraftPrompt({
root: document.querySelector('.draftprompt'),
restoreFn: () => formSaver.deserialise(serialisedForm),
deleteFn: () => apiDeleteDraft().then(response => response.ok)
})
}
function initDrafts() {
const formSaver = new FormSaver({
formId: 'case-study-form',
except: [ 'csrfmiddlewaretoken' ]
})
// Use whether the form has errors as a proxy for whether the server has
// returned us data in the form. In this case, don't show a draft.
const formHasErrors = document.getElementById('form_errors').value === 'true' ? true : false
if (!formHasErrors) {
// Get the previous draft, if any
apiGetDraft()
.then(response => response.json())
.then(json => {
// Handle the case where we didn't get a response
if (!json) return
if (json.version !== 1) {
throw new Error("Bad JSON response version")
}
showDraftPrompt(formSaver, json.data.form)
}).catch(err => {
console.error(err);
})
}
// Init the button controller
var button = new SaveButton(document.querySelector('.savebutton'))
// Dirty forms set up
$('#case-study-form').dirtyForms();
$('#case-study-form').on('dirty.dirtyforms', ev => {
button.switchStateUnsaved()
});
// Save button
button.element.button.addEventListener('click', evt => {
button.switchStateSaving()
apiPutDraft(formSaver).then(response => {
if (response.ok) {
button.switchStateSaveSuccess();
$('#case-study-form').dirtyForms('setClean');
document.querySelector('.draftprompt').style.display = "none"
} else {
button.switchStateSaveFailed();
}
}).catch(err => {
console.error(err);
button.switchStateSaveFailed();
})
})
}
// https://github.com/snikch/jquery.dirtyforms
initDrafts()
</script>
<script>
<!-- Jump to error -->
$(function() { $(function() {
initDrafts()
// Jump to error
if ($('.has-error').length) { if ($('.has-error').length) {
location.href = '#' + $('.has-error:first').attr('id'); location.href = '#' + $('.has-error:first').attr('id');
} }
}); })
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% load compress crispy_forms_tags i18n leaflet_tags static %}
{% block page_title %}{% trans "Add a Point of Interest" %} - {{ block.super }}{% endblock %}
{% block content %}
<div class="container">
<div class="col-sm-12">
<div class="content--narrow">
<h1>{% trans "Add a Point of Interest" %}</h1>
<p class="subheading">{% trans "Points of interest are a way of adding information about or linked to a particular place. They could be a marker for further research, or just a link to information elsewhere." %}</p>
{% crispy form %}
</div>
</div>
</div>
{% endblock %}
{% block stylesheets %}
{{ block.super }}
{% leaflet_css %}
{% endblock %}
{% block scripts %}
{{ form.media }}
{% leaflet_js %}
<script src="{% static 'js/map_minzoom.js' %}"></script>
{% endblock %}

View File

@ -7,17 +7,6 @@
{% block stylesheets %} {% block stylesheets %}
{{ block.super }} {{ block.super }}
<style> <style>
.form-selector {
max-width: 50em;
margin: auto;
margin-top: 2em;
}
.subhead {
font-size: 120%;
margin-bottom: 2em;
}
.entry { .entry {
display: flex; display: flex;
align-items: center; align-items: center;
@ -56,10 +45,10 @@
<div class="container"> <div class="container">
<div class="col-sm-12"> <div class="col-sm-12">
<div class="form-selector"> <div class="content--narrow">
<h1>{% trans "Create a new entry" %}</h1> <h1>{% trans "Create a new entry" %}</h1>
<p class="subhead">{% trans "What kind of entry do you want to create?" %}</p> <p class="subheading">{% trans "What kind of entry do you want to create?" %}</p>
<a class="entry" href="{% url 'long-form' %}"> <a class="entry" href="{% url 'long-form' %}">
<i class="entry-icon ojuso-green fa fa-file-text-o"></i> <i class="entry-icon ojuso-green fa fa-file-text-o"></i>
@ -80,8 +69,8 @@
</p> </p>
</div> </div>
</a> </a>
<!--
<a class="entry" href="/"> <a class="entry" href="{% url 'point-of-interest-form' %}">
<i class="entry-icon ojuso-blue fa fa-thumb-tack"></i> <i class="entry-icon ojuso-blue fa fa-thumb-tack"></i>
<div> <div>
<h3 class="entry-title">{% trans "Point of interest" %}</h3> <h3 class="entry-title">{% trans "Point of interest" %}</h3>
@ -90,7 +79,7 @@
</p> </p>
</div> </div>
</a> </a>
-->
</div> </div>
</div> </div>

View File

@ -32,59 +32,10 @@ html, body, #main {
.popup-labels .label { .popup-labels .label {
font-size: 100%; font-size: 100%;
} }
.hello {
position: fixed;
height: 100%;
width: 100%;
top: 0;
left: 0;
overflow: auto;
z-index: 1000;
background-color: rgba(0, 0, 0, 0.5);
background-image: url('data:image/svg+xml;charset=utf-8;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEiIHZpZXdCb3g9IjEwMCA1IDIwMCAyNTUiPg0KICA8cGF0aCBkPSJNMjM4IDEyN2g1M2wtMjYtMTUtMjcgMTUiIGZpbGw9InJnYmEoMjUyLCAyMTEsIDI5LCAwLjUpIi8+DQogIDxwYXRoIGQ9Ik0yOTQgMTIybC0yOS01MS0yOSA1MSAyOS0xNyAyOSAxNyIgZmlsbD0icmdiYSgxMDIsIDE5OCwgODcsIDAuNSkiLz4NCiAgPHBhdGggZD0iTTIyOCAxMjRsMzItNTZoLTY0bDMyIDU2IiBmaWxsPSJyZ2JhKDIyMSwgNjgsIDU4LCAwLjUpIi8+DQogIDxwYXRoIGQ9Ik0xODYgNjNMMTUzIDdsLTMyIDU2aDY1bTExMSAxOTNsLTMyLTU2LTMyIDU2aDY0IiBmaWxsPSJyZ2JhKDEwMiwgMTk4LCA4NywgMC41KSIvPg0KICA8cGF0aCBkPSJNMjIzIDEyN2wtMzItNTYtMzMgNTZoNjUiIGZpbGw9InJnYmEoMjUyLCAyMTEsIDI5LCAwLjUpIi8+DQogIDxwYXRoIGQ9Ik0yNjAgMTkxbC0zMi01Ni0zMiA1Nmg2NCIgZmlsbD0icmdiYSgyMjEsIDY4LCA1OCwgMC41KSIvPg0KICA8cGF0aCBkPSJNMTUzIDEyNGwzMy01NmgtNjVsMzIgNTYiIGZpbGw9InJnYmEoMzcsIDE0MCwgMjEyLCAwLjUpIi8+DQogIDxwYXRoIGQ9Ik0yMjggMjUzbDMyLTU2aC02NGwzMiA1NiIgZmlsbD0icmdiYSgyNTIsIDIxMSwgMjksIDAuNSkiLz4NCiAgDQogIA0KICA8cGF0aCBkPSJNMTg2IDE5MWwtMzMtNTUtMzIgNTVoNjUiIGZpbGw9InJnYmEoMzcsIDE0MCwgMjEyLCAwLjUpIi8+DQogIA0KICANCiAgPHBhdGggZD0iTTExOSAxODRsMjctNDYtMjcgMTV2MzEiIGZpbGw9InJnYmEoMTAyLCAxOTgsIDg3LCAwLjUpIi8+DQogIDxwYXRoIGQ9Ik0xOTMgMTg0bDI3LTQ2LTI3IDE1djMxIiBmaWxsPSJyZ2JhKDM3LCAxNDAsIDIxMiwgMC41KSIvPg0KICA8cGF0aCBkPSJNMjE3IDEzM2gtNTlsMzAgNTF2LTM0bDI5LTE3IiBmaWxsPSJyZ2JhKDEwMiwgMTk4LCA4NywgMC41KSIvPg0KICA8cGF0aCBkPSJNMTg4IDIwNWwtMjcgNDYgMjctMTZ2LTMwIiBmaWxsPSJyZ2JhKDM3LCAxNDAsIDIxMiwgMC41KSIvPg0KICA8cGF0aCBkPSJNMTY0IDI1Nmg1OWwtMzAtNTF2MzRsLTI5IDE3IiBmaWxsPSJyZ2JhKDIyMSwgNjgsIDU4LCAwLjUpIi8+DQogIDxwYXRoIGQ9Ik0yMjUgMTJsLTI2IDQ2IDI2LTE2VjEyIiBmaWxsPSJyZ2JhKDI1MiwgMjExLCAyOSwgMC41KSIvPg0KICA8cGF0aCBkPSJNMjAxIDYzaDU5bC0yOS01MXYzNGwtMzAgMTciIGZpbGw9InJnYmEoMzcsIDE0MCwgMjEyLCAwLjUpIi8+DQo8L3N2Zz4=');
background-position: center;
background-blend-mode: luminosity;
}
.hello--container {
max-width: 40em;
margin: 0 auto;
}
.hello--text {
background-color: white;
margin-top: 5em;
margin-left: 1em;
margin-right: 1em;
margin-bottom: 1em;
padding: 1em;
z-index: 1100;
}
/* Top margin on small screns should be same as the rest */
@media (max-width: 768px) {
.hello--text {
margin-top: 1em;
}
}
.hello--logo {
width: 200px;
margin-bottom: 16px;
}
.hello--logo, .hello--hide {
display: block;
margin-left: auto;
margin-right: auto;
}
</style> </style>
{% endblock %} {% endblock %}
{% block title %}{% trans "Ojuso Platform Map" %}{% endblock %}
{% block inner_content %} {% block inner_content %}
<div id="main"></div> <div id="main"></div>
<div id="modals"></div> <div id="modals"></div>
@ -143,7 +94,7 @@ function getLabelClass(pos_or_neg) {
} }
} }
function popup(feature, layer) { function caseStudyPopup(feature, layer) {
var str = ''; var str = '';
if(feature.properties.images.length > 0) { if(feature.properties.images.length > 0) {
str = "<img src='"+feature.properties.images[0].file+"' width='100%'>"; str = "<img src='"+feature.properties.images[0].file+"' width='100%'>";
@ -157,8 +108,21 @@ function popup(feature, layer) {
"<span class='label label-"+getLabelClass(feature.properties.positive_or_negative)+"'>"+ feature.properties.positive_or_negative_display+"</span>"+ "<span class='label label-"+getLabelClass(feature.properties.positive_or_negative)+"'>"+ feature.properties.positive_or_negative_display+"</span>"+
"</div>"+ "</div>"+
"<a class='btn btn-sm btn-primary' href='case-study/"+feature.properties.slug+"'>{% trans "View full case study" %}</a>") "<a class='btn btn-sm btn-primary' href='case-study/"+feature.properties.slug+"'>{% trans "View full case study" %}</a>")
layer.bindPopup(str); layer.bindPopup(str);
}; }
function poiPopup(feature, layer) {
str = (
"<div class='popup-head'>"+
"<h5>"+feature.properties.title+"</h5>" +
"</div>"+
"<p>"+feature.properties.synopsis+"</p>"+
"<p><a href='"+feature.properties.link+"'>Link</a></p>"
)
layer.bindPopup(str);
}
// This is called when the map is initialized // This is called when the map is initialized
function main_app_init(map, options) { function main_app_init(map, options) {
@ -167,7 +131,26 @@ function main_app_init(map, options) {
// Pull data as GeoJSON and add to map with a modal // Pull data as GeoJSON and add to map with a modal
$.getJSON('/api/case-studies/', function(data) { $.getJSON('/api/case-studies/', function(data) {
L.geoJson(data, { L.geoJson(data, {
onEachFeature: popup onEachFeature: caseStudyPopup
}).addTo(map)
});
var geojsonMarkerOptions = {
radius: 4,
fillColor: "#ff7800",
color: "#000",
weight: 1,
opacity: 1,
fillOpacity: 0.8
};
// Pull data as GeoJSON and add to map with a modal
$.getJSON('/api/points-of-interest/', function(data) {
L.geoJson(data, {
pointToLayer: function (feature, latlng) {
return L.circleMarker(latlng, geojsonMarkerOptions);
},
onEachFeature: poiPopup
}).addTo(map) }).addTo(map)
}); });
} }

View File

@ -1,6 +1,7 @@
from django.conf.urls import url from django.conf.urls import url
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.views.generic import RedirectView from django.views.generic import RedirectView
from django.views.i18n import JavaScriptCatalog
from djgeojson.views import GeoJSONLayerView from djgeojson.views import GeoJSONLayerView
from .models import CaseStudy from .models import CaseStudy
@ -11,12 +12,13 @@ urlpatterns = [
url(r'^case-study/create/?$', views.Create.as_view(), name="create"), url(r'^case-study/create/?$', views.Create.as_view(), name="create"),
url(r'^case-study/create/short/?$', views.ShortForm.as_view(), name='short-form'), url(r'^case-study/create/short/?$', views.ShortForm.as_view(), name='short-form'),
url(r'^case-study/create/long/?$', views.LongForm.as_view(), name='long-form'), url(r'^case-study/create/long/?$', views.LongForm.as_view(), name='long-form'),
url(r'^case-study/create/poi/?$', views.PointOfInterest.as_view(), name='point-of-interest-form'),
url(r'^case-study/create/success/?$', views.FormSuccess.as_view(), name='form-success'), url(r'^case-study/create/success/?$', views.FormSuccess.as_view(), name='form-success'),
url(r'^case-study/draft/?$', views.Drafts.as_view(), name='drafts'), url(r'^case-study/draft/?$', views.Drafts.as_view(), name='drafts'),
url(r'^case-study/(?P<slug>[-\w]+)/?$', views.CaseStudyDetail.as_view(), name='detail'), url(r'^case-study/(?P<slug>[-\w]+)/?$', views.CaseStudyDetail.as_view(), name='detail'),
url(r'^map/?$', views.Map.as_view(), name='map'), url(r'^map/?$', views.Map.as_view(), name='map'),
# API # API
url(r'^data.geojson$', GeoJSONLayerView.as_view(model=CaseStudy, geometry_field='location'), name='data'), url(r'^jsi18n/$', JavaScriptCatalog.as_view(), name='javascript-catalogue'),
url(r'^srs-autocomplete/$', views.SpatialRefSysAutocomplete.as_view(), name='srs-autocomplete'), url(r'^srs-autocomplete/$', views.SpatialRefSysAutocomplete.as_view(), name='srs-autocomplete'),
] ]

View File

@ -15,8 +15,8 @@ from dal import autocomplete
from apps.files.models import File from apps.files.models import File
from .models import CaseStudy, CaseStudyDraft, SpatialRefSys from .models import CaseStudy, CaseStudyDraft, SpatialRefSys, PointOfInterest
from .forms import ShortCaseStudyForm, LongCaseStudyForm from .forms import ShortCaseStudyForm, LongCaseStudyForm, PointOfInterest
NOTIFY_MESSAGE = """ NOTIFY_MESSAGE = """
@ -39,6 +39,14 @@ class Create(LoginRequiredMixin, TemplateView):
template_name = "map/form-selector.html" template_name = "map/form-selector.html"
class PointOfInterest(LoginRequiredMixin, CreateView):
"""View for base case study form."""
template_name = 'map/form-poi.html'
success_url = '/case-study/create/success/'
model = PointOfInterest
form_class = PointOfInterest
class BaseForm(LoginRequiredMixin, CreateView): class BaseForm(LoginRequiredMixin, CreateView):
"""View for base case study form.""" """View for base case study form."""
template_name = 'map/form-case_study.html' template_name = 'map/form-case_study.html'

View File

@ -13,6 +13,36 @@
background: red; background: red;
} }
/*
* UTILITY CLASSES
*/
/* Colours */
.ojuso-red { color: #ea4639; }
.ojuso-green { color: #4ac95d; }
.ojuso-yellow { color: #f9db3c; }
.ojuso-blue { color: #008ad5; }
/*
* LAYOUT
*/
/* Narrow content area wrapper */
.content--narrow {
max-width: 50em;
margin: auto;
margin-top: 2em;
}
/* Subheading, designed to sit on a paragraph after h1 */
.subheading {
font-size: 120%;
margin-bottom: 2em;
}
/* Page footer */ /* Page footer */
.footer { .footer {
@ -42,8 +72,128 @@
margin-top: 1em; margin-top: 1em;
} }
/* Colour utility classes */
.ojuso-red { color: #ea4639; }
.ojuso-green { color: #4ac95d; } /*
.ojuso-yellow { color: #f9db3c; } * FORMS
.ojuso-blue { color: #008ad5; } */
/* Map widget - Minimum zoom help text */
.minzoomhelptext {
font-size: 14px;
background-color: rgba(255, 255, 255, 80%);
padding: 2px 10px;
border: 1px solid #ccc;
font-weight: bold;
}
/* Case study draft functionality */
.savebutton {
position: fixed;
bottom: 0;
padding: 8px 15px;
z-index: 9999;
background-color: hsla(111, 25%, 84%, 1);
border-top-right-radius: 5px;
border-top-left-radius: 5px;
}
.savebutton--icon { margin-left: 15px; margin-right: 2px; }
.savebutton--icon-failed { color: #d9534f; }
.savebutton--details { font-style: italic; }
.draftprompt { display: none; }
.draftprompt--delete { margin-left: 10px; }
.draftprompt--details { margin-left: 10px; font-weight: bold; }
/* File upload widget styles */
.filewidget--input { position: absolute; left: -1000px; }
.filewidget--list {
list-style-type: none;
padding-left: 0;
margin-left: 0;
margin-top: 12px;
}
.filewidget--file {
border: 1px solid #aaa;
display: inline-block;
padding: 4px 8px;
width: 34em;
background-color: #eee;
border-radius: 4px;
cursor: default;
}
.filewidget--file--icon {
margin-right: 8px;
}
.filewidget--file--actions {
float: right;
}
.filewidget--file--actions a {
color: black;
cursor: pointer;
}
.filewidget--file--actions a:hover {
color: red;
}
/*
* HOME PAGE
*/
/* This is the popup that you see when you first arrive on the site */
.hello {
position: fixed;
height: 100%;
width: 100%;
top: 0;
left: 0;
overflow: auto;
z-index: 1000;
background-color: rgba(0, 0, 0, 0.5);
background-image: url('data:image/svg+xml;charset=utf-8;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEiIHZpZXdCb3g9IjEwMCA1IDIwMCAyNTUiPg0KICA8cGF0aCBkPSJNMjM4IDEyN2g1M2wtMjYtMTUtMjcgMTUiIGZpbGw9InJnYmEoMjUyLCAyMTEsIDI5LCAwLjUpIi8+DQogIDxwYXRoIGQ9Ik0yOTQgMTIybC0yOS01MS0yOSA1MSAyOS0xNyAyOSAxNyIgZmlsbD0icmdiYSgxMDIsIDE5OCwgODcsIDAuNSkiLz4NCiAgPHBhdGggZD0iTTIyOCAxMjRsMzItNTZoLTY0bDMyIDU2IiBmaWxsPSJyZ2JhKDIyMSwgNjgsIDU4LCAwLjUpIi8+DQogIDxwYXRoIGQ9Ik0xODYgNjNMMTUzIDdsLTMyIDU2aDY1bTExMSAxOTNsLTMyLTU2LTMyIDU2aDY0IiBmaWxsPSJyZ2JhKDEwMiwgMTk4LCA4NywgMC41KSIvPg0KICA8cGF0aCBkPSJNMjIzIDEyN2wtMzItNTYtMzMgNTZoNjUiIGZpbGw9InJnYmEoMjUyLCAyMTEsIDI5LCAwLjUpIi8+DQogIDxwYXRoIGQ9Ik0yNjAgMTkxbC0zMi01Ni0zMiA1Nmg2NCIgZmlsbD0icmdiYSgyMjEsIDY4LCA1OCwgMC41KSIvPg0KICA8cGF0aCBkPSJNMTUzIDEyNGwzMy01NmgtNjVsMzIgNTYiIGZpbGw9InJnYmEoMzcsIDE0MCwgMjEyLCAwLjUpIi8+DQogIDxwYXRoIGQ9Ik0yMjggMjUzbDMyLTU2aC02NGwzMiA1NiIgZmlsbD0icmdiYSgyNTIsIDIxMSwgMjksIDAuNSkiLz4NCiAgDQogIA0KICA8cGF0aCBkPSJNMTg2IDE5MWwtMzMtNTUtMzIgNTVoNjUiIGZpbGw9InJnYmEoMzcsIDE0MCwgMjEyLCAwLjUpIi8+DQogIA0KICANCiAgPHBhdGggZD0iTTExOSAxODRsMjctNDYtMjcgMTV2MzEiIGZpbGw9InJnYmEoMTAyLCAxOTgsIDg3LCAwLjUpIi8+DQogIDxwYXRoIGQ9Ik0xOTMgMTg0bDI3LTQ2LTI3IDE1djMxIiBmaWxsPSJyZ2JhKDM3LCAxNDAsIDIxMiwgMC41KSIvPg0KICA8cGF0aCBkPSJNMjE3IDEzM2gtNTlsMzAgNTF2LTM0bDI5LTE3IiBmaWxsPSJyZ2JhKDEwMiwgMTk4LCA4NywgMC41KSIvPg0KICA8cGF0aCBkPSJNMTg4IDIwNWwtMjcgNDYgMjctMTZ2LTMwIiBmaWxsPSJyZ2JhKDM3LCAxNDAsIDIxMiwgMC41KSIvPg0KICA8cGF0aCBkPSJNMTY0IDI1Nmg1OWwtMzAtNTF2MzRsLTI5IDE3IiBmaWxsPSJyZ2JhKDIyMSwgNjgsIDU4LCAwLjUpIi8+DQogIDxwYXRoIGQ9Ik0yMjUgMTJsLTI2IDQ2IDI2LTE2VjEyIiBmaWxsPSJyZ2JhKDI1MiwgMjExLCAyOSwgMC41KSIvPg0KICA8cGF0aCBkPSJNMjAxIDYzaDU5bC0yOS01MXYzNGwtMzAgMTciIGZpbGw9InJnYmEoMzcsIDE0MCwgMjEyLCAwLjUpIi8+DQo8L3N2Zz4=');
background-position: center;
background-blend-mode: luminosity;
}
.hello--container {
max-width: 40em;
margin: 0 auto;
}
.hello--text {
background-color: white;
margin-top: 5em;
margin-left: 1em;
margin-right: 1em;
margin-bottom: 1em;
padding: 1em;
z-index: 1100;
}
/* Top margin on small screns should be same as the rest */
@media (max-width: 768px) {
.hello--text {
margin-top: 1em;
}
}
.hello--logo {
width: 200px;
margin-bottom: 16px;
}
.hello--logo, .hello--hide {
display: block;
margin-left: auto;
margin-right: auto;
}

View File

@ -0,0 +1,243 @@
"use strict";
//
// UI ELEMENTS
//
class SaveButton {
constructor(div) {
this.root = div
this.root.className = "savebutton"
this.root.style.display = "block"
this.element = {
root: this.root,
button: this.root.querySelector('.savebutton--button'),
icon: this.root.querySelector('.savebutton--icon'),
details: this.root.querySelector('.savebutton--details')
}
this.switchStateInitial()
}
changeState(data) {
var data = data || {}
this.element.button.innerText = data.buttonText || "Save"
this.element.button.disabled = data.buttonClickable === false ? true : false
this.element.icon.className = 'savebutton--icon ' + (data.iconClasses || "")
this.element.details.innerText = data.detailsText || ""
}
switchStateInitial() {
this.changeState({
buttonClickable: false
})
}
switchStateUnsaved() {
this.changeState({
detailsText: django.gettext("You have unsaved changes. Click here to save a draft, which you can access next time you are here.")
})
}
switchStateSaving() {
this.changeState({
buttonText: django.gettext("Saving..."),
iconClasses: "fa fa-spinner fa-spin"
})
}
switchStateSaveSuccess() {
this.changeState({
buttonText: django.gettext("Saved"),
detailsText: django.gettext("Saved successfully."),
iconClasses: "fa fa-check",
buttonClickable: false
})
}
switchStateSaveFailed(reason = "") {
this.changeState({
detailsText: django.gettext("Save failed! ") + reason,
iconClasses: "fa fa-exclamation-triangle savebutton--icon-failed"
})
}
}
class DraftPrompt {
constructor(opts) {
this.restoreDraft = opts.restoreFn
this.deleteDraft = opts.deleteFn
this.root = opts.root
this.element = {
root: this.root,
restore: this.root.querySelector('.draftprompt--restore'),
delete: this.root.querySelector('.draftprompt--delete'),
details: this.root.querySelector('.draftprompt--details')
}
// Restore should restore, then hide the prompt
this.element.restore.addEventListener('click', () => {
this.restoreDraft()
this.switchStateHidden()
})
// Delete button will delete the draft
this.element.delete.addEventListener('click', () => {
if (window.confirm(django.gettext('Are you sure you want to delete your draft?'))) {
this.switchStateDeleting()
this.deleteDraft().then(ok => ok ?
this.switchStateHidden() :
this.switchStateDeleteFailed()
)
}
})
this.switchStateShown()
}
changeState(data) {
var data = data || {}
this.element.root.style.display = data.visible ? "block" : "none"
this.element.details.innerHTML = data.detailsText || ""
}
switchStateHidden() {
this.changeState({
visible: false
})
}
switchStateShown() {
this.changeState({
visible: true
})
}
switchStateDeleting() {
this.changeState({
visible: true,
details: '<i classs="fa fa-spinner fa-spin"></i>'
})
}
switchStateDeleteFailed() {
this.changeState({
details: django.gettext("Delete failed!")
})
}
}
//
// API UTILITIES
//
function apiGetDraft() {
return fetch('/en-gb/case-study/draft', {
method: 'GET',
credentials: "same-origin",
headers: {
"X-CSRFToken": jQuery("[name=csrfmiddlewaretoken]").val(),
}
})
}
function apiPutDraft(formSaver) {
if (!formSaver) {
throw new Error("apiPutDraft: parameter not provided")
}
return fetch('/en-gb/case-study/draft', {
method: 'PUT',
credentials: "same-origin",
headers: {
"X-CSRFToken": jQuery("[name=csrfmiddlewaretoken]").val(),
"Accept": "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify({ version: 1, data: formSaver.serialise() })
})
}
function apiDeleteDraft() {
return fetch('/en-gb/case-study/draft', {
method: 'DELETE',
credentials: "same-origin",
headers: {
"X-CSRFToken": jQuery("[name=csrfmiddlewaretoken]").val(),
}
})
}
//
// GNARLY BITS TYING API & UI STUFF TOGETHER
//
function showDraftPrompt(formSaver, serialisedForm) {
let prompt = new DraftPrompt({
root: document.querySelector('.draftprompt'),
restoreFn: () => formSaver.deserialise(serialisedForm),
deleteFn: () => apiDeleteDraft().then(response => response.ok)
})
}
function initDrafts() {
const formSaver = new FormSaver({
formId: 'case-study-form',
except: [ 'csrfmiddlewaretoken' ]
})
// Use whether the form has errors as a proxy for whether the server has
// returned us data in the form. In this case, don't show a draft.
const formHasErrors = document.getElementById('form_errors').value === 'true' ? true : false
if (!formHasErrors) {
// Get the previous draft, if any
apiGetDraft()
.then(response => response.json())
.then(json => {
// Handle the case where we didn't get a response
if (!json) return
if (json.version !== 1) {
throw new Error("Bad JSON response version")
}
showDraftPrompt(formSaver, json.data.form)
}).catch(err => {
console.error(err);
})
}
// Init the button controller
var button = new SaveButton(document.querySelector('.savebutton'))
// Dirty forms set up
$('#case-study-form').dirtyForms();
$('#case-study-form').on('dirty.dirtyforms', ev => {
button.switchStateUnsaved()
});
// Save button
button.element.button.addEventListener('click', evt => {
button.switchStateSaving()
apiPutDraft(formSaver).then(response => {
if (response.ok) {
button.switchStateSaveSuccess();
$('#case-study-form').dirtyForms('setClean');
document.querySelector('.draftprompt').style.display = "none"
} else {
button.switchStateSaveFailed();
}
}).catch(err => {
console.error(err);
button.switchStateSaveFailed();
})
})
}
// https://github.com/snikch/jquery.dirtyforms

72
assets/js/map_minzoom.js Normal file
View File

@ -0,0 +1,72 @@
ZoomHelpText = L.Control.extend({
options: {
position: 'bottomleft'
},
onAdd: function (map) {
return L.DomUtil.create('div', 'minzoomhelptext')
},
setContent: function (content) {
this.getContainer().innerHTML = content
}
})
// See GeometryField source (static/leaflet/leaflet.forms.js) to override more stuff...
MinimumZoomField = L.GeometryField.extend({
zoomLevelTooLow: function() {
if (this._controlsShown !== false) {
this._map.removeControl(this._drawControl)
this._controlsShown = false
}
this._zoomHelpText.setContent(
django.gettext("Please zoom in further to place a marker on the map.")
)
},
zoomLevelOk: function() {
if (this._controlsShown !== true) {
this._map.addControl(this._drawControl)
this._controlsShown = true
}
this._zoomHelpText.setContent(
django.gettext("Please use the marker tool on the left to select a location.")
)
},
addTo: function (map) {
// super()
L.GeometryField.prototype.addTo.call(this, map)
this._controlsShown = true
this._map = map
// Add a help text control
this._zoomHelpText = new ZoomHelpText().addTo(map)
// Only allow editing past a certain zoom level (see #56)
// Remove the edit controls
this.zoomLevelTooLow()
// Enable or disable depending on zoom level
map.addEventListener('zoomend', evt => {
if (map.getZoom() >= 13) {
this.zoomLevelOk()
} else {
this.zoomLevelTooLow()
}
})
// Respond to underlying text field changing
const textarea = document.getElementById(this.options.fieldid)
textarea.addEventListener('change', evt => {
this.load()
})
const triggerChange = evt => {
document.getElementById('case-study-form').dispatchEvent(new Event('dirty'))
}
map.on(L.Draw.Event.CREATED, triggerChange)
map.on(L.Draw.Event.EDITED, triggerChange)
map.on(L.Draw.Event.DELETED, triggerChange)
},
})

View File

@ -127,7 +127,8 @@
{# CDN Javascript #} {# CDN Javascript #}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.0.0/jquery.min.js" integrity="sha256-JmvOoLtYsmqlsWxa7mDSLMwa6dZ9rrIdtrrVYRnDRH0=" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.0.0/jquery.min.js" integrity="sha256-JmvOoLtYsmqlsWxa7mDSLMwa6dZ9rrIdtrrVYRnDRH0=" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
<script src="{% static "map/language.js" %}"></script> <script src="{% url 'javascript-catalogue' %}"></script>
<script src="{% static 'map/language.js' %}"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</html> </html>

View File

@ -23,7 +23,7 @@ from rest_framework import routers, serializers, viewsets
from rest_framework_gis import serializers as gis_serializers from rest_framework_gis import serializers as gis_serializers
from apps.files.models import File from apps.files.models import File
from apps.map.models import CaseStudy from apps.map.models import CaseStudy, PointOfInterest
from .views import LanguageDropdownView from .views import LanguageDropdownView
@ -71,9 +71,28 @@ class CaseStudyViewSet(viewsets.ModelViewSet):
serializer_class = CaseStudySerializer serializer_class = CaseStudySerializer
class PointOfInterestSerializer(gis_serializers.GeoFeatureModelSerializer):
class Meta:
model = PointOfInterest
geo_field = "location"
fields = (
'title',
'synopsis',
'link',
'slug'
)
class PointOfInterestViewSet(viewsets.ModelViewSet):
queryset = PointOfInterest.objects.approved()
serializer_class = PointOfInterestSerializer
apirouter = routers.DefaultRouter() apirouter = routers.DefaultRouter()
#apirouter.register(r'users', UserViewSet) #apirouter.register(r'users', UserViewSet)
apirouter.register(r'case-studies', CaseStudyViewSet) apirouter.register(r'case-studies', CaseStudyViewSet)
apirouter.register(r'points-of-interest', PointOfInterestViewSet)
urlpatterns = [ urlpatterns = [
url(r'api/', include(apirouter.urls)), url(r'api/', include(apirouter.urls)),