@kurtbruns/kestrel 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dev.vars.example +13 -8
- package/CHANGELOG.md +72 -2
- package/README.md +2 -2
- package/cli/dev-api.mjs +7 -1
- package/cli/dev.mjs +46 -1
- package/cli/init.mjs +6 -1
- package/dist/public/dashboard/app.js +218 -223
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +6 -6
- package/dist/public/dashboard/styles.css +173 -180
- package/dist/worker/index.js +4793 -3097
- package/docs/README.md +2 -2
- package/docs/get-started/02-deploy.md +23 -8
- package/docs/get-started/03-access.md +2 -2
- package/docs/{guides/02-ses.md → get-started/04-ses.md} +75 -52
- package/docs/get-started/05-verify.md +8 -10
- package/docs/get-started/06-go-live.md +22 -6
- package/docs/guides/07-upgrade.md +2 -0
- package/docs/reference/01-configuration.md +22 -16
- package/docs/reference/02-sending-domain-dns.md +19 -19
- package/docs/reference/03-import-format.md +103 -0
- package/migrations/0004_keyed_pending_to_resolve.sql +27 -0
- package/migrations/0005_imports.sql +74 -0
- package/migrations/0006_deliveries_fewer_indexes.sql +19 -0
- package/package.json +1 -1
- package/template/claude/CLAUDE.md +3 -1
- package/template/package.json +1 -0
- package/template/wrangler.jsonc +6 -3
- package/docs/get-started/04-resend.md +0 -136
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
// GENERATED by scripts/build-client.mjs from client/ — edit the source there, never this file.
|
|
2
|
-
"use strict";(()=>{function On(e){let t=document.getElementById(e);if(!t)throw new Error(`the admin page has no #${e}`);return t}var $e=On("app"),qn=On("identity"),Un=On("toasts"),Os=document.getElementById("navToggle"),In=document.getElementById("navScrim");function xt(e){document.body.classList.toggle("nav-open",e),Os?.setAttribute("aria-expanded",e?"true":"false"),In&&(In.hidden=!e)}var Et=document.getElementById("navCollapse"),Dn=null;function ka(){return window.innerWidth<540?"full":Dn==="collapsed"?"rail":Dn==="expanded"?"full":window.innerWidth<=1024?"rail":"full"}function Nn(){let e=ka();if(document.documentElement.dataset.nav=e,!Et)return;let t=e!=="rail",n=t?"Collapse sidebar":"Expand sidebar";Et.setAttribute("aria-expanded",t?"true":"false"),Et.setAttribute("aria-label",n),Et.setAttribute("title",n)}var Bn=0,Ds=0;function qs(){Os?.addEventListener("click",()=>xt(!document.body.classList.contains("nav-open"))),In?.addEventListener("click",()=>xt(!1)),document.querySelector(".sidebar")?.addEventListener("click",e=>{e.target instanceof Element&&e.target.closest("a")&&xt(!1)}),Et?.addEventListener("click",()=>{Dn=document.documentElement.dataset.nav!=="rail"?"collapsed":"expanded",Nn()}),window.addEventListener("resize",()=>{document.body.classList.add("nav-resizing"),clearTimeout(Ds),Ds=setTimeout(()=>{document.body.classList.remove("nav-resizing")},200),!Bn&&(Bn=requestAnimationFrame(()=>{Bn=0,Nn()}))}),Nn()}var Ot=null,qt=null;function G(e){Ot?.abort(),qt=null;let t=new AbortController;Ot=t;let n=document.createElement("div");n.className="view",$e.replaceChildren(n);let s;try{s=Promise.resolve(e(n,t.signal))}catch(o){s=Promise.reject(o)}return s.then(o=>{!t.signal.aborted&&o&&(qt=o)},o=>{if(!t.signal.aborted)throw o})}function Ut(){Ot?.abort(),Ot=null,qt=null,$e.replaceChildren()}function jt(){return qt}function Ge(e,t,n){if(n.aborted)return;let s=setInterval(t,e);n.addEventListener("abort",()=>clearInterval(s),{once:!0})}function Ft(e,t,n){if(n.aborted)return;let s=()=>clearTimeout(o),o=setTimeout(()=>{n.removeEventListener("abort",s),t()},Math.max(0,e-Date.now()));n.addEventListener("abort",s,{once:!0})}function Us(e,t,n){let s=()=>{if(n.aborted)return;let o=()=>clearTimeout(a),a=setTimeout(async()=>{n.removeEventListener("abort",o);let l=!0;try{l=await t()??!0}catch(r){Ta(r)||reportError(r)}l!==!1&&s()},e);n.addEventListener("abort",o,{once:!0})};s()}function Ye(e,t){if(e.aborted){t();return}e.addEventListener("abort",t,{once:!0})}function Ta(e){return e instanceof Error&&(e.name==="AbortError"||e.name==="ApiError")}function d(e,t=document){let n=t.querySelector(e);if(!n)throw new Error(`no element matches ${e}`);return n}function M(e,t=document){return[...t.querySelectorAll(e)]}var La={"&":"&","<":"<",">":">",'"':""","'":"'"};function Q(e){return String(e).replace(/[&<>"']/g,t=>La[t]??t)}var Ze=class{markup;constructor(t){this.markup=t,Object.freeze(this)}toString(){return this.markup}};function Fs(e){return e instanceof Ze?e.markup:e==null||e===!1?"":Array.isArray(e)?e.map(Fs).join(""):Q(e)}var Ha=/=\s*$/;function i(e,...t){let n=js(e,0);for(let s=0;s<t.length;s++){let o=t[s];if(!(o instanceof Ze)&&Ha.test(n))throw new Error("html: an interpolation in an unquoted attribute value cannot be made safe; quote it");n+=Fs(o)+js(e,s+1)}return new Ze(n)}function js(e,t){let n=e[t];if(n===void 0)throw new Error(`html: invalid escape sequence in template text near ${JSON.stringify(e.raw[t])}`);return n}function O(e){return new Ze(e)}function g(e,t){if(!(t instanceof Ze))throw new Error("setHtml: expected Html; build it with html`\u2026` or vouch for it with unsafeHtml()");e.innerHTML=t.markup}function Vt(e){if(!e||typeof e!="object")return null;let t=e;return t.error!=="invalid_config"||typeof t.variable!="string"?null:{variable:t.variable,message:typeof t.message=="string"?t.message:`${t.variable} is refused`}}var Ca="https://getkestrel.dev/docs/configuration/",Ra={PROVIDER:"provider",APP_ORIGIN:"vars",AWS_REGION:"vars",FROM_ADDRESS:"from_address-and-sending_domain",SENDING_DOMAIN:"from_address-and-sending_domain",ARCHIVE_BASE_PATH:"archive_base_path",SES_MAX_SEND_RATE:"ses_max_send_rate",SUBREQUEST_BUDGET:"subrequest_budget",MIN_LEAD_SECONDS:"min_lead_seconds",ARCHIVE_ORIGIN:"archive_origin-and-media_public_base",MEDIA_PUBLIC_BASE:"archive_origin-and-media_public_base",NOTIFY_FROM:"notify_from",ACCESS_TEAM_DOMAIN:"secrets",ACCESS_AUD:"secrets",ACCESS_ALLOWED_EMAILS:"secrets",RESEND_API_KEY:"secrets",RESEND_WEBHOOK_SECRET:"secrets",AWS_ACCESS_KEY_ID:"secrets",AWS_SECRET_ACCESS_KEY:"secrets",SNS_TOPIC_ARN:"secrets",SES_CONFIGURATION_SET:"secrets",SIMULATE_SENDS:"what-the-app-refuses"},Pa="what-the-app-refuses";function Ma(e){return`${Ca}#${Ra[e]??Pa}`}function Wt(e){$e.querySelector(".config-wall")||(Ut(),document.body.classList.add("signed-out"),g($e,i`<div class="card auth-wall config-wall" role="alert"><h2 tabindex="-1">A setting needs fixing</h2><p>Kestrel won't run until it's fixed, and nothing is sent in the meantime.</p><p class="config-wall-problem">${e.message}</p><p class="hint"><a href="${Ma(e.variable)}" target="_blank" rel="noopener">${e.variable} in the Configuration reference <span aria-hidden="true">↗</span><span class="visually-hidden"> (opens in a new tab)</span></a></p><button id="config-reload" class="primary">Reload</button></div>`),d("#config-reload",$e).onclick=()=>location.reload(),d(".config-wall h2",$e).focus())}var Kt="kestrel_token",W={token:localStorage.getItem(Kt)||"",session:null,appConfig:null};function Ks(e){W.token=(e||"").trim();try{W.token?localStorage.setItem(Kt,W.token):localStorage.removeItem(Kt)}catch{}}function jn(){return W.token?{Authorization:`Bearer ${W.token}`}:{}}function Gs(){let e=W.session;if(e?.auth.mode==="access"){let t=e.principal,n=t.email||(t.kind==="service"?"Service token":"Signed in");g(qn,i`<span class="who" title="${n}">${n}</span><a class="ghost" href="/cdn-cgi/access/logout" title="Sign out"><svg class="signout-icon" aria-hidden="true"><use href="#i-signout"/></svg><span class="signout-label">Sign out</span></a>`)}else g(qn,i`<span class="who dev" title="Local dev — auth is bypassed on localhost">Local dev</span>`)}function Fn(){Ut(),document.body.classList.add("signed-out"),g($e,i`<div class="card auth-wall"><h2>Session expired</h2><p class="hint">Your access session ended. Sign in again to continue.</p><button id="reauth">Sign in</button></div>`),d("#reauth",$e).onclick=()=>location.reload()}async function Vs(){try{let e=await fetch("/api/dev/token?kind=human");if(e.ok)return Ks((await e.json()).token),!0}catch{}return!1}async function Ws(){try{return await fetch("/api/whoami",{headers:jn(),redirect:"manual"})}catch{return null}}async function Ys(){W.token||await Vs();let e=await Ws();if(!e?.ok&&W.token&&(Ks(""),await Vs()&&(e=await Ws())),e?.ok)return await e.json();let t=e?Vt(await e.json().catch(()=>null)):null;return t?Wt(t):Fn(),null}var se=class extends Error{constructor(n,s,o=null){super(n);this.status=s;this.data=o;this.name="ApiError"}status;data;get code(){let n=this.data;return typeof n?.error=="string"?n.error:null}},Aa="Not authorized \u2014 please sign in again.";async function Vn(e,t){let n={...jn(),...t.headers??{}},s=t.body;t.json!==void 0&&(n["content-type"]="application/json",s=JSON.stringify(t.json));let o=await fetch(e,{method:t.method??"GET",headers:n,body:s,signal:t.signal});if(o.status===401)throw Fn(),new se(Aa,401);return o}function _a(e){if(e&&typeof e=="object"){let t=e;if(typeof t.message=="string")return t.message;if(typeof t.error=="string")return t.error}}async function Wn(e){let t=await e.text(),n=null;try{n=t?JSON.parse(t):null}catch{}let s=Vt(n);s&&Wt(s);let o=e.statusText||`Request failed (HTTP ${e.status})`;return new se(_a(n)??o,e.status,n)}async function k(e,t={}){let n=await Vn(e,t);if(!n.ok)throw await Wn(n);let s=await n.text();return s?JSON.parse(s):null}async function et(e,t={}){let n=await Vn(e,t);if(!n.ok)throw await Wn(n);return n.text()}async function Zs(e,t={}){let n=await Vn(e,t);if(!n.ok)throw await Wn(n);let s=n.headers.get("content-disposition")??"",o=/filename="([^"]+)"/.exec(s)?.[1]??null;return{text:await n.text(),filename:o}}function Kn(e){let t=e.trim(),n=t.lastIndexOf("<");if(n<0||!t.endsWith(">"))return"";let s=t.slice(0,n).trim();return s.length>=2&&s.startsWith('"')&&s.endsWith('"')?s.slice(1,-1).replace(/\\(.)/g,"$1").trim():s}function Gt(e,t){return e||Kn(t)}function Gn(e){let t=e?.settings.publication;return{name:Gt(t?.name||"",e?.deployment.fromAddress??"")||"Your publication",tagline:t?.tagline||"",logoUrl:t?.logoUrl||null}}function Yt(){let e=Gn(W.appConfig),t=document.getElementById("brandName"),n=document.getElementById("brandTagline"),s=document.getElementById("brandLogo");t&&(t.textContent=e.name),n&&(n.textContent=e.tagline,n.hidden=!e.tagline),s&&(e.logoUrl?(g(s,i`<img src="${e.logoUrl}" alt="">`),s.classList.remove("brand-logo-placeholder")):(s.textContent=(e.name.trim()[0]||"K").toUpperCase(),s.classList.add("brand-logo-placeholder")))}function zs(e,t,n){return`${e}${t}/${n}`}var Fd=1800*1e3,Zt=300*1e3,Vd=60*1e3,Wd=1440*60*1e3;function zt(e){let t=Math.round(e/1e3);if(t%60===0){let n=t/60;return`${n} minute${n===1?"":"s"}`}return`${t} seconds`}var Kd=10080*60*1e3;function Js(){let e=W.appConfig?.deployment.build;if(!e?.version)return null;let t=e.tagUrl?i`<a class="build-link" href="${e.tagUrl}" target="_blank" rel="noopener">v${e.version}</a>`:i`v${e.version}`,n=e.commitUrl?i`<a class="build-link" href="${e.commitUrl}" target="_blank" rel="noopener">${e.sha}</a>`:i`${e.sha}`;return{version:t,sha:n}}function Qs(){return W.appConfig?.deployment.minLeadMs??Zt}function kt(){return zt(Qs())}function Jt(e=Date.now()){return new Date(e+Qs()+6e4)}function Tt(){return W.appConfig?.deployment.provider==="fake"}function de(){return W.appConfig?.deployment.provider==="none"}var Yn="#/docs/resend";function _e(e=""){return i`<a class="${e}" href="${Yn}">Connect an email provider</a>`}function Qt(e,t){return de()?i`<p class="hint not-connected">No email provider connected, so ${e}. ${_e()}${t}.</p>`:null}function Xt(){return Qt("a test can\u2019t be sent yet"," to send one")}function Xs(){return Qt("a confirmation can\u2019t be sent yet"," to add subscribers")}function eo(){return Qt("this can\u2019t be scheduled yet"," to schedule it")}function to(){return Qt("this send is held when it fires, until one is"," to send it")}function tt(e){return Tt()?`${e} (no email provider configured, so nothing is delivered)`:e}function en(e){let t=W.appConfig?.deployment;return t?zs(t.archiveOrigin,t.archiveBasePath,e):null}var no=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M280-280h280v-80H280v80Zm0-160h400v-80H280v80Zm0-160h400v-80H280v80Zm-80 480q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm0-560v560-560Z"/></svg>
|
|
3
|
-
`;var
|
|
4
|
-
`;var
|
|
5
|
-
`;var
|
|
6
|
-
`;var
|
|
7
|
-
`;var
|
|
8
|
-
`;var
|
|
9
|
-
`;var
|
|
10
|
-
`;var
|
|
11
|
-
`;var
|
|
12
|
-
`;var
|
|
13
|
-
`;var
|
|
14
|
-
`;var
|
|
15
|
-
`;var
|
|
16
|
-
`;var
|
|
17
|
-
`;var
|
|
18
|
-
`;var
|
|
19
|
-
`;var
|
|
20
|
-
`;var
|
|
21
|
-
`;var
|
|
22
|
-
`;var
|
|
23
|
-
`;var
|
|
24
|
-
`;var
|
|
25
|
-
`;var
|
|
26
|
-
`;var
|
|
27
|
-
`;var
|
|
28
|
-
`;var
|
|
29
|
-
`;var
|
|
30
|
-
`;var
|
|
31
|
-
`;var
|
|
32
|
-
`;var
|
|
33
|
-
`;var
|
|
34
|
-
`;var
|
|
2
|
+
"use strict";(()=>{function es(e){let t=document.getElementById(e);if(!t)throw new Error(`the admin page has no #${e}`);return t}var ce=es("app"),ts=es("identity"),ns=es("toasts"),ao=document.getElementById("navToggle"),Qn=document.getElementById("navScrim");function _t(e){document.body.classList.toggle("nav-open",e),ao?.setAttribute("aria-expanded",e?"true":"false"),Qn&&(Qn.hidden=!e)}var Pt=document.getElementById("navCollapse"),Xn=null;function Vr(){return window.innerWidth<540?"full":Xn==="collapsed"?"rail":Xn==="expanded"?"full":window.innerWidth<=1024?"rail":"full"}function zn(){let e=Vr();if(document.documentElement.dataset.nav=e,!Pt)return;let t=e!=="rail",n=t?"Collapse sidebar":"Expand sidebar";Pt.setAttribute("aria-expanded",t?"true":"false"),Pt.setAttribute("aria-label",n),Pt.setAttribute("title",n)}var Jn=0,ro=0;function lo(){ao?.addEventListener("click",()=>_t(!document.body.classList.contains("nav-open"))),Qn?.addEventListener("click",()=>_t(!1)),document.querySelector(".sidebar")?.addEventListener("click",e=>{e.target instanceof Element&&e.target.closest("a")&&_t(!1)}),Pt?.addEventListener("click",()=>{Xn=document.documentElement.dataset.nav!=="rail"?"collapsed":"expanded",zn()}),window.addEventListener("resize",()=>{document.body.classList.add("nav-resizing"),clearTimeout(ro),ro=setTimeout(()=>{document.body.classList.remove("nav-resizing")},200),!Jn&&(Jn=requestAnimationFrame(()=>{Jn=0,zn()}))}),zn()}var Jt=null,Qt=null;function z(e){Jt?.abort(),Qt=null;let t=new AbortController;Jt=t;let n=document.createElement("div");n.className="view",ce.replaceChildren(n);let s;try{s=Promise.resolve(e(n,t.signal))}catch(i){s=Promise.reject(i)}return s.then(i=>{!t.signal.aborted&&i&&(Qt=i)},i=>{if(!t.signal.aborted)throw i})}function ct(){Jt?.abort(),Jt=null,Qt=null,ce.replaceChildren()}function Xt(){return Qt}function Je(e,t,n){if(n.aborted)return;let s=setInterval(t,e);n.addEventListener("abort",()=>clearInterval(s),{once:!0})}function Nt(e,t,n){if(n.aborted)return;let s=()=>clearTimeout(i),i=setTimeout(()=>{n.removeEventListener("abort",s),t()},Math.max(0,e-Date.now()));n.addEventListener("abort",s,{once:!0})}function co(e,t,n){let s=()=>{if(n.aborted)return;let i=()=>clearTimeout(r),r=setTimeout(async()=>{n.removeEventListener("abort",i);let l=!0;try{l=await t()??!0}catch(a){Wr(a)||reportError(a)}l!==!1&&s()},e);n.addEventListener("abort",i,{once:!0})};s()}function Qe(e,t){if(e.aborted){t();return}e.addEventListener("abort",t,{once:!0})}function Wr(e){return e instanceof Error&&(e.name==="AbortError"||e.name==="ApiError")}function c(e,t=document){let n=t.querySelector(e);if(!n)throw new Error(`no element matches ${e}`);return n}function A(e,t=document){return[...t.querySelectorAll(e)]}var Kr={"&":"&","<":"<",">":">",'"':""","'":"'"};function se(e){return String(e).replace(/[&<>"']/g,t=>Kr[t]??t)}var Xe=class{markup;constructor(t){this.markup=t,Object.freeze(this)}toString(){return this.markup}};function po(e){return e instanceof Xe?e.markup:e==null||e===!1?"":Array.isArray(e)?e.map(po).join(""):se(e)}var Gr=/=\s*$/;function o(e,...t){let n=uo(e,0);for(let s=0;s<t.length;s++){let i=t[s];if(!(i instanceof Xe)&&Gr.test(n))throw new Error("html: an interpolation in an unquoted attribute value cannot be made safe; quote it");n+=po(i)+uo(e,s+1)}return new Xe(n)}function uo(e,t){let n=e[t];if(n===void 0)throw new Error(`html: invalid escape sequence in template text near ${JSON.stringify(e.raw[t])}`);return n}function O(e){return new Xe(e)}function v(e,t){if(!(t instanceof Xe))throw new Error("setHtml: expected Html; build it with html`\u2026` or vouch for it with unsafeHtml()");e.innerHTML=t.markup}var Yr="https://getkestrel.dev/docs/access/";function en(e){if(!e||typeof e!="object")return!1;let t=e;return t.error==="unauthorized"&&t.reason==="access_not_configured"}function tn(){ce.querySelector(".access-wall")||(ct(),document.body.classList.add("signed-out"),v(ce,o`<div class="card auth-wall access-wall"><h2 tabindex="-1">The editor is locked until you set up Access</h2><p>Until Cloudflare Access is in front of the editor, the app can’t tell who is asking, so it lets no one in. On a new deployment, that’s expected. Your public pages work as usual.</p><p class="hint"><a href="${Yr}" target="_blank" rel="noopener">Lock the dashboard with Access <span aria-hidden="true">↗</span><span class="visually-hidden"> (opens in a new tab)</span></a></p><button id="access-reload" class="primary">Reload</button></div>`),c("#access-reload",ce).onclick=()=>location.reload(),c(".access-wall h2",ce).focus())}function nn(e){if(!e||typeof e!="object")return null;let t=e;return t.error==="schema_behind"&&typeof t.message=="string"?{message:t.message}:t.error!=="invalid_config"||typeof t.variable!="string"?null:{variable:t.variable,message:typeof t.message=="string"?t.message:`${t.variable} is refused`}}var Zr="https://getkestrel.dev/docs/configuration/",zr={PROVIDER:"provider",ALLOW_FAKE_PROVIDER:"provider",APP_ORIGIN:"vars",AWS_REGION:"vars",FROM_ADDRESS:"from_address-and-sending_domain",SENDING_DOMAIN:"from_address-and-sending_domain",ARCHIVE_BASE_PATH:"archive_base_path",SES_MAX_SEND_RATE:"ses_max_send_rate",SUBREQUEST_BUDGET:"subrequest_budget",MIN_LEAD_SECONDS:"min_lead_seconds",ARCHIVE_ORIGIN:"archive_origin-and-media_public_base",MEDIA_PUBLIC_BASE:"archive_origin-and-media_public_base",NOTIFY_FROM:"notify_from",ACCESS_TEAM_DOMAIN:"secrets",ACCESS_AUD:"secrets",ACCESS_ALLOWED_EMAILS:"secrets",AWS_ACCESS_KEY_ID:"secrets",AWS_SECRET_ACCESS_KEY:"secrets",SNS_TOPIC_ARN:"secrets",SES_CONFIGURATION_SET:"secrets",SIMULATE_SENDS:"what-the-app-refuses"},Jr="what-the-app-refuses";function Qr(e){return`${Zr}#${zr[e]??Jr}`}function sn(e){if(ce.querySelector(".config-wall"))return;ct(),document.body.classList.add("signed-out");let{variable:t}=e,n=t?"A setting needs fixing":"The database needs updating",s=t?o`<p class="hint"><a href="${Qr(t)}" target="_blank" rel="noopener">${t} in the Configuration reference <span aria-hidden="true">↗</span><span class="visually-hidden"> (opens in a new tab)</span></a></p>`:"";v(ce,o`<div class="card auth-wall config-wall" role="alert"><h2 tabindex="-1">${n}</h2><p>Kestrel won’t run until it’s fixed, and nothing is sent in the meantime.</p><p class="config-wall-problem">${e.message}</p>${s}<button id="config-reload" class="primary">Reload</button></div>`),c("#config-reload",ce).onclick=()=>location.reload(),c(".config-wall h2",ce).focus()}var on="kestrel_token",K={token:localStorage.getItem(on)||"",session:null,appConfig:null};function ho(e){K.token=(e||"").trim();try{K.token?localStorage.setItem(on,K.token):localStorage.removeItem(on)}catch{}}function ss(){return K.token?{Authorization:`Bearer ${K.token}`}:{}}function vo(){let e=K.session;if(e?.auth.mode==="access"){let t=e.principal,n=t.email||(t.kind==="service"?"Service token":"Signed in");v(ts,o`<span class="who" title="${n}">${n}</span><a class="ghost" href="/cdn-cgi/access/logout" title="Sign out"><svg class="signout-icon" aria-hidden="true"><use href="#i-signout"/></svg><span class="signout-label">Sign out</span></a>`)}else v(ts,o`<span class="who dev" title="Local dev: sign-in is skipped on localhost">Local dev</span>`)}function os(){ce.querySelector(".reauth-wall")||(ct(),document.body.classList.add("signed-out"),v(ce,o`<div class="card auth-wall reauth-wall"><h2 tabindex="-1">Session expired</h2><p class="hint">Your access session ended. Sign in again to continue.</p><button id="reauth" class="primary">Sign in</button></div>`),c("#reauth",ce).onclick=()=>location.reload(),c(".reauth-wall h2",ce).focus())}async function mo(){try{let e=await fetch("/api/dev/token?kind=human");if(e.ok)return ho((await e.json()).token),!0}catch{}return!1}async function fo(){try{return await fetch("/api/whoami",{headers:ss(),redirect:"manual"})}catch{return null}}async function go(){K.token||await mo();let e=await fo();if(!e?.ok&&K.token&&(ho(""),await mo()&&(e=await fo())),e?.ok)return await e.json();let t=e?await e.json().catch(()=>null):null,n=nn(t);return n?sn(n):en(t)?tn():os(),null}var J=class extends Error{constructor(n,s,i=null){super(n);this.status=s;this.data=i;this.name="ApiError"}status;data;get code(){let n=this.data;return typeof n?.error=="string"?n.error:null}get field(){let n=this.data;return typeof n?.field=="string"?n.field:null}},Xr="Not authorized. Sign in again.",ea=e=>/^[a-z_]+$/i.test(e.trim()),ta=/^(Failed to fetch|NetworkError when attempting to fetch resource\.?|Load failed|The network connection was lost\.?|The Internet connection appears to be offline\.?)$/;function is(e){return e instanceof TypeError&&ta.test(e.message)}function G(e){if(e instanceof J){let t=e.data?.message;return typeof t=="string"&&!ea(t)?t:e.status===401?e.message:e.status===404?"It isn\u2019t there any more. It may have been deleted elsewhere.":e.status>=500?"Something went wrong on the server. Try again in a moment.":e.status===429?"Too many requests at once. Try again in a moment.":"The server couldn\u2019t do that. Reload the page and try again."}return is(e)?"Couldn\u2019t reach Kestrel. Check your connection and try again.":e instanceof Error?e.message:String(e)}async function rs(e,t){let n={...ss(),...t.headers??{}},s=t.body;t.json!==void 0&&(n["content-type"]="application/json",s=JSON.stringify(t.json));let i=await fetch(e,{method:t.method??"GET",headers:n,body:s,signal:t.signal});if(i.status===401){let r=await i.json().catch(()=>null);throw en(r)?tn():os(),new J(Xr,401,r)}return i}function na(e){if(e&&typeof e=="object"){let t=e;if(typeof t.message=="string")return t.message;if(typeof t.error=="string")return t.error}}async function as(e){let t=await e.text(),n=null;try{n=t?JSON.parse(t):null}catch{}let s=nn(n);s&&sn(s);let i=e.statusText||`Request failed (HTTP ${e.status})`;return new J(na(n)??i,e.status,n)}async function L(e,t={}){let n=await rs(e,t);if(!n.ok)throw await as(n);let s=await n.text();return s?JSON.parse(s):null}async function dt(e,t={}){let n=await rs(e,t);if(!n.ok)throw await as(n);return n.text()}async function bo(e,t={}){let n=await rs(e,t);if(!n.ok)throw await as(n);let s=n.headers.get("content-disposition")??"",i=/filename="([^"]+)"/.exec(s)?.[1]??null;return{text:await n.text(),filename:i}}function ls(e){let t=e.trim(),n=t.lastIndexOf("<");if(n<0||!t.endsWith(">"))return"";let s=t.slice(0,n).trim();return s.length>=2&&s.startsWith('"')&&s.endsWith('"')?s.slice(1,-1).replace(/\\(.)/g,"$1").trim():s}function rn(e,t){return e||ls(t)}function cs(e){let t=e?.settings.publication;return{name:rn(t?.name||"",e?.deployment.fromAddress??"")||"Your publication",tagline:t?.tagline||"",logoUrl:t?.logoUrl||null}}function an(){let e=cs(K.appConfig),t=document.getElementById("brandName"),n=document.getElementById("brandTagline"),s=document.getElementById("brandLogo");t&&(t.textContent=e.name),n&&(n.textContent=e.tagline,n.hidden=!e.tagline),s&&(e.logoUrl?(v(s,o`<img src="${e.logoUrl}" alt="">`),s.classList.remove("brand-logo-placeholder")):(s.textContent=(e.name.trim()[0]||"K").toUpperCase(),s.classList.add("brand-logo-placeholder")))}function yo(e,t,n){return`${e}${t}/${n}`}var ds={step:"Connect Amazon SES",guide:" (the setup guide walks through it)",after:", then deploy."};var kd=1800*1e3,ln=300*1e3,xd=60*1e3,Td=1440*60*1e3;function ut(e){let t=Math.round(e/1e3);if(t%60===0){let n=t/60;return`${n} minute${n===1?"":"s"}`}return`${t} seconds`}function wo(e){return`Pick a time at least ${ut(e)} from now, so there\u2019s time to cancel.`}var Ld=10080*60*1e3,us=5e3;function So(e){return`On the Workers Free plan\u2019s limits, a send to ${e.toLocaleString("en-US")} recipients may use up the database\u2019s daily allowance of writes. That stops this newsletter until midnight UTC, the send included. Workers Paid is recommended for a list of more than ${us.toLocaleString("en-US")}.`}function $o(){let e=K.appConfig?.deployment.build;if(!e?.version)return null;let t=e.tagUrl?o`<a class="build-link" href="${e.tagUrl}" target="_blank" rel="noopener">v${e.version}</a>`:o`v${e.version}`,n=e.commitUrl?o`<a class="build-link" href="${e.commitUrl}" target="_blank" rel="noopener">${e.sha}</a>`:o`${e.sha}`;return{version:t,sha:n}}function cn(){return K.appConfig?.deployment.minLeadMs??ln}function dn(){return wo(cn())}function Eo(){let e=cn();if(e%6e4===0){let t=e/6e4;return`${t} to ${t+1} minutes`}return`${ut(e)} to ${ut(e+6e4)}`}function un(e,t=Date.now()){return e<t+cn()}function pn(e=Date.now()){return new Date(e+cn()+6e4)}function It(){return K.appConfig?.deployment.provider==="fake"}function de(){return K.appConfig?.deployment.provider==="none"}function sa(){return K.appConfig?.deployment.freePlanLimits===!0}function oa(){return de()&&K.appConfig?.deployment.notConnectedPreview===!0}var ps="#/docs/ses";function He(e){let t=e?o` class="${e}"`:null;return o`<a${t} href="${ps}">${ds.step}</a>`}function ms(){return oa()?o`Restart <code>npm run dev</code> without <code>SIMULATE_SENDS=not-connected</code> to send.`:o`${He()}${ds.after}`}function mn(e,t){let n=t===1;return o`No email provider connected, so nothing can be sent and ${e} ${n?"is":"are"} held, with no one marked unsent; ${n?"it goes out at its":"they go out at their"} first retry once one is connected. ${ms()}`}function fn(e,t){return de()?o`<div class="health amber ${e}" role="status"><span class="health-dot">⚠️</span><div><span>${t}</span></div></div>`:null}function hn(){return de()?o`<div class="sched-held">Will be held: no email provider is connected.</div>`:null}function vn(e,t){return de()?o`<p class="hint not-connected">No email provider connected, so ${e}. ${He()}${t}.</p>`:null}function gn(){return vn("a test can\u2019t be sent yet"," to send one")}function ko(){return vn("a confirmation can\u2019t be sent yet"," to add subscribers")}function fs(){return sa()&&!de()?o`<p class="hint daily-writes" id="schDailyWrites" role="status" hidden></p>`:null}function xo(){return vn("this can\u2019t be scheduled yet"," to schedule it")}function To(){return vn("this send is held when it fires, until one is"," to send it")}function pt(e){return It()?`${e} (local stand-in: nothing is delivered)`:e}function bn(e){let t=K.appConfig?.deployment;return t?yo(t.archiveOrigin,t.archiveBasePath,e):null}var Lo=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M280-280h280v-80H280v80Zm0-160h400v-80H280v80Zm0-160h400v-80H280v80Zm-80 480q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm0-560v560-560Z"/></svg>
|
|
3
|
+
`;var Ho=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M272-200v-560h221q65 0 120 40t55 111q0 51-23 78.5T602-491q25 11 55.5 41t30.5 90q0 89-65 124.5T501-200H272Zm121-112h104q48 0 58.5-24.5T566-372q0-11-10.5-35.5T494-432H393v120Zm0-228h93q33 0 48-17t15-38q0-24-17-39t-44-15h-95v109Z"/></svg>
|
|
4
|
+
`;var Co=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2z"/><path d="M4 21V5"/><path d="M9 8h6"/></svg>
|
|
5
|
+
`;var Ro=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v8a4 4 0 0 0 4 4h8"/><path d="m14 11 4 4-4 4"/></svg>
|
|
6
|
+
`;var Mo=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
|
7
|
+
`;var Ao=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/></svg>
|
|
8
|
+
`;var Po=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8 12h8"/></svg>
|
|
9
|
+
`;var _o=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M320-240 80-480l240-240 57 57-184 184 183 183-56 56Zm320 0-57-57 184-184-183-183 56-56 240 240-240 240Z"/></svg>
|
|
10
|
+
`;var No=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
|
|
11
|
+
`;var Io=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8"/><path d="M12 16v4"/></svg>
|
|
12
|
+
`;var Bo=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>
|
|
13
|
+
`;var Do=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M360-280v-400h80v160h160v-160h80v400h-80v-160H440v160h-80Z"/></svg>
|
|
14
|
+
`;var Oo=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M120-120v-80h720v80H120Zm320-160v-80h400v80H440Zm0-160v-80h400v80H440Zm0-160v-80h400v80H440ZM120-760v-80h720v80H120Zm0 440v-320l160 160-160 160Z"/></svg>
|
|
15
|
+
`;var qo=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>
|
|
16
|
+
`;var Uo=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M440-280h80v-240h-80v240Zm68.5-331.5Q520-623 520-640t-11.5-28.5Q497-680 480-680t-28.5 11.5Q440-657 440-640t11.5 28.5Q463-600 480-600t28.5-11.5ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
|
17
|
+
`;var Fo=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M200-200v-100h160l120-360H320v-100h400v100H580L460-300h140v100H200Z"/></svg>
|
|
18
|
+
`;var jo=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 360" aria-hidden="true"><path d="M290.028 216.064C285.698 218.264 280.838 219.394 275.978 219.344C281.268 225.044 284.128 232.924 283.708 240.694C283.278 248.844 279.388 256.514 274.268 262.874C269.148 269.244 262.808 274.494 256.608 279.814C237.148 296.474 218.248 314.404 195.328 325.854C178.568 334.224 160.148 338.864 143.458 347.374C140.148 349.064 136.898 350.904 133.418 352.224C129.938 353.544 126.178 354.324 122.498 353.794C121.718 353.684 120.938 353.504 120.228 353.184C119.508 352.864 118.848 352.374 118.408 351.734C117.918 351.024 117.708 350.154 117.718 349.304C117.728 348.454 117.948 347.614 118.268 346.824C118.567 346.054 118.967 345.339 119.371 344.618L119.418 344.534C128.788 327.894 140.118 312.454 150.358 296.344C160.598 280.224 169.848 263.174 174.428 244.634C176.258 237.264 177.328 229.644 176.738 222.074C176.138 214.504 173.808 206.964 169.308 200.854C164.588 194.434 157.728 189.874 150.538 186.434C139.628 181.204 127.768 178.284 115.858 176.154C93.9183 172.224 71.6083 170.884 49.4083 168.944C45.8483 168.634 42.2483 168.294 38.8783 167.124C34.7683 165.704 31.0683 163.004 28.7383 159.334C26.4183 155.664 25.5583 151.024 26.7483 146.844C27.6583 143.624 29.7283 140.784 32.3583 138.724C34.9883 136.654 38.1683 135.324 41.4383 134.634C44.7183 133.944 48.0983 133.874 51.4383 134.154C53.2683 134.304 55.0883 134.554 56.8983 134.824C72.2383 137.094 87.3783 140.484 102.548 143.644C115.468 146.334 128.498 148.864 141.688 149.224C154.878 149.574 168.338 147.654 180.178 141.824C196.548 133.764 208.758 118.514 215.298 101.474C224.258 78.094 223.028 52.174 220.398 27.284C219.788 21.484 219.108 15.614 220.058 9.854C220.178 9.124 220.328 8.38398 220.658 7.71398C220.988 7.05398 221.528 6.45399 222.228 6.19399C222.858 5.95399 223.568 6.01399 224.218 6.22399C224.858 6.43399 225.448 6.77399 226.028 7.12399C262.588 29.154 290.508 63.614 310.198 101.484C312.188 105.314 314.108 109.204 315.408 113.314C316.708 117.434 317.358 121.814 316.768 126.084C316.348 129.114 315.268 132.104 313.318 134.454C310.668 137.634 306.688 139.354 302.778 140.744C298.878 142.124 294.808 143.324 291.488 145.804C286.998 149.174 284.328 154.834 284.598 160.444C292.098 161.904 299.448 164.114 306.508 167.014C312.788 169.594 318.998 172.854 323.418 178.014C327.088 182.294 329.288 187.614 331.438 192.844C332.158 194.584 332.878 196.344 333.328 198.184C333.778 200.014 333.958 201.944 333.578 203.794C333.138 205.934 331.948 207.914 330.248 209.294C329.898 208.024 329.258 206.834 328.408 205.834C326.958 204.154 324.928 203.054 322.798 202.454C318.258 201.174 313.328 202.114 309.018 204.044C304.708 205.974 300.898 208.834 297.068 211.584C294.808 213.204 292.508 214.804 290.028 216.064Z"/></svg>
|
|
19
|
+
`;var Vo=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6h11M9 12h11M9 18h11M4 6h1M4 12h1M4 18h1"/></svg>
|
|
20
|
+
`;var Wo=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M440-280H280q-83 0-141.5-58.5T80-480q0-83 58.5-141.5T280-680h160v80H280q-50 0-85 35t-35 85q0 50 35 85t85 35h160v80ZM320-440v-80h320v80H320Zm200 160v-80h160q50 0 85-35t35-85q0-50-35-85t-85-35H520v-80h160q83 0 141.5 58.5T880-480q0 83-58.5 141.5T680-280H520Z"/></svg>
|
|
21
|
+
`;var Ko=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>
|
|
22
|
+
`;var Go=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M120-80v-60h100v-30h-60v-60h60v-30H120v-60h120q17 0 28.5 11.5T280-280v40q0 17-11.5 28.5T240-200q17 0 28.5 11.5T280-160v40q0 17-11.5 28.5T240-80H120Zm0-280v-110q0-17 11.5-28.5T160-510h60v-30H120v-60h120q17 0 28.5 11.5T280-560v70q0 17-11.5 28.5T240-450h-60v30h100v60H120Zm60-280v-180h-60v-60h120v240h-60Zm180 440v-80h480v80H360Zm0-240v-80h480v80H360Zm0-240v-80h480v80H360Z"/></svg>
|
|
23
|
+
`;var Yo=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M720-330q0 104-73 177T470-80q-104 0-177-73t-73-177v-370q0-75 52.5-127.5T400-880q75 0 127.5 52.5T580-700v350q0 46-32 78t-78 32q-46 0-78-32t-32-78v-370h80v370q0 13 8.5 21.5T470-320q13 0 21.5-8.5T500-350v-350q-1-42-29.5-71T400-800q-42 0-71 29t-29 71v370q-1 71 49 120.5T470-160q70 0 119-49.5T640-330v-390h80v390Z"/></svg>
|
|
24
|
+
`;var Zo=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg>
|
|
25
|
+
`;var zo=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
26
|
+
`;var Jo=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
27
|
+
`;var Qo=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="m228-240 92-160q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 23-5.5 42.5T458-480L320-240h-92Zm360 0 92-160q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 23-5.5 42.5T818-480L680-240h-92ZM362.5-517.5Q380-535 380-560t-17.5-42.5Q345-620 320-620t-42.5 17.5Q260-585 260-560t17.5 42.5Q295-500 320-500t42.5-17.5Zm360 0Q740-535 740-560t-17.5-42.5Q705-620 680-620t-42.5 17.5Q620-585 620-560t17.5 42.5Q655-500 680-500t42.5-17.5ZM680-560Zm-360 0Z"/></svg>
|
|
28
|
+
`;var Xo=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
|
|
29
|
+
`;var ei=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg>
|
|
30
|
+
`;var ti=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 3 11 14M22 3l-7 18-4-7-7-4 18-7z"/></svg>
|
|
31
|
+
`;var ni=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M360-200v-80h480v80H360Zm0-240v-80h480v80H360Zm0-240v-80h480v80H360ZM200-160q-33 0-56.5-23.5T120-240q0-33 23.5-56.5T200-320q33 0 56.5 23.5T280-240q0 33-23.5 56.5T200-160Zm0-240q-33 0-56.5-23.5T120-480q0-33 23.5-56.5T200-560q33 0 56.5 23.5T280-480q0 33-23.5 56.5T200-400Zm-56.5-263.5Q120-687 120-720t23.5-56.5Q167-800 200-800t56.5 23.5Q280-753 280-720t-23.5 56.5Q233-640 200-640t-56.5-23.5Z"/></svg>
|
|
32
|
+
`;var si=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M8 8l4-4 4 4"/><path d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
|
|
33
|
+
`;var oi=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M6 6l12 12M18 6 6 18"/></svg>
|
|
34
|
+
`;var Ba={article:O(Lo),bold:O(Ho),check:O(Mo),code:O(_o),copyout:O(No),editable:O(Bo),heading:O(Do),indent:O(Oo),info:O(qo),"info-filled":O(Uo),italic:O(Fo),kestrel:O(jo),lines:O(Vo),link:O(Wo),ol:O(Go),paperclip:O(Yo),plus:O(zo),preview:O(Jo),quote:O(Qo),readonly:O(Xo),search:O(ei),send:O(ti),ul:O(ni),upload:O(si),book:O(Co),branch:O(Ro),"circle-check":O(Ao),"circle-minus":O(Po),desktop:O(Io),mail:O(Ko),phone:O(Zo),x:O(oi)};function P(e){return Ba[e]}function mt(e,t,n){let s=(m,y)=>e===m?o`<a href="#/${m}" data-room="${m}" data-text="${y}" aria-current="page">${y}</a>`:o`<a href="#/${m}" data-room="${m}" data-text="${y}">${y}</a>`,i=$o(),r=K.appConfig?.deployment.build.buildTime,l=r?`Built ${new Date(r).toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}`:null,a=i?o`${i.version} <span aria-hidden="true">·</span> ${i.sha}`:null,d=a?l?o`<div class="rail-foot" title="${l}">${a}</div>`:o`<div class="rail-foot">${a}</div>`:null,u=a?l?o`<footer class="room-foot" title="${l}">${a}</footer>`:o`<footer class="room-foot">${a}</footer>`:null,p=t==null?o`<div class="room-body norail"><div class="room-main">${n}</div></div>`:o`<div class="room-body"><nav class="room-rail" aria-label="Contents"><div class="rail-inner">${t}${d}</div></nav><div class="room-main">${n}</div></div>`;return o`<div class="room">
|
|
35
35
|
<header class="room-bar">
|
|
36
36
|
<a class="room-back" href="#/dashboard" aria-label="Back to dashboard"><span aria-hidden="true">←</span><span class="room-back-label">Dashboard</span></a>
|
|
37
37
|
<div class="room-nav">
|
|
@@ -40,99 +40,88 @@
|
|
|
40
40
|
<a class="room-close" href="#/dashboard" title="Back to publication" aria-label="Back to publication"><span aria-hidden="true">✕</span></a>
|
|
41
41
|
</div>
|
|
42
42
|
</header>
|
|
43
|
-
${
|
|
43
|
+
${p}
|
|
44
44
|
${u}
|
|
45
|
-
</div>`}function
|
|
45
|
+
</div>`}function ii(){ce.addEventListener("click",e=>{let t=e.target instanceof Element?e.target.closest(".room-bar a[href^='#/']"):null;!t||t.getAttribute("href")!==location.hash||(e.preventDefault(),window.scrollTo(0,0))})}var Da=/^(\/[A-Za-z0-9._~%-]+)+$/,Oa=/\/(?:\.|%2e){1,2}(?=\/|$)/i;function hs(e){return Da.test(e)&&!Oa.test(e)}function ri(e){let t=new URLSearchParams(e).get("to");return t&&hs(t)?`#${t}`:null}var qa=/^(\d+)\.(\d+)$/;function Ua(e){return`${e.seq}.${e.at}`}function ai(e){let t=qa.exec(e);if(!t?.[1]||!t[2])return null;let n=Number(t[1]),s=Number(t[2]);return Number.isSafeInteger(n)&&Number.isSafeInteger(s)?{seq:n,at:s}:null}function yn(e,t){let n=ai(e),s=ai(t);return!n||!s?n?e:t:Ua({seq:Math.min(n.seq,s.seq),at:Math.min(n.at,s.at)})}function We(e){switch(e.status){case"scheduled":return e.phase==="due"?"due":"scheduled";case"sending":return"sending";case"sent":return e.phase==="complete"?"complete":"sent";default:return"canceled"}}var Fa=3e3,ja=6e4,Va=3e4,Ne=new Set,wn=null,et=null,Oe,Ve=null,Bt=!1,vs=!1,Sn=0;function $n(e){try{e()}catch(t){reportError(t)}}function gs(e,t){return e&&t?yn(e,t):e??t}function li(e){clearTimeout(Oe),Oe=void 0,!(!Ne.size||document.hidden)&&(Oe=setTimeout(Dt,e))}function Dt(){if(clearTimeout(Oe),Oe=void 0,!Ne.size)return;if(Ve){Bt=!0;return}let e=gs(wn,et);et=null;let t=[...Ne],n=new AbortController;Ve=n;let s=setTimeout(()=>n.abort(),Va),i=e?`?since=${encodeURIComponent(e)}`:"";L(`/api/sends/feed${i}`,{signal:n.signal}).then(r=>{if(clearTimeout(s),Ve===n){Ve=null,Sn=0,wn=r.cursor;for(let l of t)Ne.has(l)&&l.take(r);if(Bt){Bt=!1,Dt();return}li(Math.max(0,r.read_again_at-r.now))}},r=>{if(clearTimeout(s),Ve===n){if(Ve=null,Bt=!1,r instanceof J&&r.code==="cursor_ahead"){let l=[...Ne];for(let a of l)ui(a);for(let a of l)$n(()=>a.stale());return}r instanceof J&&r.status===400?wn=null:et=gs(et,e),Sn+=1,li(Math.min(Fa*2**(Sn-1),ja))}})}function Wa(){vs||(vs=!0,queueMicrotask(()=>{vs=!1,Dt()}))}function ci(){document.hidden?(clearTimeout(Oe),Oe=void 0):Dt()}function di(e,t,n){n.aborted||(Ne.add(e),et=gs(et,t),Ne.size===1&&document.addEventListener("visibilitychange",ci),n.addEventListener("abort",()=>ui(e),{once:!0}),Wa())}function ui(e){Ne.delete(e),!Ne.size&&(clearTimeout(Oe),Oe=void 0,Ve?.abort(),Ve=null,Bt=!1,Sn=0,wn=null,et=null,document.removeEventListener("visibilitychange",ci))}function ft(e,t,n){let s=new Map(e.sends.map(i=>[i.id,We(i)]));di({take(i){if(!i.sends.length&&!i.removed.length)return;let r=[];for(let a of i.sends){let d=We(a),u=s.get(a.id)??null;u!==d&&r.push({send:a,from:u,to:d}),s.set(a.id,d)}let l=i.removed.map(a=>a.id);for(let a of l)s.delete(a);$n(()=>t.update({sends:i.sends,changes:r,removed:l}))},stale:()=>t.stale()},e.cursor,n)}function Ot(e,t,n){let s=e.send.id,i=We(e.send);di({take(r){if(r.removed.some(u=>u.id===s)){$n(()=>t.removed());return}let l=r.sends.find(u=>u.id===s);if(!l)return;let a=We(l),d=a!==i?{send:l,from:i,to:a}:null;i=a,$n(()=>t.update(l,d))},stale:()=>t.stale()},e.cursor,n)}function tt(){Ne.size&&Dt()}function ht(e){return e.trim().toLowerCase()}function vt(e){return/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)}var Z=e=>e?new Date(e).toLocaleString(void 0,{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"}):"\u2014";function En(e){let t=new Set,n=[];for(let s of String(e||"").split(/[\n,]+/)){let i=ht(s);vt(i)?t.add(i):i&&n.push(s.trim())}return{valid:[...t],invalid:n}}function kn(e){return e.length?`Not ${e.length===1?"an email address":"email addresses"}: ${e.join(", ")}`:null}function xn(e,t,n,s=[]){let i=s.length?` ${s.join(" ")}`:"";return e?`Sent to ${e} of ${t}. Not sent to ${n.join(", ")}.${i} Send again to try those.`:`Not sent.${i} Send again to try.`}function gt(e){let t=n=>String(n).padStart(2,"0");return`${e.getFullYear()}-${t(e.getMonth()+1)}-${t(e.getDate())}T${t(e.getHours())}:${t(e.getMinutes())}`}function qt(e,t=!1){let n=e-Date.now();if(t||n<=0)return"Preparing to send\u2026";let s=Math.floor(n/1e3),i=Math.floor(s/60),r=Math.floor(i/60);if(s<300)return i>0?`Sends in ${i}m ${String(s%60).padStart(2,"0")}s`:`Sends in ${s}s`;if(i<60)return`Sends in ${i}m`;if(r<24){let a=i%60;return a>0?`Sends in ${r}h ${a}m`:`Sends in ${r}h`}let l=Math.round(r/24);return`Sends in ${l} day${l===1?"":"s"}`}function Tn(e){return`Missed its fire time \xB7 ${Math.max(0,Math.floor((Date.now()-e)/6e4))} min late`}function bt(e){let t=new URLSearchParams;e.status&&t.set("status",e.status),e.suppressed&&t.set("suppressed",e.suppressed),e.failures&&t.set("failures",e.failures);let n=(e.search||"").trim();return n&&t.set("search",n),e.sort&&(t.set("sort",e.sort),t.set("dir",e.dir??"desc")),t.set("limit",String(e.limit)),t.set("offset",String(e.offset)),t.toString()}function yt(e){let t=e.statuses?.length?o`<select class="lt-status" aria-label="Filter by status"><option value="${e.allValue||""}">All statuses</option>${e.statuses.map(i=>o`<option value="${i.value}">${i.label}</option>`)}</select>`:null,n=e.suppressible?o`<label class="lt-toggle"><input type="checkbox" class="lt-suppressed"><span>Suppressed</span></label>`:null,s=e.failures?o`<label class="lt-toggle"><input type="checkbox" class="lt-failures"><span>With delivery failures</span></label>`:null;return o`<div class="list-toolbar">
|
|
46
46
|
<input class="lt-search" type="search" placeholder="${e.searchPlaceholder||"Search\u2026"}" aria-label="Search" autocomplete="off">
|
|
47
47
|
<div class="lt-filters">${t}${n}${s}</div>
|
|
48
|
-
</div>`}function
|
|
49
|
-
${
|
|
48
|
+
</div>`}function wt(e,t,n){let s=e.querySelector(".lt-search"),i=e.querySelector(".lt-status"),r=e.querySelector(".lt-suppressed"),l=e.querySelector(".lt-failures");if(s){s.value=t.search||"";let a;s.oninput=()=>{clearTimeout(a),a=setTimeout(()=>{t.search=s.value,t.offset=0,n()},250)}}i&&(i.value=t.status||"",i.onchange=()=>{t.status=i.value,t.offset=0,n()}),r&&(r.checked=t.suppressed==="only",r.onchange=()=>{t.suppressed=r.checked?"only":"",t.offset=0,n()}),l&&(l.checked=t.failures==="only",l.onchange=()=>{t.failures=l.checked?"only":"",t.offset=0,n()})}function ye(e,t,n,s){if(!t)return s?o`<th class="${s}">${e}</th>`:o`<th>${e}</th>`;let i=n.sort===t,r=i?n.dir==="asc"?"\u2191":"\u2193":"",l=`${s?`${s} `:""}sortable${i?" sorted":""}`;return o`<th class="${l}"><button type="button" class="th-sort" data-sort="${t}">${e}<span class="th-arrow" aria-hidden="true">${r}</span></button></th>`}function Ke(e,t,n){for(let s of e.querySelectorAll(".th-sort"))s.onclick=()=>{let i=s.dataset.sort;t.sort===i?t.dir=t.dir==="asc"?"desc":"asc":(t.sort=i,t.dir="desc"),t.offset=0,n()}}function Ge(e,t,n,s){let i=n?.limit??t.limit;if(!n||n.total<=i){v(e,o``);return}let r=n.offset+1,l=Math.min(n.offset+i,n.total),a=n.offset>0,d=n.offset+i<n.total;if(v(e,o`<div class="pager"><button type="button" class="pager-prev"${a?null:pi}>← Prev</button><span class="pager-range muted">${r}–${l} of ${n.total}</span><button type="button" class="pager-next"${d?null:pi}>Next →</button></div>`),a){let u=e.querySelector(".pager-prev");u&&(u.onclick=()=>{t.offset=Math.max(0,n.offset-i),s()})}if(d){let u=e.querySelector(".pager-next");u&&(u.onclick=()=>{t.offset=n.offset+i,s()})}}var pi=o` disabled`;var Ka=3600,Ga=300,Ya=3,ys=new WeakMap;function B(e){let t=[...ns.querySelectorAll(".toast:not(.leaving)")],n=t.find(i=>i.textContent===e);if(n){mi(n);return}let s=document.createElement("div");s.className="toast",s.textContent=e,ns.appendChild(s),requestAnimationFrame(()=>s.classList.add("show")),mi(s);for(let i of t.slice(0,t.length+1-Ya))hi(i)}function mi(e){clearTimeout(ys.get(e)),ys.set(e,window.setTimeout(()=>hi(e),Ka))}function hi(e){clearTimeout(ys.get(e)),e.style.height=`${e.offsetHeight}px`,e.offsetHeight,e.classList.add("leaving"),e.style.height="0",setTimeout(()=>e.remove(),Ga)}var st=e=>o`<span class="badge ${e}">${e}</span>`,Za=0,bs=new WeakMap;function vi(e){let t=document.createElement("div");t.className="field-error",t.id=`field-error-${++Za}`,t.setAttribute("role","alert");let n=document.createElement("span");n.className="field-error-ico",n.setAttribute("aria-hidden","true"),n.textContent="!";let s=document.createElement("span");return s.textContent=e,t.append(n,s),t}function qe(e,t){bs.get(e)?.();let n=vi(t),s=e.getAttribute("aria-describedby");e.after(n),e.setAttribute("aria-describedby",s?`${s} ${n.id}`:n.id),e.classList.add("is-invalid");let i=!1,r=()=>{i||(i=!0,n.remove(),e.classList.remove("is-invalid"),s===null?e.removeAttribute("aria-describedby"):e.setAttribute("aria-describedby",s),e.removeEventListener("input",r),bs.delete(e))};return e.addEventListener("input",r),bs.set(e,r),r}function we(e,t){let n=document.createElement("div");n.className="modal-backdrop",v(n,o`<div class="modal" role="dialog" aria-modal="true">${e}</div>`);let s=n.firstElementChild,i=document.activeElement instanceof HTMLElement?document.activeElement:null,r=p=>{n===[...document.querySelectorAll(".modal-backdrop")].pop()&&(p.key==="Escape"?u():p.key==="Tab"&&Ja(s,p))},l=null,a=()=>{l?.(),l=null},d=(p,m)=>{if(a(),m){l=qe(m,p),m.focus();return}let y=vi(p);y.classList.add("dialog-error");let k=s.querySelector(".actions");k?k.before(y):s.append(y),l=()=>y.remove()},u=()=>{let p=document.activeElement,m=n.contains(p)||!p||p===document.body;n.remove(),document.removeEventListener("keydown",r),t.removeEventListener("abort",u),m&&i?.isConnected&&i.focus()};return t.aborted?{el:n,close:u,error:d,clearError:a}:(document.body.appendChild(n),n.addEventListener("click",p=>{p.target===n&&u()}),document.addEventListener("keydown",r),t.addEventListener("abort",u,{once:!0}),queueMicrotask(()=>{n.isConnected&&!s.contains(document.activeElement)&&gi(s)[0]?.focus()}),{el:n,close:u,error:d,clearError:a})}var za='a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';function gi(e){return[...e.querySelectorAll(za)].filter(t=>!t.closest("[hidden]"))}function Ja(e,t){let n=gi(e),s=n[0],i=n[n.length-1];if(!s||!i){t.preventDefault();return}let r=document.activeElement;e.contains(r)?t.shiftKey&&r===s?(t.preventDefault(),i.focus()):!t.shiftKey&&r===i&&(t.preventDefault(),s.focus()):(t.preventDefault(),s.focus())}var fi=8;function Qa(e){let t=getComputedStyle(e,"::after"),n=parseFloat(t.width)+parseFloat(t.paddingLeft)+parseFloat(t.paddingRight);if(!Number.isFinite(n))return;let s=e.getBoundingClientRect().left,i=document.documentElement.clientWidth,r=Math.min(0,Math.max(i-fi-n-s,fi-s));e.style.setProperty("--tip-x",`${Math.round(r)}px`)}function bi(){for(let e of["pointerover","focusin"])document.addEventListener(e,t=>{let n=t.target instanceof Element?t.target.closest(".info"):null;n&&Qa(n)});document.addEventListener("keydown",e=>{let t=document.activeElement;e.key==="Escape"&&t instanceof HTMLElement&&t.classList.contains("info")&&t.blur()})}function Hn(e,{below:t=!1}={}){return o`<span class="info${t?" tip-below":""}" role="img" tabindex="0" aria-label="${e}" data-tip="${e}">${P("info-filled")}</span>`}var Ln=[],ws=null,St=null;function nt(){let e=Ln.some(t=>t.contains(document.activeElement));for(let t of Ln)t.remove();Ln=[],ws?.removeEventListener("abort",nt),ws=null,St?.setAttribute("aria-expanded","false"),e&&St?.isConnected&&St.focus(),St=null}function $t(e,t,n,{align:s="end"}={}){if(nt(),n.aborted)return;let i=document.createElement("div");i.className="menu-overlay",i.onclick=nt;let r=document.createElement("div");r.className="menu",r.setAttribute("role","menu");for(let a of t){let d=document.createElement("button");if(d.type="button",d.setAttribute("role","menuitem"),d.tabIndex=-1,d.className=`menu-item${a.danger?" danger-item":""}`,a.description){let u=document.createElement("span");u.className="menu-item-name",u.textContent=a.label;let p=document.createElement("span");p.className="menu-item-desc",p.textContent=a.description,d.append(u,p),r.classList.add("menu-described")}else d.textContent=a.label;d.onclick=()=>{nt(),a.onClick()},r.appendChild(d)}r.addEventListener("keydown",a=>Xa(r,a)),document.body.appendChild(i),document.body.appendChild(r),Ln=[i,r],ws=n,St=e instanceof HTMLElement?e:null,St?.setAttribute("aria-expanded","true"),n.addEventListener("abort",nt,{once:!0});let l=e.getBoundingClientRect();r.style.top=`${l.bottom+window.scrollY+4}px`,r.style.left=`${(s==="start"?l.left:l.right-r.offsetWidth)+window.scrollX}px`,r.querySelector(".menu-item")?.focus()}function Xa(e,t){let n=[...e.querySelectorAll(".menu-item")],s=n.indexOf(document.activeElement),i=r=>{t.preventDefault(),n[(r+n.length)%n.length]?.focus()};t.key==="ArrowDown"?i(s+1):t.key==="ArrowUp"?i(s-1):t.key==="Home"?i(0):t.key==="End"?i(n.length-1):t.key==="Escape"?(t.preventDefault(),t.stopPropagation(),nt()):t.key==="Tab"&&nt()}async function te(e,t,n){let s=e.textContent;e.disabled=!0,t&&(e.textContent=t);try{return await n()}finally{e.isConnected&&(e.disabled=!1,e.textContent=s)}}function ie(e,t,n){v(e,o`<div class="error"><span>${t}</span><button class="ghost" data-retry>Retry</button></div>`);let s=e.querySelector("[data-retry]");s&&(s.onclick=n)}var el=[{value:"draft",label:"Draft"},{value:"scheduled",label:"Scheduled"}],Ss="draft,scheduled";function $s(e){return te(e,"Creating\u2026",async()=>{try{let{post:t}=await L("/api/posts",{method:"POST",json:{}});location.hash=`#/edit/${t.id}`}catch(t){B(G(t))}})}async function Es(e,t){let n={status:Ss,search:"",sort:"",dir:"desc",offset:0,limit:50};v(e,o`<div class="spread page-head"><h1>Drafts</h1><button class="primary" id="newPost">New post</button></div>
|
|
49
|
+
${yt({statuses:el,allValue:Ss,searchPlaceholder:"Search subject\u2026"})}
|
|
50
50
|
<div id="list" class="muted">Loading…</div>
|
|
51
|
-
<div id="postsPager"></div>`);let s=
|
|
51
|
+
<div id="postsPager"></div>`);let s=c("#newPost",e);s.onclick=()=>$s(s);let i=c("#list",e),r=c("#postsPager",e),l=new Map,a=new Set,d=0,u=()=>p(!1);async function p(E){let $=++d;try{let x=await L(`/api/posts?${bt(n)}`,{signal:t});if($!==d)return null;let H=x.posts;if(l=new Map(H.map(h=>[h.id,h.fire_at])),a=new Set(H.flatMap(h=>h.active_send_id?[h.active_send_id]:[])),!H.length){let h=n.search||n.status&&n.status!==Ss;return v(i,o`<p class="muted">${h?"No drafts match.":"No drafts yet. Create your first draft."}</p>`),v(r,o``),x}v(i,o`<div class="table-wrap"><table class="list-table posts-table stacks has-actions"><colgroup><col><col class="c-status"><col class="c-date"><col class="c-date"><col class="c-act"></colgroup><thead><tr>${ye("Title","title",n)}${ye("Status",null,n)}${ye("Scheduled","scheduled",n)}${ye("Updated","updated",n)}<th></th></tr></thead><tbody>${H.map(h=>{let M=h.active_send_status==="sending",T=M?`#/sent/${h.active_send_id}`:`#/edit/${h.id}`;return o`<tr class="clickable" data-id="${h.id}" data-target="${T}"><td><a href="${T}">${h.subject||o`<em>untitled</em>`}</a></td><td>${M?st("sending"):st(h.status)}</td><td class="muted when${h.fire_at?"":" empty"}">${h.fire_at?Z(h.fire_at):"\u2014"}</td><td class="muted updated">${Z(h.updated_at)}</td><td class="act"><button class="icon" data-menu="${h.id}" data-status="${M?"sending":h.status}" data-target="${T}" aria-label="Post actions" aria-haspopup="menu">⋯</button></td></tr>`})}</tbody></table></div>`),Ke(i,n,u);for(let h of A("tr[data-id]",i))h.onclick=M=>{let T=M.target;T instanceof Element&&T.tagName!=="A"&&!T.closest("[data-menu]")&&h.dataset.target&&(location.hash=h.dataset.target)};for(let h of A("[data-menu]",i))h.onclick=M=>{M.stopPropagation();let T=h.dataset.status,V=h.dataset.target??"",Y=[{label:T==="sending"?"Watch send":"Open",onClick:()=>{location.hash=V}}];if(T==="draft"&&h.dataset.menu){let C=h.dataset.menu;Y.push({label:"Delete draft",danger:!0,onClick:()=>tl(C,u,t)})}$t(h,Y,t)};return Ge(r,n,x.page,u),x}catch(x){return!E&&$===d&&ie(i,G(x),()=>m?u():void k()),null}}let m=!1,y=E=>{m=!0,ft({cursor:E.cursor,sends:E.posts.flatMap($=>$.active_send_id&&$.active_send_status?[{id:$.active_send_id,status:$.active_send_status,phase:$.active_send_status==="sending"?"progressing":"scheduled"}]:[])},{update({sends:$,changes:x,removed:H}){(x.some(M=>l.has(M.send.post_id))||$.some(M=>M.status==="scheduled"&&l.has(M.post_id)&&l.get(M.post_id)!==M.fire_at)||H.some(M=>a.has(M)))&&p(!0)},stale:()=>z(Es)},t)},k=async()=>{let E=await p(!1);E&&!m&&y(E)};wt(e,n,u),await k()}function tl(e,t,n){let s=we(o`<h3>Delete draft?</h3><p class="hint">This permanently deletes the draft and its revisions. This can’t be undone.</p><div class="actions"><button type="button" id="dCancel">Cancel</button><button type="button" class="danger" id="dGo">Delete</button></div>`,n),i=c("#dGo",s.el);c("#dCancel",s.el).onclick=s.close,i.onclick=()=>te(i,"Deleting\u2026",async()=>{s.clearError();try{await L(`/api/posts/${e}`,{method:"DELETE"}),s.close(),B("Draft deleted"),t()}catch(r){s.error(G(r))}})}var Ut=e=>Math.max(0,Math.min(100,Math.round(e||0)));function Cn(e){if(e==null||!Number.isFinite(e)||e<=0)return"\u2014";let t=Math.round(e/1e3);if(t<90)return`${t}s`;let n=Math.round(t/60);return n<90?`${n} min`:`${Math.round(n/60)} hr`}function Rn(e){return o`<span class="countdown" data-fire="${e.fire_at}"${e.phase==="due"&&o` data-due`}${Ce(e,"missed")&&o` data-missed`}></span>`}function Mn(e,t){let n=0,s=()=>{let i=Date.now(),r=Number.POSITIVE_INFINITY,l=a=>{a>i&&a<r&&(r=a)};for(let a of A("[data-closes]",e)){let d=Number(a.dataset.closes);a.hidden=i>=d,l(d)}for(let a of A("[data-fire]",e)){let d=Number(a.dataset.fire),u=a.dataset.missed!==void 0;a.textContent=u?Tn(d):qt(d,a.dataset.due!==void 0),a.classList.toggle("countdown-missed",u),l(d)}r-i<=1e3&&r!==n&&(n=r,Nt(r,s,t))};return Je(1e3,s,t),s}var Ce=(e,t)=>e.conditions.some(n=>n.kind===t);function he(e,t){return e.conditions.find(n=>n.kind===t)}var Ue=(e,t)=>e.actions.some(n=>n.name===t);function Et(e,t){let n=new Set(e.map(s=>s.id));return[...e.map(s=>t.get(s.id)??s),...[...t.values()].filter(s=>!n.has(s.id))]}function Ft(e){return Ce(e,"wedged")||Ce(e,"refused")}function kt(e){let t=e.delivered||0,n=[];e.complained&&n.push(`${e.complained.toLocaleString()} complained`),e.bounced&&n.push(`${e.bounced.toLocaleString()} bounced`),e.unsent&&n.push(`${e.unsent.toLocaleString()} unsent`);let s=n.length?o`<span class="muted delivered-note">${n.map((i,r)=>o`${r?", ":""}<span class="delivered-kind">${i}</span>`)}</span>`:null;return o`<span class="n">${t.toLocaleString()}</span>${s}`}function An(e){let t=e.counts,n=t.accepted+t.delivered+t.bounced+t.complained,s=t.delivered+t.bounced+t.complained,i=e.audience.count,r=i>0?100*n/i:0,l=e.dispatch.eta_ms?` \xB7 ~${Cn(e.dispatch.eta_ms)} left`:"",a=he(e,"stuck");return o`<div class="card spread clickable active-card" data-watch="${e.id}">
|
|
52
52
|
<div class="active-main">
|
|
53
|
-
<a class="card-link active-subj" href="#/sent/${e.id}">${e.subject||
|
|
54
|
-
<div class="active-bar"><div class="active-fill" style="width:${
|
|
55
|
-
<div class="muted active-stat">Sending
|
|
56
|
-
${
|
|
53
|
+
<a class="card-link active-subj" href="#/sent/${e.id}">${e.subject||o`<em>untitled</em>`}</a>
|
|
54
|
+
<div class="active-bar"><div class="active-fill" style="width:${Ut(r)}%"></div></div>
|
|
55
|
+
<div class="muted active-stat">Sending: ${n.toLocaleString()} of ${i.toLocaleString()} accepted${s?` \xB7 ${s.toLocaleString()} confirmed`:""}${l}</div>
|
|
56
|
+
${a?o`<div class="active-stuck">${a.message}</div>`:null}
|
|
57
57
|
</div>
|
|
58
58
|
<a class="ghost-link" href="#/sent/${e.id}">Watch →</a>
|
|
59
|
-
</div>`}function
|
|
60
|
-
|
|
61
|
-
<ul class="remake-list">${e.map(y=>i`<li><span>${y.subject||i`<em>untitled</em>`}</span><span>sends ${K(y.fire_at)}</span></li>`)}</ul>
|
|
62
|
-
<dl class="remake-facts">
|
|
63
|
-
<div><dt>Changes</dt><dd>the look of ${s?"its email":"their emails"}</dd></div>
|
|
64
|
-
<div><dt>Stays</dt><dd>${s?"its content and fire time":"their content and fire times"}</dd></div>
|
|
65
|
-
<div><dt>Next</dt><dd>send yourself a fresh test${s?"":" of each"}</dd></div>
|
|
66
|
-
</dl>
|
|
67
|
-
<p class="hint">To leave scheduled emails as they are while future posts change, you'd need a second template, which Kestrel doesn't offer yet.</p>
|
|
68
|
-
<div class="actions"><button type="button" id="rmCancel">Cancel</button><button type="button" class="primary" id="rmGo">${t.action} and apply</button></div>`),r=!1,c=y=>{r||(r=!0,a(y))},u=l.el.querySelector("#rmCancel"),m=l.el.querySelector("#rmGo");u&&(u.onclick=()=>{l.close(),c(!1)}),m&&(m.onclick=()=>{l.close(),c(!0)});let p=new MutationObserver(()=>{l.el.isConnected||(p.disconnect(),c(!1))});p.observe(document.body,{childList:!0}),m?.focus()})}var Go={action:"Save",gerund:"Saving",lead:e=>`This template is used by ${e} post${e===1?" that's":"s that are"} already scheduled.`},At=(e,t)=>({action:e,gerund:t,lead:n=>`Your name, tagline, address, and logo ride inside every email, including ${n} post${n===1?" that's":"s that are"} already scheduled.`});function vn(e){let t=e?.sends||[];if(!t.length)return i`<span class="set-chip">${P("info")}No posts scheduled</span>`;let n=t.length,s=e?.retry_after?`One sends at ${K(e.retry_after)}; saving waits until it has sent.`:null;return s?i`<span class="set-chip inuse" title="${s}"><span class="set-chip-dot"></span>In use by ${n} scheduled post${n===1?"":"s"}</span>`:i`<span class="set-chip inuse"><span class="set-chip-dot"></span>In use by ${n} scheduled post${n===1?"":"s"}</span>`}var Rr=e=>`${new Date(e).toLocaleString(void 0,{month:"short",day:"numeric"})} at ${new Date(e).toLocaleString(void 0,{hour:"numeric",minute:"2-digit"})}`;function gn(e,t,n){let s=n?"this post":`${t} scheduled post${t===1?"":"s"}`;return i`A template or identity change made <strong>${Rr(e)}</strong> was applied to ${s}. Send a fresh test email to review the changes.`}function mt(e,t){let n=(t||[]).length;return n?`${e} and applied to ${n} scheduled email${n===1?"":"s"}. Send yourself a test of each.`:e}function bn(e){let t=de(),n=ve(i`<h3>Add subscriber</h3><p class="hint">Starts the normal double opt-in: they get a confirmation email and won't receive posts until they confirm.</p>${Xs()}${t?null:i`<label for="addEmail">Email address</label><input type="email" id="addEmail" placeholder="person@example.com">`}<div class="actions"><button type="button" id="aCancel">Cancel</button><button type="button" class="primary" id="aGo">Send confirmation</button></div>`),s=d("#aGo",n.el);if(d("#aCancel",n.el).onclick=n.close,s.disabled=t,t){d("#aCancel",n.el).focus();return}let o=d("#addEmail",n.el);o.focus(),s.onclick=()=>J(s,"Adding\u2026",async()=>{let a=it(o.value);if(!at(a)){$("Enter a valid email");return}try{let l=await k("/api/subscribers",{method:"POST",json:{email:a}});n.close(),$(Pr(a,l.action)),e?.()}catch(l){$(l instanceof Error?l.message:String(l))}})}function Pr(e,t){switch(t){case"already_confirmed":return`${e} is already confirmed`;case"suppressed":return`${e} is suppressed, so no confirmation was sent`;case"recently_sent":return`A confirmation went to ${e} a few minutes ago; try again later`;default:return`Confirmation sent to ${e}`}}function Yo(e,t){let n=ve(i`<h3>Unsubscribe this subscriber?</h3><p class="hint">Removes <strong>${e.email}</strong> from the send audience immediately. They can re-subscribe later through the double opt-in.</p><div class="actions"><button type="button" id="uCancel">Cancel</button><button type="button" class="danger" id="uGo">Unsubscribe</button></div>`),s=d("#uGo",n.el);d("#uCancel",n.el).onclick=n.close,s.onclick=()=>J(s,"Unsubscribing\u2026",async()=>{try{await k(`/api/subscribers/${e.id}/unsubscribe`,{method:"POST"}),n.close(),$(`Unsubscribed ${e.email}`),t()}catch(o){$(o instanceof Error?o.message:String(o))}})}var Mr=1500;function Ie(e=!1,t="Copy"){return i`<span>${t}</span>${P(e?"check":"copyout")}`}function ft(e,t){return i`<button type="button" class="copy-btn" data-copy="${e}" aria-label="${t}">${Ie()}</button>`}function is(e,t,n){return i`<div class="block"><div class="block-head"><span class="block-label">${e}</span>${ft(n,`Copy ${e}`)}</div><pre><code>${t}</code></pre></div>`}var os=new WeakMap;async function as(e,t){try{await navigator.clipboard.writeText(t)}catch{$("Couldn't copy to clipboard");return}let n=os.get(e),s=n?.label??e.getAttribute("aria-label")??"Copy",o=e.dataset.copyWord;window.clearTimeout(n?.timer),g(e,Ie(!0,o)),e.setAttribute("aria-label","Copied"),e.classList.add("copied");let a=window.setTimeout(()=>{g(e,Ie(!1,o)),e.setAttribute("aria-label",s),e.classList.remove("copied"),os.delete(e)},Mr);os.set(e,{label:s,timer:a})}function ht(e){e.addEventListener("click",t=>{let n=t.target instanceof Element?t.target.closest("[data-copy]"):null;n&&e.contains(n)&&as(n,n.dataset.copy??"")})}var zo="kestrel.notices",Zo=100;function Jo(){try{let e=JSON.parse(localStorage.getItem(zo)||"{}");return e&&typeof e=="object"&&!Array.isArray(e)?e:{}}catch{return{}}}function Ar(e){try{localStorage.setItem(zo,JSON.stringify(e))}catch{}}var Qo=(e,t)=>`${e}|${t}`;function _r(e,t){let n=Jo(),s=Date.now();for(let{subject:a,version:l}of t)n[Qo(e,a)]={v:String(l),t:s};let o=Object.keys(n);if(o.length>Zo){o.sort((a,l)=>(n[a]?.t||0)-(n[l]?.t||0));for(let a of o.slice(0,o.length-Zo))delete n[a]}Ar(n)}function yn(e,t){if(!e)return null;let{kind:n,members:s,markup:o}=t,a=s??[{subject:t.subject??"",version:t.version??""}],l=s?`${n}|*`:`${n}|${t.subject??""}`,r=a.map(y=>`${y.subject}@${y.version}`).join(","),c=[...e.children].find(y=>y instanceof HTMLElement&&y.dataset.notice===l),u=Jo();if(a.every(y=>u[Qo(n,y.subject)]?.v===String(y.version)))return c?.remove(),null;if(c?.dataset.noticeEvents===r)return c;let m=document.createElement("div");m.className="notice",m.setAttribute("role","status"),m.dataset.notice=l,m.dataset.noticeEvents=r,g(m,i`<span class="notice-text">${o}</span><button type="button" class="icon notice-dismiss" aria-label="Dismiss">${P("x")}</button>`);let p=m.querySelector(".notice-dismiss");if(p&&(p.onclick=()=>{_r(n,a),m.remove()}),c){let y=c.querySelector(".notice-dismiss")===document.activeElement;c.replaceWith(m),y&&p?.focus()}else e.appendChild(m);return m}function Xo(e){let t=[],n=e.filter(p=>Te(p,"missed"));n.length&&t.push({level:"red",text:`${n.length} scheduled send${n.length===1?"":"s"} passed the fire time without going out.`});let s=e.flatMap(p=>{let y=Ee(p,"wedged");return y?[{s:p,count:y.count}]:[]}),[o]=s;if(o){let p=s.reduce((x,E)=>x+E.count,0),y=p===1?"delivery":"deliveries";t.push({level:"red",text:s.length===1?i`<a href="#/sent/${o.s.id}">${o.s.subject}</a> has ${p} ambiguous ${y} awaiting a decision; resolve ${p===1?"it":"them"} on its page.`:i`${p} ambiguous ${y} across <a href="#/sent">${s.length} sends</a> await a decision; resolve them on the Sent page.`})}let a=e.flatMap(p=>{let y=Ee(p,"refused");return y?[{s:p,c:y}]:[]}),[l]=a;if(l){let p=a.length===1?i`<a href="#/sent/${l.s.id}">${l.s.subject}</a>`:i`<a href="#/sent">${a.length} sends</a>`;t.push({level:"red",text:i`${p}: ${l.c.message}`})}let r=e.filter(p=>Te(p,"stuck")&&!Mt(p)),[c]=r;c&&t.push({level:"amber",text:r.length===1?i`<a href="#/sent/${c.id}">${c.subject}</a>: ${Ee(c,"stuck")?.message}`:i`<a href="#/sent">${r.length} sends</a> have been sending longer than they should; they may be retrying.`});let u=e.flatMap(p=>{let y=Ee(p,"bounce_spike");return y?[{s:p,c:y}]:[]}),[m]=u;return m&&t.push({level:"amber",text:i`<a href="#/sent/${m.s.id}">${m.s.subject}</a>: ${m.c.message}`}),de()&&t.push({level:"amber",text:i`<span class="dash-not-connected">No email provider connected, so nothing can be sent yet. ${_e()} to start sending.</span>`}),t}function ei(e){if(!e.length)return i``;let t=e.some(n=>n.level==="red")?"red":"amber";return i`<div class="health ${t}"><span class="health-dot">⚠️</span><div>${e.map(n=>i`<div>${n.text}</div>`)}</div></div>`}var ti=e=>e.filter(t=>t.status==="sending"&&!Mt(t));async function rs(e,t){let n=()=>{t.aborted||G(rs)};g(e,i`<div class="dash" id="dash"><p class="muted">Loading…</p></div>`);let s=d("#dash",e),o,a,l;try{let[v,S,U]=await Promise.all([k("/api/posts?limit=200",{signal:t}),k("/api/sends?limit=200",{signal:t}),k("/api/subscribers",{signal:t})]);o=v.posts,a=S,l=U.counts}catch(v){X(s,v instanceof Error?v.message:String(v),n);return}let r=a.sends,c=new Map,u=Gn(W.appConfig),m=W.appConfig?.deployment??null,p=l.confirmed+l.pending+l.unsubscribed+l.suppressed;if(!o.length&&p===0){g(s,i`<div class="dash-head"><div><h1>${u.name}</h1>${u.tagline?i`<p class="muted dash-tagline">${u.tagline}</p>`:null}<p class="muted">Let's get your first post out the door.</p></div></div>${Br(u,m)}<section class="dash-section"><h2>API access</h2>${ci(!1)}</section>`),di(s,n);return}let y=[{label:"Confirmed",emph:!0,v:l.confirmed,filter:"confirmed"},{label:"Pending",v:l.pending,filter:"pending"},{label:"Unsubscribed",v:l.unsubscribed,filter:"unsubscribed"},{label:"Suppressed",v:l.suppressed,filter:"suppressed"}],x=i`<div class="tiles">${y.map(v=>i`<a class="tile${v.emph?" tile-emph":""}" href="#/subscribers/${v.filter}"><span class="tile-n">${v.v}</span><span class="tile-label">${v.label}${v.sub?i`<span class="tile-sub">${v.sub}</span>`:null}</span></a>`)}</div>`,E=o.filter(v=>v.status==="draft").slice(0,5),N=E.length?i`<div class="table-wrap"><table><tbody>${E.map(v=>i`<tr class="clickable" data-id="${v.id}"><td><a href="#/edit/${v.id}">${v.subject||i`<em>untitled</em>`}</a></td><td class="muted">edited ${K(v.updated_at)}</td></tr>`)}</tbody></table></div>`:i`<p class="muted">No drafts in progress.</p>`,A=m?.appOrigin||location.origin,H=(m?.archiveOrigin||location.origin)+(m?.archiveBasePath||""),h=i`<div class="card pub-card">
|
|
69
|
-
<div class="pub-row"><span class="pub-key muted">Publication</span><code class="pub-val">${A}</code>${ft(A,"Copy the publication's address")}</div>
|
|
70
|
-
<div class="pub-row"><span class="pub-key muted">Archive</span><code class="pub-val">${H}</code>${ft(H,"Copy the archive's address")}</div>
|
|
59
|
+
</div>`}function Pn(e,t){let n=de(),s=we(o`<h3>Add subscriber</h3><p class="hint">Starts the normal double opt-in: they get a confirmation email and won’t receive posts until they confirm.</p>${ko()}${n?null:o`<label for="addEmail">Email address</label><input type="email" id="addEmail" placeholder="person@example.com">`}<div class="actions"><button type="button" id="aCancel">Cancel</button><button type="button" class="primary" id="aGo">Send confirmation</button></div>`,t),i=c("#aGo",s.el);if(c("#aCancel",s.el).onclick=s.close,i.disabled=n,n){c("#aCancel",s.el).focus();return}let r=c("#addEmail",s.el);r.focus(),i.onclick=()=>te(i,"Adding\u2026",async()=>{s.clearError();let l=ht(r.value);if(!vt(l)){s.error("Enter a valid email address",r);return}try{let a=await L("/api/subscribers",{method:"POST",json:{email:l}});s.close(),B(nl(l,a.action)),e()}catch(a){s.error(G(a),a instanceof J&&a.field==="email"?r:void 0)}})}function nl(e,t){switch(t){case"already_confirmed":return`${e} is already confirmed`;case"suppressed":return`${e} is suppressed, so no confirmation was sent`;case"recently_sent":return`A confirmation went to ${e} a few minutes ago; try again later`;default:return`Confirmation sent to ${e}`}}function yi(e,t,n){let s=we(o`<h3>Unsubscribe this subscriber?</h3><p class="hint">Removes <strong>${e.email}</strong> from the send audience immediately. They can re-subscribe later through the double opt-in.</p><div class="actions"><button type="button" id="uCancel">Cancel</button><button type="button" class="danger" id="uGo">Unsubscribe</button></div>`,n),i=c("#uGo",s.el);c("#uCancel",s.el).onclick=s.close,i.onclick=()=>te(i,"Unsubscribing\u2026",async()=>{s.clearError();try{await L(`/api/subscribers/${e.id}/unsubscribe`,{method:"POST"}),s.close(),B(`Unsubscribed ${e.email}`),t()}catch(r){s.error(G(r))}})}var sl=1500;function Fe(e=!1,t="Copy"){return o`<span>${t}</span>${P(e?"check":"copyout")}`}function jt(e,t){return o`<button type="button" class="copy-btn" data-copy="${e}" aria-label="${t}">${Fe()}</button>`}function xs(e,t,n){return o`<div class="block"><div class="block-head"><span class="block-label">${e}</span>${jt(n,`Copy ${e}`)}</div><pre><code>${t}</code></pre></div>`}var ks=new WeakMap;async function Ts(e,t){try{await navigator.clipboard.writeText(t)}catch{B("Couldn\u2019t copy to clipboard");return}let n=ks.get(e),s=n?.label??e.getAttribute("aria-label")??"Copy",i=e.dataset.copyWord;window.clearTimeout(n?.timer),v(e,Fe(!0,i)),e.setAttribute("aria-label","Copied"),e.classList.add("copied");let r=window.setTimeout(()=>{v(e,Fe(!1,i)),e.setAttribute("aria-label",s),e.classList.remove("copied"),ks.delete(e)},sl);ks.set(e,{label:s,timer:r})}function xt(e){e.addEventListener("click",t=>{let n=t.target instanceof Element?t.target.closest("[data-copy]"):null;n&&e.contains(n)&&Ts(n,n.dataset.copy??"")})}function wi(e){let t=[],n=e.filter($=>Ce($,"missed"));n.length&&t.push({level:"red",text:`${n.length} scheduled send${n.length===1?"":"s"} passed the fire time without going out.`});let s=e.flatMap($=>{let x=he($,"wedged");return x?[{s:$,count:x.count}]:[]}),[i]=s;if(i){let $=s.reduce((H,h)=>H+h.count,0),x=$===1?"delivery":"deliveries";t.push({level:"red",text:s.length===1?o`<a href="#/sent/${i.s.id}">${i.s.subject}</a> has ${$} ambiguous ${x} awaiting a decision; resolve ${$===1?"it":"them"} on its page.`:o`${$} ambiguous ${x} across <a href="#/sent">${s.length} sends</a> await a decision; resolve them on the Sent page.`})}let r=de(),l=e.flatMap($=>{let x=he($,"refused");return x?[{s:$,c:x}]:[]}),a=r?l.filter($=>$.c.cause==="not_connected"):[],d=l.filter($=>!a.includes($)),u=$=>$.length===1&&$[0]?o`<a href="#/sent/${$[0].s.id}">${$[0].s.subject}</a>`:o`<a href="#/sent">${$.length} sends</a>`;a.length&&t.push({level:"red",text:o`<span class="dash-not-connected">${mn(u(a),a.length)}</span>`});let[p]=d;p&&t.push({level:"red",text:o`${u(d)}: ${p.c.message}`});let m=e.filter($=>Ce($,"stuck")&&!Ft($)),[y]=m;y&&t.push({level:"amber",text:m.length===1?o`<a href="#/sent/${y.id}">${y.subject}</a>: ${he(y,"stuck")?.message}`:o`<a href="#/sent">${m.length} sends</a> have been sending longer than they should; they may be retrying.`});let k=e.flatMap($=>{let x=he($,"bounce_spike");return x?[{s:$,c:x}]:[]}),[E]=k;return E&&t.push({level:"amber",text:o`<a href="#/sent/${E.s.id}">${E.s.subject}</a>: ${E.c.message}`}),r&&!a.length&&t.push({level:"amber",text:o`<span class="dash-not-connected">No email provider connected, so nothing can be sent yet. ${He()} to start sending.</span>`}),t}function Si(e){if(!e.length)return o``;let t=e.some(n=>n.level==="red")?"red":"amber";return o`<div class="health ${t}"><span class="health-dot">⚠️</span><div>${e.map(n=>o`<div>${n.text}</div>`)}</div></div>`}var $i=e=>e.filter(t=>t.status==="sending"&&!Ft(t));async function Ls(e,t){let n=()=>{t.aborted||z(Ls)};v(e,o`<div class="dash" id="dash"><p class="muted">Loading…</p></div>`);let s=c("#dash",e),i,r,l;try{let[b,g,S]=await Promise.all([L("/api/posts?limit=200",{signal:t}),L("/api/sends?limit=200",{signal:t}),L("/api/subscribers",{signal:t})]);i=b.posts,r=g,l=S.counts}catch(b){ie(s,G(b),n);return}let a=r.sends,d=new Map,u=cs(K.appConfig),p=K.appConfig?.deployment??null,m=l.confirmed+l.pending+l.unsubscribed+l.suppressed;if(!i.length&&m===0){v(s,o`<div class="dash-head"><div><h1>${u.name}</h1>${u.tagline?o`<p class="muted dash-tagline">${u.tagline}</p>`:null}<p class="muted">Let’s get your first post out the door.</p></div></div>${il(u,p)}<section class="dash-section"><h2>API access</h2>${Mi(!1)}</section>`),Ri(s,n,t);return}let y=[{label:"Confirmed",emph:!0,v:l.confirmed,filter:"confirmed"},{label:"Pending",v:l.pending,filter:"pending"},{label:"Unsubscribed",v:l.unsubscribed,filter:"unsubscribed"},{label:"Suppressed",v:l.suppressed,filter:"suppressed"}],k=o`<div class="tiles">${y.map(b=>o`<a class="tile${b.emph?" tile-emph":""}" href="#/subscribers/${b.filter}"><span class="tile-n">${b.v}</span><span class="tile-label">${b.label}${b.sub?o`<span class="tile-sub">${b.sub}</span>`:null}</span></a>`)}</div>`,E=i.filter(b=>b.status==="draft").slice(0,5),$=E.length?o`<div class="table-wrap"><table><tbody>${E.map(b=>o`<tr class="clickable" data-id="${b.id}"><td><a href="#/edit/${b.id}">${b.subject||o`<em>untitled</em>`}</a></td><td class="muted">edited ${Z(b.updated_at)}</td></tr>`)}</tbody></table></div>`:o`<p class="muted">No drafts in progress.</p>`,x=p?.appOrigin||location.origin,H=o`<div class="card pub-card">
|
|
60
|
+
<div class="pub-row"><code class="pub-val">${x}</code>${jt(x,"Copy the publication\u2019s address")}</div>
|
|
71
61
|
<div class="pub-foot"><a href="/" target="_blank" rel="noopener">View publication ↗</a></div>
|
|
72
|
-
</div>`,
|
|
62
|
+
</div>`,h=i.some(b=>b.author==="service"),M=Mi(h),T=o`<div class="row quick-actions"><button data-act="new-post">New post</button><button data-act="add-sub">Add subscriber</button><button data-nav="#/settings">Edit publication</button></div>`;v(s,o`
|
|
73
63
|
<div class="dash-head">
|
|
74
|
-
<div><h1>${u.name}</h1>${u.tagline?
|
|
64
|
+
<div><h1>${u.name}</h1>${u.tagline?o`<p class="muted dash-tagline">${u.tagline}</p>`:null}</div>
|
|
75
65
|
<button class="primary" data-act="new-post">New post</button>
|
|
76
66
|
</div>
|
|
77
|
-
<div id="dashHealth">${
|
|
78
|
-
<div id="dashActive">${
|
|
79
|
-
<section class="dash-section"><h2>Subscribers</h2>${
|
|
80
|
-
<div id="dashNotices"></div>
|
|
67
|
+
<div id="dashHealth">${Si(wi(Et(a,d)))}</div>
|
|
68
|
+
<div id="dashActive">${Hi($i(Et(a,d)))}</div>
|
|
69
|
+
<section class="dash-section"><h2>Subscribers</h2>${k}</section>
|
|
81
70
|
<div class="dash-cols">
|
|
82
|
-
<section class="dash-section"><h2>Scheduled</h2><div id="dashScheduled">${
|
|
83
|
-
<section class="dash-section"><h2>Drafts</h2>${
|
|
71
|
+
<section class="dash-section"><h2>Scheduled</h2><div id="dashScheduled">${Ti(Ei(a))}</div></section>
|
|
72
|
+
<section class="dash-section"><h2>Drafts</h2>${$}</section>
|
|
84
73
|
</div>
|
|
85
|
-
<section class="dash-section"><h2>Sent</h2><div id="dashSent">${
|
|
86
|
-
<section class="dash-section"><h2>Quick actions</h2>${
|
|
74
|
+
<section class="dash-section"><h2>Sent</h2><div id="dashSent">${ki(a)}</div></section>
|
|
75
|
+
<section class="dash-section"><h2>Quick actions</h2>${T}</section>
|
|
87
76
|
<div class="dash-cols">
|
|
88
|
-
<section class="dash-section"><h2>Publication</h2>${
|
|
89
|
-
<section class="dash-section"><h2>API access</h2>${
|
|
90
|
-
</div>`),
|
|
77
|
+
<section class="dash-section"><h2>Publication</h2>${H}</section>
|
|
78
|
+
<section class="dash-section"><h2>API access</h2>${M}</section>
|
|
79
|
+
</div>`),Ri(s,n,t);for(let b of A("tr[data-id]",s))b.onclick=g=>{let S=g.target;S instanceof Element&&S.tagName!=="A"&&!S.closest("button")&&(location.hash=`#/edit/${b.dataset.id}`)};xi(s),Ci(s),Li(s);let V=Mn(s,t);V();let Y=0,C=async()=>{let b=++Y;try{let g=await L("/api/sends?limit=200",{signal:t});if(b!==Y)return;a=g.sends,v(c("#dashScheduled",s),Ti(Ei(a))),Li(s),V(),v(c("#dashSent",s),ki(a)),xi(s),D()}catch{}},D=()=>{let b=Et(a,d);v(c("#dashHealth",s),Si(wi(b))),v(c("#dashActive",s),Hi($i(b))),Ci(s),ol(s,[...d.values()])},N=b=>b==="scheduled"||b==="due";ft(r,{update:b=>{for(let g of b.sends)d.set(g.id,g);for(let g of b.removed)d.delete(g);D(),(b.changes.length||b.removed.length||b.sends.some(g=>N(We(g))))&&C()},stale:n},t)}var Ei=e=>e.filter(t=>t.status==="scheduled").sort((t,n)=>t.fire_at-n.fire_at);function ki(e){let t=e.filter(n=>n.status==="sent"||n.status==="sending").slice(0,5);return t.length?o`<div class="table-wrap"><table><thead><tr><th>Subject</th><th>Status</th><th class="num">Recipients</th><th class="num">Delivered</th><th></th></tr></thead><tbody>${t.map(n=>{let s=n.links.archive;return o`<tr class="clickable" data-send="${n.id}"><td><a href="#/sent/${n.id}">${n.subject}</a></td><td>${st(n.status)}</td><td class="num">${n.audience.count.toLocaleString()}</td><td class="num delivered">${kt(n.counts)}</td><td class="act">${s?o`<a class="ghost-link" href="${s}" target="_blank" rel="noopener">Archive ↗</a>`:null}</td></tr>`})}</tbody></table></div>`:o`<p class="muted">No sends yet.</p>`}function xi(e){for(let t of A("tr[data-send]",e))t.onclick=n=>{let s=n.target;s instanceof Element&&s.tagName!=="A"&&(location.hash=`#/sent/${t.dataset.send}`)}}function ol(e,t){for(let n of t){let s=A("#dashSent tr[data-send]",e).find(i=>i.dataset.send===n.id)?.querySelector("td.delivered");s&&v(s,kt(n.counts))}}function Ti(e){if(!e.length)return o`<p class="muted">Nothing scheduled.</p>`;let t=hn();return o`${e.map(n=>o`<div class="card spread clickable nextup sched-card" data-post="${n.post_id}"><div><a class="card-link sched-subj" href="#/edit/${n.post_id}">${n.subject}</a><div class="muted">${Rn(n)} · ${Z(n.fire_at)} · ${n.audience.count} recipients</div>${t}${Ce(n,"remade")?o`<div class="sched-remade-mark">Look changed since your last test</div>`:null}</div></div>`)}`}function Li(e){for(let t of A("#dashScheduled .nextup",e))t.onclick=n=>{let s=n.target;s instanceof Element&&s.tagName!=="A"&&(location.hash=`#/edit/${t.dataset.post}`)}}function Hi(e){return e.length?o`<section class="dash-section"><h2>Active send${e.length===1?"":"s"}</h2>${e.map(An)}</section>`:o``}function Ci(e){for(let t of A("#dashActive .active-card[data-watch]",e))t.onclick=n=>{let s=n.target;s instanceof Element&&s.tagName!=="A"&&(location.hash=`#/sent/${t.dataset.watch}`)}}function Ri(e,t,n){for(let s of A("[data-nav]",e))s.onclick=()=>{location.hash=s.dataset.nav??""};for(let s of A("[data-act='new-post']",e))s.onclick=()=>$s(s);for(let s of A("[data-act='add-sub']",e))s.onclick=()=>Pn(t,n);xt(e)}function Mi(e){return e?o`<div class="card pub-card">
|
|
91
80
|
<p class="conn-status"><span class="conn-dot" aria-hidden="true"></span>Claude is connected.</p>
|
|
92
81
|
<div class="pub-foot pub-links"><a href="#/reference">${P("code")}API reference →</a><a href="#/docs/connect-claude">${P("article")}Connection guide →</a></div>
|
|
93
|
-
</div>`:
|
|
82
|
+
</div>`:o`<div class="card pub-card">
|
|
94
83
|
<p class="pub-note">Let Claude draft, proofread, and schedule your posts.</p>
|
|
95
84
|
<div class="pub-foot pub-links"><a href="#/docs/connect-claude">${P("article")}Connect Claude →</a><a href="#/reference">${P("code")}API reference →</a></div>
|
|
96
|
-
</div>`}function
|
|
85
|
+
</div>`}function il(e,t){let n=`${t?.appOrigin||location.origin}/subscribe`,s=de();return o`<div class="card setup">
|
|
97
86
|
<h2 class="setup-title">Set up your publication</h2>
|
|
98
87
|
<ol class="setup-steps">
|
|
99
88
|
<li><div class="setup-step-main"><strong>Name your publication</strong><span class="muted">Currently “${e.name}”. Set the name, tagline, and brand in Settings.</span></div><button data-nav="#/settings">Settings</button></li>
|
|
100
|
-
${s?
|
|
89
|
+
${s?o`<li class="setup-connect"><div class="setup-step-main"><strong>Connect Amazon SES</strong><span class="muted">Nothing can be sent, and signups stay closed, until SES is connected.</span></div><button data-nav="${ps}">Connect Amazon SES</button></li>`:o`<li><div class="setup-step-main"><strong>Confirm your sending domain</strong><span class="muted">SPF, DKIM, and DMARC on your From address. The setup guide walks through it.</span></div><button data-nav="#/docs/sending-domain-dns">Docs</button></li>`}
|
|
101
90
|
<li><div class="setup-step-main"><strong>Write your first post</strong><span class="muted">Draft a post in Markdown and preview it exactly as the email.</span></div><button class="primary" data-act="new-post">New post</button></li>
|
|
102
|
-
${s?
|
|
91
|
+
${s?o`<li class="setup-subscribe"><div class="setup-step-main"><strong>Share your subscribe link</strong><span class="muted">Signups open once an email provider is connected; until then the page says they aren’t open yet.</span></div></li>`:o`<li class="setup-subscribe"><div class="setup-step-main"><strong>Share your subscribe link</strong><code class="setup-url">${n}</code></div><button type="button" data-copy="${n}" aria-label="Copy the subscribe link">${Fe()}</button></li>`}
|
|
103
92
|
</ol>
|
|
104
|
-
</div>`}var
|
|
105
|
-
<p class="hint">A transport error left ${
|
|
106
|
-
<p class="hint"
|
|
107
|
-
<p class="hint"><strong>Assume sent</strong
|
|
108
|
-
<
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
<
|
|
116
|
-
|
|
93
|
+
</div>`}var Hs=G;function _n(e,t,n){let s=e.c_in_flight||0,r=we(o`<h3>Resolve ${s} ambiguous ${s===1?"delivery":"deliveries"}</h3>
|
|
94
|
+
<p class="hint">A transport error left ${s} recipient${s===1?"":"s"} in flight: the request went out but the provider never confirmed, so there’s no way to know whether it was accepted. To avoid mailing anyone twice, the send won’t retry ${s===1?"it":"them"} on its own, so it can’t finish until you decide. Neither choice re-sends this post.</p>
|
|
95
|
+
<p class="hint">Kestrel can’t tell whether ${s===1?"this email":"these emails"} went out, so unless you know, <strong>Assume not sent</strong> is the safe default.</p>
|
|
96
|
+
<p class="hint"><strong>Assume not sent</strong>: recorded as unsent. ${s===1?"That reader":"Those readers"} may miss this post but ${s===1?"gets":"get"} your next one, and no one is mailed twice.</p>
|
|
97
|
+
<p class="hint"><strong>Assume sent</strong>: recorded as accepted by your provider, with delivery still waiting on its receipt. Choose this when you know otherwise, such as a reader who says they got it.</p>
|
|
98
|
+
<div class="actions"><button type="button" id="rCancel">Cancel</button><button type="button" id="rUnsent">Assume not sent</button><button type="button" class="primary" id="rAccepted">Assume sent</button></div>`,n);c("#rCancel",r.el).onclick=r.close;let l=(u,p,m)=>te(u,"Resolving\u2026",async()=>{r.clearError();try{let y=await L(`/api/sends/${e.id}/resolve`,{method:"POST",json:{resolution:p}});r.close(),B(y.completed?"Send completed":`Marked ${m}`),t()}catch(y){r.error(Hs(y))}}),a=c("#rUnsent",r.el),d=c("#rAccepted",r.el);a.onclick=()=>l(a,"unsent","not sent"),d.onclick=()=>l(d,"accepted","sent")}function Nn(e,t,n,s){let i=gt(pn()),r=gt(new Date(t)),l=we(o`<h3 id="rsHead">Reschedule this post</h3>
|
|
99
|
+
<p class="hint">Move when it sends. The content stays frozen and the cancelable window is kept. Only the time changes.</p>
|
|
100
|
+
${To()}
|
|
101
|
+
<label for="rsWhen">Send at</label><input type="datetime-local" id="rsWhen" min="${i}" value="${r}">
|
|
102
|
+
<div class="actions"><button type="button" id="rsCancel">Cancel</button><button type="button" class="primary" id="rsGo">Reschedule</button></div>`,s),a=c(".modal",l.el);a.setAttribute("aria-labelledby","rsHead");let d=c("#rsGo",a),u=c("#rsWhen",a);c("#rsCancel",a).onclick=l.close,d.onclick=()=>te(d,"Rescheduling\u2026",async()=>{l.clearError();let p=u.value,m=p?new Date(p).getTime():Number.NaN;if(Number.isNaN(m)){l.error("Pick a valid date & time",u);return}if(un(m)){l.error(dn(),u);return}try{await L(`/api/sends/${e}/reschedule`,{method:"POST",json:{fire_at:new Date(m).toISOString()}}),l.close(),B("Rescheduled"),n()}catch(y){if(y instanceof J&&y.status===404){l.close(),B("This post was deleted elsewhere."),tt();return}if(y instanceof J&&y.field==="fire_at"){l.error(Hs(y),u);return}l.error(Hs(y))}}),u.focus()}function Ai(e,t={}){return["default-src 'none'","script-src 'none'","object-src 'none'","frame-src 'none'","base-uri 'none'",`form-action ${e}`,t.framed?null:"frame-ancestors 'none'","img-src * data:","style-src 'unsafe-inline' https://fonts.googleapis.com","font-src https://fonts.gstatic.com"].filter(n=>n!==null).join("; ")}var Pi=e=>e.preventDefault(),Tt="allow-same-origin allow-popups allow-popups-to-escape-sandbox";function Lt(e){for(let t of e.querySelectorAll("a[href], area[href]")){if(t.getAttribute("href")?.startsWith("#")){t.addEventListener("click",Pi),t.addEventListener("auxclick",Pi);continue}t.target="_blank",t.rel="noopener noreferrer"}}function rl(e,t){let n=/<head(\s[^>]*)?>/i;return n.test(e)?e.replace(n,s=>s+t):t+e}function In(e){return rl(e,`<meta http-equiv="Content-Security-Policy" content="${Ai("'none'",{framed:!0})}">`)}function _i(e){return e.replace(/\{\{\s*(?:if\s+)?[\w.]+\s*\}\}/g,t=>`<span class="cx-var">${t}</span>`)}function al(e){let t=se(e);return t=t.replace(/"[^&]*?"/g,n=>`<span class="cx-str">${n}</span>`),t=t.replace(/(<\/?)([a-zA-Z][\w-]*)/g,(n,s,i)=>`<span class="cx-punct">${s}</span><span class="cx-tag">${i}</span>`),t=t.replace(/(\/?)>/g,(n,s)=>`<span class="cx-punct">${s}></span>`),_i(t)}function ll(e){let t=se(e);return t=t.replace(/"[^&]*?"/g,n=>`<span class="cx-str">${n}</span>`),t=t.replace(/#[0-9a-fA-F]{3,8}\b/g,n=>`<span class="cx-num">${n}</span>`),t=t.replace(/@[\w-]+/g,n=>`<span class="cx-at">${n}</span>`),t=t.replace(/^(\s*)(?![@\s])([^\n{}<]+?)(\s*)\{$/gm,(n,s,i,r)=>`${s}<span class="cx-sel">${i}</span>${r}{`),t=t.replace(/^(\s*)([a-z-]+)(\s*:)/gm,(n,s,i,r)=>`${s}<span class="cx-prop">${i}</span>${r}`),_i(t)}function Ni(e){return O(String(e).split(/(<style>[\s\S]*?<\/style>)/).map(t=>{let n=t.match(/^<style>([\s\S]*?)<\/style>$/);return n?`<span class="cx-punct"><</span><span class="cx-tag">style</span><span class="cx-punct">></span>${ll(n[1]??"")}<span class="cx-punct"></</span><span class="cx-tag">style</span><span class="cx-punct">></span>`:al(t)}).join(""))}function Cs(e){return se(e).replace(/(`[^`\n]+`)|(!?)(\[[^\]\n]*\])\(([^)\n]*)\)|(\*\*|__)([^\n]+?)\5|\*([^*\n]+?)\*|\b_([^_\n]+?)_\b/g,(t,n,s,i,r,l,a,d,u)=>{if(n)return`<span class="cx-md-code">${n}</span>`;if(i!==void 0)return`${s||""}${i}(<span class="cx-md-url">${r}</span>)`;if(l)return`<span class="cx-md-mark">${l}</span><span class="cx-md-strong">${a}</span><span class="cx-md-mark">${l}</span>`;let[p,m]=d!==void 0?["*",d]:["_",u];return`<span class="cx-md-mark">${p}</span><span class="cx-md-em">${m}</span><span class="cx-md-mark">${p}</span>`})}function cl(e){let t=e.match(/^(\s{0,3}#{1,6})(\s.*)?$/);if(t)return`<span class="cx-md-hmark">${se(t[1]??"")}</span><span class="cx-md-htext">${se(t[2]||"")}</span>`;if(/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/.test(e))return`<span class="cx-md-bmark">${se(e)}</span>`;let n=e.match(/^(\s{0,3})((?:>\s?)+)(.*)$/);if(n)return`${se(n[1]??"")}<span class="cx-md-bmark">${se(n[2]??"")}</span>${Cs(n[3]??"")}`;let s=e.match(/^(\s*)([-*+]|\d{1,9}[.)])(\s+)(.*)$/);return s?`${s[1]}<span class="cx-md-bmark">${se(s[2]??"")}</span>${s[3]}${Cs(s[4]??"")}`:Cs(e)}function Ii(e){let t=!1;return O(String(e).split(`
|
|
103
|
+
`).map(n=>{let s,i=!1,r=n.match(/^(\s*)(```+|~~~+)(.*)$/);if(r){t=!t;let l=r[3]?`<span class="cx-md-bmark">${se(r[3])}</span>`:"";s=`${r[1]}${se(r[2]??"")}${l}`}else t?(i=!0,s=se(n)):s=cl(n);return`<span class="cx-md-ln${i?" cx-md-band":""}">${s}</span>`}).join(""))}var dl=/("(?:\\.|[^"\\])*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false|null)\b|([{}[\],])/g;function Bi(e){let t=JSON.stringify(e,null,2)??"",n="",s=0;for(let i of t.matchAll(dl)){n+=se(t.slice(s,i.index));let[r,l,a,d,u]=i;l!==void 0?n+=a?`<span class="cx-prop">${se(l)}</span><span class="cx-punct">${se(a)}</span>`:`<span class="cx-str">${se(l)}</span>`:d!==void 0?n+=`<span class="cx-num">${d}</span>`:u!==void 0?n+=`<span class="cx-tag">${u}</span>`:n+=`<span class="cx-punct">${se(r)}</span>`,s=i.index+r.length}return O(n+se(t.slice(s)))}function Di(e){return O(se(e).replace(/^curl\b/,'<span class="cx-tag">curl</span>').replace(/(^|\s)(-[A-Za-z])\b/g,'$1<span class="cx-num">$2</span>').replace(/\$[A-Z_]+/g,'<span class="cx-var">$&</span>'))}var ul={idleMs:5e3,maxMs:3e4};function Oi(e,t=ul){let n=null,s=null,i=()=>{n!==null&&clearTimeout(n),s!==null&&clearTimeout(s),n=null,s=null},r=()=>{i(),e()};return{touch(){n!==null&&clearTimeout(n),n=setTimeout(r,t.idleMs),s===null&&(s=setTimeout(r,t.maxMs))},cancel:i,get pending(){return n!==null||s!==null}}}var Bn=class{constructor(t){this.read=t;this.#e=JSON.stringify(t())}read;#e;snapshot(){return JSON.stringify(this.read())}get dirty(){return this.snapshot()!==this.#e}markSaved(t=this.snapshot()){this.#e=t}};var qi=Promise.resolve();function Rs(e){qi=e}function Ui(){return qi.then(()=>{},()=>{})}function Fi(e){if(!(e instanceof J)||e.status!==409)return null;let t=e.data;return t?.error==="stale_revision"&&typeof t.current_revision=="string"?{kind:"stale",revision:t.current_revision,author:typeof t.author=="string"?t.author:null}:{kind:"locked"}}var Dn=class{base;warnedRevision=null;constructor(t){this.base=t}saved(t){this.base=t}noteWarned(t){t.kind==="stale"&&(this.warnedRevision=t.revision)}adoptWarned(){this.warnedRevision!==null&&(this.base=this.warnedRevision),this.warnedRevision=null}clearWarning(){this.warnedRevision=null}decide(t,n){if(n.saving||this.base!==n.baseAtRequest)return"ignore";if(t.status!=="draft")return{kind:"locked"};let s=t.revision;return s&&s!==this.base&&s!==this.warnedRevision?{kind:"stale",revision:s,author:t.author}:"fresh"}};function Vi(e){return`#/web/post/${encodeURIComponent(e)}`}function Wi(e,t){if(e!=="post"||!t)return null;try{return decodeURIComponent(t)}catch{return null}}function pl(e){return e.replace(/^https?:\/\//,"")}function ml(e){let t=o`<span class="web-bar-tag">Web version</span>`;if(e===null)return t;let n=e.scheduled?"Scheduled":"Draft",s=e.url?o` · will be published at ${pl(e.url)}`:null;return o`${t} <span class="web-bar-text">${n}${s}</span>`}function ji(e){try{return new URL(e).href}catch{return null}}function Ki(e,t,n){let s=t?ji(t):null;if(s)for(let i of e.querySelectorAll("a[href], area[href]")){let r=i.getAttribute("href");r&&ji(r)===s&&i.setAttribute("href",n)}}function fl(e){location.replace(e)}async function hl(e,t){try{return await L(`/api/posts/${encodeURIComponent(e)}`,{signal:t})}catch{return null}}async function Ms(e,t,n){let s=document.title;document.title="Web version",Qe(n,()=>{document.title=s});try{await Ui();let[i,r]=await Promise.all([dt(`/api/posts/${encodeURIComponent(e)}/web`,{signal:n}),hl(e,n)]);if(n.aborted)return;let l=r?bn(r.post.slug):null;if(r&&(r.sending||r.post.status==="sent")&&l){fl(l);return}let a=ml(r?{scheduled:r.scheduled!=null,url:l}:null);v(t,o`<div class="web-page">
|
|
104
|
+
<div class="web-bar" role="note"><div class="web-bar-in">${a}</div></div>
|
|
105
|
+
<iframe class="web-frame" sandbox="${Tt}" title="Web version" scrolling="no"></iframe>
|
|
106
|
+
</div>`);let d=t.querySelector(".web-frame");if(!d)return;d.onload=()=>{let u=d.contentDocument;if(!u||n.aborted)return;Lt(u),u.title&&(document.title=`Web version \xB7 ${u.title}`);let p=()=>{let m=u.body?.scrollHeight;m&&(d.style.height=`${m}px`)};if(p(),u.body&&typeof ResizeObserver<"u"){let m=new ResizeObserver(p);m.observe(u.body),Qe(n,()=>m.disconnect())}},d.srcdoc=In(i)}catch(i){if(n.aborted)return;v(t,o`<div class="web-error"></div>`);let r=t.querySelector(".web-error");if(r){let a=i instanceof J&&i.status===404?"There\u2019s no post here. It may have been deleted.":G(i);ie(r,a,()=>z((d,u)=>Ms(e,d,u)))}}}var je=G,vl=[[["heading","Heading"],["bold","Bold (\u2318B)"],["italic","Italic (\u2318I)"]],[["quote","Quote"],["code","Code"],["link","Link (\u2318K)"]],[["ul","Bulleted list"],["ol","Numbered list"],["indent","Indent"]]],gl=o` readonly`,bl=o` aria-disabled="true"`;async function As(e,t,n){let s=()=>{n.aborted||z((f,R)=>As(e,f,R))},i=!1,r=(f="This post was deleted elsewhere.")=>{i||(i=!0,B(f),n.aborted||On("#/drafts"))};v(t,o`<p class="muted">Loading…</p>`);let l,a=null;try{if(l=await L(`/api/posts/${e}`,{signal:n}),n.aborted)return;let f=wl(l);if(f){On(f);return}}catch(f){if(Pe(f)){r("There\u2019s no post here. It may have been deleted.");return}ie(t,je(f),s);return}if(l.scheduled){try{a=await L(`/api/sends/${l.scheduled.id}`,{signal:n})}catch(f){if(n.aborted)return;if(Pe(f)){r();return}}if(n.aborted||a&&Gi(a.send,s))return}let{post:d,markdown:u,scheduled:p}=l,m=d.status!=="draft",y=new Dn(d.current_revision),k=vl.map((f,R)=>o`${R?o`<span class="sep"></span>`:null}${f.map(([w,U])=>o`<button type="button" class="tb" data-fmt="${w}" title="${U}" aria-label="${U}"${m?bl:null}>${P(w)}</button>`)}`),E=m?gl:null,$=`${location.origin}${location.pathname}${Vi(e)}`;v(t,o`
|
|
117
107
|
<div class="editor-head">
|
|
118
108
|
<a href="#/drafts" class="back">← Drafts</a>
|
|
119
109
|
<div class="editor-head-right">
|
|
120
|
-
<a class="ghost btn" id="webLink" href="${
|
|
110
|
+
<a class="ghost btn" id="webLink" href="${$}" target="_blank" rel="noopener" aria-label="Web version (opens in a new tab)">Web version ↗</a>
|
|
121
111
|
</div>
|
|
122
112
|
</div>
|
|
123
|
-
${p
|
|
124
|
-
<div id="editorNotices"></div>
|
|
113
|
+
${m&&p?o`<div class="banner banner-scheduled"><div class="sched-main"><span id="schedWhen"></span>${hn()}<div class="sched-remade" id="schedRemade" hidden><span id="schedRemadeMsg"></span></div><div class="sched-daily-writes" id="schedDailyWrites" hidden><span class="sched-note-mark" aria-hidden="true">⚠️</span><span id="schedDailyWritesMsg"></span></div></div><span class="row" id="schedControls"><button type="button" class="ghost" id="rescheduleSchedule">Reschedule</button><button type="button" class="ghost" id="cancelSchedule">Cancel</button></span></div>`:null}
|
|
125
114
|
<div id="freshnessBanner" class="banner banner-conflict" role="alert" hidden></div>
|
|
126
115
|
<div class="card">
|
|
127
116
|
<div class="grid2">
|
|
128
|
-
<div><label for="f-subject">Subject</label><input id="f-subject" value="${
|
|
117
|
+
<div><label for="f-subject">Subject</label><input id="f-subject" value="${d.subject}" placeholder="Untitled"${E}></div>
|
|
129
118
|
<div>
|
|
130
119
|
<div class="label-row">
|
|
131
120
|
<label for="f-slug">Slug</label>
|
|
132
|
-
${
|
|
121
|
+
${Hn("The web address of this post\u2019s archive page.")}
|
|
133
122
|
</div>
|
|
134
|
-
<input id="f-slug" value="${
|
|
135
|
-
${
|
|
123
|
+
<input id="f-slug" value="${d.slug}"${E}>
|
|
124
|
+
${m?null:o`<label class="slug-auto-toggle"><input type="checkbox" id="f-slug-auto">Auto-generate from subject</label>`}
|
|
136
125
|
</div>
|
|
137
126
|
</div>
|
|
138
127
|
|
|
@@ -143,20 +132,21 @@
|
|
|
143
132
|
<button type="button" class="ctab active" data-tab="edit" data-text="Edit" role="tab" aria-selected="true"><span class="ctab-label">${P("editable")}Edit</span></button>
|
|
144
133
|
<button type="button" class="ctab" data-tab="preview" data-text="Preview" role="tab" aria-selected="false"><span class="ctab-label">${P("preview")}Preview</span></button>
|
|
145
134
|
</div>
|
|
146
|
-
<div class="toolbar" role="toolbar" aria-label="Formatting">${
|
|
135
|
+
<div class="toolbar" role="toolbar" aria-label="Formatting">${k}</div>
|
|
147
136
|
</div>
|
|
148
|
-
<div class="composer-body${
|
|
137
|
+
<div class="composer-body${m?" locked":""}" id="composerBody">
|
|
149
138
|
<pre class="md-hl" id="mdHl" aria-hidden="true"><code></code></pre>
|
|
150
|
-
<textarea id="f-markdown" class="editor"${
|
|
151
|
-
<iframe id="previewFrame" class="preview" sandbox="${
|
|
139
|
+
<textarea id="f-markdown" class="editor"${m?null:o` placeholder="Type your post in Markdown…"`}${E}>${u}</textarea>
|
|
140
|
+
<iframe id="previewFrame" class="preview" sandbox="${Tt}" title="Email preview" hidden></iframe>
|
|
152
141
|
</div>
|
|
153
|
-
${
|
|
142
|
+
${m?o`<div class="composer-foot composer-foot-lock" id="lockFoot" role="status">${P("readonly")}<span>Cancel the schedule to edit</span></div>`:o`<div class="composer-foot" id="dropFoot">${P("paperclip")}<span>Paste, drop, or click to add images</span></div>`}
|
|
154
143
|
<input type="file" id="imgInput" accept="image/png,image/jpeg,image/webp,image/gif" multiple hidden>
|
|
155
144
|
</div>
|
|
156
145
|
<div id="warnings"></div>
|
|
146
|
+
${m?null:fn("editor-not-connected",o`No email provider connected: you can write and preview, not send. ${He()}.`)}
|
|
157
147
|
|
|
158
148
|
<div class="actions-bar">
|
|
159
|
-
${
|
|
149
|
+
${m?o`<div class="row"><button type="button" class="secondary" id="testBtn">${P("send")}<span>Send test email</span></button></div>`:o`<div class="row">
|
|
160
150
|
<button type="button" class="ghost" id="saveBtn">Save draft</button>
|
|
161
151
|
<span class="save-status" id="saveStatus" aria-live="polite"></span>
|
|
162
152
|
</div>
|
|
@@ -165,90 +155,91 @@
|
|
|
165
155
|
<button type="button" class="primary" id="scheduleBtn">Schedule</button>
|
|
166
156
|
</div>`}
|
|
167
157
|
</div>
|
|
168
|
-
</div>`);let
|
|
169
|
-
`,
|
|
170
|
-
`).map(
|
|
171
|
-
`);h.value=h.value.slice(0,
|
|
172
|
-
`)?
|
|
158
|
+
</div>`);let x=c("#f-subject"),H=c("#f-slug"),h=c("#f-markdown"),M=c(".toolbar",t),T=c("#previewFrame"),V=c("#mdHl"),Y=c("code",V),C=()=>{V.scrollTop=h.scrollTop,V.scrollLeft=h.scrollLeft},D=()=>{v(Y,Ii(h.value)),C()};h.addEventListener("scroll",C),D();let N=m?null:c("#f-slug-auto"),b=()=>({subject:x.value,...N?.checked?{slug_auto:!0}:{slug:H.value,slug_auto:!1},markdown:h.value}),g=null,S=()=>{g&&H.value===g.shown&&(H.value=g.slug,Rt()),g=null},q=null;function X(){q?.(),q=null}function ue(){return x.value.trim()===""?(q=qe(x,"Add a subject before you schedule."),x.focus(),!1):(X(),!0)}if(N){N.checked=d.slug_auto;let f=()=>H.classList.toggle("slug-auto",N.checked);f(),H.addEventListener("input",()=>{N.checked=H.value.trim()==="",f()}),N.addEventListener("change",()=>{f(),Mt(),N.checked?Ae(!0).catch(()=>{}):(H.focus(),H.select())}),H.addEventListener("blur",()=>{S(),H.value.trim()===""&&(N.checked=!0,f(),Mt(),ze.dirty||(H.value=F))}),x.addEventListener("input",()=>{x.value.trim()!==""&&X()})}let Se=Oi(()=>{Ae(!0).catch(()=>{})});Qe(n,()=>Se.cancel());let Q=A(".ctab",t);function le(f){for(let R of Q){let w=R.dataset.tab===f;R.classList.toggle("active",w),R.setAttribute("aria-selected",w?"true":"false")}h.hidden=f!=="edit",V.hidden=f!=="edit",T.hidden=f!=="preview",M.classList.toggle("off",f!=="edit"),t.querySelector("#dropFoot")?.classList.toggle("off",f!=="edit"),f==="edit"&&C()}let F=d.slug;async function ne(){le("preview");try{m||await Ae(!0),T.srcdoc=await dt(`/api/posts/${e}/preview`),T.onload=()=>{try{let f=T.contentDocument;f&&(Lt(f),Ki(f,bn(F),$),T.style.height=`${f.body.scrollHeight+24}px`)}catch{}}}catch(f){if(n.aborted||At(f))return;B(je(f))}}for(let f of Q)f.onclick=()=>f.dataset.tab==="preview"?ne():le("edit");m&&ne();let Ie=m?c("#lockFoot"):null,W;function ee(){Ie&&(Ie.classList.remove("nudge"),requestAnimationFrame(()=>Ie.classList.add("nudge")),clearTimeout(W),W=setTimeout(()=>Ie.classList.remove("nudge"),900))}function oe(f,R,w){let U=h.selectionStart,_=h.selectionEnd,I=h.value.slice(U,_)||w;h.value=h.value.slice(0,U)+f+I+R+h.value.slice(_),h.focus(),h.selectionStart=U+f.length,h.selectionEnd=U+f.length+I.length}function $e(f){let R=h.selectionStart,w=h.selectionEnd,U=h.value.lastIndexOf(`
|
|
159
|
+
`,R-1)+1,_=(h.value.slice(U,w)||"").split(`
|
|
160
|
+
`).map(I=>f+I).join(`
|
|
161
|
+
`);h.value=h.value.slice(0,U)+_+h.value.slice(w),h.focus(),h.selectionStart=U,h.selectionEnd=U+_.length}function Te(f){if(m){ee();return}if(le("edit"),f==="bold")oe("**","**","bold text");else if(f==="italic")oe("*","*","italic text");else if(f==="heading")$e("## ");else if(f==="quote")$e("> ");else if(f==="ul")$e("- ");else if(f==="ol")$e("1. ");else if(f==="indent")$e(" ");else if(f==="link")oe("[","](https://)","link text");else if(f==="code"){let R=h.selectionStart,w=h.selectionEnd;R===w||h.value.slice(R,w).includes(`
|
|
162
|
+
`)?oe("```\n","\n```","code"):oe("`","`","code")}Mt()}for(let f of A(".tb[data-fmt]",t))f.onclick=()=>Te(f.dataset.fmt??"");h.addEventListener("keydown",f=>{if(!(f.metaKey||f.ctrlKey))return;let R=f.key.toLowerCase();R==="b"?(f.preventDefault(),Te("bold")):R==="i"?(f.preventDefault(),Te("italic")):R==="k"&&(f.preventDefault(),Te("link"))});let Ee=!1,it=!1,Le=!1,re=null,Ze=0,zt=!1,Gn=0,Re=m?null:c("#saveBtn"),Me=m?null:c("#saveStatus"),ze=new Bn(b),Ct=!1;function Yn(){Me&&(m?(Me.className="save-status",Me.textContent=""):Ct?(Me.className="save-status is-saving",Me.textContent="Saving\u2026"):re&&Ee?(Me.className="save-status is-error",Me.textContent=`Not saved: ${re}`):Ee?(Me.className="save-status is-dirty",Me.textContent="Unsaved changes"):(Me.className="save-status is-saved",Me.textContent="Saved"))}function Rt(){Ee=ze.dirty,Ee||(re=null,it=!1,Ze=0),Yn()}Yn();function eo(){Se.touch()}function Mt(){m||(D(),Rt(),Le||eo())}let rt=Promise.resolve();function Ae(f){let R=rt.catch(()=>{}).then(()=>Dr(f));return rt=R,Rs(rt),R}async function Dr(f){if(f&&!ze.dirty)return"unchanged";if(Le)return"refused";Se.cancel(),Ct=!0,Yn();let R=b(),w=JSON.stringify(R),U=H.value;try{let _={...R,base_revision:y.base},{post:I}=await L(`/api/posts/${e}`,{method:"PUT",json:_});if(I.slug&&H.value===U){let pe="slug"in R;pe&&document.activeElement===H?g={shown:U,slug:I.slug}:H.value=I.slug,pe&&(w=JSON.stringify({...R,slug:I.slug}))}return I.slug&&(F=I.slug),y.saved(I.current_revision),ze.markSaved(w),it=!1,re=null,Ze=0,f||B("Saved"),"saved"}catch(_){let I=Fi(_);if(I)return so(I),"refused";if(Pe(_))return r(),"refused";it=!0;let pe=_ instanceof J?{subject:x,slug:H}[_.field??""]:void 0;if(pe?(qe(pe,G(_)),re="fix the field marked above."):re=G(_),!(is(_)||_ instanceof J&&(_.status>=500||_.status===429)))Gn+=1,zt=!1,Ze=0;else if(!zt){zt=!0,Ze=Ze?Math.min(Ze*2,6e4):5e3;let me=Gn;Nt(Date.now()+Ze,()=>{me===Gn&&(zt=!1,re&&!Le&&Ae(!0).catch(()=>{}))},n)}throw _}finally{Ct=!1,Rt()}}Re&&(Re.onclick=()=>{if(Le){Be.scrollIntoView({block:"nearest"});return}te(Re,"Saving\u2026",()=>Ae(!1).catch(()=>{})).finally(Rt)});function to(){Re&&(Le?(Re.setAttribute("aria-disabled","true"),Re.title="Saving is paused: choose Reload or Keep editing above"):(Re.removeAttribute("aria-disabled"),Re.removeAttribute("title")))}let Or=()=>{if(Se.cancel(),m||!ze.dirty)return;let f=b(),R=JSON.stringify(f);Ee=!1,rt=rt.catch(()=>{}).then(async()=>{if(!ze.dirty)return;let w={...f,base_revision:y.base},{post:U}=await L(`/api/posts/${e}`,{method:"PUT",json:w});y.saved(U.current_revision),ze.markSaved(R)}).catch(w=>{if(Pe(w)){B("This post was deleted elsewhere, so your last edits weren\u2019t saved.");return}B(w instanceof J&&w.status===409?"Changed elsewhere, so your edits weren\u2019t saved":`Couldn\u2019t save your changes: ${je(w)}`)}),Rs(rt)},qr={dirty:()=>Ee,beforeLeave(){return i?"leave":Ee&&(it||Le)?"confirm":(Or(),"leave")},manualSave(){Re&&!Re.disabled&&Re.click()}};if(!m){for(let f of[x,H,h])f.addEventListener("input",Mt);for(let f of[x,H])f.addEventListener("blur",()=>Ae(!0).catch(()=>{})),f.addEventListener("keydown",R=>{R.key==="Enter"&&!R.isComposing&&R.keyCode!==229&&(R.preventDefault(),Ae(!0).then(S).catch(()=>{}))})}let Be=c("#freshnessBanner"),Ur=f=>f==="service"?"Claude":f||null;function no(){Le=!1,to(),y.clearWarning(),Be.hidden=!0,v(Be,o``)}function so(f){if(m)return;if(Le=!0,re=null,to(),y.noteWarned(f),f.kind==="locked")v(Be,o`<span><span aria-hidden="true">⚠️</span> This draft was scheduled elsewhere and can no longer be edited here.</span><span class="row"><button type="button" class="ghost" id="freshReload">Reload</button></span>`);else{let w=Ur(f.author);v(Be,o`<span><span aria-hidden="true">⚠️</span> This draft was changed elsewhere${w?o`, last edited by <strong>${w}</strong>`:null}. Saving is paused until you choose: Reload to load that version (discards your unsaved edits), or Keep editing to keep yours, and your next save overwrites that version.</span><span class="row"><button type="button" class="ghost" id="freshReload">Reload</button><button type="button" class="ghost" id="freshKeep">Keep editing</button></span>`)}Be.hidden=!1,c("#freshReload",Be).onclick=()=>{no(),s()};let R=Be.querySelector("#freshKeep");R&&(R.onclick=()=>{y.adoptWarned(),no(),Rt(),Ee&&eo()})}function At(f){return Pe(f)?(r(),!0):!1}if(!m){let f=async()=>{if(Ct||Le||i||document.hidden)return;let R=y.base;try{let w=await L(`/api/posts/${e}`,{signal:n}),U=y.decide({status:w.post.status,revision:w.post.current_revision,author:w.author},{saving:Ct,baseAtRequest:R});U!=="ignore"&&U!=="fresh"&&so(U)}catch(w){Pe(w)&&r()}};Je(1e4,f,n),Sl(f,n)}if(c("#webLink").addEventListener("click",()=>{m||Ae(!0).catch(()=>{})}),m&&p){let f=a?.send??{...p,phase:"scheduled",conditions:[]},R=c("#schedWhen"),w=c("#schedControls"),U="",_=0,I=!1,pe=c("#rescheduleSchedule"),fe=c("#cancelSchedule"),me=j=>f.actions===void 0||Ue({actions:f.actions},j),be=()=>{let j=Date.now(),ae=f.phase==="due"||j>=f.fire_at;ae?I||(pe.hidden=!0,fe.hidden=!0):(I=!0,pe.hidden=!me("reschedule"),fe.hidden=!me("cancel")),pe.disabled=ae,fe.disabled=ae,w.hidden=pe.hidden&&fe.hidden;let ke=yl(f,ae),io=String(ke);io!==U&&(U=io,v(R,ke)),!ae&&f.fire_at-j<=1e3&&f.fire_at!==_&&(_=f.fire_at,Nt(f.fire_at,be,n))};be(),Je(1e3,be,n);let at=c("#schedRemade"),lt=c("#schedDailyWrites"),_e=()=>{let j=he(f,"remade");at.hidden=!j,c("#schedRemadeMsg").textContent=j?.message??"";let ae=he(f,"daily_writes");lt.hidden=!ae,c("#schedDailyWritesMsg").textContent=ae?.message??""};_e();let xe=j=>Ot(j,{update(ae,ke){if(f=ae,ke?.to==="sending"||ke?.to==="sent"||ke?.to==="complete"){B("Your send started, so this page switched to its live watch."),On(`#/sent/${ae.id}`);return}if(ke?.to==="canceled"){B("The schedule was canceled elsewhere, so this post is a draft again."),s();return}be(),_e()},removed:r,stale:s},n);a?xe(a):co(5e3,async()=>{let j=await L(`/api/sends/${p.id}`,{signal:n});return Gi(j.send,s)||(f=j.send,be(),_e(),xe(j)),!1},n),pe.onclick=()=>Nn(f.id,f.fire_at,s,n),fe.onclick=()=>te(fe,"Canceling\u2026",async()=>{try{await L(`/api/sends/${p.id}/cancel`,{method:"POST"}),B("Schedule canceled"),s()}catch(j){Pe(j)?r():j instanceof J&&j.code==="window_closed"?(f={...f,phase:"due"},B("Too late to cancel: the send time has passed, so this email is going out."),tt()):B(je(j))}}).finally(be)}async function Zn(f){if(f.type.startsWith("image/"))try{let R=new FormData;R.append("file",f);let{image:w}=await L(`/api/posts/${e}/images`,{method:"POST",body:R}),U=h.selectionStart,_=`
|
|
173
163
|

|
|
174
|
-
`;h.value=h.value.slice(0,
|
|
175
|
-
`);let be=d("#testDefaultsHint",w.el);be.textContent="Pre-filled from your default test recipients (Settings).",be.hidden=!1}}).catch(()=>{}),j.onclick=()=>J(j,"Sending\u2026",async()=>{let{valid:D,invalid:le}=an(V.value),be=rn(le);if(be){$(be);return}if(!D.length){$("Enter at least one email address");return}try{p||await Le(!0);let ye=0,ke=null;for(let We of D){let me=await k(`/api/posts/${e}/test`,{method:"POST",json:{to:We}});me.sent&&ye++,ke=me.warnings}xa(ke),w.close(),$(tt(ye===D.length?`Test sent to ${ye} address${ye===1?"":"es"}`:`Sent ${ye} of ${D.length}; the rest failed`))}catch(ye){if(Ce(ye)){w.close(),$t(ye);return}$(we(ye))}})};let Bs=p?null:d("#scheduleBtn");return Bs&&(Bs.onclick=()=>{if(!Y())return;let f=rt(Jt()),T=rt(new Date(Date.now()+24*3600*1e3)),w=de(),j=i`<h3 id="schHead">Schedule this post</h3><p class="hint">It sends at the time you pick (at least ${kt()} out), with a cancelable window until then.</p>${eo()}${w?null:i`<label for="schWhen">Send at</label><input type="datetime-local" id="schWhen" min="${f}" value="${T}">`}<div class="actions"><button type="button" id="schCancel">Cancel</button><button type="button" class="primary" id="schGo">Schedule</button></div>${w?null:i`<div class="altrow"><span class="altrow-note">Skip the review window?</span><button type="button" class="linkbtn" id="toSendNow">Send now →</button></div>`}`,V=ve(j),D=d(".modal",V.el),le=()=>{V.close(),Me.scrollIntoView({block:"nearest"})},be=()=>J(d("#schGo",D),"Scheduling\u2026",async()=>{let me=d("#schWhen",D).value,ne=me?new Date(me).getTime():Number.NaN;if(Number.isNaN(ne)){$("Pick a valid date & time");return}try{if(await Le(!0)==="refused"){le();return}await k(`/api/posts/${e}/schedule`,{method:"POST",json:{fire_at:new Date(ne).toISOString()}}),V.close(),$(tt(`Scheduled for ${K(ne)}. Send yourself a test.`)),s()}catch(fe){if(Ce(fe)){V.close(),$t(fe);return}$(we(fe))}}),ye=()=>J(d("#snGo",D),"Queuing\u2026",async()=>{try{if(await Le(!0)==="refused"){le();return}let{send:me}=await k(`/api/posts/${e}/send`,{method:"POST"});V.close(),$(tt(`Sends at ${K(me.fire_at)}, cancelable until then.`)),s()}catch(me){if(Ce(me)){V.close(),$t(me);return}$(we(me))}});function ke(){if(D.setAttribute("aria-labelledby","schHead"),d("#schCancel",D).onclick=V.close,d("#schGo",D).onclick=be,d("#schGo",D).disabled=w,w){d("#schCancel",D).focus();return}d("#toSendNow",D).onclick=We,d("#schWhen",D).focus()}async function We(){g(D,i`<h3 id="snHead">Send now?</h3><p class="hint">Freezes the current draft and sends it to <strong id="snWho">your confirmed subscribers</strong> after a cancelable window of at least ${kt()}, on the minute. You can cancel until it fires.</p><div class="altrow altrow-top"><button type="button" class="linkbtn" id="toSchedule">← Back to schedule</button></div><div class="actions"><button type="button" id="snCancel">Cancel</button><button type="button" class="primary" id="snGo">Send now</button></div>`),D.setAttribute("aria-labelledby","snHead"),d("#snCancel",D).onclick=V.close,d("#snGo",D).onclick=ye,d("#snGo",D).disabled=de(),d("#toSchedule",D).onclick=()=>{g(D,j),ke()},d("#snGo",D).focus();try{let ne=(await k("/api/subscribers")).counts.confirmed,fe=D.querySelector("#snWho");fe&&(fe.textContent=`${ne} confirmed subscriber${ne===1?"":"s"}`)}catch{}}ke()}),Sa}function Li(e,t){return e.status==="sending"||e.status==="sent"?(Tn(`#/sent/${e.id}`),!0):e.status==="canceled"?(t(),!0):!1}function zr(e,t){return Te(e,"missed")?i`Scheduled for <strong>${K(e.fire_at)}</strong> · <span class="countdown countdown-missed">${ln(e.fire_at)}</span>`:t?i`Scheduled for <strong>${K(e.fire_at)}</strong> · <span class="countdown">${Rt(e.fire_at,!0)}</span>`:i`Scheduled for <strong>${K(e.fire_at)}</strong> · <span class="countdown">${Rt(e.fire_at)}</span> · cancelable until then.`}function Ce(e){return e instanceof se&&e.status===404}function Jr(e){return e.sending?`#/sent/${e.sending.id}`:e.post.status==="sent"?e.sent?`#/sent/${e.sent.id}`:"#/sent":null}function Tn(e){location.replace(e)}function Qr(e,t){document.addEventListener("visibilitychange",()=>{document.hidden||e()},{signal:t})}var ps=null;function Xr(e){let t=document.createElement("div");g(t,O(e.html||""));let n=(t.querySelector("p")?.textContent||"").trim().replace(/\s+/g," "),s=/[:;,]$/.test(n),o=n.replace(/[\s:;,]+$/,""),a=150;return o.length>a?`${o.slice(0,a).replace(/[\s.,;:]+\S*$/,"")}\u2026`:s?`${o}\u2026`:o}async function ms(e,t,n,s){if(g(t,nt("docs",null,i`<div class="docs-index"><p class="muted">Loading…</p></div>`)),!ps)try{ps=await k("/api/docs",{signal:n})}catch(l){n.aborted||X(d(".room-main",t),l instanceof Error?l.message:String(l),()=>G((r,c)=>ms(e,r,c,s)));return}let{landing:o,docs:a}=ps;if(!a.length){g(d(".room-main"),i`<p class="muted">No documentation.</p>`);return}e?sl(t,o,a,e,s?el(s):void 0):Ri(t,o,a)}function el(e){try{return decodeURIComponent(e)}catch{return}}function Ri(e,t,n){let s=(u,m)=>{let p=Xr(u);return i`<li><a class="doc-card" href="#/docs/${u.slug}">${m===null?null:i`<span class="doc-card-n">${String(m).padStart(2,"0")}</span>`}<span class="doc-card-main"><span class="doc-card-t">${u.title} <span class="doc-card-go" aria-hidden="true">→</span></span>${p?i`<span class="doc-card-d">${p}</span>`:null}</span></a></li>`},o=t.sections.map(u=>{let m=n.filter(x=>x.section===u.id);if(!m.length)return null;let p=m.map((x,E)=>s(x,u.numbered?E+1:null)),y=u.blurb?i`<div class="doc-section-blurb">${O(u.blurb)}</div>`:null;return i`<section class="doc-section"><h2 class="doc-section-t">${u.title}</h2>${y}${u.numbered?i`<ol class="doc-cards">${p}</ol>`:i`<ul class="doc-cards">${p}</ul>`}</section>`}),a=i`<div class="docs-index"><p class="eyebrow">Documentation</p><h1>${t.title}</h1><div class="docs-index-intro">${O(t.intro)}</div>${o}</div>`,l=W.appConfig?.deployment.build.repoUrl||"",r=(u,m,p=m)=>i`<a class="rail-link" href="${u}" target="_blank" rel="noopener" aria-label="${m}"><span class="rail-link-full">${m}</span><span class="rail-link-short" aria-hidden="true">${p}</span> <span aria-hidden="true">↗</span></a>`,c=i`<div class="toc-label">Kestrel</div>${r("https://getkestrel.dev","Project site","Project")}${l?[r(l,"Source on GitHub","Source"),r(`${l}/blob/HEAD/LICENSE`,"License")]:null}`;g(e,nt("docs",c,a)),window.scrollTo(0,0)}var fs=matchMedia("(max-width: 720px)");function Pi(e){if(!e)return;let t=fs.matches;e.open=!t,d("summary",e).tabIndex=t?0:-1}var Mi=()=>document.querySelector("details#tocOnPage"),Hi=!1;function tl(){Hi||(Hi=!0,fs.addEventListener("change",()=>Pi(Mi())))}function nl(e){return e.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu,"").replace(/\s/g,"-")}var Ci=e=>`doc-h-${e}`;function sl(e,t,n,s,o){let a=n.findIndex(b=>b.slug===s),l=n[a];if(!l){$(`No doc named \u201C${s}\u201D \u2014 showing the index.`),history.replaceState(history.state,"","#/docs"),Ri(e,t,n);return}let r=n.filter(b=>b.section===l.section),c=r.indexOf(l),u=r[c-1],m=r[c+1],p=(b,v)=>i`<a href="#/docs/${b.slug}" aria-label="${v}: ${b.title}">${v==="Previous"?i`<span aria-hidden="true">←</span><span class="doc-topnav-word">Previous</span>`:i`<span class="doc-topnav-word">Next</span><span aria-hidden="true">→</span>`}</a>`,y=u||m?i`<nav class="doc-topnav" aria-label="Adjacent docs">${u?p(u,"Previous"):null}${m?p(m,"Next"):null}</nav>`:null;g(e,nt("docs",i`<div id="docToc"></div>`,i`<article class="doc" id="docsMain"></article>`));let x=d("#docToc"),E=d("#docsMain");g(E,i`<section class="doc-part" id="doc-${l.slug}">${O(l.html)}</section>`);let N=d("section.doc-part",E),A=new Map;for(let b of M("h1, h2, h3, h4",N)){let v=nl(b.textContent||""),S=A.get(v)??0;A.set(v,S+1),b.dataset.anchor=S?`${v}-${S}`:v,b.id=Ci(b.dataset.anchor)}let H=N.querySelector("h1"),h=[];if(H&&h.push({id:H.id,anchor:"",title:H.textContent||l.title}),y){let b=document.createElement("div");b.className="doc-head",g(b,y),H?(H.before(b),b.prepend(H)):N.prepend(b)}for(let b of M("h2",N))h.push({id:b.id,anchor:b.dataset.anchor||"",title:b.textContent||""});let L=document.createElement("nav");L.className="doc-pager",g(L,i`${u?i`<a class="doc-pager-btn prev" href="#/docs/${u.slug}"><span class="doc-pager-dir">← Previous</span><span class="doc-pager-title">${u.title}</span></a>`:i`<span></span>`}${m?i`<a class="doc-pager-btn next" href="#/docs/${m.slug}"><span class="doc-pager-dir">Next →</span><span class="doc-pager-title">${m.title}</span></a>`:i`<span></span>`}`),E.appendChild(L);let C=h.map(b=>i`<a class="toc-sub" href="#/docs/${l.slug}${b.anchor?`/${b.anchor}`:""}" data-target="${b.id}">${b.title}</a>`);g(x,h.length?i`<details class="toc-onpage" id="tocOnPage"><summary class="toc-label">On this page</summary>${C}</details>`:i``);let F=Mi();Pi(F),tl();let ee=b=>{if(F)for(let v of M(".toc-sub",F))v.classList.toggle("on",v.dataset.target===b)};x.addEventListener("click",b=>{let v=b.target instanceof Element?b.target.closest("a[data-target]"):null,S=v?.dataset.target;S&&(b.preventDefault(),ee(S),F&&fs.matches&&(F.open=!1),history.replaceState(history.state,"",v?.getAttribute("href")??location.hash),document.getElementById(S)?.scrollIntoView({block:"start"}))});for(let b of M("a[href^='http']",E))b.target="_blank",b.rel="noopener";for(let b of M("pre",E)){b.classList.add("has-copy");let v=document.createElement("button");v.type="button",v.className="code-copy",v.textContent="Copy",v.addEventListener("click",async()=>{let S=b.querySelector("code");try{await navigator.clipboard.writeText((S||b).innerText),v.textContent="Copied",setTimeout(()=>{v.textContent="Copy"},1500)}catch{$("Couldn't copy to clipboard")}}),b.appendChild(v)}let R=h.map(b=>b.id),q=R[0];if(F&&q){let b=R[R.length-1]??q,S=(document.querySelector(".room-bar")?.getBoundingClientRect().height||54)+26,U=()=>{if(!document.getElementById(q)){document.onscroll=null;return}let I=q;if(Math.ceil(window.innerHeight+window.scrollY)>=document.documentElement.scrollHeight)I=b;else for(let Y of R)if((document.getElementById(Y)?.getBoundingClientRect().top??Number.POSITIVE_INFINITY)<=S)I=Y;else break;ee(I)};document.onscroll=U,U()}let _=o?document.getElementById(Ci(o)):null;_?_.scrollIntoView({block:"start"}):window.scrollTo(0,0)}function ol(e){return e==="access"?['-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID"','-H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"']:['-H "Authorization: Bearer $TOKEN"']}function Ln(e){return e.replace(/\([^)]*\)/g,"").replace(/\{[^}]*\}\??/g,"")||"/"}function Ai(e,t,n){if(e.access==="webhook")return null;let s=(e.query??[]).filter(r=>r.required),o=s.length?`?${s.map(r=>`${r.name}=:${r.name}`).join("&")}`:"",a=`"${t}${Ln(e.path)}${o}"`,l=[e.method==="GET"?`curl ${a}`:`curl -X ${e.method} ${a}`];if(e.access==="admin"&&l.push(...ol(n)),e.example?.request!==void 0){let r=JSON.stringify(e.example.request).replaceAll("'","'\\''");l.push('-H "Content-Type: application/json"',`-d '${r}'`)}return l.join(` \\
|
|
176
|
-
`)}function _i(e,t){return t===void 0?null:is(e,gi(t),JSON.stringify(t,null,2))}function il(e){let t=Ai(e,location.origin,W.session?.auth.mode??"dev");return t===null?null:is("curl",bi(t),t)}function al(e){if(!e?.length)return null;let t=e.map(n=>i`<tr><td><code>${n.name}</code>${n.required?i` <span class="api-req">required</span>`:null}</td><td class="muted">${n.description}</td></tr>`);return i`<div class="block"><div class="block-head"><span class="block-label">Query</span></div><table class="api-query"><tbody>${t}</tbody></table></div>`}function rl(e){if(!e?.length)return null;let t=e.map(n=>i`<tr><td><code>${n}</code></td></tr>`);return i`<div class="block"><div class="block-head"><span class="block-label">Content-Type</span></div><table class="api-query api-accepts"><tbody>${t}</tbody></table></div>`}function ll(e){return i`${Ln(e).split(/(:[A-Za-z_]+)/).map((t,n)=>n%2?i`<span class="api-param">${t}</span>`:t)}`}function dl(e){let t=`${e.method} ${Ln(e.path)} ${e.summary}`.toLowerCase();return i`<li data-hay="${t}"><details class="api-route">
|
|
164
|
+
`;h.value=h.value.slice(0,U)+_+h.value.slice(U),h.selectionStart=h.selectionEnd=U+_.length,Mt(),B("Image added")}catch(R){At(R)||B(je(R))}}let De=c("#composerBody");if(m){let f=w=>w.metaKey||w.ctrlKey?["x","z","y"].includes(w.key.toLowerCase()):w.key.length===1||w.key==="Enter"||w.key==="Backspace"||w.key==="Delete",R=[h,x,H];for(let w of R)w.addEventListener("keydown",U=>{f(U)&&ee()}),w.addEventListener("paste",ee);De.addEventListener("dragover",w=>w.preventDefault()),De.addEventListener("drop",w=>{w.preventDefault(),ee()})}else{let f=c("#imgInput"),R=c("#dropFoot");De.addEventListener("dragover",w=>{w.preventDefault(),De.classList.add("dragover")}),De.addEventListener("dragleave",w=>{w.target===De&&De.classList.remove("dragover")}),De.addEventListener("drop",w=>{w.preventDefault(),De.classList.remove("dragover"),le("edit");for(let U of w.dataTransfer?.files??[])Zn(U)}),h.addEventListener("paste",w=>{for(let U of w.clipboardData?.items||[])if(U.type.startsWith("image/")){let _=U.getAsFile();_&&(w.preventDefault(),Zn(_))}}),R.onclick=()=>f.click(),f.onchange=()=>{for(let w of f.files??[])Zn(w);f.value=""}}let Fr=c("#warnings");function jr(f){v(Fr,f?.length?o`<div class="warnings"><strong>Warnings:</strong> ${f.join("; ")}</div>`:o``)}c("#testBtn").onclick=()=>{let f=m?"Delivers the frozen copy that will send, exactly as it will fire, so you can check it in a client. One address per line.":"Delivers the rendered email to real inboxes so you can check it in a client. One address per line.",R=de(),w=we(o`<h3>Send a test</h3><p class="hint">${f}</p>${gn()}${R?null:o`<label for="testTo">Recipients</label><textarea id="testTo" rows="3" placeholder="you@example.com"></textarea><p class="hint" id="testDefaultsHint" hidden></p>`}<div class="actions"><button type="button" id="tCancel">Cancel</button><button type="button" class="primary" id="tGo">Send test</button></div>`,n),U=c("#tGo",w.el);if(c("#tCancel",w.el).onclick=w.close,U.disabled=R,R){c("#tCancel",w.el).focus();return}let _=c("#testTo",w.el);_.focus(),L("/api/settings").then(I=>{let pe=I.settings.testRecipients||[];if(pe.length&&!_.value.trim()){_.value=pe.join(`
|
|
165
|
+
`),w.clearError();let fe=c("#testDefaultsHint",w.el);fe.textContent="Pre-filled from your default test recipients (Settings).",fe.hidden=!1}}).catch(()=>{}),U.onclick=()=>te(U,"Sending\u2026",async()=>{w.clearError();let{valid:I,invalid:pe}=En(_.value),fe=kn(pe);if(fe){w.error(fe,_);return}if(!I.length){w.error("Enter at least one email address",_);return}try{m||await Ae(!0);let me=0,be=[],at=new Set,lt=null;for(let _e of I)try{let xe=await L(`/api/posts/${e}/test`,{method:"POST",json:{to:_e}});xe.sent?me++:be.push(_e),lt=xe.warnings}catch(xe){if(Pe(xe)||xe instanceof J&&xe.status===401)throw xe;be.push(_e),at.add(G(xe))}if(jr(lt),m&&me>0&&tt(),be.length){_.value=be.join(`
|
|
166
|
+
`),w.error(xn(me,I.length,be,[...at]));return}w.close(),B(pt(`Test sent to ${me} address${me===1?"":"es"}`))}catch(me){if(Pe(me)){w.close(),At(me);return}w.error(je(me))}})};let oo=m?null:c("#scheduleBtn");return oo&&(oo.onclick=()=>{if(!ue())return;let f=gt(pn()),R=gt(new Date(Date.now()+24*3600*1e3)),w=de(),U=o`<h3 id="schHead">Schedule this post</h3><p class="hint">It sends at the time you pick, with a cancelable window until then.</p>${xo()}${fs()}${w?null:o`<label for="schWhen">Send at</label><input type="datetime-local" id="schWhen" min="${f}" value="${R}">`}<div class="actions"><button type="button" id="schCancel">Cancel</button><button type="button" class="primary" id="schGo">Schedule</button></div>${w?null:o`<div class="altrow"><span class="altrow-note">Or send as soon as possible.</span><button type="button" class="linkbtn" id="toSendNow">Send now →</button></div>`}`,_=we(U,n),I=c(".modal",_.el),pe=null,fe=()=>(pe??=L("/api/subscribers?status=confirmed&suppressed=hide&limit=1",{signal:n}).then(j=>j.page.total).catch(()=>null),pe),me=async()=>{if(!I.querySelector("#schDailyWrites"))return;let j=await fe(),ae=I.querySelector("#schDailyWrites");ae&&j!==null&&j>us&&(ae.textContent=So(j),ae.hidden=!1)},be=()=>{_.close(),Be.scrollIntoView({block:"nearest"})},at=()=>te(c("#schGo",I),"Scheduling\u2026",async()=>{_.clearError();let j=c("#schWhen",I),ae=j.value?new Date(j.value).getTime():Number.NaN;if(Number.isNaN(ae)){_.error("Pick a valid date & time",j);return}if(un(ae)){_.error(dn(),j);return}try{if(await Ae(!0)==="refused"){be();return}await L(`/api/posts/${e}/schedule`,{method:"POST",json:{fire_at:new Date(ae).toISOString()}}),_.close(),B(pt(`Scheduled for ${Z(ae)}. Send yourself a test.`)),s()}catch(ke){if(Pe(ke)){_.close(),At(ke);return}if(ke instanceof J&&ke.field==="fire_at"){_.error(je(ke),c("#schWhen",I));return}_.error(je(ke))}}),lt=()=>te(c("#snGo",I),"Queuing\u2026",async()=>{_.clearError();try{if(await Ae(!0)==="refused"){be();return}let{send:j}=await L(`/api/posts/${e}/send`,{method:"POST"});_.close(),B(pt(`Sends at ${Z(j.fire_at)}, cancelable until then.`)),s()}catch(j){if(Pe(j)){_.close(),At(j);return}_.error(je(j))}});function _e(){if(I.setAttribute("aria-labelledby","schHead"),c("#schCancel",I).onclick=_.close,c("#schGo",I).onclick=at,c("#schGo",I).disabled=w,w){c("#schCancel",I).focus();return}c("#toSendNow",I).onclick=xe,c("#schWhen",I).focus(),me()}async function xe(){v(I,o`<h3 id="snHead">Send now?</h3><p class="hint">Sends this draft, as it is now, to <strong id="snWho">your subscribers</strong> in ${Eo()}. The post locks until then, and you can cancel any time before it starts sending.</p>${fs()}<div class="altrow altrow-top"><button type="button" class="linkbtn" id="toSchedule">← Back to schedule</button></div><div class="actions"><button type="button" id="snCancel">Cancel</button><button type="button" class="primary" id="snGo">Send now</button></div>`),I.setAttribute("aria-labelledby","snHead"),c("#snCancel",I).onclick=_.close,c("#snGo",I).onclick=lt,c("#snGo",I).disabled=de(),c("#toSchedule",I).onclick=()=>{v(I,U),_e()},c("#snGo",I).focus(),me();let j=await fe(),ae=I.querySelector("#snWho");ae&&j!==null&&(ae.textContent=`${j} recipient${j===1?"":"s"}`)}_e()}),qr}function Gi(e,t){return e.status==="sending"||e.status==="sent"?(On(`#/sent/${e.id}`),!0):e.status==="canceled"?(t(),!0):!1}function yl(e,t){return Ce(e,"missed")?o`Scheduled for <strong>${Z(e.fire_at)}</strong> · <span class="countdown countdown-missed">${Tn(e.fire_at)}</span>`:t?o`Scheduled for <strong>${Z(e.fire_at)}</strong> · <span class="countdown">${qt(e.fire_at,!0)}</span>`:o`Scheduled for <strong>${Z(e.fire_at)}</strong> · <span class="countdown">${qt(e.fire_at)}</span>`}function Pe(e){return e instanceof J&&e.status===404}function wl(e){return e.sending?`#/sent/${e.sending.id}`:e.post.status==="sent"?e.sent?`#/sent/${e.sent.id}`:"#/sent":null}function On(e){location.replace(e)}function Sl(e,t){document.addEventListener("visibilitychange",()=>{document.hidden||e()},{signal:t})}var Ps=null;function $l(e){let t=document.createElement("div");v(t,O(e.html||""));let n=(t.querySelector("p")?.textContent||"").trim().replace(/\s+/g," "),s=/[:;,]$/.test(n),i=n.replace(/[\s:;,]+$/,""),r=150;return i.length>r?`${i.slice(0,r).replace(/[\s.,;:]+\S*$/,"")}\u2026`:s?`${i}\u2026`:i}async function _s(e,t,n,s){if(v(t,mt("docs",null,o`<div class="docs-index"><p class="muted">Loading…</p></div>`)),!Ps)try{Ps=await L("/api/docs",{signal:n})}catch(l){n.aborted||ie(c(".room-main",t),G(l),()=>z((a,d)=>_s(e,a,d,s)));return}let{landing:i,docs:r}=Ps;if(!r.length){v(c(".room-main"),o`<p class="muted">No documentation.</p>`);return}e?Tl(t,i,r,e,s?El(s):void 0):zi(t,i,r)}function El(e){try{return decodeURIComponent(e)}catch{return}}function zi(e,t,n){let s=(u,p)=>{let m=$l(u);return o`<li><a class="doc-card" href="#/docs/${u.slug}">${p===null?null:o`<span class="doc-card-n">${String(p).padStart(2,"0")}</span>`}<span class="doc-card-main"><span class="doc-card-t">${u.title} <span class="doc-card-go" aria-hidden="true">→</span></span>${m?o`<span class="doc-card-d">${m}</span>`:null}</span></a></li>`},i=t.sections.map(u=>{let p=n.filter(k=>k.section===u.id);if(!p.length)return null;let m=p.map((k,E)=>s(k,u.numbered?E+1:null)),y=u.blurb?o`<div class="doc-section-blurb">${O(u.blurb)}</div>`:null;return o`<section class="doc-section"><h2 class="doc-section-t">${u.title}</h2>${y}${u.numbered?o`<ol class="doc-cards">${m}</ol>`:o`<ul class="doc-cards">${m}</ul>`}</section>`}),r=o`<div class="docs-index"><p class="eyebrow">Documentation</p><h1>${t.title}</h1><div class="docs-index-intro">${O(t.intro)}</div>${i}</div>`,l=K.appConfig?.deployment.build.repoUrl||"",a=(u,p,m=p)=>o`<a class="rail-link" href="${u}" target="_blank" rel="noopener" aria-label="${p}"><span class="rail-link-full">${p}</span><span class="rail-link-short" aria-hidden="true">${m}</span> <span aria-hidden="true">↗</span></a>`,d=o`<div class="toc-label">Kestrel</div>${a("https://getkestrel.dev","Project site","Project")}${l?[a(l,"Source on GitHub","Source"),a(`${l}/blob/HEAD/LICENSE`,"License")]:null}`;v(e,mt("docs",d,r)),window.scrollTo(0,0)}var Ns=matchMedia("(max-width: 720px)");function Ji(e){if(!e)return;let t=Ns.matches;e.open=!t,c("summary",e).tabIndex=t?0:-1}var Qi=()=>document.querySelector("details#tocOnPage"),Yi=!1;function kl(){Yi||(Yi=!0,Ns.addEventListener("change",()=>Ji(Qi())))}function xl(e){return e.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu,"").replace(/\s/g,"-")}var Zi=e=>`doc-h-${e}`;function Tl(e,t,n,s,i){let r=n.findIndex(b=>b.slug===s),l=n[r];if(!l){B(`No doc named \u201C${s}\u201D, so here\u2019s the index.`),history.replaceState(history.state,"","#/docs"),zi(e,t,n);return}let a=n.filter(b=>b.section===l.section),d=a.indexOf(l),u=a[d-1],p=a[d+1],m=(b,g)=>o`<a href="#/docs/${b.slug}" aria-label="${g}: ${b.title}">${g==="Previous"?o`<span aria-hidden="true">←</span><span class="doc-topnav-word">Previous</span>`:o`<span class="doc-topnav-word">Next</span><span aria-hidden="true">→</span>`}</a>`,y=u||p?o`<nav class="doc-topnav" aria-label="Adjacent docs">${u?m(u,"Previous"):null}${p?m(p,"Next"):null}</nav>`:null;v(e,mt("docs",o`<div id="docToc"></div>`,o`<article class="doc" id="docsMain"></article>`));let k=c("#docToc"),E=c("#docsMain");v(E,o`<section class="doc-part" id="doc-${l.slug}">${O(l.html)}</section>`);let $=c("section.doc-part",E),x=new Map;for(let b of A("h1, h2, h3, h4",$)){let g=xl(b.textContent||""),S=x.get(g)??0;x.set(g,S+1),b.dataset.anchor=S?`${g}-${S}`:g,b.id=Zi(b.dataset.anchor)}let H=$.querySelector("h1"),h=[];if(H&&h.push({id:H.id,anchor:"",title:H.textContent||l.title}),y){let b=document.createElement("div");b.className="doc-head",v(b,y),H?(H.before(b),b.prepend(H)):$.prepend(b)}for(let b of A("h2",$))h.push({id:b.id,anchor:b.dataset.anchor||"",title:b.textContent||""});let M=document.createElement("nav");M.className="doc-pager",v(M,o`${u?o`<a class="doc-pager-btn prev" href="#/docs/${u.slug}"><span class="doc-pager-dir">← Previous</span><span class="doc-pager-title">${u.title}</span></a>`:o`<span></span>`}${p?o`<a class="doc-pager-btn next" href="#/docs/${p.slug}"><span class="doc-pager-dir">Next →</span><span class="doc-pager-title">${p.title}</span></a>`:o`<span></span>`}`),E.appendChild(M);let T=h.map(b=>o`<a class="toc-sub" href="#/docs/${l.slug}${b.anchor?`/${b.anchor}`:""}" data-target="${b.id}">${b.title}</a>`);v(k,h.length?o`<details class="toc-onpage" id="tocOnPage"><summary class="toc-label">On this page</summary>${T}</details>`:o``);let V=Qi();Ji(V),kl();let Y=b=>{if(V)for(let g of A(".toc-sub",V))g.classList.toggle("on",g.dataset.target===b)};k.addEventListener("click",b=>{let g=b.target instanceof Element?b.target.closest("a[data-target]"):null,S=g?.dataset.target;S&&(b.preventDefault(),Y(S),V&&Ns.matches&&(V.open=!1),history.replaceState(history.state,"",g?.getAttribute("href")??location.hash),document.getElementById(S)?.scrollIntoView({block:"start"}))});for(let b of A("a[href^='http']",E))b.target="_blank",b.rel="noopener";for(let b of A("pre",E)){b.classList.add("has-copy");let g=document.createElement("button");g.type="button",g.className="code-copy",g.textContent="Copy",g.addEventListener("click",async()=>{let S=b.querySelector("code");try{await navigator.clipboard.writeText((S||b).innerText),g.textContent="Copied",setTimeout(()=>{g.textContent="Copy"},1500)}catch{B("Couldn\u2019t copy to clipboard")}}),b.appendChild(g)}let C=h.map(b=>b.id),D=C[0];if(V&&D){let b=C[C.length-1]??D,S=(document.querySelector(".room-bar")?.getBoundingClientRect().height||54)+26,q=()=>{if(!document.getElementById(D)){document.onscroll=null;return}let X=D;if(Math.ceil(window.innerHeight+window.scrollY)>=document.documentElement.scrollHeight)X=b;else for(let ue of C)if((document.getElementById(ue)?.getBoundingClientRect().top??Number.POSITIVE_INFINITY)<=S)X=ue;else break;Y(X)};document.onscroll=q,q()}let N=i?document.getElementById(Zi(i)):null;N?N.scrollIntoView({block:"start"}):window.scrollTo(0,0)}function Ll(e){return e==="access"?['-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID"','-H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"']:['-H "Authorization: Bearer $TOKEN"']}function qn(e){return e.replace(/\([^)]*\)/g,"").replace(/\{[^}]*\}\??/g,"")||"/"}function Xi(e,t,n){if(e.access==="webhook")return null;let s=(e.query??[]).filter(a=>a.required),i=s.length?`?${s.map(a=>`${a.name}=:${a.name}`).join("&")}`:"",r=`"${t}${qn(e.path)}${i}"`,l=[e.method==="GET"?`curl ${r}`:`curl -X ${e.method} ${r}`];if(e.access==="admin"&&l.push(...Ll(n)),e.example?.request!==void 0){let a=JSON.stringify(e.example.request).replaceAll("'","'\\''");l.push('-H "Content-Type: application/json"',`-d '${a}'`)}return l.join(` \\
|
|
167
|
+
`)}function er(e,t){return t===void 0?null:xs(e,Bi(t),JSON.stringify(t,null,2))}function Hl(e){let t=Xi(e,location.origin,K.session?.auth.mode??"dev");return t===null?null:xs("curl",Di(t),t)}function Cl(e){if(!e?.length)return null;let t=e.map(n=>o`<tr><td><code>${n.name}</code>${n.required?o` <span class="api-req">required</span>`:null}</td><td class="muted">${n.description}</td></tr>`);return o`<div class="block"><div class="block-head"><span class="block-label">Query</span></div><table class="api-query"><tbody>${t}</tbody></table></div>`}function Rl(e){if(!e?.length)return null;let t=e.map(n=>o`<tr><td><code>${n}</code></td></tr>`);return o`<div class="block"><div class="block-head"><span class="block-label">Content-Type</span></div><table class="api-query api-accepts"><tbody>${t}</tbody></table></div>`}function Ml(e){return o`${qn(e).split(/(:[A-Za-z_]+)/).map((t,n)=>n%2?o`<span class="api-param">${t}</span>`:t)}`}function Al(e){let t=`${e.method} ${qn(e.path)} ${e.summary}`.toLowerCase();return o`<li data-hay="${t}"><details class="api-route">
|
|
177
168
|
<summary class="api-route-sum">
|
|
178
169
|
<span class="api-method m-${e.method}">${e.method}</span>
|
|
179
|
-
<code class="api-path">${
|
|
170
|
+
<code class="api-path">${Ml(e.path)}</code>
|
|
180
171
|
<span class="api-route-line">${e.summary}</span>
|
|
181
172
|
<span class="api-chev" aria-hidden="true"></span>
|
|
182
173
|
</summary>
|
|
183
174
|
<div class="api-route-detail">
|
|
184
175
|
<p class="api-summary">${e.summary}</p>
|
|
185
|
-
${e.description?
|
|
186
|
-
${
|
|
187
|
-
${
|
|
188
|
-
${
|
|
189
|
-
${
|
|
190
|
-
${
|
|
176
|
+
${e.description?o`<p class="api-desc muted">${e.description}</p>`:null}
|
|
177
|
+
${Cl(e.query)}
|
|
178
|
+
${Rl(e.accepts)}
|
|
179
|
+
${er("Request",e.example?.request)}
|
|
180
|
+
${er("Response",e.example?.response)}
|
|
181
|
+
${Hl(e)}
|
|
191
182
|
</div>
|
|
192
|
-
</details></li>`}var
|
|
183
|
+
</details></li>`}var Pl=(e,t)=>e.routes.filter(n=>n.resource===t.key);function _l(e,t){let n=`${e.access}-${t.key}`,s=Pl(e,t);return o`<div class="api-res" id="api-${n}" data-sec="${n}">
|
|
193
184
|
<div class="api-res-head"><h3>${t.title}</h3></div>
|
|
194
|
-
<ul class="api-rows">${s.map(
|
|
195
|
-
</div>`}function
|
|
185
|
+
<ul class="api-rows">${s.map(Al)}</ul>
|
|
186
|
+
</div>`}function Nl(e){return o`<section class="api-section" id="api-${e.access}" data-tier="${e.access}">
|
|
196
187
|
<h2>${e.title}</h2>
|
|
197
188
|
<p class="api-blurb muted">${e.blurb}</p>
|
|
198
|
-
${e.resources.map(t=>
|
|
199
|
-
</section>`}function
|
|
200
|
-
${
|
|
201
|
-
${
|
|
189
|
+
${e.resources.map(t=>_l(e,t))}
|
|
190
|
+
</section>`}function Il(e){return o`${e.map(t=>o`<a href="#/reference" class="api-nav-tier" data-sec="${t.access}" data-tier="${t.access}">${t.title}</a>${t.resources.length>1?t.resources.map(n=>o`<a href="#/reference" class="api-nav-res" data-sec="${t.access}-${n.key}">${n.title}</a>`):null}`)}`}function tr(e,t,n){let s=e.trim().toLowerCase().split(/\s+/).filter(Boolean),i=0;for(let a of A(".api-section",n)){let d=0;for(let p of A(".api-res",a)){let m=0;for(let k of A("li[data-hay]",p)){let E=s.every($=>(k.dataset.hay??"").includes($));k.hidden=!E,m+=E?1:0}p.hidden=m===0;let y=t.querySelector(`a[data-sec="${p.dataset.sec}"]`);y&&(y.hidden=m===0),d+=m}a.hidden=d===0;let u=t.querySelector(`a[data-tier="${a.dataset.tier}"]`);u&&(u.hidden=d===0),i+=d}let r=A(".api-section",n),l=r.filter(a=>!a.hidden).at(-1);for(let a of r)a.classList.toggle("api-last",a===l);c("#apiEmpty",n).hidden=i>0}function Bl(e,t){let n=(c(".room-bar").getBoundingClientRect().height||54)+26,s=A(".api-section, .api-res",t).filter(p=>!p.closest("[hidden]")),i=window.scrollY>0&&Math.ceil(window.innerHeight+window.scrollY)>=document.documentElement.scrollHeight,r=s[0];for(let p of s)if(i||p.getBoundingClientRect().top<=n)r=p;else break;let l=r?.dataset.sec??r?.dataset.tier,a=r?.closest(".api-section")?.dataset.tier,d=A("a[data-sec]",e).filter(p=>getComputedStyle(p).display!=="none"),u=d.find(p=>p.dataset.sec===l)??(a===void 0?void 0:d.find(p=>p.dataset.tier===a));for(let p of A("a[data-sec]",e))p.classList.toggle("active",p===u)}async function Is(e,t){v(e,mt("reference",o`<div class="toc-label">API</div><nav class="api-nav" id="apiNav" aria-label="API sections"></nav>`,o`<div class="api-content" id="apiContent"><p class="muted">Loading…</p></div>`));let n=c("#apiNav"),s=c("#apiContent");n.addEventListener("click",u=>{let m=(u.target instanceof Element?u.target.closest("a[data-sec]"):null)?.dataset.sec;m&&(u.preventDefault(),document.getElementById(`api-${m}`)?.scrollIntoView({block:"start"}))});let i;try{({groups:i}=await L("/api/reference",{signal:t}))}catch(u){ie(s,G(u),()=>z(Is));return}v(n,Il(i)),v(s,o`<header class="api-head"><h1>API reference</h1><p class="muted">Generated from the route registration, so every endpoint the app and Claude can call is listed here. Base URL <code>${location.origin}</code>.</p><div class="api-filter">${P("search")}<input type="search" id="apiFilter" placeholder="Filter routes by method, path, or summary" autocomplete="off" aria-label="Filter routes" aria-keyshortcuts="/"><kbd aria-hidden="true">/</kbd><button type="button" class="icon api-filter-clear" id="apiFilterClear" aria-label="Clear filter" hidden>${P("x")}</button></div></header>${i.map(Nl)}<p class="api-empty muted" id="apiEmpty" hidden>No routes match. Try a path segment like <code>sends</code> or a method like <code>DELETE</code>.</p>`);let r=c("#apiFilter"),l=c("#apiFilterClear"),a=()=>Bl(n,s),d=u=>{r.value=u,l.hidden=u==="",tr(u,n,s),a()};r.addEventListener("input",()=>d(r.value)),l.addEventListener("click",()=>{d(""),r.focus()}),xt(s),document.addEventListener("keydown",u=>{let p=u.target instanceof HTMLElement&&(u.target.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(u.target.tagName));u.key==="/"&&!p&&!u.metaKey&&!u.ctrlKey&&!u.altKey?(u.preventDefault(),r.focus()):u.key==="Escape"&&u.target===r&&r.value&&d("")},{signal:t}),tr("",n,s),window.addEventListener("scroll",a,{passive:!0,signal:t}),window.scrollTo(0,0),a()}var Un=G;function Dl(e){let t=[Ue(e,"reschedule")?o`<button class="ghost" data-reschedule="${e.id}">Reschedule</button>`:null,Ue(e,"cancel")?o`<button class="ghost" data-cancel="${e.id}">Cancel</button>`:null];return t.some(Boolean)?o`<div class="row" data-closes="${e.fire_at}">${t}</div>`:null}async function Bs(e,t){let n={status:"sent",search:"",failures:"",sort:"fire",dir:"desc",offset:0,limit:50};v(e,o`<h1>Sent</h1>
|
|
191
|
+
${It()?o`<p class="muted">A local stand-in sends these: they are recorded here, but nothing is delivered.</p>`:null}
|
|
192
|
+
${fn("sent-not-connected",o`No email provider connected, so nothing can be sent yet. ${He()} to start sending.`)}
|
|
202
193
|
<div id="stuck"></div>
|
|
203
194
|
<h2>Scheduled</h2><div id="scheduled" class="muted">Loading…</div>
|
|
204
195
|
<div id="active"></div>
|
|
205
196
|
<h2>Sent posts</h2>
|
|
206
|
-
${
|
|
197
|
+
${yt({searchPlaceholder:"Search subject\u2026",failures:!0})}
|
|
207
198
|
<div id="sendsList" class="muted">Loading…</div>
|
|
208
|
-
<div id="sendsPager"></div>`);let s=
|
|
199
|
+
<div id="sendsPager"></div>`);let s=c("#stuck",e),i=c("#scheduled",e),r=c("#active",e),l=c("#sendsList",e),a=c("#sendsPager",e),d=Mn(e,t),u=[],p=!1,m=0,y=0,k=new Map;function E(){N(),b(),tt()}function $(g){let S=g.filter(q=>q.status==="sending"&&!Ft(q));v(r,S.length?o`<h2>In progress</h2>${S.map(An)}`:o``);for(let q of A("[data-watch]",r))q.onclick=X=>{X.target instanceof Element&&X.target.tagName==="A"||(location.hash=`#/sent/${q.dataset.watch}`)}}function x(g){let S=g.flatMap(F=>{let ne=he(F,"refused");return ne?[{s:F,c:ne}]:[]}),q=de()?S.filter(F=>F.c.cause==="not_connected"):[],X=S.filter(F=>!q.includes(F)),ue=e.querySelector(".sent-not-connected");ue&&(ue.hidden=q.length>0);let Se=q.map(({s:F},ne)=>o`${ne===0?"":ne===q.length-1?" and ":", "}<a href="#/sent/${F.id}">${F.subject}</a>`),Q=q.length?o`<div class="card stuck-card sent-held"><div class="stuck-head"><span class="stuck-dot">⚠️</span><div>${mn(o`${Se}`,q.length)}</div></div></div>`:null,le=g.flatMap(F=>{let ne=he(F,"wedged");return ne?[{s:F,c:ne}]:[]});v(s,o`${Q}${X.map(({s:F,c:ne})=>o`<div class="card stuck-card"><div class="stuck-head"><span class="stuck-dot">⚠️</span><div><strong><a href="#/sent/${F.id}">${F.subject}</a></strong><div class="muted">${ne.message}</div></div></div></div>`)}${le.map(({s:F,c:ne})=>o`<div class="card stuck-card"><div class="stuck-head"><span class="stuck-dot">⚠️</span><div><strong><a href="#/sent/${F.id}">${F.subject}</a></strong><div class="muted">${ne.message}</div></div></div>${Ue(F,"resolve")?o`<button class="primary" data-resolve="${F.id}">Resolve…</button>`:null}</div>`)}`);for(let F of A("[data-resolve]",s)){let ne=le.find(Ie=>Ie.s.id===F.dataset.resolve);ne&&(F.onclick=()=>_n({id:ne.s.id,c_in_flight:ne.c.count},E,t))}}function H(){for(let g of k.values()){let S=A("tr[data-id]",l).find(q=>q.dataset.id===g.id)?.querySelector("td.delivered");S&&v(S,kt(g.counts))}}function h(){let g=Et(u,k);x(g),p&&$(g),H()}let M=g=>g==="scheduled"||g==="due",T=g=>g==="sent"||g==="complete";function V(g){for(let S of g.sends)k.set(S.id,S);for(let S of g.removed)k.delete(S);h(),(g.sends.some(S=>M(We(S)))||g.changes.some(S=>M(S.from)))&&N(),g.removed.length?(N(),b()):g.changes.some(S=>T(S.to)&&!T(S.from))&&b()}let Y=!1;function C(g){let S=g.filter(q=>q!==null);Y||!S.length||(Y=!0,ft({cursor:S.map(q=>q.cursor).reduce(yn),sends:S.flatMap(q=>q.sends)},{update:V,stale:()=>{t.aborted||z(Bs)}},t))}async function D(){try{let g=await L("/api/sends?status=sending&limit=200",{signal:t});return u=g.sends,p=!0,h(),g}catch(g){return ie(r,Un(g),async()=>C([await D()])),null}}async function N(){let g=++m;try{let S=await L("/api/sends?status=scheduled&sort=fire&dir=asc&limit=200",{signal:t});if(g!==m)return S;let{sends:q}=S,X=Q=>o`<div class="card spread clickable sched-card" data-post="${Q.post_id}"><div><a class="card-link sched-subj" href="#/edit/${Q.post_id}">${Q.subject}</a><div class="muted">${Rn(Q)} · ${Z(Q.fire_at)} · ${Q.audience.count} recipients</div></div>${Dl(Q)}</div>`,[ue,...Se]=q;if(!ue)v(i,o`<p class="muted">Nothing scheduled.</p>`);else{v(i,o`${X(ue)}${Se.length?o`<div id="schedMore" hidden>${Se.map(X)}</div><button type="button" class="ghost sched-toggle" id="schedToggle" aria-expanded="false">Show all ${q.length} scheduled</button>`:null}`);let Q=i.querySelector("#schedToggle");Q&&(Q.onclick=()=>{let le=c("#schedMore",i),F=le.hidden;le.hidden=!F,Q.setAttribute("aria-expanded",String(F)),Q.textContent=F?"Show fewer":`Show all ${q.length} scheduled`})}for(let Q of A(".card.clickable",i))Q.onclick=le=>{let F=le.target;F instanceof Element&&F.tagName!=="A"&&!F.closest("button")&&(location.hash=`#/edit/${Q.dataset.post}`)};for(let Q of A("[data-reschedule]",i)){let le=q.find(F=>F.id===Q.dataset.reschedule);le&&(Q.onclick=()=>Nn(le.id,le.fire_at,E,t))}for(let Q of A("[data-cancel]",i))Q.onclick=()=>te(Q,"Canceling\u2026",async()=>{try{await L(`/api/sends/${Q.dataset.cancel}/cancel`,{method:"POST"}),B("Canceled"),E()}catch(le){B(Un(le))}});return d(),S}catch(S){return g===m&&ie(i,Un(S),async()=>C([await N()])),null}}async function b(){let g=++y;try{let S=await L(`/api/sends?${bt(n)}`,{signal:t});if(g!==y)return S;let q=S.sends;if(!q.length)return v(l,o`<p class="muted">${n.search?"No sent posts match.":n.failures?"Every sent post delivered cleanly.":"No sent posts yet."}</p>`),v(a,o``),S;v(l,o`<div class="table-wrap"><table class="list-table sent-table stacks"><colgroup><col><col class="c-date"><col class="c-num"><col class="c-delivered"></colgroup><thead><tr>${ye("Subject","subject",n)}${ye("When","fire",n)}${ye("Recipients","recipients",n,"num")}<th class="num">Delivered</th></tr></thead><tbody>${q.map(X=>o`<tr class="clickable" data-id="${X.id}"><td class="subject"><a href="#/sent/${X.id}">${X.subject}</a></td><td class="muted">${Z(X.completed_at??X.fire_at)}</td><td class="num recipients"><span class="n">${X.audience.count.toLocaleString()}</span></td><td class="num delivered">${kt(X.counts)}</td></tr>`)}</tbody></table></div>`),Ke(l,n,b);for(let X of A("tr[data-id]",l))X.onclick=ue=>{ue.target instanceof Element&&ue.target.tagName==="A"||(location.hash=`#/sent/${X.dataset.id}`)};return Ge(a,n,S.page,b),H(),S}catch(S){return g===y&&ie(l,Un(S),async()=>C([await b()])),null}}wt(e,n,b),C(await Promise.all([N(),D(),b()]))}var Os=G;async function qs(e,t,n){let s=()=>{n.aborted||z((l,a)=>qs(e,l,a))};v(t,o`<p class="muted">Loading…</p>`);let i;try{i=await L(`/api/sends/${e}`,{signal:n})}catch(l){ie(t,Os(l),s);return}if(n.aborted)return;let{send:r}=i;if(r.status==="scheduled"){location.replace(`#/edit/${r.post_id}`);return}return r.status==="sending"?Kl(i,t,n,s):Xl(e,i,t,n,s)}var Ol={scheduled:{label:"Scheduled",tone:"scheduled"},due:{label:"Due",tone:"scheduled"},progressing:{label:"Sending",tone:"sending"},retrying:{label:"Retrying",tone:"warn"},"backing-off":{label:"Backing off",tone:"warn"},"needs-attention":{label:"Needs attention",tone:"danger"},settling:{label:"Settling",tone:"sending"},complete:{label:"Complete",tone:"ok"},canceled:{label:"Canceled",tone:"muted"}},ql={progressing:"Handing recipients to the provider.",retrying:"Some recipients hit a transient error and will be retried.","backing-off":"Some recipients hit a transient error. They\u2019re retried on the next sweep.","needs-attention":"Some deliveries are stuck and need a decision.",settling:"Dispatch complete, waiting on delivery receipts."};function Ds(e){if(e===null)return"it is retried on its own";let t=e-Date.now();return t>0?`next retry in ${Cn(t)}`:"next retry due now"}function Ul(e){let t=e.provider.halt;return Ce(e,"refused")&&t?.cause==="not_connected"?`No email provider is connected, so nothing goes out until one is; ${Ds(t.retry_at)}.`:Ce(e,"refused")&&t?`The provider is refusing this account, so nothing more goes out until it\u2019s fixed; ${Ds(t.retry_at)}.`:e.phase==="backing-off"&&t?.reason==="unavailable"?`The provider is unavailable; ${Ds(t.retry_at)}.`:e.phase==="progressing"&&e.counts.in_flight===0&&e.counts.pending>0?"Paused between ticks. It resumes on the next sweep.":ql[e.phase]||""}function Vt(e){let t=Ol[e]??{label:e,tone:"muted"};return o`<span class="phase-pill tone-${t.tone}">${t.label}</span>`}function Fl(e,t,n,s,i){let r=s>0?Ut(100*n/s):0;return o`<div class="wbar-row">
|
|
209
200
|
<div class="wbar-head"><span class="wbar-label">${t}</span><span class="wbar-count">${n.toLocaleString()} / ${s.toLocaleString()}</span></div>
|
|
210
|
-
<div class="wbar" role="progressbar" aria-valuenow="${
|
|
211
|
-
${o
|
|
212
|
-
</div>`}function
|
|
201
|
+
<div class="wbar" role="progressbar" aria-valuenow="${r}" aria-valuemin="0" aria-valuemax="100"><div class="wbar-fill tone-${e}" style="width:${r}%"></div></div>
|
|
202
|
+
${i?o`<div class="wbar-sub muted">${i}</div>`:null}
|
|
203
|
+
</div>`}function jl(e,t,n,s,i){let r=s>0?Ut(100*n/s):0,l=s>0?Ut(100*t/s):0;return o`<div class="wbar-row">
|
|
213
204
|
<div class="wbar-head"><span class="wbar-label">${e}</span><span class="wbar-count">${t.toLocaleString()} of ${n.toLocaleString()} accepted</span></div>
|
|
214
205
|
<div class="wbar dual" role="progressbar" aria-valuenow="${l}" aria-valuemin="0" aria-valuemax="100">
|
|
215
|
-
<div class="wbar-fill tone-accepted" style="width:${
|
|
206
|
+
<div class="wbar-fill tone-accepted" style="width:${r}%"></div>
|
|
216
207
|
<div class="wbar-fill tone-ok" style="width:${l}%"></div>
|
|
217
208
|
</div>
|
|
218
|
-
<div class="wbar-sub muted">${
|
|
219
|
-
</div>`}var
|
|
220
|
-
${
|
|
221
|
-
${
|
|
209
|
+
<div class="wbar-sub muted">${i}</div>
|
|
210
|
+
</div>`}var Vl=[{key:"pending",label:"Pending",sw:"neutral"},{key:"in_flight",label:"In flight",sw:"sending"},{key:"accepted",label:"Accepted",sw:"sending"},{key:"delivered",label:"Delivered",sw:"ok"},{key:"bounced",label:"Bounced",sw:"warn"},{key:"complained",label:"Complained",sw:"danger"},{key:"unsent",label:"Unsent",sw:"neutral"},{key:"skipped",label:"Skipped",sw:"neutral"}];function Wl(e){return o`<div class="wcounts">${Vl.map(t=>o`<div class="wcount"><div class="wcount-n">${(e[t.key]||0).toLocaleString()}</div><div class="wcount-l"><span class="rec-sw sw-${t.sw}"></span>${t.label}</div></div>`)}</div>`}function ar(e){let t=e.counts,n=t.accepted+t.delivered+t.bounced+t.complained,s=e.dispatch.rate_per_min,i=e.dispatch.eta_ms,r=e.status==="sending"?`${Ul(e)}${i?`${s?` \xB7 ~${s.toLocaleString()}/min`:""} \xB7 ETA ${Cn(i)}`:""}`:"Dispatch complete.",l=(t.unsent||0)+(t.bounced||0)+(t.complained||0),a=It()?"Local stand-in: nothing is delivered":de()?"No email provider connected":`Provider: ${e.provider?.name||"unknown"}`,d=he(e,"refused"),u=d?.cause==="not_connected",p=he(e,"stuck");return o`
|
|
211
|
+
${p?o`<div class="health amber" role="status"><span class="health-dot">⚠️</span><div>${p.message}</div></div>`:null}
|
|
212
|
+
${d?o`<div class="health red" role="alert"><span class="health-dot">⚠️</span><div><strong>${u?"No email provider is connected":"The provider is refusing this account"}</strong>${u?null:o`<div>${d.error}</div>`}<div class="refused-advice">${u?ms():d.advice}</div><div>${d.since===null?null:`Since ${Z(d.since)}. `}No one has been marked unsent, and once ${u?"one is connected":"this is fixed"} the send resumes at its next retry${d.retry_at===null?"":`, ${Z(d.retry_at)}`}.</div></div></div>`:null}
|
|
222
213
|
<div class="wbars">
|
|
223
|
-
${
|
|
224
|
-
${
|
|
214
|
+
${Fl("sending","Dispatch: provider-accepted",n,e.audience.count,r)}
|
|
215
|
+
${jl("Delivery: webhook-confirmed",e.delivery.confirmed,n,e.audience.count,"Delivery lags acceptance: grey is accepted but unconfirmed, and green fills in as receipts arrive.")}
|
|
225
216
|
</div>
|
|
226
|
-
${
|
|
227
|
-
<div class="whealth${l?" has-failures":""}"><span class="whealth-dot"></span><span>${
|
|
217
|
+
${Wl(t)}
|
|
218
|
+
<div class="whealth${l?" has-failures":""}"><span class="whealth-dot"></span><span>${a} · ${l?`${l.toLocaleString()} bounced / unsent / complained`:"no delivery failures"}</span></div>`}function lr(e){let t=e.started_at?`Started ${Z(e.started_at)}`:"Sending now";return o`${t} · ${e.audience.count.toLocaleString()} recipients`}function nr(e){let t=e;return o`
|
|
228
219
|
<div class="editor-head">
|
|
229
220
|
<a href="#/sent" class="back">← Sent</a>
|
|
230
|
-
${
|
|
221
|
+
${Ue(t,"resolve")?o`<button type="button" class="primary" id="resolveBtn">Resolve…</button>`:null}
|
|
231
222
|
</div>
|
|
232
223
|
<div class="card rec-card watch-card">
|
|
233
224
|
<div class="rec-head">
|
|
234
|
-
<div class="watch-title"><h1>${e.subject||
|
|
235
|
-
<div class="rec-meta" id="watchMeta">${
|
|
225
|
+
<div class="watch-title"><h1>${e.subject||o`<em>untitled</em>`}</h1><span id="watchPill">${Vt(t.phase)}</span></div>
|
|
226
|
+
<div class="rec-meta" id="watchMeta">${lr(e)}</div>
|
|
236
227
|
</div>
|
|
237
|
-
<div id="watchBody">${
|
|
238
|
-
<p class="rec-note muted">This view updates live while the send is in flight. Delivery receipts keep arriving after dispatch finishes
|
|
239
|
-
</div>`}function
|
|
228
|
+
<div id="watchBody">${ar(t)}</div>
|
|
229
|
+
<p class="rec-note muted">This view updates live while the send is in flight. Delivery receipts keep arriving after dispatch finishes, and the record stays accurate as they settle.</p>
|
|
230
|
+
</div>`}function sr(e,t,n,s){let i=e.querySelector("#resolveBtn");if(i){let r=he(t,"wedged")?.count??t.counts.in_flight;i.onclick=()=>_n({id:t.id,c_in_flight:r},n,s)}}function or(e,t){let n=e.querySelector("#watchPill");n&&v(n,Vt(t.phase));let s=e.querySelector("#watchMeta");s&&v(s,lr(t));let i=e.querySelector("#watchBody");i&&v(i,ar(t))}function Kl(e,t,n,s){v(t,nr(e.send)),sr(t,e.send,s,n);let i=e.send;Je(1e3,()=>or(t,i),n),Ot(e,{update:r=>{if(r.status!=="sending"){s();return}i=r,Ue(r,"resolve")!==!!t.querySelector("#resolveBtn")?(v(t,nr(r)),sr(t,r,s,n)):or(t,r)},removed:s,stale:s},n)}function ir(e){let t=[{n:e.recipients,label:"Recipients",cls:"",sw:"neutral"},{n:e.delivered,label:"Delivered",cls:"ok",sw:"ok"},{n:e.bounced,label:"Bounced",cls:"warn",sw:"warn"},{n:e.complained,label:"Complained",cls:"danger",sw:"danger"},{n:e.unsent,label:"Unsent",cls:"",sw:"neutral"}];return o`${t.map(n=>o`<div class="rec-tile"><div class="rec-n ${n.cls}">${n.n.toLocaleString()}</div><div class="rec-l"><span class="rec-sw sw-${n.sw}"></span>${n.label}</div></div>`)}`}function rr(e){let t=e.recipients,n=[`${e.delivered.toLocaleString()} delivered`];e.bounced&&n.push(`${e.bounced.toLocaleString()} bounced`),e.complained&&n.push(`${e.complained.toLocaleString()} complained`),e.unsent&&n.push(`${e.unsent.toLocaleString()} unsent`),e.accepted&&n.push(`${e.accepted.toLocaleString()} accepted, awaiting a delivery receipt`),e.skipped&&n.push(`${e.skipped.toLocaleString()} skipped (unsubscribed or suppressed before hand-off, never mailed)`),e.in_flight&&n.push(`${e.in_flight.toLocaleString()} in flight`);let s=`All ${t.toLocaleString()} accounted for: ${n.join(", ")}.`,i=e.bounced||e.complained?" Hard bounces and spam complaints are suppressed automatically, so those addresses won\u2019t be mailed again.":"";return`${s}${i}`}function Gl(e,t){return Object.keys(e).every(n=>e[n]===t[n])}function Yl(e){if(e.event==="delivered")return{label:"Delivered",sw:"ok"};if(e.event==="bounced")return e.bounce_kind==="hard"?{label:"Hard bounce",sw:"warn",hint:"permanent, so the address is suppressed"}:e.bounce_kind==="soft"?{label:"Soft bounce",sw:"warn",hint:"transient, so counted but not suppressed"}:{label:"Bounce",sw:"warn"};if(e.event==="complained")return{label:"Complained",sw:"danger",hint:"suppressed"};switch(e.status){case"unsent":return{label:"Unsent",sw:"neutral"};case"skipped":return{label:"Skipped",sw:"neutral"};case"accepted":return{label:"Accepted",sw:"sending",hint:"awaiting a delivery receipt"};default:return{label:"In flight",sw:"sending"}}}var Zl=[{v:"failures",label:"Failures"},{v:"delivered",label:"Delivered"},{v:"all",label:"All"}];function zl(e){return e.search?"No recipients match that address.":e.view==="failures"?"No delivery failures. Every recipient was accepted or delivered.":e.view==="delivered"?"No delivery receipts confirmed yet.":"No recipients on this send."}function Jl(e){let t=new URLSearchParams;t.set("view",e.view);let n=(e.search||"").trim();return n&&t.set("search",n),e.sort&&(t.set("sort",e.sort),t.set("dir",e.dir??"asc")),t.set("limit",String(e.limit)),t.set("offset",String(e.offset)),t.toString()}function Ql(e,t){let n=e.map(s=>{let i=Yl(s),r=i.hint?o`<span class="rec-out" title="${i.hint}"><span class="rec-sw sw-${i.sw}"></span>${i.label}</span>`:o`<span class="rec-out"><span class="rec-sw sw-${i.sw}"></span>${i.label}</span>`,l=s.error||s.event_detail||"",a=s.event_at?Z(s.event_at):"";return o`<tr><td class="rec-email">${s.email}</td><td>${r}</td><td class="muted">${l||"\u2014"}</td><td class="muted">${a||"\u2014"}</td></tr>`});return o`<div class="table-wrap"><table class="list-table rec-people-table"><colgroup><col><col class="c-out"><col><col class="c-date"></colgroup><thead><tr>${ye("Recipient","email",t)}<th class="c-out">Outcome</th><th>Detail</th><th class="c-date">When</th></tr></thead><tbody>${n}</tbody></table></div>`}function Xl(e,t,n,s,i){let{send:r,outcomes:l}=t;if(r.status==="canceled"){tc(r,n);return}let a=r.links.archive,d=l.recipients,u=r.completed_at??r.fire_at;v(n,o`
|
|
240
231
|
<div class="editor-head">
|
|
241
232
|
<a href="#/sent" class="back">← Sent</a>
|
|
242
|
-
${
|
|
233
|
+
${a?o`<button type="button" class="primary" id="viewPublished">View published post ↗</button>`:null}
|
|
243
234
|
</div>
|
|
244
235
|
<div class="card rec-card">
|
|
245
236
|
<div class="rec-head">
|
|
246
|
-
<div class="watch-title"><h1>${
|
|
247
|
-
<div class="rec-meta">Sent ${
|
|
237
|
+
<div class="watch-title"><h1>${r.subject||o`<em>untitled</em>`}</h1><span id="recPill">${Vt(r.phase)}</span></div>
|
|
238
|
+
<div class="rec-meta">Sent ${Z(u)} · ${d.toLocaleString()} recipients</div>
|
|
248
239
|
</div>
|
|
249
|
-
<p class="rec-tiles-cap muted">Delivery outcomes
|
|
250
|
-
<div class="rec-tiles">${
|
|
251
|
-
<p class="rec-recon muted">${
|
|
240
|
+
<p class="rec-tiles-cap muted">Delivery outcomes keep updating as receipts arrive; the audience at fire and the published post are fixed.</p>
|
|
241
|
+
<div class="rec-tiles">${ir(l)}</div>
|
|
242
|
+
<p class="rec-recon muted">${rr(l)}</p>
|
|
252
243
|
<div class="rec-actions">
|
|
253
244
|
<button type="button" class="ghost" id="csvBtn">Export recipients (CSV)</button>
|
|
254
245
|
</div>
|
|
@@ -256,27 +247,27 @@
|
|
|
256
247
|
<div class="rec-people-head">
|
|
257
248
|
<h2 class="rec-people-title">Recipients</h2>
|
|
258
249
|
<div class="rec-views" role="group" aria-label="Which recipients to show">
|
|
259
|
-
${
|
|
250
|
+
${Zl.map((C,D)=>o`<button type="button" class="rec-view-btn" data-view="${C.v}" aria-pressed="${D===0?"true":"false"}">${C.label}</button>`)}
|
|
260
251
|
</div>
|
|
261
252
|
</div>
|
|
262
253
|
<input class="rec-people-search" type="search" placeholder="Search email…" aria-label="Search recipients by email" autocomplete="off">
|
|
263
254
|
<div id="recRows" class="muted">Loading…</div>
|
|
264
255
|
<div id="recPager"></div>
|
|
265
256
|
</div>
|
|
266
|
-
<p class="rec-note muted">This is the record of what went out
|
|
267
|
-
</div>`);let
|
|
257
|
+
<p class="rec-note muted">This is the record of what went out: the published post is the exact frozen copy readers received, and nothing here is editable.</p>
|
|
258
|
+
</div>`);let p=document.getElementById("viewPublished");p&&a&&(p.onclick=()=>window.open(a,"_blank","noopener"));let m={view:"failures",search:"",sort:"email",dir:"asc",offset:0,limit:50},y=c("#recRows",n),k=c("#recPager",n),E=0,$=!1,x=async C=>{let D=++E;C||($=!0);try{let N=await L(`/api/sends/${e}/deliveries?${Jl(m)}`,{signal:s});if(D!==E)return;let b=N.page.total>0?Math.floor((N.page.total-1)/N.page.limit)*N.page.limit:0;if(!N.deliveries.length&&m.offset>b)return m.offset=b,x(C);$=!1,h(()=>{if(!N.deliveries.length){v(y,o`<p class="rec-people-empty muted">${zl(m)}</p>`),v(k,o``);return}v(y,Ql(N.deliveries,m)),Ke(y,m,H),Ge(k,m,N.page,H)})}catch(N){D===E&&(!C||$)&&($=!1,ie(y,Os(N),H))}},H=()=>x(!1),h=C=>{let D=document.activeElement,N=D instanceof HTMLElement&&(y.contains(D)||k.contains(D)),b=N?D.dataset.sort:void 0,g=N?["pager-prev","pager-next"].find(q=>D.classList.contains(q)):void 0;C();let S=b?y.querySelector(`.th-sort[data-sort="${b}"]`):g?k.querySelector(`.${g}`):null;S&&!S.disabled&&S.focus()},M=A(".rec-view-btn",n);for(let C of M)C.onclick=()=>{let D=C.dataset.view;(D==="failures"||D==="delivered"||D==="all")&&(m.view=D),m.offset=0;for(let N of M)N.setAttribute("aria-pressed",String(N===C));H()};let T=n.querySelector(".rec-people-search");if(T){let C;T.oninput=()=>{clearTimeout(C),C=setTimeout(()=>{m.search=T.value,m.offset=0,H()},250)}}H();let V=c("#csvBtn");V.onclick=()=>te(V,"Exporting\u2026",async()=>{try{let C=await bo(r.links.deliveries_csv),D=URL.createObjectURL(new Blob([C.text],{type:"text/csv"})),N=document.createElement("a");N.href=D,N.download=C.filename??"deliveries.csv",N.click(),setTimeout(()=>URL.revokeObjectURL(D),1e4)}catch(C){B(C instanceof J&&C.status===404?"This send no longer exists, so there are no deliveries to export.":Os(C))}});let Y=l;Ot(t,{update(C){if(C.status==="sending"){i();return}v(c("#recPill",n),Vt(C.phase));let D=ec(C);Gl(Y,D)||(Y=D,v(c(".rec-tiles",n),ir(D)),c(".rec-recon",n).textContent=rr(D),x(!0))},removed:i,stale:i},s)}function ec(e){let t=e.counts;return{recipients:e.audience.count,delivered:t.delivered,bounced:t.bounced,complained:t.complained,unsent:t.unsent,skipped:t.skipped,accepted:t.accepted,in_flight:t.pending+t.in_flight}}function tc(e,t){v(t,o`
|
|
268
259
|
<div class="editor-head">
|
|
269
260
|
<a href="#/sent" class="back">← Sent</a>
|
|
270
261
|
</div>
|
|
271
262
|
<div class="card rec-card">
|
|
272
263
|
<div class="rec-head">
|
|
273
|
-
<div class="watch-title"><h1>${e.subject||
|
|
274
|
-
<div class="rec-meta">Canceled ${
|
|
264
|
+
<div class="watch-title"><h1>${e.subject||o`<em>untitled</em>`}</h1><span id="recPill">${Vt(e.phase)}</span></div>
|
|
265
|
+
<div class="rec-meta">Canceled ${Z(e.completed_at)} · was scheduled for ${Z(e.fire_at)}</div>
|
|
275
266
|
</div>
|
|
276
267
|
<p class="rec-note muted">This send was canceled before it fired, so no one was mailed.</p>
|
|
277
|
-
</div>`)}function
|
|
268
|
+
</div>`)}function Us(e){let t=document.getElementById(e);if(!t)throw new Error(`the admin page has no #${e}`);return t}var Fn=(()=>{let e=Us("savebar"),t=e.querySelector(".savebar-msg"),n=Us("savebarSave"),s=Us("savebarDiscard"),i=0,r=u=>{t&&v(t,o`<span class="savebar-dot"></span><span>${u}</span>`)},l=()=>{e.hidden=!1,e.offsetHeight,e.classList.add("show")};function a(){i++,e.classList.remove("show","is-error"),e.hidden=!0,n.onclick=null,s.onclick=null,document.body.classList.remove("has-savebar")}function d({onSave:u,onDiscard:p,saveLabel:m="Save changes",discardLabel:y="Discard"},k){if(k.aborted)return{setDirty(){},showError(){}};let E=++i;k.addEventListener("abort",()=>{i===E&&a()},{once:!0}),n.textContent=m,s.textContent=y,e.classList.remove("show","is-error"),r("You have unsaved changes."),e.hidden=!0,document.body.classList.add("has-savebar"),n.onclick=()=>te(n,"Saving\u2026",()=>Promise.resolve(u())),s.onclick=()=>p();let $=()=>i===E;return{setDirty(x){$()&&(e.classList.remove("is-error"),x?(r("You have unsaved changes."),l()):e.classList.remove("show"))},showError(x){$()&&(e.classList.add("is-error"),r(x),l())}}}return{attach:d}})();var Ye=G,Wt=o` hidden`;function ve(e,t){return o`<span class="set-chip ${e}">${P(e)}${t}</span>`}function ge(e,t,n=null){return o`<div class="set-sec-head"><h2 class="set-sec-title">${e}</h2>${t}${n}<span class="set-rule"></span></div>`}function Kt(e,t){return(String(e||t).trim()[0]||"K").toUpperCase()}function cr(e){let{state:t,fromName:n}=e;return o`
|
|
278
269
|
<section class="set-sec">
|
|
279
|
-
${
|
|
270
|
+
${ge("Confirmation email",ve("editable","Editable"))}
|
|
280
271
|
<p class="set-lede">The email that asks a new subscriber to confirm. Edit the wording as you see fit. Kestrel adds the confirmation link.</p>
|
|
281
272
|
<div class="set-preview">
|
|
282
273
|
<div class="set-preview-bar set-ce-bar">
|
|
@@ -287,7 +278,7 @@
|
|
|
287
278
|
</div>
|
|
288
279
|
<div id="cePreviewBody">
|
|
289
280
|
<div class="set-inbox">
|
|
290
|
-
<div class="set-inbox-avatar" id="cePvAvatar">${
|
|
281
|
+
<div class="set-inbox-avatar" id="cePvAvatar">${Kt(t.name,n)}</div>
|
|
291
282
|
<div class="set-inbox-body">
|
|
292
283
|
<div class="set-inbox-top"><span class="set-inbox-from" id="cePvFrom">${t.name||n}</span><span class="set-inbox-time">now</span></div>
|
|
293
284
|
<div class="set-inbox-subj" id="cePvSubject"></div>
|
|
@@ -336,45 +327,54 @@
|
|
|
336
327
|
</div>
|
|
337
328
|
</div>
|
|
338
329
|
</div>
|
|
339
|
-
</section>`}function
|
|
330
|
+
</section>`}function dr(e,t){let{state:n}=t,s=t.data.settings.confirmationEmailDefault,i={subject:c("#ceSubject",e),message:c("#ceMessage",e),button:c("#ceButton",e),footer:c("#ceFooter",e)},r={avatar:c("#cePvAvatar",e),from:c("#cePvFrom",e),subject:c("#cePvSubject",e),body:c("#cePvBody",e),button:c("#cePvButton",e),foot:c("#cePvFoot",e),mast:c("#cePvMast",e),mastLogo:c("#cePvMastLogo",e),mastName:c("#cePvMastName",e),mastTag:c("#cePvMastTag",e)},l=()=>{let k=n.confirmation;r.avatar.textContent=Kt(n.name,t.fromName),r.from.textContent=n.name||t.fromName,r.subject.textContent=k.subject.trim()||s.subject||"",r.body.textContent=k.body.trim()||s.body||"",r.button.textContent=k.buttonLabel.trim()||s.buttonLabel||"Confirm",r.foot.textContent=k.reassurance,r.foot.hidden=!k.reassurance.trim();let E=n.name||t.fromDisplay,$=!!n.logoUrl,x=$||!!E;r.mast.hidden=!x,x&&(r.mastLogo.hidden=!$,$&&v(r.mastLogo,o`<img src="${n.logoUrl}" alt="">`),r.mastName.textContent=E,r.mastName.hidden=!E,r.mastTag.textContent=n.tagline,r.mastTag.hidden=!n.tagline)},a=()=>{n.confirmation.subject=i.subject.value,n.confirmation.body=i.message.value,n.confirmation.buttonLabel=i.button.value,n.confirmation.reassurance=i.footer.value,t.changed()};for(let k of Object.values(i))k.addEventListener("input",a);let d=c("#ceEditBody",e),u=c("#cePreviewBody",e),p=c("#ceTabEdit",e),m=c("#ceTabPreview",e),y=k=>{d.hidden=!k,u.hidden=k,p.setAttribute("aria-pressed",String(k)),m.setAttribute("aria-pressed",String(!k)),k&&i.subject.focus()};return m.onclick=()=>y(!1),p.onclick=()=>y(!0),c("#ceToPreview",e).onclick=()=>y(!1),c("#ceReset",e).onclick=()=>{i.subject.value=s.subject||"",i.message.value=s.body||"",i.button.value=s.buttonLabel||"",i.footer.value=s.reassurance||"",a()},l(),{repaint:l,reset(){i.subject.value=n.confirmation.subject,i.message.value=n.confirmation.body,i.button.value=n.confirmation.buttonLabel,i.footer.value=n.confirmation.reassurance,l()}}}var nc={none:"No email provider connected",fake:"Fake (dev, dead-end)",ses:"Amazon SES"};function jn(e){return nc[e]||e}function sc(e){return e.provider==="none"?o`${jn(e.provider)}. ${He("set-link")} to start sending.`:o`${jn(e.provider)}${e.simulation?o`<span class="set-pill">Simulating ${oc[e.simulation.profile]}${e.simulation.faults==="none"?", no failures":""}</span>`:null}`}var oc={generic:"a generic provider",ses:"Amazon SES"};function ic(e){let t=String(e||"").match(/<([^>]+)>/);return t?.[1]?t[1]:String(e||"")}function ur(e){let t=e.data.deployment,{fromName:n}=e;return o`
|
|
340
331
|
<section class="set-sec">
|
|
341
|
-
${
|
|
332
|
+
${ge("Email sender",ve("readonly","Read-only \xB7 set at deploy"))}
|
|
342
333
|
<div class="set-card sunken">
|
|
343
|
-
<div class="set-ro-note">${P("readonly")}<span>The sender is
|
|
334
|
+
<div class="set-ro-note">${P("readonly")}<span>The sender is set at deploy in <code>wrangler.jsonc</code>, with the AWS keys stored as secrets, so it can’t be edited here. Change it in the <a class="set-link" href="#/docs">setup guide</a>, then redeploy. Credentials are never shown.</span></div>
|
|
344
335
|
<div class="set-preview-bar" style="background:transparent">
|
|
345
336
|
<span class="set-preview-lbl">Inbox preview</span>
|
|
346
337
|
<span class="set-preview-dot">How readers see the sender</span>
|
|
347
338
|
</div>
|
|
348
339
|
<div class="set-inbox">
|
|
349
|
-
<div class="set-inbox-avatar">${
|
|
340
|
+
<div class="set-inbox-avatar">${Kt(n,n)}</div>
|
|
350
341
|
<div class="set-inbox-body">
|
|
351
342
|
<div class="set-inbox-top"><span class="set-inbox-from">${n}</span><span class="set-inbox-time">9:02 AM</span></div>
|
|
352
343
|
<div class="set-inbox-subj">Your latest post: a sample subject line</div>
|
|
353
344
|
<div class="set-inbox-snip">The opening lines of your post show here as the inbox preview…</div>
|
|
354
|
-
<div class="set-inbox-addr">${
|
|
345
|
+
<div class="set-inbox-addr">${ic(t.fromAddress)}</div>
|
|
355
346
|
</div>
|
|
356
347
|
</div>
|
|
357
348
|
<div class="set-kv" style="border-top:1px solid var(--line)">
|
|
358
349
|
<div class="set-kv-k">From address</div><div class="set-kv-v"><span class="mono">${t.fromAddress}</span></div>
|
|
359
350
|
<div class="set-kv-k">Sending domain</div><div class="set-kv-v"><span class="mono">${t.sendingDomain}</span></div>
|
|
360
|
-
<div class="set-kv-k">Email provider</div><div class="set-kv-v" id="providerValue">${
|
|
351
|
+
<div class="set-kv-k">Email provider</div><div class="set-kv-v" id="providerValue">${sc(t)}</div>
|
|
361
352
|
</div>
|
|
362
353
|
</div>
|
|
363
|
-
</section>`}function
|
|
354
|
+
</section>`}function pr(e){let t=e.data.deployment,n=`${t.archiveOrigin||""}${t.archiveBasePath||""}`,s=t.archiveOrigin===t.appOrigin;return o`
|
|
364
355
|
<section class="set-sec">
|
|
365
|
-
${
|
|
356
|
+
${ge("Instance",ve("readonly","Read-only \xB7 set at deploy"))}
|
|
366
357
|
<div class="set-card sunken">
|
|
367
358
|
<div class="set-ro-note">${P("info")}<span>Deploy-time infrastructure, shown for reference. These live in your Worker config and never pass through the API. See the <a class="set-link" href="#/docs/configuration">setup guide</a> to change them.</span></div>
|
|
368
359
|
<div class="set-kv">
|
|
369
360
|
<div class="set-kv-k">App origin</div><div class="set-kv-v"><span class="mono">${t.appOrigin}</span></div>
|
|
370
|
-
<div class="set-kv-k">Archive URL base</div><div class="set-kv-v"><span class="mono">${n}</span>${s?
|
|
361
|
+
<div class="set-kv-k">Archive URL base</div><div class="set-kv-v"><span class="mono">${n}</span>${s?o`<span class="set-pill">default: app origin</span>`:null}</div>
|
|
371
362
|
<div class="set-kv-k">Image URL base</div><div class="set-kv-v"><span class="mono">${t.mediaPublicBase}</span></div>
|
|
372
363
|
<div class="set-kv-k">Auth mode</div><div class="set-kv-v">${t.authMode==="access"?"Cloudflare Access":"Local dev token"}</div>
|
|
373
|
-
<div class="set-kv-k">Cloudflare Access</div><div class="set-kv-v">${t.accessConfigured?
|
|
374
|
-
<div class="set-kv-k">Minimum lead</div><div class="set-kv-v">${
|
|
364
|
+
<div class="set-kv-k">Cloudflare Access</div><div class="set-kv-v">${t.accessConfigured?o`<span class="set-pill ok">${P("check")}Configured</span>`:o`<span class="set-pill">Not configured</span>`}</div>
|
|
365
|
+
<div class="set-kv-k">Minimum lead</div><div class="set-kv-v">${ut(t.minLeadMs)}${t.minLeadMs===ln?o`<span class="set-pill">default</span>`:null}</div>
|
|
375
366
|
</div>
|
|
376
367
|
</div>
|
|
377
|
-
</section>`}
|
|
368
|
+
</section>`}function rc(e){return e instanceof J&&e.status===409&&e.data?.error==="remake_required"?e.data:null}var ac="Not saved: the change needed your confirmation";async function ot(e,t,n){let s=null;for(;;)try{return await e(s)}catch(i){let r=rc(i);if(!r)throw i;let l=r.sends||[];if(!await lc(l,t,n))return n.aborted&&B(ac),null;s=l.map(d=>d.id)}}function lc(e,t,n){let s=e.length,i=s===1,r=`${s} scheduled email${i?"":"s"}`;return new Promise(l=>{let a=we(o`<h3>Apply this change to ${r}?</h3>
|
|
369
|
+
<p class="hint">${t.lead(s)} ${t.gerund} applies the change to ${i?"its email":"their emails"} too.</p>
|
|
370
|
+
<ul class="remake-list">${e.map(k=>o`<li><span>${k.subject||o`<em>untitled</em>`}</span><span>sends ${Z(k.fire_at)}</span></li>`)}</ul>
|
|
371
|
+
<dl class="remake-facts">
|
|
372
|
+
<div><dt>Changes</dt><dd>the look of ${i?"its email":"their emails"}</dd></div>
|
|
373
|
+
<div><dt>Stays</dt><dd>${i?"its content and fire time":"their content and fire times"}</dd></div>
|
|
374
|
+
<div><dt>Next</dt><dd>send yourself a fresh test${i?"":" of each"}</dd></div>
|
|
375
|
+
</dl>
|
|
376
|
+
<p class="hint">To leave scheduled emails as they are while future posts change, you’d need a second template, which Kestrel doesn’t offer yet.</p>
|
|
377
|
+
<div class="actions"><button type="button" id="rmCancel">Cancel</button><button type="button" class="primary" id="rmGo">${t.action} and apply</button></div>`,n);if(!a.el.isConnected){l(!1);return}let d=!1,u=k=>{d||(d=!0,l(k))},p=a.el.querySelector("#rmCancel"),m=a.el.querySelector("#rmGo");p&&(p.onclick=()=>{a.close(),u(!1)}),m&&(m.onclick=()=>{a.close(),u(!0)});let y=new MutationObserver(()=>{a.el.isConnected||(y.disconnect(),u(!1))});y.observe(document.body,{childList:!0}),m?.focus()})}var mr={action:"Save",gerund:"Saving",lead:e=>`This template is used by ${e} post${e===1?" that\u2019s":"s that are"} already scheduled.`},Gt=(e,t)=>({action:e,gerund:t,lead:n=>`Your name, tagline, address, and logo ride inside every email, including ${n} post${n===1?" that\u2019s":"s that are"} already scheduled.`});function Vn(e){let t=e?.sends||[];if(!t.length)return o`<span class="set-chip">${P("info")}No posts scheduled</span>`;let n=t.length,s=e?.retry_after?`One sends at ${Z(e.retry_after)}; saving waits until it has sent.`:null;return s?o`<span class="set-chip inuse" title="${s}"><span class="set-chip-dot"></span>In use by ${n} scheduled post${n===1?"":"s"}</span>`:o`<span class="set-chip inuse"><span class="set-chip-dot"></span>In use by ${n} scheduled post${n===1?"":"s"}</span>`}function Ht(e,t){let n=(t||[]).length;return n?`${e} and applied to ${n} scheduled email${n===1?"":"s"}. Send yourself a test of each.`:e}var fr=e=>e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");function hr(e,t){return e?`<img class="logo" src="${fr(e)}" alt="${fr(t)}" width="44" height="44" />`:""}var Wn=G,cc=[{group:"Post",vars:[{token:"{{ .Post.Body }}",desc:"Your post\u2019s Markdown, rendered to HTML."},{token:"{{ .Post.Subject }}",desc:"The post\u2019s subject line."}]},{group:"Publication",vars:[{token:"{{ .Publication.Name }}",desc:"Publication name (from Identity, above)."},{token:"{{ .Publication.Tagline }}",desc:"Your tagline."},{token:"{{ .Publication.Logo }}",desc:"Your logo as an image, or nothing when no logo is set."},{token:"{{ .Publication.LogoURL }}",desc:"Absolute URL of your logo, or empty."},{token:"{{ .Publication.Address }}",desc:"Your mailing address, if you\u2019ve added one in Settings; empty otherwise. Keep it in the footer if you send promotional email."}]},{group:"Email",vars:[{token:"{{ .Email.SentTo }}",desc:"The recipient\u2019s address (filled per send)."},{token:"{{ .Email.UnsubscribeURL }}",desc:"Their one-click unsubscribe link."},{token:"{{ .Email.ViewInBrowserURL }}",desc:"The web version\u2019s permanent URL."}]},{group:"Email only",vars:[{token:"{{ if .IsEmail }} \u2026 {{ end }}",desc:"Reports whether this is the email or the web version. Put your unsubscribe link and address inside; the web version leaves them out."}]}],Fs={signed:{label:"Signed",description:"Your post, then your logo, name, and tagline as a sign-off. Your mailing address sits in the footer once you add one.",html:`<style>
|
|
378
378
|
.email {
|
|
379
379
|
font: 16px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
380
380
|
color: #18181b;
|
|
@@ -468,7 +468,7 @@
|
|
|
468
468
|
<a href="{{ .Email.ViewInBrowserURL }}">View in browser</a>
|
|
469
469
|
<div class="address">{{ .Publication.Address }}</div>{{ end }}
|
|
470
470
|
</div>
|
|
471
|
-
</div>`},plain:{label:"Plain",html:`<style>
|
|
471
|
+
</div>`},plain:{label:"Plain",description:"Just your post and the footer links. Your mailing address sits in the footer once you add one.",html:`<style>
|
|
472
472
|
.email {
|
|
473
473
|
font: 16px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
474
474
|
color: #18181b;
|
|
@@ -529,7 +529,7 @@
|
|
|
529
529
|
<a href="{{ .Email.ViewInBrowserURL }}">View in browser</a>
|
|
530
530
|
<div class="address">{{ .Publication.Address }}</div>{{ end }}
|
|
531
531
|
</div>
|
|
532
|
-
</div>`}},
|
|
532
|
+
</div>`}},dc=e=>Object.hasOwn(Fs,e),uc=`<div style="position:relative;border:1px dashed #93a7e6;border-radius:8px;padding:20px 14px 8px;margin:0 0 6px"><span style="position:absolute;top:-8px;left:10px;font:650 10px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;letter-spacing:.04em;text-transform:uppercase;color:#3355cc;background:#fff;padding:0 6px">Your post\u2019s Markdown renders here</span><h2 style="margin:0 0 10px">Lorem ipsum,</h2><p style="margin:0">Dolor sit amet, consectetur adipiscing elit. Proin sed ex ipsum. Suspendisse vulputate nisi et odio dapibus, quis pellentesque felis sollicitudin. Proin vel cursus enim. Phasellus sollicitudin malesuada elementum. Suspendisse euismod eros turpis, ut mollis est imperdiet ut. Sed luctus accumsan erat, at eleifend purus eleifend quis.</p></div>`,pc=new Set([".Post.Body",".Publication.Logo"]);function mc(e,t){return String(e).replace(/\{\{\s*(?:if\b[^}]*|end)\s*\}\}/g,"").replace(/\{\{\s*([\w.]+)\s*\}\}/g,(s,i)=>pc.has(i)?t[i]||"":se(t[i]??""))}function fc(e){let t=e.name||"Your publication";return{".Post.Body":uc,".Post.Subject":"The starlings are back",".Publication.Name":t,".Publication.Tagline":e.tagline||"Your tagline",".Publication.LogoURL":e.logoUrl,".Publication.Logo":hr(e.logoUrl,t),".Publication.Address":e.address,".Email.SentTo":"you@example.com",".Email.UnsubscribeURL":"#unsubscribe",".Email.ViewInBrowserURL":"#view-in-browser"}}function hc(e,t){return mc(e,fc(t))}var vc='<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>html,body{margin:0}body{background:#fff}@media (prefers-color-scheme:dark){body{background:#18181b}}.kestrel-email{max-width:640px;margin:0 auto;padding:26px 20px;box-sizing:border-box}.kestrel-email img{max-width:100%}</style></head><body><div class="kestrel-email"></div></body></html>';function js(e,t,n){e.setAttribute("sandbox",Tt);let s=!1,i=()=>{try{let l=e.contentDocument?.body?.scrollHeight;l&&(e.style.height=`${Math.max(200,l)}px`)}catch{}},r=()=>{let l=e.contentDocument,a=s&&l?l.querySelector(".kestrel-email"):null;a&&(v(a,O(hc(t(),n()))),Lt(a.ownerDocument),i())};return e.addEventListener("load",()=>{s=!0,r();try{let l=e.contentDocument;l?.body&&typeof ResizeObserver<"u"&&new ResizeObserver(i).observe(l.body)}catch{}}),e.srcdoc=vc,{repaint:r}}function gc(){return o`${cc.map(e=>o`<div class="set-tpl-vargroup"><h4>${e.group}</h4>${e.vars.map(t=>o`<div class="set-tpl-var"><code>${t.token}</code><span class="set-tpl-var-desc">${t.desc}</span></div>`)}</div>`)}`}async function Vs(e,t){v(e,o`<div class="tpl-page"><div class="page-head"><div class="page-head-row"><h1>Email template</h1><span id="tplInUse"></span></div><p class="set-lede set-page-lede">The template controls the look and feel of the emails you send. You write it as HTML with a <code><style></code> block and <code>{{ variables }}</code> Kestrel fills in; your post’s Markdown is rendered into <code>{{ .Post.Body }}</code>.</p></div><div id="tplBody" class="muted">Loading…</div></div>`);let n=c("#tplBody",e),s;try{s=await L("/api/settings",{signal:t})}catch(W){ie(n,Wn(W),()=>z(Vs));return}let i=s.settings,r=s.deployment,l=i.publication,a={name:rn(l.name||"",r.fromAddress),tagline:l.tagline||"",logoUrl:l.logoUrl||"",address:l.address||""},d=i.emailTemplate||"",u=Array.isArray(i.testRecipients)?i.testRecipients:[];v(c("#tplInUse"),Vn(s.inUse)),v(n,o`
|
|
533
533
|
<div class="set-preview set-tpl-sample">
|
|
534
534
|
<div class="set-preview-bar">
|
|
535
535
|
<span class="set-preview-titles">
|
|
@@ -555,15 +555,9 @@
|
|
|
555
555
|
<div class="set-tpl-block">
|
|
556
556
|
<div class="set-tpl-editor-head">
|
|
557
557
|
<div class="set-tpl-tools">
|
|
558
|
-
<
|
|
559
|
-
<button type="button" class="ghost set-menu-btn" id="tplExamplesBtn" aria-haspopup="true" aria-expanded="false"><span>Start from example</span><span class="set-menu-caret"></span></button>
|
|
560
|
-
<div class="set-menu-list" id="tplExamplesList" role="menu" hidden>
|
|
561
|
-
<button type="button" role="menuitem" data-example="signed"><span class="set-menu-name">Signed</span><span class="set-menu-desc">Your post, then your logo, name, and tagline as a sign-off. Your mailing address sits in the footer once you add one.</span></button>
|
|
562
|
-
<button type="button" role="menuitem" data-example="plain"><span class="set-menu-name">Plain</span><span class="set-menu-desc">Just your post and the footer links. Your mailing address sits in the footer once you add one.</span></button>
|
|
563
|
-
</div>
|
|
564
|
-
</div>
|
|
558
|
+
<button type="button" class="ghost set-menu-btn" id="tplExamplesBtn" aria-haspopup="menu" aria-expanded="false"><span>Start from example</span><span class="set-menu-caret"></span></button>
|
|
565
559
|
<button type="button" class="set-icon-btn" id="tplLineNums" aria-pressed="false" title="Show line numbers" aria-label="Show line numbers">${P("lines")}</button>
|
|
566
|
-
<button type="button" class="ghost" id="tplCopyAll" title="Copy the whole template to the clipboard" aria-label="Copy the whole template">${
|
|
560
|
+
<button type="button" class="ghost" id="tplCopyAll" title="Copy the whole template to the clipboard" aria-label="Copy the whole template">${Fe()}</button>
|
|
567
561
|
</div>
|
|
568
562
|
<div class="set-tpl-required" aria-label="Required variables">
|
|
569
563
|
<span class="set-req-lbl">Required</span>
|
|
@@ -588,14 +582,15 @@
|
|
|
588
582
|
<h3 class="set-tpl-vartitle">Variables</h3>
|
|
589
583
|
<p class="field-hint">Kestrel replaces these variables with real values when you send an email. Type a variable exactly as shown, or it renders as empty.</p>
|
|
590
584
|
</div>
|
|
591
|
-
<div class="set-tpl-vars-body">${
|
|
585
|
+
<div class="set-tpl-vars-body">${gc()}</div>
|
|
592
586
|
</div>
|
|
593
|
-
</div>`);let
|
|
594
|
-
`).length;for(let
|
|
595
|
-
`;h.textContent=
|
|
596
|
-
`);let
|
|
587
|
+
</div>`);let p=c("#tplEditor"),m=js(c("#tplPreview"),()=>p.value,()=>a),y=c("#tplMsgs"),k=c("#tplTest"),E=c("#tplTestLbl"),$=()=>p.value!==d,x=c("#tplHl"),H=c("code",x),h=c("#tplGutter"),M=()=>{let W=p.value;v(H,o`${Ni(W)}\n`);let ee="",oe=W.split(`
|
|
588
|
+
`).length;for(let $e=1;$e<=oe;$e++)ee+=`${$e}
|
|
589
|
+
`;h.textContent=ee},T=()=>{x.scrollTop=p.scrollTop,x.scrollLeft=p.scrollLeft,h.scrollTop=p.scrollTop};p.addEventListener("scroll",T);let V=c("#reqBody"),Y=c("#reqUnsub"),C=()=>{V.className=`set-req-pill ${/\{\{\s*\.Post\.Body\s*\}\}/.test(p.value)?"ok":"bad"}`,Y.className=`set-req-pill ${/\{\{\s*\.Email\.UnsubscribeURL\s*\}\}/.test(p.value)?"ok":"bad"}`},D=Fn.attach({onSave:q,onDiscard:g},t);function N(){M(),C(),T(),D.setDirty($()),E.textContent=$()?"Save & send test":"Send test email",k.title=$()?"Saves your changes first, then sends: a test always reflects the saved template that will ship.":"Sends a sample post through the saved template so you can see it in a real inbox."}function b(W){if(!W.length){y.hidden=!0,v(y,o``);return}y.hidden=!1,y.className="set-tpl-msgs warn",v(y,o`${W.map(ee=>o`<div>${ee}</div>`)}`)}function g(){p.value=d,b([]),m.repaint(),N()}async function S(){let W=p.value,ee=await ot(oe=>L("/api/settings",{method:"PUT",json:oe?{emailTemplate:W,remake:oe}:{emailTemplate:W}}),mr,t);return ee?(d=ee.settings.emailTemplate,p.value=d,K.appConfig={...s,settings:ee.settings},m.repaint(),N(),{warnings:Array.isArray(ee.warnings)?ee.warnings:[],remade:ee.remade||[]}):null}async function q(){try{let W=await S();if(!W)return;b(W.warnings),B(Ht(W.warnings.length?"Template saved with warnings":"Template saved",W.remade))}catch(W){D.showError(Wn(W))}}let X=W=>{let ee=Fs[dc(W)?W:"signed"];p.value=ee.html,b([]),m.repaint(),N()};p.addEventListener("input",()=>{m.repaint(),N()});let ue=c("#tplExamplesBtn");ue.onclick=()=>$t(ue,Object.entries(Fs).map(([W,ee])=>({label:ee.label,description:ee.description,onClick:()=>X(W)})),t,{align:"start"});let Se=c("#tplPreview");for(let W of A(".wtog-btn",n))W.onclick=()=>{for(let ee of A(".wtog-btn",n))ee.setAttribute("aria-pressed",String(ee===W));Se.style.maxWidth=`${W.dataset.w}px`,m.repaint()};let Q=c("#tplEditorWrap"),le=c("#tplLineNums"),F=W=>{Q.classList.toggle("show-lines",W),le.setAttribute("aria-pressed",String(W)),T()},ne=!1;try{ne=localStorage.getItem("kestrel.tpl.lineNums")==="1"}catch{}F(ne),le.onclick=()=>{ne=!ne,F(ne);try{localStorage.setItem("kestrel.tpl.lineNums",ne?"1":"0")}catch{}};let Ie=c("#tplCopyAll");Ie.onclick=()=>Ts(Ie,p.value),k.onclick=()=>{let W=de(),ee=!W&&$(),oe=we(o`<h3>Send a test email</h3><p class="hint">Delivers a sample post rendered through your <strong>saved</strong> template, so you can see it in a real inbox. One address per line.</p>${gn()}${ee?o`<p class="hint" style="color:var(--warn-fg)"><strong>Unsaved changes:</strong> sending will save your template first, so the test reflects what will actually ship.</p>`:null}${W?null:o`<label for="tplTestTo">Recipients</label><textarea id="tplTestTo" rows="3" placeholder="you@example.com"></textarea><p class="hint" id="tplTestHint" hidden></p>`}<div class="actions"><button type="button" id="ttCancel">Cancel</button><button type="button" class="primary" id="ttGo">${ee?"Save & send test":"Send test"}</button></div>`,t),$e=c("#ttGo",oe.el);if(c("#ttCancel",oe.el).onclick=oe.close,$e.disabled=W,W){c("#ttCancel",oe.el).focus();return}let Te=c("#tplTestTo",oe.el);if(Te.focus(),u.length&&!Te.value.trim()){Te.value=u.join(`
|
|
590
|
+
`);let Ee=c("#tplTestHint",oe.el);Ee.textContent="Pre-filled from your default test recipients (Settings).",Ee.hidden=!1}$e.onclick=()=>te($e,$()?"Saving\u2026":"Sending\u2026",async()=>{oe.clearError();let{valid:Ee,invalid:it}=En(Te.value),Le=kn(it);if(Le){oe.error(Le,Te);return}if(!Ee.length){oe.error("Enter at least one email address",Te);return}if($())try{let re=await S();if(!re){oe.close();return}b(re.warnings)}catch(re){D.showError(Wn(re)),oe.close();return}try{let re=await L("/api/settings/template/test",{method:"POST",json:{to:Ee}});if(re.failed.length){Te.value=re.failed.join(`
|
|
591
|
+
`),oe.error(xn(re.sent,re.total,re.failed));return}oe.close(),B(pt(`Test sent to ${re.sent} address${re.sent===1?"":"es"}`))}catch(re){oe.error(Wn(re))}})},p.value=d,m.repaint(),N()}function vr(e){return o`
|
|
597
592
|
<section class="set-sec">
|
|
598
|
-
${
|
|
593
|
+
${ge("Email template",ve("editable","Editable"),Vn(e.data.inUse))}
|
|
599
594
|
<p class="set-lede">The template controls the look and feel of the emails you send. Edit it on the Template page.</p>
|
|
600
595
|
<div class="set-preview set-tpl-sample">
|
|
601
596
|
<div class="set-preview-bar">
|
|
@@ -604,10 +599,10 @@
|
|
|
604
599
|
</div>
|
|
605
600
|
<iframe class="set-email-frame" id="tplPreview" title="Sample email preview" scrolling="no"></iframe>
|
|
606
601
|
</div>
|
|
607
|
-
<div class="row" style="margin-top:12px"><button type="button"
|
|
608
|
-
</section>`}function
|
|
602
|
+
<div class="row" style="margin-top:12px"><button type="button" id="tplEditLink">Edit template →</button></div>
|
|
603
|
+
</section>`}function gr(e,t){let n=t.data.settings.emailTemplate||"",s=js(c("#tplPreview",e),()=>n,()=>({name:t.state.name||t.fromDisplay,tagline:t.state.tagline,logoUrl:t.state.logoUrl,address:t.state.address}));return c("#tplEditLink",e).onclick=()=>{location.hash="#/template"},{repaint:()=>s.repaint()}}function br(){return o`
|
|
609
604
|
<section class="set-sec">
|
|
610
|
-
${
|
|
605
|
+
${ge("Default test recipients",ve("editable","Editable"))}
|
|
611
606
|
<div class="set-card">
|
|
612
607
|
<div class="set-recip">
|
|
613
608
|
<p class="field-hint" style="margin:0">Pre-filled into <strong>Send test email</strong> so you can proof a post against your own inboxes before scheduling. These are your addresses, and they don’t go through the subscribe/consent flow.</p>
|
|
@@ -618,22 +613,22 @@
|
|
|
618
613
|
</div>
|
|
619
614
|
</div>
|
|
620
615
|
</div>
|
|
621
|
-
</section>`}function
|
|
616
|
+
</section>`}function yr(e,t){let{state:n}=t,s=c("#recipChips",e),i=c("#recipInput",e),r=()=>{if(!n.recipients.length){v(s,o`<span class="set-recip-empty">No default recipients yet. Add one below.</span>`);return}v(s,o`${n.recipients.map((a,d)=>o`<span class="set-recip-chip">${a}<button type="button" data-rm="${d}" aria-label="Remove ${a}">${P("x")}</button></span>`)}`)};s.addEventListener("click",a=>{let d=a.target instanceof Element?a.target.closest("[data-rm]"):null;d&&(n.recipients.splice(Number(d.dataset.rm),1),r(),t.changed())});let l=()=>{let a=ht(i.value);if(a){if(!vt(a)){qe(i,"That doesn\u2019t look like an email address."),i.focus();return}if(n.recipients.includes(a)){qe(i,"That inbox is already in the list."),i.focus();return}n.recipients.push(a),i.value="",r(),t.changed()}};return c("#recipAdd",e).onclick=l,i.addEventListener("keydown",a=>{a.key==="Enter"&&(a.preventDefault(),l())}),r(),{reset:r}}function bc(e,t){let n=Math.min(e,t);return{x:Math.floor((e-n)/2),y:Math.floor((t-n)/2),side:n}}var Ws=class extends Error{constructor(){super("Couldn\u2019t read that image. Pick a PNG, JPEG, WebP, or GIF.")}};async function wr(e,t){let n=await createImageBitmap(e,{imageOrientation:"from-image"}).catch(()=>{throw new Ws});try{let{x:s,y:i,side:r}=bc(n.width,n.height),l=document.createElement("canvas");l.width=t,l.height=t;let a=l.getContext("2d");if(!a)throw new Error("This browser can\u2019t prepare the image.");return a.imageSmoothingQuality="high",a.drawImage(n,s,i,r,r,0,0,t,t),{png:await new Promise((u,p)=>l.toBlob(m=>m?u(m):p(new Error("This browser can\u2019t prepare the image.")),"image/png")),width:n.width,height:n.height}}finally{n.close()}}function yc(e){if(!(e.identityFields||[]).length)return"Your email template doesn\u2019t show any of these.";let t="Shown in every email.",n=e.sends.length;if(!n)return t;let s=`Saving a change updates ${n} scheduled email${n===1?"":"s"} too; you\u2019ll confirm first.`,i=e.retry_after?` One sends at ${Z(e.retry_after)}, so saving waits until it has sent.`:"";return`${t} ${s}${i}`}var Ks=256;function Sr(e){let{state:t,fromDisplay:n,fromName:s}=e,i=!!t.address;return o`
|
|
622
617
|
<section class="set-sec">
|
|
623
|
-
${
|
|
618
|
+
${ge("Publication identity",ve("editable","Editable"))}
|
|
624
619
|
<div class="set-card">
|
|
625
620
|
<div class="set-id-grid">
|
|
626
621
|
<div class="set-logo-slot">
|
|
627
622
|
<div class="set-logo-tile${t.logoUrl?" has-img":""}" id="logoTile" role="button" tabindex="0" aria-label="Upload logo">
|
|
628
|
-
<span class="set-logo-ph" id="logoPh"${t.logoUrl?
|
|
623
|
+
<span class="set-logo-ph" id="logoPh"${t.logoUrl?Wt:null}>${P("upload")}Upload</span>
|
|
629
624
|
</div>
|
|
630
625
|
<input type="file" id="logoInput" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
|
631
626
|
<div class="set-logo-actions">
|
|
632
627
|
<button type="button" id="logoReplace">${t.logoUrl?"Replace":"Upload"}</button>
|
|
633
|
-
<button type="button" class="danger-subtle" id="logoRemove"${t.logoUrl?null:
|
|
628
|
+
<button type="button" class="danger-subtle" id="logoRemove"${t.logoUrl?null:Wt}>Remove</button>
|
|
634
629
|
</div>
|
|
635
630
|
<div class="set-logo-notes">
|
|
636
|
-
<p class="field-hint">Shown as a square. Pick an image at least ${String(
|
|
631
|
+
<p class="field-hint">Shown as a square. Pick an image at least ${String(Ks)} × ${String(Ks)} px; a wider or taller one is cropped to its center square.</p>
|
|
637
632
|
<p class="set-field-warn" id="logoShape" hidden>Your logo isn’t square, so emails show it stretched. Replace it to have it cropped square.</p>
|
|
638
633
|
<div class="field-error" id="logoError" role="alert" hidden><span class="field-error-ico" aria-hidden="true">!</span><span></span></div>
|
|
639
634
|
</div>
|
|
@@ -642,18 +637,18 @@
|
|
|
642
637
|
<div class="set-field">
|
|
643
638
|
<label for="setName">Name</label>
|
|
644
639
|
<input id="setName" value="${t.name}" placeholder="${s}" maxlength="120" autocomplete="off">
|
|
645
|
-
<p class="field-hint">${n?
|
|
640
|
+
<p class="field-hint">${n?o`Blank falls back to the email “From” name (“${n}”).`:"Blank leaves the publication unnamed, since the email \u201CFrom\u201D has no name."}</p>
|
|
646
641
|
</div>
|
|
647
642
|
<div class="set-field">
|
|
648
643
|
<label for="setTagline">Tagline</label>
|
|
649
644
|
<input id="setTagline" value="${t.tagline}" placeholder="A one-line description" maxlength="200" autocomplete="off">
|
|
650
645
|
<p class="field-hint">A short line under the name on your public pages.</p>
|
|
651
646
|
</div>
|
|
652
|
-
<div class="set-field set-addr-add" id="addrAdd"${
|
|
647
|
+
<div class="set-field set-addr-add" id="addrAdd"${i?Wt:null}>
|
|
653
648
|
<button type="button" class="ghost" id="addrAddBtn" aria-describedby="addrAddHint">${P("plus")}Add a mailing address</button>
|
|
654
649
|
<p class="field-hint" id="addrAddHint">For promotional email, US law (CAN-SPAM) requires a postal address in the footer. A P.O. box works.</p>
|
|
655
650
|
</div>
|
|
656
|
-
<div class="set-field" id="addrField"${
|
|
651
|
+
<div class="set-field" id="addrField"${i?null:Wt}>
|
|
657
652
|
<div class="set-field-head">
|
|
658
653
|
<label for="setAddress">Mailing address</label>
|
|
659
654
|
<button type="button" class="danger-subtle set-field-remove" id="addrRemove">Remove</button>
|
|
@@ -663,11 +658,11 @@
|
|
|
663
658
|
</div>
|
|
664
659
|
</div>
|
|
665
660
|
</div>
|
|
666
|
-
<div class="set-note">${P("info")}<span>${
|
|
661
|
+
<div class="set-note">${P("info")}<span>${yc(e.data.inUse)}</span></div>
|
|
667
662
|
</div>
|
|
668
|
-
</section>`}function
|
|
663
|
+
</section>`}function $r(e,t,n){let{state:s}=t,i=c("#setName",e),r=c("#setTagline",e),l=c("#setAddress",e),a=()=>{s.name=i.value.trim(),s.tagline=r.value.trim(),s.address=l.value.trim(),t.changed()};i.addEventListener("input",a),r.addEventListener("input",a),l.addEventListener("input",a);let d=!!s.address,u=(t.data.inUse.identityFields||[]).includes("address"),p=c("#addrAdd",e),m=c("#addrField",e),y=c("#addrWarn",e),k=()=>{p.hidden=d,m.hidden=!d,y.hidden=!(d&&s.address&&!u),y.hidden?l.removeAttribute("aria-describedby"):l.setAttribute("aria-describedby","addrWarn")};l.addEventListener("input",k),c("#addrAddBtn",e).onclick=()=>{d=!0,k(),l.focus()},c("#addrRemove",e).onclick=()=>{l.value="",d=!1,a(),k(),c("#addrAddBtn",e).focus()};let E=c("#logoInput",e),$=c("#logoTile",e),x=c("#logoPh",e),H=c("#logoReplace",e),h=c("#logoRemove",e),M=c("#logoShape",e),T=g=>{if(M.hidden=!0,!g)return;let S=new Image;S.onload=()=>{M.hidden=s.logoUrl!==g||S.naturalWidth===S.naturalHeight},S.src=g},V=()=>{let g=!!s.logoUrl;T(s.logoUrl),$.classList.toggle("has-img",g),$.style.backgroundImage=g?`url('${s.logoUrl}')`:"",x.hidden=g,h.hidden=!g,H.textContent=g?"Replace":"Upload"};H.onclick=()=>E.click(),$.addEventListener("click",()=>E.click()),$.addEventListener("keydown",g=>{(g.key==="Enter"||g.key===" ")&&(g.preventDefault(),E.click())});let Y=c("#logoError",e),C=c("span:last-of-type",Y),D=g=>{C.textContent=g,Y.hidden=!g},N=(g,S)=>{s.logoUrl=g.settings.publication.logoUrl||"",t.applySaved(g.settings),V(),t.changed(),B(Ht(S,g.remade))},b=g=>D(Ye(g));return E.onchange=async()=>{let g=E.files?.[0];if(E.value="",!!g){D("");try{let S=await wr(g,Ks),q=S.width!==S.height,X=await ot(ue=>{let Se=new FormData;Se.append("file",new File([S.png],"logo.png",{type:"image/png"}));let Q=ue?`?remake=${encodeURIComponent(ue.join(","))}`:"";return L(`/api/settings/logo${Q}`,{method:"POST",body:Se})},Gt("Upload","Uploading"),n);X&&N(X,q?"Logo updated, cropped to its center square":"Logo updated")}catch(S){b(S)}}},h.onclick=()=>te(h,"Removing\u2026",async()=>{D("");try{let g=await ot(S=>{let q=S?`?remake=${encodeURIComponent(S.join(","))}`:"";return L(`/api/settings/logo${q}`,{method:"DELETE"})},Gt("Remove","Removing"),n);g&&N(g,"Logo removed")}catch(g){b(g)}}),V(),k(),{reset(){i.value=s.name,r.value=s.tagline,l.value=s.address,d=!!s.address,k()}}}function wc(e,t){switch(e){case"cloudflare":return"Cloudflare Email, separate from your newsletter\u2019s provider";case"provider":return t==="none"?"Your email provider, once one is connected":`Your email provider (${jn(t)})`;default:return"Local stand-in: nothing is delivered"}}var Sc={finished:"went out",refused:"provider refusing",stuck:"in flight too long",wedged:"waiting to be resolved",missed:"missed fire time",import:"list imported",test:"test"};function Er(e){return e.kind==="test"?"test notification":`${e.subject} (${Sc[e.kind]})`}function Gs(e){let{lastSent:t,lastFailure:n}=e;return n?o`<span class="set-pill danger">Not delivered</span> <span class="muted">${Z(n.at)}, ${Er(n)}: ${n.error}</span>`:t?o`<span class="set-pill ok">${P("check")}Delivered</span> <span class="muted">${Z(t.at)}, ${Er(t)}</span>`:o`<span class="muted">None yet</span>`}function kr(e){let t=e.data.deployment;return o`
|
|
669
664
|
<section class="set-sec">
|
|
670
|
-
${
|
|
665
|
+
${ge("Notifications",ve("editable","Editable"))}
|
|
671
666
|
<p class="set-lede">Kestrel emails you when a send goes out, and right away if a send runs into a problem.</p>
|
|
672
667
|
<div class="set-card">
|
|
673
668
|
<div class="set-recip">
|
|
@@ -679,27 +674,27 @@
|
|
|
679
674
|
<div class="row"><button type="button" class="ghost" id="notifyTest">Send a test notification</button><span class="field-hint" id="notifyTestHint"></span></div>
|
|
680
675
|
</div>
|
|
681
676
|
<div class="set-kv" style="border-top:1px solid var(--line)">
|
|
682
|
-
<div class="set-kv-k">Sent through</div><div class="set-kv-v">${
|
|
677
|
+
<div class="set-kv-k">Sent through</div><div class="set-kv-v">${wc(t.notifyChannel,t.provider)}</div>
|
|
683
678
|
<div class="set-kv-k">From address</div><div class="set-kv-v"><span class="mono">${t.notifyFrom}</span></div>
|
|
684
|
-
<div class="set-kv-k">Last notification</div><div class="set-kv-v" id="notifyStatus">${
|
|
679
|
+
<div class="set-kv-k">Last notification</div><div class="set-kv-v" id="notifyStatus">${Gs(e.data.notificationStatus)}</div>
|
|
685
680
|
</div>
|
|
686
|
-
<div class="set-note">${P("readonly")}<span>The channel and its sender are set at deploy. ${t.notifyChannel==="provider"?"Notifications go through your newsletter
|
|
681
|
+
<div class="set-note">${P("readonly")}<span>The channel and its sender are set at deploy. ${t.notifyChannel==="provider"?"Notifications go through your newsletter\u2019s own provider, so one about the provider refusing your account cannot reach you; Cloudflare\u2019s email avoids that. ":""}See the <a class="set-link" href="#/docs/notifications">setup guide</a>.</span></div>
|
|
687
682
|
</div>
|
|
688
|
-
</section>`}function
|
|
689
|
-
<h3 class="set-rp-jhead" id="rpJourney-${e.id}">${P(
|
|
683
|
+
</section>`}function xr(e,t){let n=c("#notifyTo",e),s=c("#notifyTest",e),i=c("#notifyTestHint",e),r=c("#notifyStatus",e);n.addEventListener("input",()=>{t.state.notifyTo=n.value.trim().toLowerCase(),t.changed()});let l=()=>{let a=t.saved().notifyTo,d=a?t.state.notifyTo!==a?"Save first: the test goes to the saved address.":"":"Save an address to send a test.";s.disabled=!!d,i.textContent=d};return s.onclick=()=>te(s,"Sending\u2026",async()=>{try{let a=await L("/api/settings/notifications/test",{method:"POST"});B(a.channel==="fake"?`Test notification recorded for ${a.to} (local stand-in: nothing is delivered)`:`Test notification sent to ${a.to}`),v(r,Gs({lastSent:{kind:"test",subject:"",at:Date.now()},lastFailure:null}))}catch(a){v(r,Gs({lastSent:null,lastFailure:{kind:"test",subject:"",at:Date.now(),error:Ye(a)}}))}}),l(),{repaint:l,reset(){n.value=t.state.notifyTo,l()}}}var Ys={base:"app",path:"/subscribe"},Kn={base:"app",path:"/confirm?token=\u2026"},Zs={base:"app",path:"/unsubscribe?token=\u2026"},Yt=[{id:"subscribe",label:"Subscribe",flow:"steps",states:[{id:"form",label:"Form",at:Ys},{id:"check-inbox",label:"Check your inbox",at:Ys},{id:"not-an-email",label:"Not an email",offPath:!0,at:Ys}]},{id:"confirm",label:"Confirm",flow:"steps",states:[{id:"ready",label:"Ready",at:Kn},{id:"confirmed",label:"Confirmed",at:Kn},{id:"expired",label:"Expired",offPath:!0,at:Kn},{id:"invalid",label:"Invalid link",offPath:!0,at:Kn}]},{id:"unsubscribe",label:"Unsubscribe",flow:"steps",states:[{id:"ask",label:"Ask",at:Zs},{id:"done",label:"Unsubscribed",at:Zs},{id:"invalid",label:"Invalid link",offPath:!0,at:Zs}]},{id:"reading",label:"Reading",flow:"peers",states:[{id:"landing",label:"Landing page",at:{base:"app",path:"/"}},{id:"archive",label:"Archive",at:{base:"archive",path:""}},{id:"post",label:"Post page",at:{base:"archive",path:"/sample-post"}},{id:"landing-empty",label:"Landing, no posts yet",offPath:!0,at:{base:"app",path:"/"}},{id:"archive-empty",label:"Archive, no posts yet",offPath:!0,at:{base:"archive",path:""}},{id:"not-found",label:"Nothing here yet",offPath:!0,at:{base:"archive",path:"/\u2026"}}]}];function Tr(e,t){return`/api/reader-pages/${encodeURIComponent(e)}/${encodeURIComponent(t)}`}var $c={subscribe:o`Your name, tagline, and logo head each page; the words are Kestrel’s. The embeddable form in Ways to subscribe posts to these pages too.`,confirm:o`Your identity heads each page. You word the email that links here, under Confirmation email; these pages’ words are fixed, so what a reader agrees to can’t be edited away.`,unsubscribe:o`Your identity heads each page; the words are fixed, so leaving always reads the same. A mail client’s own Unsubscribe button leaves without a page.`,reading:o`Your identity heads each page, and a post page is your <a class="set-link" href="#/template">email template</a> around the post, as its web version shows it. Where a page lists posts, they are samples.`},Ec={subscribe:"mail",confirm:"circle-check",unsubscribe:"circle-minus",reading:"book"},kc=o`<span class="set-rp-dot"></span>`;function Lr(e,t,n){return o`<li><button type="button" class="set-rp-item${t.offPath?" off":""}" data-journey="${e.id}" data-state="${t.id}" aria-current="false"><span class="set-rp-mark" aria-hidden="true">${n}</span>${t.label}</button></li>`}function xc(){return o`${Yt.map(e=>{let t=e.states,n=e.flow==="steps";return o`<div class="set-rp-journey" data-journey="${e.id}">
|
|
684
|
+
<h3 class="set-rp-jhead" id="rpJourney-${e.id}">${P(Ec[e.id])}${e.label}</h3>
|
|
690
685
|
<ul class="set-rp-list${n?" steps":""}" aria-labelledby="rpJourney-${e.id}">
|
|
691
|
-
${t.filter(s=>!s.offPath).map((s,
|
|
686
|
+
${t.filter(s=>!s.offPath).map((s,i)=>Lr(e,s,n?String(i+1):kc))}
|
|
692
687
|
</ul>
|
|
693
688
|
<ul class="set-rp-list" aria-label="${e.label}, off the main path">
|
|
694
|
-
${t.filter(s=>s.offPath).map(s=>
|
|
689
|
+
${t.filter(s=>s.offPath).map(s=>Lr(e,s,P("branch")))}
|
|
695
690
|
</ul>
|
|
696
|
-
</div>`})}`}function
|
|
691
|
+
</div>`})}`}function Tc(){return o`<select id="rpSelect" aria-label="Reader page">${Yt.map(e=>{let t=e.states,n=0;return o`<optgroup label="${e.label}">${t.map(s=>{let i=e.flow==="steps"&&!s.offPath?`${++n}. `:"";return o`<option value="${e.id}/${s.id}">${i}${s.label}</option>`})}</optgroup>`})}</select>`}function Hr(e){return o`
|
|
697
692
|
<section class="set-sec set-rp-sec" id="readerPages">
|
|
698
693
|
${e}
|
|
699
694
|
<p class="set-lede">Every page a reader can land on, exactly as they see it, with your publication’s identity and sample values. Nothing here changes a page; it shows the ones that are hard to reach by hand.</p>
|
|
700
695
|
<div class="set-card set-rp">
|
|
701
|
-
<nav class="set-rp-nav" aria-label="Reader pages">${
|
|
702
|
-
<div class="set-rp-pick">${
|
|
696
|
+
<nav class="set-rp-nav" aria-label="Reader pages">${xc()}</nav>
|
|
697
|
+
<div class="set-rp-pick">${Tc()}</div>
|
|
703
698
|
<div class="set-rp-main">
|
|
704
699
|
<div class="set-rp-top">
|
|
705
700
|
<div>
|
|
@@ -718,45 +713,45 @@
|
|
|
718
713
|
</div>
|
|
719
714
|
</div>
|
|
720
715
|
</div>
|
|
721
|
-
</section>`}function
|
|
716
|
+
</section>`}function Lc(e,t){return`${e.base==="app"?t.appOrigin:`${t.archiveOrigin}${t.archiveBasePath}`}${e.path}`.replace(/^https?:\/\//,"")}function Cr(e,t,n){let s=c(".set-rp-nav",e),i=c("#rpSelect",e),r=c("#rpWidth",e),l=c("#rpStage",e),a=c("#rpFrame",e),d=c("#rpError",e),u=Yt[0],p=u.states[0],m="desktop",y=0,k=null;Qe(n,()=>k?.disconnect());let E=()=>{for(let h of A(".set-rp-item",s)){let M=h.dataset.journey===u.id&&h.dataset.state===p.id;h.setAttribute("aria-current",M?"true":"false")}for(let h of A(".set-rp-journey",s))h.classList.toggle("current",h.dataset.journey===u.id);i.value=`${u.id}/${p.id}`,c("#rpJourney",e).textContent=u.label,c("#rpState",e).textContent=p.label,v(c("#rpNote",e),$c[u.id]),c("#rpAt",e).textContent=Lc(p.at,t)},$=()=>{let h=a.contentDocument?.body?.scrollHeight;h&&(a.style.height=`${h}px`)},x=async()=>{let h=++y;try{let M=await dt(Tr(u.id,p.id),{signal:n});if(h!==y||n.aborted)return;d.hidden=!0,l.hidden=!1,a.srcdoc=In(M)}catch(M){if(h!==y||n.aborted)return;a.srcdoc="",l.hidden=!0,d.textContent=`Couldn\u2019t load this page: ${G(M)}`,d.hidden=!1}},H=(h,M)=>{let T=Yt.find(C=>C.id===h),Y=(T?.states??[]).find(C=>C.id===M);!T||!Y||T===u&&Y===p||(u=T,p=Y,E(),x())};return a.onload=()=>{let h=a.contentDocument;!h||n.aborted||(h.addEventListener("click",M=>{M.target?.closest?.("a[href], area[href]")&&M.preventDefault()}),k?.disconnect(),$(),h.body&&typeof ResizeObserver<"u"&&(k=new ResizeObserver($),k.observe(h.body)))},s.addEventListener("click",h=>{let M=h.target.closest(".set-rp-item");H(M?.dataset.journey,M?.dataset.state)}),i.addEventListener("change",()=>{let[h,M]=i.value.split("/");H(h,M)}),r.addEventListener("click",h=>{let T=h.target.closest("button[data-width]")?.dataset.width;if(!(T!=="desktop"&&T!=="phone"||T===m)){m=T;for(let V of A("button",r))V.setAttribute("aria-pressed",String(V.dataset.width===T));l.classList.toggle("phone",T==="phone")}}),E(),x(),{refresh:()=>{x()}}}function Mr(e){return e?`Subscribe to ${e}`:"Subscribe"}function Hc(e,t,n){let s=se(Mr(t)),i=`${se(n)}/subscribe`;return e==="styled"?`<form action="${i}" method="post" style="max-width:460px;font:16px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">
|
|
722
717
|
<div style="font:600 22px/1.2 Georgia,'Times New Roman',serif;letter-spacing:-0.01em;margin-bottom:12px">${s}</div>
|
|
723
718
|
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
|
724
719
|
<input type="email" name="email" required placeholder="you@example.com" aria-label="Email address" style="flex:1 1 220px;padding:11px 13px;border:1px solid #d6d3cc;border-radius:9px;background:#fff;color:#1b1a17;font:inherit">
|
|
725
720
|
<button type="submit" style="padding:11px 20px;border:1px solid #1b1a17;border-radius:9px;background:#1b1a17;color:#fbfbfa;font:inherit;font-size:15px;font-weight:600;cursor:pointer;white-space:nowrap">Subscribe</button>
|
|
726
721
|
</div>
|
|
727
|
-
<p style="margin:12px 0 0;font-size:12.5px;color:#6b6a63">We
|
|
728
|
-
</form>`:`<form action="${
|
|
722
|
+
<p style="margin:12px 0 0;font-size:12.5px;color:#6b6a63">We\u2019ll email you a link to confirm. Unsubscribe anytime.</p>
|
|
723
|
+
</form>`:`<form action="${i}" method="post">
|
|
729
724
|
<label>
|
|
730
725
|
${s}
|
|
731
726
|
<input type="email" name="email" placeholder="you@example.com" required>
|
|
732
727
|
</label>
|
|
733
728
|
<button type="submit">Subscribe</button>
|
|
734
|
-
</form>`}function
|
|
729
|
+
</form>`}function Cc(e,t){let n=Mr(t);return e==="styled"?o`<form class="set-pf-styled"><span class="l">${n}</span><div class="rowf"><input type="email" placeholder="you@example.com" disabled><button type="button" class="sub-btn" tabindex="-1">Subscribe</button></div><p class="set-pf-fine">We’ll email you a link to confirm. Unsubscribe anytime.</p></form>`:o`<form class="set-pf-plain"><label>${n} <input type="email" placeholder="you@example.com" disabled></label> <button type="button" tabindex="-1">Subscribe</button></form>`}function Ar(e){return e.data.deployment.appOrigin||location.origin}function Rc(e){return e?"A public page with your publication\u2019s name and a sign-up form ready to go.":"A public page with a sign-up form ready to go."}function Rr(e,t,n,s,i=null){return o`<div class="set-way"><div class="set-way-head"><div class="set-way-name"><h3 class="set-way-title">${P(e)}${t}</h3><p class="set-way-line">${n}</p></div>${i}</div>${s}</div>`}function Pr(e){let t=`${Ar(e)}/subscribe`;return o`
|
|
735
730
|
<section class="set-sec set-sec-subscribe">
|
|
736
|
-
${
|
|
731
|
+
${ge("Ways to subscribe",ve("copyout","Copy-out"))}
|
|
737
732
|
<p class="set-lede">Two ways for readers to join your list. Either way, a reader is added only once they confirm by email.</p>
|
|
738
|
-
${e.data.deployment.provider==="none"?
|
|
733
|
+
${e.data.deployment.provider==="none"?o`<div class="health amber set-sub-not-connected" role="status"><span class="health-dot">⚠️</span><div><span>No email provider connected, so signups aren’t open yet: the page below says so, and the form takes no address. ${He()} to open signups.</span></div></div>`:null}
|
|
739
734
|
<div class="set-card">
|
|
740
|
-
${
|
|
741
|
-
${
|
|
735
|
+
${Rr("link","Share the page",o`<span id="subscribePageLine"></span>`,o`<div class="set-way-body set-way-addr"><code class="set-addr">${t}</code>${jt(t,"Copy the subscribe page\u2019s address")}<a class="ghost-link" href="${t}" target="_blank" rel="noopener">Open ↗</a></div>`)}
|
|
736
|
+
${Rr("code","Embed a form",o`Put a sign-up form on another site you own.`,o`<div class="set-embed">
|
|
742
737
|
<div class="set-embed-preview" id="embedPreview"></div>
|
|
743
738
|
<pre class="set-embed-code"><code id="embedCode"></code></pre>
|
|
744
739
|
</div>
|
|
745
|
-
<div class="set-embed-foot" id="embedCopy"></div>`,
|
|
740
|
+
<div class="set-embed-foot" id="embedCopy"></div>`,o`<span class="set-wtog" role="group" aria-label="Snippet style">
|
|
746
741
|
<button type="button" class="wtog-btn" data-embed="styled" aria-pressed="true">Styled</button>
|
|
747
742
|
<button type="button" class="wtog-btn" data-embed="plain" aria-pressed="false">Plain</button>
|
|
748
743
|
</span>`)}
|
|
749
744
|
</div>
|
|
750
|
-
</section>`}function
|
|
751
|
-
${
|
|
752
|
-
${
|
|
753
|
-
${
|
|
754
|
-
${
|
|
755
|
-
${
|
|
756
|
-
`),
|
|
745
|
+
</section>`}function Mc(e,t){return o`<button type="button" class="secondary set-embed-copy" data-copy="${t}" data-copy-word="Copy HTML" aria-label="Copy HTML, ${e==="styled"?"Styled":"Plain"} form">${Fe(!1,"Copy HTML")}</button>`}function _r(e,t){let n=Ar(t),s=c("#embedCode",e),i=c("#embedCopy",e),r=c("#embedPreview",e),l=c("#subscribePageLine",e),a="styled",d=()=>t.state.name||t.fromDisplay,u=()=>{let y=Hc(a,d(),n);s.textContent=y,v(i,Mc(a,y)),v(r,Cc(a,d())),l.textContent=Rc(d())},p=A("[data-embed]",e);for(let y of p)y.onclick=()=>{a=y.dataset.embed==="styled"?"styled":"plain";for(let k of p)k.setAttribute("aria-pressed",String(k.dataset.embed===a));u()};xt(c(".set-sec-subscribe",e)),u();let m=d();return{repaint(){d()!==m&&(m=d(),u())}}}function Zt(e,t){return o`<div class="set-group" data-group="${e}">${t}</div>`}function zs(e){return{name:e.name,tagline:e.tagline,address:e.address,recipients:[...e.recipients],confirmation:{...e.confirmation},notifyTo:e.notifyTo}}var Ac=(e,t)=>e.length===t.length&&e.every((n,s)=>n===t[s]),Pc=(e,t)=>e.subject===t.subject&&e.body===t.body&&e.buttonLabel===t.buttonLabel&&e.reassurance===t.reassurance;async function Js(e,t){v(e,o`<div class="settings"><div class="page-head"><h1>Settings</h1><p class="set-lede set-page-lede">Your publication’s identity, the ways readers subscribe and the pages they land on, the email each post is sent inside, and where Kestrel tells you about your sends. Facts set when Kestrel was deployed come last, read-only.</p></div><div id="settingsBody" class="muted">Loading…</div></div>`);let n=c("#settingsBody",e),s;try{s=await L("/api/settings",{signal:t})}catch(T){ie(n,Ye(T),()=>z(Js));return}let i=s.settings,r=i.publication,l=i.confirmationEmail,a={name:r.name||"",tagline:r.tagline||"",address:r.address||"",logoUrl:r.logoUrl||"",recipients:[...i.testRecipients||[]],confirmation:{subject:l.subject||"",body:l.body||"",buttonLabel:l.buttonLabel||"",reassurance:l.reassurance||""},notifyTo:i.notifications.to||""},d=zs(a),u=ls(s.deployment.fromAddress),p=()=>a.name!==d.name||a.tagline!==d.tagline||a.address!==d.address||!Ac(a.recipients,d.recipients)||!Pc(a.confirmation,d.confirmation)||a.notifyTo!==d.notifyTo,m=[],y=Fn.attach({onSave:h,onDiscard:M},t),k=()=>{},E={data:s,state:a,saved:()=>d,fromDisplay:u,fromName:u||"Your publication",changed(){for(let T of m)T.repaint?.();y.setDirty(p())},applySaved(T){K.appConfig={...s,settings:T},an(),k()}};v(n,o`
|
|
746
|
+
${Zt("publication",[Sr(E)])}
|
|
747
|
+
${Zt("readers",[Pr(E),cr(E),Hr(ge("Reader pages",ve("preview","Preview")))])}
|
|
748
|
+
${Zt("emails",[vr(E),br()])}
|
|
749
|
+
${Zt("you",[kr(E)])}
|
|
750
|
+
${Zt("deploy",[ur(E),pr(E)])}
|
|
751
|
+
`),m.push($r(n,E,t),_r(n,E),dr(n,E),gr(n,E),yr(n,E),xr(n,E)),k=Cr(n,s.deployment,t).refresh;let x=null,H=()=>{x?.(),x=null};async function h(){H();try{let T={publication:{name:a.name,tagline:a.tagline,address:a.address},testRecipients:a.recipients,confirmationEmail:a.confirmation,notifications:{to:a.notifyTo}},V=await ot(C=>L("/api/settings",{method:"PUT",json:C?{...T,remake:C}:T}),Gt("Save","Saving"),t);if(!V)return;let Y=V.settings;a.name=Y.publication.name,a.tagline=Y.publication.tagline,a.address=Y.publication.address,a.recipients=[...Y.testRecipients],a.confirmation={...Y.confirmationEmail},a.notifyTo=Y.notifications.to,d=zs(a);for(let C of m)C.reset?.();E.applySaved(Y),E.changed(),B(Ht("Settings saved",V.remade))}catch(T){let V=T instanceof J?Nc(e,T.field):null;V&&T instanceof J?(x=qe(V,Ye(T)),V.focus(),y.showError("Not saved: fix the field marked above.")):y.showError(Ye(T))}}function M(){H(),Object.assign(a,zs(d));for(let T of m)T.reset?.();E.changed()}E.changed()}var _c={"publication.name":"#setName","publication.tagline":"#setTagline","publication.address":"#setAddress",testRecipients:"#recipInput","notifications.to":"#notifyTo","confirmationEmail.subject":"#ceSubject","confirmationEmail.body":"#ceMessage","confirmationEmail.buttonLabel":"#ceButton","confirmationEmail.reassurance":"#ceFooter"};function Nc(e,t){let n=t?_c[t]:void 0,s=n?e.querySelector(n):null;return(s instanceof HTMLInputElement||s instanceof HTMLTextAreaElement)&&!s.closest("[hidden]")?s:null}var Ic=[{value:"confirmed",label:"Confirmed"},{value:"pending",label:"Pending"},{value:"unsubscribed",label:"Unsubscribed"}];async function Nr(e,t,n){let s={status:"",search:"",suppressed:"",sort:"joined",dir:"desc",offset:0,limit:50};e==="suppressed"?s.suppressed="only":(e==="confirmed"||e==="pending"||e==="unsubscribed")&&(s.status=e),v(t,o`
|
|
757
752
|
<div class="spread page-head"><h1>Subscribers</h1><button class="primary" id="addSub">Add subscriber</button></div>
|
|
758
753
|
<div id="subCounts" class="muted">Loading…</div>
|
|
759
|
-
${
|
|
754
|
+
${yt({statuses:Ic,suppressible:!0,searchPlaceholder:"Search email\u2026"})}
|
|
760
755
|
<div id="subList" class="muted">Loading…</div>
|
|
761
|
-
<div id="subPager"></div>`);let
|
|
756
|
+
<div id="subPager"></div>`);let i=c("#subList"),r=c("#subPager");async function l(){try{let a=await L(`/api/subscribers?${bt(s)}`,{signal:n}),d=a.counts;v(c("#subCounts"),o`<div class="card row" style="gap:24px"><span><strong>${d.confirmed}</strong> confirmed</span><span>${d.pending} pending</span><span>${d.unsubscribed} unsubscribed</span><span>${d.suppressed} suppressed</span>${Hn("Pending: subscribed but hasn\u2019t clicked the confirmation email. Confirmed: consented, and receives sends. Unsubscribed: opted out. Suppressed: bounced or complained, and never mailed, whatever the consent state.",{below:!0})}</div>`),qc(i,a.subscribers,s,l,n),Ge(r,s,a.page,l)}catch(a){ie(i,G(a),l)}}c("#addSub").onclick=()=>Pn(l,n),wt(t,s,l),l()}var Bc={bounce:"bounced",complaint:"complaint",manual:"blocked"},Dc={bounce:"Hard bounce: mail to this address failed, so it won\u2019t be mailed again.",complaint:"Marked as spam, so it won\u2019t be mailed again, whatever the consent state.",manual:"Manually blocked, so it won\u2019t be mailed."};function Oc(e){if(!e.suppressed)return null;let t=e.suppression_reason??"",n=Bc[t]||"suppressed",s=Dc[t]||"Suppressed, so it won\u2019t be mailed, whatever the consent state.",i=e.suppression_detail?`${s} (${e.suppression_detail})`:s;return o` <span class="badge suppressed row-flag" title="${i}">${n}</span>`}function qc(e,t,n,s,i){if(!t.length){v(e,o`<p class="muted">No subscribers match.</p>`);return}v(e,o`<div class="table-wrap"><table class="list-table subs-table stacks has-actions"><colgroup><col><col class="c-status"><col class="c-date"><col class="c-act"></colgroup><thead><tr>${ye("Email","email",n)}${ye("Status",null,n)}${ye("Joined","joined",n)}<th></th></tr></thead><tbody>${t.map(r=>o`<tr data-id="${r.id}"><td>${r.email}${Oc(r)}</td><td>${st(r.status)}</td><td class="muted joined">${Z(r.created_at)}</td><td class="act">${r.status==="confirmed"?o`<button class="icon" data-menu="${r.id}" aria-label="Subscriber actions" aria-haspopup="menu">⋯</button>`:null}</td></tr>`)}</tbody></table></div>`),Ke(e,n,s);for(let r of A("[data-menu]",e))r.onclick=l=>{l.stopPropagation();let a=t.find(d=>d.id===r.dataset.menu);a&&$t(r,[{label:"Unsubscribe",danger:!0,onClick:()=>yi(a,s,i)}],i)}}var Uc="You have unsaved changes. Leave without saving?",Qs="";function Xs(){let e=location.hash||"#/dashboard";Qs=e;let[,t,n,s]=(hs(e.slice(1))?e:"#/dashboard").split("/");document.body.classList.toggle("editor-mode",t==="edit");let i=t==="web"?Wi(n,s):null;document.body.classList.toggle("web-mode",i!==null);let r=t==="docs"||t==="reference";document.body.classList.toggle("tool-mode",r);for(let l of document.querySelectorAll(".sidebar a[data-view]"))l.dataset.view===t?l.setAttribute("aria-current","page"):l.removeAttribute("aria-current");return _t(!1),t==="edit"&&n?z((l,a)=>As(n,l,a)):i!==null?z((l,a)=>Ms(i,l,a)):t==="drafts"?z(Es):t==="subscribers"?z((l,a)=>Nr(n,l,a)):t==="sent"?n?z((l,a)=>qs(n,l,a)):z(Bs):t==="template"?z(Vs):t==="settings"?z(Js):t==="reference"?z(Is):t==="docs"?z((l,a)=>_s(n,l,a,s)):z(Ls)}function Ir(){let e=ri(location.search);if(e){let t=new URL(location.href);t.search="",t.hash=e,history.replaceState(null,"",t)}}function Br(){window.addEventListener("hashchange",()=>{if(location.hash!==Qs){if(Xt()?.beforeLeave?.()==="confirm"&&!confirm(Uc)){location.hash=Qs;return}Xs()}}),window.addEventListener("beforeunload",e=>{Xt()?.dirty?.()&&(e.preventDefault(),e.returnValue="")}),window.addEventListener("keydown",e=>{let t=Xt()?.manualSave;(e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==="s"&&t&&(e.preventDefault(),t())})}async function Fc(){lo(),bi(),ii(),Br();let e=await go();if(e){K.session=e,document.body.classList.remove("signed-out"),vo();try{K.appConfig=await L("/api/settings")}catch{}return an(),Ir(),Xs()}}Fc();})();
|
|
762
757
|
//# sourceMappingURL=app.js.map
|