Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/controllers/ocr_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ def scan
ocrresult.image.attach(file)
ocrresult.save

if params[:files].length > 1
params[:files][1..].each { |f| ocrresult.extra_images.attach(f) }
end

logger.debug "OCR data id stored in flash: #{ocrresult.id}"

# If multiple recipes detected, redirect to selection page
Expand Down
1 change: 1 addition & 0 deletions app/controllers/recipes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def create
ocrresult = OcrResult.find_by(id: params[:ocrresult_id])
if ocrresult.present?
@recipe.recipe_images.attach(ocrresult.image.blob) if ocrresult.image.attached?
ocrresult.extra_images.each { |img| @recipe.recipe_images.attach(img.blob) }
end
end

Expand Down
108 changes: 102 additions & 6 deletions app/javascript/controllers/dropzone_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import { Controller } from "@hotwired/stimulus"
import { Utils } from "../src/utils"

export default class extends Controller {
static targets = ["dropzone", "fileInput", "spinner", "successMessage", "errorMessage", "resetButton"]
static values = { url: String }
static targets = ["dropzone", "fileInput", "spinner", "successMessage", "errorMessage", "resetButton", "previewContainer", "uploadButton"]
static values = { url: String, previewMode: Boolean }

connect() {
this.maxFiles = 10
this.acceptedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/heic', 'image/heif']
this.acceptedExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif']
this.pendingFiles = []
this.pendingObjectUrls = []

// Initialize button state in preview mode
if (this.previewModeValue && this.hasUploadButtonTarget) {
this.uploadButtonTarget.disabled = true
}
}

// Drag and drop handlers
Expand Down Expand Up @@ -58,8 +65,9 @@ export default class extends Controller {
// Hide any previous messages
this.hideMessages()

// Validate file count
if (files.length > this.maxFiles) {
// Validate file count (account for already-queued files in preview mode)
const totalCount = this.previewModeValue ? this.pendingFiles.length + files.length : files.length
if (totalCount > this.maxFiles) {
this.showError(`Maximum ${this.maxFiles} files allowed. You selected ${files.length} files.`)
return
}
Expand All @@ -81,8 +89,84 @@ export default class extends Controller {
return
}

// All validations passed, upload files
this.uploadFiles(files)
if (this.previewModeValue) {
for (let i = 0; i < files.length; i++) {
this.pendingFiles.push(files[i])
}
this.renderPreviews()
this.uploadButtonTarget.disabled = false
} else {
// All validations passed, upload files
this.uploadFiles(files)
}
}

renderPreviews() {
// Revoke old object URLs before rebuilding
this.pendingObjectUrls.forEach(url => URL.revokeObjectURL(url))
this.pendingObjectUrls = []

if (this.pendingFiles.length === 0) {
this.previewContainerTarget.classList.add('hidden')
this.previewContainerTarget.innerHTML = ''
return
}

this.previewContainerTarget.classList.remove('hidden')

const grid = document.createElement('div')
grid.className = 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 mt-4'

this.pendingFiles.forEach((file, index) => {
const objectUrl = URL.createObjectURL(file)
this.pendingObjectUrls.push(objectUrl)

const card = document.createElement('div')
card.className = 'relative rounded-lg overflow-hidden border border-gray-200 bg-gray-50'
card.innerHTML = `
<img src="${objectUrl}" alt="${this.escapeHtml(file.name)}" class="w-full h-24 object-cover">
<div class="px-2 py-1 text-xs text-gray-600 truncate">${this.escapeHtml(file.name)}</div>
<button type="button"
class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center hover:bg-red-600"
data-action="click->dropzone#removeFile"
data-index="${index}"
aria-label="Remove">
<i class="fas fa-times text-xs pointer-events-none"></i>
</button>
${index === 0 ? '<div class="absolute top-1 left-1 bg-blue-500 text-white text-xs rounded px-1 leading-5">AI</div>' : ''}
`
grid.appendChild(card)
})

this.previewContainerTarget.innerHTML = ''
this.previewContainerTarget.appendChild(grid)
}

removeFile(event) {
const index = parseInt(event.currentTarget.dataset.index, 10)
this.pendingFiles.splice(index, 1)

if (this.pendingFiles.length === 0) {
this.uploadButtonTarget.disabled = true
this.pendingObjectUrls.forEach(url => URL.revokeObjectURL(url))
this.pendingObjectUrls = []
this.previewContainerTarget.classList.add('hidden')
this.previewContainerTarget.innerHTML = ''
} else {
this.renderPreviews()
}
}

submit() {
const filesToUpload = this.pendingFiles.slice()
this.pendingFiles = []
this.uploadButtonTarget.disabled = true
this.previewContainerTarget.classList.add('hidden')
this.uploadFiles(filesToUpload)
}

escapeHtml(str) {
return str.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}

uploadFiles(files) {
Expand Down Expand Up @@ -154,6 +238,18 @@ export default class extends Controller {
// Clear file input
this.fileInputTarget.value = ''

// Clear any pending preview state
this.pendingFiles = []
this.pendingObjectUrls.forEach(url => URL.revokeObjectURL(url))
this.pendingObjectUrls = []
if (this.hasPreviewContainerTarget) {
this.previewContainerTarget.classList.add('hidden')
this.previewContainerTarget.innerHTML = ''
}
if (this.hasUploadButtonTarget) {
this.uploadButtonTarget.classList.add('hidden')
}

// Re-enable dropzone
this.enableDropzone()
}
Expand Down
1 change: 1 addition & 0 deletions app/models/ocr_result.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
class OcrResult < ApplicationRecord
has_one_attached :image
has_many_attached :extra_images
end
29 changes: 17 additions & 12 deletions app/views/ocr/select_image_for_reparse.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,23 @@
<%= t('recipes.select_image_for_reparse.description') %>
</p>

<div class="mb-6">
<label class="text-gray-700 font-semibold heading_2" for="ai_method_select_reparse">
<%= t 'ocr.ai_method.label' %>
</label>
<select id="ai_method_select_reparse"
name="ai_method"
data-reparse-target="aiMethod"
class="block w-full md:w-1/2 mt-2 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
<option value="mistral_openai" selected><%= t 'ocr.ai_method.mistral_openai' %></option>
<option value="openai_direct"><%= t 'ocr.ai_method.openai_direct' %></option>
</select>
</div>
<details class="mb-2">
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-700 select-none">
<span><%= t 'ocr.ai_method.label' %>:</span>
<span class="font-medium" id="ai_method_display_reparse"><%= t 'ocr.ai_method.mistral_openai' %></span>
<span class="text-gray-400">▼</span>
</summary>
<div class="mt-2 pl-2">
<select id="ai_method_select_reparse"
name="ai_method"
data-reparse-target="aiMethod"
class="block w-full md:w-1/2 px-2 py-1 text-sm border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
onchange="document.getElementById('ai_method_display_reparse').textContent = this.options[this.selectedIndex].text">
<option value="mistral_openai" selected><%= t 'ocr.ai_method.mistral_openai' %></option>
<option value="openai_direct"><%= t 'ocr.ai_method.openai_direct' %></option>
</select>
</div>
</details>

<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
<% @recipe.recipe_images.each_with_index do |image, index| %>
Expand Down
29 changes: 17 additions & 12 deletions app/views/recipes/new_magic.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@
<span class="heading_1"><%=t 'recipes.new_magic' %></span>
</div>

<div class="mb-4 mt-4">
<label class="text-gray-700 font-semibold heading_2" for="ai_method_select">
<%= t 'ocr.ai_method.label' %>
</label>
<select id="ai_method_select"
name="ai_method"
class="block w-full md:w-1/2 mt-2 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
<option value="mistral_openai" selected><%= t 'ocr.ai_method.mistral_openai' %></option>
<option value="openai_direct"><%= t 'ocr.ai_method.openai_direct' %></option>
</select>
</div>
<details class="mb-2 mt-2">
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-700 select-none">
<span><%= t 'ocr.ai_method.label' %>:</span>
<span class="font-medium" id="ai_method_display"><%= t 'ocr.ai_method.mistral_openai' %></span>
<span class="text-gray-400">▼</span>
</summary>
<div class="mt-2 pl-2">
<select id="ai_method_select"
name="ai_method"
class="block w-full md:w-1/2 px-2 py-1 text-sm border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
onchange="document.getElementById('ai_method_display').textContent = this.options[this.selectedIndex].text">
<option value="mistral_openai" selected><%= t 'ocr.ai_method.mistral_openai' %></option>
<option value="openai_direct"><%= t 'ocr.ai_method.openai_direct' %></option>
</select>
</div>
</details>

<%= render 'shared/dropzone', url: scan_ocr_index_path %>
<%= render 'shared/dropzone', url: scan_ocr_index_path, preview_mode: true %>
20 changes: 19 additions & 1 deletion app/views/shared/_dropzone.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
formats_key ||= 'ocr.dropzone.formats'
uploading_key ||= 'ocr.dropzone.uploading'
reset_button_key ||= 'ocr.dropzone.reset_button'
process_button_key ||= 'ocr.dropzone.process_button'
accept ||= 'image/*,.heic,.heif'
multiple ||= true
wrapper_classes ||= 'mb-6 mt-4'
preview_mode ||= false
%>

<div data-controller="dropzone" data-dropzone-url-value="<%= url %>" class="<%= wrapper_classes %>">
<div data-controller="dropzone" data-dropzone-url-value="<%= url %>" data-dropzone-preview-mode-value="<%= preview_mode %>" class="<%= wrapper_classes %>">
<!-- Dropzone Area -->
<div data-dropzone-target="dropzone"
data-action="dragenter->dropzone#dragenter dragover->dropzone#dragover dragleave->dropzone#dragleave drop->dropzone#drop click->dropzone#clickToSelect"
Expand Down Expand Up @@ -42,6 +44,22 @@
<!-- Error Message -->
<div data-dropzone-target="errorMessage" class="notification danger hidden mt-4"></div>

<!-- Image Previews (preview mode only) -->
<div data-dropzone-target="previewContainer" class="hidden"></div>

<!-- Process / Upload Button (preview mode only) -->
<% if preview_mode %>
<div class="mt-4 text-center">
<button type="button"
data-dropzone-target="uploadButton"
data-action="click->dropzone#submit"
class="button disabled:opacity-50 disabled:cursor-not-allowed"
disabled>
<i class="fas fa-paper-plane"></i> <%=t process_button_key %>
</button>
</div>
<% end %>

<!-- Reset Button -->
<div class="mt-4 text-center">
<button type="button"
Expand Down
1 change: 1 addition & 0 deletions config/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ de:
formats: 'Unterstützte Formate: JPG, PNG, WebP, HEIC, HEIF'
uploading: 'KI entziffert dein Rezept...'
reset_button: 'Weitere Bilder hochladen'
process_button: 'Bilder verarbeiten'
errors:
parse_failed: 'OCR-Ergebnisse konnten nicht verarbeitet werden. Bitte versuchen Sie es erneut.'
processing_failed: 'OCR-Verarbeitung fehlgeschlagen. Bitte versuchen Sie es erneut.'
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ en:
formats: 'Supported formats: JPG, PNG, WebP, HEIC, HEIF'
uploading: 'AI is deciphering your recipe...'
reset_button: 'Upload More Images'
process_button: 'Process Images'
errors:
parse_failed: 'Failed to parse OCR results. Please try again.'
processing_failed: 'OCR processing failed. Please try again.'
Expand Down
Loading