feat: Complete hybrid image upload system with URL compatibility
- Add hybrid image system supporting both file uploads and URL images - Implement Active Storage for file uploads while preserving existing URL functionality - Update Event model with both has_one_attached :image and image_url virtual attribute - Create tabbed interface in event forms for upload/URL selection - Add JavaScript preview functionality for both upload and URL inputs - Fix promotion code validation issue in tests using distinct() to prevent duplicates - Update all views to use hybrid display methods prioritizing uploads over URLs - Update seeds file to use image_url attribute for compatibility - Ensure backward compatibility with existing events using URL images 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
<%= 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 %>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-16 h-16 bg-slate-200 dark:bg-slate-700 rounded-lg overflow-hidden flex-shrink-0">
|
||||
<%= image_tag event.event_image_variant(:small), alt: event.name, class: "w-full h-full object-cover" if event.image.attached? %>
|
||||
<% if event.has_image? %>
|
||||
<% if event.image.attached? %>
|
||||
<%= image_tag event.event_image_variant(:small), alt: event.name, class: "w-full h-full object-cover" %>
|
||||
<% else %>
|
||||
<%= image_tag event.image_url, alt: event.name, class: "w-full h-full object-cover" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors duration-200">
|
||||
|
||||
@@ -22,9 +22,13 @@
|
||||
<% @events.each do |event| %>
|
||||
<article class="group bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden transform hover:-translate-y-1">
|
||||
<%= link_to event_path(event.slug, event), class: "block" do %>
|
||||
<% if event.image.attached? %>
|
||||
<% if event.has_image? %>
|
||||
<div class="relative overflow-hidden aspect-[4/3]">
|
||||
<%= image_tag event.event_image_variant(:medium), alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
|
||||
<% if event.image.attached? %>
|
||||
<%= image_tag event.event_image_variant(:medium), alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
|
||||
<% else %>
|
||||
<%= image_tag event.image_url, alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
|
||||
<% end %>
|
||||
<!-- Event featured badge -->
|
||||
<% if event.featured? %>
|
||||
<div class="absolute top-4 left-4">
|
||||
|
||||
@@ -10,9 +10,13 @@
|
||||
<!-- Event main wrapper -->
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<!-- Event Header with Image -->
|
||||
<% if @event.image.attached? %>
|
||||
<% if @event.has_image? %>
|
||||
<div class="relative h-96">
|
||||
<%= image_tag @event.event_image_variant(:large), class: "w-full h-full object-cover" %>
|
||||
<% if @event.image.attached? %>
|
||||
<%= image_tag @event.event_image_variant(:large), class: "w-full h-full object-cover" %>
|
||||
<% else %>
|
||||
<%= image_tag @event.image_url, class: "w-full h-full object-cover", alt: @event.name %>
|
||||
<% end %>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black via-black/70 to-transparent"></div>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-6 md:p-8">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
|
||||
@@ -89,8 +89,12 @@
|
||||
<div class="bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden">
|
||||
<!-- Event Image -->
|
||||
<div class="relative overflow-hidden aspect-[4/3]">
|
||||
<% if event.image.attached? %>
|
||||
<%= image_tag event.event_image_variant(:medium), alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
|
||||
<% if event.has_image? %>
|
||||
<% if event.image.attached? %>
|
||||
<%= image_tag event.event_image_variant(:medium), alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
|
||||
<% else %>
|
||||
<%= image_tag event.image_url, alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="w-full h-full bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
|
||||
<i data-lucide="calendar" class="w-16 h-16 text-white"></i>
|
||||
|
||||
@@ -68,16 +68,37 @@
|
||||
|
||||
<div class="mt-6">
|
||||
<%= form.label :image, "Image de couverture", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<div class="space-y-4">
|
||||
|
||||
<!-- Image type selection tabs -->
|
||||
<div class="mb-4">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
<button type="button" onclick="switchImageTab('upload')" id="upload-tab" class="tab-button active border-purple-500 text-purple-600 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
|
||||
<i data-lucide="upload" class="w-4 h-4 inline mr-2"></i>
|
||||
Télécharger un fichier
|
||||
</button>
|
||||
<button type="button" onclick="switchImageTab('url')" id="url-tab" class="tab-button border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
|
||||
<i data-lucide="link" class="w-4 h-4 inline mr-2"></i>
|
||||
Utiliser une URL
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload tab content -->
|
||||
<div id="upload-content" class="tab-content space-y-4">
|
||||
<!-- Current image preview -->
|
||||
<% if @event.image.attached? %>
|
||||
<div class="relative">
|
||||
<%= image_tag @event.image.variant(resize_to_limit: [400, 225]), class: "w-full h-48 object-cover rounded-lg border border-gray-200" %>
|
||||
<div class="absolute top-2 right-2">
|
||||
<button type="button" onclick="this.closest('div').querySelector('input[type=file]').click()" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded text-xs">
|
||||
Image actuelle
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -93,12 +114,57 @@
|
||||
</div>
|
||||
|
||||
<!-- Image preview container -->
|
||||
<div id="image-preview" class="hidden">
|
||||
<div id="upload-preview" class="hidden">
|
||||
<div class="relative">
|
||||
<img id="preview-img" src="" alt="Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
|
||||
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('image-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<img id="upload-preview-img" src="" alt="Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
|
||||
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<div class="absolute bottom-2 left-2 bg-purple-600 text-white px-2 py-1 rounded text-xs">
|
||||
Nouvelle image
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL tab content -->
|
||||
<div id="url-content" class="tab-content space-y-4 hidden">
|
||||
<!-- Current URL image preview -->
|
||||
<% if @event.image_url.present? && !@event.image.attached? %>
|
||||
<div class="relative">
|
||||
<%= image_tag @event.image_url, class: "w-full h-48 object-cover rounded-lg border border-gray-200", alt: "Current URL image" %>
|
||||
<div class="absolute top-2 right-2">
|
||||
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded text-xs">
|
||||
URL actuelle
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- URL input field -->
|
||||
<div class="relative">
|
||||
<%= form.text_field :image_url, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg", value: @event.image_url, data: { action: "input->event-form#previewImageUrl" } %>
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
Entrez l'URL d'une image (JPG, PNG, GIF, WebP)
|
||||
<% if @event.image_url.present? %>
|
||||
<br>Laissez vide pour conserver l'URL actuelle
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL preview container -->
|
||||
<div id="url-preview" class="hidden">
|
||||
<div class="relative">
|
||||
<img id="url-preview-img" src="" alt="URL Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
|
||||
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<div class="absolute bottom-2 left-2 bg-purple-600 text-white px-2 py-1 rounded text-xs">
|
||||
Nouvelle URL
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,4 +301,27 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchImageTab(tab) {
|
||||
// Hide all tab contents
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Remove active class from all tabs
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.classList.remove('active', 'border-purple-500', 'text-purple-600');
|
||||
button.classList.add('border-transparent', 'text-gray-500');
|
||||
});
|
||||
|
||||
// Show selected tab content
|
||||
document.getElementById(tab + '-content').classList.remove('hidden');
|
||||
|
||||
// Add active class to selected tab
|
||||
const activeTab = document.getElementById(tab + '-tab');
|
||||
activeTab.classList.add('active', 'border-purple-500', 'text-purple-600');
|
||||
activeTab.classList.remove('border-transparent', 'text-gray-500');
|
||||
}
|
||||
</script>
|
||||
@@ -61,13 +61,31 @@
|
||||
|
||||
<div class="mt-6">
|
||||
<%= form.label :image, "Image de couverture", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<div class="space-y-4">
|
||||
|
||||
<!-- Image type selection tabs -->
|
||||
<div class="mb-4">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
<button type="button" onclick="switchImageTab('upload')" id="upload-tab" class="tab-button active border-purple-500 text-purple-600 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
|
||||
<i data-lucide="upload" class="w-4 h-4 inline mr-2"></i>
|
||||
Télécharger un fichier
|
||||
</button>
|
||||
<button type="button" onclick="switchImageTab('url')" id="url-tab" class="tab-button border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
|
||||
<i data-lucide="link" class="w-4 h-4 inline mr-2"></i>
|
||||
Utiliser une URL
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload tab content -->
|
||||
<div id="upload-content" class="tab-content space-y-4">
|
||||
<!-- Current image preview (for edit mode) -->
|
||||
<% if @event.image.attached? %>
|
||||
<div class="relative">
|
||||
<%= image_tag @event.image.variant(resize_to_limit: [400, 225]), class: "w-full h-48 object-cover rounded-lg border border-gray-200" %>
|
||||
<div class="absolute top-2 right-2">
|
||||
<button type="button" onclick="this.closest('div').previousElementSibling.querySelector('input[type=file]').click()" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -83,10 +101,43 @@
|
||||
</div>
|
||||
|
||||
<!-- Image preview container -->
|
||||
<div id="image-preview" class="hidden">
|
||||
<div id="upload-preview" class="hidden">
|
||||
<div class="relative">
|
||||
<img id="preview-img" src="" alt="Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
|
||||
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('image-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<img id="upload-preview-img" src="" alt="Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
|
||||
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL tab content -->
|
||||
<div id="url-content" class="tab-content space-y-4 hidden">
|
||||
<!-- Current URL image preview -->
|
||||
<% if @event.image_url.present? && !@event.image.attached? %>
|
||||
<div class="relative">
|
||||
<%= image_tag @event.image_url, class: "w-full h-48 object-cover rounded-lg border border-gray-200", alt: "Current image" %>
|
||||
<div class="absolute top-2 right-2">
|
||||
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- URL input field -->
|
||||
<div class="relative">
|
||||
<%= form.text_field :image_url, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg", data: { action: "input->event-form#previewImageUrl" } %>
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
Entrez l'URL d'une image (JPG, PNG, GIF, WebP)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL preview container -->
|
||||
<div id="url-preview" class="hidden">
|
||||
<div class="relative">
|
||||
<img id="url-preview-img" src="" alt="URL Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
|
||||
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -192,3 +243,26 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchImageTab(tab) {
|
||||
// Hide all tab contents
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Remove active class from all tabs
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.classList.remove('active', 'border-purple-500', 'text-purple-600');
|
||||
button.classList.add('border-transparent', 'text-gray-500');
|
||||
});
|
||||
|
||||
// Show selected tab content
|
||||
document.getElementById(tab + '-content').classList.remove('hidden');
|
||||
|
||||
// Add active class to selected tab
|
||||
const activeTab = document.getElementById(tab + '-tab');
|
||||
activeTab.classList.add('active', 'border-purple-500', 'text-purple-600');
|
||||
activeTab.classList.remove('border-transparent', 'text-gray-500');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -174,9 +174,13 @@
|
||||
<!-- Main content -->
|
||||
<div class="lg:col-span-2 space-y-6 lg:space-y-8">
|
||||
<!-- Event image -->
|
||||
<% if @event.image.attached? %>
|
||||
<% if @event.has_image? %>
|
||||
<div class="aspect-video bg-gray-100 rounded-2xl overflow-hidden">
|
||||
<%= image_tag @event.event_image_variant(:large), alt: @event.name, class: "w-full h-full object-cover" %>
|
||||
<% if @event.image.attached? %>
|
||||
<%= image_tag @event.event_image_variant(:large), alt: @event.name, class: "w-full h-full object-cover" %>
|
||||
<% else %>
|
||||
<%= image_tag @event.image_url, alt: @event.name, class: "w-full h-full object-cover" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user