diff --git a/Gemfile b/Gemfile index 5bc497e8efc..3d5d861bb34 100644 --- a/Gemfile +++ b/Gemfile @@ -67,6 +67,7 @@ gem "rails_param" gem "rinku", ">= 2.0.6", :require => "rails_rinku" gem "strong_migrations", "< 2.0.0" gem "validates_email_format_of", ">= 1.5.1" +gem "validate_url" # Native OSM extensions gem "quad_tile", "~> 1.0.1" diff --git a/Gemfile.lock b/Gemfile.lock index 0fd0b75d88c..ae45f2f1e8b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -583,6 +583,9 @@ GEM concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) uri (0.13.0) + validate_url (1.0.15) + activemodel (>= 3.0.0) + public_suffix validates_email_format_of (1.8.2) i18n (>= 0.8.0) simpleidn @@ -703,6 +706,7 @@ DEPENDENCIES terser turbo-rails unicode-display_width + validate_url validates_email_format_of (>= 1.5.1) vendorer webmock diff --git a/app/abilities/ability.rb b/app/abilities/ability.rb index e72d5094b78..82101d16622 100644 --- a/app/abilities/ability.rb +++ b/app/abilities/ability.rb @@ -18,6 +18,7 @@ def initialize(user) can [:index, :feed, :show], Changeset can :index, ChangesetComment can [:index, :show], Community + can [:index], CommunityLink can [:confirm, :confirm_resend, :confirm_email], :confirmation can [:index, :rss, :show], DiaryEntry can :index, DiaryComment @@ -50,6 +51,7 @@ def initialize(user) can [:new, :create, :reply, :show, :inbox, :outbox, :muted, :mark, :unmute, :destroy], Message can [:create, :new], Community can [:edit, :update], Community, { :organizer_id => user.id } + can [:edit, :create, :destroy, :new, :update], CommunityLink, { :community => { :organizer_id => user.id } } can [:close, :reopen], Note can [:show, :edit, :update], :preference can [:edit, :update], :profile diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index f3c1ad38abc..d5fd050241b 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -10,6 +10,7 @@ //= require leaflet.osm //= require leaflet.map //= require leaflet.zoom +//= require leaflet.locatecontrol/src/L.Control.Locate //= require leaflet.locationfilter //= require i18n //= require oauth @@ -148,70 +149,121 @@ $(document).ready(function () { .attr("title", I18n.t("javascripts.site.edit_disabled_tooltip")); }); +window.addLocateControl = function (map, position) { + var locate = L.control.locate({ + position: position, + icon: "icon geolocate", + iconLoading: "icon geolocate", + strings: { + title: I18n.t("javascripts.map.locate.title"), + popup: function (options) { + return I18n.t("javascripts.map.locate." + options.unit + "Popup", { count: options.distance }); + } + } + }).addTo(map); + $(locate.getContainer()) + .removeClass("leaflet-control-locate leaflet-bar") + .addClass("control-locate") + .children("a") + .attr("href", "#") + .removeClass("leaflet-bar-part leaflet-bar-part-single") + .addClass("control-button"); +}; + +/* + * Create a map on the page for the given `id`. Adhere to rtl pages. Parameters to + * the map are provided as data attributes. + * * zoom + * * latitude, longitude + * * min-lat, max-lat, min-lon, max-lon + * If the map div has a class `has_marker` there will be a marker added to the map. + */ window.showMap = function (id) { - var params = $("#" + id).data(); - var map = L.map(id, { - attributionControl: false, - zoomControl: false - }); - map.addLayer(new L.OSM.Mapnik()); - var show_marker = true; - if (!params.lon || !params.lat) { - params.lon = 0; - params.lat = 0; - params.zoom = 1; - show_marker = false; + const div = $("#" + id); + // Defaults + const position = $("html").attr("dir") === "rtl" ? "topleft" : "topright"; + let zoom = 2; + let [latitude, longitude] = [0, 0]; + let bounds = null; + let marker = null; + + // Extract params + const params = div.data(); + if (params.zoom) { + zoom = params.zoom; } - map.setView([params.lat, params.lon], params.zoom); - if (show_marker) { - L.marker([params.lat, params.lon], { icon: OSM.getUserIcon() }).addTo(map); + if (params.latitude && params.longitude) { + [latitude, longitude] = [params.latitude, params.longitude]; + } + if (params.minLat && params.maxLat && params.minLon && params.maxLon) { + bounds = [ + [params.minLat, params.minLon], + [params.maxLat, params.maxLon] + ]; } -}; -window.formMapInput = function (id, type) { - var map = L.map(id, { - attributionControl: false + // Create map. + const map = L.map(id, { + attributionControl: false, + center: [latitude, longitude], + zoom: zoom, + zoomControl: false }); + window.addLocateControl(map, position); + L.OSM.zoom({ position: position }).addTo(map); map.addLayer(new L.OSM.Mapnik()); + if (bounds) { + map.fitBounds(bounds); + } - var lat_field = document.getElementById(type + "_latitude"); - var lon_field = document.getElementById(type + "_longitude"); - - if (lat_field.value) { - map.setView([lat_field.value, lon_field.value], 12); - } else { - map.setView([0, 0], 0); + if (div.hasClass("has_marker")) { + marker = L.marker([latitude, longitude], { + icon: OSM.getUserIcon(), + keyboard: false, + interactive: false + }).addTo(map); } - L.Control.Watermark = L.Control.extend({ - onAdd: function () { - var container = map.getContainer(); - var img = L.DomUtil.create("img"); - img.src = "/assets/marker-blue.png"; // 25x41 px - // img.style.width = '200px'; - img.style["margin-left"] = ((container.offsetWidth / 2) - 12) + "px"; - img.style["margin-bottom"] = ((container.offsetHeight / 2) - 20) + "px"; - return img; - }, - onRemove: function () { - // Nothing to do here + return { map, marker }; +}; + +/* + * Create a basic map using showMap above, and connect it to the form that + * contains it. If the map div has the set_location class, the map will + * set values in appropriate fields if they exist as the user clicks and + * moves the map. These fields are: + * * field_latitude + * * field_longitude + * * field_min_lat + * * field_max_lat + * * field_min_lon + * * field_max_lon + */ +window.formMapInit = function (id) { + const formDiv = $("#" + id); + const mapDiv = $("#" + id + "_map"); + const { map, marker } = window.showMap(id + "_map"); + + if (mapDiv.hasClass("set_location")) { + if ($(".field_latitude", formDiv) && $(".field_longitude", formDiv)) { + map.on("click", function (e) { + const location = e.latlng.wrap(); + marker.setLatLng(location); + // If the page has these elements, populate them. + $(".field_latitude", formDiv).val(location.lat); + $(".field_longitude", formDiv).val(location.lng); + }); } - }); - L.control.watermark = function (opts) { - return new L.Control.Watermark(opts); - }; - L.control.watermark({ position: "bottomleft" }).addTo(map); - - map.on("move", function () { - var center = map.getCenter(); - $("#" + type + "_latitude").val(center.lat); - $("#" + type + "_longitude").val(center.lng); - if ($("#" + type + "_min_lat")) { - var bounds = map.getBounds(); - $("#" + type + "_min_lat").val(bounds._southWest.lat); - $("#" + type + "_max_lat").val(bounds._northEast.lat); - $("#" + type + "_min_lon").val(bounds._southWest.lng); - $("#" + type + "_max_lon").val(bounds._northEast.lng); + + if ($(".field_min_lat", formDiv) && $(".field_max_lat", formDiv) && + $(".field_min_lon", formDiv) && $(".field_max_lon", formDiv)) { + map.on("move", function () { + var bounds = map.getBounds(); + $(".field_min_lat").val(bounds._southWest.lat); + $(".field_max_lat").val(bounds._northEast.lat); + $(".field_min_lon").val(bounds._southWest.lng); + $(".field_max_lon").val(bounds._northEast.lng); + }); } - }); + } }; diff --git a/app/assets/javascripts/communities.js b/app/assets/javascripts/communities.js index 42ef8954b26..732eb559c55 100644 --- a/app/assets/javascripts/communities.js +++ b/app/assets/javascripts/communities.js @@ -1,9 +1,9 @@ -/*global showMap,formMapInput*/ +/*global showMap,formMapInit*/ $(document).ready(function () { - if ($("#community_map_form").length) { - formMapInput("community_map_form", "community"); + if ($("#community_form").length) { + formMapInit("community_form"); } else if ($("#community_map").length) { showMap("community_map"); } diff --git a/app/assets/stylesheets/communities.scss b/app/assets/stylesheets/communities.scss index f25fb70fd3e..7b6bd73c4e3 100644 --- a/app/assets/stylesheets/communities.scss +++ b/app/assets/stylesheets/communities.scss @@ -9,10 +9,4 @@ label { font-weight: bold; } - ul { - display: inline-block; - } - ul > li { - display: inline-block; - } } diff --git a/app/controllers/community_links_controller.rb b/app/controllers/community_links_controller.rb new file mode 100644 index 00000000000..766d6d187aa --- /dev/null +++ b/app/controllers/community_links_controller.rb @@ -0,0 +1,61 @@ +class CommunityLinksController < ApplicationController + layout "site" + before_action :authorize_web + + before_action :set_link, :only => [:destroy, :edit, :update] + + load_and_authorize_resource :except => [:create, :new] + authorize_resource + + def index + @community = Community.friendly.find(params[:community_id]) + @links = @community.community_links + end + + def new + return "missing parameter community_id" unless params.key?(:community_id) + + @community = Community.friendly.find(params[:community_id]) + @title = t ".title" + @link = CommunityLink.new + @link.community_id = params[:community_id] + end + + def edit; end + + def create + @community = Community.friendly.find(params[:community_id]) + @link = @community.community_links.build(link_params) + if @link.save + response.set_header("link_id", @link.id) # for testing + redirect_to @link.community, :notice => t(".success") + else + render "new" + end + end + + def update + if @link.update(link_params) + redirect_to @link.community, :notice => t(".success") + else + flash.now[:alert] = t(".failure") + render :edit + end + end + + def destroy + community_id = @link.community_id + @link.delete + redirect_to community_path(community_id) + end + + private + + def set_link + @link = CommunityLink.find(params[:id]) + end + + def link_params + params.require(:community_link).permit(:community_id, :text, :url) + end +end diff --git a/app/models/community.rb b/app/models/community.rb index 45ab6b1b876..c5caab1c9bb 100644 --- a/app/models/community.rb +++ b/app/models/community.rb @@ -35,6 +35,7 @@ class Community < ApplicationRecord friendly_id :name, :use => :slugged belongs_to :organizer, :class_name => "User" + has_many :community_links validates :name, :presence => true, :length => 1..255, :characters => true validates :description, :presence => true, :length => 1..1023, :characters => true diff --git a/app/models/community_link.rb b/app/models/community_link.rb new file mode 100644 index 00000000000..b0cda052600 --- /dev/null +++ b/app/models/community_link.rb @@ -0,0 +1,25 @@ +# == Schema Information +# +# Table name: community_links +# +# id :bigint(8) not null, primary key +# community_id :bigint(8) not null +# text :string not null +# url :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_community_links_on_community_id (community_id) +# +# Foreign Keys +# +# fk_rails_... (community_id => communities.id) +# + +class CommunityLink < ApplicationRecord + belongs_to :community + validates :text, :presence => true, :length => 1..255, :characters => true + validates :url, :presence => true, :length => 1..255, :url => { :schemes => ["https"] } +end diff --git a/app/views/communities/_form.html.erb b/app/views/communities/_form.html.erb index 18745618fcd..7356b0563d5 100644 --- a/app/views/communities/_form.html.erb +++ b/app/views/communities/_form.html.erb @@ -4,29 +4,46 @@
All fields are required.
-<%= bootstrap_form_for @community do |form| %> +<%= bootstrap_form_with :model => @community, :id => "community_form" do |form| %> <%= form.text_field :name, :id => :community_name %> <%= form.text_field :location, :id => :community_location %><%= auto_link @community.description %>
++ <% link.url.slice! "https://" %> <%# prevent XSS %> + <%= link_to link.text, "https://#{link.url}" %> + | ++ <%= link_to t(".edit"), edit_community_link_path(link) %> + <%= link_to t(".delete"), community_link_path(link), :method => :delete %> + | +