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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +229 -0
- data/Rakefile +8 -0
- data/app/assets/config/upload_d_n_d_manifest.js +3 -0
- data/app/assets/stylesheets/upload_d_n_d/application.css +604 -0
- data/app/components/upload_d_n_d/upload_component.html.erb +53 -0
- data/app/components/upload_d_n_d/upload_component.rb +204 -0
- data/app/controllers/concerns/upload_d_n_d/rate_limiting.rb +89 -0
- data/app/controllers/upload_d_n_d/attachments_controller.rb +218 -0
- data/app/controllers/upload_d_n_d/base_attachments_controller.rb +250 -0
- data/app/controllers/upload_d_n_d/temporary_attachments_controller.rb +200 -0
- data/app/helpers/upload_d_n_d/application_helper.rb +51 -0
- data/app/helpers/upload_d_n_d/controllers/helpers.rb +21 -0
- data/app/javascript/upload_d_n_d/controllers/preview_controller.js +217 -0
- data/app/javascript/upload_d_n_d/controllers/uploads_controller.js +396 -0
- data/app/javascript/upload_d_n_d/index.js +13 -0
- data/app/jobs/upload_d_n_d/application_job.rb +4 -0
- data/app/jobs/upload_d_n_d/cleanup_temporary_attachments_job.rb +17 -0
- data/app/jobs/upload_d_n_d/purge_orphan_blobs_job.rb +25 -0
- data/app/jobs/upload_d_n_d/purge_rejected_blob_job.rb +24 -0
- data/app/models/upload_d_n_d/application_record.rb +5 -0
- data/app/models/upload_d_n_d/temporary_attachment.rb +129 -0
- data/app/views/upload_d_n_d/shared/_file_icon.html.erb +9 -0
- data/app/views/upload_d_n_d/shared/_files_list.html.erb +63 -0
- data/app/views/upload_d_n_d/shared/_temp_files_list.html.erb +53 -0
- data/config/importmap.rb +5 -0
- data/config/initializers/inflections.rb +11 -0
- data/config/locales/upload_d_n_d.en.yml +80 -0
- data/config/locales/upload_d_n_d.fr.yml +80 -0
- data/config/routes.rb +12 -0
- data/docs/CHANGELOG.md +282 -0
- data/lib/generators/upload_d_n_d/POLICY_README +49 -0
- data/lib/generators/upload_d_n_d/USAGE +80 -0
- data/lib/generators/upload_d_n_d/install_generator.rb +174 -0
- data/lib/generators/upload_d_n_d/mime_type_helper.rb +106 -0
- data/lib/generators/upload_d_n_d/model_generator.rb +313 -0
- data/lib/generators/upload_d_n_d/policy_generator.rb +25 -0
- data/lib/generators/upload_d_n_d/templates/POST_INSTALL +62 -0
- data/lib/generators/upload_d_n_d/templates/concerns/controllers_uploadable.rb +115 -0
- data/lib/generators/upload_d_n_d/templates/concerns/upload_authorization.rb +91 -0
- data/lib/generators/upload_d_n_d/templates/concerns/uploadable.rb +101 -0
- data/lib/generators/upload_d_n_d/templates/controller.rb.tt +60 -0
- data/lib/generators/upload_d_n_d/templates/helpers/upload_helper.rb +105 -0
- data/lib/generators/upload_d_n_d/templates/initializers/upload_d_n_d.rb +69 -0
- data/lib/generators/upload_d_n_d/templates/migrations/create_upload_d_n_d_temporary_attachments.rb +18 -0
- data/lib/generators/upload_d_n_d/templates/policies/upload_d_n_d_attachment_policy.rb +37 -0
- data/lib/generators/upload_d_n_d/templates/policies/upload_d_n_d_temporary_attachment_policy.rb +56 -0
- data/lib/generators/upload_d_n_d/templates/validators/file_validator.rb +171 -0
- data/lib/tasks/upload_d_n_d_storage.rake +27 -0
- data/lib/tasks/upload_d_n_d_tasks.rake +20 -0
- data/lib/upload_d_n_d/attachment_lookup.rb +24 -0
- data/lib/upload_d_n_d/authorization/can_can_can_adapter.rb +29 -0
- data/lib/upload_d_n_d/authorization/no_authorization.rb +13 -0
- data/lib/upload_d_n_d/authorization/pundit_adapter.rb +41 -0
- data/lib/upload_d_n_d/authorization/unconfigured.rb +15 -0
- data/lib/upload_d_n_d/authorization.rb +66 -0
- data/lib/upload_d_n_d/configuration.rb +97 -0
- data/lib/upload_d_n_d/controllers/uploadable.rb +271 -0
- data/lib/upload_d_n_d/engine.rb +50 -0
- data/lib/upload_d_n_d/field_constraints.rb +92 -0
- data/lib/upload_d_n_d/field_reference.rb +44 -0
- data/lib/upload_d_n_d/file_validator.rb +156 -0
- data/lib/upload_d_n_d/mime_types.rb +75 -0
- data/lib/upload_d_n_d/orphan_blobs.rb +151 -0
- data/lib/upload_d_n_d/rejected_blob.rb +42 -0
- data/lib/upload_d_n_d/session_id.rb +25 -0
- data/lib/upload_d_n_d/uploadable.rb +117 -0
- data/lib/upload_d_n_d/version.rb +3 -0
- data/lib/upload_d_n_d.rb +62 -0
- 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,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
|