Merge pull request #444 from pierotofy/osm-editor-plugin

OSM quick editor plugin
pull/451/head v0.5.2
Piero Toffanin 2018-05-04 13:44:59 -04:00 zatwierdzone przez GitHub
commit 5cd19f8a7c
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
58 zmienionych plików z 243 dodań i 212 usunięć

Wyświetl plik

@ -99,18 +99,21 @@ def add_default_presets():
Preset.objects.update_or_create(name='DSM + DTM', system=True,
defaults={
'options': [{'name': 'dsm', 'value': True}, {'name': 'dtm', 'value': True},
{'name': 'mesh-octree-depth', 'value': 6}]})
{'name': 'mesh-octree-depth', 'value': 6},
{'name': 'mesh-solver-divide', 'value': 6}]})
Preset.objects.update_or_create(name='Fast Orthophoto', system=True,
defaults={'options': [{'name': 'fast-orthophoto', 'value': True}]})
Preset.objects.update_or_create(name='High Quality', system=True,
defaults={'options': [{'name': 'dsm', 'value': True},
{'name': 'mesh-octree-depth', 'value': "12"},
{'name': 'mesh-octree-depth', 'value': 6},
{'name': 'mesh-solver-divide', 'value': 6},
{'name': 'dem-resolution', 'value': "0.04"},
{'name': 'orthophoto-resolution', 'value': "40"},
]})
Preset.objects.update_or_create(name='Default', system=True,
defaults={'options': [{'name': 'dsm', 'value': True},
{'name': 'mesh-octree-depth', 'value': 6}]})
{'name': 'mesh-octree-depth', 'value': 6},
{'name': 'mesh-solver-divide', 'value': 6}]})
except MultipleObjectsReturned:
# Mostly to handle a legacy code problem where
# multiple system presets with the same name were

Wyświetl plik

@ -105,7 +105,7 @@ class MapView extends React.Component {
opacity={opacity}
mapType={this.state.selectedMapType}
public={this.props.public} />
<div className="opacity-slider theme-secondary">
<div className="opacity-slider theme-secondary hidden-xs">
Opacity: <input type="range" step="1" value={opacity} onChange={this.updateOpacity} />
</div>
</div>);

Wyświetl plik

