PageSourceSearch

https://healthylifenetwork.com.au/assets/controllers/newsletter-bonus-form_controller-zTWsyxb.js

js healthylifenetwork.com.au collected 2026-09-25 03:33:31 UTC 13,940 bytes, 353 lines download raw bytes

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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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.