diff --git a/Gemfile b/Gemfile
index c06c5d7..9a8aaa6 100755
--- a/Gemfile
+++ b/Gemfile
@@ -40,7 +40,7 @@ gem "kamal", require: false
gem "thruster", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
-# gem "image_processing", "~> 1.2"
+gem "image_processing", "~> 1.2"
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
diff --git a/Gemfile.lock b/Gemfile.lock
index 12296b9..8525c29 100755
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -122,6 +122,13 @@ GEM
erubi (1.13.1)
et-orbi (1.3.0)
tzinfo
+ ffi (1.17.2-aarch64-linux-gnu)
+ ffi (1.17.2-aarch64-linux-musl)
+ ffi (1.17.2-arm-linux-gnu)
+ ffi (1.17.2-arm-linux-musl)
+ ffi (1.17.2-x86_64-darwin)
+ ffi (1.17.2-x86_64-linux-gnu)
+ ffi (1.17.2-x86_64-linux-musl)
fugit (1.11.2)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
@@ -129,6 +136,9 @@ GEM
activesupport (>= 6.1)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
+ image_processing (1.14.0)
+ mini_magick (>= 4.9.5, < 6)
+ ruby-vips (>= 2.0.17, < 3)
io-console (0.8.1)
irb (1.15.2)
pp (>= 0.6.0)
@@ -177,6 +187,8 @@ GEM
net-smtp
marcel (1.0.4)
matrix (0.4.3)
+ mini_magick (5.3.1)
+ logger
mini_mime (1.1.5)
minitest (5.25.5)
minitest-reporters (1.7.1)
@@ -333,6 +345,9 @@ GEM
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
+ ruby-vips (2.2.5)
+ ffi (~> 1.12)
+ logger
ruby2_keywords (0.0.5)
rubyzip (3.0.2)
securerandom (0.4.1)
@@ -429,6 +444,7 @@ DEPENDENCIES
debug
devise (~> 4.9)
dotenv-rails
+ image_processing (~> 1.2)
jbuilder
jsbundling-rails
kamal
diff --git a/app/controllers/api/v1/events_controller.rb b/app/controllers/api/v1/events_controller.rb
index 5f00625..cd2bcb1 100755
--- a/app/controllers/api/v1/events_controller.rb
+++ b/app/controllers/api/v1/events_controller.rb
@@ -73,6 +73,33 @@ module Api
private
+ # Helper method to serialize event data safely
+ def event_json(event)
+ {
+ id: event.id,
+ name: event.name,
+ slug: event.slug,
+ description: event.description,
+ state: event.state,
+ venue_name: event.venue_name,
+ venue_address: event.venue_address,
+ start_time: event.start_time,
+ end_time: event.end_time,
+ latitude: event.latitude,
+ longitude: event.longitude,
+ featured: event.featured,
+ image_url: event.display_image_url,
+ created_at: event.created_at,
+ updated_at: event.updated_at,
+ user: {
+ id: event.user.id,
+ email: event.user.email,
+ first_name: event.user.first_name,
+ last_name: event.user.last_name
+ }
+ }
+ end
+
# Finds an event by its ID or returns 404 Not Found
# Used as before_action for the show, update, and destroy actions
def set_event
diff --git a/app/controllers/promoter/events_controller.rb b/app/controllers/promoter/events_controller.rb
index 72af78d..341ccff 100644
--- a/app/controllers/promoter/events_controller.rb
+++ b/app/controllers/promoter/events_controller.rb
@@ -45,6 +45,8 @@ class Promoter::EventsController < ApplicationController
if @event.update(event_params)
redirect_to promoter_event_path(@event), notice: "Event mis à jour avec succès!"
else
+ # If validation fails and a new image was attached, purge it
+ @event.image.purge if @event.image.attached? && @event.changed.include?('image')
render :edit, status: :unprocessable_entity
end
end
@@ -132,10 +134,18 @@ class Promoter::EventsController < ApplicationController
end
def event_params
- params.require(:event).permit(
- :name, :slug, :description, :image,
- :venue_name, :venue_address, :latitude, :longitude,
- :start_time, :end_time, :featured, :allow_booking_during_event
- )
+ if action_name == 'create'
+ params.require(:event).permit(
+ :name, :slug, :description, :image,
+ :venue_name, :venue_address, :latitude, :longitude,
+ :start_time, :end_time, :featured, :allow_booking_during_event
+ )
+ else
+ params.require(:event).permit(
+ :name, :description, :image,
+ :venue_name, :venue_address, :latitude, :longitude,
+ :start_time, :end_time, :featured, :allow_booking_during_event
+ )
+ end
end
end
diff --git a/app/javascript/controllers/event_form_controller.js b/app/javascript/controllers/event_form_controller.js
index a9d4248..8775418 100644
--- a/app/javascript/controllers/event_form_controller.js
+++ b/app/javascript/controllers/event_form_controller.js
@@ -1,8 +1,11 @@
import { Controller } from "@hotwired/stimulus"
import slug from 'slug'
+// Configure slug to match Rails parameterize behavior
+slug.defaults.mode = 'rfc3986'
+
export default class extends Controller {
- static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer"]
+ static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer", "venueName"]
static values = {
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
}
@@ -27,15 +30,65 @@ export default class extends Controller {
}
}
- // Generate slug from name
+ // Generate slug from name, venue name, and city for better SEO
generateSlug() {
const name = this.nameTarget.value
+ const venueName = this.hasVenueNameTarget ? this.venueNameTarget.value : ""
+ const address = this.hasAddressTarget ? this.addressTarget.value : ""
- this.slugTarget.value = slug(name)
+ // Extract city from address
+ const city = this.extractCity(address)
+
+ // Build SEO-friendly slug: name-venue-city
+ let slugParts = []
+
+ if (name) slugParts.push(name)
+ if (venueName) slugParts.push(venueName)
+ if (city) slugParts.push(city)
+
+ let slugValue = slugParts.join('-')
+
+ // If no slug parts, generate a fallback slug
+ if (!slugValue) {
+ slugValue = `event-${Date.now()}`
+ }
+
+ // Generate slug with proper character handling (matches Rails parameterize)
+ this.slugTarget.value = slug(slugValue, { lower: true })
+ }
+
+ // Extract city from address
+ extractCity(address) {
+ if (!address) return ""
+
+ // Look for French postal code pattern (5 digits) + city
+ const match = address.match(/(\d{5})\s+([^,]+)/)
+ if (match) {
+ return match[2].trim()
+ }
+
+ // Fallback: extract last part after comma (assume it's city)
+ const parts = address.split(',')
+ if (parts.length > 1) {
+ return parts[parts.length - 1].trim()
+ }
+
+ // Another fallback: look for common French city indicators
+ const cityIndicators = ["Paris", "Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Strasbourg", "Montpellier", "Bordeaux", "Lille"]
+ for (const city of cityIndicators) {
+ if (address.toLowerCase().includes(city.toLowerCase())) {
+ return city
+ }
+ }
+
+ return ""
}
// Handle address changes with debounced geocoding
addressChanged() {
+ // Regenerate slug when address changes
+ this.generateSlug()
+
// Clear any existing timeout
if (this.geocodeTimeout) {
clearTimeout(this.geocodeTimeout)
@@ -68,6 +121,11 @@ export default class extends Controller {
}, this.geocodeDelayValue)
}
+ // Handle venue name changes to regenerate slug
+ venueNameChanged() {
+ this.generateSlug()
+ }
+
// Get user's current location and reverse geocode to address
async getCurrentLocation() {
if (!navigator.geolocation) {
@@ -516,14 +574,16 @@ export default class extends Controller {
showLocationSuccess(message) {
this.hideAllLocationMessages()
this.showMessage("location-success", message, "success")
- setTimeout(() => this.hideMessage("location-success"), 4000)
+ // Keep notification visible indefinitely
+ // setTimeout(() => this.hideMessage("location-success"), 4000)
}
// Show error message
showLocationError(message) {
this.hideAllLocationMessages()
this.showMessage("location-error", message, "error")
- setTimeout(() => this.hideMessage("location-error"), 6000)
+ // Keep notification visible indefinitely
+ // setTimeout(() => this.hideMessage("location-error"), 6000)
}
// Show geocoding warning (less intrusive than error)
@@ -531,7 +591,8 @@ export default class extends Controller {
this.hideMessage("geocoding-warning")
const message = "Les coordonnées n'ont pas pu être déterminées automatiquement. L'événement utilisera une localisation approximative."
this.showMessage("geocoding-warning", message, "warning")
- setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
+ // Keep notification visible indefinitely
+ // setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
}
// Show info about approximate location
@@ -539,7 +600,8 @@ export default class extends Controller {
this.hideMessage("approximate-location-info")
const message = `Localisation approximative trouvée: ${foundLocation}`
this.showMessage("approximate-location-info", message, "info")
- setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
+ // Keep notification visible indefinitely
+ // setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
}
// Show geocoding success with location details
@@ -547,7 +609,8 @@ export default class extends Controller {
this.hideMessage("geocoding-success")
const message = `${title}
${location}`
this.showMessage("geocoding-success", message, "success")
- setTimeout(() => this.hideMessage("geocoding-success"), 5000)
+ // Keep notification visible indefinitely
+ // setTimeout(() => this.hideMessage("geocoding-success"), 5000)
}
// Show geocoding progress with strategy info
diff --git a/app/models/event.rb b/app/models/event.rb
index 7a07beb..e2cb748 100755
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -29,7 +29,9 @@ class Event < ApplicationRecord
# === Callbacks ===
+ before_validation :generate_slug, if: :should_generate_slug?
before_validation :geocode_address, if: :should_geocode_address?
+ before_update :handle_image_replacement, if: :image_attached?
# Validations for Event attributes
# Basic information
@@ -67,19 +69,98 @@ class Event < ApplicationRecord
# === Instance Methods ===
- # Get image for display - handles both uploaded files and URLs
- def event_image_variant(size = :medium)
- if image.attached?
- case size
- when :large
- image.variant(resize_to_limit: [1200, 630])
- when :medium
- image.variant(resize_to_limit: [800, 450])
- when :small
- image.variant(resize_to_limit: [400, 225])
- else
- image
+ # Generate SEO-friendly slug from name, venue name, and city
+ def generate_slug
+ return if name.blank? && venue_name.blank? && venue_address.blank?
+
+ # Extract city from venue address
+ city = extract_city_from_address(venue_address)
+
+ # Build slug parts
+ slug_parts = []
+ slug_parts << name if name.present?
+ slug_parts << venue_name if venue_name.present?
+ slug_parts << city if city.present?
+
+ # Generate slug using Rails' parameterize
+ slug_value = slug_parts.join("-").parameterize
+
+ # Ensure minimum length
+ if slug_value.length < 3
+ slug_value = "event-#{Time.current.to_i}".parameterize
+ end
+
+ # Make sure slug is unique
+ base_slug = slug_value
+ counter = 1
+ while Event.where.not(id: id).where(slug: slug_value).exists?
+ slug_value = "#{base_slug}-#{counter}".parameterize
+ counter += 1
+ end
+
+ self.slug = slug_value
+ end
+
+ # Check if slug should be generated
+ def should_generate_slug?
+ # Generate slug if it's blank or if it's a new record
+ slug.blank? || new_record?
+ end
+
+ # Extract city from address
+ def extract_city_from_address(address)
+ return "" if address.blank?
+
+ # Look for French postal code pattern (5 digits) + city
+ match = address.match(/(\d{5})\s+([^,]+)/)
+ if match
+ return match[2].strip
+ end
+
+ # Fallback: extract last part after comma (assume it's city)
+ parts = address.split(",")
+ if parts.length > 1
+ return parts[parts.length - 1].strip
+ end
+
+ # Another fallback: look for common French city indicators
+ city_indicators = [ "Paris", "Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Strasbourg", "Montpellier", "Bordeaux", "Lille" ]
+ for city in city_indicators
+ if address.downcase.include?(city.downcase)
+ return city
end
+ end
+
+ ""
+ end
+
+ # Get image URL prioritizing old image field if it exists
+ def display_image_url
+ # First check if old image field exists and has a value
+ return self[:image] if self[:image].present?
+
+ # Fall back to attached image
+ return nil unless image.attached?
+
+ # Return the URL for the attached image
+ Rails.application.routes.url_helpers.rails_blob_url(image, only_path: true)
+ end
+
+ # Get image variants for different display sizes
+ def event_image_variant(size = :medium)
+ # For old image field, return the URL directly
+ return self[:image] if self[:image].present?
+
+ # For attached images, process variants
+ return nil unless image.attached?
+
+ case size
+ when :large
+ image.variant(resize_to_limit: [ 1200, 630 ])
+ when :medium
+ image.variant(resize_to_limit: [ 800, 450 ])
+ when :small
+ image.variant(resize_to_limit: [ 400, 225 ])
else
# Fallback to URL-based image
image_url.presence
@@ -100,6 +181,11 @@ class Event < ApplicationRecord
end
end
+ # Check if event has any image (old field or attached)
+ def has_image?
+ self[:image].present? || image.attached?
+ end
+
# Check if coordinates were successfully geocoded or are fallback coordinates
def geocoding_successful?
coordinates_look_valid?
@@ -203,6 +289,19 @@ class Event < ApplicationRecord
private
+ # Check if image is attached for the callback
+ def image_attached?
+ image.attached?
+ end
+
+ # Handle image replacement when a new image is uploaded
+ def handle_image_replacement
+ # Clear the old image field if a new image is being attached
+ if image.attached?
+ self[:image] = nil
+ end
+ end
+
# Determine if we should perform server-side geocoding
def should_geocode_address?
# Don't geocode if address is blank
diff --git a/app/views/components/_event_item.html.erb b/app/views/components/_event_item.html.erb
index 8a02f12..3ae2a3f 100755
--- a/app/views/components/_event_item.html.erb
+++ b/app/views/components/_event_item.html.erb
@@ -1,13 +1,7 @@
<%= link_to event_path(event.slug, event), class: "group block p-4 rounded-lg border border-slate-200 dark:border-slate-700 hover:border-purple-300 dark:hover:border-purple-600 hover:shadow-md transition-all duration-200" do %>
- <% if @event.published? %> - - Attention: Modifier le slug d'un événement publié peut casser les liens existants. - <% else %> - Utilisé dans l'URL de l'événement - <% end %> -
-