Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • geocontrib/geocontrib-frontend
  • ext_matthieu/geocontrib-frontend
  • fnecas/geocontrib-frontend
  • MatthieuE/geocontrib-frontend
4 results
Show changes
......@@ -220,11 +220,32 @@
class="ui tab active map-container"
data-tab="map"
>
<div
:class="{ active: mapLoading }"
class="ui inverted dimmer"
>
<div class="ui loader" />
</div>
<div
id="map"
ref="map"
/>
<div
id="popup"
class="ol-popup"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="popup-content"
/>
</div>
<SidebarLayers v-if="basemaps && map" />
<EditingToolbar v-if="basemaps && map" />
</div>
</div>
......@@ -315,21 +336,21 @@
<script>
import { mapState, mapGetters } from 'vuex';
import L from 'leaflet';
import 'leaflet-draw';
import axios from '@/axios-client.js';
import flip from '@turf/flip';
import featureAPI from '@/services/feature-api';
import { mapUtil } from '@/assets/js/map-util.js';
import { allowedStatus2change } from '@/utils';
import FeatureAttachmentForm from '@/components/Feature/FeatureAttachmentForm';
import FeatureLinkedForm from '@/components/Feature/FeatureLinkedForm';
import FeatureExtraForm from '@/components/Feature/Edit/FeatureExtraForm';
import Dropdown from '@/components/Dropdown.vue';
import SidebarLayers from '@/components/SidebarLayers';
import SidebarLayers from '@/components/Map/SidebarLayers';
import EditingToolbar from '@/components/Map/EditingToolbar';
import featureAPI from '@/services/feature-api';
import mapService from '@/services/map-service';
import editionService from '@/services/edition-service';
import { allowedStatus2change } from '@/utils';
import axios from '@/axios-client.js';
import { GeoJSON } from 'ol/format';
export default {
name: 'FeatureEdit',
......@@ -339,12 +360,14 @@ export default {
FeatureLinkedForm,
Dropdown,
SidebarLayers,
EditingToolbar,
FeatureExtraForm,
},
data() {
return {
map: null,
mapLoading: false,
baseUrl: this.$store.state.configuration.BASE_URL,
file: null,
showGeoRef: false,
......@@ -503,13 +526,6 @@ export default {
this.initMap();
this.onFeatureTypeLoaded();
this.initExtraForms(this.feature);
setTimeout(
function () {
mapUtil.addGeocoders(this.$store.state.configuration);
}.bind(this),
1000
);
});
},
......@@ -538,15 +554,23 @@ export default {
this.updateStore();
}
},
addPointToCoordinates(coordinates){
let json = {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: coordinates,
},
properties: {},
};
this.updateMap(json);
this.updateGeomField(json.geometry);
this.showGeoPositionBtn = false;
this.erreurGeolocalisationMessage = '';
},
create_point_geoposition() {
function success(position) {
const latitude = position.coords.latitude;
const longitude = position.coords.longitude;
var layer = L.circleMarker([latitude, longitude]);
this.add_layer_call_back(layer);
this.map.setView([latitude, longitude]);
this.addPointToCoordinates([position.coords.longitude, position.coords.latitude]);
}
function error(err) {
......@@ -596,16 +620,7 @@ export default {
if (response.data.geom.indexOf('POINT') >= 0) {
const regexp = /POINT\s\((.*)\s(.*)\)/;
const arr = regexp.exec(response.data.geom);
const json = {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [parseFloat(arr[1]), parseFloat(arr[2])],
},
properties: {},
};
this.updateMap(json);
this.updateGeomField(json);
this.addPointToCoordinates([parseFloat(arr[1]), parseFloat(arr[2])]);
// Set Attachment
this.addAttachment({
title: 'Localisation',
......@@ -777,175 +792,23 @@ export default {
//* ************* MAP *************** *//
onFeatureTypeLoaded() {
var geomLeaflet = {
point: 'circlemarker',
linestring: 'polyline',
polygon: 'polygon',
};
var geomType = this.feature_type.geom_type;
var drawConfig = {
polygon: false,
marker: false,
polyline: false,
rectangle: false,
circle: false,
circlemarker: false,
};
drawConfig[geomLeaflet[geomType]] = true;
L.drawLocal = {
draw: {
toolbar: {
actions: {
title: 'Annuler le dessin',
text: 'Annuler',
},
finish: {
title: 'Terminer le dessin',
text: 'Terminer',
},
undo: {
title: 'Supprimer le dernier point dessiné',
text: 'Supprimer le dernier point',
},
buttons: {
polyline: 'Dessiner une polyligne',
polygon: 'Dessiner un polygone',
rectangle: 'Dessiner un rectangle',
circle: 'Dessiner un cercle',
marker: 'Dessiner une balise',
circlemarker: 'Dessiner un point',
},
},
handlers: {
circle: {
tooltip: {
start: 'Cliquer et glisser pour dessiner le cercle.',
},
radius: 'Rayon',
},
circlemarker: {
tooltip: {
start: 'Cliquer sur la carte pour placer le point.',
},
},
marker: {
tooltip: {
start: 'Cliquer sur la carte pour placer la balise.',
},
},
polygon: {
tooltip: {
start: 'Cliquer pour commencer à dessiner.',
cont: 'Cliquer pour continuer à dessiner.',
end: 'Cliquer sur le premier point pour terminer le dessin.',
},
},
polyline: {
error: '<strong>Error:</strong> shape edges cannot cross!',
tooltip: {
start: 'Cliquer pour commencer à dessiner.',
cont: 'Cliquer pour continuer à dessiner.',
end: 'Cliquer sur le dernier point pour terminer le dessin.',
},
},
rectangle: {
tooltip: {
start: 'Cliquer et glisser pour dessiner le rectangle.',
},
},
simpleshape: {
tooltip: {
end: 'Relâcher la souris pour terminer de dessiner.',
},
},
},
},
edit: {
toolbar: {
actions: {
save: {
title: 'Sauver les modifications',
text: 'Sauver',
},
cancel: {
title:
'Annuler la modification, annule toutes les modifications',
text: 'Annuler',
},
clearAll: {
title: "Effacer l'objet",
text: 'Effacer',
},
},
buttons: {
edit: "Modifier l'objet",
editDisabled: 'Aucun objet à modifier',
remove: "Supprimer l'objet",
removeDisabled: 'Aucun objet à supprimer',
},
},
handlers: {
edit: {
tooltip: {
text: "Faites glisser les marqueurs ou les balises pour modifier l'élément.",
subtext: 'Cliquez sur Annuler pour annuler les modifications..',
},
},
remove: {
tooltip: {
text: 'Cliquez sur un élément pour le supprimer.',
},
},
},
},
};
this.drawnItems = new L.FeatureGroup();
this.map.addLayer(this.drawnItems);
this.drawControlFull = new L.Control.Draw({
position: 'topright',
edit: {
featureGroup: this.drawnItems,
},
draw: drawConfig,
editionService.addEditionControls(geomType);
editionService.draw.on('drawend', (evt) => {
var feature = evt.feature;
this.updateGeomField(new GeoJSON().writeGeometry(feature.getGeometry(),{ dataProjection:'EPSG:4326',featureProjection:'EPSG:3857' }));
if (this.feature_type.geomType === 'point') {
this.showGeoPositionBtn = false;
this.erreurGeolocalisationMessage = '';
}
});
this.drawControlEditOnly = new L.Control.Draw({
position: 'topright',
edit: {
featureGroup: this.drawnItems,
},
draw: false,
editionService.modify.on('modifyend', (evt) => {
let feature = evt.features.getArray()[0];
this.updateGeomField(new GeoJSON().writeGeometry(feature.getGeometry(),{ dataProjection:'EPSG:4326',featureProjection:'EPSG:3857' }));
});
//this.changeMobileBtnOrder();
if (this.currentRouteName === 'editer-signalement') {
this.map.addControl(this.drawControlEditOnly);
} else {
this.map.addControl(this.drawControlFull);
}
this.changeMobileBtnOrder();
this.map.on(
'draw:created',
function (e) {
var layer = e.layer;
this.add_layer_call_back(layer);
}.bind(this)
);
//var wellknown;// TODO Remplacer par autre chose
this.map.on(
'draw:edited',
function (e) {
var layers = e.layers;
const self = this;
layers.eachLayer(function (layer) {
self.updateGeomField(layer.toGeoJSON());
});
}.bind(this)
);
this.map.on(
'draw:deleted',
......@@ -962,20 +825,13 @@ export default {
},
updateMap(geomFeatureJSON) {
if (this.drawnItems) {
this.drawnItems.clearLayers();
if (editionService.drawSource) {
editionService.drawSource.clear();
}
var geomType = this.feature_type.geom_type;
if (geomFeatureJSON) {
var geomJSON = flip(geomFeatureJSON.geometry);
if (geomType === 'point') {
L.circleMarker(geomJSON.coordinates).addTo(this.drawnItems);
} else if (geomType === 'linestring') {
L.polyline(geomJSON.coordinates).addTo(this.drawnItems);
} else if (geomType === 'polygon') {
L.polygon(geomJSON.coordinates).addTo(this.drawnItems);
}
this.map.fitBounds(this.drawnItems.getBounds(), { padding: [25, 25] });
let retour = new GeoJSON().readFeature(geomFeatureJSON,{ dataProjection:'EPSG:4326',featureProjection:'EPSG:3857' });
editionService.startEditFeature(retour);
} else {
this.map.setView(
this.$store.state.configuration.DEFAULT_MAP_VIEW.center,
......@@ -985,32 +841,24 @@ export default {
},
updateGeomField(newGeom) {
this.form.geom.value = newGeom.geometry;
this.form.geom.value = newGeom;
this.updateStore();
},
initMap() {
this.mapLoading = true;
var mapDefaultViewCenter =
this.$store.state.configuration.DEFAULT_MAP_VIEW.center;
var mapDefaultViewZoom =
this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom;
// Create the map, then init the layers and features
this.map = mapUtil.createMap(this.$refs.map, {
this.map = mapService.createMap(this.$refs.map, {
mapDefaultViewCenter,
mapDefaultViewZoom,
interactions : { doubleClickZoom :false, mouseWheelZoom:true, dragPan:true }
});
const currentFeatureId = this.$route.params.slug_signal;
setTimeout(() => {
const project_id = this.$route.params.slug.split('-')[0];
const mvtUrl = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}features.mvt/?tile={z}/{x}/{y}&project_id=${project_id}`;
mapUtil.addVectorTileLayer(
mvtUrl,
this.$route.params.slug,
this.feature_types
);
}, 1000);
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.$route.params.slug}/feature/?feature_type__slug=${this.$route.params.slug_type_signal}&output=geojson`;
axios
.get(url)
......@@ -1020,7 +868,7 @@ export default {
const allFeaturesExceptCurrent = features.filter(
(feat) => feat.id !== currentFeatureId
);
mapUtil.addFeatures(
mapService.addFeatures(
allFeaturesExceptCurrent,
{},
true,
......@@ -1033,30 +881,21 @@ export default {
this.updateMap(currentFeature);
}
}
this.mapLoading = false;
})
.catch((error) => {
this.mapLoading = false;
throw error;
});
document.addEventListener('change-layers-order', (event) => {
// Reverse is done because the first layer in order has to be added in the map in last.
// Slice is done because reverse() changes the original array, so we make a copy first
mapUtil.updateOrder(event.detail.layers.slice().reverse());
mapService.updateOrder(event.detail.layers.slice().reverse());
});
},
add_layer_call_back(layer) {
layer.addTo(this.drawnItems);
this.drawControlFull.remove(this.map);
this.drawControlEditOnly.addTo(this.map);
//var wellknown;// TODO Remplacer par autre chose
this.updateGeomField(layer.toGeoJSON());
if (this.feature_type.geomType === 'point') {
this.showGeoPositionBtn = false;
this.erreurGeolocalisationMessage = '';
}
},
// TODO DDS voir adaptations
changeMobileBtnOrder() { //* move large toolbar for polygon creation, cutting map in the middle
function changeDisplay() {
const buttons = document.querySelector('.leaflet-draw-actions.leaflet-draw-actions-top.leaflet-draw-actions-bottom');
......
......@@ -103,7 +103,10 @@
@change="onGeojsonFileChange"
>
</div>
<div class="field">
<div
v-if="structure.geom_type === 'point'"
class="field"
>
<label
class="ui icon button ellipsis"
for="csv_file"
......@@ -195,11 +198,15 @@
<option value="GeoJSON">
GeoJSON
</option>
<option value="CSV">
<option
v-if="structure.geom_type === 'point'"
value="CSV"
>
CSV
</option>
</select>
<button
:class="{ loading: exportLoading }"
type="button"
class="ui fluid teal icon button"
@click="exportFeatures"
......@@ -394,7 +401,8 @@ export default {
loadingImportFile: false,
waitMessage: false,
reloadingImport: false,
exportFormat: 'GeoJSON'
exportFormat: 'GeoJSON',
exportLoading: false
};
},
......@@ -773,20 +781,26 @@ export default {
},
exportFeatures() {
this.exportLoading = true;
const url = `
${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature-type/${this.$route.params.feature_type_slug}/export/?format_export=${this.exportFormat.toLowerCase()}
`;
featureAPI.getFeaturesBlob(url).then((blob) => {
if (blob) {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${this.project.title}-${this.structure.title}.${this.exportFormat === 'GeoJSON' ? 'json' : 'csv'}`;
link.click();
setTimeout(function(){
URL.revokeObjectURL(link.href);
}, 1000);
}
});
featureAPI.getFeaturesBlob(url)
.then((blob) => {
if (blob) {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${this.project.title}-${this.structure.title}.${this.exportFormat === 'GeoJSON' ? 'json' : 'csv'}`;
link.click();
setTimeout(function(){
URL.revokeObjectURL(link.href);
}, 1000);
}
this.exportLoading = false;
})
.catch(() => {
this.exportLoading = false;
});
},
async getLastFeatures(){
const response = await
......
......@@ -151,7 +151,7 @@
class="white save icon"
aria-hidden="true"
/>
Créer et importer le(s) signalement(s) du geojson
Créer et importer le(s) signalement(s) du {{ geojson ? 'geojson' : 'csv' }}
</button>
</div>
</form>
......
......@@ -25,8 +25,21 @@
ref="map"
/>
<SidebarLayers v-if="basemaps && map" />
<Geocoder />
<div
id="popup"
class="ol-popup"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="popup-content"
/>
</div>
</div>
<FeatureListTable
v-else
:paginated-features="paginatedFeatures"
......@@ -38,42 +51,43 @@
@update:page="handlePageChange"
@update:sort="handleSortChange"
/>
</div>
<!-- MODAL ALL DELETE FEATURE TYPE -->
<div
v-if="isDeleteModalOpen"
class="ui dimmer modals page transition visible active"
style="display: flex !important"
>
<!-- MODAL ALL DELETE FEATURE TYPE -->
<div
:class="[
'ui mini modal subscription',
{ 'active visible': isDeleteModalOpen },
]"
v-if="isDeleteModalOpen"
class="ui dimmer modals page transition visible active"
style="display: flex !important"
>
<i
class="close icon"
aria-hidden="true"
@click="isDeleteModalOpen = false"
/>
<div class="ui icon header">
<div
:class="[
'ui mini modal subscription',
{ 'active visible': isDeleteModalOpen },
]"
>
<i
class="trash alternate icon"
class="close icon"
aria-hidden="true"
@click="isDeleteModalOpen = false"
/>
Êtes-vous sûr de vouloir effacer
<span v-if="checkedFeatures.length === 1"> un signalement ? </span>
<span v-else> ces {{ checkedFeatures.length }} signalements ? </span>
</div>
<div class="actions">
<button
type="button"
class="ui red compact fluid button"
@click="deleteAllFeatureSelection"
>
Confirmer la suppression
</button>
<div class="ui icon header">
<i
class="trash alternate icon"
aria-hidden="true"
/>
Êtes-vous sûr de vouloir effacer
<span v-if="checkedFeatures.length === 1"> un signalement ? </span>
<span v-else> ces {{ checkedFeatures.length }} signalements ? </span>
</div>
<div class="actions">
<button
type="button"
class="ui red compact fluid button"
@click="deleteAllFeatureSelection"
>
Confirmer la suppression
</button>
</div>
</div>
</div>
</div>
......@@ -83,12 +97,12 @@
<script>
import { mapState, mapActions, mapMutations } from 'vuex';
import { mapUtil } from '@/assets/js/map-util.js';
import mapService from '@/services/map-service';
import Geocoder from '@/components/Map/Geocoder';
import featureAPI from '@/services/feature-api';
import FeaturesListAndMapFilters from '@/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters';
import SidebarLayers from '@/components/SidebarLayers';
import SidebarLayers from '@/components/Map/SidebarLayers';
import FeatureListTable from '@/components/Project/FeaturesListAndMap/FeatureListTable';
const initialPagination = {
......@@ -104,6 +118,7 @@ export default {
components: {
FeaturesListAndMapFilters,
SidebarLayers,
Geocoder,
FeatureListTable,
},
......@@ -163,20 +178,20 @@ export default {
},
watch: {
map(newValue) {
/*map(newValue) {
if (newValue && this.paginatedFeatures && this.paginatedFeatures.length) {
if (this.currentLayer) {
this.map.removeLayer(this.currentLayer);
}
this.currentLayer = mapUtil.addFeatures(
this.currentLayer = mapService.addFeatures(
this.paginatedFeatures,
{},
true,
this.feature_types
);
}
},
paginatedFeatures: {
},*/
/*paginatedFeatures: {
deep: true,
handler(newValue, oldValue) {
if (newValue && newValue.length && newValue !== oldValue && this.map) {
......@@ -184,7 +199,7 @@ export default {
this.map.removeLayer(this.currentLayer);
this.currentLayer = null;
}
this.currentLayer = mapUtil.addFeatures(
this.currentLayer = mapService.addFeatures(
newValue,
{},
true,
......@@ -197,7 +212,7 @@ export default {
}
}
}
},
},*/
},
mounted() {
......@@ -210,7 +225,7 @@ export default {
} else {
this.initMap();
}
this.fetchPagedFeatures();
window.addEventListener('mousedown', this.clickOutsideDropdown);
},
......@@ -322,12 +337,8 @@ export default {
},
onFilterChange() {
if (mapUtil.getMap()) {
mapUtil.getMap().invalidateSize();
mapUtil.getMap()._onResize(); // force refresh for vector tiles
if (window.layerMVT) {
window.layerMVT.redraw();
}
if (mapService.getMap() && mapService.mvtLayer) {
mapService.mvtLayer.changed();
}
},
......@@ -341,44 +352,46 @@ export default {
var mapDefaultViewZoom =
this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom;
this.map = mapUtil.createMap(this.$refs.map, {
this.map = mapService.createMap(this.$refs.map, {
zoom: this.zoom,
lat: this.lat,
lng: this.lng,
mapDefaultViewCenter,
mapDefaultViewZoom,
interactions : { doubleClickZoom :false,mouseWheelZoom:true,dragPan:true }
});
this.fetchBboxNfit();
//this.fetchBboxNfit(); cette methode est appelée a nouveau par la suite donc pas utile ici
document.addEventListener('change-layers-order', (event) => {
// Reverse is done because the first layer in order has to be added in the map in last.
// Slice is done because reverse() changes the original array, so we make a copy first
mapUtil.updateOrder(event.detail.layers.slice().reverse());
mapService.updateOrder(event.detail.layers.slice().reverse());
});
// --------- End sidebar events ----------
let self=this;
setTimeout(() => {
const project_id = this.projectSlug.split('-')[0];
const mvtUrl = `${this.API_BASE_URL}features.mvt/?tile={z}/{x}/{y}&project_id=${project_id}`;
mapUtil.addVectorTileLayer(
const mvtUrl = `${this.API_BASE_URL}features.mvt/`;
mapService.addVectorTileLayer(
mvtUrl,
this.projectSlug,
this.feature_types,
this.form
project_id,
self.projectSlug,
self.feature_types,
self.form
);
mapUtil.addGeocoders(this.$store.state.configuration);
}, 1000);
this.fetchPagedFeatures();
},
fetchBboxNfit(queryParams) {
featureAPI
.getFeaturesBbox(this.projectSlug, queryParams)
.then((bbox) => {
const map = mapUtil.getMap();
if (bbox && map) {
map.fitBounds(bbox, { padding: [25, 25] });
if (bbox) {
mapService.fitBounds(bbox);
}
});
},
......@@ -445,8 +458,8 @@ export default {
//* bbox needs to be updated with the same filters
if (this.paginatedFeatures.length) {
this.fetchBboxNfit(queryString);
this.onFilterChange(); //* use paginated event to watch change in filters and modify features on map
}
this.onFilterChange(); //* use paginated event to watch change in filters and modify features on map
this.$store.commit('DISCARD_LOADER');
});
},
......
......@@ -61,7 +61,7 @@
/>
</div>
<div class="eight wide column">
<div class="eight wide column map-container">
<div
:class="{ active: mapLoading }"
class="ui inverted dimmer"
......@@ -70,10 +70,36 @@
Chargement de la carte...
</div>
</div>
<div
id="map"
ref="map"
/>
<div
class="ui button fluid teal"
@click="$router.push({
name: 'liste-signalements',
params: { slug: slug },
})"
>
<i class="ui icon arrow right" />
Voir tous les signalements
</div>
<div
id="popup"
class="ol-popup"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="popup-content"
/>
</div>
</div>
</div>
......@@ -118,7 +144,7 @@
</template>
<script>
import { mapUtil } from '@/assets/js/map-util.js';
import mapService from '@/services/map-service';
import { mapGetters, mapState, mapActions, mapMutations } from 'vuex';
import projectAPI from '@/services/project-api';
......@@ -282,10 +308,8 @@ export default {
this.DISCARD_LOADER();
this.projectInfoLoading = false;
setTimeout(() => {
const map = mapUtil.getMap();
if (map) {
map.remove();
}
let map = mapService.getMap();
if (map) mapService.destroyMap();
this.initMap();
}, 1000);
})
......@@ -412,10 +436,11 @@ export default {
if (this.project && this.permissions.can_view_project) {
await this.INITIATE_MAP(this.$refs.map);
this.checkForOfflineFeature();
const project_id = this.$route.params.slug.split('-')[0];
const mvtUrl = `${this.API_BASE_URL}features.mvt/?tile={z}/{x}/{y}&project_id=${project_id}`;
mapUtil.addVectorTileLayer(
let project_id = this.$route.params.slug.split('-')[0];
const mvtUrl = `${this.API_BASE_URL}features.mvt/`;
mapService.addVectorTileLayer(
mvtUrl,
project_id,
this.$route.params.slug,
this.feature_types
);
......@@ -431,7 +456,7 @@ export default {
})
.then(() => {
this.featuresLoading = false;
mapUtil.addFeatures(
mapService.addFeatures(
[...this.features, ...featuresOffline],
{},
true,
......@@ -445,7 +470,7 @@ export default {
featureAPI.getFeaturesBbox(this.slug).then((bbox) => {
if (bbox) {
mapUtil.getMap().fitBounds(bbox, { padding: [25, 25] });
mapService.fitBounds(bbox);
}
});
}
......@@ -454,10 +479,19 @@ export default {
};
</script>
<style scoped>
<style lang="less" scoped>
.fullwidth {
width: 100%;
}
.map-container {
display: flex !important;
flex-direction: column;
.button {
margin-top: 0.5em;
}
}
</style>
......@@ -62,7 +62,10 @@
id="form-members"
class="ui form"
>
<table class="ui red table">
<table
class="ui red table"
aria-describedby="Table des membres du projet"
>
<thead>
<tr>
<th scope="col">
......
......@@ -19,6 +19,7 @@
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
alt="Image associé au projet"
>
</div>
<div class="middle aligned content">
......