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 & 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, '"'); 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.