Skip to content
Snippets Groups Projects
ProjectEdit.vue 27.9 KiB
Newer Older
    <div
      :class="{ active: loading }"
      class="ui inverted dimmer"
    >
Florent's avatar
Florent committed
      <div class="ui text loader">
        Projet en cours de création. Vous allez être redirigé.
      </div>
    </div>
    <form
      id="form-project-edit"
      class="ui form"
    >
        <span
          v-if="action === 'edit'"
        >Édition du projet "{{ form.title }}"</span>
        <span v-else-if="action === 'create'">Création d'un projet</span>
      </h1>

      <div class="ui horizontal divider">
        INFORMATIONS
      </div>

      <div class="two fields">
        <div class="required field">
          <label for="title">Titre</label>
          <input
            id="title"
            v-model="form.title"
          >
          <ul
            id="errorlist-title"
            class="errorlist"
          >
            <li
              v-for="error in errors.title"
              :key="error"
            >
Timothee P's avatar
Timothee P committed
              {{ error }}
            </li>
          </ul>
        <div class="field file-logo">
Florent Lavelle's avatar
Florent Lavelle committed
            v-if="thumbnailFileSrc.length || form.thumbnail.length"
            class="ui small image"
            :src="
              thumbnailFileSrc
                ? thumbnailFileSrc
                : DJANGO_BASE_URL + form.thumbnail
            "
Florent Lavelle's avatar
Florent Lavelle committed
            alt="Thumbnail du projet"
          >
          <label
            class="ui icon button"
            for="thumbnail"
          >
Florent Lavelle's avatar
Florent Lavelle committed
            <i
              class="file icon"
              aria-hidden="true"
            />
            <span class="label">{{
              form.thumbnail_name ? form.thumbnail_name : fileToImport.name
            }}</span>
          </label>
          <input
            id="thumbnail"
            class="file-selection"
            type="file"
            accept="image/jpeg, image/png"
            style="display: none"
            name="thumbnail"
            @change="onFileChange"
          >
          <ul
            v-if="errorThumbnail.length"
            id="errorlist-thumbnail"
            class="errorlist"
          >
            <li>
              {{ errorThumbnail[0] }}
            </li>
          </ul>
      <div class="two fields">
        <div class="field">
          <label for="description">Description</label>
          <textarea
            id="editor"
            v-model="form.description"
            data-preview="#preview"
            name="description"
            rows="5"
          />
          <!-- {{ form.description.errors }} -->
        </div>
        <div class="field">
          <label for="preview">Aperçu</label>
          <div
            id="preview"
            class="description preview"
            name="preview"
          />
        </div>
      <div class="ui horizontal divider">
        PARAMÈTRES
      </div>
        <div
          id="published-visibility"
          class="required field"
        >
          <label
            for="access_level_pub_feature"
          >Visibilité des signalements publiés</label>
leandro's avatar
leandro committed
            :options="levelPermissionsPub"
            :selected="form.access_level_pub_feature.name"
            :selection.sync="form.access_level_pub_feature"
          />
          <ul
            id="errorlist-access_level_pub_feature"
            class="errorlist"
          >
            <li
              v-for="error in errors.access_level_pub_feature"
              :key="error"
            >
Timothee P's avatar
Timothee P committed
              {{ error }}
            </li>
          </ul>
        <div
          id="archived-visibility"
          class="required field"
        >
          <label for="access_level_arch_feature">
            Visibilité des signalements archivés
          </label>
          <Dropdown
            :options="levelPermissionsArc"
            :selected="form.access_level_arch_feature.name"
            :selection.sync="form.access_level_arch_feature"
          />
          <ul
            id="errorlist-access_level_arch_feature"
            class="errorlist"
          >
            <li
              v-for="error in errors.access_level_arch_feature"
              :key="error"
            >