@ -237,7 +237,6 @@ class ModelView extends React.Component {
{showSwitchModeButton ?
<SwitchModeButton
public={this.props.public}
style={{marginLeft: this.props.public ? '0' : '76px'}}
task={this.props.task}
type="modelToMap" /> : ""}
</div>

Wyświetl plik

@ -8,7 +8,6 @@ export default class ApiFactory{
// @param api {Object}
create(api){
// Adds two functions to obj
// - eventName
// - triggerEventName
@ -16,6 +15,14 @@ export default class ApiFactory{
// are more robust as we can detect more easily if
// things break
const addEndpoint = (obj, eventName, preTrigger = () => {}) => {
const emitResponse = (...args) => {
// Timeout needed for modules that have no dependencies
// and load synchronously. Gives time to setup the listeners.
setTimeout(() => {
this.events.emit(`${api.namespace}::${eventName}::Response`, ...args);
}, 0);
};
obj[eventName] = (callbackOrDeps, callbackOrUndef) => {
if (Array.isArray(callbackOrDeps)){
// Deps
@ -24,20 +31,23 @@ export default class ApiFactory{
this.events.addListener(`${api.namespace}::${eventName}`, (...args) => {
Promise.all(callbackOrDeps.map(dep => SystemJS.import(dep)))
.then((...deps) => {
callbackOrUndef(...(Array.from(args).concat(...deps)));
emitResponse(callbackOrUndef(...(Array.from(args).concat(...deps))));
});
});
}else{
// Callback
this.events.addListener(`${api.namespace}::${eventName}`, callbackOrDeps);
this.events.addListener(`${api.namespace}::${eventName}`, (...args) => {
emitResponse(callbackOrDeps(...args));
});
}
}
const triggerEventName = "trigger" + eventName[0].toUpperCase() + eventName.slice(1);
obj[triggerEventName] = (...args) => {
preTrigger(...args);
this.events.emit(`${api.namespace}::${eventName}`, ...args);
obj[triggerEventName] = (params, responseCb) => {
preTrigger(params, responseCb);
this.events.emit(`${api.namespace}::${eventName}`, params);
if (responseCb) this.events.addListener(`${api.namespace}::${eventName}::Response`, responseCb);
};
}

Wyświetl plik

@ -5,6 +5,7 @@ const { assert } = Utils;
const leafletPreCheck = (options) => {
assert(options.map !== undefined);
assert(options.tiles !== undefined);
};
export default {
@ -12,7 +13,8 @@ export default {
endpoints: [
["willAddControls", leafletPreCheck],
["didAddControls", leafletPreCheck]
["didAddControls", leafletPreCheck],
["addActionButton", leafletPreCheck],
]
};

Wyświetl plik

@ -14,6 +14,7 @@ import ShareButton from './ShareButton';
import AssetDownloads from '../classes/AssetDownloads';
import PropTypes from 'prop-types';
import PluginsAPI from '../classes/plugins/API';
import update from 'immutability-helper';
class Map extends React.Component {
static defaultProps = {
@ -40,7 +41,8 @@ class Map extends React.Component {
this.state = {
error: "",
singleTask: null // When this is set to a task, show a switch mode button to view the 3d model
singleTask: null, // When this is set to a task, show a switch mode button to view the 3d model
pluginActionButtons: []
};
this.imageryLayers = [];
@ -167,7 +169,7 @@ class Map extends React.Component {
}
componentDidMount() {
const { showBackground } = this.props;
const { showBackground, tiles } = this.props;
this.map = Leaflet.map(this.container, {
scrollWheelZoom: true,
@ -176,7 +178,8 @@ class Map extends React.Component {
});
PluginsAPI.Map.triggerWillAddControls({
map: this.map
map: this.map,
tiles
});
Leaflet.control.scale({
@ -236,11 +239,18 @@ class Map extends React.Component {
});
});
// PluginsAPI.events.addListener('Map::AddPanel', (e) => {
// console.log("Received response: " + e);
// });
PluginsAPI.Map.triggerDidAddControls({
map: this.map
map: this.map,
tiles: tiles
});
PluginsAPI.Map.triggerAddActionButton({
map: this.map,
tiles
}, (button) => {
this.setState(update(this.state, {
pluginActionButtons: {$push: [button]}
}));
});
}
@ -251,9 +261,7 @@ class Map extends React.Component {
});
if (prevProps.tiles !== this.props.tiles){
this.loadImageryLayers().then(() => {
// console.log("GOT: ", this.autolayers, this.autolayers.selectedOverlays);
});
this.loadImageryLayers();
}
}
@ -268,7 +276,7 @@ class Map extends React.Component {
handleMapMouseDown(e){
// Make sure the share popup closes
if (this.sharePopup) this.shareButton.hidePopup();
if (this.shareButton) this.shareButton.hidePopup();
}
render() {
@ -285,6 +293,7 @@ class Map extends React.Component {
<div className="actionButtons">
{this.state.pluginActionButtons.map((button, i) => <div key={i}>{button}</div>)}
{(!this.props.public && this.state.singleTask !== null) ?
<ShareButton
ref={(ref) => { this.shareButton = ref; }}

Wyświetl plik

@ -71,65 +71,66 @@ class SharePopup extends React.Component{
const iframeUrl = Utils.absoluteUrl(`public/task/${this.state.task.id}/iframe/${this.props.linksTarget}/`);
const iframeCode = `<iframe scrolling="no" title="WebODM" width="61.8033%" height="360" frameBorder="0" src="${iframeUrl}"></iframe>`;
return (<div onMouseDown={e => { e.stopPropagation(); }}
className={"sharePopup popover in " + this.props.placement}>
<div className="arrow"></div>
<h3 className="popover-title theme-background-highlight">Share This Task</h3>
<div className="popover-content theme-secondary">
<ErrorMessage bind={[this, 'error']} />
<div className="checkbox">
<button type="button"
className={"btn btn-qrcode btn-sm " +
(this.state.showQR ? "btn-primary " : "btn-default ") +
(!this.state.task.public ? "hide" : "")}
onClick={this.toggleQRCode}>
<i className="fa fa-qrcode"></i> QR
</button>
return (<div onMouseDown={e => { e.stopPropagation(); }} className={"sharePopup " + this.props.placement}>
<div className={"sharePopupContainer popover in " + this.props.placement}>
<div className="arrow"></div>
<h3 className="popover-title theme-background-highlight">Share This Task</h3>
<div className="popover-content theme-secondary">
<ErrorMessage bind={[this, 'error']} />
<div className="checkbox">
<button type="button"
className={"btn btn-qrcode btn-sm " +
(this.state.showQR ? "btn-primary " : "btn-default ") +
(!this.state.task.public ? "hide" : "")}
onClick={this.toggleQRCode}>
<i className="fa fa-qrcode"></i> QR
</button>
<label onClick={this.handleEnableSharing}>
{this.state.togglingShare ?
<i className="fa fa-refresh fa-spin fa-fw"></i>
: ""}
<label onClick={this.handleEnableSharing}>
{this.state.togglingShare ?
<i className="fa fa-refresh fa-spin fa-fw"></i>
: ""}
<input
className={this.state.togglingShare ? "hide" : ""}
type="checkbox"
checked={this.state.task.public}
onChange={() => {}}
/> Enabled
</label>
</div>
<div className={"share-links " + (this.state.task.public ? "show" : "")}>
<div className={"form-group " + (this.state.showQR ? "hide" : "")}>
<label>
Link:
<ClipboardInput
type="text"
className="form-control"
value={shareLink}
readOnly={true}
/>
<input
className={this.state.togglingShare ? "hide" : ""}
type="checkbox"
checked={this.state.task.public}
onChange={() => {}}
/> Enabled
</label>
</div>
<div className={"form-group " + (this.state.showQR ? "hide" : "")}>
<label>
HTML iframe:
<ClipboardInput
type="text"
className="form-control"
value={iframeCode}
readOnly={true}
/>
</label>
</div>
<div className={(this.state.showQR ? "" : "hide")}>
<QRCode
value={shareLink}
size={200}
bgColor={"#ffffff"}
fgColor={"#000000"}
level={"M"}
/>
<div className={"share-links " + (this.state.task.public ? "show" : "")}>
<div className={"form-group " + (this.state.showQR ? "hide" : "")}>
<label>
Link:
<ClipboardInput
type="text"
className="form-control"
value={shareLink}
readOnly={true}
/>
</label>
</div>
<div className={"form-group " + (this.state.showQR ? "hide" : "")}>
<label>
HTML iframe:
<ClipboardInput
type="text"
className="form-control"
value={iframeCode}
readOnly={true}
/>
</label>
</div>
<div className={(this.state.showQR ? "" : "hide")}>
<QRCode
value={shareLink}
size={200}
bgColor={"#ffffff"}
fgColor={"#000000"}
level={"M"}
/>
</div>
</div>
</div>
</div>

Wyświetl plik

@ -31,9 +31,14 @@
margin-bottom: 6px;
}
.shareButton{
.actionButtons{
position: absolute;
z-index: 2000;
bottom: 11px;
right: 38px;
right: 12px;
bottom: 20px;
& > *{
display: inline-block;
margin-left: 12px;
}
}
}

Wyświetl plik

@ -5,7 +5,7 @@
position: relative;
height: 100%;
canvas{
#potree_render_area > canvas{
width: 100% !important;
height: 100% !important;
}

Wyświetl plik

@ -1,5 +1,4 @@
.shareButton{
position: absolute;
button{
border-width: 1px;
}

Wyświetl plik

@ -1,55 +1,71 @@
.sharePopup{
position: relative;
display: block;
position: absolute;
&.top{
top: -54px;
bottom: 0;
}
&.bottom{
top: 32px;
top: 0;
}
right: 0;
pointer-events: none;
&.popover.top > .arrow{
left: auto;
right: 60px;
}
&.popover.bottom > .arrow{
left: 25px;
}
.sharePopupContainer{
pointer-events: auto;
position: relative;
display: block;
left: -26px;
min-width: 232px;
h3.popover-title{
padding-top: 8px;
}
.checkbox{
margin-top: 0;
}
.fa-refresh{
margin-left: -25px;
margin-right: 5px;
}
.btn-qrcode{
position: absolute;
right: 0px;
top: -6px;
}
.share-links{
& > div{
margin-top: 8px;
&:first-child{
margin-top: 4px;
}
&.top{
top: -43px;
}
max-height: 0;
overflow: hidden;
transition: max-height 1s ease-in-out;
&.show{
max-height: 800px;
height: auto;
&.bottom{
top: 153px;
left: -46px;
}
&.popover.top > .arrow{
left: auto;
right: 60px;
}
&.popover.bottom > .arrow{
left: 25px;
}
h3.popover-title{
padding-top: 8px;
}
.checkbox{
margin-top: 0;
}
.fa-refresh{
margin-left: -25px;
margin-right: 5px;
}
.btn-qrcode{
position: absolute;
right: 0px;
top: -6px;
}
.share-links{
& > div{
margin-top: 8px;
&:first-child{
margin-top: 4px;
}
}
max-height: 0;
overflow: hidden;
transition: max-height 1s ease-in-out;
&.show{
max-height: 800px;
height: auto;
}
}
}
}

Wyświetl plik

@ -1,7 +1,3 @@
.switchModeButton{
border-width: 1px;
position: absolute;
z-index: 2000;
bottom: 22px;
right: 12px;
}

Wyświetl plik

@ -5,7 +5,6 @@ ENV POSTGIS_MAJOR 2.3
RUN echo "deb http://deb.debian.org/debian jessie-backports main" >> /etc/apt/sources.list \
&& apt-get update \
&& apt-get install -t jessie-backports -y --no-install-recommends libsfcgal1 \
&& apt-get install -y --no-install-recommends \
postgresql-$PG_MAJOR-postgis-$POSTGIS_MAJOR \
postgresql-$PG_MAJOR-postgis-$POSTGIS_MAJOR-scripts \

Wyświetl plik

@ -36,8 +36,6 @@ module.exports = class MeasurePopup extends React.Component {
lastCoord.dd.x
));
console.log(layers);
// Did we select a layer?
if (layers.length > 0){
const layer = layers[layers.length - 1];

Wyświetl plik

@ -0,0 +1 @@
from .plugin import *

Wyświetl plik

@ -0,0 +1,13 @@
{
"name": "OSM Quick Editor Button",
"webodmMinVersion": "0.5.2",
"description": "A plugin to add a button for quickly opening OpenStreetMap's iD editor and setup a TMS basemap.",
"version": "0.1.0",
"author": "Piero Toffanin",
"email": "pt@masseranolabs.com",
"repository": "https://github.com/OpenDroneMap/WebODM",
"tags": ["osm", "editor"],
"homepage": "https://github.com/OpenDroneMap/WebODM",
"experimental": true,
"deprecated": false
}

Wyświetl plik

@ -0,0 +1,10 @@
from app.plugins import PluginBase, Menu, MountPoint
from django.shortcuts import render
class Plugin(PluginBase):
def include_js_files(self):
return ['main.js']

Wyświetl plik

@ -0,0 +1,33 @@
PluginsAPI.Map.addActionButton(function(options){
if (options.tiles.length > 0){
// TODO: pick the topmost layer instead
// of the first on the list, to support
// maps that display multiple tasks.
var tile = options.tiles[0];
var url = window.location.protocol + "//" +
window.location.host +
tile.url.replace(/tiles\.json$/, "tiles/{zoom}/{x}/{ty}.png");
return React.createElement("button", {
type: "button",
className: "btn btn-sm btn-secondary",
onClick: function(){
var mapLocation = options.map.getZoom() + "/" +
options.map.getCenter().lat + "/" +
options.map.getCenter().lng;
if (window.prompt("To start digitizing this map on OpenStreetMap:\n\n" +
"1. Copy the URL below.\n" +
"2.When the editor loads, open the Background Settings (press B) and select \"Custom\".\n" +
"3. Press \"Edit Custom Background\".\n" +
"4. Paste the URL you copied below.\n\n" +
"Press OK to go to OpenStreetMap", url)){
window.location.href = "https://www.openstreetmap.org/edit?editor=id#map=" + mapLocation;
}
}
}, React.createElement("i", {className: "fa fa-map"}, ""),
" OSM Digitize");
}
});

Wyświetl plik

@ -1,35 +0,0 @@
{
"main.css": "static/css/main.d9d37f4b.css",
"main.css.map": "static/css/main.d9d37f4b.css.map",
"main.js": "static/js/main.ce50390f.js",
"main.js.map": "static/js/main.ce50390f.js.map",
"static/media/add.png": "static/media/add.5a2714f3.png",
"static/media/add@2x.png": "static/media/add@2x.b53b9f2d.png",
"static/media/add_point.png": "static/media/add_point.e65f1d0c.png",
"static/media/add_point@2x.png": "static/media/add_point@2x.bf317640.png",
"static/media/add_point_green.png": "static/media/add_point_green.013c6b67.png",
"static/media/add_point_green@2x.png": "static/media/add_point_green@2x.1dd546dd.png",
"static/media/add_point_yellow.png": "static/media/add_point_yellow.a6d933c3.png",
"static/media/add_point_yellow@2x.png": "static/media/add_point_yellow@2x.5b290820.png",
"static/media/close.png": "static/media/close.729ab67b.png",
"static/media/close@2x.png": "static/media/close@2x.c65c9577.png",
"static/media/fit_markers.png": "static/media/fit_markers.be9754ad.png",
"static/media/fit_markers@2x.png": "static/media/fit_markers@2x.cf8c8fad.png",
"static/media/gcp-green.png": "static/media/gcp-green.cfc5c722.png",
"static/media/gcp-yellow.png": "static/media/gcp-yellow.3793065e.png",
"static/media/gcp.png": "static/media/gcp.44ed9ab1.png",
"static/media/layers-2x.png": "static/media/layers-2x.4f0283c6.png",
"static/media/layers.png": "static/media/layers.a6137456.png",
"static/media/loading.gif": "static/media/loading.e56d6770.gif",
"static/media/loading@2x.gif": "static/media/loading@2x.0ab4b1d1.gif",
"static/media/logo.png": "static/media/logo.b38a9426.png",
"static/media/marker-icon.png": "static/media/marker-icon.2273e3d8.png",
"static/media/point_icon.png": "static/media/point_icon.e206131a.png",
"static/media/point_icon@2x.png": "static/media/point_icon@2x.dd1da9a3.png",
"static/media/polygon_icon.png": "static/media/polygon_icon.83cffeed.png",
"static/media/polygon_icon@2x.png": "static/media/polygon_icon@2x.53277be6.png",
"static/media/providers.png": "static/media/providers.ad5af2f5.png",
"static/media/providers@2x.png": "static/media/providers@2x.51ed570c.png",
"static/media/search.png": "static/media/search.57a8b421.png",
"static/media/search@2x.png": "static/media/search@2x.44cf1bbe.png"
}

Plik binarny nie jest wyświetlany.

Przed

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

Wyświetl plik

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="shortcut icon" href="/plugins/posm-gcpi/favicon.ico">
<title>GCPi</title>
<link href="/plugins/posm-gcpi/static/css/main.d9d37f4b.css" rel="stylesheet">
<style type="text/css">
.header .logo{ zoom: 0.5; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/javascript" src="/plugins/posm-gcpi/static/js/main.ce50390f.js"></script>
</body>
</html>

Wyświetl plik

@ -0,0 +1,14 @@
{
"name": "posm-gcpi",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"webodm-posm-gcpi": "git+https://github.com/pierotofy/webodm-posm-gcpi.git"
}
}

File diff suppressed because one or more lines are too long

Wyświetl plik

@ -1 +0,0 @@
{"version":3,"sources":[],"names":[],"mappings":"","file":"static/css/main.d9d37f4b.css","sourceRoot":""}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Plik binarny nie jest wyświetlany.

Przed

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

Wyświetl plik

@ -1,5 +1,5 @@
{% extends "app/plugins/templates/base.html" %}
{% block content %}
<iframe src="index.html" style="border: none; display: block; min-height: 420px; width: 100%;" class="full-height"></iframe>
<iframe src="node_modules/webodm-posm-gcpi/index.html" style="border: none; display: block; min-height: 420px; width: 100%;" class="full-height"></iframe>
{% endblock %}