@kurtbruns/kestrel 1.8.0 → 1.10.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/CHANGELOG.md +66 -1
- package/README.md +4 -2
- package/cli/context.mjs +5 -1
- package/dist/public/dashboard/app.js +366 -255
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +512 -20
- package/dist/worker/index.js +3046 -632
- package/docs/README.md +3 -3
- package/docs/get-started/04-ses.md +54 -14
- package/docs/get-started/05-verify.md +5 -3
- package/docs/get-started/06-go-live.md +10 -5
- package/docs/guides/01-connect-claude.md +4 -0
- package/docs/guides/03-notifications.md +1 -1
- package/docs/publishing/01-your-list.md +86 -0
- package/docs/publishing/02-sending-rules.md +35 -0
- package/docs/publishing/03-name-your-publication.md +106 -0
- package/docs/publishing/04-write-a-post.md +98 -0
- package/docs/publishing/05-scheduling-and-sending.md +72 -0
- package/docs/publishing/06-after-a-send.md +109 -0
- package/docs/publishing/07-working-with-claude.md +48 -0
- package/docs/publishing/08-import-format.md +103 -0
- package/docs/publishing/09-share-your-subscribe-link.md +73 -0
- package/docs/publishing/10-check-your-setup.md +48 -0
- package/docs/publishing/README.md +26 -0
- package/docs/reference/01-configuration.md +7 -1
- package/docs/reference/02-sending-domain-dns.md +4 -2
- package/migrations/0005_imports.sql +74 -0
- package/migrations/0006_deliveries_fewer_indexes.sql +19 -0
- package/package.json +2 -1
- package/template/claude/CLAUDE.md +5 -2
- package/template/claude/skills/migrate-list/SKILL.md +56 -0
|
@@ -1,127 +1,141 @@
|
|
|
1
1
|
// GENERATED by scripts/build-client.mjs from client/ — edit the source there, never this file.
|
|
2
|
-
"use strict";(()=>{function Qn(e){let t=document.getElementById(e);if(!t)throw new Error(`the admin page has no #${e}`);return t}var le=Qn("app"),Xn=Qn("identity"),es=Qn("toasts"),so=document.getElementById("navToggle"),zn=document.getElementById("navScrim");function _t(e){document.body.classList.toggle("nav-open",e),so?.setAttribute("aria-expanded",e?"true":"false"),zn&&(zn.hidden=!e)}var Pt=document.getElementById("navCollapse"),Jn=null;function Br(){return window.innerWidth<540?"full":Jn==="collapsed"?"rail":Jn==="expanded"?"full":window.innerWidth<=1024?"rail":"full"}function Yn(){let e=Br();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 Zn=0,no=0;function oo(){so?.addEventListener("click",()=>_t(!document.body.classList.contains("nav-open"))),zn?.addEventListener("click",()=>_t(!1)),document.querySelector(".sidebar")?.addEventListener("click",e=>{e.target instanceof Element&&e.target.closest("a")&&_t(!1)}),Pt?.addEventListener("click",()=>{Jn=document.documentElement.dataset.nav!=="rail"?"collapsed":"expanded",Yn()}),window.addEventListener("resize",()=>{document.body.classList.add("nav-resizing"),clearTimeout(no),no=setTimeout(()=>{document.body.classList.remove("nav-resizing")},200),!Zn&&(Zn=requestAnimationFrame(()=>{Zn=0,Yn()}))}),Yn()}var zt=null,Jt=null;function Z(e){zt?.abort(),Jt=null;let t=new AbortController;zt=t;let n=document.createElement("div");n.className="view",le.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&&(Jt=i)},i=>{if(!t.signal.aborted)throw i})}function ct(){zt?.abort(),zt=null,Jt=null,le.replaceChildren()}function Qt(){return Jt}function ze(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 io(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){Dr(a)||reportError(a)}l!==!1&&s()},e);n.addEventListener("abort",i,{once:!0})};s()}function Je(e,t){if(e.aborted){t();return}e.addEventListener("abort",t,{once:!0})}function Dr(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 Or={"&":"&","<":"<",">":">",'"':""","'":"'"};function se(e){return String(e).replace(/[&<>"']/g,t=>Or[t]??t)}var Qe=class{markup;constructor(t){this.markup=t,Object.freeze(this)}toString(){return this.markup}};function ao(e){return e instanceof Qe?e.markup:e==null||e===!1?"":Array.isArray(e)?e.map(ao).join(""):se(e)}var qr=/=\s*$/;function o(e,...t){let n=ro(e,0);for(let s=0;s<t.length;s++){let i=t[s];if(!(i instanceof Qe)&&qr.test(n))throw new Error("html: an interpolation in an unquoted attribute value cannot be made safe; quote it");n+=ao(i)+ro(e,s+1)}return new Qe(n)}function ro(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 Qe(e)}function v(e,t){if(!(t instanceof Qe))throw new Error("setHtml: expected Html; build it with html`\u2026` or vouch for it with unsafeHtml()");e.innerHTML=t.markup}var Ur="https://getkestrel.dev/docs/access/";function Xt(e){if(!e||typeof e!="object")return!1;let t=e;return t.error==="unauthorized"&&t.reason==="access_not_configured"}function en(){le.querySelector(".access-wall")||(ct(),document.body.classList.add("signed-out"),v(le,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="${Ur}" 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",le).onclick=()=>location.reload(),c(".access-wall h2",le).focus())}function tn(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 Fr="https://getkestrel.dev/docs/configuration/",jr={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"},Vr="what-the-app-refuses";function Wr(e){return`${Fr}#${jr[e]??Vr}`}function nn(e){if(le.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="${Wr(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(le,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",le).onclick=()=>location.reload(),c(".config-wall h2",le).focus()}var sn="kestrel_token",K={token:localStorage.getItem(sn)||"",session:null,appConfig:null};function uo(e){K.token=(e||"").trim();try{K.token?localStorage.setItem(sn,K.token):localStorage.removeItem(sn)}catch{}}function ts(){return K.token?{Authorization:`Bearer ${K.token}`}:{}}function po(){let e=K.session;if(e?.auth.mode==="access"){let t=e.principal,n=t.email||(t.kind==="service"?"Service token":"Signed in");v(Xn,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(Xn,o`<span class="who dev" title="Local dev: sign-in is skipped on localhost">Local dev</span>`)}function ns(){le.querySelector(".reauth-wall")||(ct(),document.body.classList.add("signed-out"),v(le,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",le).onclick=()=>location.reload(),c(".reauth-wall h2",le).focus())}async function lo(){try{let e=await fetch("/api/dev/token?kind=human");if(e.ok)return uo((await e.json()).token),!0}catch{}return!1}async function co(){try{return await fetch("/api/whoami",{headers:ts(),redirect:"manual"})}catch{return null}}async function mo(){K.token||await lo();let e=await co();if(!e?.ok&&K.token&&(uo(""),await lo()&&(e=await co())),e?.ok)return await e.json();let t=e?await e.json().catch(()=>null):null,n=tn(t);return n?nn(n):Xt(t)?en():ns(),null}var z=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}},Kr="Not authorized. Sign in again.",Gr=e=>/^[a-z_]+$/i.test(e.trim()),Yr=/^(Failed to fetch|NetworkError when attempting to fetch resource\.?|Load failed|The network connection was lost\.?|The Internet connection appears to be offline\.?)$/;function J(e){if(e instanceof z){let t=e.data?.message;return typeof t=="string"&&!Gr(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 e instanceof TypeError&&Yr.test(e.message)?"Couldn\u2019t reach Kestrel. Check your connection and try again.":e instanceof Error?e.message:String(e)}async function ss(e,t){let n={...ts(),...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 Xt(r)?en():ns(),new z(Kr,401,r)}return i}function Zr(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 os(e){let t=await e.text(),n=null;try{n=t?JSON.parse(t):null}catch{}let s=tn(n);s&&nn(s);let i=e.statusText||`Request failed (HTTP ${e.status})`;return new z(Zr(n)??i,e.status,n)}async function L(e,t={}){let n=await ss(e,t);if(!n.ok)throw await os(n);let s=await n.text();return s?JSON.parse(s):null}async function dt(e,t={}){let n=await ss(e,t);if(!n.ok)throw await os(n);return n.text()}async function fo(e,t={}){let n=await ss(e,t);if(!n.ok)throw await os(n);let s=n.headers.get("content-disposition")??"",i=/filename="([^"]+)"/.exec(s)?.[1]??null;return{text:await n.text(),filename:i}}function is(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 on(e,t){return e||is(t)}function rs(e){let t=e?.settings.publication;return{name:on(t?.name||"",e?.deployment.fromAddress??"")||"Your publication",tagline:t?.tagline||"",logoUrl:t?.logoUrl||null}}function rn(){let e=rs(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 ho(e,t,n){return`${e}${t}/${n}`}var as={step:"Connect Amazon SES",guide:" (the setup guide walks through it)",after:", then deploy."};var vd=1800*1e3,an=300*1e3,gd=60*1e3,bd=1440*60*1e3;function ln(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 yd=10080*60*1e3;function vo(){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 ls(){return K.appConfig?.deployment.minLeadMs??an}function Xe(){return ln(ls())}function ut(e){let t=`Pick a time at least ${Xe()} from now, so there\u2019s a window to cancel in.`;return e?`${t} For the soonest send, use Send now below.`:t}function cn(e,t=Date.now()){return e<t+ls()}function dn(e=Date.now()){return new Date(e+ls()+6e4)}function It(){return K.appConfig?.deployment.provider==="fake"}function ue(){return K.appConfig?.deployment.provider==="none"}function zr(){return ue()&&K.appConfig?.deployment.notConnectedPreview===!0}var cs="#/docs/ses";function Le(e){let t=e?o` class="${e}"`:null;return o`<a${t} href="${cs}">${as.step}</a>`}function ds(){return zr()?o`Restart <code>npm run dev</code> without <code>SIMULATE_SENDS=not-connected</code> to send.`:o`${Le()}${as.after}`}function un(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. ${ds()}`}function pn(e,t){return ue()?o`<div class="health amber ${e}" role="status"><span class="health-dot">⚠️</span><div><span>${t}</span></div></div>`:null}function mn(){return ue()?o`<div class="sched-held">Will be held: no email provider is connected.</div>`:null}function fn(e,t){return ue()?o`<p class="hint not-connected">No email provider connected, so ${e}. ${Le()}${t}.</p>`:null}function hn(){return fn("a test can\u2019t be sent yet"," to send one")}function go(){return fn("a confirmation can\u2019t be sent yet"," to add subscribers")}function bo(){return fn("this can\u2019t be scheduled yet"," to schedule it")}function yo(){return fn("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 vn(e){let t=K.appConfig?.deployment;return t?ho(t.archiveOrigin,t.archiveBasePath,e):null}var wo=`<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 ps(e){let t=document.getElementById(e);if(!t)throw new Error(`the admin page has no #${e}`);return t}var ue=ps("app"),ms=ps("identity"),fs=ps("toasts"),Po=document.getElementById("navToggle"),cs=document.getElementById("navScrim");function Ot(e){document.body.classList.toggle("nav-open",e),Po?.setAttribute("aria-expanded",e?"true":"false"),cs&&(cs.hidden=!e)}var Bt=document.getElementById("navCollapse"),us=null;function Oa(){return window.innerWidth<540?"full":us==="collapsed"?"rail":us==="expanded"?"full":window.innerWidth<=1024?"rail":"full"}function ls(){let e=Oa();if(document.documentElement.dataset.nav=e,!Bt)return;let t=e!=="rail",n=t?"Collapse sidebar":"Expand sidebar";Bt.setAttribute("aria-expanded",t?"true":"false"),Bt.setAttribute("aria-label",n),Bt.setAttribute("title",n)}var ds=0,Ho=0;function Mo(){Po?.addEventListener("click",()=>Ot(!document.body.classList.contains("nav-open"))),cs?.addEventListener("click",()=>Ot(!1)),document.querySelector(".sidebar")?.addEventListener("click",e=>{e.target instanceof Element&&e.target.closest("a")&&Ot(!1)}),Bt?.addEventListener("click",()=>{us=document.documentElement.dataset.nav!=="rail"?"collapsed":"expanded",ls()}),window.addEventListener("resize",()=>{document.body.classList.add("nav-resizing"),clearTimeout(Ho),Ho=setTimeout(()=>{document.body.classList.remove("nav-resizing")},200),!ds&&(ds=requestAnimationFrame(()=>{ds=0,ls()}))}),ls()}var an=null,ln=null;function Q(e){an?.abort(),ln=null;let t=new AbortController;an=t;let n=document.createElement("div");n.className="view",ue.replaceChildren(n);let s;try{s=Promise.resolve(e(n,t.signal))}catch(r){s=Promise.reject(r)}return s.then(r=>{!t.signal.aborted&&r&&(ln=r)},r=>{if(!t.signal.aborted)throw r})}function ft(){an?.abort(),an=null,ln=null,ue.replaceChildren()}function dn(){return ln}function Xe(e,t,n){if(n.aborted)return;let s=setInterval(t,e);n.addEventListener("abort",()=>clearInterval(s),{once:!0})}function et(e,t,n){if(n.aborted)return;let s=()=>clearTimeout(r),r=setTimeout(()=>{n.removeEventListener("abort",s),t()},Math.max(0,e-Date.now()));n.addEventListener("abort",s,{once:!0})}function Io(e,t,n){let s=()=>{if(n.aborted)return;let r=()=>clearTimeout(i),i=setTimeout(async()=>{n.removeEventListener("abort",r);let a=!0;try{a=await t()??!0}catch(l){Ua(l)||reportError(l)}a!==!1&&s()},e);n.addEventListener("abort",r,{once:!0})};s()}function tt(e,t){if(e.aborted){t();return}e.addEventListener("abort",t,{once:!0})}function Ua(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 H(e,t=document){return[...t.querySelectorAll(e)]}var Fa={"&":"&","<":"<",">":">",'"':""","'":"'"};function ie(e){return String(e).replace(/[&<>"']/g,t=>Fa[t]??t)}var nt=class{markup;constructor(t){this.markup=t,Object.freeze(this)}toString(){return this.markup}};function Do(e){return e instanceof nt?e.markup:e==null||e===!1?"":Array.isArray(e)?e.map(Do).join(""):ie(e)}var qa=/=\s*$/;function o(e,...t){let n=No(e,0);for(let s=0;s<t.length;s++){let r=t[s];if(!(r instanceof nt)&&qa.test(n))throw new Error("html: an interpolation in an unquoted attribute value cannot be made safe; quote it");n+=Do(r)+No(e,s+1)}return new nt(n)}function No(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 W(e){return new nt(e)}function h(e,t){if(!(t instanceof nt))throw new Error("setHtml: expected Html; build it with html`\u2026` or vouch for it with unsafeHtml()");e.innerHTML=t.markup}var ja="https://getkestrel.dev/docs/access/";function cn(e){if(!e||typeof e!="object")return!1;let t=e;return t.error==="unauthorized"&&t.reason==="access_not_configured"}function un(){ue.querySelector(".access-wall")||(ft(),document.body.classList.add("signed-out"),h(ue,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="${ja}" 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>`),d("#access-reload",ue).onclick=()=>location.reload(),d(".access-wall h2",ue).focus())}function pn(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 Wa="https://getkestrel.dev/docs/configuration/",Va={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"},Ka="what-the-app-refuses";function Ga(e){return`${Wa}#${Va[e]??Ka}`}function mn(e){if(ue.querySelector(".config-wall"))return;ft(),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="${Ga(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>`:"";h(ue,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>`),d("#config-reload",ue).onclick=()=>location.reload(),d(".config-wall h2",ue).focus()}var fn="kestrel_token",X={token:localStorage.getItem(fn)||"",session:null,appConfig:null};function Uo(e){X.token=(e||"").trim();try{X.token?localStorage.setItem(fn,X.token):localStorage.removeItem(fn)}catch{}}function hs(){return X.token?{Authorization:`Bearer ${X.token}`}:{}}function Fo(){let e=X.session;if(e?.auth.mode==="access"){let t=e.principal,n=t.email||(t.kind==="service"?"Service token":"Signed in");h(ms,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 h(ms,o`<span class="who dev" title="Local dev: sign-in is skipped on localhost">Local dev</span>`)}function bs(){ue.querySelector(".reauth-wall")||(ft(),document.body.classList.add("signed-out"),h(ue,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>`),d("#reauth",ue).onclick=()=>location.reload(),d(".reauth-wall h2",ue).focus())}async function Bo(){try{let e=await fetch("/api/dev/token?kind=human");if(e.ok)return Uo((await e.json()).token),!0}catch{}return!1}async function Oo(){try{return await fetch("/api/whoami",{headers:hs(),redirect:"manual"})}catch{return null}}async function qo(){X.token||await Bo();let e=await Oo();if(!e?.ok&&X.token&&(Uo(""),await Bo()&&(e=await Oo())),e?.ok)return await e.json();let t=e?await e.json().catch(()=>null):null,n=pn(t);return n?mn(n):cn(t)?un():bs(),null}var ee=class extends Error{constructor(n,s,r=null){super(n);this.status=s;this.data=r;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}},Ya="Not authorized. Sign in again.",za=e=>/^[a-z_]+$/i.test(e.trim()),Za=/^(Failed to fetch|NetworkError when attempting to fetch resource\.?|Load failed|The network connection was lost\.?|The Internet connection appears to be offline\.?)$/;function gs(e){return e instanceof TypeError&&Za.test(e.message)}function K(e){if(e instanceof ee){let t=e.data?.message;return typeof t=="string"&&!za(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 gs(e)?"Couldn\u2019t reach Kestrel. Check your connection and try again.":e instanceof Error?e.message:String(e)}async function vs(e,t){let n={...hs(),...t.headers??{}},s=t.body;t.json!==void 0&&(n["content-type"]="application/json",s=JSON.stringify(t.json));let r=await fetch(e,{method:t.method??"GET",headers:n,body:s,signal:t.signal});if(r.status===401){let i=await r.json().catch(()=>null);throw cn(i)?un():bs(),new ee(Ya,401,i)}return r}function Ja(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 ys(e){let t=await e.text(),n=null;try{n=t?JSON.parse(t):null}catch{}let s=pn(n);s&&mn(s);let r=e.statusText||`Request failed (HTTP ${e.status})`;return new ee(Ja(n)??r,e.status,n)}async function C(e,t={}){let n=await vs(e,t);if(!n.ok)throw await ys(n);let s=await n.text();return s?JSON.parse(s):null}async function ht(e,t={}){let n=await vs(e,t);if(!n.ok)throw await ys(n);return n.text()}async function hn(e,t={}){let n=await vs(e,t);if(!n.ok)throw await ys(n);let s=n.headers.get("content-disposition")??"",r=/filename="([^"]+)"/.exec(s)?.[1]??null;return{text:await n.text(),filename:r}}function ws(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 Ut(e,t){return e||ws(t)}function Ss(e){let t=e?.settings.publication;return{name:Ut(t?.name||"",e?.deployment.fromAddress??"")||"Your publication",tagline:t?.tagline||"",logoUrl:t?.logoUrl||null}}function bn(){let e=Ss(X.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?(h(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 jo(e,t,n){return`${e}${t}/${n}`}var $s={step:"Connect Amazon SES",guide:" (the setup guide walks through it)",after:", then deploy."};var Zu=1800*1e3,vn=300*1e3,Ju=60*1e3,Qu=1440*60*1e3;function bt(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 ${bt(e)} from now, so there\u2019s time to cancel.`}var Xu=10080*60*1e3,gn=Math.floor(7e4/14);function Vo(e,t=!1){let n=e.toLocaleString("en-US");return t?`On the Workers Free plan\u2019s limits, a send to ${n} recipients on the same day as today\u2019s imports may use up the database\u2019s daily allowance of writes. That stops this newsletter until midnight UTC, the send included. Send it tomorrow (UTC), or move to Workers Paid, which is recommended for a list of more than ${gn.toLocaleString("en-US")}.`:`On the Workers Free plan\u2019s limits, a send to ${n} 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 ${gn.toLocaleString("en-US")}.`}function Ko(){let e=X.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 yn(){return X.appConfig?.deployment.minLeadMs??vn}function wn(){return Wo(yn())}function Go(){let e=yn();if(e%6e4===0){let t=e/6e4;return`${t} to ${t+1} minutes`}return`${bt(e)} to ${bt(e+6e4)}`}function Sn(e,t=Date.now()){return e<t+yn()}function $n(e=Date.now()){return new Date(e+yn()+6e4)}function Ft(){return X.appConfig?.deployment.provider==="fake"}function pe(){return X.appConfig?.deployment.provider==="none"}function el(){return X.appConfig?.deployment.freePlanLimits===!0}function tl(){return pe()&&X.appConfig?.deployment.notConnectedPreview===!0}var xs="#/docs/ses";function _e(e){let t=e?o` class="${e}"`:null;return o`<a${t} href="${xs}">${$s.step}</a>`}function Es(){return tl()?o`Restart <code>npm run dev</code> without <code>SIMULATE_SENDS=not-connected</code> to send.`:o`${_e()}${$s.after}`}function xn(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. ${Es()}`}function En(e,t){return pe()?o`<div class="health amber ${e}" role="status"><span class="health-dot">⚠️</span><div><span>${t}</span></div></div>`:null}function kn(e){if(!pe())return null;let t="Will be held: no email provider is connected.";return e==="card"?o`<div class="sched-held">${t}</div>`:o`<div class="sched-note sched-note-held"><span class="sched-note-mark" aria-hidden="true">⚠️</span><span class="sched-note-msg">${t}</span></div>`}function Tn(e,t){return pe()?o`<p class="hint not-connected">No email provider connected, so ${e}. ${_e()}${t}.</p>`:null}function Ln(){return Tn("a test can\u2019t be sent yet"," to send one")}function Yo(){return Tn("a confirmation can\u2019t be sent yet"," to add subscribers")}function ks(){return el()&&!pe()?o`<p class="hint daily-writes" id="schDailyWrites" role="status" hidden></p>`:null}function zo(){return Tn("this can\u2019t be scheduled yet"," to schedule it")}function Zo(){return Tn("this send is held when it fires, until one is"," to send it")}function gt(e){return Ft()?`${e} (local stand-in: nothing is delivered)`:e}function _n(e){let t=X.appConfig?.deployment;return t?jo(t.archiveOrigin,t.archiveBasePath,e):null}var Jo=`<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 Qo=`<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 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"><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 er=`<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 tr=`<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 nr=`<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 sr=`<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 or=`<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 rr=`<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 ir=`<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 ar=`<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 lr=`<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 dr=`<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 cr=`<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 ur=`<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 pr=`<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 mr=`<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 fr=`<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 hr=`<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 br=`<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 gr=`<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 vr=`<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 yr=`<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 wr=`<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 Sr=`<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 $r=`<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 xr=`<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 Er=`<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 kr=`<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 Tr=`<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 Lr=`<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 _r=`<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 Ml={article:W(Jo),bold:W(Qo),check:W(tr),code:W(or),copyout:W(rr),editable:W(ar),heading:W(lr),indent:W(dr),info:W(cr),"info-filled":W(ur),italic:W(pr),kestrel:W(mr),lines:W(fr),link:W(hr),ol:W(gr),paperclip:W(vr),plus:W(wr),preview:W(Sr),quote:W($r),readonly:W(xr),search:W(Er),send:W(kr),ul:W(Tr),upload:W(Lr),book:W(Xo),branch:W(er),"circle-check":W(nr),"circle-minus":W(sr),desktop:W(ir),mail:W(br),phone:W(yr),x:W(_r)};function B(e){return Ml[e]}function vt(e,t,n){let s=(p,v)=>e===p?o`<a href="#/${p}" data-room="${p}" data-text="${v}" aria-current="page">${v}</a>`:o`<a href="#/${p}" data-room="${p}" data-text="${v}">${v}</a>`,r=Ko(),i=X.appConfig?.deployment.build.buildTime,a=i?`Built ${new Date(i).toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}`:null,l=r?o`${r.version} <span aria-hidden="true">·</span> ${r.sha}`:null,c=l?a?o`<div class="rail-foot" title="${a}">${l}</div>`:o`<div class="rail-foot">${l}</div>`:null,u=l?a?o`<footer class="room-foot" title="${a}">${l}</footer>`:o`<footer class="room-foot">${l}</footer>`:null,m=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}${c}</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">
|
|
38
|
-
<a class="room-brand" href="#/docs">${
|
|
38
|
+
<a class="room-brand" href="#/docs">${B("kestrel")}<span>Kestrel</span></a>
|
|
39
39
|
<nav class="room-switch" aria-label="Reference">${s("docs","Docs")}${s("reference","API")}</nav>
|
|
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
|
+
${m}
|
|
44
44
|
${u}
|
|
45
|
-
</div>`}function
|
|
45
|
+
</div>`}function Rr(){ue.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 Il=/^(\/[A-Za-z0-9._~%-]+)+$/,Nl=/\/(?:\.|%2e){1,2}(?=\/|$)/i;function Ts(e){return Il.test(e)&&!Nl.test(e)}function Cr(e){let t=new URLSearchParams(e).get("to");return t&&Ts(t)?`#${t}`:null}var Dl=/^(\d+)\.(\d+)$/;function Bl(e){return`${e.seq}.${e.at}`}function Ar(e){let t=Dl.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 Rn(e,t){let n=Ar(e),s=Ar(t);return!n||!s?n?e:t:Bl({seq:Math.min(n.seq,s.seq),at:Math.min(n.at,s.at)})}function Ve(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 Ol=3e3,Ul=6e4,Fl=3e4,Ne=new Set,Cn=null,st=null,Ue,We=null,qt=!1,Ls=!1,An=0;function Hn(e){try{e()}catch(t){reportError(t)}}function _s(e,t){return e&&t?Rn(e,t):e??t}function Hr(e){clearTimeout(Ue),Ue=void 0,!(!Ne.size||document.hidden)&&(Ue=setTimeout(jt,e))}function jt(){if(clearTimeout(Ue),Ue=void 0,!Ne.size)return;if(We){qt=!0;return}let e=_s(Cn,st);st=null;let t=[...Ne],n=new AbortController;We=n;let s=setTimeout(()=>n.abort(),Fl),r=e?`?since=${encodeURIComponent(e)}`:"";C(`/api/sends/feed${r}`,{signal:n.signal}).then(i=>{if(clearTimeout(s),We===n){We=null,An=0,Cn=i.cursor;for(let a of t)Ne.has(a)&&a.take(i);if(qt){qt=!1,jt();return}Hr(Math.max(0,i.read_again_at-i.now))}},i=>{if(clearTimeout(s),We===n){if(We=null,qt=!1,i instanceof ee&&i.code==="cursor_ahead"){let a=[...Ne];for(let l of a)Ir(l);for(let l of a)Hn(()=>l.stale());return}i instanceof ee&&i.status===400?Cn=null:st=_s(st,e),An+=1,Hr(Math.min(Ol*2**(An-1),Ul))}})}function ql(){Ls||(Ls=!0,queueMicrotask(()=>{Ls=!1,jt()}))}function Pr(){document.hidden?(clearTimeout(Ue),Ue=void 0):jt()}function Mr(e,t,n){n.aborted||(Ne.add(e),st=_s(st,t),Ne.size===1&&document.addEventListener("visibilitychange",Pr),n.addEventListener("abort",()=>Ir(e),{once:!0}),ql())}function Ir(e){Ne.delete(e),!Ne.size&&(clearTimeout(Ue),Ue=void 0,We?.abort(),We=null,qt=!1,An=0,Cn=null,st=null,document.removeEventListener("visibilitychange",Pr))}function yt(e,t,n){let s=new Map(e.sends.map(r=>[r.id,Ve(r)]));Mr({take(r){if(!r.sends.length&&!r.removed.length)return;let i=[];for(let l of r.sends){let c=Ve(l),u=s.get(l.id)??null;u!==c&&i.push({send:l,from:u,to:c}),s.set(l.id,c)}let a=r.removed.map(l=>l.id);for(let l of a)s.delete(l);Hn(()=>t.update({sends:r.sends,changes:i,removed:a}))},stale:()=>t.stale()},e.cursor,n)}function Wt(e,t,n){let s=e.send.id,r=Ve(e.send);Mr({take(i){if(i.removed.some(u=>u.id===s)){Hn(()=>t.removed());return}let a=i.sends.find(u=>u.id===s);if(!a)return;let l=Ve(a),c=l!==r?{send:a,from:r,to:l}:null;r=l,Hn(()=>t.update(a,c))},stale:()=>t.stale()},e.cursor,n)}function ot(){Ne.size&&jt()}function rt(e){return e.trim().toLowerCase()}function it(e){return/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)}function Y(e){if(!e)return"\u2014";let t=new Date(e);return t.toLocaleString(void 0,{year:t.getFullYear()===new Date().getFullYear()?void 0:"numeric",month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}function Pn(e){let t=new Set,n=[];for(let s of String(e||"").split(/[\n,]+/)){let r=rt(s);it(r)?t.add(r):r&&n.push(s.trim())}return{valid:[...t],invalid:n}}function Mn(e){return e.length?`Not ${e.length===1?"an email address":"email addresses"}: ${e.join(", ")}`:null}function In(e,t,n,s=[]){let r=s.length?` ${s.join(" ")}`:"";return e?`Sent to ${e} of ${t}. Not sent to ${n.join(", ")}.${r} Send again to try those.`:`Not sent.${r} Send again to try.`}function wt(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 Vt(e,t=!1){let n=e-Date.now();if(t||n<=0)return"Preparing to send\u2026";let s=Math.floor(n/1e3),r=Math.floor(s/60),i=Math.floor(r/60);if(s<300)return r>0?`Sends in ${r}m ${String(s%60).padStart(2,"0")}s`:`Sends in ${s}s`;if(r<60)return`Sends in ${r}m`;if(i<24){let l=r%60;return l>0?`Sends in ${i}h ${l}m`:`Sends in ${i}h`}let a=Math.round(i/24);return`Sends in ${a} day${a===1?"":"s"}`}function Nn(e){return`Missed its fire time \xB7 ${Math.max(0,Math.floor((Date.now()-e)/6e4))} min late`}function St(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 $t(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(r=>o`<option value="${r.value}">${r.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 xt(e,t,n){let s=e.querySelector(".lt-search"),r=e.querySelector(".lt-status"),i=e.querySelector(".lt-suppressed"),a=e.querySelector(".lt-failures");if(s){s.value=t.search||"";let l;s.oninput=()=>{clearTimeout(l),l=setTimeout(()=>{t.search=s.value,t.offset=0,n()},250)}}r&&(r.value=t.status||"",r.onchange=()=>{t.status=r.value,t.offset=0,n()}),i&&(i.checked=t.suppressed==="only",i.onchange=()=>{t.suppressed=i.checked?"only":"",t.offset=0,n()}),a&&(a.checked=t.failures==="only",a.onchange=()=>{t.failures=a.checked?"only":"",t.offset=0,n()})}function ge(e,t,n,s){if(!t)return s?o`<th class="${s}">${e}</th>`:o`<th>${e}</th>`;let r=n.sort===t,i=r?n.dir==="asc"?"\u2191":"\u2193":"",a=`${s?`${s} `:""}sortable${r?" sorted":""}`;return o`<th class="${a}"><button type="button" class="th-sort" data-sort="${t}">${e}<span class="th-arrow" aria-hidden="true">${i}</span></button></th>`}function Ke(e,t,n){for(let s of e.querySelectorAll(".th-sort"))s.onclick=()=>{let r=s.dataset.sort;t.sort===r?t.dir=t.dir==="asc"?"desc":"asc":(t.sort=r,t.dir="desc"),t.offset=0,n()}}function Ge(e,t,n,s){let r=n?.limit??t.limit;if(!n||n.total<=r){h(e,o``);return}let i=n.offset+1,a=Math.min(n.offset+r,n.total),l=n.offset>0,c=n.offset+r<n.total;if(h(e,o`<div class="pager"><button type="button" class="pager-prev"${l?null:Nr}>← Prev</button><span class="pager-range muted">${i}–${a} of ${n.total}</span><button type="button" class="pager-next"${c?null:Nr}>Next →</button></div>`),l){let u=e.querySelector(".pager-prev");u&&(u.onclick=()=>{t.offset=Math.max(0,n.offset-r),s()})}if(c){let u=e.querySelector(".pager-next");u&&(u.onclick=()=>{t.offset=n.offset+r,s()})}}var Nr=o` disabled`;var jl=3600,Wl=300,Vl=3,Cs=new WeakMap;function O(e){let t=[...fs.querySelectorAll(".toast:not(.leaving)")],n=t.find(r=>r.textContent===e);if(n){Dr(n);return}let s=document.createElement("div");s.className="toast",s.textContent=e,fs.appendChild(s),requestAnimationFrame(()=>s.classList.add("show")),Dr(s);for(let r of t.slice(0,t.length+1-Vl))Or(r)}function Dr(e){clearTimeout(Cs.get(e)),Cs.set(e,window.setTimeout(()=>Or(e),jl))}function Or(e){clearTimeout(Cs.get(e)),e.style.height=`${e.offsetHeight}px`,e.offsetHeight,e.classList.add("leaving"),e.style.height="0",setTimeout(()=>e.remove(),Wl)}var Ye=e=>o`<span class="badge ${e}">${e}</span>`,Kl=0,Rs=new WeakMap;function Ur(e){let t=document.createElement("div");t.className="field-error",t.id=`field-error-${++Kl}`,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 Pe(e,t,{after:n}={}){Rs.get(e)?.();let s=Ur(t),r=e.getAttribute("aria-describedby");(n??e).after(s),e.setAttribute("aria-describedby",r?`${r} ${s.id}`:s.id),e.classList.add("is-invalid");let i=!1,a=()=>{i||(i=!0,s.remove(),e.classList.remove("is-invalid"),r===null?e.removeAttribute("aria-describedby"):e.setAttribute("aria-describedby",r),e.removeEventListener("input",a),e.removeEventListener("change",a),Rs.delete(e))};return e.addEventListener("input",a),e.addEventListener("change",a),Rs.set(e,a),a}function me(e,t){let n=document.createElement("div");n.className="modal-backdrop",h(n,o`<div class="modal" role="dialog" aria-modal="true">${e}</div>`);let s=n.firstElementChild,r=document.activeElement instanceof HTMLElement?document.activeElement:null,i=m=>{n===[...document.querySelectorAll(".modal-backdrop")].pop()&&(m.key==="Escape"?u():m.key==="Tab"&&Yl(s,m))},a=null,l=()=>{a?.(),a=null},c=(m,p)=>{if(l(),p){a=Pe(p,m),p.focus();return}let v=Ur(m);v.classList.add("dialog-error");let E=s.querySelector(".actions");E?E.before(v):s.append(v),a=()=>v.remove()},u=()=>{let m=document.activeElement,p=n.contains(m)||!m||m===document.body;n.remove(),document.removeEventListener("keydown",i),t.removeEventListener("abort",u),p&&r?.isConnected&&r.focus()};return t.aborted?{el:n,close:u,error:c,clearError:l}:(document.body.appendChild(n),n.addEventListener("click",m=>{m.target===n&&u()}),document.addEventListener("keydown",i),t.addEventListener("abort",u,{once:!0}),queueMicrotask(()=>{n.isConnected&&!s.contains(document.activeElement)&&Fr(s)[0]?.focus()}),{el:n,close:u,error:c,clearError:l})}var Gl='a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';function Fr(e){return[...e.querySelectorAll(Gl)].filter(t=>!t.closest("[hidden]"))}function Yl(e,t){let n=Fr(e),s=n[0],r=n[n.length-1];if(!s||!r){t.preventDefault();return}let i=document.activeElement;e.contains(i)?t.shiftKey&&i===s?(t.preventDefault(),r.focus()):!t.shiftKey&&i===r&&(t.preventDefault(),s.focus()):(t.preventDefault(),s.focus())}var Br=8;function zl(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,r=document.documentElement.clientWidth,i=Math.min(0,Math.max(r-Br-n-s,Br-s));e.style.setProperty("--tip-x",`${Math.round(i)}px`)}function qr(){for(let e of["pointerover","focusin"])document.addEventListener(e,t=>{let n=t.target instanceof Element?t.target.closest(".info"):null;n&&zl(n)});document.addEventListener("keydown",e=>{let t=document.activeElement;e.key==="Escape"&&t instanceof HTMLElement&&t.classList.contains("info")&&t.blur()})}function jr(e){return o`<span class="info" role="img" tabindex="0" aria-label="${e}" data-tip="${e}">${B("info-filled")}</span>`}var Dn=[],As=null,Et=null;function at(){let e=Dn.some(t=>t.contains(document.activeElement));for(let t of Dn)t.remove();Dn=[],As?.removeEventListener("abort",at),As=null,Et?.setAttribute("aria-expanded","false"),e&&Et?.isConnected&&Et.focus(),Et=null}function kt(e,t,n,{align:s="end"}={}){if(at(),n.aborted)return;let r=document.createElement("div");r.className="menu-overlay",r.onclick=at;let i=document.createElement("div");i.className="menu",i.setAttribute("role","menu");for(let l of t){let c=document.createElement("button");if(c.type="button",c.setAttribute("role","menuitem"),c.tabIndex=-1,c.className=`menu-item${l.danger?" danger-item":""}`,l.description){let u=document.createElement("span");u.className="menu-item-name",u.textContent=l.label;let m=document.createElement("span");m.className="menu-item-desc",m.textContent=l.description,c.append(u,m),i.classList.add("menu-described")}else c.textContent=l.label;c.onclick=()=>{at(),l.onClick()},i.appendChild(c)}i.addEventListener("keydown",l=>Zl(i,l)),document.body.appendChild(r),document.body.appendChild(i),Dn=[r,i],As=n,Et=e instanceof HTMLElement?e:null,Et?.setAttribute("aria-expanded","true"),n.addEventListener("abort",at,{once:!0});let a=e.getBoundingClientRect();i.style.top=`${a.bottom+window.scrollY+4}px`,i.style.left=`${(s==="start"?a.left:a.right-i.offsetWidth)+window.scrollX}px`,i.querySelector(".menu-item")?.focus()}function Zl(e,t){let n=[...e.querySelectorAll(".menu-item")],s=n.indexOf(document.activeElement),r=i=>{t.preventDefault(),n[(i+n.length)%n.length]?.focus()};t.key==="ArrowDown"?r(s+1):t.key==="ArrowUp"?r(s-1):t.key==="Home"?r(0):t.key==="End"?r(n.length-1):t.key==="Escape"?(t.preventDefault(),t.stopPropagation(),at()):t.key==="Tab"&&at()}async function J(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 oe(e,t,n){h(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 Jl=[{value:"draft",label:"Draft"},{value:"scheduled",label:"Scheduled"}],Hs="draft,scheduled";function Ps(e){return J(e,"Creating\u2026",async()=>{try{let{post:t}=await C("/api/posts",{method:"POST",json:{}});location.hash=`#/edit/${t.id}`}catch(t){O(K(t))}})}async function Ms(e,t){let n={status:Hs,search:"",sort:"",dir:"desc",offset:0,limit:50};h(e,o`<div class="spread page-head"><h1>Drafts</h1><button class="primary" id="newPost">New post</button></div>
|
|
49
|
+
${$t({statuses:Jl,allValue:Hs,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=d("#newPost",e);s.onclick=()=>Ps(s);let r=d("#list",e),i=d("#postsPager",e),a=new Map,l=new Set,c=0,u=()=>m(!1);async function m(L){let $=++c;try{let R=await C(`/api/posts?${St(n)}`,{signal:t});if($!==c)return null;let N=R.posts;if(a=new Map(N.map(g=>[g.id,g.fire_at])),l=new Set(N.flatMap(g=>g.active_send_id?[g.active_send_id]:[])),!N.length){let g=n.search||n.status&&n.status!==Hs;return h(r,o`<p class="muted">${g?"No drafts match.":"No drafts yet. Create your first draft."}</p>`),h(i,o``),R}h(r,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>${ge("Title","title",n)}${ge("Status",null,n)}${ge("Scheduled","scheduled",n)}${ge("Updated","updated",n)}<th></th></tr></thead><tbody>${N.map(g=>{let M=g.active_send_status==="sending",f=M?`#/sent/${g.active_send_id}`:`#/edit/${g.id}`;return o`<tr class="clickable" data-id="${g.id}" data-target="${f}"><td><a href="${f}">${g.subject||o`<em>untitled</em>`}</a></td><td>${M?Ye("sending"):Ye(g.status)}</td><td class="muted when${g.fire_at?"":" empty"}">${g.fire_at?Y(g.fire_at):"\u2014"}</td><td class="muted updated">${Y(g.updated_at)}</td><td class="act"><button class="icon" data-menu="${g.id}" data-status="${M?"sending":g.status}" data-target="${f}" aria-label="Post actions" aria-haspopup="menu">⋯</button></td></tr>`})}</tbody></table></div>`),Ke(r,n,u);for(let g of H("tr[data-id]",r))g.onclick=M=>{let f=M.target;f instanceof Element&&f.tagName!=="A"&&!f.closest("[data-menu]")&&g.dataset.target&&(location.hash=g.dataset.target)};for(let g of H("[data-menu]",r))g.onclick=M=>{M.stopPropagation();let f=g.dataset.status,_=g.dataset.target??"",k=[{label:f==="sending"?"Watch send":"Open",onClick:()=>{location.hash=_}}];if(f==="draft"&&g.dataset.menu){let T=g.dataset.menu;k.push({label:"Delete draft",danger:!0,onClick:()=>Ql(T,u,t)})}kt(g,k,t)};return Ge(i,n,R.page,u),R}catch(R){return!L&&$===c&&oe(r,K(R),()=>p?u():void E()),null}}let p=!1,v=L=>{p=!0,yt({cursor:L.cursor,sends:L.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:R,removed:N}){(R.some(M=>a.has(M.send.post_id))||$.some(M=>M.status==="scheduled"&&a.has(M.post_id)&&a.get(M.post_id)!==M.fire_at)||N.some(M=>l.has(M)))&&m(!0)},stale:()=>Q(Ms)},t)},E=async()=>{let L=await m(!1);L&&!p&&v(L)};xt(e,n,u),await E()}function Ql(e,t,n){let s=me(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),r=d("#dGo",s.el);d("#dCancel",s.el).onclick=s.close,r.onclick=()=>J(r,"Deleting\u2026",async()=>{s.clearError();try{await C(`/api/posts/${e}`,{method:"DELETE"}),s.close(),O("Draft deleted"),t()}catch(i){s.error(K(i))}})}var Kt=e=>Math.max(0,Math.min(100,Math.round(e||0)));function Bn(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 On(e){return o`<span class="countdown" data-fire="${e.fire_at}"${e.phase==="due"&&o` data-due`}${Re(e,"missed")&&o` data-missed`}></span>`}function Un(e,t){let n=0,s=()=>{let r=Date.now(),i=Number.POSITIVE_INFINITY,a=l=>{l>r&&l<i&&(i=l)};for(let l of H("[data-closes]",e)){let c=Number(l.dataset.closes);l.hidden=r>=c,a(c)}for(let l of H("[data-fire]",e)){let c=Number(l.dataset.fire),u=l.dataset.missed!==void 0;l.textContent=u?Nn(c):Vt(c,l.dataset.due!==void 0),l.classList.toggle("countdown-missed",u),a(c)}i-r<=1e3&&i!==n&&(n=i,et(i,s,t))};return Xe(1e3,s,t),s}var Re=(e,t)=>e.conditions.some(n=>n.kind===t);function ve(e,t){return e.conditions.find(n=>n.kind===t)}var Fe=(e,t)=>e.actions.some(n=>n.name===t);function Tt(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 Gt(e){return Re(e,"wedged")||Re(e,"refused")}function Lt(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((r,i)=>o`${i?", ":""}<span class="delivered-kind">${r}</span>`)}</span>`:null;return o`<span class="n">${t.toLocaleString()}</span>${s}`}function Fn(e){let t=e.counts,n=t.accepted+t.delivered+t.bounced+t.complained,s=t.delivered+t.bounced+t.complained,r=e.audience.count,i=r>0?100*n/r:0,a=e.dispatch.eta_ms?` \xB7 ~${Bn(e.dispatch.eta_ms)} left`:"",l=ve(e,"stuck");return o`<div class="card spread clickable active-card" data-watch="${e.id}">
|
|
52
52
|
<div class="active-main">
|
|
53
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:${
|
|
55
|
-
<div class="muted active-stat">Sending: ${n.toLocaleString()} of ${
|
|
56
|
-
${
|
|
54
|
+
<div class="active-bar"><div class="active-fill" style="width:${Kt(i)}%"></div></div>
|
|
55
|
+
<div class="muted active-stat">Sending: ${n.toLocaleString()} of ${r.toLocaleString()} accepted${s?` \xB7 ${s.toLocaleString()} confirmed`:""}${a}</div>
|
|
56
|
+
${l?o`<div class="active-stuck">${l.message}</div>`:null}
|
|
57
57
|
</div>
|
|
58
58
|
<a class="ghost-link" href="#/sent/${e.id}">Watch →</a>
|
|
59
|
-
</div>`}function
|
|
60
|
-
<
|
|
59
|
+
</div>`}var Wr={pending:"Subscribed but hasn\u2019t clicked the confirmation email.",confirmed:"Receives your posts. They confirmed here, or opted in at the service you imported them from.",unsubscribed:"Opted out, themselves or by asking you."},Vr="The address isn\u2019t sent your posts, whatever its status. The status stays as it was, greyed while the suppression keeps it from taking effect.",qn={bounce:"bounced",import_bounce:"bounced",complaint:"complaint",import_complaint:"complaint",manual:"blocked",erased:"erased"},Is={bounced:"Mail to the address failed, here or at the service it was imported from.",complaint:"Marked as spam, here or at the service it was imported from.",blocked:"Stopped by hand, by you or at the service it was imported from. You can unblock it.",erased:"Erased at their request, then signed up again. Only their confirmation email is sent, and confirming lifts this."};var Yt={unsubscribe:{label:"Unsubscribe",when:"When they ask you to take them off the list. They can rejoin by subscribing again."},block:{label:"Block",when:"When you decide yourself to stop mailing an address. Their status stays as it was, and you can unblock it."},erase:{label:"Erase",when:"When they ask you to delete their data. It can\u2019t be undone."}};var Xl={buttondown:"Buttondown",convertkit:"ConvertKit",kit:"Kit",kestrel:"Kestrel",mailchimp:"Mailchimp",mailerlite:"MailerLite",sendy:"Sendy",substack:"Substack",beehiiv:"beehiiv",ghost:"Ghost"};function zt(e){return e===""||e==="unknown"?null:Xl[e]??e.charAt(0).toUpperCase()+e.slice(1)}function Ns(e){return e==="unknown"?"opt-in unknown":`${e} opt-in`}function jn(e){return e.import_id!==null?`Imported \xB7 ${[zt(e.origin),Ns(e.opt_in)].filter(Boolean).join(", ")}`:e.confirmed_at!==null?"Double opt-in":null}function Kr(e){if(!e)return null;try{return JSON.stringify(JSON.parse(e),null,2)}catch{return e}}function Wn(e,t){let n=pe(),s=me(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>${Yo()}${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),r=d("#aGo",s.el);if(d("#aCancel",s.el).onclick=s.close,r.disabled=n,n){d("#aCancel",s.el).focus();return}let i=d("#addEmail",s.el);i.focus(),r.onclick=()=>J(r,"Adding\u2026",async()=>{s.clearError();let a=rt(i.value);if(!it(a)){s.error("Enter a valid email address",i);return}try{let l=u=>C("/api/subscribers",{method:"POST",json:u?{email:a,acknowledge_erasure:!0}:{email:a}}),c;try{c=await l(!1)}catch(u){let m=ed(u);if(!m)throw u;if(!await td(a,m.erased_at,t))return;c=await l(!0)}s.close(),O(nd(a,c.action)),e()}catch(l){s.error(K(l),l instanceof ee&&l.field==="email"?i:void 0)}})}function ed(e){return e instanceof ee&&e.status===409&&e.code==="erasure_acknowledgement_required"?e.data:null}function td(e,t,n){return new Promise(s=>{let r=me(o`<h3>Send to an erased address?</h3><p class="hint"><strong>${e}</strong> was erased at the person’s request on ${Y(t)}. Send a confirmation only if they’ve asked to join again. They rejoin only by confirming it, which lifts the erasure.</p><div class="actions"><button type="button" id="eaCancel">Cancel</button><button type="button" class="primary" id="eaGo">Send confirmation</button></div>`,n);if(!r.el.isConnected){s(!1);return}let i=!1;d("#eaCancel",r.el).onclick=r.close,d("#eaGo",r.el).onclick=()=>{i=!0,r.close()};let a=new MutationObserver(()=>{r.el.isConnected||(a.disconnect(),s(i))});a.observe(document.body,{childList:!0}),d("#eaCancel",r.el).focus()})}function nd(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}`}}var sd={pending:"Pending",confirmed:"Confirmed",unsubscribed:"Unsubscribed"},od={double:"Double",single:"Single",unknown:"Unknown"};function Gr(e,t,n){let s=Kr(e.consent_evidence),r=e.import_id!==null,i=me(o`<h3 class="sub-title">${e.email}</h3><p class="hint">${sd[e.status]}</p>
|
|
60
|
+
<dl class="sub-dl">
|
|
61
|
+
<dt>How they joined</dt><dd>${jn(e)??(e.status==="pending"?"Signed up here, not confirmed yet":"Signed up here, never confirmed")}</dd>
|
|
62
|
+
${r?o`<dt>Origin</dt><dd>${zt(e.origin)??"Unknown"}</dd>
|
|
63
|
+
<dt>Opt-in</dt><dd>${od[e.opt_in]}</dd>
|
|
64
|
+
<dt>Import</dt><dd><code>${e.import_id??""}</code></dd>`:null}
|
|
65
|
+
<dt>Joined</dt><dd>${Y(e.created_at)}</dd>
|
|
66
|
+
<dt>Confirmed</dt><dd>${Y(e.confirmed_at)}</dd>
|
|
67
|
+
${e.unsubscribed_at?o`<dt>Left</dt><dd>${Y(e.unsubscribed_at)}</dd>`:null}
|
|
68
|
+
<dt>Evidence</dt><dd>${s?o`<pre class="sub-evidence">${s}</pre>`:"None recorded"}</dd>
|
|
69
|
+
${n?o`<dt>Suppressed</dt><dd>${n.label}${n.detail?o`: ${n.detail}`:null}</dd>`:null}
|
|
70
|
+
</dl>
|
|
71
|
+
${r?o`<p class="hint sub-note">The origin, opt-in, confirmation date, and evidence are what the imported file said, not something Kestrel recorded itself.</p>`:null}
|
|
72
|
+
<div class="actions"><button type="button" id="vsClose">Close</button></div>`,t);d("#vsClose",i.el).onclick=i.close}function Yr(e,t,n){let s=me(o`<h3>Unsubscribe this subscriber?</h3><p class="hint">Use this when <strong>${e.email}</strong> asks you to take them off the list. They stop receiving posts immediately, and can rejoin by subscribing again. To stop mailing them on your own judgment, block the address instead.</p><div class="actions"><button type="button" id="uCancel">Cancel</button><button type="button" class="danger" id="uGo">Unsubscribe</button></div>`,n),r=d("#uGo",s.el);d("#uCancel",s.el).onclick=s.close,r.onclick=()=>J(r,"Unsubscribing\u2026",async()=>{s.clearError();try{await C(`/api/subscribers/${e.id}/unsubscribe`,{method:"POST"}),s.close(),O(`Unsubscribed ${e.email}`),t()}catch(i){s.error(K(i))}})}function zr(e,t,n){let s=me(o`<h3>Block this address?</h3><p class="hint">Stops all mail to <strong>${e.email}</strong>, whatever their status, which stays as it is. Use this when you decide yourself to stop mailing them; if they asked to leave, unsubscribe them instead. You can unblock it later.</p><div class="actions"><button type="button" id="bCancel">Cancel</button><button type="button" class="danger" id="bGo">Block</button></div>`,n),r=d("#bGo",s.el);d("#bCancel",s.el).onclick=s.close,r.onclick=()=>J(r,"Blocking\u2026",async()=>{s.clearError();try{let i=await C("/api/suppressions",{method:"POST",json:{email:e.email}});s.close(),O(i.added?`Blocked ${e.email}`:`${e.email} was already suppressed (${qn[i.suppression.reason]})`),t()}catch(i){s.error(K(i))}})}function Zr(e,t,n){let s=e.status==="confirmed"?"They\u2019re confirmed, so they\u2019ll receive your posts again.":"They\u2019ll receive posts again only once they\u2019re confirmed.",r=me(o`<h3>Unblock this address?</h3><p class="hint">Undoes the block on <strong>${e.email}</strong>. ${s}</p><div class="actions"><button type="button" id="ubCancel">Cancel</button><button type="button" class="primary" id="ubGo">Unblock</button></div>`,n),i=d("#ubGo",r.el);d("#ubCancel",r.el).onclick=r.close,i.onclick=()=>J(i,"Unblocking\u2026",async()=>{r.clearError();try{await C(`/api/suppressions/${encodeURIComponent(e.email)}`,{method:"DELETE"}),r.close(),O(`Unblocked ${e.email}`),t()}catch(a){r.error(K(a))}})}function Jr(e,t,n){let s=me(o`<h3>Erase this subscriber?</h3><p class="hint">Deletes <strong>${e.email}</strong> and how they joined, and past sends show an erased recipient in its place. The address is kept only so it’s never mailed again. This can’t be undone. They can still rejoin by subscribing and confirming themselves.</p><div class="actions"><button type="button" id="eCancel">Cancel</button><button type="button" class="danger" id="eGo">Erase</button></div>`,n),r=d("#eGo",s.el);d("#eCancel",s.el).onclick=s.close,r.onclick=()=>J(r,"Erasing\u2026",async()=>{s.clearError();try{let i=await C(`/api/subscribers/${e.id}/erase`,{method:"POST"});s.close(),O(id(i)),t()}catch(i){s.error(K(i))}})}var rd={bounce:"bounce",import_bounce:"bounce",complaint:"complaint",import_complaint:"complaint",manual:"block"};function id({erased:e}){return e.suppression==="erased"?`Erased ${e.email}`:`Erased ${e.email}. Its ${rd[e.suppression]??"suppression"} still stands`}var ad=1500;function qe(e=!1,t="Copy"){return o`<span>${t}</span>${B(e?"check":"copyout")}`}function Zt(e,t){return o`<button type="button" class="copy-btn" data-copy="${e}" aria-label="${t}">${qe()}</button>`}function _t(e,t,n){return o`<div class="block"><div class="block-head"><span class="block-label">${e}</span>${Zt(n,`Copy ${e}`)}</div><pre><code>${t}</code></pre></div>`}var Ds=new WeakMap;async function Bs(e,t){try{await navigator.clipboard.writeText(t)}catch{O("Couldn\u2019t copy to clipboard");return}let n=Ds.get(e),s=n?.label??e.getAttribute("aria-label")??"Copy",r=e.dataset.copyWord;window.clearTimeout(n?.timer),h(e,qe(!0,r)),e.setAttribute("aria-label","Copied"),e.classList.add("copied");let i=window.setTimeout(()=>{h(e,qe(!1,r)),e.setAttribute("aria-label",s),e.classList.remove("copied"),Ds.delete(e)},ad);Ds.set(e,{label:s,timer:i})}function ze(e){e.addEventListener("click",t=>{let n=t.target instanceof Element?t.target.closest("[data-copy]"):null;n&&e.contains(n)&&Bs(n,n.dataset.copy??"")})}function Qr(e){let t=[],n=e.filter($=>Re($,"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 R=ve($,"wedged");return R?[{s:$,count:R.count}]:[]}),[r]=s;if(r){let $=s.reduce((N,g)=>N+g.count,0),R=$===1?"delivery":"deliveries";t.push({level:"red",text:s.length===1?o`<a href="#/sent/${r.s.id}">${r.s.subject}</a> has ${$} ambiguous ${R} awaiting a decision; resolve ${$===1?"it":"them"} on its page.`:o`${$} ambiguous ${R} across <a href="#/sent">${s.length} sends</a> await a decision; resolve them on the Sent page.`})}let i=pe(),a=e.flatMap($=>{let R=ve($,"refused");return R?[{s:$,c:R}]:[]}),l=i?a.filter($=>$.c.cause==="not_connected"):[],c=a.filter($=>!l.includes($)),u=$=>$.length===1&&$[0]?o`<a href="#/sent/${$[0].s.id}">${$[0].s.subject}</a>`:o`<a href="#/sent">${$.length} sends</a>`;l.length&&t.push({level:"red",text:o`<span class="dash-not-connected">${xn(u(l),l.length)}</span>`});let[m]=c;m&&t.push({level:"red",text:o`${u(c)}: ${m.c.message}`});let p=e.filter($=>Re($,"stuck")&&!Gt($)),[v]=p;v&&t.push({level:"amber",text:p.length===1?o`<a href="#/sent/${v.id}">${v.subject}</a>: ${ve(v,"stuck")?.message}`:o`<a href="#/sent">${p.length} sends</a> have been sending longer than they should; they may be retrying.`});let E=e.flatMap($=>{let R=ve($,"bounce_spike");return R?[{s:$,c:R}]:[]}),[L]=E;return L&&t.push({level:"amber",text:o`<a href="#/sent/${L.s.id}">${L.s.subject}</a>: ${L.c.message}`}),i&&!l.length&&t.push({level:"amber",text:o`<span class="dash-not-connected">No email provider connected, so nothing can be sent yet. ${_e()} to start sending.</span>`}),t}function Xr(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 ei=e=>e.filter(t=>t.status==="sending"&&!Gt(t));async function Os(e,t){let n=()=>{t.aborted||Q(Os)};h(e,o`<div class="dash" id="dash"><p class="muted">Loading…</p></div>`);let s=d("#dash",e),r,i,a;try{let[S,P,y]=await Promise.all([C("/api/posts?limit=200",{signal:t}),C("/api/sends?limit=200",{signal:t}),C("/api/subscribers",{signal:t})]);r=S.posts,i=P,a=y.counts}catch(S){oe(s,K(S),n);return}let l=i.sends,c=new Map,u=Ss(X.appConfig),m=X.appConfig?.deployment??null;if(!r.length){h(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>${dd(u,m,a)}<section class="dash-section"><h2>API access</h2>${di(!1)}</section>`),li(s,n,t);return}let p=[{label:"Confirmed",emph:!0,v:a.confirmed,filter:"confirmed"},{label:"Pending",v:a.pending,filter:"pending"},{label:"Unsubscribed",v:a.unsubscribed,filter:"unsubscribed"},{label:"Suppressed",v:a.suppressed,filter:"suppressed"}],v=o`<div class="tiles">${p.map(S=>o`<a class="tile${S.emph?" tile-emph":""}" href="#/subscribers/${S.filter}"><span class="tile-n">${S.v}</span><span class="tile-label">${S.label}${S.sub?o`<span class="tile-sub">${S.sub}</span>`:null}</span></a>`)}</div>`,E=r.filter(S=>S.status==="draft").slice(0,5),L=E.length?o`<div class="table-wrap"><table><tbody>${E.map(S=>o`<tr class="clickable" data-id="${S.id}"><td><a href="#/edit/${S.id}">${S.subject||o`<em>untitled</em>`}</a></td><td class="muted">edited ${Y(S.updated_at)}</td></tr>`)}</tbody></table></div>`:o`<p class="muted">No drafts in progress.</p>`,$=m?.appOrigin||location.origin,R=o`<div class="card pub-card">
|
|
73
|
+
<div class="pub-row"><code class="pub-val">${$}</code>${Zt($,"Copy the publication\u2019s address")}</div>
|
|
61
74
|
<div class="pub-foot"><a href="/" target="_blank" rel="noopener">View publication ↗</a></div>
|
|
62
|
-
</div>`,
|
|
75
|
+
</div>`,N=r.some(S=>S.author==="service"),g=di(N),M=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>`;h(s,o`
|
|
63
76
|
<div class="dash-head">
|
|
64
77
|
<div><h1>${u.name}</h1>${u.tagline?o`<p class="muted dash-tagline">${u.tagline}</p>`:null}</div>
|
|
65
78
|
<button class="primary" data-act="new-post">New post</button>
|
|
66
79
|
</div>
|
|
67
|
-
<div id="dashHealth">${
|
|
68
|
-
<div id="dashActive">${
|
|
69
|
-
<section class="dash-section"><h2>Subscribers</h2>${
|
|
80
|
+
<div id="dashHealth">${Xr(Qr(Tt(l,c)))}</div>
|
|
81
|
+
<div id="dashActive">${ii(ei(Tt(l,c)))}</div>
|
|
82
|
+
<section class="dash-section"><h2>Subscribers</h2>${v}</section>
|
|
70
83
|
<div class="dash-cols">
|
|
71
|
-
<section class="dash-section"><h2>Scheduled</h2><div id="dashScheduled">${
|
|
72
|
-
<section class="dash-section"><h2>Drafts</h2>${
|
|
84
|
+
<section class="dash-section"><h2>Scheduled</h2><div id="dashScheduled">${oi(ti(l))}</div></section>
|
|
85
|
+
<section class="dash-section"><h2>Drafts</h2>${L}</section>
|
|
73
86
|
</div>
|
|
74
|
-
<section class="dash-section"><h2>Sent</h2><div id="dashSent">${
|
|
75
|
-
<section class="dash-section"><h2>Quick actions</h2>${
|
|
87
|
+
<section class="dash-section"><h2>Sent</h2><div id="dashSent">${ni(l)}</div></section>
|
|
88
|
+
<section class="dash-section"><h2>Quick actions</h2>${M}</section>
|
|
76
89
|
<div class="dash-cols">
|
|
77
|
-
<section class="dash-section"><h2>Publication</h2>${
|
|
78
|
-
<section class="dash-section"><h2>API access</h2>${
|
|
79
|
-
</div>`),
|
|
90
|
+
<section class="dash-section"><h2>Publication</h2>${R}</section>
|
|
91
|
+
<section class="dash-section"><h2>API access</h2>${g}</section>
|
|
92
|
+
</div>`),li(s,n,t);for(let S of H("tr[data-id]",s))S.onclick=P=>{let y=P.target;y instanceof Element&&y.tagName!=="A"&&!y.closest("button")&&(location.hash=`#/edit/${S.dataset.id}`)};si(s),ai(s),ri(s);let f=Un(s,t);f();let _=0,k=async()=>{let S=++_;try{let P=await C("/api/sends?limit=200",{signal:t});if(S!==_)return;l=P.sends,h(d("#dashScheduled",s),oi(ti(l))),ri(s),f(),h(d("#dashSent",s),ni(l)),si(s),T()}catch{}},T=()=>{let S=Tt(l,c);h(d("#dashHealth",s),Xr(Qr(S))),h(d("#dashActive",s),ii(ei(S))),ai(s),ld(s,[...c.values()])},I=S=>S==="scheduled"||S==="due";yt(i,{update:S=>{for(let P of S.sends)c.set(P.id,P);for(let P of S.removed)c.delete(P);T(),(S.changes.length||S.removed.length||S.sends.some(P=>I(Ve(P))))&&k()},stale:n},t)}var ti=e=>e.filter(t=>t.status==="scheduled").sort((t,n)=>t.fire_at-n.fire_at);function ni(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>${Ye(n.status)}</td><td class="num">${n.audience.count.toLocaleString()}</td><td class="num delivered">${Lt(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 si(e){for(let t of H("tr[data-send]",e))t.onclick=n=>{let s=n.target;s instanceof Element&&s.tagName!=="A"&&(location.hash=`#/sent/${t.dataset.send}`)}}function ld(e,t){for(let n of t){let s=H("#dashSent tr[data-send]",e).find(r=>r.dataset.send===n.id)?.querySelector("td.delivered");s&&h(s,Lt(n.counts))}}function oi(e){if(!e.length)return o`<p class="muted">Nothing scheduled.</p>`;let t=kn("card");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">${On(n)} · ${Y(n.fire_at)} · ${n.audience.count} recipients</div>${t}${Re(n,"remade")?o`<div class="sched-remade-mark">Look changed since your last test</div>`:null}</div></div>`)}`}function ri(e){for(let t of H("#dashScheduled .nextup",e))t.onclick=n=>{let s=n.target;s instanceof Element&&s.tagName!=="A"&&(location.hash=`#/edit/${t.dataset.post}`)}}function ii(e){return e.length?o`<section class="dash-section"><h2>Active send${e.length===1?"":"s"}</h2>${e.map(Fn)}</section>`:o``}function ai(e){for(let t of H("#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 li(e,t,n){for(let s of H("[data-nav]",e))s.onclick=()=>{location.hash=s.dataset.nav??""};for(let s of H("[data-act='new-post']",e))s.onclick=()=>Ps(s);for(let s of H("[data-act='add-sub']",e))s.onclick=()=>Wn(t,n);ze(e)}function di(e){return e?o`<div class="card pub-card">
|
|
80
93
|
<p class="conn-status"><span class="conn-dot" aria-hidden="true"></span>Claude is connected.</p>
|
|
81
|
-
<div class="pub-foot pub-links"><a href="#/reference">${
|
|
94
|
+
<div class="pub-foot pub-links"><a href="#/reference">${B("code")}API reference →</a><a href="#/docs/connect-claude">${B("article")}Connection guide →</a></div>
|
|
82
95
|
</div>`:o`<div class="card pub-card">
|
|
83
96
|
<p class="pub-note">Let Claude draft, proofread, and schedule your posts.</p>
|
|
84
|
-
<div class="pub-foot pub-links"><a href="#/docs/connect-claude">${
|
|
85
|
-
</div>`}function
|
|
97
|
+
<div class="pub-foot pub-links"><a href="#/docs/connect-claude">${B("article")}Connect Claude →</a><a href="#/reference">${B("code")}API reference →</a></div>
|
|
98
|
+
</div>`}function dd(e,t,n){let s=`${t?.appOrigin||location.origin}/subscribe`,r=pe(),i=n.confirmed+n.pending;return o`<div class="card setup">
|
|
86
99
|
<h2 class="setup-title">Set up your publication</h2>
|
|
87
100
|
<ol class="setup-steps">
|
|
88
101
|
<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>
|
|
89
|
-
${
|
|
102
|
+
${r?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="${xs}">Connect Amazon SES</button></li>`:o`<li class="setup-check"><div class="setup-step-main"><strong>Check your setup</strong><span class="muted">Send yourself a test, and see how Kestrel tells you when something needs attention.</span></div><button data-nav="#/docs/check-your-setup">Docs</button></li>`}
|
|
103
|
+
${i?o`<li class="setup-done setup-list"><div class="setup-step-main"><strong>Bring your existing list</strong><span class="muted">${i.toLocaleString()} ${i===1?"subscriber":"subscribers"} on your list.</span></div><button data-nav="#/subscribers">Subscribers</button></li>`:o`<li class="setup-list"><div class="setup-step-main"><strong>Bring your existing list</strong><span class="muted">Import the subscribers you have on another service, with the consent they gave there. Skip this if you’re starting fresh.</span></div><button data-nav="#/subscribers/import">Import</button></li>`}
|
|
90
104
|
<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>
|
|
91
|
-
${
|
|
105
|
+
${r?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">${s}</code></div><button type="button" data-copy="${s}" aria-label="Copy the subscribe link">${qe()}</button></li>`}
|
|
92
106
|
</ol>
|
|
93
|
-
</div>`}var
|
|
107
|
+
</div>`}var Us=K;function Vn(e,t,n){let s=e.c_in_flight||0,i=me(o`<h3>Resolve ${s} ambiguous ${s===1?"delivery":"deliveries"}</h3>
|
|
94
108
|
<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
109
|
<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
110
|
<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
111
|
<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);
|
|
99
|
-
<p class="hint">Move when it sends
|
|
100
|
-
${
|
|
101
|
-
<label for="rsWhen">Send at</label><input type="datetime-local" id="rsWhen" min="${
|
|
102
|
-
<div class="actions"><button type="button" id="rsCancel">Cancel</button><button type="button" class="primary" id="rsGo">Reschedule</button></div>`,s),
|
|
103
|
-
`).map(n=>{let s,
|
|
104
|
-
<div class="web-bar" role="note"><div class="web-bar-in">${
|
|
105
|
-
<iframe class="web-frame" sandbox="${
|
|
106
|
-
</div>`);let
|
|
112
|
+
<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);d("#rCancel",i.el).onclick=i.close;let a=(u,m,p)=>J(u,"Resolving\u2026",async()=>{i.clearError();try{let v=await C(`/api/sends/${e.id}/resolve`,{method:"POST",json:{resolution:m}});i.close(),O(v.completed?"Send completed":`Marked ${p}`),t()}catch(v){i.error(Us(v))}}),l=d("#rUnsent",i.el),c=d("#rAccepted",i.el);l.onclick=()=>a(l,"unsent","not sent"),c.onclick=()=>a(c,"accepted","sent")}function Kn(e,t,n,s){let r=wt($n()),i=wt(new Date(t)),a=me(o`<h3 id="rsHead">Reschedule this post</h3>
|
|
113
|
+
<p class="hint">Move when it sends. The content stays frozen and the cancelable window is kept. Only the time changes.</p>
|
|
114
|
+
${Zo()}
|
|
115
|
+
<label for="rsWhen">Send at</label><input type="datetime-local" id="rsWhen" min="${r}" value="${i}">
|
|
116
|
+
<div class="actions"><button type="button" id="rsCancel">Cancel</button><button type="button" class="primary" id="rsGo">Reschedule</button></div>`,s),l=d(".modal",a.el);l.setAttribute("aria-labelledby","rsHead");let c=d("#rsGo",l),u=d("#rsWhen",l);d("#rsCancel",l).onclick=a.close,c.onclick=()=>J(c,"Rescheduling\u2026",async()=>{a.clearError();let m=u.value,p=m?new Date(m).getTime():Number.NaN;if(Number.isNaN(p)){a.error("Pick a valid date & time",u);return}if(Sn(p)){a.error(wn(),u);return}try{await C(`/api/sends/${e}/reschedule`,{method:"POST",json:{fire_at:new Date(p).toISOString()}}),a.close(),O("Rescheduled"),n()}catch(v){if(v instanceof ee&&v.status===404){a.close(),O("This post was deleted elsewhere."),ot();return}if(v instanceof ee&&v.field==="fire_at"){a.error(Us(v),u);return}a.error(Us(v))}}),u.focus()}function ci(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 ui=e=>e.preventDefault(),Rt="allow-same-origin allow-popups allow-popups-to-escape-sandbox";function Ct(e){for(let t of e.querySelectorAll("a[href], area[href]")){if(t.getAttribute("href")?.startsWith("#")){t.addEventListener("click",ui),t.addEventListener("auxclick",ui);continue}t.target="_blank",t.rel="noopener noreferrer"}}function cd(e,t){let n=/<head(\s[^>]*)?>/i;return n.test(e)?e.replace(n,s=>s+t):t+e}function Gn(e){return cd(e,`<meta http-equiv="Content-Security-Policy" content="${ci("'none'",{framed:!0})}">`)}function pi(e){return e.replace(/\{\{\s*(?:if\s+)?[\w.]+\s*\}\}/g,t=>`<span class="cx-var">${t}</span>`)}function ud(e){let t=ie(e);return t=t.replace(/"[^&]*?"/g,n=>`<span class="cx-str">${n}</span>`),t=t.replace(/(<\/?)([a-zA-Z][\w-]*)/g,(n,s,r)=>`<span class="cx-punct">${s}</span><span class="cx-tag">${r}</span>`),t=t.replace(/(\/?)>/g,(n,s)=>`<span class="cx-punct">${s}></span>`),pi(t)}function pd(e){let t=ie(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,r,i)=>`${s}<span class="cx-sel">${r}</span>${i}{`),t=t.replace(/^(\s*)([a-z-]+)(\s*:)/gm,(n,s,r,i)=>`${s}<span class="cx-prop">${r}</span>${i}`),pi(t)}function mi(e){return W(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>${pd(n[1]??"")}<span class="cx-punct"></</span><span class="cx-tag">style</span><span class="cx-punct">></span>`:ud(t)}).join(""))}function Fs(e){return ie(e).replace(/(`[^`\n]+`)|(!?)(\[[^\]\n]*\])\(([^)\n]*)\)|(\*\*|__)([^\n]+?)\5|\*([^*\n]+?)\*|\b_([^_\n]+?)_\b/g,(t,n,s,r,i,a,l,c,u)=>{if(n)return`<span class="cx-md-code">${n}</span>`;if(r!==void 0)return`${s||""}${r}(<span class="cx-md-url">${i}</span>)`;if(a)return`<span class="cx-md-mark">${a}</span><span class="cx-md-strong">${l}</span><span class="cx-md-mark">${a}</span>`;let[m,p]=c!==void 0?["*",c]:["_",u];return`<span class="cx-md-mark">${m}</span><span class="cx-md-em">${p}</span><span class="cx-md-mark">${m}</span>`})}function md(e){let t=e.match(/^(\s{0,3}#{1,6})(\s.*)?$/);if(t)return`<span class="cx-md-hmark">${ie(t[1]??"")}</span><span class="cx-md-htext">${ie(t[2]||"")}</span>`;if(/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/.test(e))return`<span class="cx-md-bmark">${ie(e)}</span>`;let n=e.match(/^(\s{0,3})((?:>\s?)+)(.*)$/);if(n)return`${ie(n[1]??"")}<span class="cx-md-bmark">${ie(n[2]??"")}</span>${Fs(n[3]??"")}`;let s=e.match(/^(\s*)([-*+]|\d{1,9}[.)])(\s+)(.*)$/);return s?`${s[1]}<span class="cx-md-bmark">${ie(s[2]??"")}</span>${s[3]}${Fs(s[4]??"")}`:Fs(e)}function fi(e){let t=!1;return W(String(e).split(`
|
|
117
|
+
`).map(n=>{let s,r=!1,i=n.match(/^(\s*)(```+|~~~+)(.*)$/);if(i){t=!t;let a=i[3]?`<span class="cx-md-bmark">${ie(i[3])}</span>`:"";s=`${i[1]}${ie(i[2]??"")}${a}`}else t?(r=!0,s=ie(n)):s=md(n);return`<span class="cx-md-ln${r?" cx-md-band":""}">${s}</span>`}).join(""))}var fd=/("(?:\\.|[^"\\])*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false|null)\b|([{}[\],])/g;function hi(e){let t=JSON.stringify(e,null,2)??"",n="",s=0;for(let r of t.matchAll(fd)){n+=ie(t.slice(s,r.index));let[i,a,l,c,u]=r;a!==void 0?n+=l?`<span class="cx-prop">${ie(a)}</span><span class="cx-punct">${ie(l)}</span>`:`<span class="cx-str">${ie(a)}</span>`:c!==void 0?n+=`<span class="cx-num">${c}</span>`:u!==void 0?n+=`<span class="cx-tag">${u}</span>`:n+=`<span class="cx-punct">${ie(i)}</span>`,s=r.index+i.length}return W(n+ie(t.slice(s)))}function bi(e){return W(ie(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 hd={idleMs:5e3,maxMs:3e4};function gi(e,t=hd){let n=null,s=null,r=()=>{n!==null&&clearTimeout(n),s!==null&&clearTimeout(s),n=null,s=null},i=()=>{r(),e()};return{touch(){n!==null&&clearTimeout(n),n=setTimeout(i,t.idleMs),s===null&&(s=setTimeout(i,t.maxMs))},cancel:r,get pending(){return n!==null||s!==null}}}var Yn=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 vi=Promise.resolve();function qs(e){vi=e}function yi(){return vi.then(()=>{},()=>{})}function wi(e){if(!(e instanceof ee)||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 zn=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 $i(e){return`#/web/post/${encodeURIComponent(e)}`}function xi(e,t){if(e!=="post"||!t)return null;try{return decodeURIComponent(t)}catch{return null}}function bd(e){return e.replace(/^https?:\/\//,"")}function gd(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 ${bd(e.url)}`:null;return o`${t} <span class="web-bar-text">${n}${s}</span>`}function Si(e){try{return new URL(e).href}catch{return null}}function Ei(e,t,n){let s=t?Si(t):null;if(s)for(let r of e.querySelectorAll("a[href], area[href]")){let i=r.getAttribute("href");i&&Si(i)===s&&r.setAttribute("href",n)}}function vd(e){location.replace(e)}async function yd(e,t){try{return await C(`/api/posts/${encodeURIComponent(e)}`,{signal:t})}catch{return null}}async function js(e,t,n){let s=document.title;document.title="Web version",tt(n,()=>{document.title=s});try{await yi();let[r,i]=await Promise.all([ht(`/api/posts/${encodeURIComponent(e)}/web`,{signal:n}),yd(e,n)]);if(n.aborted)return;let a=i?_n(i.post.slug):null;if(i&&(i.sending||i.post.status==="sent")&&a){vd(a);return}let l=gd(i?{scheduled:i.scheduled!=null,url:a}:null);h(t,o`<div class="web-page">
|
|
118
|
+
<div class="web-bar" role="note"><div class="web-bar-in">${l}</div></div>
|
|
119
|
+
<iframe class="web-frame" sandbox="${Rt}" title="Web version" scrolling="no"></iframe>
|
|
120
|
+
</div>`);let c=t.querySelector(".web-frame");if(!c)return;c.onload=()=>{let u=c.contentDocument;if(!u||n.aborted)return;Ct(u),u.title&&(document.title=`Web version \xB7 ${u.title}`);let m=()=>{let p=u.body?.scrollHeight;p&&(c.style.height=`${p}px`)};if(m(),u.body&&typeof ResizeObserver<"u"){let p=new ResizeObserver(m);p.observe(u.body),tt(n,()=>p.disconnect())}},c.srcdoc=Gn(r)}catch(r){if(n.aborted)return;h(t,o`<div class="web-error"></div>`);let i=t.querySelector(".web-error");if(i){let l=r instanceof ee&&r.status===404?"There\u2019s no post here. It may have been deleted.":K(r);oe(i,l,()=>Q((c,u)=>js(e,c,u)))}}}var je=K,wd=[[["heading","Heading"],["bold","Bold (\u2318B)"],["italic","Italic (\u2318I)"]],[["quote","Quote"],["code","Code"],["link","Link (\u2318K)"]],[["ul","Bulleted list"],["ol","Numbered list"],["indent","Indent"]]],Sd=o` readonly`,$d=o` aria-disabled="true"`;async function Ws(e,t,n){let s=()=>{n.aborted||Q((b,D)=>Ws(e,b,D))},r=!1,i=(b="This post was deleted elsewhere.")=>{r||(r=!0,O(b),n.aborted||Zn("#/drafts"))};h(t,o`<p class="muted">Loading…</p>`);let a,l=null;try{if(a=await C(`/api/posts/${e}`,{signal:n}),n.aborted)return;let b=Ed(a);if(b){Zn(b);return}}catch(b){if(Me(b)){i("There\u2019s no post here. It may have been deleted.");return}oe(t,je(b),s);return}if(a.scheduled){try{l=await C(`/api/sends/${a.scheduled.id}`,{signal:n})}catch(b){if(n.aborted)return;if(Me(b)){i();return}}if(n.aborted||l&&ki(l.send,s))return}let{post:c,markdown:u,scheduled:m}=a,p=c.status!=="draft",v=new zn(c.current_revision),E=wd.map((b,D)=>o`${D?o`<span class="sep"></span>`:null}${b.map(([x,G])=>o`<button type="button" class="tb" data-fmt="${x}" title="${G}" aria-label="${G}"${p?$d:null}>${B(x)}</button>`)}`),L=p?Sd:null,$=`${location.origin}${location.pathname}${$i(e)}`;h(t,o`
|
|
107
121
|
<div class="editor-head">
|
|
108
122
|
<a href="#/drafts" class="back">← Drafts</a>
|
|
109
123
|
<div class="editor-head-right">
|
|
110
124
|
<a class="ghost btn" id="webLink" href="${$}" target="_blank" rel="noopener" aria-label="Web version (opens in a new tab)">Web version ↗</a>
|
|
111
125
|
</div>
|
|
112
126
|
</div>
|
|
113
|
-
${m
|
|
127
|
+
${p&&m?o`<div class="banner banner-scheduled"><div class="sched-main"><span id="schedWhen"></span>${kn("banner")}<div class="sched-remade" id="schedRemade" hidden><span id="schedRemadeMsg"></span></div><div class="sched-note" id="schedDailyWrites" hidden><span class="sched-note-mark" aria-hidden="true">⚠️</span><span class="sched-note-msg" 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}
|
|
114
128
|
<div id="freshnessBanner" class="banner banner-conflict" role="alert" hidden></div>
|
|
115
129
|
<div class="card">
|
|
116
130
|
<div class="grid2">
|
|
117
|
-
<div><label for="f-subject">Subject</label><input id="f-subject" value="${
|
|
131
|
+
<div><label for="f-subject">Subject</label><input id="f-subject" value="${c.subject}" placeholder="Untitled"${L}></div>
|
|
118
132
|
<div>
|
|
119
133
|
<div class="label-row">
|
|
120
134
|
<label for="f-slug">Slug</label>
|
|
121
|
-
${
|
|
135
|
+
${jr("The web address of this post\u2019s archive page.")}
|
|
122
136
|
</div>
|
|
123
|
-
<input id="f-slug" value="${
|
|
124
|
-
${
|
|
137
|
+
<input id="f-slug" value="${c.slug}"${L}>
|
|
138
|
+
${p?null:o`<label class="slug-auto-toggle"><input type="checkbox" id="f-slug-auto">Auto-generate from subject</label>`}
|
|
125
139
|
</div>
|
|
126
140
|
</div>
|
|
127
141
|
|
|
@@ -129,117 +143,117 @@
|
|
|
129
143
|
<div class="composer">
|
|
130
144
|
<div class="composer-head">
|
|
131
145
|
<div class="ctabs" role="tablist">
|
|
132
|
-
<button type="button" class="ctab active" data-tab="edit" data-text="Edit" role="tab" aria-selected="true"><span class="ctab-label">${
|
|
133
|
-
<button type="button" class="ctab" data-tab="preview" data-text="Preview" role="tab" aria-selected="false"><span class="ctab-label">${
|
|
146
|
+
<button type="button" class="ctab active" data-tab="edit" data-text="Edit" role="tab" aria-selected="true"><span class="ctab-label">${B("editable")}Edit</span></button>
|
|
147
|
+
<button type="button" class="ctab" data-tab="preview" data-text="Preview" role="tab" aria-selected="false"><span class="ctab-label">${B("preview")}Preview</span></button>
|
|
134
148
|
</div>
|
|
135
|
-
<div class="toolbar" role="toolbar" aria-label="Formatting">${
|
|
149
|
+
<div class="toolbar" role="toolbar" aria-label="Formatting">${E}</div>
|
|
136
150
|
</div>
|
|
137
|
-
<div class="composer-body${
|
|
151
|
+
<div class="composer-body${p?" locked":""}" id="composerBody">
|
|
138
152
|
<pre class="md-hl" id="mdHl" aria-hidden="true"><code></code></pre>
|
|
139
|
-
<textarea id="f-markdown" class="editor"${
|
|
140
|
-
<iframe id="previewFrame" class="preview" sandbox="${
|
|
153
|
+
<textarea id="f-markdown" class="editor"${p?null:o` placeholder="Type your post in Markdown…"`}${L}>${u}</textarea>
|
|
154
|
+
<iframe id="previewFrame" class="preview" sandbox="${Rt}" title="Email preview" hidden></iframe>
|
|
141
155
|
</div>
|
|
142
|
-
${
|
|
156
|
+
${p?o`<div class="composer-foot composer-foot-lock" id="lockFoot" role="status">${B("readonly")}<span>Cancel the schedule to edit</span></div>`:o`<div class="composer-foot" id="dropFoot">${B("paperclip")}<span>Paste, drop, or click to add images</span></div>`}
|
|
143
157
|
<input type="file" id="imgInput" accept="image/png,image/jpeg,image/webp,image/gif" multiple hidden>
|
|
144
158
|
</div>
|
|
145
159
|
<div id="warnings"></div>
|
|
146
|
-
${
|
|
160
|
+
${p?null:En("editor-not-connected",o`No email provider connected: you can write and preview, not send. ${_e()}.`)}
|
|
147
161
|
|
|
148
162
|
<div class="actions-bar">
|
|
149
|
-
${
|
|
163
|
+
${p?o`<div class="row"><button type="button" class="secondary" id="testBtn">${B("send")}<span>Send test email</span></button></div>`:o`<div class="row">
|
|
150
164
|
<button type="button" class="ghost" id="saveBtn">Save draft</button>
|
|
151
165
|
<span class="save-status" id="saveStatus" aria-live="polite"></span>
|
|
152
166
|
</div>
|
|
153
167
|
<div class="row">
|
|
154
|
-
<button type="button" class="secondary" id="testBtn">${
|
|
168
|
+
<button type="button" class="secondary" id="testBtn">${B("send")}<span>Send test email</span></button>
|
|
155
169
|
<button type="button" class="primary" id="scheduleBtn">Schedule</button>
|
|
156
170
|
</div>`}
|
|
157
171
|
</div>
|
|
158
|
-
</div>`);let
|
|
159
|
-
`,
|
|
160
|
-
`).map(
|
|
161
|
-
`);
|
|
162
|
-
`)?
|
|
163
|
-
![${
|
|
164
|
-
`;
|
|
165
|
-
`),
|
|
166
|
-
`),w.error(En(Ee,D.length,ve,[...lt]));return}w.close(),I(pt(`Test sent to ${Ee} address${Ee===1?"":"es"}`))}catch(Ee){if(Ae(Ee)){w.close(),At(Ee);return}w.error(Ue(Ee))}})};let eo=m?null:c("#scheduleBtn");return eo&&(eo.onclick=()=>{if(!ce())return;let f=gt(dn()),R=gt(new Date(Date.now()+24*3600*1e3)),w=ue(),U=o`<h3 id="schHead">Schedule this post</h3><p class="hint">It sends at the time you pick (at least ${Xe()} out), with a cancelable window until then.</p>${bo()}${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">Need it sooner? The soonest is ${Xe()} out, still cancelable.</span><button type="button" class="linkbtn" id="toSendNow">Send now →</button></div>`}`,_=be(U,n),D=c(".modal",_.el),de=()=>{_.close(),Ne.scrollIntoView({block:"nearest"})},he=()=>te(c("#schGo",D),"Scheduling\u2026",async()=>{_.clearError();let pe=c("#schWhen",D),ke=pe.value?new Date(pe.value).getTime():Number.NaN;if(Number.isNaN(ke)){_.error("Pick a valid date & time",pe);return}if(cn(ke)){_.error(ut(!0),pe);return}try{if(await Me(!0)==="refused"){de();return}await L(`/api/posts/${e}/schedule`,{method:"POST",json:{fire_at:new Date(ke).toISOString()}}),_.close(),I(pt(`Scheduled for ${Y(ke)}. Send yourself a test.`)),s()}catch(V){if(Ae(V)){_.close(),At(V);return}if(V instanceof z&&V.field==="fire_at"){_.error(V.code==="fire_at_too_soon"?ut(!0):Ue(V),c("#schWhen",D));return}_.error(Ue(V))}}),Ee=()=>te(c("#snGo",D),"Queuing\u2026",async()=>{_.clearError();try{if(await Me(!0)==="refused"){de();return}let{send:pe}=await L(`/api/posts/${e}/send`,{method:"POST"});_.close(),I(pt(`Sends at ${Y(pe.fire_at)}, cancelable until then.`)),s()}catch(pe){if(Ae(pe)){_.close(),At(pe);return}_.error(Ue(pe))}});function ve(){if(D.setAttribute("aria-labelledby","schHead"),c("#schCancel",D).onclick=_.close,c("#schGo",D).onclick=he,c("#schGo",D).disabled=w,w){c("#schCancel",D).focus();return}c("#toSendNow",D).onclick=lt,c("#schWhen",D).focus()}async function lt(){v(D,o`<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 ${Xe()}, 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"),c("#snCancel",D).onclick=_.close,c("#snGo",D).onclick=Ee,c("#snGo",D).disabled=ue(),c("#toSchedule",D).onclick=()=>{v(D,U),ve()},c("#snGo",D).focus();try{let ke=(await L("/api/subscribers?status=confirmed&suppressed=hide&limit=1",{signal:n})).page.total,V=D.querySelector("#snWho");V&&(V.textContent=`${ke} confirmed subscriber${ke===1?"":"s"}`)}catch{}}ve()}),Pr}function qi(e,t){return e.status==="sending"||e.status==="sent"?(Bn(`#/sent/${e.id}`),!0):e.status==="canceled"?(t(),!0):!1}function ul(e,t){return He(e,"missed")?o`Scheduled for <strong>${Y(e.fire_at)}</strong> · <span class="countdown countdown-missed">${kn(e.fire_at)}</span>`:t?o`Scheduled for <strong>${Y(e.fire_at)}</strong> · <span class="countdown">${qt(e.fire_at,!0)}</span>`:o`Scheduled for <strong>${Y(e.fire_at)}</strong> · <span class="countdown">${qt(e.fire_at)}</span> · cancelable until then.`}function Ae(e){return e instanceof z&&e.status===404}function pl(e){return e.sending?`#/sent/${e.sending.id}`:e.post.status==="sent"?e.sent?`#/sent/${e.sent.id}`:"#/sent":null}function Bn(e){location.replace(e)}function ml(e,t){document.addEventListener("visibilitychange",()=>{document.hidden||e()},{signal:t})}var Cs=null;function fl(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 Rs(e,t,n,s){if(v(t,mt("docs",null,o`<div class="docs-index"><p class="muted">Loading…</p></div>`)),!Cs)try{Cs=await L("/api/docs",{signal:n})}catch(l){n.aborted||ie(c(".room-main",t),J(l),()=>Z((a,d)=>Rs(e,a,d,s)));return}let{landing:i,docs:r}=Cs;if(!r.length){v(c(".room-main"),o`<p class="muted">No documentation.</p>`);return}e?bl(t,i,r,e,s?hl(s):void 0):ji(t,i,r)}function hl(e){try{return decodeURIComponent(e)}catch{return}}function ji(e,t,n){let s=(u,p)=>{let m=fl(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 Ms=matchMedia("(max-width: 720px)");function Vi(e){if(!e)return;let t=Ms.matches;e.open=!t,c("summary",e).tabIndex=t?0:-1}var Wi=()=>document.querySelector("details#tocOnPage"),Ui=!1;function vl(){Ui||(Ui=!0,Ms.addEventListener("change",()=>Vi(Wi())))}function gl(e){return e.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu,"").replace(/\s/g,"-")}var Fi=e=>`doc-h-${e}`;function bl(e,t,n,s,i){let r=n.findIndex(b=>b.slug===s),l=n[r];if(!l){I(`No doc named \u201C${s}\u201D, so here\u2019s the index.`),history.replaceState(history.state,"","#/docs"),ji(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=gl(b.textContent||""),S=x.get(g)??0;x.set(g,S+1),b.dataset.anchor=S?`${g}-${S}`:g,b.id=Fi(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 j=Wi();Vi(j),vl();let G=b=>{if(j)for(let g of A(".toc-sub",j))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(),G(S),j&&Ms.matches&&(j.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{I("Couldn\u2019t copy to clipboard")}}),b.appendChild(g)}let C=h.map(b=>b.id),B=C[0];if(j&&B){let b=C[C.length-1]??B,S=(document.querySelector(".room-bar")?.getBoundingClientRect().height||54)+26,q=()=>{if(!document.getElementById(B)){document.onscroll=null;return}let X=B;if(Math.ceil(window.innerHeight+window.scrollY)>=document.documentElement.scrollHeight)X=b;else for(let ce of C)if((document.getElementById(ce)?.getBoundingClientRect().top??Number.POSITIVE_INFINITY)<=S)X=ce;else break;G(X)};document.onscroll=q,q()}let N=i?document.getElementById(Fi(i)):null;N?N.scrollIntoView({block:"start"}):window.scrollTo(0,0)}function yl(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 Dn(e){return e.replace(/\([^)]*\)/g,"").replace(/\{[^}]*\}\??/g,"")||"/"}function Ki(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}${Dn(e.path)}${i}"`,l=[e.method==="GET"?`curl ${r}`:`curl -X ${e.method} ${r}`];if(e.access==="admin"&&l.push(...yl(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
|
|
172
|
+
</div>`);let R=d("#f-subject"),N=d("#f-slug"),g=d("#f-markdown"),M=d(".toolbar",t),f=d("#previewFrame"),_=d("#mdHl"),k=d("code",_),T=()=>{_.scrollTop=g.scrollTop,_.scrollLeft=g.scrollLeft},I=()=>{h(k,fi(g.value)),T()};g.addEventListener("scroll",T),I();let S=p?null:d("#f-slug-auto"),P=()=>({subject:R.value,...S?.checked?{slug_auto:!0}:{slug:N.value,slug_auto:!1},markdown:g.value}),y=null,A=()=>{y&&N.value===y.shown&&(N.value=y.slug,It()),y=null},w=null;function F(){w?.(),w=null}function te(){return R.value.trim()===""?(w=Pe(R,"Add a subject before you schedule."),R.focus(),!1):(F(),!0)}if(S){S.checked=c.slug_auto;let b=()=>N.classList.toggle("slug-auto",S.checked);b(),N.addEventListener("input",()=>{S.checked=N.value.trim()==="",b()}),S.addEventListener("change",()=>{b(),Nt(),S.checked?He(!0).catch(()=>{}):(N.focus(),N.select())}),N.addEventListener("blur",()=>{A(),N.value.trim()===""&&(S.checked=!0,b(),Nt(),Qe.dirty||(N.value=V))}),R.addEventListener("input",()=>{R.value.trim()!==""&&F()})}let ce=gi(()=>{He(!0).catch(()=>{})});tt(n,()=>ce.cancel());let q=H(".ctab",t);function ne(b){for(let D of q){let x=D.dataset.tab===b;D.classList.toggle("active",x),D.setAttribute("aria-selected",x?"true":"false")}g.hidden=b!=="edit",_.hidden=b!=="edit",f.hidden=b!=="preview",M.classList.toggle("off",b!=="edit"),t.querySelector("#dropFoot")?.classList.toggle("off",b!=="edit"),b==="edit"&&T()}let V=c.slug;async function re(){ne("preview");try{p||await He(!0),f.srcdoc=await ht(`/api/posts/${e}/preview`),f.onload=()=>{try{let b=f.contentDocument;b&&(Ct(b),Ei(b,_n(V),$),f.style.height=`${b.body.scrollHeight+24}px`)}catch{}}}catch(b){if(n.aborted||Dt(b))return;O(je(b))}}for(let b of q)b.onclick=()=>b.dataset.tab==="preview"?re():ne("edit");p&&re();let De=p?d("#lockFoot"):null,Z;function se(){De&&(De.classList.remove("nudge"),requestAnimationFrame(()=>De.classList.add("nudge")),clearTimeout(Z),Z=setTimeout(()=>De.classList.remove("nudge"),900))}function ae(b,D,x){let G=g.selectionStart,U=g.selectionEnd,j=g.value.slice(G,U)||x;g.value=g.value.slice(0,G)+b+j+D+g.value.slice(U),g.focus(),g.selectionStart=G+b.length,g.selectionEnd=G+b.length+j.length}function $e(b){let D=g.selectionStart,x=g.selectionEnd,G=g.value.lastIndexOf(`
|
|
173
|
+
`,D-1)+1,U=(g.value.slice(G,x)||"").split(`
|
|
174
|
+
`).map(j=>b+j).join(`
|
|
175
|
+
`);g.value=g.value.slice(0,G)+U+g.value.slice(x),g.focus(),g.selectionStart=G,g.selectionEnd=G+U.length}function Te(b){if(p){se();return}if(ne("edit"),b==="bold")ae("**","**","bold text");else if(b==="italic")ae("*","*","italic text");else if(b==="heading")$e("## ");else if(b==="quote")$e("> ");else if(b==="ul")$e("- ");else if(b==="ol")$e("1. ");else if(b==="indent")$e(" ");else if(b==="link")ae("[","](https://)","link text");else if(b==="code"){let D=g.selectionStart,x=g.selectionEnd;D===x||g.value.slice(D,x).includes(`
|
|
176
|
+
`)?ae("```\n","\n```","code"):ae("`","`","code")}Nt()}for(let b of H(".tb[data-fmt]",t))b.onclick=()=>Te(b.dataset.fmt??"");g.addEventListener("keydown",b=>{if(!(b.metaKey||b.ctrlKey))return;let D=b.key.toLowerCase();D==="b"?(b.preventDefault(),Te("bold")):D==="i"?(b.preventDefault(),Te("italic")):D==="k"&&(b.preventDefault(),Te("link"))});let xe=!1,ct=!1,Le=!1,le=null,Je=0,rn=!1,rs=0,Ce=p?null:d("#saveBtn"),Ae=p?null:d("#saveStatus"),Qe=new Yn(P),Mt=!1;function is(){Ae&&(p?(Ae.className="save-status",Ae.textContent=""):Mt?(Ae.className="save-status is-saving",Ae.textContent="Saving\u2026"):le&&xe?(Ae.className="save-status is-error",Ae.textContent=`Not saved: ${le}`):xe?(Ae.className="save-status is-dirty",Ae.textContent="Unsaved changes"):(Ae.className="save-status is-saved",Ae.textContent="Saved"))}function It(){xe=Qe.dirty,xe||(le=null,ct=!1,Je=0),is()}is();function To(){ce.touch()}function Nt(){p||(I(),It(),Le||To())}let ut=Promise.resolve();function He(b){let D=ut.catch(()=>{}).then(()=>Pa(b));return ut=D,qs(ut),D}async function Pa(b){if(b&&!Qe.dirty)return"unchanged";if(Le)return"refused";ce.cancel(),Mt=!0,is();let D=P(),x=JSON.stringify(D),G=N.value;try{let U={...D,base_revision:v.base},{post:j}=await C(`/api/posts/${e}`,{method:"PUT",json:U});if(j.slug&&N.value===G){let fe="slug"in D;fe&&document.activeElement===N?y={shown:G,slug:j.slug}:N.value=j.slug,fe&&(x=JSON.stringify({...D,slug:j.slug}))}return j.slug&&(V=j.slug),v.saved(j.current_revision),Qe.markSaved(x),ct=!1,le=null,Je=0,b||O("Saved"),"saved"}catch(U){let j=wi(U);if(j)return Ro(j),"refused";if(Me(U))return i(),"refused";ct=!0;let fe=U instanceof ee?{subject:R,slug:N}[U.field??""]:void 0;if(fe?(Pe(fe,K(U)),le="fix the field marked above."):le=K(U),!(gs(U)||U instanceof ee&&(U.status>=500||U.status===429)))rs+=1,rn=!1,Je=0;else if(!rn){rn=!0,Je=Je?Math.min(Je*2,6e4):5e3;let he=rs;et(Date.now()+Je,()=>{he===rs&&(rn=!1,le&&!Le&&He(!0).catch(()=>{}))},n)}throw U}finally{Mt=!1,It()}}Ce&&(Ce.onclick=()=>{if(Le){Be.scrollIntoView({block:"nearest"});return}J(Ce,"Saving\u2026",()=>He(!1).catch(()=>{})).finally(It)});function Lo(){Ce&&(Le?(Ce.setAttribute("aria-disabled","true"),Ce.title="Saving is paused: choose Reload or Keep editing above"):(Ce.removeAttribute("aria-disabled"),Ce.removeAttribute("title")))}let Ma=()=>{if(ce.cancel(),p||!Qe.dirty)return;let b=P(),D=JSON.stringify(b);xe=!1,ut=ut.catch(()=>{}).then(async()=>{if(!Qe.dirty)return;let x={...b,base_revision:v.base},{post:G}=await C(`/api/posts/${e}`,{method:"PUT",json:x});v.saved(G.current_revision),Qe.markSaved(D)}).catch(x=>{if(Me(x)){O("This post was deleted elsewhere, so your last edits weren\u2019t saved.");return}O(x instanceof ee&&x.status===409?"Changed elsewhere, so your edits weren\u2019t saved":`Couldn\u2019t save your changes: ${je(x)}`)}),qs(ut)},Ia={dirty:()=>xe,beforeLeave(){return r?"leave":xe&&(ct||Le)?"confirm":(Ma(),"leave")},manualSave(){Ce&&!Ce.disabled&&Ce.click()}};if(!p){for(let b of[R,N,g])b.addEventListener("input",Nt);for(let b of[R,N])b.addEventListener("blur",()=>He(!0).catch(()=>{})),b.addEventListener("keydown",D=>{D.key==="Enter"&&!D.isComposing&&D.keyCode!==229&&(D.preventDefault(),He(!0).then(A).catch(()=>{}))})}let Be=d("#freshnessBanner"),Na=b=>b==="service"?"Claude":b||null;function _o(){Le=!1,Lo(),v.clearWarning(),Be.hidden=!0,h(Be,o``)}function Ro(b){if(p)return;if(Le=!0,le=null,Lo(),v.noteWarned(b),b.kind==="locked")h(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 x=Na(b.author);h(Be,o`<span><span aria-hidden="true">⚠️</span> This draft was changed elsewhere${x?o`, last edited by <strong>${x}</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,d("#freshReload",Be).onclick=()=>{_o(),s()};let D=Be.querySelector("#freshKeep");D&&(D.onclick=()=>{v.adoptWarned(),_o(),It(),xe&&To()})}function Dt(b){return Me(b)?(i(),!0):!1}if(!p){let b=async()=>{if(Mt||Le||r||document.hidden)return;let D=v.base;try{let x=await C(`/api/posts/${e}`,{signal:n}),G=v.decide({status:x.post.status,revision:x.post.current_revision,author:x.author},{saving:Mt,baseAtRequest:D});G!=="ignore"&&G!=="fresh"&&Ro(G)}catch(x){Me(x)&&i()}};Xe(1e4,b,n),kd(b,n)}if(d("#webLink").addEventListener("click",()=>{p||He(!0).catch(()=>{})}),p&&m){let b=l?.send??{...m,phase:"scheduled",conditions:[]},D=d("#schedWhen"),x=d("#schedControls"),G="",U=0,j=!1,fe=d("#rescheduleSchedule"),be=d("#cancelSchedule"),he=z=>b.actions===void 0||Fe({actions:b.actions},z),Se=()=>{let z=Date.now(),de=b.phase==="due"||z>=b.fire_at;de?j||(fe.hidden=!0,be.hidden=!0):(j=!0,fe.hidden=!he("reschedule"),be.hidden=!he("cancel")),fe.disabled=de,be.disabled=de,x.hidden=fe.hidden&&be.hidden;let Ee=xd(b,de),Ao=String(Ee);Ao!==G&&(G=Ao,h(D,Ee)),!de&&b.fire_at-z<=1e3&&b.fire_at!==U&&(U=b.fire_at,et(b.fire_at,Se,n))};Se(),Xe(1e3,Se,n);let pt=d("#schedRemade"),mt=d("#schedDailyWrites"),Ie=()=>{let z=ve(b,"remade");pt.hidden=!z,d("#schedRemadeMsg").textContent=z?.message??"";let de=ve(b,"daily_writes");mt.hidden=!de,d("#schedDailyWritesMsg").textContent=de?.message??""};Ie();let ke=z=>Wt(z,{update(de,Ee){if(b=de,Ee?.to==="sending"||Ee?.to==="sent"||Ee?.to==="complete"){O("Your send started, so this page switched to its live watch."),Zn(`#/sent/${de.id}`);return}if(Ee?.to==="canceled"){O("The schedule was canceled elsewhere, so this post is a draft again."),s();return}Se(),Ie()},removed:i,stale:s},n);l?ke(l):Io(5e3,async()=>{let z=await C(`/api/sends/${m.id}`,{signal:n});return ki(z.send,s)||(b=z.send,Se(),Ie(),ke(z)),!1},n),fe.onclick=()=>Kn(b.id,b.fire_at,s,n),be.onclick=()=>J(be,"Canceling\u2026",async()=>{try{await C(`/api/sends/${m.id}/cancel`,{method:"POST"}),O("Schedule canceled"),s()}catch(z){Me(z)?i():z instanceof ee&&z.code==="window_closed"?(b={...b,phase:"due"},O("Too late to cancel: the send time has passed, so this email is going out."),ot()):O(je(z))}}).finally(Se)}async function as(b){if(b.type.startsWith("image/"))try{let D=new FormData;D.append("file",b);let{image:x}=await C(`/api/posts/${e}/images`,{method:"POST",body:D}),G=g.selectionStart,U=`
|
|
177
|
+

|
|
178
|
+
`;g.value=g.value.slice(0,G)+U+g.value.slice(G),g.selectionStart=g.selectionEnd=G+U.length,Nt(),O("Image added")}catch(D){Dt(D)||O(je(D))}}let Oe=d("#composerBody");if(p){let b=x=>x.metaKey||x.ctrlKey?["x","z","y"].includes(x.key.toLowerCase()):x.key.length===1||x.key==="Enter"||x.key==="Backspace"||x.key==="Delete",D=[g,R,N];for(let x of D)x.addEventListener("keydown",G=>{b(G)&&se()}),x.addEventListener("paste",se);Oe.addEventListener("dragover",x=>x.preventDefault()),Oe.addEventListener("drop",x=>{x.preventDefault(),se()})}else{let b=d("#imgInput"),D=d("#dropFoot");Oe.addEventListener("dragover",x=>{x.preventDefault(),Oe.classList.add("dragover")}),Oe.addEventListener("dragleave",x=>{x.target===Oe&&Oe.classList.remove("dragover")}),Oe.addEventListener("drop",x=>{x.preventDefault(),Oe.classList.remove("dragover"),ne("edit");for(let G of x.dataTransfer?.files??[])as(G)}),g.addEventListener("paste",x=>{for(let G of x.clipboardData?.items||[])if(G.type.startsWith("image/")){let U=G.getAsFile();U&&(x.preventDefault(),as(U))}}),D.onclick=()=>b.click(),b.onchange=()=>{for(let x of b.files??[])as(x);b.value=""}}let Da=d("#warnings");function Ba(b){h(Da,b?.length?o`<div class="warnings"><strong>Warnings:</strong> ${b.join("; ")}</div>`:o``)}d("#testBtn").onclick=()=>{let b=p?"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.",D=pe(),x=me(o`<h3>Send a test</h3><p class="hint">${b}</p>${Ln()}${D?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),G=d("#tGo",x.el);if(d("#tCancel",x.el).onclick=x.close,G.disabled=D,D){d("#tCancel",x.el).focus();return}let U=d("#testTo",x.el);U.focus(),C("/api/settings").then(j=>{let fe=j.settings.testRecipients||[];if(fe.length&&!U.value.trim()){U.value=fe.join(`
|
|
179
|
+
`),x.clearError();let be=d("#testDefaultsHint",x.el);be.textContent="Pre-filled from your default test recipients (Settings).",be.hidden=!1}}).catch(()=>{}),G.onclick=()=>J(G,"Sending\u2026",async()=>{x.clearError();let{valid:j,invalid:fe}=Pn(U.value),be=Mn(fe);if(be){x.error(be,U);return}if(!j.length){x.error("Enter at least one email address",U);return}try{p||await He(!0);let he=0,Se=[],pt=new Set,mt=null;for(let Ie of j)try{let ke=await C(`/api/posts/${e}/test`,{method:"POST",json:{to:Ie}});ke.sent?he++:Se.push(Ie),mt=ke.warnings}catch(ke){if(Me(ke)||ke instanceof ee&&ke.status===401)throw ke;Se.push(Ie),pt.add(K(ke))}if(Ba(mt),p&&he>0&&ot(),Se.length){U.value=Se.join(`
|
|
180
|
+
`),x.error(In(he,j.length,Se,[...pt]));return}x.close(),O(gt(`Test sent to ${he} address${he===1?"":"es"}`))}catch(he){if(Me(he)){x.close(),Dt(he);return}x.error(je(he))}})};let Co=p?null:d("#scheduleBtn");return Co&&(Co.onclick=()=>{if(!te())return;let b=wt($n()),D=wt(new Date(Date.now()+24*3600*1e3)),x=pe(),G=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>${zo()}${ks()}${x?null:o`<label for="schWhen">Send at</label><input type="datetime-local" id="schWhen" min="${b}" value="${D}">`}<div class="actions"><button type="button" id="schCancel">Cancel</button><button type="button" class="primary" id="schGo">Schedule</button></div>${x?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>`}`,U=me(G,n),j=d(".modal",U.el),fe=null,be=()=>(fe??=C("/api/subscribers?status=confirmed&suppressed=hide&limit=1",{signal:n}).then(z=>z.page.total).catch(()=>null),fe),he=async()=>{if(!j.querySelector("#schDailyWrites"))return;let z=await be(),de=j.querySelector("#schDailyWrites");de&&z!==null&&z>gn&&(de.textContent=Vo(z),de.hidden=!1)},Se=()=>{U.close(),Be.scrollIntoView({block:"nearest"})},pt=()=>J(d("#schGo",j),"Scheduling\u2026",async()=>{U.clearError();let z=d("#schWhen",j),de=z.value?new Date(z.value).getTime():Number.NaN;if(Number.isNaN(de)){U.error("Pick a valid date & time",z);return}if(Sn(de)){U.error(wn(),z);return}try{if(await He(!0)==="refused"){Se();return}await C(`/api/posts/${e}/schedule`,{method:"POST",json:{fire_at:new Date(de).toISOString()}}),U.close(),O(gt(`Scheduled for ${Y(de)}. Send yourself a test.`)),s()}catch(Ee){if(Me(Ee)){U.close(),Dt(Ee);return}if(Ee instanceof ee&&Ee.field==="fire_at"){U.error(je(Ee),d("#schWhen",j));return}U.error(je(Ee))}}),mt=()=>J(d("#snGo",j),"Queuing\u2026",async()=>{U.clearError();try{if(await He(!0)==="refused"){Se();return}let{send:z}=await C(`/api/posts/${e}/send`,{method:"POST"});U.close(),O(gt(`Sends at ${Y(z.fire_at)}, cancelable until then.`)),s()}catch(z){if(Me(z)){U.close(),Dt(z);return}U.error(je(z))}});function Ie(){if(j.setAttribute("aria-labelledby","schHead"),d("#schCancel",j).onclick=U.close,d("#schGo",j).onclick=pt,d("#schGo",j).disabled=x,x){d("#schCancel",j).focus();return}d("#toSendNow",j).onclick=ke,d("#schWhen",j).focus(),he()}async function ke(){h(j,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 ${Go()}. The post locks until then, and you can cancel any time before it starts sending.</p>${ks()}<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>`),j.setAttribute("aria-labelledby","snHead"),d("#snCancel",j).onclick=U.close,d("#snGo",j).onclick=mt,d("#snGo",j).disabled=pe(),d("#toSchedule",j).onclick=()=>{h(j,G),Ie()},d("#snGo",j).focus(),he();let z=await be(),de=j.querySelector("#snWho");de&&z!==null&&(de.textContent=`${z} recipient${z===1?"":"s"}`)}Ie()}),Ia}function ki(e,t){return e.status==="sending"||e.status==="sent"?(Zn(`#/sent/${e.id}`),!0):e.status==="canceled"?(t(),!0):!1}function xd(e,t){return Re(e,"missed")?o`Scheduled for <strong>${Y(e.fire_at)}</strong> · <span class="countdown countdown-missed">${Nn(e.fire_at)}</span>`:t?o`Scheduled for <strong>${Y(e.fire_at)}</strong> · <span class="countdown">${Vt(e.fire_at,!0)}</span>`:o`Scheduled for <strong>${Y(e.fire_at)}</strong> · <span class="countdown">${Vt(e.fire_at)}</span>`}function Me(e){return e instanceof ee&&e.status===404}function Ed(e){return e.sending?`#/sent/${e.sending.id}`:e.post.status==="sent"?e.sent?`#/sent/${e.sent.id}`:"#/sent":null}function Zn(e){location.replace(e)}function kd(e,t){document.addEventListener("visibilitychange",()=>{document.hidden||e()},{signal:t})}function Td(e){return e.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu,"").replace(/\s/g,"-")}function Ti(e){let t=new Map;return e.map(n=>{let s=Td(n),r=t.get(s)??0;return t.set(s,r+1),r?`${s}-${r}`:s})}var Vs=null;function Ld(e){let t=document.createElement("div");h(t,W(e.html||""));let n=(t.querySelector("p")?.textContent||"").trim().replace(/\s+/g," "),s=/[:;,]$/.test(n),r=n.replace(/[\s:;,]+$/,""),i=150;return r.length>i?`${r.slice(0,i).replace(/[\s.,;:]+\S*$/,"")}\u2026`:s?`${r}\u2026`:r}async function Ks(e,t,n,s){if(h(t,vt("docs",null,o`<div class="docs-index"><p class="muted">Loading…</p></div>`)),!Vs)try{Vs=await C("/api/docs",{signal:n})}catch(c){n.aborted||oe(d(".room-main",t),K(c),()=>Q((u,m)=>Ks(e,u,m,s)));return}let{sets:r,docs:i}=Vs,a=r[0];if(!i.length||!a){h(d(".room-main"),o`<p class="muted">No documentation.</p>`);return}let l=e?r.find(c=>c.id===e):a;l?Ri(t,l,r,i):e&&Cd(t,r,i,e,s?_d(s):void 0)}function _d(e){try{return decodeURIComponent(e)}catch{return}}function Ri(e,t,n,s){let r=(p,v)=>{let E=Ld(p);return o`<li><a class="doc-card" href="#/docs/${p.slug}">${v===null?null:o`<span class="doc-card-n">${String(v).padStart(2,"0")}</span>`}<span class="doc-card-main"><span class="doc-card-t">${p.title} <span class="doc-card-go" aria-hidden="true">→</span></span>${E?o`<span class="doc-card-d">${E}</span>`:null}</span></a></li>`},i=t.sections.map(p=>{let v=s.filter($=>$.set===t.id&&$.section===p.id);if(!v.length)return null;let E=v.map(($,R)=>r($,p.numbered?R+1:null)),L=p.blurb?o`<div class="doc-section-blurb">${W(p.blurb)}</div>`:null;return o`<section class="doc-section"><h2 class="doc-section-t">${p.title}</h2>${L}${p.numbered?o`<ol class="doc-cards">${E}</ol>`:o`<ul class="doc-cards">${E}</ul>`}</section>`}),a=o`<nav class="docs-tabs" aria-label="Doc sets">${n.map(p=>o`<a href="#/docs/${p.id}" title="${p.title}"${p.id===t.id?o` aria-current="page"`:null}>${p.name}</a>`)}</nav>`,l=o`<div class="docs-index"><h1>Kestrel docs</h1><p class="docs-index-intro"><strong>Publishing</strong> covers writing and sending your newsletter, from naming your publication to what happens after each send. <strong>Self-hosting</strong> covers deploying Kestrel to your own Cloudflare account and keeping it running. One person often does both.</p>${a}${i}</div>`,c=X.appConfig?.deployment.build.repoUrl||"",u=(p,v,E=v)=>o`<a class="rail-link" href="${p}" target="_blank" rel="noopener" aria-label="${v}"><span class="rail-link-full">${v}</span><span class="rail-link-short" aria-hidden="true">${E}</span> <span aria-hidden="true">↗</span></a>`,m=o`<div class="toc-label">Kestrel</div>${u("https://getkestrel.dev","Project site","Project")}${c?[u(c,"Source on GitHub","Source"),u(`${c}/blob/HEAD/LICENSE`,"License")]:null}`;h(e,vt("docs",m,l)),window.scrollTo(0,0)}var Gs=matchMedia("(max-width: 720px)");function Ci(e){if(!e)return;let t=Gs.matches;e.open=!t,d("summary",e).tabIndex=t?0:-1}var Ai=()=>document.querySelector("details#tocOnPage"),Li=!1;function Rd(){Li||(Li=!0,Gs.addEventListener("change",()=>Ci(Ai())))}var _i=e=>`doc-h-${e}`;function Cd(e,t,n,s,r){let i=n.findIndex(w=>w.slug===s),a=n[i],l=t.find(w=>w.id===a?.set);if(!a||!l){let w=t[0];O(`No doc named \u201C${s}\u201D, so here\u2019s the index.`),history.replaceState(history.state,"","#/docs"),w&&Ri(e,w,t,n);return}let c=n.filter(w=>w.set===a.set&&w.section===a.section),u=c.indexOf(a),m=c[u-1],p=c[u+1],v=(w,F)=>o`<a href="#/docs/${w.slug}" aria-label="${F}: ${w.title}">${F==="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>`,E=m||p?o`<nav class="doc-topnav" aria-label="Adjacent docs">${m?v(m,"Previous"):null}${p?v(p,"Next"):null}</nav>`:null;h(e,vt("docs",o`<div id="docToc"></div>`,o`<article class="doc" id="docsMain"></article>`));let L=d("#docToc"),$=d("#docsMain");h($,o`<section class="doc-part" id="doc-${a.slug}">${W(a.html)}</section>`);let R=d("section.doc-part",$),N=H("h1, h2, h3, h4",R),g=Ti(N.map(w=>w.textContent||""));for(let[w,F]of N.entries()){let te=g[w]??"";F.dataset.anchor=te,F.id=_i(te)}let M=R.querySelector("h1"),f=[];if(M&&f.push({id:M.id,anchor:"",title:M.textContent||a.title}),E){let w=document.createElement("div");w.className="doc-head",h(w,E),M?(M.before(w),w.prepend(M)):R.prepend(w)}let _=document.createElement("p");_.className="eyebrow doc-set",h(_,o`<a href="#/docs/${l.id}">${l.name}</a>`),R.prepend(_);for(let w of H("h2",R))f.push({id:w.id,anchor:w.dataset.anchor||"",title:w.textContent||""});let k=document.createElement("nav");k.className="doc-pager",h(k,o`${m?o`<a class="doc-pager-btn prev" href="#/docs/${m.slug}"><span class="doc-pager-dir">← Previous</span><span class="doc-pager-title">${m.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>`}`),$.appendChild(k);let T=f.map(w=>o`<a class="toc-sub" href="#/docs/${a.slug}${w.anchor?`/${w.anchor}`:""}" data-target="${w.id}">${w.title}</a>`);h(L,f.length?o`<details class="toc-onpage" id="tocOnPage"><summary class="toc-label">On this page</summary>${T}</details>`:o``);let I=Ai();Ci(I),Rd();let S=w=>{if(I)for(let F of H(".toc-sub",I))F.classList.toggle("on",F.dataset.target===w)};L.addEventListener("click",w=>{let F=w.target instanceof Element?w.target.closest("a[data-target]"):null,te=F?.dataset.target;te&&(w.preventDefault(),S(te),I&&Gs.matches&&(I.open=!1),history.replaceState(history.state,"",F?.getAttribute("href")??location.hash),document.getElementById(te)?.scrollIntoView({block:"start"}))});for(let w of H("a[href^='http']",$))w.target="_blank",w.rel="noopener";for(let w of H("pre",$)){w.classList.add("has-copy");let F=document.createElement("button");F.type="button",F.className="code-copy",F.textContent="Copy",F.addEventListener("click",async()=>{let te=w.querySelector("code");try{await navigator.clipboard.writeText((te||w).innerText),F.textContent="Copied",setTimeout(()=>{F.textContent="Copy"},1500)}catch{O("Couldn\u2019t copy to clipboard")}}),w.appendChild(F)}let P=f.map(w=>w.id),y=P[0];if(I&&y){let w=P[P.length-1]??y,te=(document.querySelector(".room-bar")?.getBoundingClientRect().height||54)+26,ce=()=>{if(!document.getElementById(y)){document.onscroll=null;return}let q=y;if(Math.ceil(window.innerHeight+window.scrollY)>=document.documentElement.scrollHeight)q=w;else for(let ne of P)if((document.getElementById(ne)?.getBoundingClientRect().top??Number.POSITIVE_INFINITY)<=te)q=ne;else break;S(q)};document.onscroll=ce,ce()}let A=r?document.getElementById(_i(r)):null;A?A.scrollIntoView({block:"start"}):window.scrollTo(0,0)}function Ad(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 Jn(e){return e.replace(/\([^)]*\)/g,"").replace(/\{[^}]*\}\??/g,"")||"/"}function Hi(e,t,n){if(e.access==="webhook")return null;let s=(e.query??[]).filter(l=>l.required),r=s.length?`?${s.map(l=>`${l.name}=:${l.name}`).join("&")}`:"",i=`"${t}${Jn(e.path)}${r}"`,a=[e.method==="GET"?`curl ${i}`:`curl -X ${e.method} ${i}`];if(e.access==="admin"&&a.push(...Ad(n)),e.example?.request!==void 0){let l=JSON.stringify(e.example.request).replaceAll("'","'\\''");a.push('-H "Content-Type: application/json"',`-d '${l}'`)}return a.join(` \\
|
|
181
|
+
`)}function Pi(e,t){return t===void 0?null:_t(e,hi(t),JSON.stringify(t,null,2))}function Hd(e){let t=Hi(e,location.origin,X.session?.auth.mode??"dev");return t===null?null:_t("curl",bi(t),t)}function Pd(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 Md(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 Id(e){return o`${Jn(e).split(/(:[A-Za-z_]+)/).map((t,n)=>n%2?o`<span class="api-param">${t}</span>`:t)}`}function Nd(e){let t=`${e.method} ${Jn(e.path)} ${e.summary}`.toLowerCase();return o`<li data-hay="${t}"><details class="api-route">
|
|
168
182
|
<summary class="api-route-sum">
|
|
169
183
|
<span class="api-method m-${e.method}">${e.method}</span>
|
|
170
|
-
<code class="api-path">${
|
|
184
|
+
<code class="api-path">${Id(e.path)}</code>
|
|
171
185
|
<span class="api-route-line">${e.summary}</span>
|
|
172
186
|
<span class="api-chev" aria-hidden="true"></span>
|
|
173
187
|
</summary>
|
|
174
188
|
<div class="api-route-detail">
|
|
175
189
|
<p class="api-summary">${e.summary}</p>
|
|
176
190
|
${e.description?o`<p class="api-desc muted">${e.description}</p>`:null}
|
|
177
|
-
${
|
|
178
|
-
${
|
|
179
|
-
${
|
|
180
|
-
${
|
|
181
|
-
${
|
|
191
|
+
${Pd(e.query)}
|
|
192
|
+
${Md(e.accepts)}
|
|
193
|
+
${Pi("Request",e.example?.request)}
|
|
194
|
+
${Pi("Response",e.example?.response)}
|
|
195
|
+
${Hd(e)}
|
|
182
196
|
</div>
|
|
183
|
-
</details></li>`}var
|
|
197
|
+
</details></li>`}var Dd=(e,t)=>e.routes.filter(n=>n.resource===t.key);function Bd(e,t){let n=`${e.access}-${t.key}`,s=Dd(e,t);return o`<div class="api-res" id="api-${n}" data-sec="${n}">
|
|
184
198
|
<div class="api-res-head"><h3>${t.title}</h3></div>
|
|
185
|
-
<ul class="api-rows">${s.map(
|
|
186
|
-
</div>`}function
|
|
199
|
+
<ul class="api-rows">${s.map(Nd)}</ul>
|
|
200
|
+
</div>`}function Od(e){return o`<section class="api-section" id="api-${e.access}" data-tier="${e.access}">
|
|
187
201
|
<h2>${e.title}</h2>
|
|
188
202
|
<p class="api-blurb muted">${e.blurb}</p>
|
|
189
|
-
${e.resources.map(t=>
|
|
190
|
-
</section>`}function
|
|
191
|
-
${
|
|
192
|
-
${
|
|
203
|
+
${e.resources.map(t=>Bd(e,t))}
|
|
204
|
+
</section>`}function Ud(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 Mi(e,t,n){let s=e.trim().toLowerCase().split(/\s+/).filter(Boolean),r=0;for(let l of H(".api-section",n)){let c=0;for(let m of H(".api-res",l)){let p=0;for(let E of H("li[data-hay]",m)){let L=s.every($=>(E.dataset.hay??"").includes($));E.hidden=!L,p+=L?1:0}m.hidden=p===0;let v=t.querySelector(`a[data-sec="${m.dataset.sec}"]`);v&&(v.hidden=p===0),c+=p}l.hidden=c===0;let u=t.querySelector(`a[data-tier="${l.dataset.tier}"]`);u&&(u.hidden=c===0),r+=c}let i=H(".api-section",n),a=i.filter(l=>!l.hidden).at(-1);for(let l of i)l.classList.toggle("api-last",l===a);d("#apiEmpty",n).hidden=r>0}function Fd(e,t){let n=(d(".room-bar").getBoundingClientRect().height||54)+26,s=H(".api-section, .api-res",t).filter(m=>!m.closest("[hidden]")),r=window.scrollY>0&&Math.ceil(window.innerHeight+window.scrollY)>=document.documentElement.scrollHeight,i=s[0];for(let m of s)if(r||m.getBoundingClientRect().top<=n)i=m;else break;let a=i?.dataset.sec??i?.dataset.tier,l=i?.closest(".api-section")?.dataset.tier,c=H("a[data-sec]",e).filter(m=>getComputedStyle(m).display!=="none"),u=c.find(m=>m.dataset.sec===a)??(l===void 0?void 0:c.find(m=>m.dataset.tier===l));for(let m of H("a[data-sec]",e))m.classList.toggle("active",m===u)}async function Ys(e,t){h(e,vt("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=d("#apiNav"),s=d("#apiContent");n.addEventListener("click",u=>{let p=(u.target instanceof Element?u.target.closest("a[data-sec]"):null)?.dataset.sec;p&&(u.preventDefault(),document.getElementById(`api-${p}`)?.scrollIntoView({block:"start"}))});let r;try{({groups:r}=await C("/api/reference",{signal:t}))}catch(u){oe(s,K(u),()=>Q(Ys));return}h(n,Ud(r)),h(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">${B("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>${B("x")}</button></div></header>${r.map(Od)}<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 i=d("#apiFilter"),a=d("#apiFilterClear"),l=()=>Fd(n,s),c=u=>{i.value=u,a.hidden=u==="",Mi(u,n,s),l()};i.addEventListener("input",()=>c(i.value)),a.addEventListener("click",()=>{c(""),i.focus()}),ze(s),document.addEventListener("keydown",u=>{let m=u.target instanceof HTMLElement&&(u.target.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(u.target.tagName));u.key==="/"&&!m&&!u.metaKey&&!u.ctrlKey&&!u.altKey?(u.preventDefault(),i.focus()):u.key==="Escape"&&u.target===i&&i.value&&c("")},{signal:t}),Mi("",n,s),window.addEventListener("scroll",l,{passive:!0,signal:t}),window.scrollTo(0,0),l()}var Qn=K;function qd(e){let t=[Fe(e,"reschedule")?o`<button class="ghost" data-reschedule="${e.id}">Reschedule</button>`:null,Fe(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 zs(e,t){let n={status:"sent",search:"",failures:"",sort:"fire",dir:"desc",offset:0,limit:50};h(e,o`<h1>Sent</h1>
|
|
205
|
+
${Ft()?o`<p class="muted">A local stand-in sends these: they are recorded here, but nothing is delivered.</p>`:null}
|
|
206
|
+
${En("sent-not-connected",o`No email provider connected, so nothing can be sent yet. ${_e()} to start sending.`)}
|
|
193
207
|
<div id="stuck"></div>
|
|
194
208
|
<h2>Scheduled</h2><div id="scheduled" class="muted">Loading…</div>
|
|
195
209
|
<div id="active"></div>
|
|
196
210
|
<h2>Sent posts</h2>
|
|
197
|
-
${
|
|
211
|
+
${$t({searchPlaceholder:"Search subject\u2026",failures:!0})}
|
|
198
212
|
<div id="sendsList" class="muted">Loading…</div>
|
|
199
|
-
<div id="sendsPager"></div>`);let s=
|
|
213
|
+
<div id="sendsPager"></div>`);let s=d("#stuck",e),r=d("#scheduled",e),i=d("#active",e),a=d("#sendsList",e),l=d("#sendsPager",e),c=Un(e,t),u=[],m=!1,p=0,v=0,E=new Map;function L(){S(),P(),ot()}function $(y){let A=y.filter(w=>w.status==="sending"&&!Gt(w));h(i,A.length?o`<h2>In progress</h2>${A.map(Fn)}`:o``);for(let w of H("[data-watch]",i))w.onclick=F=>{F.target instanceof Element&&F.target.tagName==="A"||(location.hash=`#/sent/${w.dataset.watch}`)}}function R(y){let A=y.flatMap(V=>{let re=ve(V,"refused");return re?[{s:V,c:re}]:[]}),w=pe()?A.filter(V=>V.c.cause==="not_connected"):[],F=A.filter(V=>!w.includes(V)),te=e.querySelector(".sent-not-connected");te&&(te.hidden=w.length>0);let ce=w.map(({s:V},re)=>o`${re===0?"":re===w.length-1?" and ":", "}<a href="#/sent/${V.id}">${V.subject}</a>`),q=w.length?o`<div class="card stuck-card sent-held"><div class="stuck-head"><span class="stuck-dot">⚠️</span><div>${xn(o`${ce}`,w.length)}</div></div></div>`:null,ne=y.flatMap(V=>{let re=ve(V,"wedged");return re?[{s:V,c:re}]:[]});h(s,o`${q}${F.map(({s:V,c:re})=>o`<div class="card stuck-card"><div class="stuck-head"><span class="stuck-dot">⚠️</span><div><strong><a href="#/sent/${V.id}">${V.subject}</a></strong><div class="muted">${re.message}</div></div></div></div>`)}${ne.map(({s:V,c:re})=>o`<div class="card stuck-card"><div class="stuck-head"><span class="stuck-dot">⚠️</span><div><strong><a href="#/sent/${V.id}">${V.subject}</a></strong><div class="muted">${re.message}</div></div></div>${Fe(V,"resolve")?o`<button class="primary" data-resolve="${V.id}">Resolve…</button>`:null}</div>`)}`);for(let V of H("[data-resolve]",s)){let re=ne.find(De=>De.s.id===V.dataset.resolve);re&&(V.onclick=()=>Vn({id:re.s.id,c_in_flight:re.c.count},L,t))}}function N(){for(let y of E.values()){let A=H("tr[data-id]",a).find(w=>w.dataset.id===y.id)?.querySelector("td.delivered");A&&h(A,Lt(y.counts))}}function g(){let y=Tt(u,E);R(y),m&&$(y),N()}let M=y=>y==="scheduled"||y==="due",f=y=>y==="sent"||y==="complete";function _(y){for(let A of y.sends)E.set(A.id,A);for(let A of y.removed)E.delete(A);g(),(y.sends.some(A=>M(Ve(A)))||y.changes.some(A=>M(A.from)))&&S(),y.removed.length?(S(),P()):y.changes.some(A=>f(A.to)&&!f(A.from))&&P()}let k=!1;function T(y){let A=y.filter(w=>w!==null);k||!A.length||(k=!0,yt({cursor:A.map(w=>w.cursor).reduce(Rn),sends:A.flatMap(w=>w.sends)},{update:_,stale:()=>{t.aborted||Q(zs)}},t))}async function I(){try{let y=await C("/api/sends?status=sending&limit=200",{signal:t});return u=y.sends,m=!0,g(),y}catch(y){return oe(i,Qn(y),async()=>T([await I()])),null}}async function S(){let y=++p;try{let A=await C("/api/sends?status=scheduled&sort=fire&dir=asc&limit=200",{signal:t});if(y!==p)return A;let{sends:w}=A,F=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">${On(q)} · ${Y(q.fire_at)} · ${q.audience.count} recipients</div></div>${qd(q)}</div>`,[te,...ce]=w;if(!te)h(r,o`<p class="muted">Nothing scheduled.</p>`);else{h(r,o`${F(te)}${ce.length?o`<div id="schedMore" hidden>${ce.map(F)}</div><button type="button" class="ghost sched-toggle" id="schedToggle" aria-expanded="false">Show all ${w.length} scheduled</button>`:null}`);let q=r.querySelector("#schedToggle");q&&(q.onclick=()=>{let ne=d("#schedMore",r),V=ne.hidden;ne.hidden=!V,q.setAttribute("aria-expanded",String(V)),q.textContent=V?"Show fewer":`Show all ${w.length} scheduled`})}for(let q of H(".card.clickable",r))q.onclick=ne=>{let V=ne.target;V instanceof Element&&V.tagName!=="A"&&!V.closest("button")&&(location.hash=`#/edit/${q.dataset.post}`)};for(let q of H("[data-reschedule]",r)){let ne=w.find(V=>V.id===q.dataset.reschedule);ne&&(q.onclick=()=>Kn(ne.id,ne.fire_at,L,t))}for(let q of H("[data-cancel]",r))q.onclick=()=>J(q,"Canceling\u2026",async()=>{try{await C(`/api/sends/${q.dataset.cancel}/cancel`,{method:"POST"}),O("Canceled"),L()}catch(ne){O(Qn(ne))}});return c(),A}catch(A){return y===p&&oe(r,Qn(A),async()=>T([await S()])),null}}async function P(){let y=++v;try{let A=await C(`/api/sends?${St(n)}`,{signal:t});if(y!==v)return A;let w=A.sends;if(!w.length)return h(a,o`<p class="muted">${n.search?"No sent posts match.":n.failures?"Every sent post delivered cleanly.":"No sent posts yet."}</p>`),h(l,o``),A;h(a,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>${ge("Subject","subject",n)}${ge("When","fire",n)}${ge("Recipients","recipients",n,"num")}<th class="num">Delivered</th></tr></thead><tbody>${w.map(F=>o`<tr class="clickable" data-id="${F.id}"><td class="subject"><a href="#/sent/${F.id}">${F.subject}</a></td><td class="muted">${Y(F.completed_at??F.fire_at)}</td><td class="num recipients"><span class="n">${F.audience.count.toLocaleString()}</span></td><td class="num delivered">${Lt(F.counts)}</td></tr>`)}</tbody></table></div>`),Ke(a,n,P);for(let F of H("tr[data-id]",a))F.onclick=te=>{te.target instanceof Element&&te.target.tagName==="A"||(location.hash=`#/sent/${F.dataset.id}`)};return Ge(l,n,A.page,P),N(),A}catch(A){return y===v&&oe(a,Qn(A),async()=>T([await P()])),null}}xt(e,n,P),T(await Promise.all([S(),I(),P()]))}var jd="erased:";function Ii(e){return e.startsWith(jd)}function lt(e,t,n){let s=URL.createObjectURL(new Blob([e],{type:n})),r=document.createElement("a");r.href=s,r.download=t,r.click(),setTimeout(()=>URL.revokeObjectURL(s),1e4)}var Js=K;async function Qs(e,t,n){let s=()=>{n.aborted||Q((a,l)=>Qs(e,a,l))};h(t,o`<p class="muted">Loading…</p>`);let r;try{r=await C(`/api/sends/${e}`,{signal:n})}catch(a){oe(t,Js(a),s);return}if(n.aborted)return;let{send:i}=r;if(i.status==="scheduled"){location.replace(`#/edit/${i.post_id}`);return}return i.status==="sending"?Jd(r,t,n,s):oc(e,r,t,n,s)}var Wd={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"}},Vd={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 Zs(e){if(e===null)return"it is retried on its own";let t=e-Date.now();return t>0?`next retry in ${Bn(t)}`:"next retry due now"}function Kd(e){let t=e.provider.halt;return Re(e,"refused")&&t?.cause==="not_connected"?`No email provider is connected, so nothing goes out until one is; ${Zs(t.retry_at)}.`:Re(e,"refused")&&t?`The provider is refusing this account, so nothing more goes out until it\u2019s fixed; ${Zs(t.retry_at)}.`:e.phase==="backing-off"&&t?.reason==="unavailable"?`The provider is unavailable; ${Zs(t.retry_at)}.`:e.phase==="progressing"&&e.counts.in_flight===0&&e.counts.pending>0?"Paused between ticks. It resumes on the next sweep.":Vd[e.phase]||""}function Jt(e){let t=Wd[e]??{label:e,tone:"muted"};return o`<span class="phase-pill tone-${t.tone}">${t.label}</span>`}function Gd(e,t,n,s,r){let i=s>0?Kt(100*n/s):0;return o`<div class="wbar-row">
|
|
200
214
|
<div class="wbar-head"><span class="wbar-label">${t}</span><span class="wbar-count">${n.toLocaleString()} / ${s.toLocaleString()}</span></div>
|
|
201
|
-
<div class="wbar" role="progressbar" aria-valuenow="${
|
|
202
|
-
${
|
|
203
|
-
</div>`}function
|
|
215
|
+
<div class="wbar" role="progressbar" aria-valuenow="${i}" aria-valuemin="0" aria-valuemax="100"><div class="wbar-fill tone-${e}" style="width:${i}%"></div></div>
|
|
216
|
+
${r?o`<div class="wbar-sub muted">${r}</div>`:null}
|
|
217
|
+
</div>`}function Yd(e,t,n,s,r){let i=s>0?Kt(100*n/s):0,a=s>0?Kt(100*t/s):0;return o`<div class="wbar-row">
|
|
204
218
|
<div class="wbar-head"><span class="wbar-label">${e}</span><span class="wbar-count">${t.toLocaleString()} of ${n.toLocaleString()} accepted</span></div>
|
|
205
|
-
<div class="wbar dual" role="progressbar" aria-valuenow="${
|
|
206
|
-
<div class="wbar-fill tone-accepted" style="width:${
|
|
207
|
-
<div class="wbar-fill tone-ok" style="width:${
|
|
219
|
+
<div class="wbar dual" role="progressbar" aria-valuenow="${a}" aria-valuemin="0" aria-valuemax="100">
|
|
220
|
+
<div class="wbar-fill tone-accepted" style="width:${i}%"></div>
|
|
221
|
+
<div class="wbar-fill tone-ok" style="width:${a}%"></div>
|
|
208
222
|
</div>
|
|
209
|
-
<div class="wbar-sub muted">${
|
|
210
|
-
</div>`}var
|
|
211
|
-
${
|
|
212
|
-
${
|
|
223
|
+
<div class="wbar-sub muted">${r}</div>
|
|
224
|
+
</div>`}var zd=[{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 Zd(e){return o`<div class="wcounts">${zd.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 Fi(e){let t=e.counts,n=t.accepted+t.delivered+t.bounced+t.complained,s=e.dispatch.rate_per_min,r=e.dispatch.eta_ms,i=e.status==="sending"?`${Kd(e)}${r?`${s?` \xB7 ~${s.toLocaleString()}/min`:""} \xB7 ETA ${Bn(r)}`:""}`:"Dispatch complete.",a=(t.unsent||0)+(t.bounced||0)+(t.complained||0),l=Ft()?"Local stand-in: nothing is delivered":pe()?"No email provider connected":`Provider: ${e.provider?.name||"unknown"}`,c=ve(e,"refused"),u=c?.cause==="not_connected",m=ve(e,"stuck");return o`
|
|
225
|
+
${m?o`<div class="health amber" role="status"><span class="health-dot">⚠️</span><div>${m.message}</div></div>`:null}
|
|
226
|
+
${c?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>${c.error}</div>`}<div class="refused-advice">${u?Es():c.advice}</div><div>${c.since===null?null:`Since ${Y(c.since)}. `}No one has been marked unsent, and once ${u?"one is connected":"this is fixed"} the send resumes at its next retry${c.retry_at===null?"":`, ${Y(c.retry_at)}`}.</div></div></div>`:null}
|
|
213
227
|
<div class="wbars">
|
|
214
|
-
${
|
|
215
|
-
${
|
|
228
|
+
${Gd("sending","Dispatch: provider-accepted",n,e.audience.count,i)}
|
|
229
|
+
${Yd("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.")}
|
|
216
230
|
</div>
|
|
217
|
-
${
|
|
218
|
-
<div class="whealth${
|
|
231
|
+
${Zd(t)}
|
|
232
|
+
<div class="whealth${a?" has-failures":""}"><span class="whealth-dot"></span><span>${l} · ${a?`${a.toLocaleString()} bounced / unsent / complained`:"no delivery failures"}</span></div>`}function qi(e){let t=e.started_at?`Started ${Y(e.started_at)}`:"Sending now";return o`${t} · ${e.audience.count.toLocaleString()} recipients`}function Ni(e){let t=e;return o`
|
|
219
233
|
<div class="editor-head">
|
|
220
234
|
<a href="#/sent" class="back">← Sent</a>
|
|
221
|
-
${
|
|
235
|
+
${Fe(t,"resolve")?o`<button type="button" class="primary" id="resolveBtn">Resolve…</button>`:null}
|
|
222
236
|
</div>
|
|
223
237
|
<div class="card rec-card watch-card">
|
|
224
238
|
<div class="rec-head">
|
|
225
|
-
<div class="watch-title"><h1>${e.subject||o`<em>untitled</em>`}</h1><span id="watchPill">${
|
|
226
|
-
<div class="rec-meta" id="watchMeta">${
|
|
239
|
+
<div class="watch-title"><h1>${e.subject||o`<em>untitled</em>`}</h1><span id="watchPill">${Jt(t.phase)}</span></div>
|
|
240
|
+
<div class="rec-meta" id="watchMeta">${qi(e)}</div>
|
|
227
241
|
</div>
|
|
228
|
-
<div id="watchBody">${
|
|
242
|
+
<div id="watchBody">${Fi(t)}</div>
|
|
229
243
|
<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
|
|
244
|
+
</div>`}function Di(e,t,n,s){let r=e.querySelector("#resolveBtn");if(r){let i=ve(t,"wedged")?.count??t.counts.in_flight;r.onclick=()=>Vn({id:t.id,c_in_flight:i},n,s)}}function Bi(e,t){let n=e.querySelector("#watchPill");n&&h(n,Jt(t.phase));let s=e.querySelector("#watchMeta");s&&h(s,qi(t));let r=e.querySelector("#watchBody");r&&h(r,Fi(t))}function Jd(e,t,n,s){h(t,Ni(e.send)),Di(t,e.send,s,n);let r=e.send;Xe(1e3,()=>Bi(t,r),n),Wt(e,{update:i=>{if(i.status!=="sending"){s();return}r=i,Fe(i,"resolve")!==!!t.querySelector("#resolveBtn")?(h(t,Ni(i)),Di(t,i,s,n)):Bi(t,i)},removed:s,stale:s},n)}function Oi(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 Ui(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(", ")}.`,r=e.bounced||e.complained?" Hard bounces and spam complaints are suppressed automatically, so those addresses won\u2019t be mailed again.":"",i=e.bounced?" A soft bounce is temporary, so it\u2019s counted and the address stays on the list.":"";return`${s}${r}${i}`}function Qd(e,t){return Object.keys(e).every(n=>e[n]===t[n])}function Xd(e){if(e.event==="delivered")return{label:"Delivered",sw:"ok"};if(e.event==="bounced")return e.bounce_kind==="hard"?{label:"Hard bounce",sw:"warn"}:e.bounce_kind==="soft"?{label:"Soft bounce",sw:"warn"}:{label:"Bounce",sw:"warn"};if(e.event==="complained")return{label:"Complained",sw:"danger"};switch(e.status){case"unsent":return{label:"Unsent",sw:"neutral"};case"skipped":return{label:"Skipped",sw:"neutral"};case"accepted":return{label:"Accepted",sw:"sending"};default:return{label:"In flight",sw:"sending"}}}var ec=[{v:"failures",label:"Failures"},{v:"delivered",label:"Delivered"},{v:"all",label:"All"}];function tc(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 nc(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 sc(e,t){let n=e.map(s=>{let r=Xd(s),i=o`<span class="rec-out"><span class="rec-sw sw-${r.sw}"></span>${r.label}</span>`,a=s.error||s.event_detail||"",l=s.event_at?Y(s.event_at):"",c=Ii(s.email)?o`<span class="muted">Erased recipient</span>`:s.email;return o`<tr><td class="rec-email">${c}</td><td>${i}</td><td class="muted">${a||"\u2014"}</td><td class="muted">${l||"\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>${ge("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 oc(e,t,n,s,r){let{send:i,outcomes:a}=t;if(i.status==="canceled"){ic(i,n);return}let l=i.links.archive,c=a.recipients,u=i.completed_at??i.fire_at;h(n,o`
|
|
231
245
|
<div class="editor-head">
|
|
232
246
|
<a href="#/sent" class="back">← Sent</a>
|
|
233
|
-
${
|
|
247
|
+
${l?o`<button type="button" class="primary" id="viewPublished">View published post ↗</button>`:null}
|
|
234
248
|
</div>
|
|
235
249
|
<div class="card rec-card">
|
|
236
250
|
<div class="rec-head">
|
|
237
|
-
<div class="watch-title"><h1>${
|
|
238
|
-
<div class="rec-meta">Sent ${Y(u)} · ${
|
|
251
|
+
<div class="watch-title"><h1>${i.subject||o`<em>untitled</em>`}</h1><span id="recPill">${Jt(i.phase)}</span></div>
|
|
252
|
+
<div class="rec-meta">Sent ${Y(u)} · ${c.toLocaleString()} recipients</div>
|
|
239
253
|
</div>
|
|
240
254
|
<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">${
|
|
242
|
-
<p class="rec-recon muted">${
|
|
255
|
+
<div class="rec-tiles">${Oi(a)}</div>
|
|
256
|
+
<p class="rec-recon muted">${Ui(a)}</p>
|
|
243
257
|
<div class="rec-actions">
|
|
244
258
|
<button type="button" class="ghost" id="csvBtn">Export recipients (CSV)</button>
|
|
245
259
|
</div>
|
|
@@ -247,7 +261,7 @@
|
|
|
247
261
|
<div class="rec-people-head">
|
|
248
262
|
<h2 class="rec-people-title">Recipients</h2>
|
|
249
263
|
<div class="rec-views" role="group" aria-label="Which recipients to show">
|
|
250
|
-
${
|
|
264
|
+
${ec.map((T,I)=>o`<button type="button" class="rec-view-btn" data-view="${T.v}" aria-pressed="${I===0?"true":"false"}">${T.label}</button>`)}
|
|
251
265
|
</div>
|
|
252
266
|
</div>
|
|
253
267
|
<input class="rec-people-search" type="search" placeholder="Search email…" aria-label="Search recipients by email" autocomplete="off">
|
|
@@ -255,30 +269,30 @@
|
|
|
255
269
|
<div id="recPager"></div>
|
|
256
270
|
</div>
|
|
257
271
|
<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
|
|
272
|
+
</div>`);let m=document.getElementById("viewPublished");m&&l&&(m.onclick=()=>window.open(l,"_blank","noopener"));let p={view:"failures",search:"",sort:"email",dir:"asc",offset:0,limit:50},v=d("#recRows",n),E=d("#recPager",n),L=0,$=!1,R=async T=>{let I=++L;T||($=!0);try{let S=await C(`/api/sends/${e}/deliveries?${nc(p)}`,{signal:s});if(I!==L)return;let P=S.page.total>0?Math.floor((S.page.total-1)/S.page.limit)*S.page.limit:0;if(!S.deliveries.length&&p.offset>P)return p.offset=P,R(T);$=!1,g(()=>{if(!S.deliveries.length){h(v,o`<p class="rec-people-empty muted">${tc(p)}</p>`),h(E,o``);return}h(v,sc(S.deliveries,p)),Ke(v,p,N),Ge(E,p,S.page,N)})}catch(S){I===L&&(!T||$)&&($=!1,oe(v,Js(S),N))}},N=()=>R(!1),g=T=>{let I=document.activeElement,S=I instanceof HTMLElement&&(v.contains(I)||E.contains(I)),P=S?I.dataset.sort:void 0,y=S?["pager-prev","pager-next"].find(w=>I.classList.contains(w)):void 0;T();let A=P?v.querySelector(`.th-sort[data-sort="${P}"]`):y?E.querySelector(`.${y}`):null;A&&!A.disabled&&A.focus()},M=H(".rec-view-btn",n);for(let T of M)T.onclick=()=>{let I=T.dataset.view;(I==="failures"||I==="delivered"||I==="all")&&(p.view=I),p.offset=0;for(let S of M)S.setAttribute("aria-pressed",String(S===T));N()};let f=n.querySelector(".rec-people-search");if(f){let T;f.oninput=()=>{clearTimeout(T),T=setTimeout(()=>{p.search=f.value,p.offset=0,N()},250)}}N();let _=d("#csvBtn");_.onclick=()=>J(_,"Exporting\u2026",async()=>{try{let T=await hn(i.links.deliveries_csv);lt(T.text,T.filename??"deliveries.csv","text/csv")}catch(T){O(T instanceof ee&&T.status===404?"This send no longer exists, so there are no deliveries to export.":Js(T))}});let k=a;Wt(t,{update(T){if(T.status==="sending"){r();return}h(d("#recPill",n),Jt(T.phase));let I=rc(T);Qd(k,I)||(k=I,h(d(".rec-tiles",n),Oi(I)),d(".rec-recon",n).textContent=Ui(I),R(!0))},removed:r,stale:r},s)}function rc(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 ic(e,t){h(t,o`
|
|
259
273
|
<div class="editor-head">
|
|
260
274
|
<a href="#/sent" class="back">← Sent</a>
|
|
261
275
|
</div>
|
|
262
276
|
<div class="card rec-card">
|
|
263
277
|
<div class="rec-head">
|
|
264
|
-
<div class="watch-title"><h1>${e.subject||o`<em>untitled</em>`}</h1><span id="recPill">${
|
|
278
|
+
<div class="watch-title"><h1>${e.subject||o`<em>untitled</em>`}</h1><span id="recPill">${Jt(e.phase)}</span></div>
|
|
265
279
|
<div class="rec-meta">Canceled ${Y(e.completed_at)} · was scheduled for ${Y(e.fire_at)}</div>
|
|
266
280
|
</div>
|
|
267
281
|
<p class="rec-note muted">This send was canceled before it fired, so no one was mailed.</p>
|
|
268
|
-
</div>`)}function
|
|
282
|
+
</div>`)}function Xs(e){let t=document.getElementById(e);if(!t)throw new Error(`the admin page has no #${e}`);return t}var Xn=(()=>{let e=Xs("savebar"),t=e.querySelector(".savebar-msg"),n=Xs("savebarSave"),s=Xs("savebarDiscard"),r=0,i=u=>{t&&h(t,o`<span class="savebar-dot"></span><span>${u}</span>`)},a=()=>{e.hidden=!1,e.offsetHeight,e.classList.add("show")};function l(){r++,e.classList.remove("show","is-error"),e.hidden=!0,n.onclick=null,s.onclick=null,document.body.classList.remove("has-savebar")}function c({onSave:u,onDiscard:m,saveLabel:p="Save changes",discardLabel:v="Discard"},E){if(E.aborted)return{setDirty(){},showError(){}};let L=++r;E.addEventListener("abort",()=>{r===L&&l()},{once:!0}),n.textContent=p,s.textContent=v,e.classList.remove("show","is-error"),i("You have unsaved changes."),e.hidden=!0,document.body.classList.add("has-savebar"),n.onclick=()=>J(n,"Saving\u2026",()=>Promise.resolve(u())),s.onclick=()=>m();let $=()=>r===L;return{setDirty(R){$()&&(e.classList.remove("is-error"),R?(i("You have unsaved changes."),a()):e.classList.remove("show"))},showError(R){$()&&(e.classList.add("is-error"),i(R),a())}}}return{attach:c}})();var Ze=K,Qt=o` hidden`;function ye(e,t){return o`<span class="set-chip ${e}">${B(e)}${t}</span>`}function we(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 Xt(e,t){return(String(e||t).trim()[0]||"K").toUpperCase()}function ji(e){let{state:t,fromName:n}=e;return o`
|
|
269
283
|
<section class="set-sec">
|
|
270
|
-
${
|
|
284
|
+
${we("Confirmation email",ye("editable","Editable"))}
|
|
271
285
|
<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>
|
|
272
286
|
<div class="set-preview">
|
|
273
287
|
<div class="set-preview-bar set-ce-bar">
|
|
274
288
|
<div class="set-ce-modetog" role="group" aria-label="Confirmation email view">
|
|
275
|
-
<button type="button" class="set-ce-modebtn" id="ceTabPreview" aria-pressed="true">${
|
|
276
|
-
<button type="button" class="set-ce-modebtn" id="ceTabEdit" aria-pressed="false">${
|
|
289
|
+
<button type="button" class="set-ce-modebtn" id="ceTabPreview" aria-pressed="true">${B("preview")}Preview</button>
|
|
290
|
+
<button type="button" class="set-ce-modebtn" id="ceTabEdit" aria-pressed="false">${B("editable")}Edit</button>
|
|
277
291
|
</div>
|
|
278
292
|
</div>
|
|
279
293
|
<div id="cePreviewBody">
|
|
280
294
|
<div class="set-inbox">
|
|
281
|
-
<div class="set-inbox-avatar" id="cePvAvatar">${
|
|
295
|
+
<div class="set-inbox-avatar" id="cePvAvatar">${Xt(t.name,n)}</div>
|
|
282
296
|
<div class="set-inbox-body">
|
|
283
297
|
<div class="set-inbox-top"><span class="set-inbox-from" id="cePvFrom">${t.name||n}</span><span class="set-inbox-time">now</span></div>
|
|
284
298
|
<div class="set-inbox-subj" id="cePvSubject"></div>
|
|
@@ -327,54 +341,102 @@
|
|
|
327
341
|
</div>
|
|
328
342
|
</div>
|
|
329
343
|
</div>
|
|
330
|
-
</section>`}function
|
|
344
|
+
</section>`}function Wi(e,t){let{state:n}=t,s=t.data.settings.confirmationEmailDefault,r={subject:d("#ceSubject",e),message:d("#ceMessage",e),button:d("#ceButton",e),footer:d("#ceFooter",e)},i={avatar:d("#cePvAvatar",e),from:d("#cePvFrom",e),subject:d("#cePvSubject",e),body:d("#cePvBody",e),button:d("#cePvButton",e),foot:d("#cePvFoot",e),mast:d("#cePvMast",e),mastLogo:d("#cePvMastLogo",e),mastName:d("#cePvMastName",e),mastTag:d("#cePvMastTag",e)},a=()=>{let E=n.confirmation;i.avatar.textContent=Xt(n.name,t.fromName),i.from.textContent=n.name||t.fromName,i.subject.textContent=E.subject.trim()||s.subject||"",i.body.textContent=E.body.trim()||s.body||"",i.button.textContent=E.buttonLabel.trim()||s.buttonLabel||"Confirm",i.foot.textContent=E.reassurance,i.foot.hidden=!E.reassurance.trim();let L=n.name||t.fromDisplay,$=!!n.logoUrl,R=$||!!L;i.mast.hidden=!R,R&&(i.mastLogo.hidden=!$,$&&h(i.mastLogo,o`<img src="${n.logoUrl}" alt="">`),i.mastName.textContent=L,i.mastName.hidden=!L,i.mastTag.textContent=n.tagline,i.mastTag.hidden=!n.tagline)},l=()=>{n.confirmation.subject=r.subject.value,n.confirmation.body=r.message.value,n.confirmation.buttonLabel=r.button.value,n.confirmation.reassurance=r.footer.value,t.changed()};for(let E of Object.values(r))E.addEventListener("input",l);let c=d("#ceEditBody",e),u=d("#cePreviewBody",e),m=d("#ceTabEdit",e),p=d("#ceTabPreview",e),v=E=>{c.hidden=!E,u.hidden=E,m.setAttribute("aria-pressed",String(E)),p.setAttribute("aria-pressed",String(!E)),E&&r.subject.focus()};return p.onclick=()=>v(!1),m.onclick=()=>v(!0),d("#ceToPreview",e).onclick=()=>v(!1),d("#ceReset",e).onclick=()=>{r.subject.value=s.subject||"",r.message.value=s.body||"",r.button.value=s.buttonLabel||"",r.footer.value=s.reassurance||"",l()},a(),{repaint:a,reset(){r.subject.value=n.confirmation.subject,r.message.value=n.confirmation.body,r.button.value=n.confirmation.buttonLabel,r.footer.value=n.confirmation.reassurance,a()}}}var ac={none:"No email provider connected",fake:"Fake (dev, dead-end)",ses:"Amazon SES"};function es(e){return ac[e]||e}function lc(e){return e.provider==="none"?o`${es(e.provider)}. ${_e("set-link")} to start sending.`:o`${es(e.provider)}${e.simulation?o`<span class="set-pill">Simulating ${dc[e.simulation.profile]}${e.simulation.faults==="none"?", no failures":""}</span>`:null}`}var dc={generic:"a generic provider",ses:"Amazon SES"},cc="#/docs/ses/3-receive-replies-at-your-from-address";function uc(e){let t=String(e||"").match(/<([^>]+)>/);return t?.[1]?t[1]:String(e||"")}function Vi(e){let t=e.data.deployment,{fromName:n}=e;return o`
|
|
331
345
|
<section class="set-sec">
|
|
332
|
-
${
|
|
346
|
+
${we("Email sender",ye("readonly","Read-only \xB7 set at deploy"))}
|
|
333
347
|
<div class="set-card sunken">
|
|
334
|
-
<div class="set-ro-note">${
|
|
348
|
+
<div class="set-ro-note">${B("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/self-hosting">setup guide</a>, then redeploy. Credentials are never shown.</span></div>
|
|
335
349
|
<div class="set-preview-bar" style="background:transparent">
|
|
336
350
|
<span class="set-preview-lbl">Inbox preview</span>
|
|
337
351
|
<span class="set-preview-dot">How readers see the sender</span>
|
|
338
352
|
</div>
|
|
339
353
|
<div class="set-inbox">
|
|
340
|
-
<div class="set-inbox-avatar">${
|
|
354
|
+
<div class="set-inbox-avatar">${Xt(n,n)}</div>
|
|
341
355
|
<div class="set-inbox-body">
|
|
342
356
|
<div class="set-inbox-top"><span class="set-inbox-from">${n}</span><span class="set-inbox-time">9:02 AM</span></div>
|
|
343
357
|
<div class="set-inbox-subj">Your latest post: a sample subject line</div>
|
|
344
358
|
<div class="set-inbox-snip">The opening lines of your post show here as the inbox preview…</div>
|
|
345
|
-
<div class="set-inbox-addr">${
|
|
359
|
+
<div class="set-inbox-addr">${uc(t.fromAddress)}</div>
|
|
346
360
|
</div>
|
|
347
361
|
</div>
|
|
348
362
|
<div class="set-kv" style="border-top:1px solid var(--line)">
|
|
349
|
-
<div class="set-kv-k">From address</div><div class="set-kv-v"><span class="mono">${t.fromAddress}</span></div>
|
|
363
|
+
<div class="set-kv-k">From address</div><div class="set-kv-v set-kv-stack"><span class="mono">${t.fromAddress}</span><span class="muted" id="replyNote">Readers’ replies go to this address. <a class="set-link" href="${cc}">Forward it to your inbox</a>.</span></div>
|
|
350
364
|
<div class="set-kv-k">Sending domain</div><div class="set-kv-v"><span class="mono">${t.sendingDomain}</span></div>
|
|
351
|
-
<div class="set-kv-k">Email provider</div><div class="set-kv-v" id="providerValue">${
|
|
365
|
+
<div class="set-kv-k">Email provider</div><div class="set-kv-v" id="providerValue">${lc(t)}</div>
|
|
352
366
|
</div>
|
|
353
367
|
</div>
|
|
354
|
-
</section>`}function
|
|
368
|
+
</section>`}function Ki(e){let t=e.data.deployment,n=`${t.archiveOrigin||""}${t.archiveBasePath||""}`,s=t.archiveOrigin===t.appOrigin;return o`
|
|
355
369
|
<section class="set-sec">
|
|
356
|
-
${
|
|
370
|
+
${we("Instance",ye("readonly","Read-only \xB7 set at deploy"))}
|
|
357
371
|
<div class="set-card sunken">
|
|
358
|
-
<div class="set-ro-note">${
|
|
372
|
+
<div class="set-ro-note">${B("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>
|
|
359
373
|
<div class="set-kv">
|
|
360
374
|
<div class="set-kv-k">App origin</div><div class="set-kv-v"><span class="mono">${t.appOrigin}</span></div>
|
|
361
375
|
<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>
|
|
362
376
|
<div class="set-kv-k">Image URL base</div><div class="set-kv-v"><span class="mono">${t.mediaPublicBase}</span></div>
|
|
363
377
|
<div class="set-kv-k">Auth mode</div><div class="set-kv-v">${t.authMode==="access"?"Cloudflare Access":"Local dev token"}</div>
|
|
364
|
-
<div class="set-kv-k">Cloudflare Access</div><div class="set-kv-v">${t.accessConfigured?o`<span class="set-pill ok">${
|
|
365
|
-
<div class="set-kv-k">Minimum lead</div><div class="set-kv-v">${
|
|
378
|
+
<div class="set-kv-k">Cloudflare Access</div><div class="set-kv-v">${t.accessConfigured?o`<span class="set-pill ok">${B("check")}Configured</span>`:o`<span class="set-pill">Not configured</span>`}</div>
|
|
379
|
+
<div class="set-kv-k">Minimum lead</div><div class="set-kv-v">${bt(t.minLeadMs)}${t.minLeadMs===vn?o`<span class="set-pill">default</span>`:null}</div>
|
|
366
380
|
</div>
|
|
367
381
|
</div>
|
|
368
|
-
</section>`}function
|
|
369
|
-
<p class="hint">${t.lead(s)} ${t.gerund} applies the change to ${
|
|
370
|
-
<ul class="remake-list">${e.map(
|
|
382
|
+
</section>`}function pc(e){return e instanceof ee&&e.status===409&&e.data?.error==="remake_required"?e.data:null}var mc="Not saved: the change needed your confirmation";async function dt(e,t,n){let s=null;for(;;)try{return await e(s)}catch(r){let i=pc(r);if(!i)throw r;let a=i.sends||[];if(!await fc(a,t,n))return n.aborted&&O(mc),null;s=a.map(c=>c.id)}}function fc(e,t,n){let s=e.length,r=s===1,i=`${s} scheduled email${r?"":"s"}`;return new Promise(a=>{let l=me(o`<h3>Apply this change to ${i}?</h3>
|
|
383
|
+
<p class="hint">${t.lead(s)} ${t.gerund} applies the change to ${r?"its email":"their emails"} too.</p>
|
|
384
|
+
<ul class="remake-list">${e.map(E=>o`<li><span>${E.subject||o`<em>untitled</em>`}</span><span>sends ${Y(E.fire_at)}</span></li>`)}</ul>
|
|
371
385
|
<dl class="remake-facts">
|
|
372
|
-
<div><dt>Changes</dt><dd>the look of ${
|
|
373
|
-
<div><dt>Stays</dt><dd>${
|
|
374
|
-
<div><dt>Next</dt><dd>send yourself a fresh test${
|
|
386
|
+
<div><dt>Changes</dt><dd>the look of ${r?"its email":"their emails"}</dd></div>
|
|
387
|
+
<div><dt>Stays</dt><dd>${r?"its content and fire time":"their content and fire times"}</dd></div>
|
|
388
|
+
<div><dt>Next</dt><dd>send yourself a fresh test${r?"":" of each"}</dd></div>
|
|
375
389
|
</dl>
|
|
376
390
|
<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(!
|
|
391
|
+
<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(!l.el.isConnected){a(!1);return}let c=!1,u=E=>{c||(c=!0,a(E))},m=l.el.querySelector("#rmCancel"),p=l.el.querySelector("#rmGo");m&&(m.onclick=()=>{l.close(),u(!1)}),p&&(p.onclick=()=>{l.close(),u(!0)});let v=new MutationObserver(()=>{l.el.isConnected||(v.disconnect(),u(!1))});v.observe(document.body,{childList:!0}),p?.focus()})}var Gi={action:"Save",gerund:"Saving",lead:e=>`This template is used by ${e} post${e===1?" that\u2019s":"s that are"} already scheduled.`},en=(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 ts(e){let t=e?.sends||[];if(!t.length)return o`<span class="set-chip">${B("info")}No posts scheduled</span>`;let n=t.length;return o`<span class="set-chip inuse"><span class="set-chip-dot"></span>In use by ${n} scheduled post${n===1?"":"s"}</span>`}function hc(e,t=Date.now()){let n=e?.retry_after;if(!e||!n||t>=n)return"";let s=Math.max(1,e.sends.filter(r=>r.fire_at<=n).length);return s===1?`A scheduled post sends at ${Y(n)}. You can save once it has sent.`:`${s} scheduled posts send by ${Y(n)}. You can save once they have sent.`}function At(e){let t=hc(e);return t?o`<span data-remake-wait> ${t}</span>`:null}function ns(e,t,n){let s=t?.retry_after;s&&et(s,()=>{for(let r of e.querySelectorAll("[data-remake-wait]"))r.remove()},n)}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 Yi=e=>e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");function zi(e,t){return e?`<img class="logo" src="${Yi(e)}" alt="${Yi(t)}" width="44" height="44" />`:""}var Zi="You\u2019ve added a mailing address in Settings, but this template doesn\u2019t include the {{ .Publication.Address }} field. If you send promotional email, add it to the footer. If you\u2019ve written your address into the template yourself, or don\u2019t need to provide one, you can ignore this or remove the address in Settings.";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 eo=class extends Error{constructor(){super("Couldn\u2019t read that image. Pick a PNG, JPEG, WebP, or GIF.")}};async function Ji(e,t){let n=await createImageBitmap(e,{imageOrientation:"from-image"}).catch(()=>{throw new eo});try{let{x:s,y:r,side:i}=bc(n.width,n.height),a=document.createElement("canvas");a.width=t,a.height=t;let l=a.getContext("2d");if(!l)throw new Error("This browser can\u2019t prepare the image.");return l.imageSmoothingQuality="high",l.drawImage(n,s,r,i,i,0,0,t,t),{png:await new Promise((u,m)=>a.toBlob(p=>p?u(p):m(new Error("This browser can\u2019t prepare the image.")),"image/png")),width:n.width,height:n.height}}finally{n.close()}}function gc(e){if(!(e.identityFields||[]).length)return o`Your email template doesn’t show any of these.`;let t="Shown in every email.",n=e.sends.length;if(!n)return o`${t}`;let s=`Saving a change updates ${n} scheduled email${n===1?"":"s"} too; you\u2019ll confirm first.`;return o`${t} ${s}${At(e)}`}var vc="#/docs/name-your-publication/when-to-provide-a-mailing-address";function tn(){return o`<a class="set-link" href="${vc}" target="_blank" rel="noopener">When to provide a mailing address <span aria-hidden="true">↗</span><span class="visually-hidden"> (opens in a new tab)</span></a>`}var to=256;function Qi(e){let{state:t,fromDisplay:n,fromName:s}=e,r=!!t.address;return o`
|
|
392
|
+
<section class="set-sec">
|
|
393
|
+
${we("Publication identity",ye("editable","Editable"))}
|
|
394
|
+
<div class="set-card">
|
|
395
|
+
<div class="set-id-grid">
|
|
396
|
+
<div class="set-logo-slot">
|
|
397
|
+
<div class="set-logo-tile${t.logoUrl?" has-img":""}" id="logoTile" role="button" tabindex="0" aria-label="Upload logo">
|
|
398
|
+
<span class="set-logo-ph" id="logoPh"${t.logoUrl?Qt:null}>${B("upload")}Upload</span>
|
|
399
|
+
</div>
|
|
400
|
+
<input type="file" id="logoInput" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
|
401
|
+
<div class="set-logo-actions">
|
|
402
|
+
<button type="button" id="logoReplace">${t.logoUrl?"Replace":"Upload"}</button>
|
|
403
|
+
<button type="button" class="danger-subtle" id="logoRemove"${t.logoUrl?null:Qt}>Remove</button>
|
|
404
|
+
</div>
|
|
405
|
+
<div class="set-logo-notes">
|
|
406
|
+
<p class="field-hint">Shown as a square. Pick an image at least ${String(to)} × ${String(to)} px; a wider or taller one is cropped to its center square.</p>
|
|
407
|
+
<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>
|
|
408
|
+
<div class="field-error" id="logoError" role="alert" hidden><span class="field-error-ico" aria-hidden="true">!</span><span></span></div>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
<div class="set-id-fields">
|
|
412
|
+
<div class="set-field">
|
|
413
|
+
<label for="setName">Name</label>
|
|
414
|
+
<input id="setName" value="${t.name}" placeholder="${s}" maxlength="120" autocomplete="off">
|
|
415
|
+
<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>
|
|
416
|
+
</div>
|
|
417
|
+
<div class="set-field">
|
|
418
|
+
<label for="setTagline">Tagline</label>
|
|
419
|
+
<input id="setTagline" value="${t.tagline}" placeholder="A one-line description" maxlength="200" autocomplete="off">
|
|
420
|
+
<p class="field-hint">A short line under the name on your public pages.</p>
|
|
421
|
+
</div>
|
|
422
|
+
<div class="set-field set-addr-add" id="addrAdd"${r?Qt:null}>
|
|
423
|
+
<button type="button" class="ghost" id="addrAddBtn" aria-describedby="addrAddHint">${B("plus")}Add a mailing address</button>
|
|
424
|
+
<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. ${tn()}</p>
|
|
425
|
+
</div>
|
|
426
|
+
<div class="set-field" id="addrField"${r?null:Qt}>
|
|
427
|
+
<div class="set-field-head">
|
|
428
|
+
<label for="setAddress">Mailing address</label>
|
|
429
|
+
<button type="button" class="danger-subtle set-field-remove" id="addrRemove">Remove</button>
|
|
430
|
+
</div>
|
|
431
|
+
<input id="setAddress" value="${t.address}" placeholder="PO Box 123, City, ST 00000" maxlength="300" autocomplete="off">
|
|
432
|
+
<p class="field-hint">${tn()}</p>
|
|
433
|
+
<p class="set-field-warn" id="addrWarn" hidden>Your email template currently doesn’t include this field. If you send promotional email, add it to the template. ${tn()}</p>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
<div class="set-note">${B("info")}<span>${gc(e.data.inUse)}</span></div>
|
|
438
|
+
</div>
|
|
439
|
+
</section>`}function Xi(e,t,n){let{state:s}=t,r=d("#setName",e),i=d("#setTagline",e),a=d("#setAddress",e),l=()=>{s.name=r.value.trim(),s.tagline=i.value.trim(),s.address=a.value.trim(),t.changed()};r.addEventListener("input",l),i.addEventListener("input",l),a.addEventListener("input",l);let c=!!s.address,u=(t.data.inUse.identityFields||[]).includes("address"),m=d("#addrAdd",e),p=d("#addrField",e),v=d("#addrWarn",e),E=()=>{m.hidden=c,p.hidden=!c,v.hidden=!(c&&s.address&&!u),v.hidden?a.removeAttribute("aria-describedby"):a.setAttribute("aria-describedby","addrWarn")};a.addEventListener("input",E),d("#addrAddBtn",e).onclick=()=>{c=!0,E(),a.focus()},d("#addrRemove",e).onclick=()=>{a.value="",c=!1,l(),E(),d("#addrAddBtn",e).focus()};let L=d("#logoInput",e),$=d("#logoTile",e),R=d("#logoPh",e),N=d("#logoReplace",e),g=d("#logoRemove",e),M=d("#logoShape",e),f=y=>{if(M.hidden=!0,!y)return;let A=new Image;A.onload=()=>{M.hidden=s.logoUrl!==y||A.naturalWidth===A.naturalHeight},A.src=y},_=()=>{let y=!!s.logoUrl;f(s.logoUrl),$.classList.toggle("has-img",y),$.style.backgroundImage=y?`url('${s.logoUrl}')`:"",R.hidden=y,g.hidden=!y,N.textContent=y?"Replace":"Upload"};N.onclick=()=>L.click(),$.addEventListener("click",()=>L.click()),$.addEventListener("keydown",y=>{(y.key==="Enter"||y.key===" ")&&(y.preventDefault(),L.click())});let k=d("#logoError",e),T=d("span:last-of-type",k),I=y=>{T.textContent=y,k.hidden=!y},S=(y,A)=>{s.logoUrl=y.settings.publication.logoUrl||"",t.applySaved(y.settings),_(),t.changed(),O(Ht(A,y.remade))},P=y=>I(Ze(y));return L.onchange=async()=>{let y=L.files?.[0];if(L.value="",!!y){I("");try{let A=await Ji(y,to),w=A.width!==A.height,F=await dt(te=>{let ce=new FormData;ce.append("file",new File([A.png],"logo.png",{type:"image/png"}));let q=te?`?remake=${encodeURIComponent(te.join(","))}`:"";return C(`/api/settings/logo${q}`,{method:"POST",body:ce})},en("Upload","Uploading"),n);F&&S(F,w?"Logo updated, cropped to its center square":"Logo updated")}catch(A){P(A)}}},g.onclick=()=>J(g,"Removing\u2026",async()=>{I("");try{let y=await dt(A=>{let w=A?`?remake=${encodeURIComponent(A.join(","))}`:"";return C(`/api/settings/logo${w}`,{method:"DELETE"})},en("Remove","Removing"),n);y&&S(y,"Logo removed")}catch(y){P(y)}}),_(),E(),{reset(){r.value=s.name,i.value=s.tagline,a.value=s.address,c=!!s.address,E()}}}var ss=K,yc=[{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."}]}],no={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
440
|
.email {
|
|
379
441
|
font: 16px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
380
442
|
color: #18181b;
|
|
@@ -529,7 +591,7 @@
|
|
|
529
591
|
<a href="{{ .Email.ViewInBrowserURL }}">View in browser</a>
|
|
530
592
|
<div class="address">{{ .Publication.Address }}</div>{{ end }}
|
|
531
593
|
</div>
|
|
532
|
-
</div>`}},
|
|
594
|
+
</div>`}},wc=e=>Object.hasOwn(no,e),Sc=`<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>`,$c=new Set([".Post.Body",".Publication.Logo"]);function xc(e,t){return String(e).replace(/\{\{\s*(?:if\b[^}]*|end)\s*\}\}/g,"").replace(/\{\{\s*([\w.]+)\s*\}\}/g,(s,r)=>$c.has(r)?t[r]||"":ie(t[r]??""))}function Ec(e){let t=e.name||"Your publication";return{".Post.Body":Sc,".Post.Subject":"The starlings are back",".Publication.Name":t,".Publication.Tagline":e.tagline||"Your tagline",".Publication.LogoURL":e.logoUrl,".Publication.Logo":zi(e.logoUrl,t),".Publication.Address":e.address,".Email.SentTo":"you@example.com",".Email.UnsubscribeURL":"#unsubscribe",".Email.ViewInBrowserURL":"#view-in-browser"}}function kc(e,t){return xc(e,Ec(t))}var Tc='<!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 so(e,t,n){e.setAttribute("sandbox",Rt);let s=!1,r=()=>{try{let a=e.contentDocument?.body?.scrollHeight;a&&(e.style.height=`${Math.max(200,a)}px`)}catch{}},i=()=>{let a=e.contentDocument,l=s&&a?a.querySelector(".kestrel-email"):null;l&&(h(l,W(kc(t(),n()))),Ct(l.ownerDocument),r())};return e.addEventListener("load",()=>{s=!0,i();try{let a=e.contentDocument;a?.body&&typeof ResizeObserver<"u"&&new ResizeObserver(r).observe(a.body)}catch{}}),e.srcdoc=Tc,{repaint:i}}function Lc(){return o`${yc.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 oo(e,t){h(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>.<span id="tplWait"></span></p></div><div id="tplBody" class="muted">Loading…</div></div>`);let n=d("#tplBody",e),s;try{s=await C("/api/settings",{signal:t})}catch(Z){oe(n,ss(Z),()=>Q(oo));return}let r=s.settings,i=s.deployment,a=r.publication,l={name:Ut(a.name||"",i.fromAddress),tagline:a.tagline||"",logoUrl:a.logoUrl||"",address:a.address||""},c=r.emailTemplate||"",u=Array.isArray(r.testRecipients)?r.testRecipients:[];h(d("#tplInUse"),ts(s.inUse)),h(d("#tplWait"),o`${At(s.inUse)}`),ns(e,s.inUse,t),h(n,o`
|
|
533
595
|
<div class="set-preview set-tpl-sample">
|
|
534
596
|
<div class="set-preview-bar">
|
|
535
597
|
<span class="set-preview-titles">
|
|
@@ -541,7 +603,7 @@
|
|
|
541
603
|
<button type="button" class="wtog-btn" data-w="640" aria-pressed="true">640</button>
|
|
542
604
|
<button type="button" class="wtog-btn" data-w="375" aria-pressed="false">375</button>
|
|
543
605
|
</span>
|
|
544
|
-
<button type="button" class="secondary" id="tplTest">${
|
|
606
|
+
<button type="button" class="secondary" id="tplTest">${B("send")}<span id="tplTestLbl">Send test email</span></button>
|
|
545
607
|
</span>
|
|
546
608
|
</div>
|
|
547
609
|
<div class="set-email-stage" id="tplStage">
|
|
@@ -556,7 +618,7 @@
|
|
|
556
618
|
<div class="set-tpl-editor-head">
|
|
557
619
|
<div class="set-tpl-tools">
|
|
558
620
|
<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>
|
|
559
|
-
<button type="button" class="set-icon-btn" id="tplLineNums" aria-pressed="false" title="Show line numbers" aria-label="Show line numbers">${
|
|
621
|
+
<button type="button" class="set-icon-btn" id="tplLineNums" aria-pressed="false" title="Show line numbers" aria-label="Show line numbers">${B("lines")}</button>
|
|
560
622
|
<button type="button" class="ghost" id="tplCopyAll" title="Copy the whole template to the clipboard" aria-label="Copy the whole template">${qe()}</button>
|
|
561
623
|
</div>
|
|
562
624
|
<div class="set-tpl-required" aria-label="Required variables">
|
|
@@ -573,7 +635,7 @@
|
|
|
573
635
|
<div class="set-tpl-msgs" id="tplMsgs" hidden></div>
|
|
574
636
|
</div>
|
|
575
637
|
</div>
|
|
576
|
-
<div class="set-note">${
|
|
638
|
+
<div class="set-note">${B("info")}<span>Kestrel uses this one template for every post, starting from a sensible default. Saving a change while posts are scheduled applies it to their emails too, after you confirm, so nothing scheduled goes out on an older look; their content stays as it was. Sent emails are archived exactly as they went out and never change. Save and Discard are in the bar at the bottom of the page.</span></div>
|
|
577
639
|
</div>
|
|
578
640
|
|
|
579
641
|
<div class="set-card set-tpl-varcard">
|
|
@@ -582,16 +644,16 @@
|
|
|
582
644
|
<h3 class="set-tpl-vartitle">Variables</h3>
|
|
583
645
|
<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>
|
|
584
646
|
</div>
|
|
585
|
-
<div class="set-tpl-vars-body">${
|
|
647
|
+
<div class="set-tpl-vars-body">${Lc()}</div>
|
|
586
648
|
</div>
|
|
587
|
-
</div>`);let
|
|
588
|
-
`).length;for(let
|
|
589
|
-
`;
|
|
590
|
-
`);let
|
|
591
|
-
`),
|
|
649
|
+
</div>`);let m=d("#tplEditor"),p=so(d("#tplPreview"),()=>m.value,()=>l),v=d("#tplMsgs"),E=d("#tplTest"),L=d("#tplTestLbl"),$=()=>m.value!==c,R=d("#tplHl"),N=d("code",R),g=d("#tplGutter"),M=()=>{let Z=m.value;h(N,o`${mi(Z)}\n`);let se="",ae=Z.split(`
|
|
650
|
+
`).length;for(let $e=1;$e<=ae;$e++)se+=`${$e}
|
|
651
|
+
`;g.textContent=se},f=()=>{R.scrollTop=m.scrollTop,R.scrollLeft=m.scrollLeft,g.scrollTop=m.scrollTop};m.addEventListener("scroll",f);let _=d("#reqBody"),k=d("#reqUnsub"),T=()=>{_.className=`set-req-pill ${/\{\{\s*\.Post\.Body\s*\}\}/.test(m.value)?"ok":"bad"}`,k.className=`set-req-pill ${/\{\{\s*\.Email\.UnsubscribeURL\s*\}\}/.test(m.value)?"ok":"bad"}`},I=Xn.attach({onSave:w,onDiscard:y},t);function S(){M(),T(),f(),I.setDirty($()),L.textContent=$()?"Save & send test":"Send test email",E.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 P(Z){if(!Z.length){v.hidden=!0,h(v,o``);return}v.hidden=!1,v.className="set-tpl-msgs warn",h(v,o`${Z.map(se=>o`<div>${se}${se===Zi?o` ${tn()}`:null}</div>`)}`)}function y(){m.value=c,P([]),p.repaint(),S()}async function A(){let Z=m.value,se=await dt(ae=>C("/api/settings",{method:"PUT",json:ae?{emailTemplate:Z,remake:ae}:{emailTemplate:Z}}),Gi,t);return se?(c=se.settings.emailTemplate,m.value=c,X.appConfig={...s,settings:se.settings},p.repaint(),S(),{warnings:Array.isArray(se.warnings)?se.warnings:[],remade:se.remade||[]}):null}async function w(){try{let Z=await A();if(!Z)return;P(Z.warnings),O(Ht(Z.warnings.length?"Template saved with warnings":"Template saved",Z.remade))}catch(Z){I.showError(ss(Z))}}let F=Z=>{let se=no[wc(Z)?Z:"signed"];m.value=se.html,P([]),p.repaint(),S()};m.addEventListener("input",()=>{p.repaint(),S()});let te=d("#tplExamplesBtn");te.onclick=()=>kt(te,Object.entries(no).map(([Z,se])=>({label:se.label,description:se.description,onClick:()=>F(Z)})),t,{align:"start"});let ce=d("#tplPreview");for(let Z of H(".wtog-btn",n))Z.onclick=()=>{for(let se of H(".wtog-btn",n))se.setAttribute("aria-pressed",String(se===Z));ce.style.maxWidth=`${Z.dataset.w}px`,p.repaint()};let q=d("#tplEditorWrap"),ne=d("#tplLineNums"),V=Z=>{q.classList.toggle("show-lines",Z),ne.setAttribute("aria-pressed",String(Z)),f()},re=!1;try{re=localStorage.getItem("kestrel.tpl.lineNums")==="1"}catch{}V(re),ne.onclick=()=>{re=!re,V(re);try{localStorage.setItem("kestrel.tpl.lineNums",re?"1":"0")}catch{}};let De=d("#tplCopyAll");De.onclick=()=>Bs(De,m.value),E.onclick=()=>{let Z=pe(),se=!Z&&$(),ae=me(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>${Ln()}${se?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}${Z?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">${se?"Save & send test":"Send test"}</button></div>`,t),$e=d("#ttGo",ae.el);if(d("#ttCancel",ae.el).onclick=ae.close,$e.disabled=Z,Z){d("#ttCancel",ae.el).focus();return}let Te=d("#tplTestTo",ae.el);if(Te.focus(),u.length&&!Te.value.trim()){Te.value=u.join(`
|
|
652
|
+
`);let xe=d("#tplTestHint",ae.el);xe.textContent="Pre-filled from your default test recipients (Settings).",xe.hidden=!1}$e.onclick=()=>J($e,$()?"Saving\u2026":"Sending\u2026",async()=>{ae.clearError();let{valid:xe,invalid:ct}=Pn(Te.value),Le=Mn(ct);if(Le){ae.error(Le,Te);return}if(!xe.length){ae.error("Enter at least one email address",Te);return}if($())try{let le=await A();if(!le){ae.close();return}P(le.warnings)}catch(le){I.showError(ss(le)),ae.close();return}try{let le=await C("/api/settings/template/test",{method:"POST",json:{to:xe}});if(le.failed.length){Te.value=le.failed.join(`
|
|
653
|
+
`),ae.error(In(le.sent,le.total,le.failed));return}ae.close(),O(gt(`Test sent to ${le.sent} address${le.sent===1?"":"es"}`))}catch(le){ae.error(ss(le))}})},m.value=c,p.repaint(),S()}function ea(e){return o`
|
|
592
654
|
<section class="set-sec">
|
|
593
|
-
${
|
|
594
|
-
<p class="set-lede">The template controls the look and feel of the emails you send. Edit it on the Template page
|
|
655
|
+
${we("Email template",ye("editable","Editable"),ts(e.data.inUse))}
|
|
656
|
+
<p class="set-lede">The template controls the look and feel of the emails you send. Edit it on the Template page.${At(e.data.inUse)}</p>
|
|
595
657
|
<div class="set-preview set-tpl-sample">
|
|
596
658
|
<div class="set-preview-bar">
|
|
597
659
|
<span class="set-preview-lbl">Sample email</span>
|
|
@@ -600,12 +662,12 @@
|
|
|
600
662
|
<iframe class="set-email-frame" id="tplPreview" title="Sample email preview" scrolling="no"></iframe>
|
|
601
663
|
</div>
|
|
602
664
|
<div class="row" style="margin-top:12px"><button type="button" id="tplEditLink">Edit template →</button></div>
|
|
603
|
-
</section>`}function
|
|
665
|
+
</section>`}function ta(e,t){let n=t.data.settings.emailTemplate||"",s=so(d("#tplPreview",e),()=>n,()=>({name:t.state.name||t.fromDisplay,tagline:t.state.tagline,logoUrl:t.state.logoUrl,address:t.state.address}));return d("#tplEditLink",e).onclick=()=>{location.hash="#/template"},{repaint:()=>s.repaint()}}function na(){return o`
|
|
604
666
|
<section class="set-sec">
|
|
605
|
-
${
|
|
667
|
+
${we("Default test recipients",ye("editable","Editable"))}
|
|
606
668
|
<div class="set-card">
|
|
607
669
|
<div class="set-recip">
|
|
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
|
|
670
|
+
<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-and-confirm flow.</p>
|
|
609
671
|
<div class="set-chips" id="recipChips"></div>
|
|
610
672
|
<div class="set-recip-add">
|
|
611
673
|
<input type="email" id="recipInput" placeholder="you@example.com" autocomplete="off">
|
|
@@ -613,56 +675,9 @@
|
|
|
613
675
|
</div>
|
|
614
676
|
</div>
|
|
615
677
|
</div>
|
|
616
|
-
</section>`}function
|
|
678
|
+
</section>`}function sa(e,t){let{state:n}=t,s=d("#recipChips",e),r=d("#recipInput",e),i=()=>{if(!n.recipients.length){h(s,o`<span class="set-recip-empty">No default recipients yet. Add one below.</span>`);return}h(s,o`${n.recipients.map((l,c)=>o`<span class="set-recip-chip">${l}<button type="button" data-rm="${c}" aria-label="Remove ${l}">${B("x")}</button></span>`)}`)};s.addEventListener("click",l=>{let c=l.target instanceof Element?l.target.closest("[data-rm]"):null;c&&(n.recipients.splice(Number(c.dataset.rm),1),i(),t.changed())});let a=()=>{let l=rt(r.value);if(l){if(!it(l)){Pe(r,"That doesn\u2019t look like an email address."),r.focus();return}if(n.recipients.includes(l)){Pe(r,"That inbox is already in the list."),r.focus();return}n.recipients.push(l),r.value="",i(),t.changed()}};return d("#recipAdd",e).onclick=a,r.addEventListener("keydown",l=>{l.key==="Enter"&&(l.preventDefault(),a())}),i(),{reset:i}}function _c(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 (${es(t)})`;default:return"Local stand-in: nothing is delivered"}}var Rc={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 oa(e){return e.kind==="test"?"test notification":`${e.subject} (${Rc[e.kind]})`}function ro(e){let{lastSent:t,lastFailure:n}=e;return n?o`<span class="set-pill danger">Not delivered</span> <span class="muted">${Y(n.at)}, ${oa(n)}: ${n.error}</span>`:t?o`<span class="set-pill ok">${B("check")}Delivered</span> <span class="muted">${Y(t.at)}, ${oa(t)}</span>`:o`<span class="muted">None yet</span>`}function ra(e){let t=e.data.deployment;return o`
|
|
617
679
|
<section class="set-sec">
|
|
618
|
-
${
|
|
619
|
-
<div class="set-card">
|
|
620
|
-
<div class="set-id-grid">
|
|
621
|
-
<div class="set-logo-slot">
|
|
622
|
-
<div class="set-logo-tile${t.logoUrl?" has-img":""}" id="logoTile" role="button" tabindex="0" aria-label="Upload logo">
|
|
623
|
-
<span class="set-logo-ph" id="logoPh"${t.logoUrl?Wt:null}>${P("upload")}Upload</span>
|
|
624
|
-
</div>
|
|
625
|
-
<input type="file" id="logoInput" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
|
626
|
-
<div class="set-logo-actions">
|
|
627
|
-
<button type="button" id="logoReplace">${t.logoUrl?"Replace":"Upload"}</button>
|
|
628
|
-
<button type="button" class="danger-subtle" id="logoRemove"${t.logoUrl?null:Wt}>Remove</button>
|
|
629
|
-
</div>
|
|
630
|
-
<div class="set-logo-notes">
|
|
631
|
-
<p class="field-hint">Shown as a square. Pick an image at least ${String(Fs)} × ${String(Fs)} px; a wider or taller one is cropped to its center square.</p>
|
|
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>
|
|
633
|
-
<div class="field-error" id="logoError" role="alert" hidden><span class="field-error-ico" aria-hidden="true">!</span><span></span></div>
|
|
634
|
-
</div>
|
|
635
|
-
</div>
|
|
636
|
-
<div class="set-id-fields">
|
|
637
|
-
<div class="set-field">
|
|
638
|
-
<label for="setName">Name</label>
|
|
639
|
-
<input id="setName" value="${t.name}" placeholder="${s}" maxlength="120" autocomplete="off">
|
|
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>
|
|
641
|
-
</div>
|
|
642
|
-
<div class="set-field">
|
|
643
|
-
<label for="setTagline">Tagline</label>
|
|
644
|
-
<input id="setTagline" value="${t.tagline}" placeholder="A one-line description" maxlength="200" autocomplete="off">
|
|
645
|
-
<p class="field-hint">A short line under the name on your public pages.</p>
|
|
646
|
-
</div>
|
|
647
|
-
<div class="set-field set-addr-add" id="addrAdd"${i?Wt:null}>
|
|
648
|
-
<button type="button" class="ghost" id="addrAddBtn" aria-describedby="addrAddHint">${P("plus")}Add a mailing address</button>
|
|
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>
|
|
650
|
-
</div>
|
|
651
|
-
<div class="set-field" id="addrField"${i?null:Wt}>
|
|
652
|
-
<div class="set-field-head">
|
|
653
|
-
<label for="setAddress">Mailing address</label>
|
|
654
|
-
<button type="button" class="danger-subtle set-field-remove" id="addrRemove">Remove</button>
|
|
655
|
-
</div>
|
|
656
|
-
<input id="setAddress" value="${t.address}" placeholder="PO Box 123, City, ST 00000" maxlength="300" autocomplete="off">
|
|
657
|
-
<p class="set-field-warn" id="addrWarn" hidden>Your email template currently doesn’t include this field. If you send promotional email, add it to the template.</p>
|
|
658
|
-
</div>
|
|
659
|
-
</div>
|
|
660
|
-
</div>
|
|
661
|
-
<div class="set-note">${P("info")}<span>${uc(e.data.inUse)}</span></div>
|
|
662
|
-
</div>
|
|
663
|
-
</section>`}function hr(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},j=()=>{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 G=c("#logoError",e),C=c("span:last-of-type",G),B=g=>{C.textContent=g,G.hidden=!g},N=(g,S)=>{s.logoUrl=g.settings.publication.logoUrl||"",t.applySaved(g.settings),j(),t.changed(),I(Ht(S,g.remade))},b=g=>B(Ge(g));return E.onchange=async()=>{let g=E.files?.[0];if(E.value="",!!g){B("");try{let S=await mr(g,Fs),q=S.width!==S.height,X=await ot(ce=>{let we=new FormData;we.append("file",new File([S.png],"logo.png",{type:"image/png"}));let Q=ce?`?remake=${encodeURIComponent(ce.join(","))}`:"";return L(`/api/settings/logo${Q}`,{method:"POST",body:we})},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()=>{B("");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)}}),j(),k(),{reset(){i.value=s.name,r.value=s.tagline,l.value=s.address,d=!!s.address,k()}}}function pc(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 (${Un(t)})`;default:return"Local stand-in: nothing is delivered"}}var mc={finished:"went out",refused:"provider refusing",stuck:"in flight too long",wedged:"waiting to be resolved",missed:"missed fire time",test:"test"};function vr(e){return e.kind==="test"?"test notification":`${e.subject} (${mc[e.kind]})`}function js(e){let{lastSent:t,lastFailure:n}=e;return n?o`<span class="set-pill danger">Not delivered</span> <span class="muted">${Y(n.at)}, ${vr(n)}: ${n.error}</span>`:t?o`<span class="set-pill ok">${P("check")}Delivered</span> <span class="muted">${Y(t.at)}, ${vr(t)}</span>`:o`<span class="muted">None yet</span>`}function gr(e){let t=e.data.deployment;return o`
|
|
664
|
-
<section class="set-sec">
|
|
665
|
-
${fe("Notifications",me("editable","Editable"))}
|
|
680
|
+
${we("Notifications",ye("editable","Editable"))}
|
|
666
681
|
<p class="set-lede">Kestrel emails you when a send goes out, and right away if a send runs into a problem.</p>
|
|
667
682
|
<div class="set-card">
|
|
668
683
|
<div class="set-recip">
|
|
@@ -674,27 +689,27 @@
|
|
|
674
689
|
<div class="row"><button type="button" class="ghost" id="notifyTest">Send a test notification</button><span class="field-hint" id="notifyTestHint"></span></div>
|
|
675
690
|
</div>
|
|
676
691
|
<div class="set-kv" style="border-top:1px solid var(--line)">
|
|
677
|
-
<div class="set-kv-k">Sent through</div><div class="set-kv-v">${
|
|
692
|
+
<div class="set-kv-k">Sent through</div><div class="set-kv-v">${_c(t.notifyChannel,t.provider)}</div>
|
|
678
693
|
<div class="set-kv-k">From address</div><div class="set-kv-v"><span class="mono">${t.notifyFrom}</span></div>
|
|
679
|
-
<div class="set-kv-k">Last notification</div><div class="set-kv-v" id="notifyStatus">${
|
|
694
|
+
<div class="set-kv-k">Last notification</div><div class="set-kv-v" id="notifyStatus">${ro(e.data.notificationStatus)}</div>
|
|
680
695
|
</div>
|
|
681
|
-
<div class="set-note">${
|
|
696
|
+
<div class="set-note">${B("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>
|
|
682
697
|
</div>
|
|
683
|
-
</section>`}function
|
|
684
|
-
<h3 class="set-rp-jhead" id="rpJourney-${e.id}">${
|
|
698
|
+
</section>`}function ia(e,t){let n=d("#notifyTo",e),s=d("#notifyTest",e),r=d("#notifyTestHint",e),i=d("#notifyStatus",e);n.addEventListener("input",()=>{t.state.notifyTo=n.value.trim().toLowerCase(),t.changed()});let a=()=>{let l=t.saved().notifyTo,c=l?t.state.notifyTo!==l?"Save first: the test goes to the saved address.":"":"Save an address to send a test.";s.disabled=!!c,r.textContent=c};return s.onclick=()=>J(s,"Sending\u2026",async()=>{try{let l=await C("/api/settings/notifications/test",{method:"POST"});O(l.channel==="fake"?`Test notification recorded for ${l.to} (local stand-in: nothing is delivered)`:`Test notification sent to ${l.to}`),h(i,ro({lastSent:{kind:"test",subject:"",at:Date.now()},lastFailure:null}))}catch(l){h(i,ro({lastSent:null,lastFailure:{kind:"test",subject:"",at:Date.now(),error:Ze(l)}}))}}),a(),{repaint:a,reset(){n.value=t.state.notifyTo,a()}}}var io={base:"app",path:"/subscribe"},os={base:"app",path:"/confirm?token=\u2026"},ao={base:"app",path:"/unsubscribe?token=\u2026"},nn=[{id:"subscribe",label:"Subscribe",flow:"steps",states:[{id:"form",label:"Form",at:io},{id:"check-inbox",label:"Check your inbox",at:io},{id:"not-an-email",label:"Not an email",offPath:!0,at:io}]},{id:"confirm",label:"Confirm",flow:"steps",states:[{id:"ready",label:"Ready",at:os},{id:"confirmed",label:"Confirmed",at:os},{id:"expired",label:"Expired",offPath:!0,at:os},{id:"invalid",label:"Invalid link",offPath:!0,at:os}]},{id:"unsubscribe",label:"Unsubscribe",flow:"steps",states:[{id:"ask",label:"Ask",at:ao},{id:"done",label:"Unsubscribed",at:ao},{id:"invalid",label:"Invalid link",offPath:!0,at:ao}]},{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 aa(e,t){return`/api/reader-pages/${encodeURIComponent(e)}/${encodeURIComponent(t)}`}var Cc={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.`},Ac={subscribe:"mail",confirm:"circle-check",unsubscribe:"circle-minus",reading:"book"},Hc=o`<span class="set-rp-dot"></span>`;function la(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 Pc(){return o`${nn.map(e=>{let t=e.states,n=e.flow==="steps";return o`<div class="set-rp-journey" data-journey="${e.id}">
|
|
699
|
+
<h3 class="set-rp-jhead" id="rpJourney-${e.id}">${B(Ac[e.id])}${e.label}</h3>
|
|
685
700
|
<ul class="set-rp-list${n?" steps":""}" aria-labelledby="rpJourney-${e.id}">
|
|
686
|
-
${t.filter(s=>!s.offPath).map((s,
|
|
701
|
+
${t.filter(s=>!s.offPath).map((s,r)=>la(e,s,n?String(r+1):Hc))}
|
|
687
702
|
</ul>
|
|
688
703
|
<ul class="set-rp-list" aria-label="${e.label}, off the main path">
|
|
689
|
-
${t.filter(s=>s.offPath).map(s=>
|
|
704
|
+
${t.filter(s=>s.offPath).map(s=>la(e,s,B("branch")))}
|
|
690
705
|
</ul>
|
|
691
|
-
</div>`})}`}function
|
|
706
|
+
</div>`})}`}function Mc(){return o`<select id="rpSelect" aria-label="Reader page">${nn.map(e=>{let t=e.states,n=0;return o`<optgroup label="${e.label}">${t.map(s=>{let r=e.flow==="steps"&&!s.offPath?`${++n}. `:"";return o`<option value="${e.id}/${s.id}">${r}${s.label}</option>`})}</optgroup>`})}</select>`}function da(e){return o`
|
|
692
707
|
<section class="set-sec set-rp-sec" id="readerPages">
|
|
693
708
|
${e}
|
|
694
709
|
<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>
|
|
695
710
|
<div class="set-card set-rp">
|
|
696
|
-
<nav class="set-rp-nav" aria-label="Reader pages">${
|
|
697
|
-
<div class="set-rp-pick">${
|
|
711
|
+
<nav class="set-rp-nav" aria-label="Reader pages">${Pc()}</nav>
|
|
712
|
+
<div class="set-rp-pick">${Mc()}</div>
|
|
698
713
|
<div class="set-rp-main">
|
|
699
714
|
<div class="set-rp-top">
|
|
700
715
|
<div>
|
|
@@ -702,8 +717,8 @@
|
|
|
702
717
|
<p class="set-rp-note" id="rpNote"></p>
|
|
703
718
|
</div>
|
|
704
719
|
<span class="set-wtog" id="rpWidth" role="group" aria-label="Preview width">
|
|
705
|
-
<button type="button" class="wtog-btn" data-width="desktop" aria-pressed="true">${
|
|
706
|
-
<button type="button" class="wtog-btn" data-width="phone" aria-pressed="false">${
|
|
720
|
+
<button type="button" class="wtog-btn" data-width="desktop" aria-pressed="true">${B("desktop")}Desktop</button>
|
|
721
|
+
<button type="button" class="wtog-btn" data-width="phone" aria-pressed="false">${B("phone")}Phone</button>
|
|
707
722
|
</span>
|
|
708
723
|
</div>
|
|
709
724
|
<div class="set-rp-at"><span class="mono" id="rpAt"></span><span>Sample · links and forms do nothing</span></div>
|
|
@@ -713,27 +728,27 @@
|
|
|
713
728
|
</div>
|
|
714
729
|
</div>
|
|
715
730
|
</div>
|
|
716
|
-
</section>`}function
|
|
731
|
+
</section>`}function Ic(e,t){return`${e.base==="app"?t.appOrigin:`${t.archiveOrigin}${t.archiveBasePath}`}${e.path}`.replace(/^https?:\/\//,"")}function ca(e,t,n){let s=d(".set-rp-nav",e),r=d("#rpSelect",e),i=d("#rpWidth",e),a=d("#rpStage",e),l=d("#rpFrame",e),c=d("#rpError",e),u=nn[0],m=u.states[0],p="desktop",v=0,E=null;tt(n,()=>E?.disconnect());let L=()=>{for(let g of H(".set-rp-item",s)){let M=g.dataset.journey===u.id&&g.dataset.state===m.id;g.setAttribute("aria-current",M?"true":"false")}for(let g of H(".set-rp-journey",s))g.classList.toggle("current",g.dataset.journey===u.id);r.value=`${u.id}/${m.id}`,d("#rpJourney",e).textContent=u.label,d("#rpState",e).textContent=m.label,h(d("#rpNote",e),Cc[u.id]),d("#rpAt",e).textContent=Ic(m.at,t)},$=()=>{let g=l.contentDocument?.body?.scrollHeight;g&&(l.style.height=`${g}px`)},R=async()=>{let g=++v;try{let M=await ht(aa(u.id,m.id),{signal:n});if(g!==v||n.aborted)return;c.hidden=!0,a.hidden=!1,l.srcdoc=Gn(M)}catch(M){if(g!==v||n.aborted)return;l.srcdoc="",a.hidden=!0,c.textContent=`Couldn\u2019t load this page: ${K(M)}`,c.hidden=!1}},N=(g,M)=>{let f=nn.find(T=>T.id===g),k=(f?.states??[]).find(T=>T.id===M);!f||!k||f===u&&k===m||(u=f,m=k,L(),R())};return l.onload=()=>{let g=l.contentDocument;!g||n.aborted||(g.addEventListener("click",M=>{M.target?.closest?.("a[href], area[href]")&&M.preventDefault()}),E?.disconnect(),$(),g.body&&typeof ResizeObserver<"u"&&(E=new ResizeObserver($),E.observe(g.body)))},s.addEventListener("click",g=>{let M=g.target.closest(".set-rp-item");N(M?.dataset.journey,M?.dataset.state)}),r.addEventListener("change",()=>{let[g,M]=r.value.split("/");N(g,M)}),i.addEventListener("click",g=>{let f=g.target.closest("button[data-width]")?.dataset.width;if(!(f!=="desktop"&&f!=="phone"||f===p)){p=f;for(let _ of H("button",i))_.setAttribute("aria-pressed",String(_.dataset.width===f));a.classList.toggle("phone",f==="phone")}}),L(),R(),{refresh:()=>{R()}}}function pa(e){return e?`Subscribe to ${e}`:"Subscribe"}function Nc(e,t,n){let s=ie(pa(t)),r=`${ie(n)}/subscribe`;return e==="styled"?`<form action="${r}" method="post" style="max-width:460px;font:16px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">
|
|
717
732
|
<div style="font:600 22px/1.2 Georgia,'Times New Roman',serif;letter-spacing:-0.01em;margin-bottom:12px">${s}</div>
|
|
718
733
|
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
|
719
734
|
<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">
|
|
720
735
|
<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>
|
|
721
736
|
</div>
|
|
722
737
|
<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="${
|
|
738
|
+
</form>`:`<form action="${r}" method="post">
|
|
724
739
|
<label>
|
|
725
740
|
${s}
|
|
726
741
|
<input type="email" name="email" placeholder="you@example.com" required>
|
|
727
742
|
</label>
|
|
728
743
|
<button type="submit">Subscribe</button>
|
|
729
|
-
</form>`}function
|
|
744
|
+
</form>`}function Dc(e,t){let n=pa(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 ma(e){return e.data.deployment.appOrigin||location.origin}function Bc(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 ua(e,t,n,s,r=null){return o`<div class="set-way"><div class="set-way-head"><div class="set-way-name"><h3 class="set-way-title">${B(e)}${t}</h3><p class="set-way-line">${n}</p></div>${r}</div>${s}</div>`}function fa(e){let t=`${ma(e)}/subscribe`;return o`
|
|
730
745
|
<section class="set-sec set-sec-subscribe">
|
|
731
|
-
${
|
|
746
|
+
${we("Ways to subscribe",ye("copyout","Copy-out"))}
|
|
732
747
|
<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>
|
|
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. ${
|
|
748
|
+
${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. ${_e()} to open signups.</span></div></div>`:null}
|
|
734
749
|
<div class="set-card">
|
|
735
|
-
${
|
|
736
|
-
${
|
|
750
|
+
${ua("link","Share the page",o`<span id="subscribePageLine"></span>`,o`<div class="set-way-body set-way-addr"><code class="set-addr">${t}</code>${Zt(t,"Copy the subscribe page\u2019s address")}<a class="ghost-link" href="${t}" target="_blank" rel="noopener">Open ↗</a></div>`)}
|
|
751
|
+
${ua("code","Embed a form",o`Put a sign-up form on another site you own.`,o`<div class="set-embed">
|
|
737
752
|
<div class="set-embed-preview" id="embedPreview"></div>
|
|
738
753
|
<pre class="set-embed-code"><code id="embedCode"></code></pre>
|
|
739
754
|
</div>
|
|
@@ -742,16 +757,112 @@
|
|
|
742
757
|
<button type="button" class="wtog-btn" data-embed="plain" aria-pressed="false">Plain</button>
|
|
743
758
|
</span>`)}
|
|
744
759
|
</div>
|
|
745
|
-
</section>`}function
|
|
746
|
-
${
|
|
747
|
-
${
|
|
748
|
-
${
|
|
749
|
-
${
|
|
750
|
-
${
|
|
751
|
-
`),
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
760
|
+
</section>`}function Oc(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">${qe(!1,"Copy HTML")}</button>`}function ha(e,t){let n=ma(t),s=d("#embedCode",e),r=d("#embedCopy",e),i=d("#embedPreview",e),a=d("#subscribePageLine",e),l="styled",c=()=>t.state.name||t.fromDisplay,u=()=>{let v=Nc(l,c(),n);s.textContent=v,h(r,Oc(l,v)),h(i,Dc(l,c())),a.textContent=Bc(c())},m=H("[data-embed]",e);for(let v of m)v.onclick=()=>{l=v.dataset.embed==="styled"?"styled":"plain";for(let E of m)E.setAttribute("aria-pressed",String(E.dataset.embed===l));u()};ze(d(".set-sec-subscribe",e)),u();let p=c();return{repaint(){c()!==p&&(p=c(),u())}}}function sn(e,t){return o`<div class="set-group" data-group="${e}">${t}</div>`}function lo(e){return{name:e.name,tagline:e.tagline,address:e.address,recipients:[...e.recipients],confirmation:{...e.confirmation},notifyTo:e.notifyTo}}var Uc=(e,t)=>e.length===t.length&&e.every((n,s)=>n===t[s]),Fc=(e,t)=>e.subject===t.subject&&e.body===t.body&&e.buttonLabel===t.buttonLabel&&e.reassurance===t.reassurance;async function co(e,t){h(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=d("#settingsBody",e),s;try{s=await C("/api/settings",{signal:t})}catch(f){oe(n,Ze(f),()=>Q(co));return}let r=s.settings,i=r.publication,a=r.confirmationEmail,l={name:i.name||"",tagline:i.tagline||"",address:i.address||"",logoUrl:i.logoUrl||"",recipients:[...r.testRecipients||[]],confirmation:{subject:a.subject||"",body:a.body||"",buttonLabel:a.buttonLabel||"",reassurance:a.reassurance||""},notifyTo:r.notifications.to||""},c=lo(l),u=ws(s.deployment.fromAddress),m=()=>l.name!==c.name||l.tagline!==c.tagline||l.address!==c.address||!Uc(l.recipients,c.recipients)||!Fc(l.confirmation,c.confirmation)||l.notifyTo!==c.notifyTo,p=[],v=Xn.attach({onSave:g,onDiscard:M},t),E=()=>{},L={data:s,state:l,saved:()=>c,fromDisplay:u,fromName:u||"Your publication",changed(){for(let f of p)f.repaint?.();v.setDirty(m())},applySaved(f){X.appConfig={...s,settings:f},bn(),E()}};h(n,o`
|
|
761
|
+
${sn("publication",[Qi(L)])}
|
|
762
|
+
${sn("readers",[fa(L),ji(L),da(we("Reader pages",ye("preview","Preview")))])}
|
|
763
|
+
${sn("emails",[ea(L),na()])}
|
|
764
|
+
${sn("you",[ra(L)])}
|
|
765
|
+
${sn("deploy",[Vi(L),Ki(L)])}
|
|
766
|
+
`),p.push(Xi(n,L,t),ha(n,L),Wi(n,L),ta(n,L),sa(n,L),ia(n,L)),ns(n,s.inUse,t),E=ca(n,s.deployment,t).refresh;let R=null,N=()=>{R?.(),R=null};async function g(){N();try{let f={publication:{name:l.name,tagline:l.tagline,address:l.address},testRecipients:l.recipients,confirmationEmail:l.confirmation,notifications:{to:l.notifyTo}},_=await dt(T=>C("/api/settings",{method:"PUT",json:T?{...f,remake:T}:f}),en("Save","Saving"),t);if(!_)return;let k=_.settings;l.name=k.publication.name,l.tagline=k.publication.tagline,l.address=k.publication.address,l.recipients=[...k.testRecipients],l.confirmation={...k.confirmationEmail},l.notifyTo=k.notifications.to,c=lo(l);for(let T of p)T.reset?.();L.applySaved(k),L.changed(),O(Ht("Settings saved",_.remade))}catch(f){let _=f instanceof ee?jc(e,f.field):null;_&&f instanceof ee?(R=Pe(_,Ze(f)),_.focus(),v.showError("Not saved: fix the field marked above.")):v.showError(Ze(f))}}function M(){N(),Object.assign(l,lo(c));for(let f of p)f.reset?.();L.changed()}L.changed()}var qc={"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 jc(e,t){let n=t?qc[t]:void 0,s=n?e.querySelector(n):null;return(s instanceof HTMLInputElement||s instanceof HTMLTextAreaElement)&&!s.closest("[hidden]")?s:null}var on="kestrel-subscribers/1",Wc=[on],Vc=["subscribed","pending","unsubscribed","none"],uo=["double","single","unknown"],Kc=["bounce","complaint","manual","erased"],ba=["subscribed","unsubscribed","bounced"],Gc={maxRows:1e4,maxRowsFree:1e3,maxBodyBytes:10*1024*1024,maxEvidenceBytes:500};var ga=[{name:"email",required:!0,values:null,takes:"an email address; trimmed and lowercased before it is matched",meaning:"The address. An invalid one is still a row, counted as invalid and reported, never dropped."},{name:"status",required:!0,values:Vc,takes:"one of the values",meaning:"The consent claim. `none` is a known address with no consent claim, present only to carry a suppression."},{name:"opt_in",required:!0,values:uo,takes:"one of the values",meaning:"How consent was collected. `unknown` unless the source states it."},{name:"origin",required:!0,values:null,takes:"a short lowercase name (letters, digits, `.`, `_`, `-`, up to 40): `sendy`, `mailchimp`, `kestrel`, `unknown`",meaning:"Where consent was first collected, as the file claims it."},{name:"suppression",required:!1,values:Kc,takes:"one of the values, or empty",meaning:"A suppression on top of `status`: the address bounced, complained, was suppressed by hand, or was erased."},{name:"subscribed_at",required:!1,values:null,takes:"a UTC ISO-8601 instant with its zone (`2024-03-01T09:30:00Z`), or empty",meaning:"When the person subscribed, if the source states it."},{name:"confirmed_at",required:!1,values:null,takes:"an instant with its zone, or empty",meaning:"When the person confirmed, if the source states it."},{name:"unsubscribed_at",required:!1,values:null,takes:"an instant with its zone, or empty",meaning:"When the person left, if the source states it. A reader who confirmed here after it stays confirmed."},{name:"suppressed_at",required:!1,values:null,takes:"an instant with its zone, or empty",meaning:"When the address was suppressed, if the source states it."},{name:"evidence",required:!1,values:null,takes:`a JSON object of at most ${Gc.maxEvidenceBytes} bytes, or empty`,meaning:"The source\u2019s consent-relevant columns, verbatim (a signup IP, a form name, a confirmation time). Nothing reads it to decide anything: it is the proof of consent and the audit trail of a conversion."},{name:"format",required:!1,values:Wc,takes:"the format\u2019s name and version, or empty",meaning:"The version the file is written in. A file without it is read as version 1; a version this release doesn\u2019t know is refused by name."}],Tv=new Set(ga.map(e=>e.name));var Yc=/^[=+\-@\t\r]/;function zc(e){return Yc.test(e)?`'${e}`:e}function Zc(e){let t=zc(e);return/[",\r\n]/.test(t)||t!==t.trim()?`"${t.replace(/"/g,'""')}"`:t}function po(e){return`${e.map(t=>t.map(Zc).join(",")).join(`\r
|
|
767
|
+
`)}\r
|
|
768
|
+
`}function mo(e){let t=ga.map(n=>n.name);return po([t,...e.map(n=>t.map(s=>n[s]??""))])}var fo=[{email:"ada@example.com",status:"subscribed",opt_in:"double",origin:"sendy",suppression:null,subscribed_at:"2024-03-01T09:30:00Z",confirmed_at:"2024-03-01T09:42:00Z",unsubscribed_at:null,suppressed_at:null,evidence:'{"signup_ip":"203.0.113.7","list":"Field Notes"}',format:on},{email:"grace@example.com",status:"subscribed",opt_in:"single",origin:"mailchimp",suppression:null,subscribed_at:"2023-11-12T16:05:00Z",confirmed_at:null,unsubscribed_at:null,suppressed_at:null,evidence:null,format:on},{email:"edsger@example.com",status:"unsubscribed",opt_in:"unknown",origin:"unknown",suppression:null,subscribed_at:null,confirmed_at:null,unsubscribed_at:"2025-01-20T08:00:00Z",suppressed_at:null,evidence:null,format:on},{email:"bounced@example.com",status:"none",opt_in:"unknown",origin:"unknown",suppression:"bounce",subscribed_at:null,confirmed_at:null,unsubscribed_at:null,suppressed_at:"2025-02-02T12:00:00Z",evidence:null,format:on}],Lv=mo(fo),ho=`ada@example.com\r
|
|
769
|
+
grace@example.com\r
|
|
770
|
+
linus@example.com\r
|
|
771
|
+
`,va="kestrel-import-example.csv",bo="kestrel-import-example.txt";var ya=(e,t,n)=>`${e.toLocaleString("en-US")} ${e===1?t:n}`,Jc=[{outcome:"create_confirmed",one:"address added as a confirmed subscriber",many:"addresses added as confirmed subscribers"},{outcome:"confirm_pending",one:"pending subscriber here confirmed",many:"pending subscribers here confirmed"},{outcome:"mark_unsubscribed",one:"subscriber here unsubscribed",many:"subscribers here unsubscribed"},{outcome:"create_unsubscribed",one:"address recorded as having left (unsubscribed), so the list remembers them",many:"addresses recorded as having left (unsubscribed), so the list remembers them"},{outcome:"suppression_added",one:"address suppressed as the file says (bounced, complained, or blocked), so it\u2019s never mailed",many:"addresses suppressed as the file says (bounced, complained, or blocked), so they\u2019re never mailed"},{outcome:"unchanged",one:"address already here as the file says, so it\u2019s unchanged",many:"addresses already here as the file says, so they\u2019re unchanged"},{outcome:"left_out",one:"row still pending, or with no status, left out, since an import sends no email",many:"rows still pending, or with no status, left out, since an import sends no email"},{outcome:"invalid",one:"row isn\u2019t a valid address",many:"rows aren\u2019t valid addresses"},{outcome:"merged",one:"row repeats an address earlier in the file, and is folded into it",many:"rows repeat an address earlier in the file, and are folded into it"},{outcome:"blocked_by_suppression",one:"address is blocked by a suppression here, so it isn\u2019t imported",many:"addresses are blocked by a suppression here, so they aren\u2019t imported",kept:!0},{outcome:"kept_unsubscribed",one:"reader unsubscribed here, and stays unsubscribed",many:"readers unsubscribed here, and stay unsubscribed",kept:!0},{outcome:"kept_confirmed_newer",one:"reader confirmed here after the file says they left, and stays confirmed",many:"readers confirmed here after the file says they left, and stay confirmed",kept:!0}];function go(e){return Jc.filter(t=>e[t.outcome]>0).map(t=>({outcome:t.outcome,count:e[t.outcome],text:ya(e[t.outcome],t.one,t.many),kept:t.kept===!0}))}function wa(e){return e.create_confirmed+e.confirm_pending}function vo(e,t){let n=t-e.suppression_added;return n>0?`${ya(n,"of the addresses above also gains","of the addresses above also gain")} a suppression, so ${n===1?"it\u2019s":"they\u2019re"} never mailed.`:null}function Sa(e){let t=uo.filter(s=>(e.opt_in[s]??0)>0).map(s=>`${e.opt_in[s].toLocaleString("en-US")} ${Ns(s)}`).join(", "),n=Object.entries(e.origin).filter(([,s])=>s>0).sort((s,r)=>r[1]-s[1]||s[0].localeCompare(r[0])).map(([s,r])=>`${zt(s)??"unknown"} ${r.toLocaleString("en-US")}`).join(", ");return{optIn:t,origin:n}}var Pt={create_confirmed:"Imported as subscribed",confirm_pending:"Imported as subscribed",mark_unsubscribed:"Unsubscribed",create_unsubscribed:"Recorded as having left",suppression_added:"Suppressed",unchanged:"Unchanged",left_out:"Left out",invalid:"Invalid",merged:"Folded into an earlier row",blocked_by_suppression:"Blocked by a suppression here",kept_unsubscribed:"Kept unsubscribed",kept_confirmed_newer:"Kept confirmed"};function $a(e,t,n){let s=new Map(e.map(a=>[a.row,a])),r=new Map(t.map(a=>[a.row,a])),i=new Map;for(let a of new Set([...s.keys(),...r.keys()])){let l=s.get(a),c=r.get(a),u=l?Pt[l.outcome]:Pt.create_confirmed,m=c?Pt[c.outcome]:Pt.create_confirmed;u!==m&&i.set(a,{row:a,email:(c??l)?.email??"",planned:u,applied:m})}for(let a of n){let l=Pt[a.planned],c=Pt[a.applied];!i.has(a.row)&&l!==c&&i.set(a.row,{row:a.row,email:a.email,planned:l,applied:c})}return[...i.values()].sort((a,l)=>a.row-l.row)}function xa(e){return po([["row","email","outcome","suppression_added"],...e.map(t=>[String(t.row),t.email,t.outcome,t.suppression_added?"yes":"no"])])}var Ea="Please import my Sendy subscribers from ~/Downloads/sendy-export.csv into the newsletter.",Qc="Your list or its scheduled posts changed since this plan, so here is the plan again as things stand now. Review it, then import.",yo=["email","status","opt_in","origin","subscribed_at"],Xc={subscribed:"Subscribed",unsubscribed:"Unsubscribed",bounced:"Bounced"};async function So(e,t){h(e,o`<div class="imp">
|
|
772
|
+
<div class="editor-head"><a href="#/subscribers" class="back">← Subscribers</a></div>
|
|
773
|
+
<div class="page-head"><h1>Import subscribers</h1><p class="set-lede" id="impLede">Nothing changes until you review what will happen and confirm.</p></div>
|
|
774
|
+
<div id="impBody" class="muted">Loading…</div>
|
|
775
|
+
<input type="file" id="impInput" accept=".csv,.txt,text/csv,text/plain" hidden>
|
|
776
|
+
</div>`);let n=d("#impBody",e),s=d("#impInput",e);ze(n);let r;try{r=await C("/api/imports/format",{signal:t})}catch(f){t.aborted||oe(n,K(f),()=>{Q(So)});return}if(t.aborted)return;let i="full";function a(f){d("#impLede",e).hidden=!1;let _=r.limits.max_rows.toLocaleString("en-US");h(n,o`<section class="card imp-card">
|
|
777
|
+
<div class="imp-card-head"><h2>The file Kestrel reads</h2>
|
|
778
|
+
<span class="set-wtog" role="group" aria-label="Which file">
|
|
779
|
+
<button type="button" class="wtog-btn" data-sample="full" aria-pressed="${String(i==="full")}">Full list</button>
|
|
780
|
+
<button type="button" class="wtog-btn" data-sample="plain" aria-pressed="${String(i==="plain")}">Plain list</button>
|
|
781
|
+
</span>
|
|
782
|
+
</div>
|
|
783
|
+
<div id="impSample">${i==="full"?l():c()}</div>
|
|
784
|
+
</section>
|
|
785
|
+
<section class="card imp-card imp-claude">
|
|
786
|
+
<h2>Just ask Claude</h2>
|
|
787
|
+
<p class="imp-text">Coming from Sendy, Mailchimp, or another service? Claude converts your export and checks it with you before importing.</p>
|
|
788
|
+
${u()}
|
|
789
|
+
</section>
|
|
790
|
+
<div class="imp-drop" id="impDrop">
|
|
791
|
+
${B("upload")}
|
|
792
|
+
<p class="imp-drop-line">Drop your file here, or</p>
|
|
793
|
+
<button type="button" class="primary" id="impChoose" aria-describedby="impLimit${f?" impFileError":""}">Choose file</button>
|
|
794
|
+
<p class="field-hint" id="impLimit">CSV or .txt, up to ${_} rows.${r.limits.max_rows_reason?o` ${r.limits.max_rows_reason}`:null}</p>
|
|
795
|
+
</div>
|
|
796
|
+
${f?o`<div class="imp-file-error"><div class="field-error" id="impFileError" role="alert"><span class="field-error-ico" aria-hidden="true">!</span><span>${f}</span></div>
|
|
797
|
+
<p class="imp-text">Exported from another service? Claude can convert it for you. Ask Claude Code:</p>${u()}</div>`:null}`);for(let T of H("[data-sample]",n))T.onclick=()=>{i=T.dataset.sample==="plain"?"plain":"full";for(let I of H("[data-sample]",n))I.setAttribute("aria-pressed",String(I.dataset.sample===i));h(d("#impSample",n),i==="full"?l():c()),m()};m(),d("#impChoose",n).onclick=()=>s.click();let k=d("#impDrop",n);k.addEventListener("dragover",T=>{T.preventDefault(),k.classList.add("over")}),k.addEventListener("dragleave",()=>k.classList.remove("over")),k.addEventListener("drop",T=>{T.preventDefault(),k.classList.remove("over");let I=T.dataTransfer?.files[0];I&&p(I)})}function l(){let f=fo.slice(0,3),_=r.columns.filter(k=>!k.required&&!yo.includes(k.name)).map(k=>k.name);return o`<p class="imp-text">A CSV with a header row, one row per address. A row says what your old service recorded about each person; Kestrel decides what that means here.</p>
|
|
798
|
+
<div class="table-wrap"><table class="imp-sample"><thead><tr>${yo.map(k=>o`<th><code>${k}</code></th>`)}</tr></thead>
|
|
799
|
+
<tbody>${f.map(k=>o`<tr>${yo.map(T=>o`<td>${k[T]??""}</td>`)}</tr>`)}</tbody></table></div>
|
|
800
|
+
<p class="field-hint">Optional columns: ${_.map((k,T)=>o`${T?", ":""}<code>${k}</code>`)}.</p>
|
|
801
|
+
<div class="row imp-links"><button type="button" class="ghost" data-template="csv">Download example file (CSV)</button><a class="ghost-link" href="#/docs/import-format">Import format reference →</a></div>`}function c(){return o`<p class="imp-text">A <code>.txt</code> file with one address per line.</p>
|
|
802
|
+
${_t(bo,o`${ho.replace(/\r\n/g,`
|
|
803
|
+
`).trim()}`,ho)}
|
|
804
|
+
<p class="imp-text">Next you’ll say what they are: subscribed (the default), unsubscribed, or bounced.</p>
|
|
805
|
+
<div class="row imp-links"><button type="button" class="ghost" data-template="txt">Download example file (.txt)</button></div>`}function u(){return _t("Prompt for Claude Code",o`${Ea}`,Ea)}function m(){for(let f of H("[data-template]",n))f.onclick=()=>J(f,null,async()=>{let _=f.dataset.template==="txt"?"txt":"csv";try{let k=await hn(`/api/imports/template.${_}`);lt(k.text,k.filename??(_==="txt"?bo:va),_==="txt"?"text/plain":"text/csv")}catch(k){O(K(k))}})}s.onchange=()=>{let f=s.files?.[0];s.value="",f&&p(f)};async function p(f){let _=await f.text();if(t.aborted)return;let k={name:f.name,text:_},T=eu(k)?r.plain_list.default:null,I=n.querySelector("#impChoose, #impReplace"),S=await(I?J(I,"Reading\u2026",()=>v(k,T)):v(k,T));S!==null&&(E(S,k),d(".imp-file",n).focus())}async function v(f,_){try{return await C("/api/imports/plan",{method:"POST",json:_===null?{file:f}:{file:f,plain_list_status:_},signal:t})}catch(k){return t.aborted||(a(K(k)),d("#impChoose",n).focus()),null}}function E(f,_,k){let T=wa(f.counts),I=go(f.counts),S=Sa(f.confirmed),P=vo(f.counts,f.suppressions_added),y=f.rows.length,A=f.file.kind==="txt"?"Plain list (.txt)":`CSV, ${r.format}`;h(n,o`<section class="card imp-card imp-file" tabindex="-1" aria-label="The file chosen and its plan">
|
|
806
|
+
<div class="imp-file-row">
|
|
807
|
+
<div><strong class="imp-file-name">${f.file.name}</strong><div class="muted">${A} · ${wo(f.file.rows)} · ${tu(f.file.addresses)}</div></div>
|
|
808
|
+
<button type="button" class="ghost" id="impReplace">Replace file</button>
|
|
809
|
+
</div>
|
|
810
|
+
${f.file.kind==="txt"?L(f.file.plain_list_status??r.plain_list.default):null}
|
|
811
|
+
</section>
|
|
812
|
+
<section class="card imp-card">
|
|
813
|
+
<h2>What will happen</h2>
|
|
814
|
+
${Ta(I,S,T)}
|
|
815
|
+
${P?o`<p class="imp-text">${P}</p>`:null}
|
|
816
|
+
${y>0?o`<div class="row imp-links"><button type="button" class="ghost" id="impNotImported">Download the ${wo(y)} not imported as subscribed (CSV)</button></div>`:null}
|
|
817
|
+
${f.daily_writes_warning?o`<p class="hint daily-writes imp-warn" role="status">${f.daily_writes_warning}</p>`:null}
|
|
818
|
+
</section>
|
|
819
|
+
<section class="card imp-card imp-confirm">
|
|
820
|
+
<h2>Confirm</h2>
|
|
821
|
+
${f.sends_growing.length>0?o`<p class="imp-text">A scheduled post goes to the list as it stands when it sends, so this import adds to its audience. Acknowledge each one.</p>
|
|
822
|
+
${f.sends_growing.map(q=>o`<label class="imp-check"><input type="checkbox" data-ack="${q.id}"><span>“${q.subject||"Untitled"}”, sending ${Y(q.fire_at)}, goes to ${q.gains.toLocaleString("en-US")} more ${q.gains===1?"person":"people"}.</span></label>`)}`:null}
|
|
823
|
+
${f.attestation?o`<label class="imp-check"><input type="checkbox" id="impAttest"><span>${f.attestation.text}</span></label>
|
|
824
|
+
<p class="field-hint imp-attest-hint">Only import people who asked to receive ${f.attestation.publication}. If you’re unsure about some, take them out of the file before importing.</p>`:null}
|
|
825
|
+
${f.attestation===null&&f.sends_growing.length===0?o`<p class="imp-text">Nobody is imported as a subscriber, so there’s nothing to attest.</p>`:null}
|
|
826
|
+
${k?ka(k):null}
|
|
827
|
+
<div class="actions imp-actions"><a class="ghost-link" href="#/subscribers">Cancel</a><button type="button" class="primary" id="impGo">Import</button></div>
|
|
828
|
+
</section>`);let w=d("#impReplace",n);w.onclick=()=>s.click(),y>0&&(d("#impNotImported",n).onclick=()=>$(f.file.name,f.rows));let F=d("#impGo",n),te=H("[data-plain]",n),ce=q=>{w.disabled=q;for(let ne of te)ne.disabled=q};for(let q of te)q.onclick=()=>{let ne=q.dataset.plain;ne!==f.file.plain_list_status&&(F.disabled=!0,ce(!0),(async()=>{let V=await v(_,ne);V&&!t.aborted&&(E(V,_),d(`[data-plain="${ne}"]`,n).focus())})())};F.onclick=()=>R(f,_,F,ce)}function L(f){return o`<div class="imp-plain">
|
|
829
|
+
<span class="imp-plain-label" id="impPlainLabel">These addresses are</span>
|
|
830
|
+
<span class="set-wtog" role="group" aria-labelledby="impPlainLabel">${ba.map(_=>o`<button type="button" class="wtog-btn" data-plain="${_}" aria-pressed="${String(_===f)}">${Xc[_]}</button>`)}</span>
|
|
831
|
+
<p class="field-hint">A plain list doesn’t say how each person joined, so it’s recorded as unknown.</p>
|
|
832
|
+
</div>`}function $(f,_){let k=f.replace(/\.(csv|txt)$/i,"");lt(xa(_),`${k}-not-imported.csv`,"text/csv")}async function R(f,_,k,T){n.querySelector(".imp-act-error")?.remove();let I=H("[data-ack]",n),S=n.querySelector("#impAttest");for(let P of I)if(!P.checked){N(P,"Tick this to acknowledge that this post will go to the people this import adds.");return}if(S&&!S.checked){N(S,"Tick this to attest that everyone you\u2019re importing as subscribed opted in.");return}T(!0),await J(k,"Importing\u2026",async()=>{try{let P=await C("/api/imports",{method:"POST",json:{file:_,plain_list_status:f.file.plain_list_status??void 0,plan_hash:f.plan_hash,...f.attestation?{attestation:{version:f.attestation.version,accepted:!0}}:{},acknowledge_sends:f.sends_growing.map(y=>y.id)}});t.aborted||M(P,f)}catch(P){if(t.aborted)return;if(P instanceof ee&&P.code==="plan_stale"){let y=await v(_,f.file.plain_list_status);y&&!t.aborted&&(E(y,_,Qc),d(".imp-act-error",n).focus());return}g(K(P)),T(!1)}})}function N(f,_){Pe(f,_,{after:f.closest("label")??f}),f.focus()}function g(f){n.querySelector(".imp-act-error")?.remove();let _=document.createElement("div");h(_,ka(f)),d(".imp-actions",n).before(..._.childNodes)}function M(f,_){d("#impLede",e).hidden=!0;let k=f.import,T=k.principal.kind==="service"?"Claude, for you":k.principal.email?k.principal.email:"you",I=go(f.counts),S=vo(f.counts,f.suppressions_added),P=f.replayed?[]:$a(_.rows,f.rows,f.differed);h(n,o`<section class="card imp-card imp-done">
|
|
833
|
+
<h2 id="impDoneHead" tabindex="-1">${f.replayed?"This import was already done":"Import done"}</h2>
|
|
834
|
+
${f.replayed?o`<p class="imp-text">This plan was already imported, so nothing was written again. Here is its record.</p>`:null}
|
|
835
|
+
${Ta(I,null,0)}
|
|
836
|
+
${S?o`<p class="imp-text">${S}</p>`:null}
|
|
837
|
+
<p class="imp-text imp-nobody">Nobody was emailed. To tell your readers about the move, write a post as usual.</p>
|
|
838
|
+
</section>
|
|
839
|
+
${P.length>0?o`<section class="card imp-card imp-differed">
|
|
840
|
+
<h2>Rows that turned out differently</h2>
|
|
841
|
+
<p class="imp-text">Your list changed between the plan and the import, so ${wo(P.length)} had a different result. Each was applied to the list as it stood.</p>
|
|
842
|
+
<div class="table-wrap"><table class="imp-ref"><thead><tr><th>Row</th><th>Email</th><th>Planned</th><th>Applied</th></tr></thead><tbody>${P.map(y=>o`<tr><td>${String(y.row)}</td><td>${y.email}</td><td>${y.planned}</td><td>${y.applied}</td></tr>`)}</tbody></table></div>
|
|
843
|
+
</section>`:null}
|
|
844
|
+
<section class="card imp-card imp-record">
|
|
845
|
+
<h2>The import’s record</h2>
|
|
846
|
+
<dl class="imp-dl">
|
|
847
|
+
<dt>File</dt><dd>${k.file_name}</dd>
|
|
848
|
+
<dt>Fingerprint</dt><dd><code>${k.file_sha256.slice(0,16)}</code></dd>
|
|
849
|
+
<dt>Imported</dt><dd>${Y(k.created_at)} by ${T}</dd>
|
|
850
|
+
<dt>Attestation</dt><dd>${k.attestation_version===null?"None needed: nobody was imported as a subscriber.":_.attestation?o`“${_.attestation.text}” <span class="muted">(version ${String(k.attestation_version)})</span>`:`Version ${k.attestation_version}`}</dd>
|
|
851
|
+
</dl>
|
|
852
|
+
<div class="actions imp-actions"><button type="button" class="ghost" id="impAnother">Import another file</button><button type="button" class="primary" id="impToList">See your subscribers</button></div>
|
|
853
|
+
</section>`),d("#impAnother",n).onclick=()=>{a(),d("#impChoose",n).focus()},d("#impToList",n).onclick=()=>{location.hash="#/subscribers"},d("#impDoneHead",n).focus()}a()}function ka(e){return o`<div class="field-error imp-act-error" role="alert" tabindex="-1"><span class="field-error-ico" aria-hidden="true">!</span><span>${e}</span></div>`}function eu(e){return e.name.trim().toLowerCase().endsWith(".txt")}function wo(e){return`${e.toLocaleString("en-US")} ${e===1?"row":"rows"}`}function tu(e){return`${e.toLocaleString("en-US")} ${e===1?"address":"addresses"}`}function Ta(e,t,n){let s=e.filter(a=>!a.kept),r=e.filter(a=>a.kept),i=s.find(a=>a.outcome==="create_confirmed"||a.outcome==="confirm_pending")?.outcome;return o`<ul class="imp-outcomes">${s.map(a=>o`<li data-outcome="${a.outcome}"><span>${a.text}</span>${a.outcome===i&&t&&n>0?o`<div class="field-hint imp-breakdown">Of the ${n.toLocaleString("en-US")} confirmed, by opt-in: ${t.optIn}. From: ${t.origin}.</div>`:null}</li>`)}</ul>
|
|
854
|
+
${r.length>0?o`<p class="imp-kept-head">Kept as they are here</p><ul class="imp-outcomes imp-kept">${r.map(a=>o`<li data-outcome="${a.outcome}"><span>${a.text}</span></li>`)}</ul>`:null}`}async function nu(){let e=[],t=null,n=Date.now();do{let s=t===null?"":`?after=${encodeURIComponent(t)}`,r=await C(`/api/subscribers/export${s}`);if(r.next!==null&&r.next===t)throw new Error("The export repeated a page. Try again.");e.push(...r.subscribers),n=r.exported_at,t=r.next}while(t!==null);return{rows:e,exportedAt:n}}async function La(){let{rows:e,exportedAt:t}=await nu();return{name:`kestrel-subscribers-${new Date(t).toISOString().slice(0,10)}.csv`,text:mo(e)}}var _a=[{value:"confirmed",label:"Confirmed"},{value:"pending",label:"Pending"},{value:"unsubscribed",label:"Unsubscribed"}];async function Ra(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),h(t,o`
|
|
855
|
+
<div class="spread page-head"><h1>Subscribers</h1><span class="row"><button type="button" class="ghost" id="importSubs">Import</button><button type="button" class="ghost" id="exportSubs">Export</button><button class="primary" id="addSub">Add subscriber</button></span></div>
|
|
856
|
+
<div class="card sub-summary"><div class="sub-summary-head"><div id="subCounts" class="row muted" role="group" aria-label="Show">Loading…</div><button type="button" class="sub-legend-toggle" id="subLegendToggle" aria-expanded="false" aria-controls="subLegend">Legend</button></div>${au()}</div>
|
|
857
|
+
${$t({statuses:_a,suppressible:!0,searchPlaceholder:"Search email\u2026"})}
|
|
755
858
|
<div id="subList" class="muted">Loading…</div>
|
|
756
|
-
<div id="subPager"></div>`);let
|
|
859
|
+
<div id="subPager"></div>`);let r=d("#subList"),i=d("#subPager");async function a(){try{let u=await C(`/api/subscribers?${St(s)}`,{signal:n}),m=u.counts;ou(d("#subCounts"),m,s);let p=m.pending+m.confirmed+m.unsubscribed===0;cu(r,u.subscribers,s,a,n,p),Ge(i,s,u.page,a)}catch(u){oe(r,K(u),a)}}ru(t,s,a);let l=d("#subLegendToggle");l.onclick=()=>{let u=l.getAttribute("aria-expanded")!=="true";l.setAttribute("aria-expanded",String(u)),d("#subLegend").hidden=!u},d("#addSub").onclick=()=>Wn(a,n),d("#importSubs").onclick=()=>{location.hash="#/subscribers/import"};let c=d("#exportSubs");c.onclick=()=>J(c,"Exporting\u2026",async()=>{try{let u=await La();lt(u.text,u.name,"text/csv")}catch(u){O(K(u))}}),xt(t,s,a),a()}var su=["confirmed","pending","unsubscribed","suppressed"];function xo(e,t){return e.search?!1:t==="suppressed"?e.suppressed==="only"&&!e.status:e.status===t&&!e.suppressed}function ou(e,t,n){e.querySelector("[data-filter]")||h(e,o`${su.map(s=>o`<button type="button" class="sub-count" data-filter="${s}" aria-pressed="false"></button>`)}`);for(let s of H("[data-filter]",e)){let r=s.dataset.filter;h(s,o`${r==="confirmed"?o`<strong>${t[r]}</strong>`:t[r]} ${r}`),s.setAttribute("aria-pressed",String(xo(n,r)))}}function ru(e,t,n){let s=d("#subCounts",e);s.onclick=r=>{let i=r.target instanceof Element?r.target.closest("[data-filter]"):null;if(!i)return;let a=i.dataset.filter,l=!xo(t,a);t.status=l&&a!=="suppressed"?a:"",t.suppressed=l&&a==="suppressed"?"only":"",t.search="",t.offset=0,d(".lt-search",e).value="",d(".lt-status",e).value=t.status,d(".lt-suppressed",e).checked=t.suppressed==="only";for(let c of H("[data-filter]",s))c.setAttribute("aria-pressed",String(xo(t,c.dataset.filter)));n()}}function Ca(e){return e.suppression_reason&&qn[e.suppression_reason]||"suppressed"}function iu(e){return e.suppressed?o` <span class="badge suppressed row-flag">${Ca(e)}</span>`:null}function $o(e,t){return o`<dt>${e}</dt><dd>${t}</dd>`}function au(){let e=Object.keys(Is);return o`<div class="sub-legend" id="subLegend" role="region" aria-labelledby="subLegendToggle" hidden><div class="sub-legend-body">
|
|
860
|
+
<h2>Status</h2>
|
|
861
|
+
<dl>${_a.map(({value:t})=>$o(Ye(t),Wr[t]))}</dl>
|
|
862
|
+
<h2>Suppressed</h2>
|
|
863
|
+
<p>${Vr} The red flag beside the address says why:</p>
|
|
864
|
+
<dl>${e.map(t=>$o(o`<span class="badge suppressed">${t}</span>`,Is[t]))}</dl>
|
|
865
|
+
<h2>What you can do</h2>
|
|
866
|
+
<dl>${Object.values(Yt).map(({label:t,when:n})=>$o(o`<strong class="sub-legend-act">${t}</strong>`,n))}</dl>
|
|
867
|
+
</div></div>`}function lu(e){return!e.suppressed||e.suppression_reason==="erased"&&e.status==="pending"?Ye(e.status):o`<span class="badge ${e.status} held">${e.status}</span>`}function du(e){let t=e.lastIndexOf("@");return t>0?o`${e.slice(0,t)}<wbr>${e.slice(t)}`:o`${e}`}function cu(e,t,n,s,r,i){if(!t.length){h(e,i?o`<p class="muted">No subscribers yet. <a href="#/subscribers/import">Import your list</a> from another service, or share your subscribe page.</p>`:o`<p class="muted">No subscribers match.</p>`);return}h(e,o`<div class="table-wrap subs-wrap"><table class="list-table subs-table stacks has-actions"><colgroup><col><col class="c-status"><col class="c-how"><col class="c-date"><col class="c-act"></colgroup><thead><tr>${ge("Email","email",n)}${ge("Status",null,n)}${ge("How they joined",null,n)}${ge("Joined","joined",n)}<th></th></tr></thead><tbody>${t.map(a=>{let l=jn(a);return o`<tr data-id="${a.id}"><td>${du(a.email)}${iu(a)}</td><td>${lu(a)}</td><td class="muted how${l?"":" empty"}">${l??"\u2014"}</td><td class="muted joined">${Y(a.created_at)}</td><td class="act"><button class="icon" data-menu="${a.id}" aria-label="Subscriber actions" aria-haspopup="menu">⋯</button></td></tr>`})}</tbody></table></div>`),Ke(e,n,s);for(let a of H("[data-menu]",e))a.onclick=l=>{l.stopPropagation();let c=t.find(m=>m.id===a.dataset.menu);if(!c)return;let u=[{label:"View subscriber",onClick:()=>Gr(c,r,c.suppressed?{label:Ca(c),detail:c.suppression_detail}:void 0)}];c.status==="confirmed"&&u.push({label:Yt.unsubscribe.label,danger:!0,onClick:()=>Yr(c,s,r)}),c.suppressed?c.suppression_reason==="manual"&&u.push({label:"Unblock",onClick:()=>Zr(c,s,r)}):u.push({label:Yt.block.label,danger:!0,onClick:()=>zr(c,s,r)}),u.push({label:Yt.erase.label,danger:!0,onClick:()=>Jr(c,s,r)}),kt(a,u,r)}}var uu="You have unsaved changes. Leave without saving?",Eo="";function ko(){let e=location.hash||"#/dashboard";Eo=e;let[,t,n,s]=(Ts(e.slice(1))?e:"#/dashboard").split("/");document.body.classList.toggle("editor-mode",t==="edit");let r=t==="web"?xi(n,s):null;document.body.classList.toggle("web-mode",r!==null);let i=t==="docs"||t==="reference";document.body.classList.toggle("tool-mode",i);for(let a of document.querySelectorAll(".sidebar a[data-view]"))a.dataset.view===t?a.setAttribute("aria-current","page"):a.removeAttribute("aria-current");return Ot(!1),t==="edit"&&n?Q((a,l)=>Ws(n,a,l)):r!==null?Q((a,l)=>js(r,a,l)):t==="drafts"?Q(Ms):t==="subscribers"&&n==="import"?Q(So):t==="subscribers"?Q((a,l)=>Ra(n,a,l)):t==="sent"?n?Q((a,l)=>Qs(n,a,l)):Q(zs):t==="template"?Q(oo):t==="settings"?Q(co):t==="reference"?Q(Ys):t==="docs"?Q((a,l)=>Ks(n,a,l,s)):Q(Os)}function Aa(){let e=Cr(location.search);if(e){let t=new URL(location.href);t.search="",t.hash=e,history.replaceState(null,"",t)}}function Ha(){window.addEventListener("hashchange",()=>{if(location.hash!==Eo){if(dn()?.beforeLeave?.()==="confirm"&&!confirm(uu)){location.hash=Eo;return}ko()}}),window.addEventListener("beforeunload",e=>{dn()?.dirty?.()&&(e.preventDefault(),e.returnValue="")}),window.addEventListener("keydown",e=>{let t=dn()?.manualSave;(e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==="s"&&t&&(e.preventDefault(),t())})}async function pu(){Mo(),qr(),Rr(),Ha();let e=await qo();if(e){X.session=e,document.body.classList.remove("signed-out"),Fo();try{X.appConfig=await C("/api/settings")}catch{}return bn(),Aa(),ko()}}pu();})();
|
|
757
868
|
//# sourceMappingURL=app.js.map
|