upload_d_n_d 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of upload_d_n_d might be problematic. Click here for more details.

Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +229 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/config/upload_d_n_d_manifest.js +3 -0
  6. data/app/assets/stylesheets/upload_d_n_d/application.css +604 -0
  7. data/app/components/upload_d_n_d/upload_component.html.erb +53 -0
  8. data/app/components/upload_d_n_d/upload_component.rb +204 -0
  9. data/app/controllers/concerns/upload_d_n_d/rate_limiting.rb +89 -0
  10. data/app/controllers/upload_d_n_d/attachments_controller.rb +218 -0
  11. data/app/controllers/upload_d_n_d/base_attachments_controller.rb +250 -0
  12. data/app/controllers/upload_d_n_d/temporary_attachments_controller.rb +200 -0
  13. data/app/helpers/upload_d_n_d/application_helper.rb +51 -0
  14. data/app/helpers/upload_d_n_d/controllers/helpers.rb +21 -0
  15. data/app/javascript/upload_d_n_d/controllers/preview_controller.js +217 -0
  16. data/app/javascript/upload_d_n_d/controllers/uploads_controller.js +396 -0
  17. data/app/javascript/upload_d_n_d/index.js +13 -0
  18. data/app/jobs/upload_d_n_d/application_job.rb +4 -0
  19. data/app/jobs/upload_d_n_d/cleanup_temporary_attachments_job.rb +17 -0
  20. data/app/jobs/upload_d_n_d/purge_orphan_blobs_job.rb +25 -0
  21. data/app/jobs/upload_d_n_d/purge_rejected_blob_job.rb +24 -0
  22. data/app/models/upload_d_n_d/application_record.rb +5 -0
  23. data/app/models/upload_d_n_d/temporary_attachment.rb +129 -0
  24. data/app/views/upload_d_n_d/shared/_file_icon.html.erb +9 -0
  25. data/app/views/upload_d_n_d/shared/_files_list.html.erb +63 -0
  26. data/app/views/upload_d_n_d/shared/_temp_files_list.html.erb +53 -0
  27. data/config/importmap.rb +5 -0
  28. data/config/initializers/inflections.rb +11 -0
  29. data/config/locales/upload_d_n_d.en.yml +80 -0
  30. data/config/locales/upload_d_n_d.fr.yml +80 -0
  31. data/config/routes.rb +12 -0
  32. data/docs/CHANGELOG.md +282 -0
  33. data/lib/generators/upload_d_n_d/POLICY_README +49 -0
  34. data/lib/generators/upload_d_n_d/USAGE +80 -0
  35. data/lib/generators/upload_d_n_d/install_generator.rb +174 -0
  36. data/lib/generators/upload_d_n_d/mime_type_helper.rb +106 -0
  37. data/lib/generators/upload_d_n_d/model_generator.rb +313 -0
  38. data/lib/generators/upload_d_n_d/policy_generator.rb +25 -0
  39. data/lib/generators/upload_d_n_d/templates/POST_INSTALL +62 -0
  40. data/lib/generators/upload_d_n_d/templates/concerns/controllers_uploadable.rb +115 -0
  41. data/lib/generators/upload_d_n_d/templates/concerns/upload_authorization.rb +91 -0
  42. data/lib/generators/upload_d_n_d/templates/concerns/uploadable.rb +101 -0
  43. data/lib/generators/upload_d_n_d/templates/controller.rb.tt +60 -0
  44. data/lib/generators/upload_d_n_d/templates/helpers/upload_helper.rb +105 -0
  45. data/lib/generators/upload_d_n_d/templates/initializers/upload_d_n_d.rb +69 -0
  46. data/lib/generators/upload_d_n_d/templates/migrations/create_upload_d_n_d_temporary_attachments.rb +18 -0
  47. data/lib/generators/upload_d_n_d/templates/policies/upload_d_n_d_attachment_policy.rb +37 -0
  48. data/lib/generators/upload_d_n_d/templates/policies/upload_d_n_d_temporary_attachment_policy.rb +56 -0
  49. data/lib/generators/upload_d_n_d/templates/validators/file_validator.rb +171 -0
  50. data/lib/tasks/upload_d_n_d_storage.rake +27 -0
  51. data/lib/tasks/upload_d_n_d_tasks.rake +20 -0
  52. data/lib/upload_d_n_d/attachment_lookup.rb +24 -0
  53. data/lib/upload_d_n_d/authorization/can_can_can_adapter.rb +29 -0
  54. data/lib/upload_d_n_d/authorization/no_authorization.rb +13 -0
  55. data/lib/upload_d_n_d/authorization/pundit_adapter.rb +41 -0
  56. data/lib/upload_d_n_d/authorization/unconfigured.rb +15 -0
  57. data/lib/upload_d_n_d/authorization.rb +66 -0
  58. data/lib/upload_d_n_d/configuration.rb +97 -0
  59. data/lib/upload_d_n_d/controllers/uploadable.rb +271 -0
  60. data/lib/upload_d_n_d/engine.rb +50 -0
  61. data/lib/upload_d_n_d/field_constraints.rb +92 -0
  62. data/lib/upload_d_n_d/field_reference.rb +44 -0
  63. data/lib/upload_d_n_d/file_validator.rb +156 -0
  64. data/lib/upload_d_n_d/mime_types.rb +75 -0
  65. data/lib/upload_d_n_d/orphan_blobs.rb +151 -0
  66. data/lib/upload_d_n_d/rejected_blob.rb +42 -0
  67. data/lib/upload_d_n_d/session_id.rb +25 -0
  68. data/lib/upload_d_n_d/uploadable.rb +117 -0
  69. data/lib/upload_d_n_d/version.rb +3 -0
  70. data/lib/upload_d_n_d.rb +62 -0
  71. metadata +150 -0
