PageSourceSearch

https://andersaamodt.com/static/blog-page.js?v=1488115087

js andersaamodt.com collected 2026-10-08 10:31:30 UTC 194,324 bytes, 5,217 lines download raw bytes

1(function () {
2  'use strict';
3
4  function isPostRoute(pathname) {
5    var path = String(pathname || '');
6    if (/^\/posts\/[^/?#]+\/?$/.test(path)) {
7      return true;
8    }
9    if (/^\/pages\/posts\/[^/?#]+(?:\.html?)?\/?$/.test(path)) {
10      return true;
11    }
12    if (path === '/cgi/blog-open-post' || path.indexOf('/cgi/blog-open-post/') === 0) {
13      return true;
14    }
15    return false;
16  }
17
18  if (isPostRoute(window.location.pathname || '')) {
19    return;
20  }
21
22  var CACHE_KEY = 'wizardry_blog_posts_v2';
23  var POSTS_CACHE_MAX_AGE_MS = 15000;
24  var BOOTSTRAP_CACHE_MAX_AGE_MS = 15000;
25  var DRAFT_NOTICE_CACHE_KEY = 'wizardry_blog_draft_notice_v1';
26  var DRAFT_NOTICE_CACHE_MAX_AGE_MS = 10 * 60 * 1000;
27  var root = document.getElementById('blog-page-root');
28  if (!root) {
29    return;
30  }
31
32  var slug = String(root.getAttribute('data-blog-slug') || 'blog').trim() || 'blog';
33  var pageType = String(root.getAttribute('data-page-type') || 'blog').trim().toLowerCase() || 'blog';
34  var els = {
35    title: document.getElementById('blog-page-title'),
36    description: document.getElementById('blog-page-description'),
37    admin: document.getElementById('blog-page-admin'),
38    validation: document.getElementById('blog-page-validation'),
39    content: document.getElementById('blog-page-content'),
40    toggle: document.getElementById('blog-filter-toggle'),
41    panel: document.getElementById('blog-filter-panel'),
42    tags: document.getElementById('blog-filter-tags'),
43    years: document.getElementById('blog-filter-years'),
44    types: document.getElementById('blog-filter-types'),
45    clear: document.getElementById('blog-clear-filters'),
46    list: document.getElementById('blog-post-list'),
47    empty: document.getElementById('blog-empty'),
48    draftNotice: document.getElementById('blog-draft-notice'),
49    composeFab: document.getElementById('blog-compose-fab'),
50    composeSlot: document.getElementById('blog-compose-slot')
51  };
52
53  var state = {
54    payload: null,
55    authSignature: '',
56    posts: [],
57    postsLoading: true,
58    staticPrerenderListAdopted: false,
59    adoptedPostListSignature: '',
60    renderSignature: '',
61    defaultFiltersApplied: false,
62    pageSettingsOpen: false,
63    filters: {
64      tags: new Set(),
65      years: new Set(),
66      types: new Set()
67    },
68    compose: {
69      open: false,
70      preview: false,
71      draftId: '',
72      sourcePostPath: '',
73      postTypeLocked: false,
74      postTypeChosen: false,
75      tags: [],
76      tagsOpen: false,
77      tagsDraftText: '',
78      postType: 'longform',
79      postTypeToolbarCollapsed: false,
80      postTypeToolbarCollapseTimer: null,
81      publishDestination: 'local_only',
82      shortformLimit: 280,
83      shortformLimitEditing: false,
84      linkUrl: '',
85      cameraStream: null,
86      cameraStarting: false,
87      cameraError: '',
88      cameraFullscreen: false,
89      cameraReturnToChooser: false,
90      audioStream: null,
91      audioRecorder: null,
92      audioChunks: [],
93      audioStarting: false,
94      audioRecording: false,
95      audioError: '',
96      uploading: 0,
97      uploadItems: [],
98      uploadSeq: 0,
99      uploadCleanupTimer: null,
100      pendingContentAdditions: [],
101      autosaveTimer: null,
102      busy: false,
103      output: '',
104      outputTone: '',
105      saveStatus: ''
106    },
107    draftNotice: {
108      loading: false,
109      loaded: false,
110      requestSeq: 0,
111      drafts: [],
112      dismissedSignature: ''
113    }
114  };
115  var panelHideTimer = null;
116  var pageSettingsHideTimer = null;
117  var composeToggleGuardUntil = 0;
118  var composeDropHoverTimer = null;
119  var authRefreshPollState = { timer: 0, startedAt: 0 };
120  var markedUpgradeState = { timer: 0, attempts: 0 };
121  var postsCatalogReady = false;
122  var COMPOSE_POST_TYPES = ['shortform', 'longform', 'capture-media', 'upload-media', 'attachment', 'audio-note', 'link-share', 'go-live'];
123  var routeSelfHealTriggered = false;
124  var postCardMenuBusy = false;
125  var managedSlugFromPath = window.__wizardryManagedSlugFromPath;
126  var ROUTE_REPAIR_PARAM = '__route_repair';
127
128  function normalizeManagedSlug(value) {
129    return String(value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
130  }
131
132  function managedSlugsEquivalent(expected, rootSlug) {
133    var a = normalizeManagedSlug(expected);
134    var b = normalizeManagedSlug(rootSlug);
135    return !!(a && b && (a === b || (a === 'index' && b === 'blog') || (a === 'blog' && b === 'index')));
136  }
137
138  function overflowMenuIconSvg() {
139    return '<svg class="overflow-menu-icon-svg" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewB
139ox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="5.5" r="1.9" fill="currentColor"/><circle cx="12" cy="12" r="1.9" fill="currentColor"/><circle cx="12" cy="18.5" r="1.9" fill="currentColor"/></svg>';
140  }
141
142  function closeIconSvg() {
143    return '<svg class="blog-draft-notice-close-svg" xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5.5 5.5 18.5 18.5M18.5 5.5 5.5 18.5" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
144  }
145
146  function maybeReloadForAuthChange(event) {
147    var nextSig = window.__wizardryViewerAuthSignature();
148    var lastSig = String(state.authSignature || '');
149    if (nextSig === lastSig) {
150      return;
151    }
152    state.authSignature = nextSig;
153    applyOptimisticAdminFromAuthEvent(event);
154    loadPageState({ deferRender: false });
155  }
156
157  function ensureComposeStateShape() {
158    if (!state.compose || typeof state.compose !== 'object') {
159      state.compose = {};
160    }
161    if (typeof state.compose.open !== 'boolean') state.compose.open = false;
162    if (typeof state.compose.preview !== 'boolean') state.compose.preview = false;
163    if (typeof state.compose.draftId !== 'string') state.compose.draftId = '';
164    if (typeof state.compose.sourcePostPath !== 'string') state.compose.sourcePostPath = '';
165    if (typeof state.compose.postTypeLocked !== 'boolean') state.compose.postTypeLocked = false;
166    if (typeof state.compose.postTypeChosen !== 'boolean') state.compose.postTypeChosen = false;
167    if (!Array.isArray(state.compose.tags)) state.compose.tags = [];
168    if (typeof state.compose.tagsOpen !== 'boolean') state.compose.tagsOpen = false;
169    if (typeof state.compose.tagsDraftText !== 'string') state.compose.tagsDraftText = '';
170    if (typeof state.compose.postType !== 'string') state.compose.postType = 'longform';
171    if (typeof state.compose.postTypeToolbarCollapsed !== 'boolean') state.compose.postTypeToolbarCollapsed = false;
172    if (typeof state.compose.postTypeToolbarCollapseTimer === 'undefined') state.compose.postTypeToolbarCollapseTimer = null;
173    if (typeof state.compose.publishDestination !== 'string') state.compose.publishDestination = 'local_only';
174    if (typeof state.compose.shortformLimit !== 'number' || !isFinite(state.compose.shortformLimit)) state.compose.shortformLimit = 280;
175    if (typeof state.compose.shortformLimitEditing !== 'boolean') state.compose.shortformLimitEditing = false;
176    if (typeof state.compose.linkUrl !== 'string') state.compose.linkUrl = '';
177    if (typeof state.compose.cameraStarting !== 'boolean') state.compose.cameraStarting = false;
178    if (typeof state.compose.cameraError !== 'string') state.compose.cameraError = '';
179    if (typeof state.compose.cameraStream === 'undefined') state.compose.cameraStream = null;
180    if (typeof state.compose.cameraFullscreen !== 'boolean') state.compose.cameraFullscreen = false;
181    if (typeof state.compose.cameraReturnToChooser !== 'boolean') state.compose.cameraReturnToChooser = false;
182    if (typeof state.compose.audioStream === 'undefined') state.compose.audioStream = null;
183    if (typeof state.compose.audioRecorder === 'undefined') state.compose.audioRecorder = null;
184    if (!Array.isArray(state.compose.audioChunks)) state.compose.audioChunks = [];
185    if (typeof state.compose.audioStarting !== 'boolean') state.compose.audioStarting = false;
186    if (typeof state.compose.audioRecording !== 'boolean') state.compose.audioRecording = false;
187    if (typeof state.compose.audioError !== 'string') state.compose.audioError = '';
188    if (typeof state.compose.uploading !== 'number' || !isFinite(state.compose.uploading)) state.compose.uploading = 0;
189    if (!Array.isArray(state.compose.uploadItems)) state.compose.uploadItems = [];
190    if (typeof state.compose.uploadSeq !== 'number' || !isFinite(state.compose.uploadSeq)) state.compose.uploadSeq = 0;
191    if (typeof state.compose.uploadCleanupTimer === 'undefined') state.compose.uploadCleanupTimer = null;
192    if (!Array.isArray(state.compose.pendingContentAdditions)) state.compose.pendingContentAdditions = [];
193    if (typeof state.compose.autosaveTimer === 'undefined') state.compose.autosaveTimer = null;
194    if (typeof state.compose.busy !== 'boolean') state.compose.busy = false;
195    if (typeof state.compose.output !== 'string') state.compose.output = '';
196    if (typeof state.compose.outputTone !== 'string') state.compose.outputTone = '';
197    if (typeof state.compose.saveStatus !== 'string') state.compose.saveStatus = '';
198  }
199
200  function maybeRepairRoute(reason) {
201    if (routeSelfHealTriggered) {
202      return;
203    }
204    routeSelfHealTriggered = true;
205    requestManagedRouteRepair({
206      reason: reason,
207      preflight: function () {
208        return fetch('/cgi/blog-list-navbar-pages', { cache: 'no-store' });
209      }
210    });
211  }
212
213  function requestManagedRouteRepair(options) {
214    var opts = options && typeof options === 'object' ? options : {};
215    var url;
216    try {
217      url = Object.prototype.hasOwnProperty.call(opts, 'url')
218        ? new URL(String(opts.url || ''), window.location.href)
219        : new URL(window.location.href);
220    } catch (_err) {
221      if (typeof opts.onUrlError === 'function') {
222        opts.onUrlError();
223        return false;
224      }
225      window.location.reload();
226      return false;
227    }
228    if (url.searchParams.get(ROUTE_REPAIR_PARAM) === '1') {
229      url.searchParams.delete(ROUTE_REPAIR_PARAM);
230      return false;
231    }
232    url.searchParams.set(ROUTE_REPAIR_PARAM, '1');
233    var preflight = typeof opts.preflight === 'function' ? opts.preflight() : null;
234    Promise.resolve(preflight)
235      .catch(function () {
236        // Ignore preflight failures; still attempt one repair reload.
237      })
238      .finally(function () {
239        window.setTimeout(function () {
240          if (opts.reason) {
241            try {
242              sessionStorage.setItem('wizardry_last_route_repair_reason', String(opts.reason));
243            } catch (_storageErr) {
244              // Ignore storage errors.
245            }
246          }
247          window.location.replace(url.toString());
248        }, Math.max(0, Number(opts.delayMs || 220) || 0));
249      });
250    return true;
251  }
252
253  function escapeHtml(value) {
254    return String(value || '')
255      .replace(/&/g, '&amp;')
256      .replace(/</g, '&lt;')
257      .replace(/>/g, '&gt;')
258      .replace(/"/g, '&quot;')
259      .replace(/'/g, '&#39;');
260  }
261
262  function markdownInline(md) {
263    return window.__wizardryRenderManagedMarkdownInline(md, {
264      upgradeState: markedUpgradeState,
265      onUpgrade: renderAll,
266      renderFallback: window.__wizardryRenderManagedMarkdownInlineLinkFallback
267    });
268  }
269
270  function markdownBlock(md) {
271    return window.__wizardryRenderManagedMarkdownBlock(md, {
272      upgradeState: markedUpgradeState,
273      onUpgrade: renderAll,
274      renderInlineFallback: window.__wizardryRenderManagedMarkdownInlineLinkFallback
275    });
276  }
277
278  function prerenderFragment(payload, key) {
279    return String(payload && payload.prerender && payload.prerender[key] || '');
280  }
281
282  function readPrerenderHtmlFragment(payload, name, options) {
283    var opts = options && typeof options === 'object' ? options : {};
284    var fragmentName = String(name || '').trim();
285    if (!fragmentName) {
286      return null;
287    }
288    var html = prerenderFragment(payload, fragmentName + '_html');
289    if (!html && opts.allowEmpty !== true) {
290      return null;
291    }
292    return {
293      html: html,
294      hidden: /\bhidden\b/i.test(prerenderFragment(payload, fragmentName + '_attrs'))
295    };
296  }
297
298  function mountPrerenderHtmlFragment(targetNode, payload, name, options) {
299    var fragment = readPrerenderHtmlFragment(payload, name, options);
300    if (!targetNode || !fragment) {
301      return false;
302    }
303    targetNode.innerHTML = fragment.html;
304    targetNode.hidden = fragment.hidden;
305    return true;
306  }
307
308  function titleizeSlug(value) {
309    var text = String(value || '').trim().replace(/-/g, ' ');
310    if (!text || text === 'index') {
311      return 'Blog';
312    }
313    return text.split(/\s+/).map(function (word) {
314      return word ? (word.charAt(0).toUpperCase() + word.slice(1)) : '';
315    }).join(' ');
316  }
317
318  function fallbackPageTitle() {
319    return window.__wizardryResolveRootAttributeFallbackText(
320      root,
321      ['data-page-title'],
322      titleizeSlug(slug)
323    ) || titleizeSlug(slug);
324  }
325
326  function normalizePageState(raw) {
327    var src = raw || {};
328    return {
329      slug: String(src.slug || slug),
330      title: String(src.title || fallbackPageTitle()),
331      description: String(src.description || ''),
332      image: String(src.image || ''),
333      content: String(src.content || ''),
334      default_tag: String(src.default_tag || '').trim(),
335      seed_tag: String(src.seed_tag || '').trim(),
336      theme: String(src.theme || '').trim(),
337      member_addresses: Array.isArray(src.member_addresses) ? src.member_addresses.slice() : [],
338      extras_after: String(src.extras_after || '')
339    };
340  }
341
342  function filteredPostsForPageDefaults(posts, payload) {
343    var list = Array.isArray(posts) ? posts.slice() : [];
344    var page = payload && payload.state ? normalizePageState(payload.state) : null;
345    if (pageType === 'publication') {
346      var members = new Set(page && page.member_addresses || []);
347      if (members.size) {
348        var postsByAddress = {};
349        list.forEach(function (post) {
350          postsByAddress[String(post && post.nostr_address || '').trim().toLowerCase()] = post;
351        });
352        return Array.from(members).map(function (address) {
353          return postsByAddress[String(address || '').trim().toLowerCase()];
354        }
354).filter(Boolean);
355      }
356      return [];
357    }
358    var defaultTag = String(page && page.default_tag || '').trim().toLowerCase();
359    if (!defaultTag) {
360      return list;
361    }
362    return list.filter(function (post) {
363      return normalizePostTags(post && post.tags).some(function (tag) {
364        return String(tag || '').trim().toLowerCase() === defaultTag;
365      });
366    });
367  }
368
369  function payloadHasDefaultPostTag(payload) {
370    var page = payload && payload.state ? normalizePageState(payload.state) : null;
371    return !!String(page && (pageType === 'publication' ? page.seed_tag : page.default_tag) || '').trim();
372  }
373
374  function postsHaveRenderedHtml(posts) {
375    return Array.isArray(posts) && posts.every(function (post) {
376      return !!renderedPostCardHtml(post);
377    });
378  }
379
380  function getRenderState() {
381    if (state.payload && state.payload.state) {
382      return normalizePageState(state.payload.state);
383    }
384    return normalizePageState({ title: fallbackPageTitle() });
385  }
386
387  function hasLikelyAdminSession() {
388    var auth = window.__wizardryAuthPayload();
389    if (!auth || !auth.session_token || !auth.csrf_token) {
390      return false;
391    }
392    try {
393      return String(localStorage.getItem('last_auth_is_admin') || '') 
393=== '1';
394    } catch (_err) {
395      return false;
396    }
397  }
398
399  function isAdmin() {
400    if (state.payload && state.payload.is_admin) {
401      return true;
402    }
403    return hasLikelyAdminSession();
404  }
405
406  var apiPost = window.__wizardryPostFormJson;
407
408  function renderHead() {
409    var page = getRenderState();
410    var publicationHead = root.querySelector('.list-page-head');
411    if (pageType === 'publication' && publicationHead instanceof HTMLElement) {
412      var imageUrl = String(page.image || '').trim();
413      var validImage = /^https?:\/\//i.test(imageUrl) || imageUrl.charAt(0) === '/';
414      publicationHead.classList.toggle('publication-head-with-image', validImage);
415      publicationHead.style.backgroundImage = validImage
416        ? 'linear-gradient(rgba(0,0,0,.5), rgba(0,0,0,.5)), url("' + imageUrl.replace(/["\\\n\r]/g, '') + '")'
417        : '';
418    }
419    window.__wizardryRenderManagedPageHeadForPage({
420      isAdmin: false,
421      payload: state.payload,
422      titleNode: els.title,
423      descriptionNode: els.description,
424      titleStateOptions: {
425        renderState: page,
426        fallbackTitleText: fallbackPageTitle(),
427        useFallbackForPageTitle: true
428      },
429      descriptionStateOptions: {
430        format: 'html',
431        text: pageType === 'publication' ? page.description : '',
432        html: pageType === 'publication' ? markdownBlock(page.description) : '',
433        hidden: pageType !== 'publication' || !page.description
434      },
435      navbarTitleOptions: {
436        renderState: page,
437        rootNode: root,
438        fallbackAttributeNames: ['data-page-title'],
439        fallbackText: fallbackPageTitle(),
440        isAdmin: false,
441        editMode: false
442      }
443    });
444  }
445
446  function clearDraftNoticeState() {
447    state.draftNotice.loading = false;
448    state.draftNotice.loaded = false;
449    state.draftNotice.drafts = [];
450    clearDraftNoticeCache();
451    renderDraftNotice();
452  }
453
454  function draftNoticeIdentity() {
455    var username = '';
456    try {
457      username = String(localStorage.getItem('last_auth_username') || '').trim().toLowerCase();
458    } catch (_err) {
459      username = '';
460    }
461    if (username) {
462      return 'u:' + username;
463    }
464    var auth = window.__wizardryAuthPayload();
465    if (auth.session_token) {
466      return 's:' + auth.session_token.slice(0, 24);
467    }
468    return '';
469  }
470
471  function removeTimedCacheRecord(storageKey) {
472    try {
473      localStorage.removeItem(String(storageKey || ''));
474    } catch (_err) {
475      // Ignore storage failures.
476    }
477  }
478
479  function readTimedCacheRecord(storageKey, maxAgeMs) {
480    try {
481      var raw = localStorage.getItem(String(storageKey || ''));
482      if (!raw) {
483        return null;
484      }
485      var parsed = JSON.parse(raw);
486      if (!parsed || typeof parsed !== 'object') {
487        return null;
488      }
489      var savedAt = Number(parsed.saved_at || 0);
490      if (!isFinite(savedAt) || savedAt <= 0 || (Number(maxAgeMs || 0) > 0 && (Date.now() - savedAt) > Number(maxAgeMs || 0))) {
491        removeTimedCacheRecord(storageKey);
492        return null;
493      }
494      return parsed;
495    } catch (_err) {
496      return null;
497    }
498  }
499
500  function writeTimedCacheRecord(storageKey, payload) {
501    try {
502      localStorage.setItem(String(storageKey || ''), JSON.stringify(Object.assign({}, payload || {}, {
503        saved_at: Date.now()
504      })));
505    } catch (_err) {
506      // Ignore storage failures.
507    }
508  }
509
510  function clearDraftNoticeCache() {
511    removeTimedCacheRecord(DRAFT_NOTICE_CACHE_KEY);
512  }
513
514  function readDraftNoticeCache() {
515    var parsed = readTimedCacheRecord(DRAFT_NOTICE_CACHE_KEY, DRAFT_NOTICE_CACHE_MAX_AGE_MS);
516    if (!parsed) {
517      return null;
518    }
519    try {
520      var identity = String(parsed.identity || '').trim();
521      if (!identity || identity !== draftNoticeIdentity()) {
522        return null;
523      }
524      var drafts = Array.isArray(parsed.drafts) ? parsed.drafts : [];
525      return drafts.slice();
526    } catch (_err) {
527      return null;
528    }
529  }
530
531  function writeDraftNoticeCache(drafts) {
532    var identity = draftNoticeIdentity();
533    if (!identity) {
534      return;
535    }
536    writeTimedCacheRecord(DRAFT_NOTICE_CACHE_KEY, {
537      identity: identity,
538      drafts: Array.isArray(drafts) ? drafts : []
539    });
540  }
541
542  function hydrateDraftNoticeFromCache() {
543    if (!isAdmin()) {
544      return false;
545    }
546    var cachedDrafts = readDraftNoticeCache();
547    if (!cachedDrafts) {
548      return false;
549    }
550    state.draftNotice.loaded = true;
551    state.draftNotice.drafts = cachedDrafts;
552    renderDraftNotice();
553    return true;
554  }
555
556  function draftTitleFromRecord(draft) {
557    var title = String(draft && draft.title || '').trim();
558    if (title) {
559      return title;
560    }
561    var excerpt = String(draft && draft.content_excerpt || '').trim();
562    if (excerpt) {
563      return excerpt;
564    }
565    return 'Untitled draft';
566  }
567
568  function draftTimestampMs(draft) {
569    var updated = String(draft && draft.updated_at || '').trim();
570    var created = String(draft && draft.created_at || '').trim();
571    var raw = updated || created;
572    if (!raw) {
573      return 0;
574    }
575    var ms = Date.parse(raw);
576    if (!isFinite(ms)) {
577      return 0;
578    }
579    return ms;
580  }
581
582  function pickDraftNoticeSummary(drafts) {
583    var list = Array.isArray(drafts) ? drafts : [];
584    if (!list.length) {
585      return { mode: 'none' };
586    }
587    var sorted = list.slice().sort(function (a, b) {
588      return draftTimestampMs(b) - draftTimestampMs(a);
589    });
590    if (sorted.length === 1) {
591      return { mode: 'continue', draft: sorted[0], count: 1 };
592    }
593    var recentWindowMs = 36 * 60 * 60 * 1000;
594    var cutoff = Date.now() - recentWindowMs;
595    var recent = null;
596    for (var i = 0; i < sorted.length; i += 1) {
597      if (draftTimestampMs(sorted[i]) >= cutoff) {
598        recent = sorted[i];
599        break;
600      }
601    }
602    if (recent) {
603      return { mode: 'continue', draft: recent, count: sorted.length };
604    }
605    return { mode: 'count', count: sorted.length };
606  }
607
608  function draftNoticeSignature(drafts) {
609    var list = Array.isArray(drafts) ? drafts : [];
610    return list.map(function (draft) {
611      return [
612        String(draft && draft.draft_id || '').trim(),
613        String(draft && draft.updated_at || '').trim(),
614        String(draft && draft.created_at || '').trim()
615      ].join(':');
616    }).sort().join('|');
617  }
618
619  function draftNoticeDismissButtonHtml() {
620    return '<button type="button" class="blog-draft-notice-close" data-draft-banner-action="dismiss" aria-label="Close draft notice">' + closeIconSvg() + '</button>';
621  }
622
623  function renderDraftNotice() {
624    var host = els.draftNotice;
625    if (!host) {
626      return;
627    }
628    if (!isAdmin()) {
629      host.hidden = true;
630      host.innerHTML = '';
631      return;
632    }
633    if (state.compose && state.compose.open && String(state.compose.draftId || '').trim()) {
634      host.hidden = true;
635      host.innerHTML = '';
636      return;
637    }
638    var summary = pickDraftNoticeSummary(state.draftNotice.drafts);
639    if (summary.mode === 'none') {
640      host.hidden = true;
641      host.innerHTML = '';
642      return;
643    }
644    var signature = draftNoticeSignature(state.draftNotice.drafts);
645    if (signature && state.draftNotice.dismissedSignature === signature) {
646      host.hidden = true;
647      host.innerHTML = '';
648      return;
649    }
650    if (summary.mode === 'count') {
651      var count = Number(summary.count) || 0;
652      var noun = count === 1 ? 'saved draft' : 'saved drafts';
653      host.innerHTML = '<div class="blog-draft-notice-card"><span class="blog-draft-notice-main"><span>You have </span><a href="/admin#drafts" class="blog-draft-notice-link">' + escapeHtml(String(count) + ' ' + noun) + '</a><span>.</span></span>' + draftNoticeDismissButtonHtml() + '</div>';
654      host.hidden = false;
655      return;
656    }
657    var draftId = String(summary.draft && summary.draft.draft_id || '').trim();
658    var title = draftTitleFromRecord(summary.draft);
659    var allDraftsLink = '';
660    if ((Number(summary.count) || 0) > 1) {
661      allDraftsLink = '<a href="/admin#drafts" class="blog-draft-notice-link blog-draft-notice-all-link">View all drafts</a>';
662    }
663    host.innerHTML = '' +
664      '<div class="blog-draft-notice-card">' +
665        '<span class="blog-draft-notice-main">' +
666          '<span class="blog-draft-notice-text">Continue working on: </span>' +
667          '<a href="#" class="blog-draft-notice-link blog-draft-notice-continue-link" data-draft-banner-action="continue" data-draft-id="' + escapeHtml(draftId) + '"><strong>' + escapeHtml(title) + '</strong></a>' +
668        '</span>' +
669        allDraftsLink +
670        draftNoticeDismissButtonHtml() +
671      '</div>';
672    host.hidden = false;
673  }
674
675  function loadDraftNoticeData() {
676    if (!isAdmin()) {
677      clearDraftNoticeState();
678      return Promise.resolve();
679    }
680    if (state.draftNotice.loading) {
681      return Promise.resolve();
682    }
683    var auth = window.__wizardryAuthPayload();
684    if (!auth.session_token || !auth.csrf_token) {
685      clearDraftNoticeState();
686      return Promise.resolve();
687    }
688    hydrateDraftNoticeFromCache();
689    state.draftNotice.loading = true;
690    var requestSeq = state.draftNotice.requestSeq + 1;
691    state.draftNotice.requestSeq = requestSeq;
692    return apiPost('/cgi/blog-list-drafts', {
693      session_token: auth.session_token,
694      csrf_token: auth.csrf_token
695    }).then(function (data) {
696      if (requestSeq !== state.draftNotice.requestSeq) {
697        return;
698      }
699      state.draftNotice.loading = false;
700      state.draftNotice.loaded = true;
701      state.draftNotice.drafts = Array.isArray(data && data.drafts) ? data.drafts : [];
702      writeDraftNoticeCache(state.draftNotice.drafts);
703      renderDraftNotice();
704    }).catch(function () {
705      if (requestSeq !== state.draftNotice.requestSeq) {
706        return;
707      }
708      state.draftNotice.loading = false;
709      state.draftNotice.loaded = true;
710      state.draftNotice.drafts = [];
711      renderDraftNotice();
712    });
713  }
714
715  function waitForInitialDraftNotice() {
716    if (!isAdmin()) {
717      clearDraftNoticeState();
718      return Promise.resolve();
719    }
720    return loadDraftNoticeData();
721  }
722
723  function openDraftFromNotice(draftId) {
724    var id = String(draftId || '').trim();
725    if (!id) {
726      return;
727    }
728    openComposeDraftInPlace(id, {}).catch(function (err) {
729      showTopToast(err && err.message ? err.message : 'Could not open draft.', 'error', 3600);
730    });
731  }
732
733  function renderValidation() {
734    if (!els.validation) {
735      return;
736    }
737    window.__wizardryRenderManagedPayloadValidationSummary({
738      containerNode: els.validation,
739      payload: state.payload,
740      visible: isAdmin(),
741      suppressTransientStateErrors: true
742    });
743  }
744
745  function pageSettingsTags() {
746    var seen = {};
747    var tags = [];
748    (Array.isArray(state.posts) ? state.posts : []).forEach(function (post) {
749      var list = Array.isArray(post && post.tags) ? post.tags : [];
750      list.forEach(function (raw) {
751        var tag = String(raw || '').trim();
752        if (!tag || seen[tag]) {
753          return;
754        }
755        seen[tag] = true;
756        tags.push(tag);
757      });
758    });
759    tags.sort(function (a, b) {
760      return a.localeCompare(b);
761    });
762    return tags;
763  }
764
765  function setPageSettingsOpen(open) {
766    if (!els.admin) {
767      return;
768    }
769    var next = !!open;
770    state.pageSettingsOpen = next;
771    var editToggle = root ? root.querySelector('[data-blog-action="toggle-page-settings"]') : null;
772    if (editToggle instanceof HTMLElement) {
773      editToggle.setAttribute('aria-expanded', next ? 'true' : 'false');
774    }
775    if (pageSettingsHideTimer) {
776      window.clearTimeout(pageSettingsHideTimer);
777      pageSettingsHideTimer = null;
778    }
779    if (next) {
780      els.admin.hidden = false;
781      els.admin.classList.remove('is-open');
782      void els.admin.offsetHeight;
783      window.requestAnimationFrame(function () {
784        if (els.admin) {
785          els.admin.classList.add('is-open');
786        }
787      });
788      return;
789    }
790    els.admin.classList.remove('is-open');
791    pageSettingsHideTimer = window.setTimeout(function () {
792      pageSettingsHideTimer = null;
793      if (els.admin && !state.pageSettingsOpen) {
794        els.admin.hidden = true;
795      }
796    }, 240);
797  }
798
799  function renderAdmin() {
800    if (!els.admin) {
801      return;
802    }
803    window.__wizardryRenderManagedAdminActionBar({
804      adminNode: els.admin,
805      actionsHostNode: document.getElementById('blog-page-title-actions'),
806      isAdmin: isAdmin(),
807      payload: state.payload,
808      editMode: state.pageSettingsOpen,
809      showRevert: false,
810      showPublish: false,
811      editActionAttrName: 'data-blog-action',
812      editAction: 'toggle-page-settings',
813      onHide: function () {
814        state.pageSettingsOpen = false;
815      }
816    });
817    if (!isAdmin()) {
818      return;
819    }
820    var page = getRenderState();
821    var selectedTag = String(pageType === 'publication' ? page.seed_tag : page.default_tag || '').trim();
822    var tags = pageSettingsTags();
823    var options = '<option value="">All posts</option>' + tags.map(function (tag) {
824      return '<option value="' + escapeHtml(tag) + '"' + (selectedTag === tag ? ' selected' : '') + '>' + escapeHtml(tag) + '</option>';
825    }).join('');
826    if (pageType === 'publication') {
827      els.admin.innerHTML = '' +
828        '<div id="blog-page-settings-panel" class="blog-page-settings-panel' + (state.pageSettingsOpen ? ' is-open' : '') + '">' +
829          '<label><span>Title</span><input type="text" data-publication-setting="title" value="' + escapeHtml(page.title) + '"></label>' +
830          '<label><span>Description</span><textarea data-publication-setting="description">' + escapeHtml(page.description) + '</textarea></label>' +
831          '<label><span>Image URL</span><input type="url" data-publication-setting="image" value="' + escapeHtml(page.image) + '"></label>' +
832          '<label><span>Homepage content (local Markdown)</span><textarea data-publication-setting="extras_after">' + escapeHtml(page.extras_after) + '</textarea></label>' +
833          '<label><span>Posts (ordered Nostr addresses)</span><textarea data-publication-setting="member_addresses">' + escapeHtml(page.member_addresses.join('\n')) + '</textarea></label>' +
834          '<div class="blog-page-settings-actions"><button type="button" data-blog-action="save-publication">Save draft</button><button type="button" data-blog-action="publish-publication">Publish…</button></div>' +
835        '</div>';
836    } else {
837      els.admin.innerHTML = '' +
838      '<div id="blog-page-settings-panel" class="blog-page-settings-panel' + (state.pageSettingsOpen ? ' is-open' : '') + '">' +
839        '<label><span>Show posts</span><select data-blog-setting="default-tag">' + options + '</select></label>' +
840      '</div>';
841    }
842    if (state.pageSettingsOpen) {
843      els.admin.hidden = false;
844      els.admin.classList.add('is-open');
845    } else {
846      els.admin.hidden = true;
847      els.admin.classList.remove('is-open');
848    }
849  }
850
851  function publicationDraftFromSettings() {
852    var current = getRenderState();
853    if (!els.admin) {
854      return current;
855    }
856    ['title', 'description', 'image', 'extras_after'].forEach(function (key) {
857      var field = els.admin.querySelector('[data-publication-setting="' + key + '"]');
858      if (field) {
859        current[key] = String(field.value || '').trim();
860      }
861    });
862    var membersField = els.admin.querySelector('[data-publication-setting="member_addresses"]');
863    if (membersField) {
864      current.member_addresses = String(membersField.value || '').split(/\r?\n/).map(function (address) {
865        return address.trim().toLowerCase();
866      }).filter(Boolean);
867    }
868    return current;
869  }
870
871  function savePublicationDraft() {
872    if (pageType !== 'publication' || !isAdmin()) {
873      return Promise.resolve(false);
874    }
875    var draft = publicationDraftFromSettings();
876    var auth = window.__wizardryAuthPayload();
877    return apiPost('/cgi/blog-save-nostr-page-draft', {
878      page_slug: slug,
879      state_json: JSON.stringify(draft),
880      session_token: auth.session_token,
881      csrf_token: auth.csrf_token
882    }).then(function (data) {
883      state.payload = Object.assign({}, state.payload || {}, data || {}, {
884        state: data.state || draft,
885        draft_exists: true,
886        draft_differs: true
887      });
888      renderAll();
889      return true;
890    }).catch(function (err) {
891      window.alert(err && err.message ? err.message : 'Could not save publication draft');
892      return false;
893    });
894  }
895
896  async function publishPublication() {
897    if (!await savePublicationDraft()) {
898      return;
899    }
900    var ensure = window.__wizardryEnsureNostrPublishDialog;
901    if (typeof ensure !== 'function' || !await ensure()) {
902      window.alert('Publish dialog unavailable');
903      return;
904    }
905    var published = await window.blogNostrPublishDialog.open({
906      pageSlug: slug,
907      pageLabel: String(getRenderState().title || slug).trim()
908    });
909    if (published) {
910      await loadPageState({ deferRender: false });
911    }
912  }
913
914  function eventTargetElement(target) {
915    if (target instanceof Element) {
916      return target;
917    }
918    if (target && target.parentElement instanceof Element) {
919      return target.parentElement;
920    }
921    return null;
922  }
923
924  function ensureComposeHosts() {
925    if (!els.composeSlot || !els.composeSlot.isConnected) {
926      els.composeSlot = document.getElementById('blog-compose-slot');
927    }
928    if (!els.composeFab || !els.composeFab.isConnected) {
929      els.composeFab = document.getElementById('blog-compose-fab');
930    }
931    if (els.composeFab instanceof HTMLButtonElement) {
932      els.composeFab.type = 'button';
933      els.composeFab.setAttribute('data-blog-action', 'toggle-compose');
934      if (!els.composeFab.getAttribute('aria-label')) {
935        els.composeFab.setAttribute('aria-label', 'Compose');
936      }
937      if (!els.composeFab.querySelector('.blog-compose-fab-icon')) {
938        els.composeFab.innerHTML = '<span class="blog-compose-fab-icon" aria-hidden="true">+</span>';
939      }
940    }
941  }
942
943  function normalizeComposePostType(raw) {
944    var picked = String(raw || '').trim().toLowerCase();
945    if (COMPOSE_POST_TYPES.indexOf(picked) >= 0) {
946      return picked;
947    }
948    return 'longform';
949  }
950
951  function composePostType() {
952    ensureComposeStateShape();
953    return normalizeComposePostType(state.compose.postType);
954  }
955
956  function composePostTypeIsTextual(type) {
957    var picked = normalizeComposePostType(type);
958    return picked === 'shortform' || picked === 'longform' || picked === 'attachment';
959  }
960
961  function composeBackingPostType(type) {
962    var picked = normalizeComposePostType(type);
963    return picked;
964  }
965
966  function composeBuildLinkMarkdown(urlValue, bodyValue, titleValue) {
967    var url = String(urlValue || '').trim();
968    if (!url) {
969      return '';
970    }
971    var label = String(titleValue || '').trim() || url;
972    var out = '[' + label + '](' + url + ')';
973    var body = String(bodyValue || '').trim();
974    if (body) {
975      out += '\n\n' + body;
976    }
977    return out;
978  }
979
980  function parseComposeLinkShareContent(rawContent) {
981    var content = String(rawContent || '');
982    var match = content.match(/\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/i);
983    var url = '';
984    if (match && match[1]) {
985      url = String(match[1]).trim();
986    }
987    var body = content.replace(/\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/i, '').trim();
988    return {
989      url: url,
990      body: body
991    };
992  }
993
994  function composeNostrTarget(postType) {
995    var type = composeBackingPostType(postType);
996    if (type === 'shortform') {
997      return { kind: '1', tags: 't=short, alt' };
998    }
999    if (type === 'longform') {
1000      return { kind: '30023', tags: 'd, title, summary, published_at' };
1001    }
1002    if (type === 'attachment') {
1003      return { kind: '15', tags: 'url, m, size, ox, alt' };
1004    }
1005    if (type === 'capture-media') {
1006      return { kind: '20 or 21', tags: 'url, m=image/*|video/*, alt, dim|duration' };
1007    }
1008    if (type === 'upload-media') {
1009      return { kind: '20 or 21', tags: 'url, m=image/*|video/*, ox, size, dim|duration' };
1010    }
1011    if (type === 'audio-note') {
1012      return { kind: '21', tags: 'url, m=audio/*, duration, alt' };
1013    }
1014    if (type === 'link-share') {
1015      return { kind: '1', tags: 'r, title, summary, image' };
1016    }
1017    return { kind: '30311', tags: 'streaming, starts, status=live' };
1018  }
1019
1020  function composePostTypeLabel(postType) {
1021    var type = normalizeComposePostType(postType);
1022    if (type === 'shortform') return 'shortform';
1023    if (type === 'longform') return 'post';
1024    if (type === 'capture-media') return 'capture';
1025    if (type === 'upload-media') return 'media';
1026    if (type === 'attachment') return 'attachment';
1027    if (type === 'audio-note') return 'audio';
1028    if (type === 'link-share') return 'link';
1029    return 'go live';
1030  }
1031
1032  function composePostKindPillText(postType) {
1033    var type = composeBackingPostType(postType);
1034    var target = composeNostrTarget(type);
1035    if (type === 'longform') return 'post (kind ' + target.kind + ')';
1036    if (type === 'shortform') return 'shortform (kind ' + target.kind + ')';
1037    if (type === 'capture-media') return 'capture (kind ' + target.kind + ')';
1038    if (type === 'upload-media') return 'media (kind ' + target.kind + ')';
1039    if (type === 'attachment') return 'attachment (kind ' + target.kind + ')';
1040    if (type === 'audio-note') return 'audio (kind ' + target.kind + ')';
1041    if (type === 'link-share') return 'link (kind ' + target.kind + ')';
1042    return 'go live (kind ' + target.kind + ')';
1043  }
1044
1045  function composePostKindPillClass(postType) {
1046    var type = composeBackingPostType(postType);
1047    if (type === 'longform') return 'is-type-nip23';
1048    if (type === 'capture-media' || type === 'upload-media') return 'is-type-icon-gallery';
1049    if (type === 'audio-note') return 'is-type-public-ranking';
1050    if (type === 'link-share') return 'is-type-blog';
1051    if (type === 'shortform') return 'is-type-list';
1052    return 'is-type-public-ranking';
1053  }
1054
1055  function composeNostrPillsHtml(postType) {
1056    var type = composeBackingPostType(postType);
1057    var target = composeNostrTarget(type);
1058    var kindText = composePostKindPillText(type);
1059    var badgeClass = composePostKindPillClass(type);
1060    return '<span class="nostr-page-kind-badge ' + badgeClass + '" title="' + escapeHtml(kindText + ' · ' + target.tags) + '">' + escapeHtml(kindText) + '</span>';
1061  }
1062
1063  function clearComposePostTypeCollapseTimer() {
1064    ensureComposeStateShape();
1065    if (state.compose.postTypeToolbarCollapseTimer) {
1066      window.clearTimeout(state.compose.postTypeToolbarCollapseTimer);
1067      state.compose.postTypeToolbarCollapseTimer = null;
1068    }
1069  }
1070
1071  function applyComposePostTypeControlState() {
1072    if (!els.composeSlot) {
1073      return;
1074    }
1075    var control = els.composeSlot.querySelector('[data-compose-type-control]');
1076    var headRow = els.composeSlot.querySelector('[data-compose-head-row]');
1077    if (!control) {
1078      return;
1079    }
1080    control.classList.toggle('is-collapsed', !!state.compose.postTypeToolbarCollapsed);
1081    if (headRow) {
1082      headRow.classList.toggle('is-type-collapsed', !!state.compose.postTypeToolbarCollapsed);
1083    }
1084  }
1085
1086  function setComposePostTypeToolbarCollapsed(collapsed, options) {
1087    ensureComposeStateShape();
1088    var opts = options || {};
1089    state.compose.postTypeToolbarCollapsed = !!collapsed;
1090    if (opts.clearTimer !== false && collapsed) {
1091      clearComposePostTypeCollapseTimer();
1092    }
1093    if (opts.skipDom) {
1094      return;
1095    }
1096    applyComposePostTypeControlState();
1097  }
1098
1099  function scheduleComposePostTypeCollapse(delayMs) {
1100    ensureComposeStateShape();
1101    clearComposePostTypeCollapseTimer();
1102    if (!state.compose.open) {
1103      return;
1104    }
1105    state.compose.postTypeToolbarCollapseTimer = window.setTimeout(function () {
1106      state.compose.postTypeToolbarCollapseTimer = null;
1107      if (!state.compose.open) {
1108        return;
1109      }
1110      setComposePostTypeToolbarCollapsed(true);
1111    }, Math.max(800, Number(delayMs) || 2600));
1112  }
1113
1114  function composePostTypeIconSvg(type) {
1115    var picked = normalizeComposePostType(type);
1116    if (picked === 'shortform') {
1117      return '<svg class="compose-post-type-icon" viewBox="0 0 96.314 100" fill="none" aria-hidden="true">' +
1118        '<path fill="currentColor" stroke="none" d="M84.39,11.825C83.396,8.452,80.595,5,75.097,5c-5.605,0-16.216,3.389-16.216,16.216c0,2.613-1.786,3.205-3.243,3.243c-27.467,0-47.354,32.62-48.187,34.006L5,62.549l4.7,0.785c14.088,2.349,26.242,3.646,36.208,3.879V95h6.486V67.204c1.035-0.03,2.055-0.068,3.041-0.127V95h6.486V66.438c15.209-2.16,22.906-8.613,22.906-19.276V24.459h6.486v-3.243C91.314,17.868,89.414,13.124,84.39,11.825z M78.34,47.162c0,3.367,0,13.606-29.252,13.606c-9.03,0-20.227-1.017-33.326-3.031c6.043-8.383,21.414-26.792,39.876-26.792c3.908,0,9.729-2.591,9.729-9.729c0-9.157,8.12-9.711,9.729-9.73c2.609,0,3.205,1.783,3.243,3.243V47.162z M75.446,17.837c0,1.384-1.128,2.505-2.509,2.505s-2.508-1.121-2.508-2.505c0-1.384,1.127-2.505,2.508-2.505S75.446,16.453,75.446,17.837z"/>' +
1119      '</svg>';
1120    }
1121    if (picked === 'longform') {
1122      return '<svg class="compose-post-type-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
1123        '<path d="M5 7H19M5 11H19M5 15H17M5 19H19" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>' +
1124      '</svg>';
1125    }
1126    if (picked === 'capture-media') {
1127      return '<svg class="compose-post-type-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
1128        '<rect x="4" y="7" width="16" height="11" rx="2.2" stroke="currentColor" stroke-width="1.8"/>' +
1129        '<circle cx="12" cy="12.5" r="2.6" stroke="currentColor" stroke-width="1.8"/>' +
1130        '<path d="M9.2 7L10.4 5.2H13.6L14.8 7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>' +
1131      '</svg>';
1132    }
1133    if (picked === 'upload-media') {
1134      return '<svg class="compose-post-type-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
1135        '<rect x="3.6" y="5.1" width="16.8" height="13.8" rx="2.2" stroke="currentColor" stroke-width="1.8"/>' +
1136        '<circle cx="8.4" cy="9.7" r="1.4" fill="currentColor"/>' +
1137        '<path d="M5.8 16.4L10.2 12.1L13.1 15L15.8 12.5L18.2 16.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>' +
1138      '</svg>';
1139    }
1140    if (picked === 'attachment') {
1141      return '<svg class="compose-post-type-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
1142        '<path d="M21.4 11.1L12.9 19.6C10.6 21.9 6.8 21.9 4.5 19.6C2.2 17.3 2.2 13.5 4.5 11.2L13 2.8C14.6 1.2 17.1 1.2 18.7 2.8C20.2 4.4 20.2 6.9 18.7 8.5L10.2 16.9C9.3 17.8 7.8 17.8 6.9 16.9C6 16 6 14.5 6.9 13.6L14.8 5.7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>' +
1143      '</svg>';
1144    }
1145    if (picked === 'audio-note') {
1146      return '<svg class="compose-post-type-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
1147        '<rect x="9" y="4.2" width="6" height="10" rx="3" stroke="currentColor" stroke-width="1.8"/>' +
1148        '<path d="M6.6 11.2C6.6 14.4 9.1 16.9 12 16.9C14.9 16.9 17.4 14.4 17.4 11.2M12 16.9V20.2M9.3 20.2H14.7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>' +
1149      '</svg>';
1150    }
1151    if (picked === 'link-share') {
1152      return '<svg class="compose-post-type-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
1153        '<rect x="2.7" y="8.5" width="8.8" height="7" rx="3.5" stroke="currentColor" stroke-width="1.8"/>' +
1154        '<rect x="12.5" y="8.5" width="8.8" height="7" rx="3.5" stroke="currentColor" stroke-width="1.8"/>' +
1155        '<path d="M9.8 12H14.2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>' +
1156      '</svg>';
1157    }
1158    return '<svg class="compose-post-type-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
1159      '<circle cx="12" cy="12" r="7" stroke="currentColor" stroke-width="1.8"/>' +
1160      '<circle cx="12" cy="12" r="2.5" fill="currentColor"/>' +
1161    '</svg>';
1162  }
1163
1164  function composePreviewToggleIconSvg() {
1165    return '<svg class="compose-preview-toggle-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
1166      '<path d="M7.2 3.8H13.4L18.9 9.3V18.5C18.9 19.4 18.2 20.1 17.3 20.1H7.2C6.3 20.1 5.6 19.4 5.6 18.5V5.4C5.6 4.5 6.3 3.8 7.2 3.8Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>' +
1167      '<path d="M13.4 3.8V9.3H18.9" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>' +
1168      '<circle cx="10.6" cy="13.8" r="2.1" stroke="currentColor" stroke-width="1.8"/>' +
1169      '<path d="M12.1 15.3L14.3 17.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>' +
1170    '</svg>';
1171  }
1172
1173  function composeCloseIconSvg() {
1174    return '<svg class="compose-close-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
1175      '<path d="M7 7L17 17" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>' +
1176      '<path d="M17 7L7 17" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>' +
1177    '</svg>';
1178  }
1179
1180  function composeTypeButtonsHtml(activeType, opts) {
1181    var options = opts || {};
1182    var lockAll = !!options.lockAll;
1183    var noActive = !!options.noActive;
1184    var current = noActive ? '' : normalizeComposePostType(activeType);
1185    function btn(type, label, disabled) {
1186      var buttonDisabled = !!disabled || lockAll;
1187      var cls = 'compose-post-type-pill';
1188      if (type === current) {
1189        cls += ' is-active';
1190      }
1191      if (buttonDisabled) {
1192        cls += ' is-disabled';
1193      }
1194      var icon = composePostTypeIconSvg(type);
1195      var title = disabled ? ('Coming soon: ' + label) : (buttonDisabled ? ('Post type locked: ' + label) : label);
1196      return '<button type="button" class="' + cls + '" data-compose-action="set-post-type" data-compose-post-type="' + escapeHtml(type) + '"' +
1197        (buttonDisabled ? ' disabled aria-disabled="true"' : '') +
1198        ' aria-pressed="' + (type === current ? 'true' : 'false') + '"' +
1199        ' aria-label="' + escapeHtml(label) + '"' +
1200        ' title="' + escapeHtml(title) + '"' +
1201        '>' + icon + '</button>';
1202    }
1203    return '' +
1204      '<div class="compose-post-type-toolbar" role="tablist" aria-label="Post type">' +
1205      btn('shortform', composePostTypeLabel('shortform'), false) +
1206      btn('longform', composePostTypeLabel('longform'), false) +
1207      btn('capture-media', composePostTypeLabel('capture-media'), false) +
1208      btn('upload-media', composePostTypeLabel('upload-media'), false) +
1209      btn('attachment', composePostTypeLabel('attachment'), false) +
1210      btn('audio-note', composePostTypeLabel('audio-note'), false) +
1211      btn('link-share', composePostTypeLabel('link-share'), false) +
1212      btn('go-live', composePostTypeLabel('go-live'), true) +
1213      '</div>';
1214  }
1215
1216  function normalizeComposeShortformLimit(raw) {
1217    var n = parseInt(String(raw || '').trim(), 10);
1218    if (!isFinite(n) || n < 1) {
1219      return 280;
1220    }
1221    return Math.max(1, Math.min(5000, n));
1222  }
1223
1224  function currentComposeShortformLimit() {
1225    ensureComposeStateShape();
1226    state.compose.shortformLimit = normalizeComposeShortformLimit(state.compose.shortformLimit);
1227    return state.compose.shortformLimit;
1228  }
1229
1230  function enforceComposeShortformLimitOnFields(fields) {
1231    var data = fields || readComposeFields();
1232    if (!data) {
1233      return;
1234    }
1235    if (normalizeComposePostType(data.postType) !== 'shortform') {
1236      return;
1237    }
1238    var limit = currentComposeShortformLimit();
1239    if (String(data.content || '').length <= limit) {
1240      return;
1241    }
1242    var next = String(data.content || '').slice(0, limit);
1243    if (els.composeSlot) {
1244      var contentField = els.composeSlot.querySelector('[data-compose-field="content"]');
1245      if (contentField instanceof HTMLTextAreaElement) {
1246        contentField.value = next;
1247      }
1248    }
1249  }
1250
1251  function composeShortformMeterHtml(contentValue) {
1252    var limit = currentComposeShortformLimit();
1253    var count = String(contentValue || '').length;
1254    var editing = !!state.compose.shortformLimitEditing;
1255    return '' +
1256      '<div class="blog-compose-shortform-meter">' +
1257        '<button type="button" class="unobtrusive-icon-button blog-compose-shortform-limit-btn" data-compose-action="shortform-limit-toggle" title="Click to toggle 280/140. Double-click to set custom limit."' + (editing ? ' hidden' : '') + '>' + escapeHtml(String(count) + '/' + String(limit)) + '</button>' +
1258        '<input type="number" class="blog-compose-shortform-limit-input" data-compose-field="shortform-limit" min="1" step="1" inputmode="numeric" value="' + escapeHtml(String(limit)) + '"' + (editing ? '' : ' hidden') + '>' +
1259      '</div>';
1260  }
1261
1262  function setComposeShortformLimit(raw, opts) {
1263    var options = opts || {};
1264    state.compose.shortformLimit = normalizeComposeShortformLimit(raw);
1265    if (options.editing === true || options.editing === false) {
1266      state.compose.shortformLimitEditing = !!options.editing;
1267    }
1268    enforceComposeShortformLimitOnFields();
1269    if (!options.skipRender) {
1270      renderComposeUi();
1271    }
1272    if (!options.skipAutosave) {
1273      queueComposeAutosave();
1274    }
1275  }
1276
1277  function refreshComposeShortformMeterFromFields() {
1278    if (!els.composeSlot || composePostType() !== 'shortform') {
1279      return;
1280    }
1281    var contentField = els.composeSlot.querySelector('[data-compose-field="content"]');
1282    var meterButton = els.composeSlot.querySelector('[data-compose-action="shortform-limit-toggle"]');
1283    if (!(contentField instanceof HTMLTextAreaElement) || !(meterButton instanceof HTMLButtonElement)) {
1284      return;
1285    }
1286    meterButton.textContent = String(contentField.value || '').length + '/' + String(currentComposeShortformLimit());
1287  }
1288
1289  function composeTextareaRows(postType) {
1290    var type = normalizeComposePostType(postType);
1291    if (type === 'shortform') {
1292      return 9;
1293    }
1294    if (composePostTypeIsTextual(type)) {
1295      return 14;
1296    }
1297    return 8;
1298  }
1299
1300  function setComposePostType(nextType, options) {
1301    var opts = options || {};
1302    var normalized = normalizeComposePostType(nextType);
1303    var wasChosen = !!state.compose.postTypeChosen;
1304    if (normalized === 'go-live') {
1305      setComposeOutput('Go Live is a future feature.', 'warn');
1306      renderComposeStatusOnly();
1307      return;
1308    }
1309    // For initial media selection, keep chooser mode until capture/file pick completes.
1310    if (opts.interactive && normalized === 'capture-media') {
1311      state.compose.postType = normalized;
1312      state.compose.postTypeChosen = wasChosen;
1313      state.compose.postTypeToolbarCollapsed = false;
1314      if (!opts.skipRender) {
1315        renderComposeUi();
1316      }
1317      setTimeout(function () {
1318        openComposeCameraCapture({ returnToChooser: !wasChosen });
1319      }, 0);
1320      return;
1321    }
1322    if (opts.interactive && normalized === 'upload-media' && !wasChosen) {
1323      state.compose.postType = normalized;
1324      state.compose.postTypeChosen = false;
1325      state.compose.postTypeToolbarCollapsed = false;
1326      if (!opts.skipRender) {
1327        renderComposeUi();
1328      }
1329      if (!openComposePickerForType(normalized)) {
1330        setTimeout(function () {
1331          openComposePickerForType(normalized);
1332        }, 0);
1333      }
1334      return;
1335    }
1336    if (opts.interactive && normalized === 'audio-note') {
1337      state.compose.postType = normalized;
1338      state.compose.postTypeChosen = true;
1339      state.compose.postTypeToolbarCollapsed = true;
1340      if (!opts.skipRender) {
1341        renderComposeUi();
1342      }
1343      if (!opts.skipAutosave) {
1344        queueComposeAutosave();
1345      }
1346      setTimeout(function () {
1347        if (!state.compose.open || composePostType() !== 'audio-note') {
1348          return;
1349        }
1350        if (!state.compose.audioRecording) {
1351          startComposeAudioRecording();
1352        }
1353      }, 0);
1354      return;
1355    }
1356    state.compose.postType = normalized;
1357    state.compose.postTypeChosen = true;
1358    state.compose.postTypeToolbarCollapsed = true;
1359    if (!opts.skipRender) {
1360      renderComposeUi();
1361    }
1362    if (!opts.skipAutosave) {
1363      queueComposeAutosave();
1364    }
1365    if (opts.interactive) {
1366      if (normalized === 'upload-media' || normalized === 'attachment') {
1367        if (!openComposePickerForType(normalized)) {
1368          setTimeout(function () {
1369            openComposePickerForType(normalized);
1370          }, 0);
1371        }
1372      } else if (normalized === 'capture-media') {
1373        setTimeout(function () {
1374          applyComposeModeEffects(normalized);
1375        }, 0);
1376      }
1377    }
1378  }
1379
1380  function normalizeTagValue(tag) {
1381    var value = String(tag || '').trim();
1382    if (!value) {
1383      return '';
1384    }
1385    value = value.replace(/^\[+|\]+$/g, '');
1386    value = value.replace(/^["']+|["']+$/g, '');
1387    value = value.replace(/\\+/g, '');
1388    value = value.replace(/\s+/g, '-');
1389    value = value.replace(/^-+|-+$/g, '');
1390    if (!/[A-Za-z0-9]/.test(value)) {
1391      return '';
1392    }
1393    if (/[\[\]{}]/.test(value)) {
1394      return '';
1395    }
1396    value = value.replace(/[^A-Za-z0-9._:+/-]/g, '');
1397    value = value.replace(/^-+|-+$/g, '');
1398    if (!value || value.length > 64) {
1399      return '';
1400    }
1401    return value;
1402  }
1403
1404  function syncComposeTagsField() {
1405    if (!els.composeSlot) {
1406      return;
1407    }
1408    var hidden = els.composeSlot.querySelector('[data-compose-field="tags"]');
1409    if (hidden instanceof HTMLInputElement) {
1410      hidden.value = state.compose.tags.join(',');
1411    }
1412  }
1413
1414  function setComposeTags(tags) {
1415    var list = Array.from(tags || [])
1416      .map(normalizeTagValue)
1417      .filter(Boolean);
1418    state.compose.tags = list.filter(function (tag, idx) {
1419      return list.indexOf(tag) === idx;
1420    });
1421    syncComposeTagsField();
1422  }
1423
1424  function composeDefaultTag() {
1425    var page = getRenderState();
1426    return normalizeTagValue(String(page && page.default_tag || ''));
1427  }
1428
1429  function seedComposeDefaultTag(forceReplace) {
1430    var tag = composeDefaultTag();
1431    if (!tag) {
1432      if (forceReplace) {
1433        setComposeTags([]);
1434      }
1435      return;
1436    }
1437    if (forceReplace) {
1438      setComposeTags([tag]);
1439      return;
1440    }
1441    if (!state.compose.tags.length) {
1442      setComposeTags([tag]);
1443    }
1444  }
1445
1446  function composeTagTokenHtml(tag) {
1447    return '<span class="tag-token" contenteditable="false" data-compose-tag-token="' + escapeHtml(tag) + '" tabindex="-1"><span class="tag-token-label">' + escapeHtml(tag) + '</span></span>';
1448  }
1449
1450  function composeTagsEditorHtml() {
1451    var tokensHtml = state.compose.tags.map(composeTagTokenHtml).join('');
1452    return '' +
1453      '<div class="tag-token-editor" data-compose-field="tags-editor" contenteditable="true" role="textbox" aria-label="Post tags" spellcheck="false" data-placeholder="tag, tag, tag">' +
1454        tokensHtml +
1455        '<span class="tag-token-editor-draft" data-compose-tag-draft>' + escapeHtml(state.compose.tagsDraftText || '') + '</span>' +
1456      '</div>';
1457  }
1458
1459  function composeTagsEditorNode() {
1460    if (!els.composeSlot) {
1461      return null;
1462    }
1463    var node = els.composeSlot.querySelector('[data-compose-field="tags-editor"]');
1464    return node instanceof HTMLElement ? node : null;
1465  }
1466
1467  function composeTagsEditorDraftNode(editor) {
1468    if (!(editor instanceof HTMLElement)) {
1469      return null;
1470    }
1471    var node = editor.querySelector('[data-compose-tag-draft]');
1472    return node instanceof HTMLElement ? node : null;
1473  }
1474
1475  function composeTagsEditorSelectedToken(editor) {
1476    if (!(editor instanceof HTMLElement)) {
1477      return null;
1478    }
1479    var node = editor.querySelector('.tag-token.is-selected[data-compose-tag-token]');
1480    return node instanceof HTMLElement ? node : null;
1481  }
1482
1483  function composeTagsEditorSetEmptyClass(editor) {
1484    if (!(editor instanceof HTMLElement)) {
1485      return;
1486    }
1487    var draft = String(state.compose.tagsDraftText || '').trim();
1488    var empty = !state.compose.tags.length && !draft;
1489    editor.classList.toggle('is-empty', empty);
1490  }
1491
1492  function composeTagsEditorRender(editor) {
1493    if (!(editor instanceof HTMLElement)) {
1494      return;
1495    }
1496    var html = state.compose.tags.map(composeTagTokenHtml).join('');
1497    html += '<span class="tag-token-editor-draft" data-compose-tag-draft>' + escapeHtml(state.compose.tagsDraftText || '') + '</span>';
1498    editor.innerHTML = html;
1499    composeTagsEditorSetEmptyClass(editor);
1500  }
1501
1502  function composeTagsEditorFocusDraft(editor) {
1503    if (!(editor instanceof HTMLElement)) {
1504      return;
1505    }
1506    var draftNode = composeTagsEditorDraftNode(editor);
1507    if (!(draftNode instanceof HTMLElement)) {
1508      return;
1509    }
1510    var range = document.createRange();
1511    range.selectNodeContents(draftNode);
1512    range.collapse(false);
1513    var selection = window.getSelection ? window.getSelection() : null;
1514    if (selection) {
1515      selection.removeAllRanges();
1516      selection.addRange(range);
1517    }
1518    if (document.activeElement !== editor) {
1519      try {
1520        editor.focus({ preventScroll: true });
1521      } catch (_focusErr) {
1522        editor.focus();
1523      }
1524    }
1525  }
1526
1527  function composeTagsEditorPlaceCaretFromPoint(editor, clientX, clientY) {
1528    if (!(editor instanceof HTMLElement)) {
1529      return false;
1530    }
1531    var selection = window.getSelection ? window.getSelection() : null;
1532    if (!selection) {
1533      return false;
1534    }
1535    var range = null;
1536    if (document.caretPositionFromPoint) {
1537      var pos = document.caretPositionFromPoint(clientX, clientY);
1538      if (pos && pos.offsetNode && editor.contains(pos.offsetNode)) {
1539        range = document.createRange();
1540        range.setStart(pos.offsetNode, Number(pos.offset) || 0);
1541        range.collapse(true);
1542      }
1543    } else if (document.caretRangeFromPoint) {
1544      var pointRange = document.caretRangeFromPoint(clientX, clientY);
1545      if (pointRange && pointRange.startContainer && editor.contains(pointRange.startContainer)) {
1546        range = pointRange;
1547        range.collapse(true);
1548      }
1549    }
1550    if (!range) {
1551      return false;
1552    }
1553    selection.removeAllRanges();
1554    selection.addRange(range);
1555    return true;
1556  }
1557
1558  function composeTagsEditorClearSelection(editor) {
1559    if (!(editor instanceof HTMLElement)) {
1560      return;
1561    }
1562    var selected = composeTagsEditorSelectedToken(editor);
1563    if (selected) {
1564      selected.classList.remove('is-selected');
1565    }
1566  }
1567
1568  function composeTagsEditorSelectToken(editor, tokenNode) {
1569    if (!(editor instanceof HTMLElement) || !(tokenNode instanceof HTMLElement)) {
1570      return;
1571    }
1572    composeTagsEditorClearSelection(editor);
1573    tokenNode.classList.add('is-selected');
1574    try {
1575      editor.focus({ preventScroll: true });
1576    } catch (_focusErr) {
1577      editor.focus();
1578    }
1579  }
1580
1581  function composeTagsEditorReadDraftText(editor) {
1582    if (!(editor instanceof HTMLElement)) {
1583      return '';
1584    }
1585    var clone = editor.cloneNode(true);
1586    Array.from(clone.querySelectorAll('[data-compose-tag-token]')).forEach(function (node) {
1587      if (node && node.parentNode) {
1588        node.parentNode.removeChild(node);
1589      }
1590    });
1591    return String(clone.textContent || '').replace(/\u00a0/g, ' ');
1592  }
1593
1594  function composeTagsEditorSyncDraft(editor) {
1595    if (!(editor instanceof HTMLElement)) {
1596      return;
1597    }
1598    state.compose.tagsDraftText = composeTagsEditorReadDraftText(editor);
1599    composeTagsEditorSetEmptyClass(editor);
1600  }
1601
1602  function composeTagsEditorCommit(editor, forceFinalize) {
1603    if (!(editor instanceof HTMLElement)) {
1604      return false;
1605    }
1606    var draft = composeTagsEditorReadDraftText(editor);
1607    var rawParts = String(draft || '').split(',');
1608    if (!rawParts.length) {
1609      return false;
1610    }
1611    var changed = false;
1612    var limit = forceFinalize ? rawParts.length : Math.max(0, rawParts.length - 1);
1613    for (var i = 0; i < limit; i += 1) {
1614      if (addComposeTag(rawParts[i])) {
1615        changed = true;
1616      }
1617    }
1618    var nextDraft = forceFinalize ? '' : String(rawParts[rawParts.length - 1] || '');
1619    var draftChanged = nextDraft !== draft;
1620    state.compose.tagsDraftText = nextDraft;
1621    if (changed || draftChanged) {
1622      composeTagsEditorRender(editor);
1623      composeTagsEditorFocusDraft(editor);
1624    } else {
1625      composeTagsEditorSetEmptyClass(editor);
1626    }
1627    return changed || draftChanged;
1628  }
1629
1630  function composeTagsEditorRemoveTagByNode(editor, tokenNode) {
1631    if (!(editor instanceof HTMLElement) || !(tokenNode instanceof HTMLElement)) {
1632      return false;
1633    }
1634    var tag = String(tokenNode.getAttribute('data-compose-tag-token') || '').trim();
1635    if (!tag) {
1636      return false;
1637    }
1638    if (state.compose.tags.indexOf(tag) < 0) {
1639      return false;
1640    }
1641    removeComposeTag(tag);
1642    composeTagsEditorRender(editor);
1643    composeTagsEditorFocusDraft(editor);
1644    return true;
1645  }
1646
1647  function hydrateComposeTagsEditor() {
1648    var editor = composeTagsEditorNode();
1649    if (!editor) {
1650      return;
1651    }
1652    if (!(composeTagsEditorDraftNode(editor) instanceof HTMLElement)) {
1653      composeTagsEditorRender(editor);
1654    } else {
1655      composeTagsEditorSetEmptyClass(editor);
1656    }
1657  }
1658
1659  function addComposeTag(rawTag) {
1660    var tag = normalizeTagValue(rawTag);
1661    if (!tag || state.compose.tags.indexOf(tag) !== -1) {
1662      return false;
1663    }
1664    state.compose.tags.push(tag);
1665    state.compose.tagsOpen = true;
1666    syncComposeTagsField();
1667    return true;
1668  }
1669
1670  function removeComposeTag(tag) {
1671    setComposeTags(state.compose.tags.filter(function (item) {
1672      return item !== tag;
1673    }));
1674  }
1675
1676  function commitComposeTagInput() {
1677    var editor = composeTagsEditorNode();
1678    if (editor) {
1679      return composeTagsEditorCommit(editor, true);
1680    }
1681    state.compose.tagsDraftText = '';
1682    return false;
1683  }
1684
1685  function readFileAsDataUrl(file) {
1686    return new Promise(function (resolve, reject) {
1687      var reader = new FileReader();
1688      reader.onload = function () { resolve(String(reader.result || '')); };
1689      reader.onerror = function () { reject(new Error('Failed to read file')); };
1690      reader.readAsDataURL(file);
1691    });
1692  }
1693
1694  function composeUploadMarkdown(url, file) {
1695    var safeUrl = String(url || '').trim();
1696    if (!safeUrl) {
1697      return '';
1698    }
1699    var name = String((file && file.name) || 'file').trim();
1700    var mime = String((file && file.type) || '').toLowerCase();
1701    var alt = name.replace(/\.[^.]+$/, '') || 'media';
1702    if (mime.indexOf('image/') === 0) {
1703      return '![' + alt + '](' + safeUrl + ')';
1704    }
1705    if (mime.indexOf('video/') === 0) {
1706      return '<video controls src="' + safeUrl + '"></video>';
1707    }
1708    if (mime.indexOf('audio/') === 0) {
1709      return '<audio controls src="' + safeUrl + '"></audio>';
1710    }
1711    return '[' + name + '](' + safeUrl + ')';
1712  }
1713
1714  function appendComposeContent(text) {
1715    ensureComposeStateShape();
1716    if (!els.composeSlot) {
1717      var queuedOnly = String(text || '').trim();
1718      if (queuedOnly) {
1719        state.compose.pendingContentAdditions.push(queuedOnly);
1720      }
1721      return queuedOnly ? 'queued' : 'skipped';
1722    }
1723    var source = els.composeSlot.querySelector('[data-compose-field="content"]');
1724    var addition = String(text || '').trim();
1725    if (!addition) {
1726      return 'skipped';
1727    }
1728    if (!(source instanceof HTMLTextAreaElement)) {
1729      state.compose.pendingContentAdditions.push(addition);
1730      return 'queued';
1731    }
1732    var current = String(source.value || '');
1733    source.value = current.trim() ? (current.replace(/\s*$/, '') + '\n\n' + addition) : addition;
1734    return 'inserted';
1735  }
1736
1737  function clearComposeUploadCleanupTimer() {
1738    if (!state.compose || !state.compose.uploadCleanupTimer) {
1739      return;
1740    }
1741    clearTimeout(state.compose.uploadCleanupTimer);
1742    state.compose.uploadCleanupTimer = null;
1743  }
1744
1745  function clearComposeUploadItems() {
1746    ensureComposeStateShape();
1747    clearComposeUploadCleanupTimer();
1748    state.compose.uploadItems = [];
1749    state.compose.uploading = 0;
1750  }
1751
1752  function composeUploadDisplayName(file) {
1753    var raw = String((file && file.name) || '').trim();
1754    if (raw) {
1755      return raw;
1756    }
1757    return 'upload-' + String(Date.now()) + '.bin';
1758  }
1759
1760  function addComposeUploadEntries(files) {
1761    ensureComposeStateShape();
1762    var entries = Array.from(files || []).map(function (file) {
1763      state.compose.uploadSeq += 1;
1764      return {
1765        id: 'upload-' + String(state.compose.uploadSeq),
1766        name: composeUploadDisplayName(file),
1767        progress: 0,
1768        status: 'queued',
1769        error: ''
1770      };
1771    });
1772    if (entries.length) {
1773      state.compose.uploadItems = state.compose.uploadItems.concat(entries);
1774      clearComposeUploadCleanupTimer();
1775    }
1776    return entries;
1777  }
1778
1779  function updateComposeUploadEntry(entryId, patch) {
1780    ensureComposeStateShape();
1781    var id = String(entryId || '').trim();
1782    if (!id) {
1783      return;
1784    }
1785    for (var idx = 0; idx < state.compose.uploadItems.length; idx += 1) {
1786      var item = state.compose.uploadItems[idx];
1787      if (String(item && item.id || '') !== id) {
1788        continue;
1789      }
1790      var next = Object.assign({}, item, patch || {});
1791      if (!isFinite(Number(next.progress))) {
1792        next.progress = 0;
1793      }
1794      next.progress = Math.max(0, Math.min(100, Math.round(Number(next.progress))));
1795      state.compose.uploadItems[idx] = next;
1796      return;
1797    }
1798  }
1799
1800  function scheduleComposeUploadCleanup() {
1801    ensureComposeStateShape();
1802    clearComposeUploadCleanupTimer();
1803    // Keep completed uploads visible in the compose upload list until the
1804    // compose state is explicitly cleared/closed by the user.
1805  }
1806
1807  function composeUploadProgressHtml() {
1808    ensureComposeStateShape();
1809    var items = Array.isArray(state.compose.uploadItems) ? state.compose.uploadItems : [];
1810    if (!items.length) {
1811      return '<div class="blog-compose-upload-list" data-compose-upload-progress hidden></div>';
1812    }
1813    var rows = items.map(function (item) {
1814      var name = escapeHtml(String((item && item.name) || 'upload.bin'));
1815      var status = String((item && item.status) || 'queued');
1816      var progress = Math.max(0, Math.min(100, Math.round(Number(item && item.progress) || 0)));
1817      var pctText = status === 'error' ? 'Failed' : (String(progress) + '%');
1818      return '' +
1819        '<div class="blog-compose-upload-row is-' + escapeHtml(status) + '">' +
1820          '<span class="blog-compose-upload-name">' + name + '</span>' +
1821          '<div class="blog-compose-upload-progress">' +
1822            '<div class="blog-compose-upload-track"><div class="blog-compose-upload-fill" style="width:' + String(progress) + '%"></div></div>' +
1823            '<span class="blog-compose-upload-state">' + pctText + '</span>' +
1824          '</div>' +
1825        '</div>';
1826    }).join('');
1827    return '<div class="blog-compose-upload-list" data-compose-upload-progress>' + rows + '</div>';
1828  }
1829
1830  function renderComposeUploadProgressOnly() {
1831    if (!els.composeSlot || !state.compose.open) {
1832      return;
1833    }
1834    var host = els.composeSlot.querySelector('[data-compose-upload-progress]');
1835    if (!host) {
1836      return;
1837    }
1838    var html = composeUploadProgressHtml();
1839    var wrapper = document.createElement('div');
1840    wrapper.innerHTML = html;
1841    var nextHost = wrapper.firstChild;
1842    if (!(nextHost instanceof Element)) {
1843      host.hidden = true;
1844      host.innerHTML = '';
1845      return;
1846    }
1847    host.hidden = !!nextHost.hidden;
1848    host.className = nextHost.className;
1849    host.innerHTML = nextHost.innerHTML;
1850  }
1851
1852  function uploadComposeFile(file, options) {
1853    var opts = options || {};
1854    function normalizeUploadDataBase64(dataUrl) {
1855      var raw = String(dataUrl || '').trim();
1856      if (!raw) {
1857        return '';
1858      }
1859      var commaIdx = raw.indexOf(',');
1860      if (commaIdx > 0 && /^data:/i.test(raw.slice(0, commaIdx))) {
1861        return raw.slice(commaIdx + 1);
1862      }
1863      return raw;
1864    }
1865    function isMissingUploadPayloadError(data, statusCode) {
1866      if (!data || typeof data !== 'object') {
1867        return false;
1868      }
1869      var message = String(data.error || '').toLowerCase();
1870      var code = String(data.code || '').toLowerCase();
1871      if (message.indexOf('filename and data_base64 are required') < 0) {
1872        return false;
1873      }
1874      // Some deployments return this payload with HTTP 200 and success=false.
1875      return code === '' || code === 'invalid_request' || Number(statusCode) >= 200;
1876    }
1877    var auth = window.__wizardryAuthPayload();
1878    if (!auth.session_token || !auth.csrf_token) {
1879      return Promise.reject(new Error('Sign in again to upload.'));
1880    }
1881    return readFileAsDataUrl(file).then(function (dataUrl) {
1882      var rawDataUrl = String(dataUrl || '').trim();
1883      var bareDataBase64 = normalizeUploadDataBase64(rawDataUrl);
1884      var safeFilename = String((file && file.name) || 'upload.bin').trim() || 'upload.bin';
1885      var safeMimeType = String((file && file.type) || '');
1886      if (!rawDataUrl) {
1887        return Promise.reject(new Error('Failed to read file'));
1888      }
1889      return new Promise(function (resolve, reject) {
1890        var fallbackAttempted = false;
1891        function buildPayload(useBareData) {
1892          return {
1893            session_token: auth.session_token,
1894            csrf_token: auth.csrf_token,
1895            draft_id: String(state.compose.draftId || ''),
1896            filename: safeFilename,
1897            mime_type: safeMimeType,
1898            data_base64: useBareData ? bareDataBase64 : rawDataUrl
1899          };
1900        }
1901        function sendAttempt(useBareData) {
1902          var body = new URLSearchParams(buildPayload(useBareData)).toString();
1903          var xhr = new XMLHttpRequest();
1904          xhr.open('POST', '/cgi/blog-upload-media', true);
1905          xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
1906          if (xhr.upload && typeof opts.onProgress === 'function') {
1907            xhr.upload.onprogress = function (progressEvent) {
1908              if (!progressEvent || !progressEvent.lengthComputable || progressEvent.total <= 0) {
1909                return;
1910              }
1911              opts.onProgress(progressEvent.loaded, progressEvent.total);
1912            };
1913          }
1914          xhr.onreadystatechange = function () {
1915            if (xhr.readyState !== 4) {
1916              return;
1917            }
1918            var data;
1919            try {
1920              data = JSON.parse(String(xhr.responseText || ''));
1921            } catch (_err) {
1922              reject(new Error('Invalid JSON response'));
1923              return;
1924            }
1925            if (xhr.status < 200 || xhr.status >= 300 || !data || data.success === false) {
1926              if (!fallbackAttempted && isMissingUploadPayloadError(data, xhr.status)) {
1927                fallbackAttempted = true;
1928                sendAttempt(!useBareData);
1929                return;
1930              }
1931              reject(new Error((data && data.error) || ('Upload failed (' + xhr.status + ')')));
1932              return;
1933            }
1934            resolve(data);
1935          };
1936          xhr.onerror = function () {
1937            reject(new Error('Upload request failed'));
1938          };
1939          xhr.send(body);
1940        }
1941        // Prefer bare base64 first; fallback to data URL form for compatibility.
1942        sendAttempt(true);
1943      });
1944    });
1945  }
1946
1947  function handleComposeUploads(files, preferredType) {
1948    ensureComposeStateShape();
1949    var list = Array.from(files || []).filter(function (file) {
1950      return file && file.size >= 0;
1951    });
1952    if (!list.length) {
1953      return Promise.resolve();
1954    }
1955    if (!state.compose.open) {
1956      setComposeOpen(true);
1957    }
1958    var currentType = state.compose.postTypeChosen ? composePostType() : '';
1959    var targetType = normalizeComposePostType(preferredType || currentType || 'attachment');
1960    if (targetType === 'shortform' || targetType === 'capture-media' || targetType === 'go-live') {
1961      targetType = 'attachment';
1962    }
1963    if (!state.compose.postTypeChosen || preferredType) {
1964      setComposePostType(targetType, { skipAutosave: true });
1965    } else {
1966      renderComposeStatusOnly();
1967    }
1968    var uploadEntries = addComposeUploadEntries(list);
1969    state.compose.uploading += list.length;
1970    setComposeOutput('', '');
1971    showTopToast('Uploading ' + list.length + ' file(s)...', 'warn', 1600);
1972    renderComposeStatusOnly();
1973    var insertedCount = 0;
1974    var errorCount = 0;
1975    var chain = Promise.resolve();
1976    list.forEach(function (file, idx) {
1977      var entry = uploadEntries[idx];
1978      chain = chain.then(function () {
1979        if (entry && entry.id) {
1980          updateComposeUploadEntry(entry.id, { status: 'uploading', progress: 2, error: '' });
1981          renderComposeStatusOnly();
1982        }
1983        return uploadComposeFile(file, {
1984          onProgress: function (loaded, total) {
1985            if (!entry || !entry.id) {
1986              return;
1987            }
1988            var pct = total > 0 ? (loaded / total) * 100 : 0;
1989            updateComposeUploadEntry(entry.id, { status: 'uploading', progress: Math.max(2, Math.min(98, pct)) });
1990            renderComposeStatusOnly();
1991          }
1992        }).then(function (data) {
1993          if (data && data.draft_id) {
1994            state.compose.draftId = String(data.draft_id);
1995          }
1996          var uploadUrl = String((data && data.url) || '').trim();
1997          if (!uploadUrl) {
1998            var uploadedFileId = String((data && data.file_id) || '').trim();
1999            var uploadedName = String((data && (data.filename || data.name)) || '').trim();
2000            if (uploadedFileId) {
2001              uploadUrl = '/files/' + encodeURIComponent(uploadedFileId);
2002              if (uploadedName) {
2003                uploadUrl += '/' + encodeURIComponent(uploadedName);
2004              }
2005            }
2006          }
2007          if (!uploadUrl) {
2008            throw new Error('Upload succeeded but file URL is missing');
2009          }
2010          var appendState = appendComposeContent(composeUploadMarkdown(uploadUrl, file));
2011          if (appendState === 'inserted' || appendState === 'queued') {
2012            insertedCount += 1;
2013          }
2014          if (entry && entry.id) {
2015            updateComposeUploadEntry(entry.id, { status: 'done', progress: 100, error: '' });
2016            renderComposeStatusOnly();
2017          }
2018        }).catch(function (err) {
2019          errorCount += 1;
2020          if (entry && entry.id) {
2021            updateComposeUploadEntry(entry.id, {
2022              status: 'error',
2023              progress: 100,
2024              error: String((err && err.message) || 'Upload failed')
2025            });
2026            renderComposeStatusOnly();
2027          }
2028        });
2029      });
2030    });
2031    return chain.then(function () {
2032      renderComposeUi();
2033      if (insertedCount > 0) {
2034        flushComposeAutosaveNow();
2035      } else {
2036        queueComposeAutosave();
2037      }
2038      if (insertedCount > 0) {
2039        showTopToast('Upload complete. Added to body.', 'ok', 2600);
2040      } else if (!errorCount) {
2041        showTopToast('Upload complete.', 'ok', 2400);
2042      }
2043      if (errorCount > 0) {
2044        showTopToast('Upload failed for ' + String(errorCount) + ' file(s).', 'error', 3600);
2045      }
2046      scheduleComposeUploadCleanup();
2047    }).finally(function () {
2048      state.compose.uploading = Math.max(0, state.compose.uploading - list.length);
2049      renderComposeStatusOnly();
2050    });
2051  }
2052
2053  function composeDroppedFiles(event) {
2054    var dt = event && event.dataTransfer;
2055    if (!dt || !dt.files) {
2056      return [];
2057    }
2058    return Array.from(dt.files).filter(function (file) {
2059      return file && file.size >= 0;
2060    });
2061  }
2062
2063  function composeDragHasFiles(event) {
2064    var dt = event && event.dataTransfer;
2065    if (!dt) {
2066      return false;
2067    }
2068    if (dt.files && dt.files.length > 0) {
2069      return true;
2070    }
2071    var types = dt.types ? Array.from(dt.types) : [];
2072    return types.indexOf('Files') >= 0;
2073  }
2074
2075  function composeDropInComposeCard(target) {
2076    if (!state.compose.open || !els.composeSlot || !target || !(target instanceof Element)) {
2077      return false;
2078    }
2079    return !!target.closest('.blog-compose-card');
2080  }
2081
2082  function clearComposeDropHover() {
2083    if (composeDropHoverTimer) {
2084      clearTimeout(composeDropHoverTimer);
2085      composeDropHoverTimer = null;
2086    }
2087    if (!els.composeSlot) {
2088      return;
2089    }
2090    var active = els.composeSlot.querySelector('.blog-compose-card.is-drop-hover');
2091    if (active) {
2092      active.classList.remove('is-drop-hover');
2093    }
2094  }
2095
2096  function setComposeDropHover(target) {
2097    if (!els.composeSlot || !target || !(target instanceof Element)) {
2098      return;
2099    }
2100    var card = target.closest('.blog-compose-card');
2101    if (!card) {
2102      return;
2103    }
2104    var active = els.composeSlot.querySelector('.blog-compose-card.is-drop-hover');
2105    if (active && active !== card) {
2106      active.classList.remove('is-drop-hover');
2107    }
2108    card.classList.add('is-drop-hover');
2109    if (composeDropHoverTimer) {
2110      clearTimeout(composeDropHoverTimer);
2111    }
2112    composeDropHoverTimer = setTimeout(function () {
2113      clearComposeDropHover();
2114    }, 150);
2115  }
2116
2117  function composeCanAcceptDroppedFiles(postType) {
2118    if (state.compose.cameraFullscreen) {
2119      return false;
2120    }
2121    var type = normalizeComposePostType(postType);
2122    if (!type) {
2123      return true;
2124    }
2125    return type !== 'shortform' && type !== 'capture-media';
2126  }
2127
2128  function composePreferredDropType(files, currentType) {
2129    var type = normalizeComposePostType(currentType);
2130    var list = Array.from(files || []);
2131    if (!list.length) {
2132      return '';
2133    }
2134    var allAudio = list.every(function (file) {
2135      return String((file && file.type) || '').toLowerCase().indexOf('audio/') === 0;
2136    });
2137    var allVisual = list.every(function (file) {
2138      var mime = String((file && file.type) || '').toLowerCase();
2139      return mime.indexOf('image/') === 0 || mime.indexOf('video/') === 0;
2140    });
2141    if (type) {
2142      if (type === 'upload-media' && !allVisual) {
2143        return 'attachment';
2144      }
2145      if (type === 'audio-note' && !allAudio) {
2146        return 'attachment';
2147      }
2148      if (type === 'shortform' || type === 'capture-media') {
2149        return '';
2150      }
2151      return '';
2152    }
2153    // When chooser is showing (no type selected), auto-pick based on dropped file kind:
2154    // image files -> image upload mode, everything else -> attachment mode.
2155    var allImages = list.every(function (file) {
2156      return String((file && file.type) || '').toLowerCase().indexOf('image/') === 0;
2157    });
2158    if (allImages) {
2159      return 'upload-media';
2160    }
2161    return 'attachment';
2162  }
2163
2164  function composePickerFieldByType(type) {
2165    var picked = normalizeComposePostType(type);
2166    if (picked === 'capture-media') return 'capture-upload';
2167    if (picked === 'upload-media') return 'media-upload';
2168    if (picked === 'attachment') return 'file-upload';
2169    if (picked === 'audio-note') return 'audio-upload';
2170    return '';
2171  }
2172
2173  function openComposePickerForType(type) {
2174    if (!els.composeSlot) {
2175      return false;
2176    }
2177    var field = composePickerFieldByType(type);
2178    if (!field) {
2179      return false;
2180    }
2181    var picker = els.composeSlot.querySelector('[data-compose-field="' + field + '"]');
2182    if (!(picker instanceof HTMLInputElement)) {
2183      return false;
2184    }
2185    picker.click();
2186    return true;
2187  }
2188
2189  function composeCameraOverlayHtml() {
2190    var status = state.compose.cameraStarting
2191      ? 'Requesting camera access...'
2192      : (state.compose.cameraError ? escapeHtml(state.compose.cameraError) : 'Frame your shot and capture.');
2193    return '' +
2194      '<div class="blog-compose-camera-overlay-shell">' +
2195        '<div class="blog-compose-camera-overlay-stage">' +
2196          '<video class="blog-compose-camera-overlay-video" data-compose-camera-fullscreen-preview playsinline autoplay muted></video>' +
2197        '</div>' +
2198        '<div class="blog-compose-camera-overlay-controls">' +
2199          '<button type="button" class="unobtrusive-icon-button blog-compose-camera-control" data-compose-action="cancel-camera-capture">Cancel</button>' +
2200          '<button type="button" class="list-admin-primary-btn blog-compose-camera-capture" data-compose-action="capture-photo">Capture Photo</button>' +
2201        '</div>' +
2202        '<div class="blog-compose-camera-overlay-status">' + status + '</div>' +
2203      '</div>';
2204  }
2205
2206  function renderComposeCameraOverlay() {
2207    if (!root) {
2208      return;
2209    }
2210    var shouldShow = !!(state.compose.open && state.compose.cameraFullscreen);
2211    var overlay = root.querySelector('.blog-compose-camera-overlay');
2212    if (!shouldShow) {
2213      if (overlay && overlay.parentNode) {
2214        overlay.parentNode.removeChild(overlay);
2215      }
2216      if (document && document.body) {
2217        document.body.classList.remove('blog-camera-mode-active');
2218      }
2219      return;
2220    }
2221    if (!overlay) {
2222      overlay = document.createElement('div');
2223      overlay.className = 'blog-compose-camera-overlay';
2224      root.appendChild(overlay);
2225    }
2226    overlay.innerHTML = composeCameraOverlayHtml();
2227    if (document && document.body) {
2228      document.body.classList.add('blog-camera-mode-active');
2229    }
2230    attachComposeCameraPreview();
2231  }
2232
2233  function openComposeCameraCapture(options) {
2234    ensureComposeStateShape();
2235    var opts = options || {};
2236    state.compose.postType = 'capture-media';
2237    state.compose.cameraFullscreen = true;
2238    state.compose.cameraReturnToChooser = !!opts.returnToChooser;
2239    if (opts.returnToChooser) {
2240      state.compose.postTypeChosen = false;
2241      state.compose.postTypeToolbarCollapsed = false;
2242    }
2243    renderComposeUi();
2244    ensureComposeCameraStream();
2245    attachComposeCameraPreview();
2246  }
2247
2248  function closeComposeCameraCapture(options) {
2249    ensureComposeStateShape();
2250    var opts = options || {};
2251    var shouldReturnToChooser = !!opts.returnToChooser || !!state.compose.cameraReturnToChooser;
2252    state.compose.cameraFullscreen = false;
2253    state.compose.cameraReturnToChooser = false;
2254    stopComposeCameraStream();
2255    if (shouldReturnToChooser) {
2256      state.compose.postTypeChosen = false;
2257      state.compose.postTypeToolbarCollapsed = false;
2258    }
2259    renderComposeUi();
2260  }
2261
2262  function stopComposeCameraStream() {
2263    ensureComposeStateShape();
2264    var stream = state.compose.cameraStream;
2265    if (stream && typeof stream.getTracks === 'function') {
2266      stream.getTracks().forEach(function (track) {
2267        if (track && typeof track.stop === 'function') {
2268          track.stop();
2269        }
2270      });
2271    }
2272    state.compose.cameraStream = null;
2273    state.compose.cameraStarting = false;
2274  }
2275
2276  function attachComposeCameraPreview() {
2277    if (!root) {
2278      return;
2279    }
2280    var previews = root.querySelectorAll('[data-compose-camera-preview], [data-compose-camera-fullscreen-preview]');
2281    Array.prototype.forEach.call(previews, function (video) {
2282      if (!(video instanceof HTMLVideoElement)) {
2283        return;
2284      }
2285      if (state.compose.cameraStream && video.srcObject !== state.compose.cameraStream) {
2286        video.srcObject = state.compose.cameraStream;
2287      }
2288      if (video.srcObject) {
2289        video.play().catch(function () {
2290          // Ignore autoplay restrictions; controls are still available.
2291        });
2292      }
2293    });
2294  }
2295
2296  function ensureComposeCameraStream() {
2297    ensureComposeStateShape();
2298    if (state.compose.cameraStream || state.compose.cameraStarting) {
2299      attachComposeCameraPreview();
2300      return;
2301    }
2302    if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function') {
2303      state.compose.cameraError = 'Camera is unavailable in this browser.';
2304      renderComposeUi();
2305      return;
2306    }
2307    state.compose.cameraStarting = true;
2308    state.compose.cameraError = '';
2309    renderComposeUi();
2310    navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false })
2311      .catch(function () {
2312        return navigator.mediaDevices.getUserMedia({ video: true, audio: false });
2313      })
2314      .then(function (stream) {
2315        state.compose.cameraStream = stream || null;
2316      })
2317      .catch(function (err) {
2318        state.compose.cameraError = String((err && err.message) || 'Could not access camera.');
2319      })
2320      .finally(function () {
2321        state.compose.cameraStarting = false;
2322        renderComposeUi();
2323      });
2324  }
2325
2326  function stopComposeAudioStream() {
2327    ensureComposeStateShape();
2328    var recorder = state.compose.audioRecorder;
2329    if (recorder && recorder.state === 'recording') {
2330      recorder.onstop = null;
2331      recorder.onerror = null;
2332      try {
2333        recorder.stop();
2334      } catch (_err) {
2335        // Ignore stop errors.
2336      }
2337    }
2338    var stream = state.compose.audioStream;
2339    if (stream && typeof stream.getTracks === 'function') {
2340      stream.getTracks().forEach(function (track) {
2341        if (track && typeof track.stop === 'function') {
2342          track.stop();
2343        }
2344      });
2345    }
2346    state.compose.audioStream = null;
2347    state.compose.audioRecorder = null;
2348    state.compose.audioChunks = [];
2349    state.compose.audioStarting = false;
2350    state.compose.audioRecording = false;
2351  }
2352
2353  function ensureComposeAudioStream() {
2354    ensureComposeStateShape();
2355    if (state.compose.audioStream) {
2356      return Promise.resolve(state.compose.audioStream);
2357    }
2358    if (state.compose.audioStarting) {
2359      return Promise.resolve(null);
2360    }
2361    if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function') {
2362      state.compose.audioError = 'Microphone is unavailable in this browser.';
2363      renderComposeUi();
2364      return Promise.resolve(null);
2365    }
2366    state.compose.audioStarting = true;
2367    state.compose.audioError = '';
2368    renderComposeUi();
2369    return navigator.mediaDevices.getUserMedia({ audio: true, video: false })
2370      .then(function (stream) {
2371        state.compose.audioStream = stream || null;
2372        return state.compose.audioStream;
2373      })
2374      .catch(function (err) {
2375        state.compose.audioError = String((err && err.message) || 'Could not access microphone.');
2376        return null;
2377      })
2378      .finally(function () {
2379        state.compose.audioStarting = false;
2380        renderComposeUi();
2381      });
2382  }
2383
2384  function preferredComposeAudioMimeType() {
2385    if (typeof MediaRecorder === 'undefined' || typeof MediaRecorder.isTypeSupported !== 'function') {
2386      return '';
2387    }
2388    var candidates = [
2389      'audio/webm;codecs=opus',
2390      'audio/webm',
2391      'audio/ogg;codecs=opus',
2392      'audio/ogg',
2393      'audio/mp4'
2394    ];
2395    for (var idx = 0; idx < candidates.length; idx += 1) {
2396      if (MediaRecorder.isTypeSupported(candidates[idx])) {
2397        return candidates[idx];
2398      }
2399    }
2400    return '';
2401  }
2402
2403  function startComposeAudioRecording() {
2404    ensureComposeStateShape();
2405    if (state.compose.audioRecording || state.compose.audioStarting) {
2406      return;
2407    }
2408    if (typeof MediaRecorder === 'undefined') {
2409      state.compose.audioError = 'Audio recording is unavailable in this browser.';
2410      renderComposeUi();
2411      return;
2412    }
2413    ensureComposeAudioStream().then(function (stream) {
2414      if (!stream || state.compose.audioRecording) {
2415        return;
2416      }
2417      var recorder;
2418      var preferredMime = preferredComposeAudioMimeType();
2419      try {
2420        recorder = preferredMime ? new MediaRecorder(stream, { mimeType: preferredMime }) : new MediaRecorder(stream);
2421      } catch (_err) {
2422        try {
2423          recorder = new MediaRecorder(stream);
2424        } catch (fallbackErr) {
2425          state.compose.audioError = String((fallbackErr && fallbackErr.message) || 'Could not start recording.');
2426          renderComposeUi();
2427          return;
2428        }
2429      }
2430      state.compose.audioChunks = [];
2431      state.compose.audioRecorder = recorder;
2432      state.compose.audioError = '';
2433      recorder.ondataavailable = function (event) {
2434        if (event && event.data && event.data.size > 0) {
2435          state.compose.audioChunks.push(event.data);
2436        }
2437      };
2438      recorder.onerror = function (event) {
2439        state.compose.audioError = String((event && event.error && event.error.message) || 'Recording failed.');
2440      };
2441      recorder.onstop = function () {
2442        var chunks = Array.isArray(state.compose.audioChunks) ? state.compose.audioChunks.slice() : [];
2443        state.compose.audioChunks = [];
2444        state.compose.audioRecorder = null;
2445        state.compose.audioRecording = false;
2446        if (!chunks.length) {
2447          renderComposeUi();
2448          return;
2449        }
2450        var mime = (chunks[0] && chunks[0].type) ? String(chunks[0].type) : String(recorder.mimeType || preferredMime || 'audio/webm');
2451        var blob = new Blob(chunks, { type: mime });
2452        var ext = mime.indexOf('ogg') >= 0 ? 'ogg' : (mime.indexOf('mp4') >= 0 ? 'm4a' : 'webm');
2453        var file = new File([blob], 'voice-note-' + Date.now() + '.' + ext, { type: mime });
2454        handleComposeUploads([file], 'audio-note');
2455        renderComposeUi();
2456      };
2457      try {
2458        recorder.start(250);
2459      } catch (err) {
2460        try {
2461          recorder.start();
2462        } catch (fallbackStartErr) {
2463          state.compose.audioRecorder = null;
2464          state.compose.audioRecording = false;
2465          state.compose.audioError = String((fallbackStartErr && fallbackStartErr.message) || 'Could not start recording.');
2466          renderComposeUi();
2467          return;
2468        }
2469      }
2470      state.compose.audioRecording = true;
2471      state.compose.audioError = '';
2472      renderComposeUi();
2473    });
2474  }
2475
2476  function stopComposeAudioRecording() {
2477    ensureComposeStateShape();
2478    var recorder = state.compose.audioRecorder;
2479    if (!recorder || recorder.state !== 'recording') {
2480      return;
2481    }
2482    try {
2483      recorder.stop();
2484    } catch (_err) {
2485      state.compose.audioRecording = false;
2486      state.compose.audioRecorder = null;
2487      renderComposeUi();
2488    }
2489  }
2490
2491  function capturePhotoFromComposeCamera() {
2492    if (!root) {
2493      return;
2494    }
2495    var video = root.querySelector('[data-compose-camera-fullscreen-preview]') || root.querySelector('[data-compose-camera-preview]');
2496    if (!(video instanceof HTMLVideoElement)) {
2497      setComposeOutput('Camera preview is not ready yet.', 'warn');
2498      renderComposeStatusOnly();
2499      return;
2500    }
2501    var width = video.videoWidth;
2502    var height = video.videoHeight;
2503    if (!width || !height) {
2504      setComposeOutput('Camera preview is still starting. Try again in a moment.', 'warn');
2505      renderComposeStatusOnly();
2506      return;
2507    }
2508    var canvas = document.createElement('canvas');
2509    canvas.width = width;
2510    canvas.height = height;
2511    var ctx = canvas.getContext('2d');
2512    if (!ctx) {
2513      setComposeOutput('Could not capture from camera.', 'error');
2514      renderComposeStatusOnly();
2515      return;
2516    }
2517    ctx.drawImage(video, 0, 0, width, height);
2518    canvas.toBlob(function (blob) {
2519      if (!blob) {
2520        setComposeOutput('Could not capture photo.', 'error');
2521        renderComposeStatusOnly();
2522        return;
2523      }
2524      var file = new File([blob], 'capture-' + Date.now() + '.jpg', { type: 'image/jpeg' });
2525      state.compose.cameraFullscreen = false;
2526      state.compose.cameraReturnToChooser = false;
2527      state.compose.postType = 'capture-media';
2528      state.compose.postTypeChosen = true;
2529      state.compose.preview = false;
2530      stopComposeCameraStream();
2531      renderComposeUi();
2532      handleComposeUploads([file], 'capture-media');
2533    }, 'image/jpeg', 0.92);
2534  }
2535
2536  function isEditableTarget(target) {
2537    if (!target || !(target instanceof Element)) {
2538      return false;
2539    }
2540    if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) {
2541      return true;
2542    }
2543    if (target.isContentEditable) {
2544      return true;
2545    }
2546    return !!target.closest('textarea, input, [contenteditable="true"], [contenteditable=""]');
2547  }
2548
2549  function clipboardImageFiles(event) {
2550    var out = [];
2551    var clip = event && event.clipboardData;
2552    if (!clip) {
2553      return out;
2554    }
2555    var items = clip.items ? Array.from(clip.items) : [];
2556    items.forEach(function (item) {
2557      if (!item || item.kind !== 'file' || String(item.type || '').indexOf('image/') !== 0) {
2558        return;
2559      }
2560      var file = item.getAsFile ? item.getAsFile() : null;
2561      if (file) {
2562        out.push(file);
2563      }
2564    });
2565    if (out.length) {
2566      return out;
2567    }
2568    var files = clip.files ? Array.from(clip.files) : [];
2569    return files.filter(function (file) {
2570      return file && String(file.type || '').indexOf('image/') === 0;
2571    });
2572  }
2573
2574  function composeLocalToIso(value) {
2575    if (!value) {
2576      return '';
2577    }
2578    var dt = new Date(value);
2579    if (isNaN(dt.getTime())) {
2580      return '';
2581    }
2582    return dt.toISOString().replace('.000Z', 'Z');
2583  }
2584
2585  function composeNormalizeScheduledDate(value) {
2586    var raw = String(value || '').trim();
2587    if (!raw) {
2588      return '';
2589    }
2590    if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
2591      return raw;
2592    }
2593    if (/^\d{4}-\d{2}-\d{2}T/.test(raw)) {
2594      return raw.slice(0, 10);
2595    }
2596    var dt = new Date(raw);
2597    if (isNaN(dt.getTime())) {
2598      return '';
2599    }
2600    var local = new Date(dt.getTime() - dt.getTimezoneOffset() * 60000);
2601    return local.toISOString().slice(0, 10);
2602  }
2603
2604  function composePublishMode() {
2605    if (!els.composeSlot) {
2606      return 'immediate';
2607    }
2608    var checked = els.composeSlot.querySelector('input[name="blog-inline-compose-mode"]:checked');
2609    var value = checked ? String(checked.value || '') : 'immediate';
2610    if (value === 'scheduled' || value === 'drip' || value === 'immediate') {
2611      return value;
2612    }
2613    return 'immediate';
2614  }
2615
2616  function normalizeComposePublishDestination(raw) {
2617    var picked = String(raw || '').trim().toLowerCase();
2618    if (picked === 'nostr_now') {
2619      return 'nostr_now';
2620    }
2621    return 'local_only';
2622  }
2623
2624  function composePublishDestination() {
2625    ensureComposeStateShape();
2626    if (!els.composeSlot) {
2627      return normalizeComposePublishDestination(state.compose.publishDestination);
2628    }
2629    var checked = els.composeSlot.querySelector('input[name="blog-inline-compose-destination"]:checked');
2630    var value = checked ? String(checked.value || '') : String(state.compose.publishDestination || '');
2631    return normalizeComposePublishDestination(value);
2632  }
2633
2634  function composePrimaryLabel(mode, destination) {
2635    var shared = (typeof window !== 'undefined' && window.BlogComposeShared) ? window.BlogComposeShared : null;
2636    if (shared && typeof shared.primaryPublishLabel === 'function') {
2637      return shared.primaryPublishLabel(mode, destination, { postTypeLocked: !!state.compose.postTypeLocked });
2638    }
2639    if (state.compose.postTypeLocked) {
2640      return 'Publish Changes';
2641    }
2642    if (mode === 'scheduled') {
2643      return 'Schedule Post';
2644    }
2645    if (mode === 'drip') {
2646      return 'Enqueue Post';
2647    }
2648    if (normalizeComposePublishDestination(destination) === 'local_only') {
2649      return 'Publish to Server';
2650    }
2651    return 'Publish to Nostr';
2652  }
2653
2654  function composePublishDestinationFieldHtml(destination) {
2655    var shared = (typeof window !== 'undefined' && window.BlogComposeShared) ? window.BlogComposeShared : null;
2656    if (shared && typeof shared.renderPublishDestinationField === 'function') {
2657      return shared.renderPublishDestinationField({
2658        inputName: 'blog-inline-compose-destination',
2659        destination: destination
2660      });
2661    }
2662    return '' +
2663      '<strong>Publish to</strong>' +
2664      '<div class="mode-row">' +
2665        '<label><input type="radio" name="blog-inline-compose-destination" value="local_only"' + (destination === 'local_only' ? ' checked' : '') + '> Server only</label>' +
2666        '<label><input type="radio" name="blog-inline-compose-destination" value="nostr_now"' + (destination === 'nostr_now' ? ' checked' : '') + '> Server + Nostr</label>' +
2667      '</div>';
2668  }
2669
2670  function composeModeAction(mode, destination) {
2671    if (state.compose.postTypeLocked) {
2672      return 'publish_now';
2673    }
2674    if (mode === 'scheduled') {
2675      return 'queue_scheduled';
2676    }
2677    if (mode === 'drip') {
2678      return 'queue_drip';
2679    }
2680    return 'publish_now';
2681  }
2682
2683  function openComposeScheduledPicker() {
2684    if (!els.composeSlot || !state.compose.open) {
2685      return;
2686    }
2687    if (composePublishMode() !== 'scheduled') {
2688      return;
2689    }
2690    var scheduleInput = els.composeSlot.querySelector('[data-compose-field="scheduled-at"]');
2691    if (!(scheduleInput instanceof HTMLInputElement)) {
2692      return;
2693    }
2694    var releaseRow = scheduleInput.closest('.compose-release-row');
2695    var scheduleOption = els.composeSlot.querySelector('input[name="blog-inline-compose-mode"][value="scheduled"]');
2696    var scheduleAnchor = scheduleOption instanceof HTMLInputElement
2697      ? (scheduleOption.closest('label') || scheduleOption)
2698      : null;
2699    if (releaseRow && scheduleAnchor instanceof Element) {
2700      var rowRect = releaseRow.getBoundingClientRect();
2701      var anchorRect = scheduleAnchor.getBoundingClientRect();
2702      var left = Math.max(0, Math.round(anchorRect.left - rowRect.left));
2703      var top = Math.max(0, Math.round(anchorRect.bottom - rowRect.top + 3));
2704      scheduleInput.style.left = String(left) + 'px';
2705      scheduleInput.style.top = String(top) + 'px';
2706    }
2707    try {
2708      if (typeof scheduleInput.showPicker === 'function') {
2709        scheduleInput.showPicker();
2710      } else {
2711        scheduleInput.focus();
2712        scheduleInput.click();
2713      }
2714    } catch (_err) {
2715      try {
2716        scheduleInput.focus();
2717      } catch (_focusErr) {
2718        // Ignore picker focus errors.
2719      }
2720    }
2721  }
2722
2723  function composeScheduledDisplayValue(rawValue) {
2724    var value = composeNormalizeScheduledDate(rawValue);
2725    if (!value) {
2726      return 'No schedule selected';
2727    }
2728    return value;
2729  }
2730
2731  function composeToolbarButtonHtml(action, label, icon) {
2732    return '<button type="button" class="unobtrusive-icon-button toolbar-button" data-compose-toolbar="' + escapeHtml(action) + '" aria-label="' + escapeHtml(label) + '" title="' + escapeHtml(label) + '">' + icon + '</button>';
2733  }
2734
2735  function composeToolbarHtml() {
2736    return '' +
2737      '<div class="toolbar blog-compose-toolbar" aria-label="Markdown toolbar">' +
2738        composeToolbarButtonHtml('bold', 'Bold', '<span class="tb-glyph tb-glyph-bold" aria-hidden="true">B</span>') +
2739        composeToolbarButtonHtml('italic', 'Italic', '<span class="tb-glyph tb-glyph-italic" aria-hidden="true">I</span>') +
2740        composeToolbarButtonHtml('h2', 'Heading 2', '<span class="tb-glyph tb-glyph-heading" aria-hidden="true">H2</span>') +
2741        composeToolbarButtonHtml('h3', 'Heading 3', '<span class="tb-glyph tb-glyph-heading" aria-hidden="true">H3</span>') +
2742        composeToolbarButtonHtml('code', 'Inline code', '<span class="tb-glyph tb-glyph-code" aria-hidden="true">&lt;/&gt;</span>') +
2743        composeToolbarButtonHtml('code_block', 'Code block', '<span class="tb-glyph tb-glyph-code" aria-hidden="true">{ }</span>') +
2744        composeToolbarButtonHtml('link', 'Insert link', '<svg class="tb-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M10 13.9L8.4 15.5C6.8 17.1 4.2 17.1 2.6 15.5C1 13.9 1 11.3 2.6 9.7L4.2 8.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M14 10.1L15.6 8.5C17.2 6.9 19.8 6.9 21.4 8.5C23 10.1 23 12.7 21.4 14.3L19.8 15.9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M9 12H15" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>') +
2745        composeToolbarButtonHtml('quote', 'Quote', '<span class="tb-glyph tb-glyph-quote" aria-hidden="true">“”</span>') +
2746        composeToolbarButtonHtml('ul', 'Bullet list', '<svg class="tb-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="5.1" cy="7.2" r="1.2" fill="currentColor"/><circle cx="5.1" cy="12" r="1.2" fill="currentColor"/><circle cx="5.1" cy="16.8" r="1.2" fill="currentColor"/><path d="M9.2 7.2H19" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M9.2 12H19" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M9.2 16.8H19" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>') +
2747        composeToolbarButtonHtml('ol', 'Numbered list', '<span class="tb-glyph tb-glyph-list" aria-hidden="true">1.</span>') +
2748        composeToolbarButtonHtml('image', 'Insert image', '<svg class="tb-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3.6" y="5.1" width="16.8" height="13.8" rx="2.1" stroke="currentColor" stroke-width="1.8"/><circle cx="9.2" cy="10.2" r="1.2" fill="currentColor"/><path d="M6.2 16.1L10.7 11.7L13.2 14.2L16.1 11.5L17.8 13.2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>') +
2749        composeToolbarButtonHtml('attachment', 'Attach file', '<svg class="tb-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M9.2 12.8L14.4 7.6C15.8 6.2 18 6.2 19.4 7.6C20.8 9 20.8 11.2 19.4 12.6L11.2 20.8C8.9 23.1 5.2 23.1 2.9 20.8C0.6 18.5 0.6 14.8 2.9 12.5L11 4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>') +
2750        composeToolbarButtonHtml('audio_record', 'Record audio', '<svg class="tb-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="6.2" stroke="currentColor" stroke-width="1.9"/><circle cx="12" cy="12" r="2.5" fill="currentColor"/></svg>') +
2751      '</div>';
2752  }
2753
2754  function composeModePanelHtml(postType, fields) {
2755    var type = normalizeComposePostType(postType);
2756    if (type === 'capture-media') {
2757      return '' +
2758        '<div class="compose-media-tools compose-mode-panel compose-camera-panel">' +
2759          '<div class="compose-media-actions">' +
2760            '<button type="button" class="list-admin-primary-btn compose-media-btn compose-media-btn-primary" data-compose-action="open-camera-capture">Open Camera</button>' +
2761            '<button type="button" class="unobtrusive-icon-button compose-media-btn" data-compose-action="open-mode-picker" data-compose-mode-target="capture-media">Use Camera App / Upload</button>' +
2762          '</div>' +
2763          '<div class="compose-camera-status">' +
2764            (state.compose.cameraError ? escapeHtml(state.compose.cameraError) : 'Open camera for fullscreen capture, or upload from device.') +
2765          '</div>' +
2766        '</div>';
2767    }
2768    if (type === 'upload-media') {
2769      return '' +
2770        '<div class="compose-media-tools compose-mode-panel">' +
2771          '<div class="compose-media-actions">' +
2772            '<button type="button" class="list-admin-primary-btn compose-media-btn" data-compose-action="open-mode-picker" data-compose-mode-target="upload-media">Upload Photo/Video</button>' +
2773          '</div>' +
2774        '</div>';
2775    }
2776    if (type === 'attachment') {
2777      return '' +
2778        '<div class="compose-media-tools compose-mode-panel">' +
2779          '<div class="compose-media-actions">' +
2780            '<button type="button" class="list-admin-primary-btn compose-media-btn" data-compose-action="open-mode-picker" data-compose-mode-target="attachment">Upload Attachment/File</button>' +
2781          '</div>' +
2782        '</div>';
2783    }
2784    if (type === 'audio-note') {
2785      return '' +
2786        '<div class="compose-media-tools compose-mode-panel">' +
2787          '<div class="compose-media-actions">' +
2788            '<button type="button" class="list-admin-primary-btn compose-media-btn" data-compose-action="audio-record-toggle">' + (state.compose.audioRecording ? 'Stop Recording' : 'Start Recording') + '</button>' +
2789            '<button type="button" class="unobtrusive-icon-button compose-media-btn" data-compose-action="open-mode-picker" data-compose-mode-target="audio-note">Upload Audio</button>' +
2790          '</div>' +
2791          '<div class="compose-audio-status">' + escapeHtml(state.compose.audioError || (state.compose.audioRecording ? 'Recording audio...' : 'Press Start Recording to request microphone access.')) + '</div>' +
2792        '</div>';
2793    }
2794    if (type === 'link-share') {
2795      return '' +
2796        '<div class="compose-media-tools compose-mode-panel">' +
2797          '<div class="compose-link-fields">' +
2798            '<label><strong>Link URL</strong></label>' +
2799            '<input type="url" data-compose-field="link-url" placeholder="https://example.com" value="' + escapeHtml(fields.linkUrl) + '">' +
2800          '</div>' +
2801        '</div>';
2802    }
2803    return '';
2804  }
2805
2806  function applyComposeModeEffects(postType) {
2807    var type = normalizeComposePostType(postType);
2808    root.classList.remove('blog-camera-mode');
2809    if (!state.compose.open || !state.compose.cameraFullscreen) {
2810      stopComposeCameraStream();
2811    } else {
2812      ensureComposeCameraStream();
2813      attachComposeCameraPreview();
2814    }
2815    if (!state.compose.open || type !== 'audio-note') {
2816      stopComposeAudioStream();
2817    }
2818  }
2819
2820  function renderComposePreviewHtml(title, content) {
2821    var body = String(content || '').trim();
2822    var heading = String(title || '').trim();
2823    if (!body && !heading) {
2824      return '<p class="placeholder">Preview will appear here...</p>';
2825    }
2826    var html = '';
2827    if (heading) {
2828      html += '<h2>' + escapeHtml(heading) + '</h2>';
2829    }
2830    if (body) {
2831      html += markdownBlock(body);
2832    }
2833    return html;
2834  }
2835
2836  function composeTrashIconSvg() {
2837    return '<svg class="trash-icon-svg" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
2837<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"/></svg>';
2838  }
2839
2840  function readComposeFields() {
2841    if (!els.composeSlot) {
2842      return null;
2843    }
2844    var title = els.composeSlot.querySelector('[data-compose-field="title"]');
2845    var content = els.composeSlot.querySelector('[data-compose-field="content"]');
2846    var scheduled = els.composeSlot.querySelector('[data-compose-field="scheduled-at"]');
2847    var tags = els.composeSlot.querySelector('[data-compose-field="tags"]');
2848    var linkUrl = els.composeSlot.querySelector('[data-compose-field="link-url"]');
2849    return {
2850      title: title instanceof HTMLInputElement ? String(title.value || '') : '',
2851      content: content instanceof HTMLTextAreaElement ? String(content.value || '') : '',
2852      scheduledAt: scheduled instanceof HTMLInputElement ? composeNormalizeScheduledDate(String(scheduled.value || '')) : '',
2853      tags: tags instanceof HTMLInputElement ? String(tags.value || '') : '',
2854      linkUrl: linkUrl instanceof HTMLInputElement ? String(linkUrl.value || '') : String(state.compose.linkUrl || ''),
2855      postType: composePostType()
2856    };
2857  }
2858
2859  function composePayload(action) {
2860    commitComposeTagInput();
2861    syncComposeTagsField();
2862    var fields = readComposeFields();
2863    if (!fields) {
2864      return null;
2865    }
2866    var payloadContent = fields.content;
2867    if (fields.postType === 'link-share') {
2868      var linkMd = composeBuildLinkMarkdown(fields.linkUrl, fields.content, fields.title);
2869      if (linkMd) {
2870        payloadContent = linkMd;
2871      }
2872      state.compose.linkUrl = String(fields.linkUrl || '');
2873    }
2874    return {
2875      action: action,
2876      draft_id: String(state.compose.draftId || ''),
2877      source_post_path: String(state.compose.sourcePostPath || ''),
2878      title: fields.postType === 'shortform' ? '' : fields.title.trim(),
2879      tags: fields.tags.trim(),
2880      summary: '',
2881      content: payloadContent,
2882      post_type: fields.postType,
2883      scheduled_at: composeLocalToIso(fields.scheduledAt),
2884      publish_mode: composePublishMode(),
2885      publish_destination: composePublishDestination()
2886    };
2887  }
2888
2889  function setComposeOutput(message, tone) {
2890    var text = String(message || '').trim();
2891    var safeTone = String(tone || '').trim().toLowerCase();
2892    if (safeTone === 'error' || safeTone === 'warn') {
2893      if (text) {
2894        showTopToast(text, safeTone === 'error' ? 'error' : 'warn', safeTone === 'error' ? 3800 : 3400);
2895      }
2896      state.compose.output = '';
2897      state.compose.outputTone = '';
2898      return;
2899    }
2900    state.compose.output = text;
2901    state.compose.outputTone = safeTone;
2902  }
2903
2904  function captureComposeFieldFocus() {
2905    if (!els.composeSlot) {
2906      return null;
2907    }
2908    var active = document.activeElement;
2909    var isTextControl = (active instanceof HTMLInputElement) || (active instanceof HTMLTextAreaElement);
2910    var isTagEditor = (active instanceof HTMLElement) && String(active.getAttribute('data-compose-field') || '') 
2910=== 'tags-editor';
2911    if (!isTextControl && !isTagEditor) {
2912      return null;
2913    }
2914    if (!els.composeSlot.contains(active)) {
2915      return null;
2916    }
2917    var field = String(active.getAttribute('data-compose-field') || '').trim();
2918    if (!field) {
2919      return null;
2920    }
2921    var snapshot = {
2922      field: field,
2923      scrollTop: typeof active.scrollTop === 'number' ? active.scrollTop : 0,
2924      scrollLeft: typeof active.scrollLeft === 'number' ? active.scrollLeft : 0
2925    };
2926    if (isTextControl) {
2927      snapshot.selectionStart = typeof active.selectionStart === 'number' ? active.selectionStart : null;
2928      snapshot.selectionEnd = typeof active.selectionEnd === 'number' ? active.selectionEnd : null;
2929      snapshot.selectionDirection = typeof active.selectionDirection === 'string' ? active.selectionDirection : 'none';
2930      return snapshot;
2931    }
2932    snapshot.tagsDraftText = String(state.compose.tagsDraftText || '');
2933    return snapshot;
2934  }
2935
2936  function restoreComposeFieldFocus(snapshot) {
2937    if (!snapshot || !els.composeSlot) {
2938      return;
2939    }
2940    var selector = '[data-compose-field="' + String(snapshot.field || '').replace(/"/g, '\\"') + '"]';
2941    var target = els.composeSlot.querySelector(selector);
2942    if ((target instanceof HTMLElement) && String(snapshot.field || '') === 'tags-editor') {
2943      state.compose.tagsDraftText = typeof snapshot.tagsDraftText === 'string' ? snapshot.tagsDraftText : String(state.compose.tagsDraftText || '');
2944      composeTagsEditorRender(target);
2945      composeTagsEditorFocusDraft(target);
2946      return;
2947    }
2948    if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement)) {
2949      return;
2950    }
2951    try {
2952      target.focus({ preventScroll: true });
2953    } catch (_focusErr) {
2954      target.focus();
2955    }
2956    if (typeof snapshot.selectionStart === 'number' && typeof snapshot.selectionEnd === 'number' && typeof target.setSelectionRange === 'function') {
2957      try {
2958        target.setSelectionRange(snapshot.selectionStart, snapshot.selectionEnd, snapshot.selectionDirection || 'none');
2959      } catch (_selectionErr) {
2960        // Ignore selection restore errors.
2961      }
2962    }
2963    try {
2964      target.scrollTop = Number(snapshot.scrollTop) || 0;
2965      target.scrollLeft = Number(snapshot.scrollLeft) || 0;
2966    } catch (_scrollErr) {
2967      // Ignore scroll restore errors.
2968    }
2969  }
2970
2971  function renderComposeUiPreserveFieldFocus() {
2972    var focusSnapshot = captureComposeFieldFocus();
2973    renderComposeUi();
2974    if (focusSnapshot) {
2975      restoreComposeFieldFocus(focusSnapshot);
2976    }
2977  }
2978
2979  function toastHost() {
2980    var host = document.getElementById('nav-top-toast-host');
2981    if (host) {
2982      return host;
2983    }
2984    if (!document || !document.body) {
2985      return null;
2986    }
2987    host = document.createElement('div');
2988    host.id = 'nav-top-toast-host';
2989    host.className = 'nav-top-toast-host';
2990    document.body.appendChild(host);
2991    return host;
2992  }
2993
2994  function showTopToast(message, tone, ttlMs) {
2995    var text = String(message || '').trim();
2996    if (!text) {
2997      return;
2998    }
2999    var host = toastHost();
3000    if (!host) {
3001      return;
3002    }
3003    var kind = String(tone || '').trim().toLowerCase();
3004    var safeTone = (kind === 'ok' || kind === 'warn' || kind === 'error') ? kind : '';
3005    var toast = document.createElement('div');
3006    toast.className = 'nav-top-toast' + (safeTone ? (' is-' + safeTone) : '');
3007    toast.textContent = text;
3008    host.appendChild(toast);
3009    requestAnimationFrame(function () {
3010      toast.classList.add('is-visible');
3011    });
3012    var stayMs = Math.max(1800, Number(ttlMs) || 3200);
3013    window.setTimeout(function () {
3014      toast.classList.remove('is-visible');
3015      toast.classList.add('is-closing');
3016      window.setTimeout(function () {
3017        if (toast.parentNode) {
3018          toast.parentNode.removeChild(toast);
3019        }
3020      }, 240);
3021    }, stayMs);
3022  }
3023
3024  function queueComposeAutosave() {
3025    if (!isAdmin() || !state.compose.open) {
3026      return;
3027    }
3028    if (state.compose.autosaveTimer) {
3029      clearTimeout(state.compose.autosaveTimer);
3030    }
3031    state.compose.saveStatus = 'saving';
3032    renderComposeStatusOnly();
3033    state.compose.autosaveTimer = setTimeout(function () {
3034      state.compose.autosaveTimer = null;
3035      autosaveCompose();
3036    }, 500);
3037  }
3038
3039  function flushComposeAutosaveNow() {
3040    if (!isAdmin() || !state.compose.open) {
3041      return;
3042    }
3043    if (state.compose.autosaveTimer) {
3044      clearTimeout(state.compose.autosaveTimer);
3045      state.compose.autosaveTimer = null;
3046    }
3047    autosaveCompose();
3048  }
3049
3050  function afterComposePublishSuccess() {
3051    state.compose.draftId = '';
3052    state.compose.sourcePostPath = '';
3053    state.compose.postTypeLocked = false;
3054    state.compose.postTypeChosen = false;
3055    state.compose.saveStatus = '';
3056    state.compose.uploading = 0;
3057    state.compose.postType = 'longform';
3058    state.compose.tagsOpen = false;
3059    state.compose.tagsDraftText = '';
3060    state.compose.publishDestination = 'local_only';
3061    state.compose.shortformLimitEditing = false;
3062    state.compose.linkUrl = '';
3063    state.compose.pendingContentAdditions = [];
3064    clearComposeUploadItems();
3065    stopComposeCameraStream();
3066    stopComposeAudioStream();
3067    setComposeTags([]);
3068    var fields = readComposeFields();
3069    if (!fields || !els.composeSlot) {
3070      return;
3071    }
3072    var title = els.composeSlot.querySelector('[data-compose-field="title"]');
3073    var content = els.composeSlot.querySelector('[data-compose-field="content"]');
3074    var schedule = els.composeSlot.querySelector('[data-compose-field="scheduled-at"]');
3075    var immediateMode = els.composeSlot.querySelector('input[name="blog-inline-compose-mode"][value="immediate"]');
3076    if (title instanceof HTMLInputElement) {
3077      title.value = '';
3078    }
3079    if (content instanceof HTMLTextAreaElement) {
3080      content.value = '';
3081    }
3082    if (schedule instanceof HTMLInputElement) {
3083      schedule.value = '';
3084    }
3085    if (immediateMode instanceof HTMLInputElement) {
3086      immediateMode.checked = true;
3087    }
3088    var localOnlyTarget = els.composeSlot.querySelector('input[name="blog-inline-compose-destination"][value="local_only"]');
3089    if (localOnlyTarget instanceof HTMLInputElement) {
3090      localOnlyTarget.checked = true;
3091    }
3092  }
3093
3094  function saveCompose(action) {
3095    if (state.compose.busy || !isAdmin()) {
3096      return;
3097    }
3098    var auth = window.__wizardryAuthPayload();
3099    if (!auth.session_token || !auth.csrf_token) {
3100      setComposeOutput('Sign in again to compose posts.', 'error');
3101      renderComposeStatusOnly();
3102      return;
3103    }
3104    var payload = composePayload(action);
3105    if (!payload) {
3106      return;
3107    }
3108    if (action === 'publish_now' && !payload.content.trim()) {
3109      setComposeOutput('Cannot publish an empty post.', 'warn');
3110      renderComposeStatusOnly();
3111      return;
3112    }
3113    if (action === 'queue_scheduled' && !payload.scheduled_at) {
3114      setComposeOutput('Scheduled posts need a release date/time.', 'warn');
3115      renderComposeStatusOnly();
3116      return;
3117    }
3118    payload.session_token = auth.session_token;
3119    payload.csrf_token = auth.csrf_token;
3120    state.compose.busy = true;
3121    state.compose.saveStatus = action === 'autosave' ? 'saving' : '';
3122    renderComposeStatusOnly();
3123    apiPost('/cgi/blog-save-post', payload).then(function (data) {
3124      if (data && data.draft_id) {
3125        state.compose.draftId = String(data.draft_id);
3126      }
3127      if (action === 'autosave') {
3128        state.compose.saveStatus = 'saved';
3129        renderComposeStatusOnly();
3130        return;
3131      }
3132      if (action === 'publish_now') {
3133        setComposeOutput('', '');
3134        showTopToast('Published. Rebuild may take a few seconds.', 'ok', 2600);
3135        afterComposePublishSuccess();
3136        toggleComposeFromUi(false);
3137      } else {
3138        setComposeOutput((data && data.message) || 'Saved.', 'ok');
3139      }
3140      renderComposeUi();
3141      loadPageState();
3142      loadDraftNoticeData();
3143      setTimeout(function () { loadPageState(); }, 2500);
3144    }).catch(function (err) {
3145      var message = err && err.message ? err.message : 'Save failed';
3146      if (action === 'autosave') {
3147        state.compose.saveStatus = '';
3148        renderComposeStatusOnly();
3149        return;
3150      }
3151      setComposeOutput('Error: ' + message, 'error');
3152      renderComposeStatusOnly();
3153    }).finally(function () {
3154      state.compose.busy = false;
3155      renderComposeStatusOnly();
3156    });
3157  }
3158
3159  function clearComposeFields() {
3160    state.compose.draftId = '';
3161    state.compose.sourcePostPath = '';
3162    state.compose.postTypeLocked = false;
3163    state.compose.postTypeChosen = false;
3164    state.compose.saveStatus = '';
3165    state.compose.uploading = 0;
3166    state.compose.postType = 'longform';
3167    state.compose.tagsOpen = false;
3168    state.compose.tagsDraftText = '';
3169    state.compose.publishDestination = 'local_only';
3170    state.compose.shortformLimitEditing = false;
3171    state.compose.linkUrl = '';
3172    clearComposeUploadItems();
3173    stopComposeCameraStream();
3174    stopComposeAudioStream();
3175    setComposeOutput('', '');
3176    seedComposeDefaultTag(true);
3177    if (!els.composeSlot) {
3178      renderComposeStatusOnly();
3179      return;
3180    }
3181    var title = els.composeSlot.querySelector('[data-compose-field="title"]');
3182    var content = els.composeSlot.querySelector('[data-compose-field="content"]');
3183    var schedule = els.composeSlot.querySelector('[data-compose-field="scheduled-at"]');
3184    var immediateMode = els.composeSlot.querySelector('input[name="blog-inline-compose-mode"][value="immediate"]');
3185    if (title instanceof HTMLInputElement) {
3186      title.value = '';
3187    }
3188    if (content instanceof HTMLTextAreaElement) {
3189      content.value = '';
3190    }
3191    if (schedule instanceof HTMLInputElement) {
3192      schedule.value = '';
3193    }
3194    if (immediateMode instanceof HTMLInputElement) {
3195      immediateMode.checked = true;
3196    }
3197    var localOnlyTarget = els.composeSlot.querySelector('input[name="blog-inline-compose-destination"][value="local_only"]');
3198    if (localOnlyTarget instanceof HTMLInputElement) {
3199      localOnlyTarget.checked = true;
3200    }
3201    renderComposeUi();
3202  }
3203
3204  function deleteComposeDraft() {
3205    var fields = readComposeFields() || { title: '', content: '' };
3206    var hasTitle = composePostType() === 'shortform' ? false : !!String(fields.title || '').trim();
3207    var hasContent = hasTitle ||
3208      !!String(fields.content || '').trim() ||
3209      !!String(fields.linkUrl || '').trim() ||
3210      state.compose.tags.length > 0 ||
3211      !!state.compose.draftId;
3212    if (hasContent && !window.confirm('Delete this draft?')) {
3213      return;
3214    }
3215    if (!state.compose.draftId) {
3216      clearComposeFields();
3217      return;
3218    }
3219    var auth = window.__wizardryAuthPayload();
3220    if (!auth.session_token || !auth.csrf_token) {
3221      setComposeOutput('Sign in again to delete draft.', 'error');
3222      renderComposeStatusOnly();
3223      return;
3224    }
3225    state.compose.busy = true;
3226    renderComposeStatusOnly();
3227    apiPost('/cgi/blog-delete-draft', {
3228      draft_id: String(state.compose.draftId || ''),
3229      session_token: auth.session_token,
3230      csrf_token: auth.csrf_token
3231    }).then(function () {
3232      clearComposeFields();
3233      setComposeOutput('Draft deleted.', 'ok');
3234      renderComposeStatusOnly();
3235      loadDraftNoticeData();
3236    }).catch(function (err) {
3237      setComposeOutput('Error: ' + ((err && err.message) ? err.message : 'Delete failed'), 'error');
3238      renderComposeStatusOnly();
3239    }).finally(function () {
3240      state.compose.busy = false;
3241      renderComposeStatusOnly();
3242    });
3243  }
3244
3245  function autosaveCompose() {
3246    var payload = composePayload('autosave');
3247    if (!payload) {
3248      return;
3249    }
3250    if (!payload.title.trim() && !payload.content.trim()) {
3251      state.compose.saveStatus = '';
3252      renderComposeStatusOnly();
3253      return;
3254    }
3255    saveCompose('autosave');
3256  }
3257
3258  function setComposeOpen(open) {
3259    ensureComposeStateShape();
3260    state.compose.open = !!open;
3261    if (!state.compose.open) {
3262      clearComposePostTypeCollapseTimer();
3263      state.compose.postTypeToolbarCollapsed = false;
3264      state.compose.postTypeLocked = false;
3265      state.compose.sourcePostPath = '';
3266      state.compose.cameraFullscreen = false;
3267      state.compose.cameraReturnToChooser = false;
3268      clearComposeUploadItems();
3269    } else {
3270      state.compose.postTypeToolbarCollapsed = false;
3271      state.compose.postTypeChosen = false;
3272      state.compose.postTypeLocked = false;
3273      state.compose.sourcePostPath = '';
3274      state.compose.cameraFullscreen = false;
3275      state.compose.cameraReturnToChooser = false;
3276      clearComposeUploadItems();
3277      if (!state.compose.draftId && !state.compose.sourcePostPath) {
3278        seedComposeDefaultTag(false);
3279      }
3280    }
3281    try {
3282      renderComposeUi();
3283      renderDraftNotice();
3284    } catch (err) {
3285      state.compose.open = false;
3286      clearComposePostTypeCollapseTimer();
3287      stopComposeCameraStream();
3288      stopComposeAudioStream();
3289      if (window && window.console && typeof window.console.error === 'function') {
3290        window.console.error('Compose render failed', err);
3291      }
3292      if (els.composeSlot) {
3293        els.composeSlot.hidden = true;
3294        els.composeSlot.classList.remove('is-open');
3295        els.composeSlot.innerHTML = '';
3296      }
3297      if (els.composeFab) {
3298        els.composeFab.classList.remove('is-open');
3299        els.composeFab.setAttribute('aria-expanded', 'false');
3300        els.composeFab.setAttribute('aria-pressed', 'false');
3301        els.composeFab.setAttribute('aria-label', 'Compose');
3302      }
3303      setComposeOutput('Compose failed to open. Reload and try again.', 'error');
3304      return;
3305    }
3306    if (state.compose.open && els.composeSlot) {
3307      setTimeout(function () {
3308        if (els.composeSlot && typeof els.composeSlot.scrollIntoView === 'function') {
3309          try {
3310            els.composeSlot.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
3311          } catch (_err) {
3312            els.composeSlot.scrollIntoView(true);
3313          }
3314        }
3315        var focusTarget = els.composeSlot.querySelector('[data-compose-action="toggle-post-type-toolbar"]');
3316        if (!state.compose.postTypeChosen) {
3317          focusTarget = els.composeSlot.querySelector('[data-compose-post-type]');
3318        } else if (!focusTarget) {
3319          focusTarget = composePostType() === 'shortform'
3320            ? els.composeSlot.querySelector('[data-compose-field="content"]')
3321            : els.composeSlot.querySelector('[data-compose-field="title"]');
3322        }
3323        if (focusTarget && typeof focusTarget.focus === 'function') {
3324          focusTarget.focus();
3325        }
3326      }, 30);
3327    }
3328  }
3329
3330  function toggleComposeFromUi(nextOpen) {
3331    var now = Date.now();
3332    if (now < composeToggleGuardUntil) {
3333      return;
3334    }
3335    composeToggleGuardUntil = now + 120;
3336    setComposeOpen(!!nextOpen);
3337  }
3338
3339  function parseComposeTags(raw) {
3340    return String(raw || '')
3341      .split(',')
3342      .map(function (part) { return normalizeTagValue(part); })
3343      .filter(Boolean);
3344  }
3345
3346  function openComposeDraftInPlace(draftId, options) {
3347    var opts = options || {};
3348    var id = String(draftId || '').trim();
3349    if (!id) {
3350      return Promise.reject(new Error('Draft id is missing.'));
3351    }
3352    var auth = window.__wizardryAuthPayload();
3353    if (!auth.session_token || !auth.csrf_token) {
3354      return Promise.reject(new Error('Sign in as admin first.'));
3355    }
3356    return apiPost('/cgi/blog-get-draft', {
3357      draft_id: id,
3358      session_token: auth.session_token,
3359      csrf_token: auth.csrf_token
3360    }).then(function (data) {
3361      if (!data || !data.success || !data.draft) {
3362        throw new Error((data && data.error) ? data.error : 'Could not load draft');
3363      }
3364      var draft = data.draft || {};
3365      ensureComposeStateShape();
3366      state.compose.open = true;
3367      state.compose.preview = false;
3368      state.compose.busy = false;
3369      state.compose.saveStatus = '';
3370      state.compose.output = '';
3371      state.compose.outputTone = '';
3372      state.compose.draftId = String(draft.draft_id || id).trim();
3373      state.compose.sourcePostPath = String(draft.source_post_path || '').trim();
3374      state.compose.postType = normalizeComposePostType(draft.post_type || 'longform');
3375      state.compose.postTypeChosen = true;
3376      state.compose.postTypeLocked = !!opts.lockPostType;
3377      state.compose.postTypeToolbarCollapsed = true;
3378      state.compose.tagsOpen = false;
3379      state.compose.tagsDraftText = '';
3380      state.compose.linkUrl = '';
3381      state.compose.shortformLimitEditing = false;
3382      state.compose.publishDestination = 'local_only';
3383      stopComposeCameraStream();
3384      stopComposeAudioStream();
3385      setComposeTags(parseComposeTags(draft.tags || ''));
3386      if (els.composeSlot) {
3387        els.composeSlot.innerHTML = '';
3388      }
3389      renderComposeUi();
3390      renderDraftNotice();
3391      if (els.composeSlot) {
3392        var titleInput = els.composeSlot.querySelector('[data-compose-field="title"]');
3393        var contentInput = els.composeSlot.querySelector('[data-compose-field="content"]');
3394        var linkUrlInput = els.composeSlot.querySelector('[data-compose-field="link-url"]');
3395        var scheduleInput = els.composeSlot.querySelector('[data-compose-field="scheduled-at"]');
3396        var loadedContent = String(draft.content || '');
3397        if (state.compose.postType === 'link-share') {
3398          var parsedLink = parseComposeLinkShareContent(loadedContent);
3399          state.compose.linkUrl = parsedLink.url;
3400          loadedContent = parsedLink.body;
3401        }
3402        if (titleInput instanceof HTMLInputElement) {
3403          titleInput.value = String(draft.title || '');
3404        }
3405        if (contentInput instanceof HTMLTextAreaElement) {
3406          contentInput.value = loadedContent;
3407        }
3408        if (linkUrlInput instanceof HTMLInputElement) {
3409          linkUrlInput.value = String(state.compose.linkUrl || '');
3410        }
3411        if (scheduleInput instanceof HTMLInputElement) {
3412          scheduleInput.value = composeNormalizeScheduledDate(String(draft.scheduled_at_local || ''));
3413        }
3414        refreshComposeShortformMeterFromFields();
3415      }
3416      if (els.composeSlot && typeof els.composeSlot.scrollIntoView === 'function') {
3417        try {
3418          els.composeSlot.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
3419        } catch (_err) {
3420          els.composeSlot.scrollIntoView(true);
3421        }
3422      }
3423      window.setTimeout(function () {
3424        if (!els.composeSlot) {
3425          return;
3426        }
3427        var postType = normalizeComposePostType(state.compose.postType);
3428        var focusInput = postType === 'shortform'
3429          ? els.composeSlot.querySelector('[data-compose-field="content"]')
3430          : els.composeSlot.querySelector('[data-compose-field="title"]');
3431        if (focusInput && typeof focusInput.focus === 'function') {
3432          focusInput.focus();
3433          if (focusInput.setSelectionRange) {
3434            var length = String(focusInput.value || '').length;
3435            focusInput.setSelectionRange(length, length);
3436          }
3437        }
3438      }, 30);
3439      return true;
3440    });
3441  }
3442
3443  function composeToolbarAction(action) {
3444    if (!els.composeSlot) {
3445      return;
3446    }
3447    var textarea = els.composeSlot.querySelector('[data-compose-field="content"]');
3448    if (!(textarea instanceof HTMLTextAreaElement)) {
3449      return;
3450    }
3451    function placeCursor(start, end) {
3452      textarea.focus();
3453      textarea.setSelectionRange(start, end);
3454    }
3455    function applyEdit(start, end, nextText, cursorStart, cursorEnd) {
3456      var replacement = String(nextText || '');
3457      var selStart = Math.max(0, Number(start) || 0);
3458      var selEnd = Math.max(selStart, Number(end) || selStart);
3459      if (typeof textarea.setRangeText === 'function') {
3460        textarea.focus();
3461        textarea.setRangeText(replacement, selStart, selEnd, 'preserve');
3462      } else {
3463        var currentValue = String(textarea.value || '');
3464        textarea.value = currentValue.slice(0, selStart) + replacement + currentValue.slice(selEnd);
3465      }
3466      placeCursor(selStart + (Number(cursorStart) || 0), selStart + (Number(cursorEnd) || 0));
3467    }
3468    function replaceSelection(transformer) {
3469      var start = textarea.selectionStart;
3470      var end = textarea.selectionEnd;
3471      var selected = textarea.value.slice(start, end);
3472      var updated = transformer(selected);
3473      applyEdit(start, end, updated.text, updated.cursorStart, updated.cursorEnd);
3474    }
3475    function replaceSelectedLines(transformer) {
3476      var value = textarea.value;
3477      var selStart = textarea.selectionStart;
3478      var selEnd = textarea.selectionEnd;
3479      var lineStart = value.lastIndexOf('\n', Math.max(0, selStart - 1)) + 1;
3480      var lineEndIdx = value.indexOf('\n', selEnd);
3481      var lineEnd = lineEndIdx === -1 ? value.length : lineEndIdx;
3482      var source = value.slice(lineStart, lineEnd);
3483      var lines = source.split('\n');
3484      var result = transformer(lines);
3485      if (!result || !Array.isArray(result.lines)) {
3486        return;
3487      }
3488      var next = result.lines.join('\n');
3489      applyEdit(lineStart, lineEnd, next, 0, next.length);
3490    }
3491
3492    if (action === 'bold' || action === 'italic' || action === 'code') {
3493      var token = action === 'bold' ? '**' : (action === 'italic' ? '*' : '`');
3494      replaceSelection(function (selected) {
3495        var s = selected || 'text';
3496        if (s.startsWith(token) && s.endsWith(token)) {
3497          var unwrapped = s.slice(token.length, s.length - token.length);
3498          return { text: unwrapped, cursorStart: 0, cursorEnd: unwrapped.length };
3499        }
3500        var wrapped = token + s + token;
3501        return { text: wrapped, cursorStart: token.length, cursorEnd: token.length + s.length };
3502      });
3503    } else if (action === 'h2' || action === 'h3') {
3504      var heading = action === 'h2' ? '## ' : '### ';
3505      replaceSelectedLines(function (lines) {
3506        var line = lines[0] || '';
3507        var stripped = line.replace(/^#{1,6}\s+/, '');
3508        lines[0] = line.startsWith(heading) ? stripped : (heading + stripped);
3509        return { lines: lines };
3510      });
3511    } else if (action === 'quote' || action === 'ul') {
3512      var prefix = action === 'quote' ? '> ' : '- ';
3513      replaceSelectedLines(function (lines) {
3514        var nonEmpty = lines.filter(function (line) { return line.trim() !== ''; });
3515        var allHave = nonEmpty.length > 0 && nonEmpty.every(function (line) {
3516          return line.startsWith(prefix);
3517        });
3518        return {
3519          lines: lines.map(function (line) {
3520            if (line.trim() === '') {
3521              return line;
3522            }
3523            if (allHave) {
3524              return line.startsWith(prefix) ? line.slice(prefix.length) : line;
3525            }
3526            return prefix + line;
3527          })
3528        };
3529      });
3530    } else if (action === 'ol') {
3531      replaceSelectedLines(function (lines) {
3532        var nonEmpty = lines.filter(function (line) { return line.trim() !== ''; });
3533        var allOrdered = nonEmpty.length > 0 && nonEmpty.every(function (line) {
3534          return /^\d+\.\s+/.test(line);
3535        });
3536        var index = 1;
3537        return {
3538          lines: lines.map(function (line) {
3539            if (line.trim() === '') {
3540              return line;
3541            }
3542            if (allOrdered) {
3543              return line.replace(/^\d+\.\s+/, '');
3544            }
3545            var text = line.replace(/^\d+\.\s+/, '').replace(/^-+\s+/, '');
3546            var out = index + '. ' + text;
3547            index += 1;
3548            return out;
3549          })
3550        };
3551      });
3552    } else if (action === 'code_block') {
3553      replaceSelection(function (selected) {
3554        var source = selected || '';
3555        if (/^```[\s\S]*```$/.test(source.trim())) {
3556          var unwrapped = source.trim().replace(/^```[\n]?/, '').replace(/\n?```$/, '');
3557          return { text: unwrapped, cursorStart: 0, cursorEnd: unwrapped.length };
3558        }
3559        var wrapped = '```\n' + source + '\n```';
3560        return { text: wrapped, cursorStart: 4, cursorEnd: wrapped.length - 4 };
3561      });
3562    } else if (action === 'link') {
3563      replaceSelection(function (selected) {
3564        var label = selected || 'link text';
3565        var text = '[' + label + '](https://)';
3566        var start = text.indexOf('https://');
3567        return { text: text, cursorStart: start, cursorEnd: start + 8 };
3568      });
3569    }
3569 else if (action === 'image') {
3570      replaceSelection(function (selected) {
3571        var label = selected || 'image';
3572        var text = '![' + label + '](https://)';
3573        var start = text.indexOf('https://');
3574        return { text: text, cursorStart: start, cursorEnd: start + 8 };
3575      });
3576    } else if (action === 'audio_record') {
3577      if (composePostType() !== 'audio-note') {
3578        setComposePostType('audio-note', { skipAutosave: true, interactive: true });
3579        return;
3580      }
3581      if (state.compose.audioRecording) {
3582        stopComposeAudioRecording();
3583      } else {
3584        startComposeAudioRecording();
3585      }
3586      return;
3587    } else if (action === 'attachment') {
3588      openComposePickerForType('attachment');
3589      return;
3590    }
3591
3592    queueComposeAutosave();
3593    renderComposeUi();
3594  }
3595
3596  function renderComposeUi() {
3597    ensureComposeStateShape();
3598    ensureComposeHosts();
3599    if (!els.composeSlot || !els.composeFab) {
3600      renderComposeCameraOverlay();
3601      return;
3602    }
3603    var admin = isAdmin();
3604    els.composeFab.hidden = !admin;
3605    if (!admin) {
3606      state.compose.open = false;
3607      applyComposeModeEffects('longform');
3608      els.composeSlot.hidden = true;
3609      els.composeSlot.classList.remove('is-open');
3610      els.composeSlot.innerHTML = '';
3611      renderComposeCameraOverlay();
3612      return;
3613    }
3614    els.composeFab.classList.toggle('is-open', state.compose.open);
3615    els.composeFab.setAttribute('aria-expanded', state.compose.open ? 'true' : 'false');
3616    els.composeFab.setAttribute('aria-pressed', state.compose.open ? 'true' : 'false');
3617    els.composeFab.setAttribute('aria-label', state.compose.open ? 'Close compose' : 'Compose');
3618    if (!state.compose.open) {
3619      applyComposeModeEffects('longform');
3620      els.composeSlot.classList.remove('is-open');
3621      setTimeout(function () {
3622        if (!state.compose.open && els.composeSlot) {
3623          els.composeSlot.hidden = true;
3624          els.composeSlot.innerHTML = '';
3625        }
3626      }, 300);
3627      renderComposeCameraOverlay();
3628      return;
3629    }
3630    var composeSlotWasVisible = !els.composeSlot.hidden && els.composeSlot.classList.contains('is-open');
3631    function setComposeSlotOpenHeight() {
3632      if (!els.composeSlot) {
3633        return;
3634      }
3635      var measuredHeight = Math.ceil(els.composeSlot.scrollHeight || 0);
3636      var openHeight = Math.max(120, measuredHeight + 8);
3637      els.composeSlot.style.setProperty('--compose-slot-open-max-height', String(openHeight) + 'px');
3638    }
3639    function openComposeSlotAnimated() {
3640      if (!els.composeSlot) {
3641        return;
3642      }
3643      setComposeSlotOpenHeight();
3644      if (composeSlotWasVisible) {
3645        els.composeSlot.classList.add('is-open');
3646        return;
3647      }
3648      els.composeSlot.classList.remove('is-open');
3649      // Force Firefox to commit the collapsed style before opening.
3650      void els.composeSlot.offsetHeight;
3651      requestAnimationFrame(function () {
3652        if (els.composeSlot && state.compose.open) {
3653          setComposeSlotOpenHeight();
3654          els.composeSlot.classList.add('is-open');
3655        }
3656      });
3657    }
3658
3659    var fields = readComposeFields() || {
3660      title: '',
3661      content: '',
3662      scheduledAt: '',
3663      tags: state.compose.tags.join(','),
3664      linkUrl: String(state.compose.linkUrl || ''),
3665      postType: composePostType()
3666    };
3667    fields.tags = state.compose.tags.join(',');
3668    var mode = composePublishMode();
3669    var destination = composePublishDestination();
3670    var postType = normalizeComposePostType(fields.postType);
3671    if (Array.isArray(state.compose.pendingContentAdditions) && state.compose.pendingContentAdditions.length) {
3672      var pendingJoined = state.compose.pendingContentAdditions
3673        .map(function (item) { return String(item || '').trim(); })
3674        .filter(Boolean)
3675        .join('\n\n');
3676      if (pendingJoined) {
3677        fields.content = String(fields.content || '').trim()
3678          ? (String(fields.content).replace(/\s*$/, '') + '\n\n' + pendingJoined)
3679          : pendingJoined;
3680      }
3681      state.compose.pendingContentAdditions = [];
3682    }
3683    var showTitleField = postType !== 'shortform';
3684    if (postType !== 'shortform') {
3685      state.compose.shortformLimitEditing = false;
3686    }
3687    state.compose.postType = postType;
3688    state.compose.publishDestination = destination;
3689    state.compose.linkUrl = String(fields.linkUrl || '');
3690    var postTypeLocked = !!state.compose.postTypeLocked;
3691    var waitingForPostType = !state.compose.postTypeChosen && !postTypeLocked;
3692    if (waitingForPostType) {
3693      var chooseHeadRowClass = 'field-row blog-compose-head-row is-type-picker-only';
3694      var chooseTypeControlClass = 'compose-post-type-control';
3695      els.composeSlot.hidden = false;
3696      els.composeSlot.innerHTML = '' +
3697        '<article class="post-item blog-post-item blog-compose-card blog-compose-type-only">' +
3698          '<div class="blog-compose-body">' +
3699            '<div class="blog-compose-type-title"><strong>Compose New Post</strong></div>' +
3700            '<div class="' + chooseHeadRowClass + '" data-compose-head-row>' +
3701              '<div class="' + chooseTypeControlClass + ' is-choose-mode" data-compose-type-control>' +
3702                '<div class="compose-post-type-toolbar-wrap"><div class="compose-post-type-row">' + composeTypeButtonsHtml('', { lockAll: postTypeLocked, noActive: true }) + '</div></div>' +
3703              '</div>' +
3704            '</div>' +
3705            '<input type="file" data-compose-field="capture-upload" data-compose-upload="capture-media" accept="image/*,video/*" capture="environment" multiple hidden>' +
3706            '<input type="file" data-compose-field="media-upload" data-compose-upload="upload-media" accept="image/*,video/*" multiple hidden>' +
3707          '</div>' +
3708        '</article>';
3709      openComposeSlotAnimated();
3710      renderComposeCameraOverlay();
3711      return;
3712    }
3713    if (postType === 'shortform') {
3714      enforceComposeShortformLimitOnFields(fields);
3715      var shortLimit = currentComposeShortformLimit();
3716      if (String(fields.content || '').length > shortLimit) {
3717        fields.content = String(fields.content || '').slice(0, shortLimit);
3718      }
3719    }
3720    var previewContent = fields.content;
3721    if (postType === 'link-share') {
3722      var linkPreview = composeBuildLinkMarkdown(fields.linkUrl, fields.content, fields.title);
3723      if (linkPreview) {
3724        previewContent = linkPreview;
3725      }
3726    }
3727    var previewTitle = showTitleField ? fields.title : '';
3728    var previewHtml = renderComposePreviewHtml(previewTitle, previewContent);
3729    var contentLabel = 'Body';
3730    var contentPlaceholder = 'Post body';
3731    if (postType === 'shortform') {
3732      contentPlaceholder = 'Write a short post...';
3733    } else if (postType === 'link-share') {
3734      contentPlaceholder = 'Optional commentary...';
3735    } else if (postType === 'capture-media') {
3736      contentPlaceholder = 'Optional caption for captured media...';
3737    } else if (postType === 'upload-media') {
3738      contentPlaceholder = 'Optional caption for uploaded media...';
3739    } else if (postType === 'attachment') {
3740      contentPlaceholder = 'Optional note for attached files...';
3741    } else if (postType === 'audio-note') {
3742      contentPlaceholder = 'Optional note for uploaded audio...';
3743    }
3744    var titlePlaceholder = 'Post title';
3745    if (postType === 'shortform') {
3746      titlePlaceholder = 'Short post';
3747    } else if (postType === 'link-share') {
3748      titlePlaceholder = 'Link title (optional)';
3749    } else if (postType === 'capture-media' || postType === 'upload-media') {
3750      titlePlaceholder = 'Media title (optional)';
3751    }
3752    var mediaToolsHtml = composeModePanelHtml(postType, fields);
3753    var forcePreview = postType === 'capture-media' || postType === 'upload-media';
3754    var showPreview = !!state.compose.preview || forcePreview;
3755    var previewIslandLayout = showPreview;
3756    var headRowClass = 'field-row blog-compose-head-row' + (state.compose.postTypeToolbarCollapsed ? ' is-type-collapsed' : '');
3757    var typeControlClass = state.compose.postTypeToolbarCollapsed ? 'compose-post-type-control is-collapsed' : 'compose-post-type-control';
3758    var shortform = postType === 'shortform';
3759    var autosaveClass = 'autosave-indicator compose-editor-autosave' +
3760      (state.compose.saveStatus === 'saving' ? ' is-saving' : '') +
3761      (state.compose.saveStatus === 'error' ? ' is-error' : '') +
3762      (shortform ? ' is-shortform-corner' : '');
3763    var shortformCornerHtml = shortform
3764      ? ('<div class="blog-compose-shortform-corner">' +
3765          '<div class="' + autosaveClass + '"' + (state.compose.saveStatus ? '' : ' hidden') + '>
3765' + (state.compose.saveStatus === 'saving' ? 'Saving...' : (state.compose.saveStatus === 'error' ? 'Save failed' : '✓ Saved')) + '</div>' +
3766          composeShortformMeterHtml(fields.content) +
3767        '</div>')
3768      : '';
3769    var editorBlockHtml = '' +
3770      '<div class="field-row">' +
3771        '<label><strong>' + escapeHtml(contentLabel) + '</strong></label>' +
3772        '<div class="editor-shell blog-compose-editor-shell">' +
3773          composeToolbarHtml() +
3774          '<textarea data-compose-field="content" rows="' + String(composeTextareaRows(postType)) + '"' + (postType === 'shortform' ? (' maxlength="' + escapeHtml(String(currentComposeShortformLimit())) + '"') : '') + ' placeholder="' + escapeHtml(contentPlaceholder) + '">' + escapeHtml(fields.content) + '</textarea>' +
3775          (shortform
3776            ? shortformCornerHtml
3777            : ('<div class="' + autosaveClass + '"' + (state.compose.saveStatus ? '' : ' hidden') + '>' + (state.compose.saveStatus === 'saving' ? 'Saving...' : (state.compose.saveStatus === 'error' ? 'Save failed' : '✓ Saved')) + '</div>')) +
3778        '</div>' +
3779        composeUploadProgressHtml() +
3780      '</div>';
3781    var contentPaneHtml = editorBlockHtml;
3782    var outputClass = 'output';
3783    if (state.compose.outputTone) {
3784      outputClass += ' ' + state.compose.outputTone;
3785    }
3786    var composeCardClass = 'post-item blog-post-item blog-compose-card';
3787    var previewActionLabel = 'Preview';
3788    var previewToggleTitle = state.compose.preview ? 'Preview is on' : 'Preview';
3789    var previewTogglePressed = state.compose.preview ? 'true' : 'false';
3790    var previewToggleClass = 'unobtrusive-icon-button blog-compose-preview-toggle' + (state.compose.preview ? ' is-active' : '');
3791    var previewToggleHtml = '<button type="button" class="' + previewToggleClass + '" data-compose-action="toggle-preview" aria-label="' + previewActionLabel + '" aria-pressed="' + previewTogglePressed + '" title="' + previewToggleTitle + '">' + composePreviewToggleIconSvg() + '<span class="sr-only">' + previewActionLabel + '</span></button>';
3792    var postKindFooterHtml = composeNostrPillsHtml(postType) + (postTypeLocked ? '<span class="nostr-target-pill">Post type locked</span>' : '');
3793    var composeCardHtml = '' +
3794      '<article class="' + composeCardClass + '">' +
3795        '<div class="blog-compose-body">' +
3796          '<div class="' + headRowClass + '" data-compose-head-row>' +
3797            '<div class="' + typeControlClass + '" data-compose-type-control>' +
3798              '<button type="button" class="compose-post-type-current-btn unobtrusive-icon-button"' + (postTypeLocked ? ' disabled aria-disabled="true" aria-label="Post type is locked for existing posts" title="Post type is locked for existing posts"' : ' data-compose-action="toggle-post-type-toolbar" aria-label="Choose post type" title="Choose post type"') + '>' + composePostTypeIconSvg(composeBackingPostType(postType)) + '</button>' +
3799              '<div class="compose-post-type-toolbar-wrap"><div class="compose-post-type-row">' + composeTypeButtonsHtml(postType, { lockAll: postTypeLocked }) + '</div></div>' +
3800            '</div>' +
3801            '<div class="blog-compose-head-actions">' +
3802              previewToggleHtml +
3803              '<button type="button" class="unobtrusive-icon-button blog-compose-close" data-compose-action="close-compose" aria-label="Close compose" title="Close compose">' + composeCloseIconSvg() + '<span class="sr-only">Close compose</span></button>' +
3804            '</div>' +
3805          '</div>' +
3806          '<div class="compose-nostr-target-row">' + postKindFooterHtml + '</div>' +
3807          '<div class="field-row blog-compose-title-row"' + (showTitleField ? '' : ' hidden aria-hidden="true"') + '>' +
3808            '<label><strong>Title</strong></label>' +
3809            '<input type="text" data-compose-field="title" placeholder="' + escapeHtml(titlePlaceholder) + '" value="' + escapeHtml(fields.title) + '">' +
3810          '</div>' +
3811          mediaToolsHtml +
3812          contentPaneHtml +
3813          '<input type="file" data-compose-field="capture-upload" data-compose-upload="capture-media" accept="image/*,video/*" capture="environment" multiple hidden>' +
3814          '<input type="file" data-compose-field="media-upload" data-compose-upload="upload-media" accept="image/*,video/*" multiple hidden>' +
3815          '<input type="file" data-compose-field="file-upload" data-compose-upload="attachment" multiple hidden>' +
3816          '<input type="file" data-compose-field="audio-upload" data-compose-upload="audio-note" accept="audio/*" multiple hidden>' +
3817          '<div class="grid-two">' +
3818            '<div class="field-row">' +
3819              '<label><strong>Tags</strong></label>' +
3820              '<input type="hidden" data-compose-field="tags" value="' + escapeHtml(fields.tags) + '">' +
3821              '<div class="tag-editor' + (state.compose.tags.length ? ' has-tags' : '') + '" role="group" aria-label="Post tags">' +
3822                composeTagsEditorHtml() +
3823              '</div>' +
3824            '</div>' +
3825          '</div>' +
3826          '<div class="field-row compose-release-row">' +
3827            '<strong>When</strong>' +
3828            '<div class="mode-row">' +
3829              '<label><input type="radio" name="blog-inline-compose-mode" value="immediate"' + (mode === 'immediate' ? ' checked' : '') + '> Now</label>' +
3830              '<label><input type="radio" name="blog-inline-compose-mode" value="drip"' + (mode === 'drip' ? ' checked' : '') + '> Queue</label>' +
3831              '<label><input type="radio" name="blog-inline-compose-mode" value="scheduled"' + (mode === 'scheduled' ? ' checked' : '') + '> Schedule...</label>' +
3832            '</div>' +
3833            '<div class="compose-scheduled-value" data-compose-scheduled-value' + (mode === 'scheduled' ? '' : ' hidden') + '>
3833' + escapeHtml(composeScheduledDisplayValue(fields.scheduledAt)) + '</div>' +
3834            '<input type="date" class="compose-scheduled-picker-hidden" data-compose-field="scheduled-at" value="' + escapeHtml(composeNormalizeScheduledDate(fields.scheduledAt)) + '" aria-label="Scheduled release date">' +
3835          '</div>' +
3836          '<div class="field-row compose-destination-row">' +
3837              composePublishDestinationFieldHtml(destination) +
3838          '</div>' +
3839        '</div>' +
3840        '<div class="compose-footer blog-compose-footer">' +
3841          '<div class="compose-actions blog-compose-footer-actions">' +
3842            '<button type="button" class="icon-danger unobtrusive-icon-button blog-compose-delete" data-compose-action="delete" aria-label="Delete draft" title="Delete draft"' + (state.compose.busy ? ' disabled aria-disabled="true"' : '') + '>' + composeTrashIconSvg() + '</button>' +
3843            '<div class="blog-compose-publish-stack">' +
3844              '<div class="blog-compose-publish-row">' + postKindFooterHtml + '<button type="button" class="list-admin-primary-btn blog-compose-btn" data-compose-action="publish"' + (state.compose.busy ? ' disabled aria-disabled="true"' : '') + '>' + escapeHtml(composePrimaryLabel(mode, destination)) + '</button></div>' +
3845            '</div>' +
3846          '</div>' +
3847          '<div class="blog-compose-status-row">' +
3848            '<div class="' + outputClass + '">' + escapeHtml(state.compose.output) + '</div>' +
3849          '</div>' +
3850        '</div>' +
3851      '</article>';
3852    var previewCardHtml = previewIslandLayout
3853      ? '<article class="post-item blog-post-item blog-compose-preview-card"><div class="blog-compose-preview-card-body"><label><strong>Preview</strong></label><div class="preview-box blog-compose-preview">' + previewHtml + '</div></div></article>'
3854      : '';
3855
3856    els.composeSlot.hidden = false;
3857    els.composeSlot.innerHTML = previewIslandLayout
3858      ? ('<div class="blog-compose-islands">' + composeCardHtml + previewCardHtml + '</div>')
3859      : composeCardHtml;
3860    hydrateComposeTagsEditor();
3861    openComposeSlotAnimated();
3862    requestAnimationFrame(function () {
3863      applyComposeModeEffects(postType);
3864    });
3865    renderComposeCameraOverlay();
3866  }
3867
3868  function renderComposeStatusOnly() {
3869    if (!els.composeSlot || !state.compose.open) {
3870      return;
3871    }
3872    var mode = composePublishMode();
3873    var destination = composePublishDestination();
3874    state.compose.publishDestination = destination;
3875    var publishBtn = els.composeSlot.querySelector('[data-compose-action="publish"]');
3876    if (publishBtn instanceof HTMLButtonElement) {
3877      publishBtn.textContent = composePrimaryLabel(mode, destination);
3878      publishBtn.disabled = !!state.compose.busy || state.compose.uploading > 0;
3879    }
3880    var output = els.composeSlot.querySelector('.output');
3881    if (output) {
3882      output.textContent = state.compose.output || '';
3883      output.className = 'output' + (state.compose.outputTone ? (' ' + state.compose.outputTone) : '');
3884    }
3885    renderComposeUploadProgressOnly();
3886    var scheduleValue = els.composeSlot.querySelector('[data-compose-scheduled-value]');
3887    if (scheduleValue) {
3888      scheduleValue.hidden = mode !== 'scheduled';
3889      scheduleValue.textContent = composeScheduledDisplayValue((readComposeFields() || {}).scheduledAt || '');
3890    }
3891    var autosave = els.composeSlot.querySelector('.compose-editor-autosave');
3892    if (autosave) {
3893      var modeStatus = String(state.compose.saveStatus || '');
3894      if (state.compose.uploading > 0) {
3895        autosave.hidden = false;
3896        autosave.classList.remove('is-error');
3897        autosave.classList.add('is-saving');
3898        autosave.textContent = 'Uploading...';
3899        return;
3900      }
3901      autosave.hidden = !modeStatus;
3902      autosave.classList.toggle('is-saving', modeStatus === 'saving');
3903      autosave.classList.toggle('is-error', modeStatus === 'error');
3904      autosave.textContent = modeStatus === 'saving' ? 'Saving...' : (modeStatus === 'error' ? 'Save failed' : '✓ Saved');
3905    }
3906  }
3907
3908  function renderExtrasAfter() {
3909    if (!els.content) {
3910      return;
3911    }
3912    window.__wizardryRenderResolvedReadOnlyContent({
3913      targetNode: els.content,
3914      payload: state.payload
3915    });
3916  }
3917
3918  function matchFacet(set, value) {
3919    if (!set || !set.size) {
3920      return true;
3921    }
3922    return set.has(String(value || ''));
3923  }
3924
3925  function matchTags(postTags) {
3926    if (!state.filters.tags.size) {
3927      return true;
3928    }
3929    var tags = normalizePostTags(postTags);
3930    for (var i = 0; i < tags.length; i += 1) {
3931      if (state.filters.tags.has(String(tags[i]))) {
3932        return true;
3933      }
3934    }
3935    return false;
3936  }
3937
3938  function normalizePostTags(raw) {
3939    if (Array.isArray(raw)) {
3940      return raw.map(function (tag) {
3941        return String(tag || '').trim();
3942      }).filter(Boolean);
3943    }
3944    if (typeof raw === 'string') {
3945      return raw.split(',').map(function (tag) {
3946        return String(tag || '').trim();
3947      }).filter(Boolean);
3948    }
3949    return [];
3950  }
3951
3952  function filteredPosts() {
3953    return state.posts.filter(function (post) {
3954      return matchTags(post.tags) &&
3955        matchFacet(state.filters.years, post.year) &&
3956        matchFacet(state.filters.types, post.type);
3957    });
3958  }
3959
3960  function renderedPostCardHtml(post) {
3961    return String(post && post.rendered_html || '').trim();
3962  }
3963
3964  function postListSignature() {
3965    return JSON.stringify(filteredPosts().map(renderedPostCardHtml).filter(Boolean));
3966  }
3967
3968  function managedBlogFilterCatalogSignature(posts) {
3969    return JSON.stringify((Array.isArray(posts) ? posts : []).map(function (post) {
3970      var tags = Array.isArray(post && post.tags)
3971        ? post.tags.map(function (tag) {
3972            return String(tag || '').trim();
3973          }).filter(Boolean)
3974        : (typeof (post && post.tags) === 'string'
3975            ? String(post.tags).split(',').map(function (tag) {
3976                return String(tag || '').trim();
3977              }).filter(Boolean)
3978            : []);
3979      return {
3980        tags: tags,
3981        year: String(post && post.year || ''),
3982        type: String(post && post.type || 'post')
3983      };
3984    }).sort(function (a, b) {
3985      var left = JSON.stringify(a);
3986      var right = JSON.stringify(b);
3987      return left.localeCompare(right);
3988    }));
3989  }
3990
3991  function renderManagedBlogFilterFragments(payload) {
3992    if (!payload || !els.tags || !els.years || !els.types) {
3993      return false;
3994    }
3995    return mountPrerenderHtmlFragment(els.tags, payload, 'filter_tags', { allowEmpty: true }) &&
3996      mountPrerenderHtmlFragment(els.years, payload, 'filter_years', { allowEmpty: true }) &&
3997      mountPrerenderHtmlFragment(els.types, payload, 'filter_types', { allowEmpty: true });
3998  }
3999
4000  function syncManagedBlogFilterPanelState() {
4001    [els.tags || null, els.years || null, els.types || null].forEach(function (host) {
4002      if (!host) {
4003        return;
4004      }
4005      host.querySelectorAll('[data-filter-group][data-filter-value]').forEach(function (button) {
4006        var group = String(button.getAttribute('data-filter-group') || '');
4007        var value = String(button.getAttribute('data-filter-value') || '');
4008        var filterSet = state.filters[group];
4009        var active = !!(filterSet && filterSet.has && filterSet.has(value));
4010        button.classList.toggle('is-active', active);
4011        button.setAttribute('aria-pressed', active ? 'true' : 'false');
4012      });
4013    });
4014  }
4015
4016  function renderManagedBlogFilterPanel() {
4017    if (!els.tags || !els.years || !els.types) {
4018      return false;
4019    }
4020    var prerenderedPosts = readPrerenderPostsBootstrap(state.payload);
4021    if (state.payload && prerenderedPosts &&
4022        managedBlogFilterCatalogSignature(state.posts) === managedBlogFilterCatalogSignature(prerenderedPosts) &&
4023        renderManagedBlogFilterFragments(state.payload)) {
4024      syncManagedBlogFilterPanelState();
4025      return true;
4026    }
4027    syncManagedBlogFilterPanelState();
4028    return false;
4029  }
4030
4031  function renderFilters() {
4032    if (!els.tags || !els.years || !els.types) {
4033      return;
4034    }
4035    renderManagedBlogFilterPanel();
4036  }
4037
4038  function renderList() {
4039    if (!els.list || !els.empty) {
4040      return;
4041    }
4042
4043    if (state.postsLoading && !state.posts.length) {
4044      els.list.innerHTML = '';
4045      els.empty.hidden = true;
4046      return;
4047    }
4048
4049    var shown = filteredPosts();
4050    var hasFilters = state.filters.tags.size || state.filters.years.size || state.filters.types.size;
4051
4052    if (!shown.length) {
4053      els.list.innerHTML = '';
4054      if (!state.posts.length && !hasFilters) {
4055        if (!postsCatalogReady) {
4056          els.empty.hidden = true;
4057          return;
4058        }
4059        els.empty.textContent = 'No posts to show yet.';
4060      } else {
4061        els.empty.textContent = 'No posts match these filters.';
4062      }
4063      els.empty.hidden = false;
4064      return;
4065    }
4066
4067    els.empty.hidden = true;
4068    els.list.innerHTML = shown.map(renderedPostCardHtml).filter(Boolean).join('');
4069    syncRenderedListDecorations();
4070  }
4071
4072  function postCardMenuHtml(postPath) {
4073    var normalizedPath = String(postPath || '').trim();
4074    if (!normalizedPath) {
4075      return '';
4076    }
4077    return '' +
4078      '<div class="post-page-menu">' +
4079        '<button type="button" class="post-page-menu-trigger" data-post-card-menu-toggle="' + escapeHtml(normalizedPath) + '" aria-label="Post menu" aria-haspopup="menu" aria-expanded="false">' + overflowMenuIconSvg() + '</button>' +
4080        '<div class="post-page-menu-panel" role="menu" hidden>' +
4081          '<button type="button" data-post-card-action="edit_post" data-post-path="' + escapeHtml(normalizedPath) + '" role="menuitem">Edit post...</button>' +
4082          '<button type="button" class="post-page-menu-delete" data-post-card-action="delete_post" data-post-path="' + escapeHtml(normalizedPath) + '" role="menuitem">Delete post...</button>' +
4083        '</div>' +
4084      '</div>';
4085  }
4086
4087  function syncRenderedPostCardMenus() {
4088    if (!els.list) {
4089      return;
4090    }
4091    var cards = els.list.querySelectorAll('.blog-post-item');
4092    cards.forEach(function (card) {
4093      var existing = card.querySelector('.post-page-menu');
4094      var postPath = String(card.getAttribute('data-post-path') || '').trim();
4095      if (!isAdmin() || !postPath) {
4096        if (existing && existing.parentNode) {
4097          existing.parentNode.removeChild(existing);
4098        }
4099        return;
4100      }
4101      if (!existing) {
4102        var head = card.querySelector('.post-head');
4103        if (!head) {
4104          return;
4105        }
4106        head.insertAdjacentHTML('beforeend', postCardMenuHtml(postPath));
4107      }
4108    });
4109  }
4110
4111  function syncRenderedListInlineFilterState() {
4112    if (!els.list) {
4113      return;
4114    }
4115    els.list.querySelectorAll('[data-inline-tag]').forEach(function (button) {
4116      var value = String(button.getAttribute('data-inline-tag') || '');
4117      var active = state.filters.tags.has(value);
4118      button.classList.toggle('is-active', active);
4119      button.setAttribute('aria-pressed', active ? 'true' : 'false');
4120    });
4121    els.list.querySelectorAll('[data-inline-filter-group][data-inline-filter-value]').forEach(function (button) {
4122      var group = String(button.getAttribute('data-inline-filter-group') || '');
4123      var value = String(button.getAttribute('data-inline-filter-value') || '');
4124      var filterSet = state.filters[group];
4125      var active = !!(filterSet && filterSet.has(value));
4126      button.classList.toggle('is-active', active);
4127      button.setAttribute('aria-pressed', active ? 'true' : 'false');
4128    });
4129  }
4130
4131  function syncRenderedListDecorations() {
4132    syncRenderedPostCardMenus();
4133    syncRenderedListInlineFilterState();
4134  }
4135
4136  function closePostCardMenus() {
4137    if (!els.list) {
4138      return;
4139    }
4140    var panels = els.list.querySelectorAll('.post-page-menu-panel');
4141    panels.forEach(function (panel) {
4142      panel.hidden = true;
4143    });
4144    var triggers = els.list.querySelectorAll('.post-page-menu-trigger[data-post-card-menu-toggle]');
4145    triggers.forEach(function (trigger) {
4146      trigger.setAttribute('aria-expanded', 'false');
4147    });
4148  }
4149
4150  function togglePostCardMenu(trigger) {
4151    if (!(trigger instanceof HTMLElement)) {
4152      return;
4153    }
4154    var wrap = trigger.closest('.post-page-menu');
4155    if (!wrap) {
4156      return;
4157    }
4158    var panel = wrap.querySelector('.post-page-menu-panel');
4159    if (!(panel instanceof HTMLElement)) {
4160      return;
4161    }
4162    var shouldOpen = !!panel.hidden;
4163    closePostCardMenus();
4164    panel.hidden = !shouldOpen;
4165    trigger.setAttribute('aria-expanded', shouldOpen ? 'true' : 'false');
4166  }
4167
4168  function runPostCardAction(action, postPath) {
4169    var picked = String(action || '').trim();
4170    var path = String(postPath || '').trim();
4171    if (!picked || !path || postCardMenuBusy) {
4172      return;
4173    }
4174    if (!isAdmin()) {
4175      window.alert('Sign in as admin first.');
4176      return;
4177    }
4178    var auth = window.__wizardryAuthPayload();
4179    if (!auth.session_token || !auth.csrf_token) {
4180      window.alert('Sign in as admin first.');
4181      return;
4182    }
4183    if (picked === 'edit_post') {
4184      postCardMenuBusy = true;
4185      apiPost('/cgi/blog-create-draft-from-post', {
4186        post_path: path,
4187        session_token: auth.session_token,
4188        csrf_token: auth.csrf_token
4189      }).then(function (data) {
4190        var draftId = String((data && data.draft_id) || '').trim();
4191        if (!draftId) {
4192          throw new Error('Draft was created but no draft id was returned');
4193        }
4194        closePostCardMenus();
4195        return openComposeDraftInPlace(draftId, { lockPostType: true });
4196      }).catch(function (err) {
4197        window.alert(err && err.message ? err.message : 'Could not create draft from post');
4198      }).finally(function () {
4199        postCardMenuBusy = false;
4200      });
4201      return;
4202    }
4203    if (picked === 'delete_post') {
4204      if (!window.confirm('Delete this published post from this site? This cannot be undone.')) {
4205        return;
4206      }
4207      postCardMenuBusy = true;
4208      apiPost('/cgi/blog-manage-post', {
4209        action: 'delete',
4210        post_path: path,
4211        session_token: auth.session_token,
4212        csrf_token: auth.csrf_token
4213      }).then(function () {
4214        state.posts = (Array.isArray(state.posts) ? state.posts : []).filter(function (post) {
4215          return String(post && (post.source_path || post.post_path || post.path) || '').trim() !== path;
4216        });
4217        renderList();
4218        updateRenderSignature();
4219        loadPageState();
4220      }).catch(function (err) {
4221        window.alert(err && err.message ? err.message : 'Delete failed');
4222      }).finally(function () {
4223        postCardMenuBusy = false;
4224      });
4225    }
4226  }
4227
4228  function renderAdminChromeUi() {
4229    renderAdmin();
4230    renderDraftNotice();
4231    renderValidation();
4232    renderExtrasAfter();
4233    renderComposeUi();
4234    syncRenderedPostCardMenus();
4235  }
4236
4237  function renderPreservedPostListShell() {
4238    renderHead();
4239    renderAdminChromeUi();
4240    renderFilters();
4241    syncRenderedListInlineFilterState();
4242  }
4243
4244  function renderAll() {
4245    renderHead();
4246    renderAdminChromeUi();
4247    renderFilters();
4248    renderList();
4249    updateRenderSignature();
4250  }
4251
4252  function toggleFilter(group, value, multi) {
4253    var key = String(group || '');
4254    var target = state.filters[key];
4255    if (!target) {
4256      return;
4257    }
4258    var normalized = String(value || '');
4259    if (!normalized) {
4260      return;
4261    }
4262
4263    if (multi) {
4264      if (target.has(normalized)) {
4265        target.delete(normalized);
4266      } else {
4267        target.add(normalized);
4268      }
4269    } else {
4270      if (target.size === 1 && target.has(normalized)) {
4271        target.clear();
4272      } else {
4273        target.clear();
4274        target.add(normalized);
4275      }
4276    }
4277    renderFilters();
4278    renderList();
4279    updateRenderSignature();
4280  }
4281
4282  function clearFilters() {
4283    state.filters.tags.clear();
4284    state.filters.years.clear();
4285    state.filters.types.clear();
4286    renderFilters();
4287    renderList();
4288    updateRenderSignature();
4289  }
4290
4291  function applyDefaultFilters() {
4292    if (state.defaultFiltersApplied) {
4293      return;
4294    }
4295    var page = getRenderState();
4296    var defaultTag = String(page.default_tag || '').trim();
4297    if (!defaultTag) {
4298      state.defaultFiltersApplied = true;
4299      return;
4300    }
4301    if (!state.filters.tags.size && !state.filters.years.size && !state.filters.types.size) {
4302      state.filters.tags.add(defaultTag);
4303    }
4304    state.defaultFiltersApplied = true;
4305  }
4306
4307  function setPanelOpen(open) {
4308    if (!els.panel || !els.toggle) {
4309      return;
4310    }
4311    var isOpen = !!open;
4312    els.toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
4313    if (panelHideTimer) {
4314      window.clearTimeout(panelHideTimer);
4315      panelHideTimer = null;
4316    }
4317
4318    if (isOpen) {
4319      var wasHidden = !!els.panel.hidden;
4320      els.panel.hidden = false;
4321      if (wasHidden) {
4322        els.panel.classList.remove('is-open');
4323        void els.panel.offsetHeight;
4324        var finishOpen = function () {
4325          if (els.toggle.getAttribute('aria-expanded') === 'true') {
4326            els.panel.classList.add('is-open');
4327          }
4328        };
4329        window.requestAnimationFrame(finishOpen);
4330        window.setTimeout(finishOpen, 32);
4331      } else {
4332        els.panel.classList.add('is-open');
4333      }
4334      return;
4335    }
4336
4337    els.panel.classList.remove('is-open');
4338    panelHideTimer = window.setTimeout(function () {
4339      panelHideTimer = null;
4340      if (!els.panel.classList.contains('is-open')) {
4341        els.panel.hidden = true;
4342      }
4343    }, 420);
4344  }
4345
4346  function readCache() {
4347    var parsed = readTimedCacheRecord(CACHE_KEY, POSTS_CACHE_MAX_AGE_MS);
4348    if (!parsed) {
4349      return null;
4350    }
4351    try {
4352      if (Array.isArray(parsed.posts)) {
4353        if (!postsHaveRenderedHtml(parsed.posts)) {
4354          removeTimedCacheRecord(CACHE_KEY);
4355          return null;
4356        }
4357        return parsed.posts;
4358      }
4359    } catch (_err) {
4360      return null;
4361    }
4362    return null;
4363  }
4364
4365  function writeCache(posts) {
4366    writeTimedCacheRecord(CACHE_KEY, {
4367      posts: posts || []
4368    });
4369  }
4370
4371  function isExpectedPayload(payload) {
4372    var payloadType = String(payload && payload.page_type || '').trim().toLowerCase();
4373    var payloadSlug = String(payload && payload.slug || '').trim();
4374    return payloadType === pageType && !!payloadSlug && managedSlugsEquivalent(payloadSlug, slug);
4375  }
4376
4377  function readPrerenderPostsBootstrap(payload) {
4378    if (!payload || typeof payload !== 'object') {
4379      return null;
4380    }
4381    if (!Array.isArray(payload.bootstrap_posts)) {
4382      return null;
4383    }
4384    if (!postsHaveRenderedHtml(payload.bootstrap_posts)) {
4385      return null;
4386    }
4387    return filteredPostsForPageDefaults(payload.bootstrap_posts, payload);
4388  }
4389
4390  function applyBootstrapState(optimisticPayload) {
4391    state.payload = optimisticPayload;
4392    applyDefaultFilters();
4393  }
4394
4395  function applyPayloadBootstrapPosts(payload) {
4396    var prerenderedPosts = readPrerenderPostsBootstrap(payload);
4397    if (!prerenderedPosts) {
4398      return false;
4399    }
4400    postsCatalogReady = true;
4401    state.posts = prerenderedPosts;
4402    if (pageType !== 'publication' && !payloadHasDefaultPostTag(payload)) {
4403      writeCache(prerenderedPosts);
4404    }
4405    return true;
4406  }
4407
4408  function primeBootstrapPosts(payload) {
4409    if (applyPayloadBootstrapPosts(payload)) {
4410      return true;
4411    }
4412    var cached = readCache();
4413    if (!cached) {
4414      return false;
4415    }
4416    postsCatalogReady = true;
4417    state.posts = filteredPostsForPageDefaults(cached, payload);
4418    return true;
4419  }
4420
4421  function canUsePrerenderReadOnlyView(payload) {
4422    return window.__wizardryCanUsePayloadPrerenderReadOnly(payload, {
4423      allowAuthenticatedField: 'viewer_authenticated'
4424    });
4425  }
4426
4427  function shouldPreserveAdoptedStaticPrerender() {
4428    return state.staticPrerenderListAdopted &&
4429      canUsePrerenderReadOnlyView(state.payload) &&
4430      state.adoptedPostListSignature === postListSignature();
4431  }
4432
4433  function renderBootstrapPayload(payload, hasWarmPosts) {
4434    return window.__wizardryRenderManagedNostrPageBootstrapPayload(payload, {
4435      state: state,
4436      rootNode: root,
4437      contentNode: els.list,
4438      markPageReady: true,
4439      isExpectedPayload: isExpectedPayload,
4440      applyState: applyBootstrapState,
4441      canAdoptStaticPrerender: function (optimisticPayload) {
4442        return canUsePrerenderReadOnlyView(optimisticPayload);
4443      },
4444      renderAdoptedPrerender: function () {
4445        state.postsLoading = !hasWarmPosts;
4446        state.staticPrerenderListAdopted = true;
4447        state.adoptedPostListSignature = postListSignature();
4448        renderPreservedPostListShell();
4449      },
4450      renderAll: function () {
4451        state.postsLoading = !hasWarmPosts;
4452        state.staticPrerenderListAdopted = false;
4453        renderAll();
4454      },
4455      afterRender: function () {
4456        state.renderSignature = renderSignature();
4457      }
4458    });
4459  }
4460
4461  function normalizedPayloadForRenderSignature() {
4462    if (!state.payload) {
4463      return null;
4464    }
4465    return {
4466      slug: state.payload.slug || '',
4467      page_type: state.payload.page_type || '',
4468      nav_title: state.payload.nav_title || '',
4469      kind: state.payload.kind || '',
4470      is_admin: !!state.payload.is_admin,
4471      view_mode: state.payload.view_mode || '',
4472      canonical_exists: !!state.payload.canonical_exists,
4473      draft_exists: !!state.payload.draft_exists,
4474      draft_differs: !!state.payload.draft_differs,
4475      sync_status: state.payload.sync_status || null,
4476      state: state.payload.state || null,
4477      validation: state.payload.validation || null
4478    };
4479  }
4480
4481	  function renderSignature() {
4482	    function sortedSet(setObj) {
4483	      return Array.from(setObj || []).sort();
4484	    }
4485	    return JSON.stringify({
4486	      payload: normalizedPayloadForRenderSignature(),
4487	      isAdmin: isAdmin(),
4488	      postList: postListSignature(),
4489	      filters: {
4490	        tags: sortedSet(state.filters.tags),
4491	        years: sortedSet(state.filters.years),
4492	        types: sortedSet(state.filters.types)
4493	      }
4494	    });
4495	  }
4496
4497	  function updateRenderSignature() {
4498	    state.renderSignature = renderSignature();
4499	    return state.renderSignature;
4500	  }
4501
4502	  function renderFetchedPageStateIfChanged(previousSignature) {
4503	    var nextSignature = renderSignature();
4504	    return window.__wizardryReconcileManagedPageRefreshRender({
4505	      state: state,
4506	      previousSignature: previousSignature,
4507	      nextRenderSignature: nextSignature,
4508	      renderPreserved: renderPreservedPostListShell,
4509	      renderAll: renderAll
4510	    });
4511	  }
4512
4513  function loadPageState(options) {
4514    var opts = options || {};
4515    var expectedSlug = managedSlugFromPath(window.location.pathname || '/');
4516    var requestedSlug = expectedSlug || slug;
4517    if (managedSlugsEquivalent(expectedSlug, slug) && normalizeManagedSlug(expectedSlug) === 'index' && normalizeManagedSlug(slug) === 'blog') {
4518      requestedSlug = slug;
4519    }
4520    if (expectedSlug && !managedSlugsEquivalent(expectedSlug, slug)) {
4521      maybeRepairRoute('blog-root-slug-mismatch');
4522      return Promise.resolve();
4523    }
4524    state.authSignature = window.__wizardryViewerAuthSignature();
4525    function hasExpectedSlug(payload) {
4526      if (!payload || typeof payload !== 'object') {
4527        return false;
4528      }
4529      return managedSlugsEquivalent(String(payload.slug || '').trim(), slug);
4530    }
4531    function applyFetchedPageState(data) {
4532      var pageType = String(data && data.page_type || '').trim().toLowerCase();
4533      if (pageType && pageType !== String(root.getAttribute('data-page-type') || 'blog').trim().toLowerCase()) {
4534        maybeRepairRoute('blog-template-page-type-mismatch:' + pageType);
4535        return;
4536      }
4537      var previousSignature = state.renderSignature;
4538      state.payload = data;
4539      try {
4540        if (typeof data.is_admin !== 'undefined') {
4541          localStorage.setItem('last_auth_is_admin', data.is_admin ? '1' : '0');
4542        }
4543      } catch (_storageErr) {
4544        // Ignore storage sync failures.
4545      }
4546      applyPayloadBootstrapPosts(data);
4547      applyDefaultFilters();
4548      if (!opts.deferRender) {
4549        renderFetchedPageStateIfChanged(previousSignature);
4550      }
4551    }
4552    var refreshOptions = {
4553      slug: slug,
4554      pageSlug: requestedSlug,
4555      authSignature: window.__wizardryViewerAuthSignature,
4556      isExpectedPayload: hasExpectedSlug,
4557      allowWrite: isExpectedPayload,
4558      unexpectedPayloadMessage: 'Unexpected page payload for blog page',
4559      renderPayload: applyFetchedPageState,
4560      handleFailurePayload: function (payload) {
4561        if (payload && payload.success === false && String(payload.code || '') === 'unknown_page') {
4562          maybeRepairRoute('unknown-page-slug');
4563          if (!opts.deferRender) {
4564            renderFetchedPageStateIfChanged(state.renderSignature);
4565          }
4566          return true;
4567        }
4568        return false;
4569      },
4570      handleError: function () {
4571        if (!opts.deferRender) {
4572          renderFetchedPageStateIfChanged(state.renderSignature);
4573        }
4574      },
4575      consoleLabel: 'Blog page',
4576      state: state,
4577      contentNode: els.list,
4578      fallbackText: 'Page content is still loading. The latest blog data was not available yet.',
4579      rootNode: root,
4580      markPageReady: true,
4581      onFinally: function () {
4582        loadDraftNoticeData();
4583      }
4584    };
4585    if (opts.deferRender) {
4586      refreshOptions.notifyInitialContentPainted = false;
4587    }
4588    return window.__wizardryRefreshManagedNostrPageView(refreshOptions);
4589  }
4590
4591  function applyOptimisticAdminFromAuthEvent(event) {
4592    if (!window.__wizardryAuthEventIsLikelyAdmin(event) || !state.payload || state.payload.is_admin) {
4593      return;
4594    }
4595    state.payload = Object.assign({}, state.payload, { is_admin: true });
4596    renderAdmin();
4597    renderValidation();
4598    renderComposeUi();
4599    syncRenderedPostCardMenus();
4600    updateRenderSignature();
4601  }
4602
4603  root.addEventListener('click', function (event) {
4604    var target = eventTargetElement(event.target);
4605    var draftBannerAction = target ? target.closest('[data-draft-banner-action]') : null;
4606    if (draftBannerAction) {
4607      event.preventDefault();
4608      event.stopPropagation();
4609      var draftBannerActionName = String(draftBannerAction.getAttribute('data-draft-banner-action') || '');
4610      if (draftBannerActionName === 'continue') {
4611        openDraftFromNotice(String(draftBannerAction.getAttribute('data-draft-id') || ''));
4612      } else if (draftBannerActionName === 'dismiss') {
4613        state.draftNotice.dismissedSignature = draftNoticeSignature(state.draftNotice.drafts);
4614        renderDraftNotice();
4615      }
4616      return;
4617    }
4618    var postCardMenuTrigger = target ? target.closest('.post-page-menu-trigger[data-post-card-menu-toggle]') : null;
4619    if (postCardMenuTrigger) {
4620      event.preventDefault();
4621      togglePostCardMenu(postCardMenuTrigger);
4622      return;
4623    }
4624
4625    var postCardMenuAction = target ? target.closest('[data-post-card-action]') : null;
4626    if (postCardMenuAction instanceof HTMLElement) {
4627      event.preventDefault();
4628      closePostCardMenus();
4629      runPostCardAction(
4630        String(postCardMenuAction.getAttribute('data-post-card-action') || ''),
4631        String(postCardMenuAction.getAttribute('data-post-path') || '')
4632      );
4633      return;
4634    }
4635
4636    if (!target || !target.closest('.post-page-menu')) {
4637      closePostCardMenus();
4638    }
4639
4640    var composeFab = target ? target.closest('[data-blog-action="toggle-compose"]') : null;
4641    if (composeFab) {
4642      event.preventDefault();
4643      event.stopPropagation();
4644      toggleComposeFromUi(!state.compose.open);
4645      return;
4646    }
4647
4648    var composeTagToken = target ? target.closest('[data-compose-tag-token]') : null;
4649    if (composeTagToken instanceof HTMLElement) {
4650      event.preventDefault();
4651      event.stopPropagation();
4652      var tokenEditor = composeTagsEditorNode();
4653      if (tokenEditor && tokenEditor.contains(composeTagToken)) {
4654        composeTagsEditorSelectToken(tokenEditor, composeTagToken);
4655      }
4656      return;
4657    }
4658
4659    var composeTagEditor = target ? target.closest('[data-compose-field="tags-editor"]') : null;
4660    if (composeTagEditor instanceof HTMLElement) {
4661      composeTagsEditorClearSelection(composeTagEditor);
4662      try {
4663        composeTagEditor.focus({ preventScroll: true });
4664      } catch (_focusErr) {
4665        composeTagEditor.focus();
4666      }
4667      if (!composeTagsEditorPlaceCaretFromPoint(composeTagEditor, Number(event.clientX) || 0, Number(event.clientY) || 0)) {
4668        var draftNode = composeTagsEditorDraftNode(composeTagEditor);
4669        if (target === composeTagEditor || !(draftNode && draftNode.contains(target))) {
4670          composeTagsEditorFocusDraft(composeTagEditor);
4671        }
4672      }
4673      setTimeout(function () {
4674        if (document.activeElement === composeTagEditor) {
4675          composeTagsEditorSyncDraft(composeTagEditor);
4676        }
4677      }, 0);
4678    }
4679
4680    var composeAction = target ? target.closest('[data-compose-action]') : null;
4681    if (composeAction) {
4682      event.preventDefault();
4683      var actionName = String(composeAction.getAttribute('data-compose-action') || '');
4684      if (actionName === 'toggle-preview') {
4685        state.compose.preview = !state.compose.preview;
4686        renderComposeUi();
4687        return;
4688      }
4689      if (actionName === 'close-compose') {
4690        toggleComposeFromUi(false);
4691        return;
4692      }
4693      if (actionName === 'set-post-type') {
4694        if (state.compose.postTypeLocked) {
4695          return;
4696        }
4697        setComposePostType(String(composeAction.getAttribute('data-compose-post-type') || ''), { interactive: true, skipAutosave: true });
4698        return;
4699      }
4700      if (actionName === 'toggle-post-type-toolbar') {
4701        if (state.compose.postTypeLocked) {
4702          return;
4703        }
4704        if (!state.compose.postTypeChosen) {
4705          setComposePostTypeToolbarCollapsed(false);
4706          return;
4707        }
4708        if (state.compose.postTypeToolbarCollapsed) {
4709          setComposePostTypeToolbarCollapsed(false);
4710        } else {
4711          setComposePostTypeToolbarCollapsed(true);
4712        }
4713        return;
4714      }
4715      if (actionName === 'publish') {
4716        saveCompose(composeModeAction(composePublishMode(), composePublishDestination()));
4717        return;
4718      }
4719      if (actionName === 'delete') {
4720        deleteComposeDraft();
4721        return;
4722      }
4723      if (actionName === 'remove-tag') {
4724        removeComposeTag(String(composeAction.getAttribute('data-compose-tag') || ''));
4725        renderComposeUi();
4726        return;
4727      }
4728      if (actionName === 'toggle-tags') {
4729        state.compose.tagsOpen = !state.compose.tagsOpen;
4730        renderComposeUi();
4731        return;
4732      }
4733      if (actionName === 'open-mode-picker') {
4734        openComposePickerForType(String(composeAction.getAttribute('data-compose-mode-target') || composePostType()));
4735        return;
4736      }
4737      if (actionName === 'open-camera-capture') {
4738        openComposeCameraCapture({ returnToChooser: false });
4739        return;
4740      }
4741      if (actionName === 'cancel-camera-capture') {
4742        closeComposeCameraCapture({});
4743        return;
4744      }
4745      if (actionName === 'capture-photo') {
4746        capturePhotoFromComposeCamera();
4747        return;
4748      }
4749      if (actionName === 'audio-record-toggle') {
4750        if (state.compose.audioRecording) {
4751          stopComposeAudioRecording();
4752        } else {
4753          startComposeAudioRecording();
4754        }
4755        return;
4756      }
4757      if (actionName === 'shortform-limit-toggle') {
4758        if (event.detail > 1) {
4759          return;
4760        }
4761        if (composePostType() !== 'shortform' || state.compose.shortformLimitEditing) {
4762          return;
4763        }
4764        var currentLimit = currentComposeShortformLimit();
4765        setComposeShortformLimit(currentLimit === 280 ? 140 : 280, { editing: false, skipAutosave: true });
4766        return;
4767      }
4768      if (actionName === 'pick-capture-media' || actionName === 'pick-upload-media' || actionName === 'pick-upload-file' || actionName === 'pick-upload-audio') {
4769        var field = '';
4770        var type = '';
4771        if (actionName === 'pick-capture-media') {
4772          field = 'capture-upload';
4773          type = 'capture-media';
4774        } else if (actionName === 'pick-upload-media') {
4775          field = 'media-upload';
4776          type = 'upload-media';
4777        } else if (actionName === 'pick-upload-file') {
4778          field = 'file-upload';
4779          type = 'attachment';
4780        } else {
4781          type = 'audio-note';
4782          setComposePostType(type, { skipAutosave: true, interactive: true });
4783          return;
4784        }
4785        setComposePostType(type, { skipAutosave: true, skipRender: true });
4786        var picker = els.composeSlot.querySelector('[data-compose-field="' + field + '"]');
4787        if (picker instanceof HTMLInputElement) {
4788          picker.click();
4789        }
4790        return;
4791      }
4792    }
4793    var scheduledModeClick = target ? target.closest('input[name="blog-inline-compose-mode"][value="scheduled"]') : null;
4794    if (scheduledModeClick instanceof HTMLInputElement) {
4795      setTimeout(openComposeScheduledPicker, 0);
4796    }
4797
4798    var composeToolbar = target ? target.closest('[data-compose-toolbar]') : null;
4799    if (composeToolbar) {
4800      event.preventDefault();
4801      composeToolbarAction(String(composeToolbar.getAttribute('data-compose-toolbar') || ''));
4802      return;
4803    }
4804
4805    var toggle = target ? target.closest('[data-filter-group][data-filter-value]') : null;
4806    if (toggle) {
4807      event.preventDefault();
4808      toggleFilter(
4809        toggle.getAttribute('data-filter-group'),
4810        toggle.getAttribute('data-filter-value'),
4811        !!(event.metaKey || event.ctrlKey)
4812      );
4813      return;
4814    }
4815
4816    var inlineTag = target ? target.closest('[data-inline-tag]') : null;
4817    if (inlineTag) {
4818      event.preventDefault();
4819      setPanelOpen(true);
4820      toggleFilter('tags', inlineTag.getAttribute('data-inline-tag'), !!(event.metaKey || event.ctrlKey));
4821      return;
4822    }
4823
4824    var inlineFilter = target ? target.closest('[data-inline-filter-group][data-inline-filter-value]') : null;
4825    if (inlineFilter) {
4826      event.preventDefault();
4827      setPanelOpen(true);
4828      toggleFilter(
4829        inlineFilter.getAttribute('data-inline-filter-group'),
4830        inlineFilter.getAttribute('data-inline-filter-value'),
4831        !!(event.metaKey || event.ctrlKey)
4832      );
4833      return;
4834    }
4835
4836    var action = target ? target.closest('[data-blog-action]') : null;
4837    if (action) {
4838      event.preventDefault();
4839      if (action.getAttribute('data-blog-action') === 'toggle-page-settings') {
4840        setPageSettingsOpen(!state.pageSettingsOpen);
4841      } else if (action.getAttribute('data-blog-action') === 'save-publication') {
4842        savePublicationDraft();
4843      } else if (action.getAttribute('data-blog-action') === 'publish-publication') {
4844        publishPublication();
4845      }
4846    }
4847  });
4848
4849  root.addEventListener('change', function (event) {
4850    var target = event.target;
4851    if (!(target instanceof HTMLSelectElement)) {
4852      return;
4853    }
4854    if (!target.matches('[data-blog-setting="default-tag"]')) {
4855      return;
4856    }
4857    var picked = String(target.value || '').trim();
4858    if (state.payload && state.payload.state) {
4859      state.payload.state.default_tag = picked;
4860    }
4861    state.defaultFiltersApplied = true;
4862    state.filters.tags.clear();
4863    state.filters.years.clear();
4864    state.filters.types.clear();
4865    if (picked) {
4866      state.filters.tags.add(picked);
4867    }
4868    renderFilters();
4869    renderList();
4870  });
4871
4872  root.addEventListener('dragover', function (event) {
4873    var target = eventTargetElement(event.target);
4874    if (!composeDropInComposeCard(target)) {
4875      return;
4876    }
4877    if (!composeDragHasFiles(event)) {
4878      return;
4879    }
4880    if (!composeCanAcceptDroppedFiles(state.compose.postTypeChosen ? composePostType() : '')) {
4881      return;
4882    }
4883    event.preventDefault();
4884    setComposeDropHover(target);
4885    if (event.dataTransfer) {
4886      event.dataTransfer.dropEffect = 'copy';
4887    }
4888  });
4889
4890  root.addEventListener('dragleave', function () {
4891    clearComposeDropHover();
4892  });
4893
4894  root.addEventListener('drop', function (event) {
4895    var target = eventTargetElement(event.target);
4896    if (!composeDropInComposeCard(target)) {
4897      return;
4898    }
4899    var files = composeDroppedFiles(event);
4900    if (!files.length) {
4901      return;
4902    }
4903    if (!composeCanAcceptDroppedFiles(state.compose.postTypeChosen ? composePostType() : '')) {
4904      event.preventDefault();
4905      clearComposeDropHover();
4906      setComposeOutput('File drop is disabled for this post type.', 'warn');
4907      renderComposeStatusOnly();
4908      return;
4909    }
4910    event.preventDefault();
4911    clearComposeDropHover();
4912    var currentType = state.compose.postTypeChosen ? composePostType() : '';
4913    var preferredType = composePreferredDropType(files, currentType);
4914    handleComposeUploads(files, preferredType || undefined);
4915  });
4916
4917  document.addEventListener('click', function (event) {
4918    var target = eventTargetElement(event.target);
4919    if (!target) {
4920      return;
4921    }
4922    var navCompose = target.closest('.nav-compose');
4923    if (!navCompose) {
4924      return;
4925    }
4926    if (!isAdmin()) {
4927      return;
4928    }
4929    event.preventDefault();
4930    event.stopPropagation();
4931    toggleComposeFromUi(true);
4932  });
4933
4934  root.addEventListener('dblclick', function (event) {
4935    var toggle = event.target && event.target.closest('[data-compose-action="shortform-limit-toggle"]');
4936    if (!toggle || !state.compose.open || composePostType() !== 'shortform') {
4937      return;
4938    }
4939    event.preventDefault();
4940    state.compose.shortformLimitEditing = true;
4941    renderComposeUi();
4942    if (els.composeSlot) {
4943      var input = els.composeSlot.querySelector('[data-compose-field="shortform-limit"]');
4944      if (input instanceof HTMLInputElement) {
4945        input.focus();
4946        input.select();
4947      }
4948    }
4949  });
4950
4951  root.addEventListener('input', function (event) {
4952    var target = event.target;
4953    if (!(target instanceof HTMLElement) || !state.compose.open) {
4954      return;
4955    }
4956    var tagEditorTarget = target.closest('[data-compose-field="tags-editor"]');
4957    if (tagEditorTarget instanceof HTMLElement) {
4958      composeTagsEditorClearSelection(tagEditorTarget);
4959      composeTagsEditorSyncDraft(tagEditorTarget);
4960      syncComposeTagsField();
4961      if (String(state.compose.tagsDraftText || '').trim()) {
4962        queueComposeAutosave();
4963      } else {
4964        renderComposeStatusOnly();
4965      }
4966      return;
4967    }
4968    if (target.matches('[data-compose-field="shortform-limit"]')) {
4969      return;
4970    }
4971    if (target.matches('[data-compose-field="title"], [data-compose-field="content"], [data-compose-field="scheduled-at"], [data-compose-field="link-url"]')) {
4972      if (target.matches('[data-compose-field="link-url"]')) {
4973        state.compose.linkUrl = String(target.value || '');
4974        if (composePostType() !== 'link-share') {
4975          state.compose.postType = 'link-share';
4976          state.compose.postTypeChosen = true;
4977        }
4978      }
4979      if (target.matches('[data-compose-field="content"]') && composePostType() === 'shortform') {
4980        enforceComposeShortformLimitOnFields();
4981        if (els.composeSlot) {
4982          var meterButton = els.composeSlot.querySelector('[data-compose-action="shortform-limit-toggle"]');
4983          if (meterButton instanceof HTMLButtonElement) {
4984            meterButton.textContent = String(target.value || '').length + '/' + String(currentComposeShortformLimit());
4985          }
4986        }
4987      }
4988      queueComposeAutosave();
4989      if (state.compose.preview) {
4990        renderComposeUiPreserveFieldFocus();
4991      } else {
4992        renderComposeStatusOnly();
4993      }
4994      return;
4995    }
4996    if (target.matches('input[name="blog-inline-compose-mode"], input[name="blog-inline-compose-destination"]')) {
4997      var shouldOpenSchedulePicker = target.matches('input[name="blog-inline-compose-mode"][value="scheduled"]') && target.checked;
4998      renderComposeUi();
4999      if (shouldOpenSchedulePicker) {
5000        setTimeout(openComposeScheduledPicker, 0);
5001      }
5002    }
5003  });
5004
5005  root.addEventListener('change', function (event) {
5006    var target = event.target;
5007    if (!(target instanceof HTMLInputElement) || !state.compose.open) {
5008      return;
5009    }
5010    if (target.matches('[data-compose-field="scheduled-at"]')) {
5011      queueComposeAutosave();
5012      renderComposeStatusOnly();
5013      return;
5014    }
5015    if (target.matches('[data-compose-field="shortform-limit"]')) {
5016      setComposeShortformLimit(target.value, { editing: false });
5017      return;
5018    }
5019    if (!target.matches('[data-compose-upload]')) {
5020      return;
5021    }
5022    var uploadType = String(target.getAttribute('data-compose-upload') || '').trim();
5023    var files = target.files ? Array.from(target.files) : [];
5024    if (!files.length) {
5025      return;
5026    }
5027    handleComposeUploads(files, uploadType).finally(function () {
5028      target.value = '';
5029    });
5030  });
5031
5032  root.addEventListener('keydown', function (event) {
5033    var target = event.target;
5034    if (!(target instanceof HTMLElement) || !state.compose.open) {
5035      return;
5036    }
5037    if (state.compose.cameraFullscreen && event.key === 'Escape') {
5038      event.preventDefault();
5039      closeComposeCameraCapture({});
5040      return;
5041    }
5042    if (target.matches('[data-compose-field="shortform-limit"]')) {
5043      if (event.key === 'Enter') {
5044        event.preventDefault();
5045        setComposeShortformLimit(target.value, { editing: false });
5046        return;
5047      }
5048      if (event.key === 'Escape') {
5049        event.preventDefault();
5050        state.compose.shortformLimitEditing = false;
5051        renderComposeUi();
5052        return;
5053      }
5054    }
5055    var tagEditor = target.closest('[data-compose-field="tags-editor"]');
5056    if (tagEditor instanceof HTMLElement) {
5057      var selectedToken = composeTagsEditorSelectedToken(tagEditor);
5058      if (event.key === ',' || event.code === 'Comma' || event.key === 'Tab') {
5059        event.preventDefault();
5060        if (composeTagsEditorCommit(tagEditor, true)) {
5061          queueComposeAutosave();
5062        } else {
5063          renderComposeStatusOnly();
5064        }
5065        return;
5066      }
5067      if (selectedToken && event.key && event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
5068        composeTagsEditorClearSelection(tagEditor);
5069        composeTagsEditorFocusDraft(tagEditor);
5070      }
5071      if (event.key === 'Backspace' || event.key === 'Delete') {
5072        if (selectedToken) {
5073          event.preventDefault();
5074          if (composeTagsEditorRemoveTagByNode(tagEditor, selectedToken)) {
5075            queueComposeAutosave();
5076          } else {
5077            renderComposeStatusOnly();
5078          }
5079          return;
5080        }
5081        var draftText = composeTagsEditorReadDraftText(tagEditor);
5082        if (!draftText.trim() && state.compose.tags.length) {
5083          event.preventDefault();
5084          var edgeTag = event.key === 'Delete' ? state.compose.tags[0] : state.compose.tags[state.compose.tags.length - 1];
5085          if (edgeTag) {
5086            removeComposeTag(edgeTag);
5087            composeTagsEditorRender(tagEditor);
5088            composeTagsEditorFocusDraft(tagEditor);
5089            queueComposeAutosave();
5090          }
5091          return;
5092        }
5093      }
5094      if (event.key === 'Escape') {
5095        composeTagsEditorClearSelection(tagEditor);
5096      }
5097    }
5098  });
5099
5100  root.addEventListener('focusout', function (event) {
5101    var target = event.target;
5102    if (!(target instanceof HTMLElement) || !state.compose.open) {
5103      return;
5104    }
5105    var tagEditorTarget = target.closest('[data-compose-field="tags-editor"]');
5106    if (tagEditorTarget instanceof HTMLElement) {
5107      composeTagsEditorSyncDraft(tagEditorTarget);
5108      renderComposeStatusOnly();
5109      return;
5110    }
5111    if (!(target instanceof HTMLInputElement)) {
5112      return;
5113    }
5114    if (!target.matches('[data-compose-field="shortform-limit"]')) {
5115      return;
5116    }
5117    if (!state.compose.shortformLimitEditing) {
5118      return;
5119    }
5120    setComposeShortformLimit(target.value, { editing: false });
5121  });
5122
5123  document.addEventListener('paste', function (event) {
5124    if (!isAdmin()) {
5125      return;
5126    }
5127    if (isEditableTarget(event.target)) {
5128      return;
5129    }
5130    var images = clipboardImageFiles(event);
5131    if (!images.length) {
5132      return;
5133    }
5134    event.preventDefault();
5135    if (!state.compose.open) {
5136      setComposeOpen(true);
5137    }
5138    setComposePostType('upload-media', { skipAutosave: true, skipRender: true });
5139    handleComposeUploads(images, 'upload-media');
5140  });
5141
5142  if (els.toggle) {
5143    els.toggle.addEventListener('click', function () {
5144      var open = els.toggle.getAttribute('aria-expanded') !== 'true';
5145      setPanelOpen(open);
5146    });
5147  }
5148
5149  if (els.clear) {
5150    els.clear.addEventListener('click', function () {
5151      clearFilters();
5152    });
5153  }
5154
5155  window.__wizardryBindSessionAuthRefresh({
5156    onChange: maybeReloadForAuthChange,
5157    storageKeys: ['session_token', 'csrf_token', 'last_auth_is_admin'],
5158    pollState: authRefreshPollState,
5159    pollDurationMs: 15000,
5160    stopWhen: isAdmin
5161  });
5162
5163  window.addEventListener('beforeunload', function () {
5164    clearComposePostTypeCollapseTimer();
5165    stopComposeCameraStream();
5166    stopComposeAudioStream();
5167  });
5168
5169  var composeResizeTimer = null;
5170  window.addEventListener('resize', function () {
5171    if (!state.compose.open || !state.compose.preview) {
5172      return;
5173    }
5174    if (composeResizeTimer) {
5175      window.clearTimeout(composeResizeTimer);
5176    }
5177    composeResizeTimer = window.setTimeout(function () {
5178      composeResizeTimer = null;
5179      if (state.compose.open && state.compose.preview) {
5180        renderComposeUi();
5181      }
5182    }, 120);
5183  });
5184
5185  window.__wizardryRemoveLegacyTitleBlock(root);
5186  (function bootstrapOnce() {
5187    hydrateDraftNoticeFromCache();
5188    window.__wizardryRenderManagedNostrBootstrapSources({
5189      slug: slug,
5190      maxAgeMs: BOOTSTRAP_CACHE_MAX_AGE_MS,
5191      authSignature: window.__wizardryViewerAuthSignature,
5192      isExpectedPayload: isExpectedPayload,
5193      renderPayload: function (payload) {
5194        return renderBootstrapPayload(payload, primeBootstrapPosts(payload));
5195      }
5196    });
5197    waitForInitialDraftNotice();
5198
5199    loadPageState({ deferRender: true }).finally(function () {
5200      state.postsLoading = false;
5201      var nextSignature = renderSignature();
5202      window.__wizardryReconcileManagedPageRefreshRender({
5203        state: state,
5204        previousSignature: state.renderSignature,
5205        nextRenderSignature: nextSignature,
5206        shouldPreserveAdoptedPrerender: shouldPreserveAdoptedStaticPrerender,
5207        renderPreserved: renderPreservedPostListShell,
5208        renderAll: renderAll
5209      });
5210      window.__wizardryNotifyCurrentManagedPageReady({
5211        state: state,
5212        rootNode: root,
5213        markPageReady: true
5214      });
5215    });
5216  })();
5217})();

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.