Timothee P's avatar
Timothee P committed
              {{ error }}
            </li>
          </ul>
      <div class="two fields">
        <div class="fields grouped checkboxes">
          <div class="field">
            <div class="ui checkbox">
              <input
                id="moderation"
                v-model="form.moderation"
                class="hidden"
                type="checkbox"
                name="moderation"
              >
              <label for="moderation">Modération</label>
            </div>
          </div>
          <div class="field">
            <div class="ui checkbox">
              <input
                id="is_project_type"
                v-model="form.is_project_type"
                class="hidden"
                type="checkbox"
                name="is_project_type"
              >
              <label for="is_project_type">Est un projet type</label>
            </div>
          </div>
          <div class="field">
            <div class="ui checkbox">
              <input
                id="generate_share_link"
                v-model="form.generate_share_link"
                class="hidden"
                type="checkbox"
                name="generate_share_link"
              >
              <label for="generate_share_link">Génération d'un lien de partage externe</label>
            </div>
          </div>

          <div class="field">
            <div class="ui checkbox">
              <input
                id="fast_edition_mode"
                v-model="form.fast_edition_mode"
                class="hidden"
                type="checkbox"
                name="fast_edition_mode"
              >
              <label for="fast_edition_mode">Mode d'édition rapide de signalements</label>
Timothee P's avatar
Timothee P committed
              <a
                class="
                  ui
                  small
                  button
                  circular
                  compact
                  absolute-right
                  icon
                  teal
                "
                data-tooltip="Consulter la documentation"
                data-position="right center"
                data-variation="mini"
                href="https://geocontrib.readthedocs.io/fr/latest/documentation_fonctionnelle/feature_editing/"
Timothee P's avatar
Timothee P committed
                target="_blank"
                rel="noopener"
              >
                <i class="question icon" />
              </a>
            <div class="field">
              <label for="feature_browsing">Configuration du parcours de signalement</label>
            </div>
            <div
              id="feature_browsing_filter"
              class="field inline"
            >
              <label for="feature_browsing_default_filter">Filtrer sur</label>
              <Dropdown
                :options="featureBrowsingOptions.filter"
                :selected="form.feature_browsing_default_filter.name"
                :selection.sync="form.feature_browsing_default_filter"
              />
            </div>
            <div
              id="feature_browsing_sort"
              class="field inline"
            >
              <label for="feature_browsing_default_sort">Trier par</label>
              <Dropdown
                :options="featureBrowsingOptions.sort"
                :selected="form.feature_browsing_default_sort.name"
                :selection.sync="form.feature_browsing_default_sort"
              />
            </div>
          </div>
        <div class="field">
          <label>Niveau de zoom maximum de la carte</label>
          <div class="map-maxzoom-selector">
            <div class="range-container">
              <input
                v-model="form.map_max_zoom_level"
                type="range"
                min="0"
                max="22"
                step="1"
                @input="zoomMap"
              ><output class="range-output-bubble">{{
                scalesTable[form.map_max_zoom_level]
              }}</output>
            </div>
            <div class="map-preview">
              <label>Aperçu :</label>
              <div
                id="map"
                ref="map"
              />
              <div class="no-preview">
                pas de fond&nbsp;de&nbsp;carte disponible à&nbsp;cette&nbsp;échelle
              </div>
            </div>

      <div class="ui horizontal divider">
        ATTRIBUTS
      </div>
      <div class="fields grouped">
        <ProjectAttributeForm
          v-for="(attribute, index) in projectAttributes"
          :key="index"
          :attribute="attribute"
          :form-project-attributes="form.project_attributes"
          @update:project_attributes="updateProjectAttributes($event)"
      <div class="ui divider" />
      <button
        type="button"
        class="ui teal icon button"
        @click="postForm"
      >
Florent Lavelle's avatar
Florent Lavelle committed
        <i
          class="white save icon"
          aria-hidden="true"
        /> Enregistrer les changements
import axios from '@/axios-client.js';
import Dropdown from '@/components/Dropdown';
import ProjectAttributeForm from '@/components/Project/Edition/ProjectAttributeForm';
import mapService from '@/services/map-service';
import TextareaMarkdown from 'textarea-markdown';

import { mapActions, mapState } from 'vuex';
  name: 'ProjectEdit',
Florent's avatar
Florent committed
      loading: false,
      action: 'create',
        name: 'Sélectionner une image ...',
      errors_archive_feature: [],
