PageSourceSearch

https://ifixindia.in/spa/pages/admin-blog-edit.js?v=20260724editor1

js ifixindia.in collected 2026-10-08 18:55:24 UTC 46,066 bytes, 970 lines download raw bytes

1/**
2 * Admin blog create / edit pages
3 * /admin/blogs/new/
4 * /admin/blogs/edit/:id/
5 */
6App.registerPage('admin-blog-edit', {
7    title: 'Blog Editor | iFix India Admin',
8    description: 'Create or edit iFix India blog posts',
9
10    TOKEN: 'ifix-admin-2026-secure',
11    CONTENT_TOKEN: 'ifix-content-2026-secure',
12    PREVIEW_STORAGE_KEY: 'ifix_blog_preview_v1',
13    LOGIN_PASSWORD_ROLES: {
14        'ifix2026': 'full',
15        'ifix-admin-2026-secure': 'full',
16        'ifixblog2026': 'content'
17    },
18
19    getAuthToken() {
20        return sessionStorage.getItem('ifix_admin_token') || '';
21    },
22
23    canAccessEditorToken(token) {
24        return token === this.CONTENT_TOKEN;
25    },
26
27    isAuthed() {
28        return this.canAccessEditorToken(this.getAuthToken());
29    },
30
31    resolveLoginToken(password) {
32        const role = this.LOGIN_PASSWORD_ROLES[password];
33        if (role === 'content') return this.CONTENT_TOKEN;
34        return null;
35    },
36
37    mode: 'new',
38    blogId: null,
39    slugManual: false,
40    slugCheckTimer: null,
41    categories: [],
42    selectedCategoryIds: [],
43    quill: null,
44    quillSourceMode: false,
45
46    render() {
47        return `
48            <link rel="stylesheet" href="/spa/admin.css?v=20260724editor1">
49            <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/quill.snow.css">
50            <div class="admin-wrapper blog-edit-page">
51                <div id="blog-edit-login" class="admin-login-container" style="display:none;">
52                    <div class="admin-login-card">
53                        <h2>Admin Access</h2>
54                        <p class="admin-login-lead">Sign in to manage blog posts.</p>
55                        <form id="blog-edit-login-form">
56                            <div class="form-group">
57                                <input type="password" id="blog-edit-login-password" class="admin-input" placeholder="Admin password" autofocus>
58                            </div>
59                            <button type="submit" class="btn btn-primary btn-block">Continue</button>
60                            <p id="blog-edit-login-error" class="admin-error" style="display:none;">Invalid password</p>
61                        </form>
62                    </div>
63                </div>
64                <div id="blog-edit-shell" style="display:none;">
65                    <div class="blog-edit-topbar">
66                        <div>
67                            <a href="/admin/" class="blog-edit-back" data-admin-blogs>« Back to Manage Blog</a>
68                            <h1 id="blog-edit-heading">New Blog Post</h1>
69                            <p class="blog-edit-sub">Slug auto-generates from the title and stays editable. Duplicates get a unique slug automatically.</p>
70                        </div>
71                        <div class="blog-edit-top-actions">
72                            <button type="button" class="btn btn-outline" id="blog-edit-preview-btn">
73                                <i class="fas fa-eye"></i> Preview
74                            </button>
75                            <a href="/admin/" class="btn btn-outline" data-admin-blogs>Cancel</a>
76                            <button type="submit" form="blog-edit-form" class="btn btn-primary" id="blog-edit-save-btn">
77                                <i class="fas fa-save"></i> Save Post
78                            </button>
79                        </div>
80                    </div>
81                    <form id="blog-edit-form" class="blog-edit-form-page">
82                        <input type="hidden" id="blog-edit-id" value="">
83                        <div class="blog-editor-grid">
84                            <div class="form-group blog-editor-full">
85                                <label for="blog-edit-title">Title *</label>
86                                <input type="text" id="blog-edit-title" class="admin-input" required placeholder="iPhone 18: Everything We Know...">
87                            </div>
88                            <div class="form-group blog-editor-full">
89                                <label for="blog-edit-slug">Slug * <span class="blog-slug-hint">(auto from title, editable)</span></label>
90                                <div class="blog-slug-row">
91                                    <span class="blog-slug-prefix">/blog/</span>
92                                    <input type="text" id="blog-edit-slug" class="admin-input" placeholder="iphone-18" required>
93                                    <button type="button" id="blog-slug-regen" class="btn btn-outline" title="Regenerate from title">From title</button>
94                                </div>
95                                <p id="blog-slug-status" class="blog-slug-status" aria-live="polite"></p>
96                            </div>
97                            <div class="form-group">
98                                <label for="blog-edit-author">Author</label>
99                                <input type="text" id="blog-edit-author" class="admin-input" value="Imran">
100                            </div>
101                            <div class="form-group blog-editor-full">
102                                <label>Categories <span class="blog-slug-hint">(optional, multi-select)</span></label>
103                                <div id="blog-category-select" class="ms-select" data-placeholder="Search & select categories...">
104                                    <button type="button" class="ms-select-trigger admin-input" id="blog-category-trigger" aria-haspopup="listbox" aria-expanded="false">
105                                        <span class="ms-select-label">No category selected</span>
106                                        <i class="fas fa-chevron-down"></i>
107                                    </button>
108                                    <div class="ms-select-panel" id="blog-category-panel" hidden>
109                                        <input type="search" class="admin-input ms-select-search" id="blog-category-search" placeholder="Search categories..." autocomplete="off">
110                                        <div class="ms-select-options" id="blog-category-options"></div>
111                                        <div class="ms-select-empty" id="blog-category-empty" hidden>No categories found</div>
112                                    </div>
113                                </div>
114                                <p class="blog-slug-hint" style="margin-top:8px;">Leave empty if this post has no category. Create categories under Manage Category.</p>
115                            </div>
116                            <div class="form-group">
117                                <label for="blog-edit-status">Status</label>
118                                <select id="blog-edit-status" class="admin-input">
119                                    <option value="published">Published</option>
120                                    <option value="draft">Draft</option>
121                                </select>
122                            </div>
123                            <div class="form-group">
124                                <label for="blog-edit-published">Published date</label>
125                                <input type="date" id="blog-edit-published" class="admin-input">
126                            </div>
127                            <div class="form-group blog-editor-full">
128                                <label>Cover image upload <span class="blog-slug-hint">(optional — or paste a URL below)</span></label>
129                                <div id="blog-cover-dropzone" class="blog-cover-dropzone" tabindex="0" role="button" aria-label="Upload cover image (optional)">
130                                    <input type="file" id="blog-cover-file" accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,image/avif,.jpg,.jpeg,.png,.gif,.webp,.svg,.avif,.heic,.heif" hidden>
131                                    <div class="blog-cover-dropzone-inner" id="blog-cover-dropzone-inner">
132                                        <i class="fas fa-cloud-upload-alt" aria-hidden="true"></i>
133                                        <p><strong>Drag &amp; drop</strong> an image here, or <button type="button" class="blog-cover-browse" id="blog-cover-browse">browse</button></p>
134                                        <span class="blog-cover-hint">Optional · JPG, PNG, WebP, GIF, SVG · max 25MB · URL auto-fills after upload</span>
135                                    </div>
136                                    <div class="blog-cover-preview" id="blog-cover-preview" hidden>
137                                        <img id="blog-cover-preview-img" alt="Cover preview">
138                                        <div class="blog-cover-preview-actions">
139                                            <button type="button" class="btn btn-outline" id="blog-cover-replace">Replace</button>
140                                            <button type="button" class="btn btn-outline" id="blog-cover-clear">Remove</button>
141                                        </div>
142                                    </div>
143                                    <div class="blog-cover-progress" id="blog-cover-progress" hidden>
144                                        <div class="blog-cover-progress-bar"><span id="blog-cover-progress-fill"></span></div>
145                                        <p id="blog-cover-progress-text">Uploading…</p>
146                                    </div>
147                                </div>
148                                <p id="blog-cover-status" class="blog-cover-status" aria-l
148ive="polite"></p>
149                            </div>
150                            <div class="form-group blog-editor-full">
151                                <label for="blog-edit-cover">Cover image URL <span class="blog-slug-hint">(optional — auto-filled after upload)</span></label>
152                                <input type="text" id="blog-edit-cover" class="admin-input" inputmode="url" autocomplete="off" placeholder="/api/uploads/... or https://...">
153                                <p class="blog-slug-hint" style="margin-top:8px;">Provide either an upload or a URL (at least one is required to save).</p>
154                            </div>
155                            <div class="form-group blog-editor-full">
156                                <label for="blog-edit-excerpt">Excerpt</label>
157                                <textarea id="blog-edit-excerpt" class="admin-textarea" rows="3"></textarea>
158                            </div>
159                            <div class="form-group blog-editor-full">
160                                <label for="blog-edit-tags">Tags (comma separated)</label>
161                                <input type="text" id="blog-edit-tags" class="admin-input" placeholder="iPhone, Repair, Bangalore">
162                            </div>
163                            <div class="form-group blog-editor-full">
164                                <label for="blog-edit-seo-title">SEO title</label>
165                                <input type="text" id="blog-edit-seo-title" class="admin-input">
166                            </div>
167                            <div class="form-group blog-editor-full">
168                                <label for="blog-edit-seo-desc">SEO description</label>
169                                <textarea id="blog-edit-seo-desc" class="admin-textarea" rows="2"></textarea>
170                            </div>
171                            <div class="form-group blog-editor-full">
172                                <label for="blog-content-quill">Content</label>
173                                <div class="blog-quill-wrap">
174                                    <div class="blog-quill-toolbar-extra">
175                                        <button type="button" class="btn btn-outline blog-quill-toggle-source" id="blog-quill-toggle-source">
176                                            <i class="fas fa-code"></i> HTML source
177                                        </button>
178                                    </div>
179                                    <div id="blog-content-quill" class="blog-content-quill"></div>
180                                    <textarea id="blog-edit-content" class="admin-textarea blog-content-source" rows="18" hidden placeholder="<p>Write your article...</p>"></textarea>
181                                </div>
182                                <p class="blog-slug-hint" style="margin-top:8px;">Use the toolbar for formatting, or switch to HTML source to edit raw markup.</p>
183                            </div>
184                        </div>
185                        <p id="blog-edit-error" class="admin-error" style="display:none;"></p>
186                        <div class="blog-edit-footer-actions">
187                            <button type="button" class="btn btn-outline" id="blog-edit-preview-btn-footer">
188                                <i class="fas fa-eye"></i> Preview
189                            </button>
190                            <a href="/admin/" class="btn btn-outline" data-admin-blogs>Cancel</a>
191                            <button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> Save Post</button>
192                        </div>
193                    </form>
194                </div>
195            </div>
196        `;
197    },
198
199    init() {
200        const path = window.location.pathname.replace(/\/+$/, '') + '/';
201        const editMatch = path.match(/^\/admin\/blogs\/edit\/([^/]+)\/$/);
202        if (editMatch) {
203            this.mode = 'edit';
204            this.blogId = decodeURIComponent(editMatch[1]);
205        } else {
206            this.mode = 'new';
207            this.blogId = null;
208        }
209
210        const currentToken = this.getAuthToken();
211        const authed = this.isAuthed();
212        const hasDisallowedSession = currentToken === this.TOKEN;
213
214        document.getElementById('blog-edit-login').style.display = authed ? 'none' : 'flex';
215        document.getElementById('blog-edit-shell').style.display = authed ? 'block' : 'none';
216        if (hasDisallowedSession) {
217            const error = document.getElementById('blog-edit-login-error');
218            if (error) {
219                error.textContent = 'Only the ifixblog2026 login can access blog management.';
220                error.style.display = 'block';
221            }
222        }
223
224        document.getElementById('blog-edit-login-form')?.addEventListener('submit', (e) => {
225            e.preventDefault();
226            const pwd = document.getElementById('blog-edit-login-password').value;
227            const token = this.resolveLoginToken(pwd);
228            if (token) {
229                sessionStorage.setItem('ifix_admin_token', token);
230                document.getElementById('blog-edit-login-error').style.display = 'none';
231                document.getElementById('blog-edit-login').style.display = 'none';
232                document.getElementById('blog-edit-shell').style.display = 'block';
233                this.setupEditor();
234            } else {
235                document.getElementById('blog-edit-login-error').textContent = 'Only the ifixblog2026 login can access blog management.';
236                document.getElementById('blog-edit-login-error').style.display = 'block';
237            }
238        });
239
240        document.querySelectorAll('[data-admin-blogs]').forEach((el) => {
241            el.addEventListener('click', (e) => {
242                e.preventDefault();
243                sessionStorage.setItem('ifix_admin_tab', 'blogs');
244                App.navigate('/admin/');
245            });
246        });
247
248        if (authed) this.setupEditor();
249    },
250
251    async setupEditor() {
252        const heading = document.getElementById('blog-edit-heading');
253        if (heading) heading.textContent = this.mode === 'edit' ? 'Edit Blog Post' : 'New Blog Post';
254
255        document.getElementById('blog-edit-published').value = new Date().toISOString().slice(0, 10);
256        document.getElementById('blog-edit-author').value = 'Imran';
257        this.slugManual = false;
258        this.selectedCategoryIds = [];
259
260        await this.loadCategories();
261        this.bindCategorySelect();
262
263        document.getElementById('blog-edit-title')?.addEventListener('input', (e) => {
264            if (!this.slugManual) {
265                document.getElementById('blog-edit-slug').value = this.slugify(e.target.value);
266                this.scheduleSlugCheck();
267            }
268        });
269
270        document.getElementById('blog-edit-slug')?.addEventListener('input', () => {
271            this.slugManual = true;
272            this.scheduleSlugCheck();
273        });
274
275        document.getElementById('blog-slug-regen')?.addEventListener('click', () => {
276            const title = document.getElementById('blog-edit-title').value;
277            document.getElementById('blog-edit-slug').value = this.slugify(title);
278            this.slugManual = false;
279            this.scheduleSlugCheck();
280        });
281
282        document.getElementById('blog-edit-form')?.addEventListener('submit', (e) => {
283            e.preventDefault();
284            this.save();
285        });
286
287        document.getElementById('blog-edit-preview-btn')?.addEventListener('click', () => this.openPreview());
288        document.getElementById('blog-edit-preview-btn-footer')?.addEventListener('click', () => this.openPreview());
289
290        this.bindCoverUpload();
291        await this.initContentEditor();
292
293        document.getElementById('blog-slug-status')?.addEventListener('click', (e) => {
294            const btn = e.target.closest('[data-use-suggestion]');
295            if (!btn) return;
296            document.getElementById('blog-edit-slug').value = btn.dataset.useSuggestion;
297            this.slugManual = true;
298            this.scheduleSlugCheck();
299        });
300
301        if (this.mode === 'edit' && this.blogId) {
302            this.loadBlog(this.blogId);
303        } else {
304            this.renderCategoryOptions();
305            this.setContentHtml('<p><br></p>');
306        }
307    },
308
309    loadScript(src) {
310        return new Promise((resolve, reject) => {
311            const existing = document.querySelector(`script[src="${src}"]`);
312            if (existing) {
313                if (window.Quill) return resolve();
314                existing.addEventListener('load', () => resolve());
315                existing.addEventListener('error', () => reject(new Error('Failed to load editor')));
316                return;
317            }
318            const script = document.createElement('script');
319            script.src = src;
320            script.async = true;
321            script.onload = () => resolve();
322            script.onerror = () => reject(new Error('Failed to load editor'));
323            document.head.appendChild(script);
324        });
325    },
326
327    async ensureQuillLibrary() {
328        if (window.Quill) return;
329        await this.loadScript('https://cdn.jsdelivr.net/npm/[email protected]/dist/quill.min.js');
330        if (!window.Quill) throw new Error('Quill failed to initialize');
331    },
332
333    async initContentEditor() {
334        const host = document.getElementById('blog-content-quill');
335        const textarea = document.getElementById('blog-edit-content');
336        if (!host || !textarea) return;
337
338        try {
339            await this.ensureQuillLibrary();
340        } catch (err) {
341            console.error(err);
342            // Fallback: plain HTML textarea
343            host.hidden = true;
344            textarea.hidden = false;
345            textarea.classList.add('blog-content-editor');
346            document.getElementById('blog-quill-toggle-source')?.setAttribute('hidden', '');
347            return;
348        }
349
350        if (this.quill) {
351            try { this.quill = null; } catch (_e) { /* ignore */ }
352        }
353
354        host.innerHTML = '';
355        this.quill = new window.Quill(host, {
356            theme: 'snow',
357            placeholder: 'Write your article…',
358            modules: {
359                toolbar: {
360                    container: [
361                        [{ header: [1, 2, 3, false] }],
362                        ['bold', 'italic', 'underline', 'strike'],
363                        [{ list: 'ordered' }, { list: 'bullet' }],
364                        [{ indent: '-1' }, { indent: '+1' }],
365                        ['blockquote', 'code-block'],
366                        ['link', 'image'],
367                        [{ align: [] }],
368                        ['clean']
369                    ],
370                    handlers: {
371                        image: () => this.insertContentImage()
372                    }
373                }
374            }
375        });
376
377        this.quillSourceMode = false;
378        document.getElementById('blog-quill-toggle-source')?.addEventListener('click', () => {
379            this.toggleContentSourceMode();
380        });
381
382        this.quill.on('text-change', () => {
383            if (!this.quillSourceMode) {
384                textarea.value = this.getContentHtml();
385            }
386        });
387    },
388
389    insertContentImage() {
390        if (!this.quill) return;
391        const url = window.prompt('Image URL (/api/uploads/... or https://...)');
392        if (!url || !String(url).trim()) return;
393        const range = this.quill.getSelection(true);
394        const index = range ? range.index : this.quill.getLength();
395        this.quill.insertEmbed(index, 'image', String(url).trim(), 'user');
396        this.quill.setSelection(index + 1, 0);
397    },
398
399    getContentHtml() {
400        const textarea = document.getElementById('blog-edit-content');
401        if (this.quillSourceMode && textarea) {
402            return textarea.value || '';
403        }
404        if (this.quill) {
405            const html = this.quill.root.innerHTML || '';
406            // Quill empty state
407            if (html === '<p><br></p>' || html === '<p><br/></p>') return '';
408            return html;
409        }
410        return textarea ? textarea.value : '';
411    },
412
413    setContentHtml(html) {
414        const value = String(html || '');
415        const textarea = document.getElementById('blog-edit-content');
416        if (textarea) textarea.value = value;
417
418        if (this.quillSourceMode) {
419            if (textarea) {
420                textarea.hidden = false;
421                document.getElementById('blog-content-quill')?.closest('.blog-quill-wrap')
422                    ?.querySelector('.ql-toolbar')?.setAttribute('hidden', '');
423            }
424            return;
425        }
426
427        if (this.quill) {
428            const content = value.trim() ? value : '<p><br></p>';
429            this.quill.setContents([]);
430            this.quill.clipboard.dangerouslyPasteHTML(0, content);
431            textarea && (textarea.value = this.getContentHtml());
432        }
433    },
434
435    toggleContentSourceMode() {
436        const textarea = document.getElementById('blog-edit-content');
437        const quillHost = document.getElementById('blog-content-quill');
438        const toolbar = document.querySelector('.blog-quill-wrap .ql-toolbar');
439        const btn = document.getElementById('blog-quill-toggle-source');
440        if (!textarea || !quillHost) return;
441
442        if (!this.quillSourceMode) {
443            // Visual → HTML
444            textarea.value = this.getContentHtml();
445            this.quillSourceMode = true;
446            quillHost.hidden = true;
447            if (toolbar) toolbar.hidden = true;
448            textarea.hidden = false;
449            if (btn) btn.innerHTML = '<i class="fas fa-eye"></i> Visual editor';
450        } else {
451            // HTML → Visual
452            const html = textarea.value || '';
453            this.quillSourceMode = false;
454            textarea.hidden = true;
455            quillHost.hidden = false;
456            if (toolbar) toolbar.hidden = false;
457            if (this.quill) {
458                this.quill.setContents([]);
459                this.quill.clipboard.dangerouslyPasteHTML(0, html.trim() ? html : '<p><br></p>');
460            }
461            if (btn) btn.innerHTML = '<i class="fas fa-code"></i> HTML source';
462        }
463    },
464
465    async loadCategories() {
466        try {
467            const res = await fetch('/api/categories', { cache: 'no-store' });
468            if (!res.ok) throw new Error('Failed to load categories');
469            const data = await res.json();
470            this.categories = Array.isArray(data.items) ? data.items : [];
471        } catch (err) {
472            console.error(err);
473            this.categories = [];
474        }
475    },
476
477    bindCategorySelect() {
478        const trigger = document.getElementById('blog-category-trigger');
479        const panel = document.getElementById('blog-category-panel');
480        const search = document.getElementById('blog-category-search');
481        if (!trigger || !panel) return;
482
483        trigger.addEventListener('click', () => {
484            const open = !panel.hidden;
485            panel.hidden = open;
486            trigger.setAttribute('aria-expanded', String(!open));
487            if (!open) {
488                search.value = '';
489                this.renderCategoryOptions();
490                setTimeout(() => search.focus(), 0);
491            }
492        });
493
494        search?.addEventListener('input', () => this.renderCategoryOptions(search.value));
495
496        document.addEventListener('click', (e) => {
497            const wrap = document.getElementById('blog-category-select');
498            if (wrap && !wrap.contains(e.target)) {
499                panel.hidden = true;
500                trigger.setAttribute('aria-expanded', 'false');
501            }
502        });
503
504        document.getElementById('blog-category-options')?.addEventListener('change', (e) => {
505            const cb = e.target.closest('input[type="checkbox"][data-category-id]');
506            if (!cb) return;
507            const id = cb.dataset.categoryId;
508            if (cb.checked) {
509                if (!this.selectedCategoryIds.includes(id)) this.selectedCategoryIds.push(id);
510            } else {
511                this.selectedCategoryIds = this.selectedCategoryIds.filter((x) => x !== id);
512            }
513            this.updateCategoryTriggerLabel();
514        });
515    },
516
517    renderCategoryOptions(query = '') {
518        const options = document.getElementById('blog-category-options');
519        const empty = document.getElementById('blog-category-empty');
520        if (!options) return;
521        const q = String(query || '').trim().toLowerCase();
522        const filtered = this.categories.filter((c) => !q || c.name.toLowerCase().includes(q) || c.slug.toLowerCase().includes(q));
523        if (!filtered.length) {
524            options.innerHTML = '';
525            if (empty) empty.hidden = false;
526            this.updateCategoryTriggerLabel();
527            return;
528        }
529        if (empty) empty.hidden = true;
530        options.innerHTML = filtered.map((c) => {
531            const checked = this.selectedCategoryIds.includes(c.id) ? 'checked' : '';
532            return `
533                <label class="ms-option">
534                    <input type="checkbox" data-category-id="${this.escapeAttr(c.id)}" ${checked}>
535                    <span>${this.escapeHtml(c.name)}</span>
536                    <em>${Number(c.blogCount || 0)} blogs</em>
537                </label>`;
538        }).join('');
539        this.updateCategoryTriggerLabel();
540    },
541
542    updateCategoryTriggerLabel() {
543        const label = document.querySelector('#blog-category-trigger .ms-select-label');
544        if (!label) return;
545        const selected = this.categories.filter((c) => this.selectedCategoryIds.includes(c.id));
546        if (!selected.length) {
547            label.textContent = 'No category selected';
548            return;
549        }
550        if (selected.length <= 2) {
551            label.textContent = selected.map((c) => c.name).join(', ');
552            return;
553        }
554        label.textContent = `${selected.length} categories selected`;
555    },
556
557    bindCoverUpload() {
558        const dropzone = document.getElementById('blog-cover-dropzone');
559        const fileInput = document.getElementById('blog-cover-file');
560        const browseBtn = document.getElementById('blog-cover-browse');
561        const replaceBtn = document.getElementById('blog-cover-replace');
562        const clearBtn = document.getElementById('blog-cover-clear');
563        const coverInput = document.getElementById('blog-edit-cover');
564        if (!dropzone || !fileInput) return;
565
566        const openPicker = () => {
567            if (dropzone.classList.contains('is-uploading')) return;
568            fileInput.value = '';
569            fileInput.click();
570        };
571
572        browseBtn?.addEventListener('click', (e) => {
573            e.preventDefault();
574            e.stopPropagation();
575            openPicker();
576        });
577        replaceBtn?.addEventListener('click', (e) => {
578            e.preventDefault();
579            openPicker();
580        });
581        clearBtn?.addEventListener('click', (e) => {
582            e.preventDefault();
583            if (coverInput) coverInput.value = '';
584            this.setCoverPreview('');
585            this.setCoverStatus('');
586        });
587
588        dropzone.addEventListener('click', (e) => {
589            if (e.target.closest('.blog-cover-preview-actions') || e.target.closest('.blog-cover-browse')) return;
590            if (!document.getElementById('blog-cover-preview')?.hidden) return;
591            openPicker();
592        });
593
594        dropzone.addEventListener('keydown', (e) => {
595            if (e.key === 'Enter' || e.key === ' ') {
596                e.preventDefault();
597                openPicker();
598            }
599        });
600
601        ['dragenter', 'dragover'].forEach((evt) => {
602            dropzone.addEventListener(evt, (e) => {
603                e.preventDefault();
604                e.stopPropagation();
605                dropzone.classList.add('is-dragover');
606            });
607        });
608        ['dragleave', 'drop'].forEach((evt) => {
609            dropzone.addEventListener(evt, (e) => {
610                e.preventDefault();
611                e.stopPropagation();
612                dropzone.classList.remove('is-dragover');
613            });
614        });
615        dropzone.addEventListener('drop', (e) => {
616            const file = e.dataTransfer?.files?.[0];
617            if (file) this.uploadCoverFile(file);
618        });
619
620        fileInput.addEventListener('change', () => {
621            const file = fileInput.files?.[0];
622            if (file) this.uploadCoverFile(file);
623        });
624
625        coverInput?.addEventListener('input', () => {
626            this.setCoverPreview(coverInput.value.trim());
627        });
628    },
629
630    setCoverStatus(message, type = '') {
631        const el = document.getElementById('blog-cover-status');
632        if (!el) return;
633        el.textContent = message || '';
634        el.className = 'blog-cover-status' + (type ? ` is-${type}` : '');
635    },
636
637    setCoverProgress(percent, text) {
638        const wrap = document.getElementById('blog-cover-progress');
639        const fill = document.getElementById('blog-cover-progress-fill');
640        const label = document.getElementById('blog-cover-progress-text');
641        if (!wrap) return;
642        if (percent == null) {
643            wrap.hidden = true;
644            return;
645        }
646        wrap.hidden = false;
647        if (fill) fill.style.width = `${Math.max(0, Math.min(100, percent))}%`;
648        if (label) label.textContent = text || `Uploading… ${Math.round(percent)}%`;
649    },
650
651    setCoverPreview(url) {
652        const preview = document.getElementById('blog-cover-preview');
653        const inner = document.getElementById('blog-cover-dropzone-inner');
654        const img = document.getElementById('blog-cover-preview-img');
655        if (!preview || !img) return;
656        if (url) {
657            img.src = url;
658            preview.hidden = false;
659            if (inner) inner.hidden = true;
660        } else {
661            img.removeAttribute('src');
662            preview.hidden = true;
663            if (inner) inner.hidden = false;
664        }
665    },
666
667    async uploadCoverFile(file) {
668        const dropzone = document.getElementById('blog-cover-dropzone');
669        if (!file || !dropzone) return;
670
671        const name = file.name || 'image';
672        const mime = file.type || '';
673        const isImage = /^image\//i.test(mime) || /\.(jpe?g|png|gif|webp|svg|avif|heic|heif)$/i.test(name);
674        if (!isImage) {
675            this.setCoverStatus('Please select an image file.', 'error');
676            return;
677        }
678        if (file.size > 25 * 1024 * 1024) {
679            this.setCoverStatus('File too large. Maximum size is 25MB.', 'error');
680            return;
681        }
682
683        dropzone.classList.add('is-uploading');
684        this.setCoverStatus(`Uploading ${name}…`);
685        this.setCoverProgress(0, 'Starting upload…');
686
687        try {
688            const initRes = await fetch('/api/images/chunk/init', {
689                method: 'POST',
690                headers: {
691                    'Content-Type': 'application/json',
692                    'X-Admin-Token': this.getAuthToken()
693                },
694                body: JSON.stringify({
695                    filename: name,
696                    size: file.size,
697                    mimeType: mime
698                })
699            });
700            const initData = await initRes.json().catch(() => ({}));
701            if (!initRes.ok) throw new Error(initData.error || 'Failed to start upload');
702
703            const { uploadId, chunkSize, totalChunks } = initData;
704            for (let i = 0; i < totalChunks; i++) {
705                const start = i * chunkSize;
706                const end = Math.min(file.size, start + chunkSize);
707                const blob = file.slice(start, end);
708                const form = new FormData();
709                form.append('uploadId', uploadId);
710                form.append('index', String(i));
711                form.append('chunk', blob, `chunk-${i}`);
712
713                const chunkRes = await fetch('/api/images/chunk', {
714                    method: 'POST',
715                    headers: { 'X-Admin-Token': this.getAuthToken() },
716                    body: form
717                });
718                const chunkData = await chunkRes.json().catch(() => ({}));
719                if (!chunkRes.ok) throw new Error(chunkData.error || `Failed to upload chunk ${i + 1}`);
720
721                const pct = ((i + 1) / totalChunks) * 90;
722                this.setCoverProgress(pct, `Uploading… ${i + 1}/${totalChunks}`);
723            }
724
725            this.setCoverProgress(95, 'Assembling image…');
726            const doneRes = await fetch('/api/images/chunk/complete', {
727                method: 'POST',
728                headers: {
729                    'Content-Type': 'application/json',
730                    'X-Admin-Token': this.getAuthToken()
731                },
732                body: JSON.stringify({ uploadId })
733            });
734            const doneData = await doneRes.json().catch(() => ({}));
735            if (!doneRes.ok) throw new Error(doneData.error || 'Failed to complete upload');
736
737            const url = doneData.file?.url;
738            if (!url) throw new Error('Upload succeeded but no URL returned');
739
740            const coverInput = document.getElementById('blog-edit-cover');
741            if (coverInput) coverInput.value = url;
742            this.setCoverPreview(url);
743            this.setCoverProgress(100, 'Upload complete');
744            this.setCoverStatus('Cover uploaded — URL auto-filled below.', 'ok');
745            setTimeout(() => this.setCoverProgress(null), 600);
746        } catch (err) {
747            this.setCoverProgress(null);
748            this.setCoverStatus(err.message || 'Upload failed', 'error');
749        } finally {
750            dropzone.classList.remove('is-uploading');
751        }
752    },
753
754    slugify(input) {
755        return String(input || '')
756            .toLowerCase()
757            .trim()
758            .replace(/['"]/g, '')
759            .replace(/[^a-z0-9]+/g, '-')
760            .replace(/^-+|-+$/g, '')
761            .slice(0, 120);
762    },
763
764    scheduleSlugCheck() {
765        clearTimeout(this.slugCheckTimer);
766        this.slugCheckTimer = setTimeout(() => this.checkSlug(), 280);
767    },
768
769    async checkSlug() {
770        const status = document.getElementById('blog-slug-status');
771        const slug = this.slugify(document.getElementById('blog-edit-slug').value);
772        if (!status) return;
773        if (!slug) {
774            status.className = 'blog-slug-status is-warn';
775            status.textContent = 'Enter a title or slug.';
776            return;
777        }
778
779        status.className = 'blog-slug-status';
780        status.textContent = 'Checking slug…';
781
782        try {
783            const exclude = this.blogId ? `&excludeId=${encodeURIComponent(this.blogId)}` : '';
784            const res = await fetch(`/api/blogs/check-slug?slug=${encodeURIComponent(slug)}${exclude}`, {
785                headers: { 'X-Admin-Token': this.getAuthToken() }
786            });
787            const data = await res.json();
788            if (!res.ok) throw new Error(data.error || 'Check failed');
789
790            if (data.available) {
791                status.className = 'blog-slug-status is-ok';
792                status.innerHTML = `Available — will publish at <code>/blog/${this.escapeHtml(data.slug)}/</code>`;
793            } else {
794                status.className = 'blog-slug-status is-warn';
795                status.innerHTML = `Slug <code>${this.escapeHtml(data.slug)}</code> is taken.
796                    Suggested: <code>${this.escapeHtml(data.suggestion)}</code>
797                    <button type="button" class="blog-slug-use" data-use-suggestion="${this.escapeAttr(data.suggestion)}">Use suggested slug</button>`;
798            }
799        } catch (err) {
800            status.className = 'blog-slug-status is-warn';
801            status.textContent = err.message || 'Could not verify slug';
802        }
803    },
804
805    async loadBlog(id) {
806        try {
807            const res = await fetch(`/api/blogs/${encodeURIComponent(id)}`, {
808                headers: { 'X-Admin-Token': this.getAuthToken() }
809            });
810            if (!res.ok) throw new Error('Blog not found');
811            const blog = await res.json();
812            document.getElementById('blog-edit-id').value = blog.id;
813            document.getElementById('blog-edit-title').value = blog.title || '';
814            document.getElementById('blog-edit-slug').value = blog.slug || '';
815            this.slugManual = true;
816            document.getElementById('blog-edit-category') && (document.getElementById('blog-edit-category').value = '');
817            document.getElementById('blog-edit-author').value = blog.author || 'Imran';
818            document.getElementById('blog-edit-status').value = blog.status || 'published';
819            document.getElementById('blog-edit-published').value = (blog.publishedAt || '').slice(0, 10);
820            document.getElementById('blog-edit-cover').value = blog.coverImage || '';
821            this.setCoverPreview(blog.coverImage || '');
822            document.getElementById('blog-edit-excerpt').value = blog.excerpt || '';
823            document.getElementById('blog-edit-tags').value = (blog.tags || []).join(', ');
824            document.getElementById('blog-edit-seo-title').value = blog.seoTitle || '';
825            document.getElementById('blog-edit-seo-desc').value = blog.seoDescription || '';
826            this.setContentHtml(blog.content || '');
827            this.selectedCategoryIds = Array.isArray(blog.categoryIds) ? blog.categoryIds.slice() : [];
828            this.renderCategoryOptions();
829            this.scheduleSlugCheck();
830        } catch (err) {
831            const error = document.getElementById('blog-edit-error');
832            if (error) {
833                error.style.display = 'block';
834                error.textContent = err.message || 'Failed to load blog';
835            }
836        }
837    },
838
839    async save() {
840        const error = document.getElementById('blog-edit-error');
841        if (error) error.style.display = 'none';
842
843        const id = document.getElementById('blog-edit-id').value.trim() || this.blogId;
844        const published = document.getElementById('blog-edit-published').value;
845        let slug = this.slugify(document.getElementById('blog-edit-slug').value);
846
847        // If slug is taken, auto-apply suggested unique slug before save
848        try {
849            const exclude = id ? `&excludeId=${encodeURIComponent(id)}` : '';
850            const checkRes = await fetch(`/api/blogs/check-slug?slug=${encodeURIComponent(slug)}${exclude}`, {
851                headers: { 'X-Admin-Token': this.getAuthToken() }
852            });
853            const check = await checkRes.json();
854            if (checkRes.ok && !check.available && check.suggestion) {
855                slug = check.suggestion;
856                document.getElementById('blog-edit-slug').value = slug;
857                this.slugManual = true;
858            }
859        } catch (_e) { /* server will unique-ify on save */ }
860
861        const coverImage = document.getElementById('blog-edit-cover').value.trim();
862        if (!coverImage) {
863            if (error) {
864                error.style.display = 'block';
865                error.textContent = 'Add a cover image: upload a file or paste a cover image URL.';
866            }
867            document.getElementById('blog-edit-cover')?.focus();
868            document.getElementById('blog-cover-dropzone')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
869            return;
870        }
871
872        const payload = {
873            title: document.getElementById('blog-edit-title').value.trim(),
874            slug,
875            categoryIds: this.selectedCategoryIds.slice(),
876            author: document.getElementById('blog-edit-author').value.trim(),
877            status: document.getElementById('blog-edit-status').value,
878            publishedAt: published ? new Date(published + 'T10:00:00').toISOString() : undefined,
879            coverImage,
880            excerpt: document.getElementById('blog-edit-excerpt').value.trim(),
881            tags: document.getElementById('blog-edit-tags').value,
882            seoTitle: document.getElementById('blog-edit-seo-title').value.trim(),
883            seoDescription: document.getElementById('blog-edit-seo-desc').value.trim(),
884            content: this.getContentHtml()
885        };
886
887        try {
888            const res = await fetch(id ? `/api/blogs/${encodeURIComponent(id)}` : '/api/blogs', {
889                method: id ? 'PUT' : 'POST',
890                headers: {
891                    'Content-Type': 'application/json',
892                    'X-Admin-Token': this.getAuthToken()
893                },
894                body: JSON.stringify(payload)
895            });
896            const data = await res.json().catch(() => ({}));
897            if (!res.ok) throw new Error(data.error || 'Save failed');
898
899            if (data.slugAutoAdjusted && data.blog?.slug) {
900                alert(`Slug was already taken. Saved with unique slug: ${data.blog.slug}`);
901            }
902
903            sessionStorage.setItem('ifix_admin_tab', 'blogs');
904            App.navigate('/admin/');
905        } catch (err) {
906            if (error) {
907                error.style.display = 'block';
908                error.textContent = err.message || 'Failed to save blog';
909            }
910        }
911    },
912
913    openPreview() {
914        const data = this.collectPreviewData();
915        data.slug = this.slugify(document.getElementById('blog-edit-slug')?.value || '') || 'preview';
916        data.updatedAt = Date.now();
917
918        try {
919            localStorage.setItem(this.PREVIEW_STORAGE_KEY, JSON.stringify(data));
920        } catch (err) {
921            console.error(err);
922            alert('Could not open preview — content may be too large for browser storage.');
923            return;
924        }
925
926        const win = window.open('/admin/blogs/preview/', '_blank');
927        if (!win) {
928            alert('Please allow pop-ups for this site to open the blog preview in a new tab.');
929        }
930    },
931
932    getPreviewCategoryLabel() {
933        const selected = this.categories.filter((c) => this.selectedCategoryIds.includes(c.id));
934        if (selected.length) return selected.map((c) => c.name).join(', ');
935        return 'Blog';
936    },
937
938    collectPreviewData() {
939        const title = document.getElementById('blog-edit-title')?.value.trim() || 'Untitled post';
940        const author = document.getElementById('blog-edit-author')?.value.trim() || 'iFix India';
941        const published = document.getElementById('blog-edit-published')?.value || '';
942        const coverImage = document.getElementById('blog-edit-cover')?.value.trim() || '';
943        const excerpt = document.getElementById('blog-edit-excerpt')?.value.trim() || '';
944        const content = this.getContentHtml();
945        const tags = (document.getElementById('blog-edit-tags')?.value || '')
946            .split(',')
947            .map((t) => t.trim())
948            .filter(Boolean);
949        return {
950            title,
951            author,
952            publishedAt: published ? `${published}T00:00:00.000Z` : '',
953            coverImage,
954            excerpt,
955            content,
956            tags,
957            category: this.getPreviewCategoryLabel()
958        };
959    },
960
961    escapeHtml(text) {
962        const el = document.createElement('div');
963        el.textContent = text == null ? '' : String(text);
964        return el.innerHTML;
965    },
966
967    escapeAttr(text) {
968        return this.escapeHtml(text).replace(/"/g, '&quot;');
969    }
970});

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.