@@ -0,0 +1,217 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ url: String,
6
+ filename: String,
7
+ contentType: String
8
+ }
9
+
10
+ disconnect() {
11
+ this.close()
12
+ }
13
+
14
+ // Ouvrir la modale avec prévisualisation
15
+ open(event) {
16
+ event.preventDefault()
17
+
18
+ const dataset = event.currentTarget.dataset
19
+ const url = dataset.previewUrl
20
+ const filename = dataset.previewFilename
21
+ const contentType = dataset.previewContentType
22
+
23
+ // Libellés i18n fournis par les partials (fallback français)
24
+ this.i18n = {
25
+ unavailable: dataset.previewI18nUnavailable || 'Aperçu non disponible pour ce type de fichier',
26
+ download: dataset.previewI18nDownload || 'Télécharger le fichier',
27
+ close: dataset.previewI18nClose || 'Fermer'
28
+ }
29
+
30
+ this.showPreview(url, filename, contentType)
31
+ }
32
+
33
+ // Afficher la prévisualisation selon le type de fichier
34
+ showPreview(url, filename, contentType) {
35
+ if (this.modal) this.close()
36
+
37
+ this.previousFocus = document.activeElement
38
+ this.modal = this.createModal(filename)
39
+
40
+ const content = this.modal.querySelector('.udnd-preview-body')
41
+ content.appendChild(this.buildContent(url, filename, contentType))
42
+
43
+ document.body.appendChild(this.modal)
44
+ document.body.style.overflow = 'hidden'
45
+
46
+ // Gestion clavier (Escape + focus trap) et fermeture sur navigation Turbo
47
+ this.keydownHandler = (e) => this.handleKeydown(e)
48
+ this.beforeVisitHandler = () => this.close()
49
+ document.addEventListener('keydown', this.keydownHandler)
50
+ document.addEventListener('turbo:before-visit', this.beforeVisitHandler)
51
+
52
+ this.modal.querySelector('.udnd-preview-close').focus()
53
+
54
+ // Animation d'entrée
55
+ requestAnimationFrame(() => this.modal.classList.add('show'))
56
+ }
57
+
58
+ // Contenu de la modale selon le type de fichier (DOM APIs, jamais d'innerHTML
59
+ // avec des données utilisateur : filename est contrôlé par l'uploadeur)
60
+ buildContent(url, filename, contentType) {
61
+ if (this.isImage(contentType)) {
62
+ const img = document.createElement('img')
63
+ img.className = 'udnd-preview-image'
64
+ img.setAttribute('src', url)
65
+ img.setAttribute('alt', filename)
66
+ return img
67
+ }
68
+
69
+ if (this.isPDF(contentType)) {
70
+ // <object> plutôt qu'<iframe> : fallback natif quand le navigateur
71
+ // ne sait pas afficher le PDF (Safari mobile notamment)
72
+ const object = document.createElement('object')
73
+ object.className = 'udnd-preview-pdf'
74
+ object.setAttribute('type', 'application/pdf')
75
+ object.setAttribute('data', url)
76
+ object.appendChild(this.buildUnsupported(url, filename))
77
+ return object
78
+ }
79
+
80
+ return this.buildUnsupported(url, filename)
81
+ }
82
+
83
+ buildUnsupported(url, filename) {
84
+ const wrapper = document.createElement('div')
85
+ wrapper.className = 'udnd-preview-unsupported'
86
+
87
+ const icon = document.createElement('span')
88
+ icon.innerHTML = this.fileIcon() // SVG statique, sans donnée utilisateur
89
+ wrapper.appendChild(icon.firstElementChild)
90
+
91
+ const message = document.createElement('p')
92
+ message.textContent = this.i18n.unavailable
93
+ wrapper.appendChild(message)
94
+
95
+ const link = document.createElement('a')
96
+ link.className = 'udnd-btn-download'
97
+ link.setAttribute('href', url)
98
+ link.setAttribute('download', filename)
99
+ link.textContent = this.i18n.download
100
+ wrapper.appendChild(link)
101
+
102
+ return wrapper
103
+ }
104
+
105
+ // Créer la structure HTML de la modale (DOM APIs)
106
+ createModal(filename) {
107
+ const modal = document.createElement('div')
108
+ modal.className = 'udnd-preview-modal'
109
+
110
+ const overlay = document.createElement('div')
111
+ overlay.className = 'udnd-preview-overlay'
112
+ overlay.addEventListener('click', () => this.close())
113
+ modal.appendChild(overlay)
114
+
115
+ const container = document.createElement('div')
116
+ container.className = 'udnd-preview-container'
117
+ container.setAttribute('role', 'dialog')
118
+ container.setAttribute('aria-modal', 'true')
119
+ container.setAttribute('aria-labelledby', 'udnd-preview-title')
120
+
121
+ const header = document.createElement('div')
122
+ header.className = 'udnd-preview-header'
123
+
124
+ const title = document.createElement('h3')
125
+ title.className = 'udnd-preview-title'
126
+ title.id = 'udnd-preview-title'
127
+ title.textContent = filename
128
+ header.appendChild(title)
129
+
130
+ const closeButton = document.createElement('button')
131
+ closeButton.type = 'button'
132
+ closeButton.className = 'udnd-preview-close'
133
+ closeButton.setAttribute('aria-label', this.i18n.close)
134
+ closeButton.innerHTML = this.closeIcon() // SVG statique, sans donnée utilisateur
135
+ closeButton.addEventListener('click', () => this.close())
136
+ header.appendChild(closeButton)
137
+
138
+ const body = document.createElement('div')
139
+ body.className = 'udnd-preview-body'
140
+
141
+ container.appendChild(header)
142
+ container.appendChild(body)
143
+ modal.appendChild(container)
144
+
145
+ return modal
146
+ }
147
+
148
+ // Fermer la modale
149
+ close() {
150
+ if (!this.modal) return
151
+
152
+ const modal = this.modal
153
+ this.modal = null
154
+
155
+ document.removeEventListener('keydown', this.keydownHandler)
156
+ document.removeEventListener('turbo:before-visit', this.beforeVisitHandler)
157
+
158
+ modal.classList.remove('show')
159
+ setTimeout(() => {
160
+ modal.remove()
161
+ document.body.style.overflow = ''
162
+ }, 300)
163
+
164
+ if (this.previousFocus && document.contains(this.previousFocus)) {
165
+ this.previousFocus.focus()
166
+ }
167
+ this.previousFocus = null
168
+ }
169
+
170
+ // Escape ferme la modale, Tab reste piégé dedans
171
+ handleKeydown(event) {
172
+ if (event.key === 'Escape') {
173
+ this.close()
174
+ return
175
+ }
176
+
177
+ if (event.key === 'Tab' && this.modal) {
178
+ const focusables = this.modal.querySelectorAll('button, a[href], object')
179
+ if (focusables.length === 0) return
180
+
181
+ const first = focusables[0]
182
+ const last = focusables[focusables.length - 1]
183
+
184
+ if (event.shiftKey && document.activeElement === first) {
185
+ event.preventDefault()
186
+ last.focus()
187
+ } else if (!event.shiftKey && document.activeElement === last) {
188
+ event.preventDefault()
189
+ first.focus()
190
+ }
191
+ }
192
+ }
193
+
194
+ // Helpers de détection de type
195
+ isImage(contentType) {
196
+ return contentType && (
197
+ contentType.startsWith('image/jpeg') ||
198
+ contentType.startsWith('image/jpg') ||
199
+ contentType.startsWith('image/png') ||
200
+ contentType.startsWith('image/webp') ||
201
+ contentType.startsWith('image/gif')
202
+ )
203
+ }
204
+
205
+ isPDF(contentType) {
206
+ return contentType && contentType.includes('pdf')
207
+ }
208
+
209
+ // SVG statiques (contenu fixe, aucune interpolation)
210
+ fileIcon() {
211
+ return '<svg class="udnd-icon udnd-icon-xl" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/></svg>'
212
+ }
213
+
214
+ closeIcon() {
215
+ return '<svg class="udnd-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>'
216
+ }
217
+ }
@@ -0,0 +1,396 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { DirectUpload } from "@rails/activestorage"
3
+
4
+ export default class extends Controller {
5
+ static targets = ["progressBar"]
6
+ static values = {
7
+ attachUrl: String,
8
+ attributeName: String,
9
+ recordId: String,
10
+ recordGid: String,
11
+ multiple: Boolean,
12
+ maxSize: Number,
13
+ acceptedTypes: String,
14
+ mimeTypes: String,
15
+ fieldRef: String,
16
+ i18n: Object
17
+ }
18
+
19
+ connect() {
20
+ this.dropzone = this.element.querySelector('.udnd-dropzone')
21
+ }
22
+
23
+ // --- Handlers Drag & Drop ---
24
+ handleDragEnter(e) {
25
+ e.preventDefault()
26
+ this.dropzone.classList.add('dragover')
27
+ }
28
+
29
+ handleDragOver(e) {
30
+ e.preventDefault()
31
+ this.dropzone.classList.add('dragover')
32
+ }
33
+
34
+ handleDragLeave(e) {
35
+ this.dropzone.classList.remove('dragover')
36
+ }
37
+
38
+ handleDrop(e) {
39
+ e.preventDefault()
40
+ this.dropzone.classList.remove('dragover')
41
+ const files = Array.from(e.dataTransfer.files)
42
+ this.processFiles(files)
43
+ }
44
+
45
+ previewFiles(e) {
46
+ const files = Array.from(e.target.files)
47
+ this.processFiles(files)
48
+ }
49
+
50
+ // --- Logique d'Upload ---
51
+ async processFiles(files) {
52
+ // VALIDATION UX : Si multiple: false, n'accepter qu'un seul fichier
53
+ if (!this.multipleValue && files.length > 1) {
54
+ const msg = this.i18nValue.alerts.singleFileOnly
55
+ this.showAlert(msg.title, msg.text, "warning")
56
+ // Ne traiter que le premier fichier
57
+ files = [files[0]]
58
+ }
59
+
60
+ for (const file of files) {
61
+ if (!this.validateFile(file)) continue
62
+
63
+ // État UI "upload en cours"
64
+ this.element.classList.add('upload-active')
65
+ this.dropzone?.classList.add('upload-active')
66
+ const upload = new DirectUpload(file, '/rails/active_storage/direct_uploads', this)
67
+
68
+ upload.create((error, blob) => {
69
+ if (error) {
70
+ const msg = this.i18nValue.alerts.uploadError
71
+ this.showAlert(msg.title, msg.text, "error")
72
+ this.element.classList.remove('upload-active')
73
+ this.dropzone?.classList.remove('upload-active')
74
+ } else {
75
+ this.attachBlob(blob)
76
+ }
77
+ })
78
+ }
79
+ }
80
+
81
+ validateFile(file) {
82
+ // Validation de la taille
83
+ if (file.size > this.maxSizeValue) {
84
+ const msg = this.i18nValue.alerts.fileTooLarge
85
+ const maxSizeMB = this.maxSizeValue / 1048576
86
+ this.showAlert(msg.title, msg.text.replace('%{max_size}', maxSizeMB), "error")
87
+ return false
88
+ }
89
+
90
+ // Validation fichier vide
91
+ if (file.size === 0) {
92
+ const msg = this.i18nValue.alerts.emptyFile
93
+ this.showAlert(msg.title, msg.text, "error")
94
+ return false
95
+ }
96
+
97
+ // Validation de l'extension — comparaison exacte sur la liste,
98
+ // pas en substring (".jpe" ne doit pas passer parce que ".jpeg" est accepté)
99
+ const ext = "." + file.name.split('.').pop().toLowerCase()
100
+ const acceptedExts = this.acceptedTypesValue.split(',').map(e => e.trim().toLowerCase())
101
+ if (!acceptedExts.includes(ext)) {
102
+ const msg = this.i18nValue.alerts.invalidFormat
103
+ this.showAlert(msg.title, msg.text.replace('%{accepted_types}', this.acceptedTypesValue), "error")
104
+ return false
105
+ }
106
+
107
+ // Validation du MIME type (sécurité supplémentaire)
108
+ // Note: file.type peut être trompé mais c'est une première barrière
109
+ if (file.type && this.hasAcceptedMimeTypesValue) {
110
+ // mimeTypes : types calculés par le serveur (pas hasMimeTypesValue,
111
+ // l'attribut est toujours rendu, éventuellement vide)
112
+ const acceptedMimes = this.mimeTypesValue
113
+ ? this.mimeTypesValue.split(',').map(m => m.trim())
114
+ : this.getAcceptedMimeTypes(this.acceptedTypesValue)
115
+ if (!acceptedMimes.includes(file.type)) {
116
+ const msg = this.i18nValue.alerts.suspiciousType
117
+ this.showAlert(msg.title, msg.text, "error")
118
+ return false
119
+ }
120
+ }
121
+
122
+ return true
123
+ }
124
+
125
+ // Repli si le serveur n'a pas fourni les types MIME (ancien rendu)
126
+ // Mapping extensions → MIME types communs
127
+ getAcceptedMimeTypes(extensions) {
128
+ const mimeMap = {
129
+ '.jpg': 'image/jpeg',
130
+ '.jpeg': 'image/jpeg',
131
+ '.png': 'image/png',
132
+ '.gif': 'image/gif',
133
+ '.webp': 'image/webp',
134
+ '.pdf': 'application/pdf',
135
+ '.doc': 'application/msword',
136
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
137
+ '.xls': 'application/vnd.ms-excel',
138
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
139
+ '.csv': 'text/csv',
140
+ '.txt': 'text/plain',
141
+ '.zip': 'application/zip',
142
+ '.mp4': 'video/mp4',
143
+ '.mp3': 'audio/mpeg'
144
+ }
145
+
146
+ const exts = extensions.split(',').map(e => e.trim().toLowerCase())
147
+ return exts.flatMap(ext => mimeMap[ext] || [])
148
+ }
149
+
150
+ get hasAcceptedMimeTypesValue() {
151
+ return this.acceptedTypesValue && this.acceptedTypesValue.trim().length > 0
152
+ }
153
+
154
+ attachBlob(blob) {
155
+ const payload = {
156
+ signed_blob_id: blob.signed_id,
157
+ attribute_name: this.attributeNameValue,
158
+ multiple: this.multipleValue
159
+ }
160
+
161
+ // Référence signée du champ : le serveur applique les limites du modèle
162
+ if (this.fieldRefValue) {
163
+ payload.field_ref = this.fieldRefValue
164
+ }
165
+
166
+ // Si on a un record existant, on passe son GlobalID
167
+ if (this.hasRecordGidValue && this.recordGidValue) {
168
+ payload.record_gid = this.recordGidValue
169
+ }
170
+
171
+ fetch(this.attachUrlValue, {
172
+ method: 'POST',
173
+ headers: {
174
+ 'Content-Type': 'application/json',
175
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
176
+ 'Accept': 'text/vnd.turbo-stream.html'
177
+ },
178
+ body: JSON.stringify(payload)
179
+ })
180
+ .then(response => {
181
+ if (!response.ok) {
182
+ throw new Error(`HTTP ${response.status}`)
183
+ }
184
+ return response.text()
185
+ })
186
+ .then(html => {
187
+ Turbo.renderStreamMessage(html)
188
+ this.element.classList.remove('upload-active')
189
+ this.dropzone?.classList.remove('upload-active')
190
+ })
191
+ .catch(error => {
192
+ console.error('Upload failed:', error)
193
+ const msg = this.i18nValue.alerts.attachError
194
+ this.showAlert(msg.title, msg.text, "error")
195
+ this.element.classList.remove('upload-active')
196
+ this.dropzone?.classList.remove('upload-active')
197
+ })
198
+ }
199
+
200
+ async confirmDelete(e) {
201
+ e.preventDefault()
202
+ const url = e.currentTarget.dataset.url
203
+
204
+ const msg = this.i18nValue.confirms.deleteFile
205
+ const confirmed = await this.askConfirmation(
206
+ msg.title,
207
+ msg.text,
208
+ msg.confirmButton,
209
+ msg.cancelButton
210
+ )
211
+
212
+ if (confirmed) {
213
+ fetch(url, {
214
+ method: 'DELETE',
215
+ headers: {
216
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
217
+ 'Accept': 'text/vnd.turbo-stream.html'
218
+ }
219
+ })
220
+ .then(response => {
221
+ if (!response.ok) {
222
+ throw new Error(`HTTP ${response.status}`)
223
+ }
224
+ return response.text()
225
+ })
226
+ .then(html => Turbo.renderStreamMessage(html))
227
+ .catch(error => {
228
+ console.error('Delete failed:', error)
229
+ const errorMsg = this.i18nValue.alerts.deleteError
230
+ this.showAlert(errorMsg.title, errorMsg.text, "error")
231
+ })
232
+ }
233
+ }
234
+
235
+ // --- Helpers UI ---
236
+ showAlert(title, text, icon) {
237
+ // Utilise le système de modales natif
238
+ this.createModal(title, text, icon, false)
239
+ }
240
+
241
+ async askConfirmation(title, text, confirmButtonText, cancelButtonText) {
242
+ // Utilise le système de modales natif pour la confirmation
243
+ return this.createModal(title, text, 'warning', true, confirmButtonText, cancelButtonText)
244
+ }
245
+
246
+ // Crée une modale (alert ou confirm) via DOM APIs (pas d'innerHTML)
247
+ createModal(title, message, type, isConfirm, confirmText = "OK", cancelText = "Cancel") {
248
+ return new Promise((resolve) => {
249
+ // Overlay
250
+ // Listeners directs obligatoires : la modale est appendue à document.body,
251
+ // hors du scope de ce controller Stimulus — les data-action n'y sont jamais bindés
252
+ const overlay = document.createElement('div')
253
+ overlay.className = 'udnd-modal-overlay'
254
+ overlay.addEventListener('click', (e) => {
255
+ if (e.target === overlay) this.cancelModal()
256
+ })
257
+ overlay.setAttribute('role', 'dialog')
258
+ overlay.setAttribute('aria-modal', 'true')
259
+
260
+ // Modal
261
+ const modal = document.createElement('div')
262
+ modal.className = 'udnd-modal'
263
+
264
+ // Header + icon
265
+ const header = document.createElement('div')
266
+ header.className = 'udnd-modal-header'
267
+ const iconDiv = document.createElement('div')
268
+ iconDiv.className = `udnd-modal-icon udnd-modal-icon--${type}`
269
+ iconDiv.innerHTML = this.getIcon(type) // SVG icons are safe (static content)
270
+ header.appendChild(iconDiv)
271
+
272
+ // Body
273
+ const body = document.createElement('div')
274
+ body.className = 'udnd-modal-body'
275
+ const h3 = document.createElement('h3')
276
+ h3.className = 'udnd-modal-title'
277
+ h3.textContent = title
278
+ const p = document.createElement('p')
279
+ p.className = 'udnd-modal-message'
280
+ p.textContent = message
281
+ body.appendChild(h3)
282
+ body.appendChild(p)
283
+
284
+ // Footer
285
+ const footer = document.createElement('div')
286
+ footer.className = 'udnd-modal-footer'
287
+
288
+ if (isConfirm) {
289
+ const cancelBtn = document.createElement('button')
290
+ cancelBtn.type = 'button'
291
+ cancelBtn.className = 'udnd-modal-button udnd-modal-button--cancel'
292
+ cancelBtn.addEventListener('click', () => this.cancelModal())
293
+ cancelBtn.textContent = cancelText
294
+
295
+ const confirmBtn = document.createElement('button')
296
+ confirmBtn.type = 'button'
297
+ confirmBtn.className = 'udnd-modal-button udnd-modal-button--confirm'
298
+ confirmBtn.addEventListener('click', () => this.confirmModal())
299
+ confirmBtn.textContent = confirmText
300
+
301
+ footer.appendChild(cancelBtn)
302
+ footer.appendChild(confirmBtn)
303
+ } else {
304
+ const okBtn = document.createElement('button')
305
+ okBtn.type = 'button'
306
+ okBtn.className = 'udnd-modal-button udnd-modal-button--primary'
307
+ okBtn.addEventListener('click', () => this.closeModal())
308
+ okBtn.textContent = confirmText
309
+ footer.appendChild(okBtn)
310
+ }
311
+
312
+ modal.appendChild(header)
313
+ modal.appendChild(body)
314
+ modal.appendChild(footer)
315
+ overlay.appendChild(modal)
316
+
317
+ const container = document.createElement('div')
318
+ container.appendChild(overlay)
319
+ container._resolveFunction = resolve
320
+ this.currentModalContainer = container
321
+
322
+ document.body.appendChild(container)
323
+
324
+ // Anime l'ouverture
325
+ requestAnimationFrame(() => {
326
+ overlay.classList.add('udnd-modal-overlay--active')
327
+ })
328
+
329
+ // Gestion Escape
330
+ this.modalKeydownHandler = (e) => {
331
+ if (e.key === 'Escape') {
332
+ this.cancelModal()
333
+ }
334
+ }
335
+ document.addEventListener('keydown', this.modalKeydownHandler)
336
+ })
337
+ }
338
+
339
+ getIcon(type) {
340
+ const icons = {
341
+ info: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
342
+ success: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
343
+ warning: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
344
+ error: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>'
345
+ }
346
+ return icons[type] || icons.info
347
+ }
348
+
349
+ confirmModal() {
350
+ if (this.currentModalContainer?._resolveFunction) {
351
+ this.currentModalContainer._resolveFunction(true)
352
+ }
353
+ this.closeModal()
354
+ }
355
+
356
+ cancelModal() {
357
+ if (this.currentModalContainer?._resolveFunction) {
358
+ this.currentModalContainer._resolveFunction(false)
359
+ }
360
+ this.closeModal()
361
+ }
362
+
363
+ closeModal() {
364
+ if (!this.currentModalContainer) return
365
+
366
+ // Résout la Promise pour les alertes simples
367
+ if (this.currentModalContainer._resolveFunction) {
368
+ this.currentModalContainer._resolveFunction(true)
369
+ }
370
+
371
+ const overlay = this.currentModalContainer.querySelector('.udnd-modal-overlay')
372
+ overlay.classList.remove('udnd-modal-overlay--active')
373
+
374
+ // Supprime du DOM après l'animation
375
+ setTimeout(() => {
376
+ this.currentModalContainer?.remove()
377
+ this.currentModalContainer = null
378
+ }, 300)
379
+
380
+ // Nettoie le listener Escape
381
+ if (this.modalKeydownHandler) {
382
+ document.removeEventListener('keydown', this.modalKeydownHandler)
383
+ this.modalKeydownHandler = null
384
+ }
385
+ }
386
+
387
+ // Callback obligatoire pour DirectUpload (ActiveStorage)
388
+ directUploadWillStoreFileWithXHR(xhr) {
389
+ xhr.upload.addEventListener("progress", event => {
390
+ const progress = (event.loaded / event.total) * 100
391
+ if (this.hasProgressBarTarget) {
392
+ this.progressBarTarget.style.width = `${progress}%`
393
+ }
394
+ })
395
+ }
396
+ }
@@ -0,0 +1,13 @@
1
+ import UploadsController from "./controllers/uploads_controller.js"
2
+ import PreviewController from "./controllers/preview_controller.js"
3
+
4
+ // Export nommés pour usage manuel
5
+ export { UploadsController, PreviewController }
6
+
7
+ // Auto-enregistrement des controllers Stimulus
8
+ // L'app hôte doit juste ajouter : import "upload_d_n_d"
9
+ const application = window.Stimulus
10
+ if (application) {
11
+ application.register("upload-d-n-d--uploads", UploadsController)
12
+ application.register("upload-d-n-d--preview", PreviewController)
13
+ }
@@ -0,0 +1,4 @@
1
+ module UploadDND
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UploadDND
4
+ # Purge les fichiers temporaires expirés (au-delà de
5
+ # temporary_files_retention jours, 7 par défaut).
6
+ #
7
+ # La gem fournit le job mais ne le planifie pas : brancher la récurrence
8
+ # côté app hôte (solid_queue recurring, sidekiq-cron, whenever...).
9
+ # Voir la section "Nettoyage automatique" du README.
10
+ class CleanupTemporaryAttachmentsJob < ApplicationJob
11
+ queue_as :default
12
+
13
+ def perform
14
+ TemporaryAttachment.cleanup_expired!
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UploadDND
4
+ # Nettoie les blobs orphelins listés par UploadDND::OrphanBlobs. Opt-in : la
5
+ # gem fournit le job, l'app hôte le planifie (voir README, "Nettoyage
6
+ # automatique"). apply: false (défaut) journalise un rapport sans supprimer ;
7
+ # l'app passe apply: true en connaissance de cause.
8
+ class PurgeOrphanBlobsJob < ApplicationJob
9
+ queue_as :default
10
+
11
+ def perform(apply: false)
12
+ results = UploadDND::OrphanBlobs.call
13
+ purgeable = results.select(&:purgeable?)
14
+
15
+ purgeable.each { |result| result.blob.purge } if apply
16
+
17
+ size = ActiveSupport::NumberHelper.number_to_human_size(purgeable.sum { |result| result.blob.byte_size })
18
+ Rails.logger.info(
19
+ "UploadDND::PurgeOrphanBlobsJob: #{purgeable.size} blob(s) orphelin(s) " \
20
+ "#{apply ? "purgé(s)" : "à purger (simulation)"} (#{size}), " \
21
+ "#{results.size - purgeable.size} cité(s) dans un texte et conservé(s)."
22
+ )
23
+ end
24
+ end
25
+ end