Timothee P's avatar
Timothee P committed
      errors: {
        title: [],
        access_level_pub_feature: [],
        access_level_arch_feature: [],
      },
      errorThumbnail: [],
      featureBrowsingOptions: {
        filter: [{
          name: 'Désactivé',
          value: ''
        },
        {
          name: 'Type de signalement',
        }],
        sort: [{
          name: 'Date de création',
        },
        {
          name: 'Date de modification',
        title: '',
        slug: '',
        created_on: '',
        updated_on: '',
        description: '',
        thumbnail: '', // todo : utiliser l'image par défaut
        thumbnail_name: '', // todo: delete after getting image in jpg or png instead of data64 (require post to django)
        access_level_pub_feature: { name: '', value: '' },
        access_level_arch_feature: { name: '', value: '' },
        map_max_zoom_level: 22,
        nb_features: 0,
        nb_published_features: 0,
        nb_comments: 0,
        nb_published_features_comments: 0,
        nb_contributors: 0,
        is_project_type: false,
        generate_share_link: false,
        feature_browsing_default_filter: '',
        feature_browsing_default_sort: '-created_on',
        project_attributes: [],

      thumbnailFileSrc: '',
        '1:500 000 000',
        '1:250 000 000',
        '1:150 000 000',
        '1:70 000 000',
        '1:35 000 000',
        '1:15 000 000',
        '1:10 000 000',
        '1:4 000 000',
        '1:2 000 000',
        '1:1 000 000',
        '1:500 000',
        '1:250 000',
        '1:150 000',
        '1:70 000',
        '1:35 000',
        '1:15 000',
        '1:8 000',
        '1:4 000',
        '1:2 000',
        '1:1 000',
        '1:500',
        '1:250',
        '1:150',
      ]
leandro's avatar
leandro committed
    ...mapState([
      'levelsPermissions',
      'projectAttributes'
leandro's avatar
leandro committed
    ]),
    ...mapState('projects', ['project']),
Timothee P's avatar
Timothee P committed
    DJANGO_BASE_URL: function () {
      return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
    },
    levelPermissionsArc(){
Florent Lavelle's avatar
Florent Lavelle committed
      const levels = new Array();
      if(this.levelsPermissions) {
        this.levelsPermissions.forEach((item) => {
Florent Lavelle's avatar
Florent Lavelle committed
          if (item.user_type_id !== 'super_contributor') {
            levels.push({
              name: this.translateRoleToFrench(item.user_type_id), 
              value: item.user_type_id,
            });
Florent Lavelle's avatar
Florent Lavelle committed
          }
          if (!this.form.moderation && item.user_type_id === 'moderator') {
            levels.pop();
          }
        });
leandro's avatar
leandro committed
      }
      return levels;
    },
    levelPermissionsPub(){
Florent Lavelle's avatar
Florent Lavelle committed
      const levels = new Array();
      if (this.levelsPermissions) {
        this.levelsPermissions.forEach((item) => {
Florent Lavelle's avatar
Florent Lavelle committed
          if (
            item.user_type_id !== 'super_contributor' &&
            item.user_type_id !== 'admin' &&
            item.user_type_id !== 'moderator'
          ) {
            levels.push({
              name: this.translateRoleToFrench(item.user_type_id), 
              value: item.user_type_id,
            });
leandro's avatar
leandro committed
          }
leandro's avatar
leandro committed
        });
leandro's avatar
leandro committed
      }
      return levels;
  watch: {
    'form.moderation': function (newValue){
      if(!newValue && this.form.access_level_arch_feature.value === 'moderator') {
        this.form.access_level_arch_feature = { name: '', value: '' };
      }
    }
  },
    this.definePageType();
    if (this.action === 'create') {
      this.thumbnailFileSrc = require('@/assets/img/default.png');
      this.initPreviewMap();
    } else if (this.action === 'edit' || this.action === 'create_from') {
      if (!this.project) {
        this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug)
          .then((projet) => {
Florent Lavelle's avatar
Florent Lavelle committed
            if (projet) {
              this.fillProjectForm();
            }
          });
      } else {
        this.fillProjectForm();
      }
    let textarea = document.querySelector('textarea');
    new TextareaMarkdown(textarea);
  },

    ...mapActions('map', [
      'INITIATE_MAP'
    ]),
      if (this.$router.history.current.name === 'project_create') {
        this.action = 'create';
      } else if (this.$router.history.current.name === 'project_edit') {
        this.action = 'edit';
      } else if (this.$router.history.current.name === 'project_create_from') {
        this.action = 'create_from';
    translateRoleToFrench(role){
      switch (role) {
      case 'admin':
        return 'Administrateur projet';
      case 'moderator':
        return 'Modérateur';
      case 'contributor':
        return 'Contributeur';
      case 'logged_user':
        return 'Utilisateur connecté';
      case 'anonymous':
        return 'Utilisateur anonyme';
      }
leandro's avatar
leandro committed
    },
Florent Lavelle's avatar
Florent Lavelle committed
      const ext = n.substring(n.lastIndexOf('.') + 1, n.length).toLowerCase();
      let filename = n.replace('.' + ext, '');
      filename = filename.substr(0, len) + (n.length > len ? '[...]' : '');
      return filename + '.' + ext;
    validateImgFile(files, handleFile) {
Florent Lavelle's avatar
Florent Lavelle committed
      const url = window.URL || window.webkitURL;
      const image = new Image();
      image.onload = function () {
        handleFile(true);
        URL.revokeObjectURL(image.src);
      };
      image.onerror = function () {
        handleFile(false);
        URL.revokeObjectURL(image.src);
      };
      image.src = url.createObjectURL(files);
    },

    onFileChange(e) {
      // * read image file
      const files = e.target.files || e.dataTransfer.files;

      const _this = this; //* 'this' is different in onload function
      function handleFile(isValid) {
        if (isValid) {
          _this.fileToImport = files[0]; //* store the file to post later
Florent Lavelle's avatar
Florent Lavelle committed
          const reader = new FileReader(); //* read the file to display in the page
          reader.onload = function (e) {
            _this.thumbnailFileSrc = e.target.result;
          };
          reader.readAsDataURL(_this.fileToImport);
          _this.errorThumbnail = [];
        } else {
          _this.errorThumbnail.push(
            "Transférez une image valide. Le fichier que vous avez transféré n'est pas une image, ou il est corrompu."
          );
        }
      }

      if (files.length) {
        //* check if file is an image and pass callback to handle file
        this.validateImgFile(files[0], handleFile);
      }
Timothee P's avatar
Timothee P committed
    checkEmpty() {
      //* forbid empty fields
Florent Lavelle's avatar
Florent Lavelle committed
      if (!this.form.archive_feature) {
        this.form.archive_feature = 0;
      }
      if (!this.form.delete_feature) {
        this.form.delete_feature = 0;
      }
Timothee P's avatar
Timothee P committed
        this.$store.dispatch('GET_USER_LEVEL_PROJECTS'), //* refresh projects user levels
        this.$store.dispatch('GET_USER_LEVEL_PERMISSIONS'), //* refresh projects permissions
        this.$store.dispatch('projects/GET_PROJECT', slug), //* refresh current project
      ]).then(() =>
        // * go back to project list
        this.$router.push({
          name: 'project_detail',
      );
    },

    postProjectThumbnail(projectSlug) {
      //* send img to the backend when feature_type is created
      if (this.fileToImport) {
Florent Lavelle's avatar
Florent Lavelle committed
        const formData = new FormData();
        formData.append('file', this.fileToImport);
        const url =
          this.$store.state.configuration.VUE_APP_DJANGO_API_BASE +
          'projects/' +
          projectSlug +
          '/thumbnail/';
        return axios
          .put(url, formData, {
            headers: {
              'Content-Type': 'multipart/form-data',
            },
          })
          .then((response) => {
            if (response && response.status === 200) {
              this.goBackNrefresh(projectSlug);
            }
          })
          .catch((error) => {
            let err_msg =
              "Transférez une image valide. Le fichier que vous avez transféré n'est pas une image, ou il est corrompu.";
Florent Lavelle's avatar
Florent Lavelle committed
            if (error.response.data[0]) {
              err_msg = error.response.data[0];
            }
            this.errorThumbnail.push(err_msg);
            throw error;
          });
      }
Timothee P's avatar
Timothee P committed
    checkForm() {
      if (this.form.archive_feature > this.form.delete_feature) {
        this.errors_archive_feature.push(
          "Le délais de suppression doit être supérieur au délais d'archivage."
        );
        return false;
      }
Timothee P's avatar
Timothee P committed
      for (const key in this.errors) {
        if ((key === 'title' && this.form[key]) || this.form[key].value) {
Timothee P's avatar
Timothee P committed
          this.errors[key] = [];
        } else if (!this.errors[key].length) {
          this.errors[key].push(
            key === 'title'
              ? 'Veuillez compléter ce champ.'
              : 'Sélectionnez un choix valide. Ce choix ne fait pas partie de ceux disponibles.'
Timothee P's avatar
Timothee P committed
          );
          document
            .getElementById(`errorlist-${key}`)
            .scrollIntoView({ block: 'end', inline: 'nearest' });
Timothee P's avatar
Timothee P committed
          return false;
        }
      }
      return true;
    },

Florent Lavelle's avatar
Florent Lavelle committed
      if (!this.checkForm()) {
        return;
      }
        ...this.form,
        access_level_arch_feature: this.form.access_level_arch_feature.value,
        access_level_pub_feature: this.form.access_level_pub_feature.value,
        feature_browsing_default_sort: this.form.feature_browsing_default_sort.value,
        feature_browsing_default_filter: this.form.feature_browsing_default_filter.value,
      if (this.action === 'edit') {
          .put((`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}v2/projects/${this.project.slug}/`), projectData)
Timothee P's avatar
Timothee P committed
            if (response && response.status === 200) {
              //* send thumbnail after feature_type was updated
Timothee P's avatar
Timothee P committed
              if (this.fileToImport.size > 0) {
Timothee P's avatar
Timothee P committed
                this.postProjectThumbnail(this.project.slug);
Timothee P's avatar
Timothee P committed
              } else {
Timothee P's avatar
Timothee P committed
                this.goBackNrefresh(this.project.slug);
            if (error.response && error.response.data.title[0]) {
Timothee P's avatar
Timothee P committed
              this.errors.title.push(error.response.data.title[0]);
            }
Timothee P's avatar
Timothee P committed
      } else {
        let url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}v2/projects/`;
        if (this.action === 'create_from') {
          url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.project.slug}/duplicate/`;
Timothee P's avatar
Timothee P committed
        }
        this.loading = true;
Timothee P's avatar
Timothee P committed
          .post(url, projectData)
Timothee P's avatar
Timothee P committed
            if (response && response.status === 201 && response.data) {
              //* send thumbnail after feature_type was created
              if (this.fileToImport.size > 0) {
Timothee P's avatar
Timothee P committed
                this.postProjectThumbnail(response.data.slug);
Timothee P's avatar
Timothee P committed
                this.goBackNrefresh(response.data.slug);
Timothee P's avatar
Timothee P committed
            this.loading = false;
            if (error.response && error.response.data.title[0]) {
Timothee P's avatar
Timothee P committed
              this.errors.title.push(error.response.data.title[0]);
            }
Timothee P's avatar
Timothee P committed
            this.loading = false;
    fillProjectForm() {
      //* create a new object to avoid modifying original one
      this.form = { ...this.project };
      //* if duplication of project, generate new name
      if (this.action === 'create_from') {
        this.form.title =
          this.project.title +
          ` (Copie-${new Date()
            .toLocaleString()
            .slice(0, -3)
            .replace(',', '')})`;
      //* transform string values to objects used with dropdowns 
      // fill dropdown current selection for archived feature viewing permission
      if (this.levelPermissionsArc) {
        const accessLevelArc = this.levelPermissionsArc.find(
          (el) => el.name === this.project.access_level_arch_feature
        if (accessLevelArc) {
          this.form.access_level_arch_feature = {
            name: this.project.access_level_arch_feature,
            value: accessLevelArc.value ,
leandro's avatar
leandro committed
          };
        }
      }
      // fill dropdown current selection for published feature viewing permission
      if (this.levelPermissionsPub) {
        const accessLevelPub = this.levelPermissionsPub.find(
          (el) => el.name === this.project.access_level_pub_feature
        if (accessLevelPub) {
          this.form.access_level_pub_feature = {
            name: this.project.access_level_pub_feature,
            value: accessLevelPub.value ,
leandro's avatar
leandro committed
          };
        }
      }
      // fill dropdown current selection for feature browsing default filtering
      const default_filter = this.featureBrowsingOptions.filter.find(
        (el) => el.value === this.project.feature_browsing_default_filter
      );
      if (default_filter) {
        this.form.feature_browsing_default_filter = default_filter;
      }
      // fill dropdown current selection for feature browsing default sorting
      const default_sort = this.featureBrowsingOptions.sort.find(
        (el) => el.value === this.project.feature_browsing_default_sort
      );
      if (default_sort) {
        this.form.feature_browsing_default_sort = default_sort;
      }
      this.initPreviewMap();
    },

    initPreviewMap () {
      const map = mapService.getMap();
      if (map) mapService.destroyMap();
      //On récupère le zoom maximum autorisé par la couche
      const maxZoomLayer = this.$store.state.configuration.DEFAULT_BASE_MAP_OPTIONS.maxZoom;
      let activeZoom = maxZoomLayer;
      if(this.project && this.project.map_max_zoom_level < maxZoomLayer){
        activeZoom = this.project.map_max_zoom_level;
      }
      this.INITIATE_MAP({
        el: this.$refs.map,
        center: this.$store.state.configuration.MAP_PREVIEW_CENTER,
        maxZoom: 22,
        controls: [],
        zoomControl: false,
        //On désactive le zoom et le pan => gérer par le composant zoom max
        interactions: { dragPan: false, mouseWheelZoom: false }
      // add default basemap (in other maps the component SidebarLayer handles layers)
      mapService.addLayers(
        null,
        this.$store.state.configuration.DEFAULT_BASE_MAP_SERVICE,
        this.$store.state.configuration.DEFAULT_BASE_MAP_OPTIONS,
        this.$store.state.configuration.DEFAULT_BASE_MAP_SCHEMA_TYPE
      );
      //La tuile au dessus du zoom maximum n'existe pas
      //On attend un peu qu'elle se charge et on zoom si besoin
      setTimeout(() => {
        mapService.zoom(this.project ? this.project.map_max_zoom_level : 22);
      }, 500);

    zoomMap() {
      mapService.zoom(this.form.map_max_zoom_level);

    /**
     * Updates the value of a project attribute or adds a new attribute if it does not exist.
     * 
     * This function looks for an attribute by its ID. If the attribute exists, its value is updated.
     * If the attribute does not exist, a new attribute object is added to the `project_attributes` array.
     * 
     * @param {String} value - The new value to be assigned to the project attribute.
     * @param {Number} attributeId - The ID of the attribute to be updated or added.
     */
    updateProjectAttributes({ value, attributeId }) {
      // Find the index of the attribute in the project_attributes array.
      const attributeIndex = this.form.project_attributes.findIndex(el => el.attribute_id === attributeId);
      if (attributeIndex !== -1) {
        // Directly update the attribute's value if it exists.
        this.form.project_attributes[attributeIndex].value = value;
      } else {
        // Add a new attribute object if it does not exist.
        this.form.project_attributes.push({ attribute_id: attributeId, value });
      }
    }
<style media="screen" lang="less">
#form-input-file-logo {
  margin-left: auto;
  margin-right: auto;
}

.file-logo {
  min-height: calc(150px + 2.4285em);
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}


textarea {
  height: 10em;
}

.description.preview {
  height: 10em;
  overflow: scroll;
  border: 1px solid rgba(34, 36, 38, .15);
  padding: .78571429em 1em;
}

.checkboxes {
  padding-left: .5em;
Timothee P's avatar
Timothee P committed
  .absolute-right.ui.compact.icon.button {
    position: absolute;
    right: -2.75em;
    top: calc(50% - 1em);
    padding: .4em;
  }
}

.map-maxzoom-selector {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  
  input, output {
    height: fit-content;
  }
  output {
    white-space: nowrap;
    min-width: auto;
  }

  .range-container {
    margin-bottom: 2rem;
  }
  .map-preview {
    margin-top: -1rem;
    display: flex;
    position: relative;
    label {
      white-space: nowrap;
      font-size: .95em;
      margin-right: 1rem;
    }
    #map {
      min-height: 80px;
      height: 80px;
      width: 150px;
      max-width: 150px;
      z-index: 1;
    }
    
    .no-preview {
      position: absolute;
      top: 25%;
      left: 25%;
      text-align: center;
      font-size: .75em;
      color: #656565;
    }
  }
}

label[for=feature_browsing] {
  padding-left: 2em;
}
label[for=feature_browsing_default_filter],
label[for=feature_browsing_default_sort] {
  min-width: 4em;
}
#feature_browsing_filter,
#feature_browsing_sort {
  margin-left: 2.5rem;
@media only screen and (min-width: 1100px) {
  #feature_browsing_filter {
    margin-top: -2.25em;
  }
  #feature_browsing_filter,
  #feature_browsing_sort {
    float: right;
  }