1import { Controller } from '@hotwired/stimulus'; 2 3const MIME_TYPE_MAP = { 4 // audio 5 'audio/mpeg': 'audio', 'audio/mp3': 'audio', 'audio/ogg': 'audio', 6 'audio/wav': 'audio', 'audio/webm': 'audio', 'audio/aac': 'audio', 7 // video 8 'video/mp4': 'video', 'video/webm': 'video', 'video/ogg': 'video', 9 'video/quicktime': 'video', 'video/x-msvideo': 'video', 10}; 11 12export default class extends Controller { 13 static targets = [ 14 // mode tabs 15 'tabUpload', 'tabUrl', 'tabBook', 16 // mode panels 17 'panelUpload', 'panelUrl', 'panelBook', 18 // upload panel 19 'dropZone', 'fileInput', 'dropZoneContent', 'uploadProgress', 'uploadSuccess', 'uploadFilename', 20 // url panel â hidden form field (HiddenType) + visible mirror 21 'urlField', 'urlDisplay', 22 // book panel 23 'bookSearch', 'bookList', 24 // type selector (Symfony form select) 25 'typeField', 26 // thumbnail 27 'thumbField', 'thumbPreview', 'thumbPreviewImg', 'thumbEmpty', 'thumbInput', 'removeThumbBtn', 28 // ai generate 29 'aiBtn', 30 // title (for AI prompt) 31 'titleInput', 32 // description (for AI prompt context) 33 'descField', 34 ]; 35 36 static values = { 37 uploadUrl: String, 38 bookSearchUrl: String, 39 aiThumbUrl: String, 40 uploadCsrf: String, 41 aiCsrf: String, 42 }; 43 44 connect() { 45 this._currentMode = null; 46 this._allBooks = []; 47 48 // Determine initial mode from current url value 49 const url = this.hasUrlFieldTarget ? this.urlFieldTarget.value.trim() : ''; 50 if (url === '') { 51 this._setMode('upload'); 52 } else { 53 // Editing an existing bonus that already has a URL â show paste mode 54 this._setMode('url'); 55 if (this.hasUrlDisplayTarget) this.urlDisplayTarget.value = url; 56 // Show upload success state if the existing url looks like a CDN upload 57 if (url.includes('cdn') || url.includes('spaces') || url.includes('digitalocean')) { 58 this._showUploadSuccess(url.split('/').pop() || url); 59 } 60 } 61 62 // Initialise thumbnail display from existing value 63 const thumb = this.hasThumbFieldTarget ? this.thumbFieldTarget.value.trim() : ''; 64 if (thumb !== '') { 65 this._showThumb(thumb); 66 } 67 68 // Load books in background so the picker is ready 69 this._loadBooks(); 70 } 71 72 // Sync the visible paste-URL input â the hidden url form field 73 syncUrlDisplay() { 74 if (this.hasUrlDisplayTarget && this.hasUrlFieldTarget) { 75 this.urlFieldTarget.value = this.urlDisplayTarget.value; 76 } 77 } 78 79 // ââ Mode switching ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 80 81 switchToUpload() { this._setMode('upload'); } 82 switchToUrl() { this._setMode('url'); } 83 switchToBook() { this._setMode('book'); } 84 85 _setMode(mode) { 86 this._currentMode = mode; 87 88 const modes = ['upload', 'url', 'book']; 89 for (const m of modes) { 90 const tab = this[`hasTab${this._cap(m)}Target`] ? this[`tab${this._cap(m)}Target`] : null; 91 const panel = this[`hasPanel${this._cap(m)}Target`] ? this[`panel${this._cap(m)}Target`] : null; 92 const active = m === mode; 93 tab?.classList.toggle('btn-outline', active); 94 tab?.classList.toggle('btn-ghost', !active); 95 panel?.classList.toggle('hidden', !active); 96 } 97 98 // When entering URL mode, seed the display input from the hidden field 99 if (mode === 'url' && this.hasUrlDisplayTarget && this.hasUrlFieldTarget) { 100 if (!this.urlDisplayTarget.value) { 101 this.urlDisplayTarget.value = this.urlFieldTarget.value; 102 } 103 } 104 } 105 106 _showUploadSuccess(filename) { 107 if (this.hasDropZoneContentTarget) this.dropZoneContentTarget.classList.add('hidden'); 108 if (this.hasUploadProgressTarget) this.uploadProgressTarget.classList.add('hidden'); 109 if (this.hasUploadSuccessTarget) this.uploadSuccessTarget.classList.remove('hidden'); 110 if (this.hasUploadFilenameTarget) this.uploadFilenameTarget.textContent = filename; 111 } 112 113 _cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); } 114 115 // ââ File upload âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 116 117 triggerFileInput() { 118 this.fileInputTarget.click(); 119 } 120 121 onDragOver(event) { 122 event.preventDefault(); 123 this.dropZoneTarget.classList.add('border-primary'); 124 } 125 126 onDrop(event) { 127 event.preventDefault(); 128 this.dropZoneTarget.classList.remove('border-primary'); 129 const file = event.dataTransfer?.files?.[0]; 130 if (file) this._uploadFile(file); 131 } 132 133 onFileSelected() { 134 const file = this.fileInputTarget.files[0]; 135 if (file) this._uploadFile(file); 136 } 137 138 async _uploadFile(file) { 139 this.dropZoneContentTarget.classList.add('hidden'); 140 this.uploadSuccessTarget.classList.add('hidden'); 141 this.uploadProgressTarget.classList.remove('hidden'); 142 143 const fd = new FormData(); 144 fd.append('file', file); 145 fd.append('_token', this.uploadCsrfValue); 146 fd.append('source', 'newsletter_bonus'); 147 148 try { 149 const res = await fetch(this.uploadUrlValue, { method: 'POST', body: fd }); 150 const data = await res.json(); 151 152 if (!res.ok || !data.cdnUrl) { 153 throw new Error(data.error || 'Upload failed.'); 154 } 155 156 // Populate url field 157 if (this.hasUrlFieldTarget) this.urlFieldTarget.value = data.cdnUrl; 158 159 // Auto-set type from mime 160 const mime = file.type; 161 const inferredType = MIME_TYPE_MAP[mime] ?? 'download'; 162 this._setTypeField(inferredType); 163 164 // Auto-thumbnail for image uploads 165 if (mime.startsWith('image/') && this.hasThumbFieldTarget && !this.thumbFieldTarget.value) { 166 this._showThumb(data.cdnUrl); 167 this.thumbFieldTarget.value = data.cdnUrl; 168 } 169 170 this._showUploadSuccess(file.name); 171 172 } catch (e) { 173 this.uploadProgressTarget.classList.add('hidden'); 174 this.dropZoneContentTarget.classList.remove('hidden'); 175 window.showToast?.(e.message || 'Upload failed.', 'error'); 176 } 177 } 178 179 // ââ EvolveWriter book picker ââââââââââââââââââââââââââââââââââââââââââââââ 180 181 async _loadBooks() { 182 try { 183 const res = await fetch(this.bookSearchUrlValue); 184 const data = await res.json();
185 this._allBooks = Array.isArray(data) ? data : []; 186 this._renderBooks(this._allBooks); 187 } catch { 188 // fail silently; user can still paste URL 189 } 190 } 191 192 filterBooks() { 193 const q = this.bookSearchTarget.value.toLowerCase(); 194 const filtered = q 195 ? this._allBooks.filter(b => b.title.toLowerCase().includes(q)) 196 : this._allBooks; 197 this._renderBooks(filtered); 198 } 199 200 _renderBooks(books) { 201 if (!this.hasBookListTarget) return; 202 203 if (books.length === 0) { 204 this.bookListTarget.innerHTML = '<p class="text-sm opacity-50 text-center py-4">No books found.</p>'; 205 return; 206 } 207 208 this.bookListTarget.innerHTML = books.map(b => ` 209 <button type="button" 210 class="w-full flex items-center gap-3 p-3 rounded-lg border border-base-300 hover:border-primary/50 hover:bg-base-200 transition-colors text-left" 211 data-book-id="${this._esc(b.id)}" 212 data-book-title="${this._esc(b.title)}" 213 data-book-cover="${this._esc(b.coverImage || '')}" 214 data-book-url="${this._esc(b.readUrl)}" 215 data-action="click->newsletter-bonus-form#selectBook"> 216 ${b.coverImage 217 ? `<img src="${this._esc(b.coverImage)}" alt="" class="w-10 h-14 object-cover rounded shrink-0">` 218 : `<div class="w-10 h-14 bg-base-300 rounded shrink-0 flex items-center justify-center"><span class="opacity-30 text-xs">ð</span></div>` 219 } 220 <span class="font-medium text-sm">${this._esc(b.title)}</span> 221 </button> 222 `).join(''); 223 } 224 225 selectBook(event) { 226 const btn = event.currentTarget; 227 const url = btn.dataset.bookUrl; 228 const cover = btn.dataset.bookCover; 229 230 if (this.hasUrlFieldTarget) this.urlFieldTarget.value = url; 231 this._setTypeField('link'); 232 233 if (cover && this.hasThumbFieldTarget) { 234 this._showThumb(cover); 235 this.thumbFieldTarget.value = cover; 236 } 237 238 // Highlight selection 239 this.bookListTarget.querySelectorAll('button').forEach(b => b.classList.remove('border-primary', 'bg-primary/5')); 240 btn.classList.add('border-primary', 'bg-primary/5'); 241 242 window.showToast?.(`Book linked: ${btn.dataset.bookTitle}`, 'success'); 243 } 244 245 // ââ Thumbnail âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 246 247 triggerThumbInput() { 248 this.thumbInputTarget.click(); 249 } 250 251 async onThumbSelected() { 252 const file = this.thumbInputTarget.files[0]; 253 if (!file) return; 254 255 if (!file.type.startsWith('image/')) { 256 window.showToast?.('Please select an image file.', 'error'); 257 return; 258 } 259 260 const fd = new FormData(); 261 fd.append('file', file); 262 fd.append('_token', this.uploadCsrfValue); 263 fd.append('source', 'newsletter_bonus_thumb'); 264 265 try { 266 const res = await fetch(this.uploadUrlValue, { method: 'POST', body: fd }); 267 const data = await res.json(); 268 269 if (!res.ok || !data.cdnUrl) throw new Error(data.error || 'Upload failed.'); 270 271 this._showThumb(data.cdnUrl); 272 if (this.hasThumbFieldTarget) this.thumbFieldTarget.value = data.cdnUrl; 273 274 } catch (e) { 275 window.showToast?.(e.message || 'Thumbnail upload failed.', 'error'); 276 } 277 } 278 279 removeThumb() { 280 if (this.hasThumbFieldTarget) this.thumbFieldTarget.value = ''; 281 this._clearThumb(); 282 } 283 284 _showThumb(url) { 285 if (this.hasThumbPreviewImgTarget) this.thumbPreviewImgTarget.src = url; 286 if (this.hasThumbPreviewTarget) this.thumbPreviewTarget.classList.remove('hidden'); 287 if (this.hasThumbEmptyTarget) this.thumbEmptyTarget.classList.add('hidden'); 288 if (this.hasRemoveThumbBtnTarget) this.removeThumbBtnTarget.classList.remove('hidden'); 289 } 290 291 _clearThumb() { 292 if (this.hasThumbPreviewTarget) this.thumbPreviewTarget.classList.add('hidden'); 293 if (this.hasThumbEmptyTarget) this.thumbEmptyTarget.classList.remove('hidden'); 294 if (this.hasRemoveThumbBtnTarget) this.removeThumbBtnTarget.classList.add('hidden'); 295 if (this.hasThumbInputTarget) this.thumbInputTarget.value = ''; 296 } 297 298 // ââ AI thumbnail ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 299 300 async generateAiThumb() { 301 const title = this.hasTitleInputTarget ? this.titleInputTarget.value.trim() : ''; 302 if (!title) { 303 window.showToast?.('Enter a title first so the AI knows what to create.', 'error'); 304 return; 305 } 306 307 const btn = this.hasAiBtnTarget ? this.aiBtnTarget : null; 308 const origHtml = btn?.innerHTML ?? ''; 309 if (btn) { 310 btn.disabled = true; 311 btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Generatingâ¦'; 312 } 313 314 const description = this.hasDescFieldTarget ? this.descFieldTarget.value.replace(/<[^>]*>/g, ' ').trim() : ''; 315 316 const fd = new FormData(); 317 fd.append('_token', this.aiCsrfValue); 318 fd.append('title', title); 319 fd.append('description', description); 320 321 try { 322 const res = await fetch(this.aiThumbUrlValue, { method: 'POST', body: fd }); 323 const data = await res.json(); 324 325 if (!res.ok || !data.url) throw new Error(data.error || 'Generation failed.'); 326 327 this._showThumb(data.url); 328 if (this.hasThumbFieldTarget) this.thumbFieldTarget.value = data.url; 329 window.showToast?.('AI thumbnail generated.', 'success'); 330 331 } catch (e) { 332 window.showToast?.(e.message || 'Thumbnail generation failed.', 'error'); 333 } finally { 334 if (btn) { 335 btn.disabled = false;
336 btn.innerHTML = origHtml; 337 } 338 } 339 } 340 341 // ââ Helpers âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 342 343 _setTypeField(type) { 344 if (!this.hasTypeFieldTarget) return; 345 const select = this.typeFieldTarget; 346 const opt = Array.from(select.options).find(o => o.value === type); 347 if (opt) select.value = type; 348 } 349 350 _esc(str) { 351 return String(str ?? '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); 352 } 353}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.