domma-cms 0.91.1 → 0.92.1

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.
Files changed (38) hide show
  1. package/CLAUDE.md +18 -9
  2. package/admin/js/lib/collection-editor-arrange.js +2 -2
  3. package/admin/js/lib/view-editor-arrange.js +1 -1
  4. package/admin/js/templates/collection-editor.html +3 -1
  5. package/admin/js/templates/docs/api-users.html +8 -8
  6. package/admin/js/templates/docs/api-views.html +5 -4
  7. package/admin/js/templates/docs/usage-users-roles.html +28 -48
  8. package/admin/js/templates/docs/usage-views.html +6 -4
  9. package/admin/js/views/collection-editor.js +4 -4
  10. package/admin/js/views/view-editor.js +1 -1
  11. package/package.json +1 -1
  12. package/plugins/feedback/CLAUDE.md +96 -0
  13. package/plugins/feedback/admin/css/index.css +80 -0
  14. package/plugins/feedback/admin/lib/kit.js +105 -0
  15. package/plugins/feedback/admin/templates/feedback.html +1 -0
  16. package/plugins/feedback/admin/views/feedback.js +63 -0
  17. package/plugins/feedback/admin/views/inbox.js +325 -0
  18. package/plugins/feedback/admin/views/send.js +223 -0
  19. package/plugins/feedback/collections/feedback-reports.json +24 -0
  20. package/plugins/feedback/config.js +15 -0
  21. package/plugins/feedback/lib/receiver.js +121 -0
  22. package/plugins/feedback/lib/sender.js +161 -0
  23. package/plugins/feedback/lib/shape.js +211 -0
  24. package/plugins/feedback/plugin.js +276 -0
  25. package/plugins/feedback/plugin.json +48 -0
  26. package/plugins/feedback/tests/api.test.js +219 -0
  27. package/plugins/feedback/tests/shape.test.js +87 -0
  28. package/plugins/free-tier.lock.json +22 -1
  29. package/server/routes/api/collections.js +8 -1
  30. package/server/routes/api/search.js +27 -6
  31. package/server/routes/api/views.js +19 -20
  32. package/server/routes/public.js +9 -0
  33. package/server/services/content.js +6 -4
  34. package/server/services/markdown.js +66 -33
  35. package/server/services/rowAccess.js +49 -0
  36. package/server/services/search.js +145 -17
  37. package/server/services/viewerSession.js +2 -2
  38. package/server/services/views.js +67 -5
@@ -1 +1 @@
1
- import{api as C,apiRequest as Fe}from"../api.js";import{addBannerAction as Y}from"../lib/plugin-chrome.js";import{helpPopovers as ht}from"../lib/help-popover.js";import{openMenuAt as ae,sidebarCount as he}from"../lib/tool-kit.js";import{OPERATORS as gt,STAGE_TYPES as _e,cellText as wt,checkView as bt,draftKey as w,errorCount as Be,isGuided as St,matchToRows as kt,move as He,parseStageJson as xt,resultColumns as Et,rowsToMatch as qt,rowsToSort as $t,slugify as Je,snapshot as P,sortToRows as Ct,stageSummary as Mt,stageText as Lt}from"../lib/view-editor-arrange.js";const Rt=/^\/views\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,ne=v=>String(v||"").split("?")[0].replace(/\/$/,""),Tt=["super-admin","admin","user"],At=v=>String(v??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function l(v,m={},...i){const h=document.createElement(v);for(const[q,d]of Object.entries(m))d==null||d===!1||(q==="class"?h.className=d:q==="text"?h.textContent=d:h.setAttribute(q,d===!0?"":d));for(const q of i)q&&h.append(q);return h}let A=null,Ye=!1;function Nt(){Ye||!window.R?.use||(Ye=!0,R.use((v,m,i)=>{const h=ne(m?.path);return!(Rt.test(m?.path||"")&&ne(v?.path)!==h)||!A?.dirty()?i():E.confirm("This view has unsaved changes. Leave anyway? They are kept as a draft for your next visit.").then(d=>{d?i():history.replaceState(null,"",`#${m.path}`)})}),M.subscribe("router:afterChange",({to:v}={})=>{if(A&&ne(v?.path)!==A.path){const m=A;A=null,m.dispose()}}))}function Ot(v,m){const i=v.querySelector(".dm-plugin-banner-name");if(!i)return;const h=i.querySelector(".dm-plugin-banner-sep");if(h){for(;h.nextSibling;)h.nextSibling.remove();h.after(document.createTextNode(m))}}export const viewEditorView={templateUrl:"/admin/js/templates/view-editor.html",async onMount(v){if(Nt(),A){const e=A;A=null,e.dispose()}const m=v.get(0),i=m.querySelector(".vex")||m,h=$(i),q=[],d=e=>(q.push(e),e);let ge=!1;const j=()=>!ge&&i.isConnected,we=location.hash.match(/^#\/views\/edit\/([^/?#]+)/),f=we?decodeURIComponent(we[1]):null,b=!f,Ge=ne(location.hash.slice(1));let L=[],N=null,be=[];const Se=E.loader(m,{type:"dots"});try{const[e,t,o,a,s,c]=await Promise.all([C.collections.list().catch(()=>[]),C.collections.getConnections().catch(()=>({})),C.blocks.list().catch(()=>[]),C.projects.list().catch(()=>[]),Fe("/collections/roles/entries?limit=1000").catch(()=>null),C.views.list().catch(()=>[])]);be=(c||[]).map(n=>n.slug),F(i.querySelector("#view-source"),(e||[]).map(n=>[n.slug,`${n.title||n.slug} (${n.slug})`])),F(i.querySelector("#view-connection"),Object.keys(t||{}).filter(n=>n!=="default").map(n=>[n,n])),F(i.querySelector("#view-block-name"),(o||[]).map(n=>[n.name,`${n.name}.html`])),F(i.querySelector("#view-project"),(a||[]).map(n=>[n.slug,n.name||n.slug]));const r=(s?.entries||s?.data||[]).map(n=>n.data||n).filter(n=>n?.name).sort((n,p)=>(n.level??9)-(p.level??9)).map(n=>({name:n.name,label:n.label||n.name}));if(We(r.length?r:Tt.map(n=>({name:n,label:n}))),F(i.querySelector("#add-stage-type"),_e.map(n=>[n.type,`${n.type} - ${n.label}`])),!b&&(N=await C.views.get(f),!N))throw new Error("View not found")}catch(e){Se.destroy(),E.toast(e.message==="View not found"?"That view does not exist.":`Could not load the view: ${e.message||e}`,{type:"error"}),R.navigate("/views");return}if(Se.destroy(),!j())return;function F(e,t){if(e)for(const[o,a]of t)e.querySelector(`option[value="${CSS.escape(o)}"]`)||e.append(l("option",{value:o,text:a}))}function We(e){const t=i.querySelector("#view-roles-checkboxes");t.replaceChildren();for(const o of e)t.append(l("label",{},l("input",{type:"checkbox",class:"view-role-cb",value:o.name}),document.createTextNode(o.label)))}function Qe(){const e=(L||[]).map(t=>({value:`data.${t.name}`,label:`${t.label||t.name} (${t.name})`}));return e.push({value:"__meta.createdAt",label:"Created (meta)"},{value:"__meta.updatedAt",label:"Updated (meta)"}),e}function _(e,t,o,a){const s=l("select",{class:`form-input form-input--sm ${o||""}`,"aria-label":a});for(const c of e)s.append(l("option",{value:c.value,text:c.label}));return t&&!e.some(c=>String(c.value)===String(t))&&s.append(l("option",{value:t,text:t})),s.value=t??"",s}function G(e,t){return _([{value:"",label:"- field -"},...Qe()],e,t,"Field")}function ke(e,t,o){const a=(L||[]).find(r=>`data.${r.name}`===e);if(a?.type==="select"&&(t==="eq"||t==="ne")&&a.options?.length){const r=a.options.map(n=>typeof n=="string"?{value:n,label:n}:{value:n.value??"",label:n.label||n.value}).filter(n=>n.value&&n.value!=="undefined");return _([{value:"",label:"- value -"},...r],o,"match-val","Value")}const s=t==="in"?"a, b, c":t==="contains"?"text (a pattern)":["gt","lt","gte","lte"].includes(t)?"0":"value",c=l("input",{type:"text",class:"form-input form-input--sm match-val",placeholder:s,"aria-label":"Value"});return c.value=o??"",c}function Xe(e,t){const{logic:o,rows:a}=kt(t),s=_([{value:"and",label:"ALL conditions (and)"},{value:"or",label:"ANY condition (or)"}],o,"match-logic","Match"),c=l("div",{class:"vex-conds"}),r=l("button",{type:"button",class:"btn btn-sm btn-ghost"},l("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Condition")),n=(p={field:"",op:"eq",val:""})=>{const oe=G(p.field,"match-field"),mt=_(gt,p.op,"match-op","Operator"),yt=l("span",{class:"vex-val"},ke(p.field,p.op,p.val)),ze=l("button",{type:"button",class:"vex-x",title:"Remove this condition","aria-label":"Remove this condition",text:"\xD7"});ze.addEventListener("click",()=>{Ue.remove(),g()});const Ue=l("div",{class:"vex-cond match-condition-row"},oe,mt,yt,ze);c.append(Ue)};r.addEventListener("click",()=>{n(),g()}),e.append(l("div",{class:"vex-logic"},l("span",{class:"vex-muted",text:"Keep rows matching"}),s),c,r),(a.length?a:[void 0]).forEach(p=>n(p))}function Ze(e,t){const o=l("div",{class:"vex-conds"}),a=l("button",{type:"button",class:"btn btn-sm btn-ghost"},l("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Sort field")),s=(r={field:"",dir:1})=>{const n=l("button",{type:"button",class:"vex-x",title:"Remove","aria-label":"Remove this sort",text:"\xD7"}),p=l("div",{class:"vex-sort-row sort-row"},G(r.field,"sort-field"),_([{value:"1",label:"\u2191 Ascending"},{value:"-1",label:"\u2193 Descending"}],String(r.dir),"sort-dir","Direction"),n);n.addEventListener("click",()=>{p.remove(),g()}),o.append(p)};a.addEventListener("click",()=>{s(),g()}),e.append(o,a);const c=Ct(t);(c.length?c:[void 0]).forEach(r=>s(r))}const k=i.querySelector("#pipeline-stages-list");let et=0;function xe(e){const t=l("div",{class:"vex-stage stage-card","data-stage-type":e.type,"data-sid":String(++et)}),o=l("div",{class:"vex-stage-head",tabindex:"0"},l("span",{class:"vex-stage-no"}),l("code",{class:"vex-stage-type",text:e.type}),l("span",{class:"vex-stage-sum"}),l("button",{type:"button",class:"vex-kebab","aria-label":`Actions for this ${e.type} stage`,title:"Actions",text:"\u22EE"})),a=l("div",{class:"vex-stage-body"});if(St(e.type)){t.dataset.guided="true";const s=l("div",{class:"stage-guided"});a.append(s),e.type==="$match"?Xe(s,e.config):Ze(s,e.config)}else{const s=_e.find(r=>r.type===e.type),c=l("textarea",{class:"form-input stage-config",rows:"5",spellcheck:"false","aria-label":`${e.type} settings`,placeholder:s?.example||"{}"});c.value=Lt(e.type,e.config),a.append(l("small",{class:"vex-hint",text:`The inside of ${e.type} only - e.g. ${(s?.example||"{}").replace(/\s+/g," ")}`}),c,l("small",{class:"vex-stage-err",hidden:!0}))}return t.append(o,a),t}function Ee(e,t=null){const o=xe(e);return t?t.after(o):k.append(o),I.scan(o),g(),o}function qe(e){const t=e.dataset.stageType;if(e.dataset.guided){const s=e.querySelector(".stage-guided");if(t==="$match"){const r=[...s.querySelectorAll(".match-condition-row")].map(n=>({field:n.querySelector(".match-field")?.value,op:n.querySelector(".match-op")?.value,val:n.querySelector(".match-val")?.value}));return{type:t,config:qt(s.querySelector(".match-logic")?.value,r),error:""}}const c=[...s.querySelectorAll(".sort-row")].map(r=>({field:r.querySelector(".sort-field")?.value,dir:r.querySelector(".sort-dir")?.value}));return{type:t,config:$t(c),error:""}}const{config:o,error:a}=xt(t,e.querySelector(".stage-config")?.value);return{type:t,config:o,error:a}}const V=()=>[...k.querySelectorAll(":scope > .stage-card")],x=i.querySelector("#view-columns-builder");let B=null;function $e(e={key:"",label:""}){const t=L.length?G(e.key,"col-key"):l("input",{type:"text",class:"form-input form-input--sm col-key font-mono",placeholder:"data.field","aria-label":"Field"});L.length||(t.value=e.key||"");const o=l("input",{type:"text",class:"form-input form-input--sm col-label",placeholder:"Heading","aria-label":"Heading"});o.value=e.label||"";const a=l("button",{type:"button",class:"vex-kebab","aria-label":"Actions for this column",title:"Actions",text:"\u22EE"});return l("div",{class:"vex-col col-row",tabindex:"0"},t,o,a)}function Ce(e){x.replaceChildren();const t=e??L.map(o=>({key:`data.${o.name}`,label:o.label||o.name}));for(const o of t)x.append($e(o));B=l("button",{type:"button",class:"btn btn-sm btn-ghost vex-col-add"},l("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Column")),B.addEventListener("click",()=>{B.before($e()),I.scan(x),g()}),x.append(B),I.scan(x)}const le=()=>[...x.querySelectorAll(":scope > .col-row")];async function Me(e){if(!e){L=[];return}try{L=(await C.collections.get(e))?.fields||[]}catch{L=[]}}function tt(){for(const e of i.querySelectorAll(".match-field, .sort-field, select.col-key")){const t=e.value;e.replaceWith(G(t,[...e.classList].find(o=>["match-field","sort-field","col-key"].includes(o))))}}const u=e=>i.querySelector(`#${e}`);let W={};function Q(){const e=[],t=[];V().forEach((r,n)=>{const p=qe(r);p.error&&t.push({index:n,type:p.type,error:p.error}),e.push({type:p.type,config:p.error?{}:p.config})});const o=le().map(r=>({key:r.querySelector(".col-key")?.value?.trim(),label:r.querySelector(".col-label")?.value?.trim()})).filter(r=>r.key).map(r=>({key:r.key,label:r.label||r.key})),a=u("view-rowlevel-enabled").checked,s=u("view-rowlevel-mode").value||"owner";return{payload:{title:u("view-title").value.trim(),slug:u("view-slug").value.trim()||void 0,description:u("view-description").value.trim(),...u("view-bundled").checked?{bundled:!0}:{},connection:u("view-connection").value||"default",pipeline:{source:u("view-source").value,stages:e},display:{mode:u("view-display-mode").value||"table",columns:o,pageSize:Number.parseInt(u("view-page-size").value,10),block:u("view-block-name").value||""},access:{roles:[...i.querySelectorAll(".view-role-cb:checked")].map(r=>r.value),public:u("view-public").checked,rowLevel:a?{mode:s,userKey:u("view-rowlevel-userkey").value||"id",...s==="field"?{field:u("view-rowlevel-field").value.trim()}:{}}:null},meta:{...W,project:u("view-project").value||null}},stageErrors:t}}async function se(e){W=e?.meta||{},u("view-title").value=e?.title||"",u("view-slug").value=e?.slug||"",u("view-description").value=e?.description||"",u("view-source").value=e?.pipeline?.source||"",u("view-bundled").checked=!!e?.bundled,u("view-project").value=e?.meta?.project||"",u("view-connection").value=e?.connection||"default",u("view-display-mode").value=e?.display?.mode||"table",u("view-page-size").value=String(e?.display?.pageSize??25),u("view-block-name").value=e?.display?.block||"";const t=e?.access?.roles||["admin"];i.querySelectorAll(".view-role-cb").forEach(a=>{a.checked=t.includes(a.value)});for(const a of t)i.querySelector(`.view-role-cb[value="${CSS.escape(a)}"]`)||i.querySelector("#view-roles-checkboxes").append(l("label",{title:"This role no longer exists"},l("input",{type:"checkbox",class:"view-role-cb",value:a,checked:!0}),document.createTextNode(`${a} (missing)`)));u("view-public").checked=!!e?.access?.public;const o=e?.access?.rowLevel;u("view-rowlevel-enabled").checked=!!o,u("view-rowlevel-mode").value=o?.mode||"owner",u("view-rowlevel-userkey").value=o?.userKey||"id",u("view-rowlevel-field").value=o?.field||"",await Me(e?.pipeline?.source||""),k.replaceChildren();for(const a of e?.pipeline?.stages||[])k.append(xe(a));I.scan(k),Ce(e?e.display?.columns||[]:null),Le(),g()}function Le(){const e=u("view-display-mode").value||"table";i.querySelector("#view-columns-section").hidden=e!=="table",i.querySelector("#view-block-section").hidden=e!=="block";const t=u("view-rowlevel-enabled").checked;i.querySelector("#view-rowlevel-config").hidden=!t,i.querySelector("#view-rowlevel-field-group").hidden=!t||u("view-rowlevel-mode").value!=="field",u("view-slug").readOnly=!b}const ie=M.observable(0);function g(){ie.value=ie.peek()+1}const y=M.computed(()=>(ie.value,Q())),O=M.observable(""),X=M.observable(!1),H=M.computed(()=>bt(y.value.payload,{stageErrors:y.value.stageErrors,isNew:b,taken:be})),Z=M.computed(()=>{if(P(y.value.payload)===O.value)return!1;if(b){const t=y.value.payload;return!!(t.title||t.description||t.pipeline.source||t.pipeline.stages.length)}return!0}),ot=M.computed(()=>y.value.payload.pipeline.stages.length),Re=M.computed(()=>{const e=Be(H.value);if(!e)return"";const t=Object.values(H.value).flat()[0];return e===1?t:`${t} (+${e-1} more)`}),at=M.computed(()=>{if(y.value,!b)return"Cannot change once the view exists.";const e=y.value.payload,t=e.slug?Je(e.slug):Je(e.title);return t?`Saved as "${t}".`:""});d(M.effect(()=>{const{payload:e,stageErrors:t}=y.value,o=new Set(t.map(a=>a.index));V().forEach((a,s)=>{a.querySelector(".vex-stage-no").textContent=String(s+1);const c=t.find(n=>n.index===s);a.querySelector(".vex-stage-sum").textContent=c?"":Mt(e.pipeline.stages[s]),a.classList.toggle("has-error",o.has(s));const r=a.querySelector(".vex-stage-err");r&&(r.hidden=!c,r.textContent=c?.error||"")})})),d(M.effect(()=>{const e=y.value.payload.title;Ot(m,e||(b?"New view":f))}));const J=i.querySelector(".vex-results"),T=M.observable(0),ee=M.observable(""),z=M.observable("Open this tab to run the pipeline."),re=M.observable("");let U=[],ce=!1,ue=0,de=null;async function pe(){const{payload:e,stageErrors:t}=y.peek(),o=++ue;if(re.value="",!e.pipeline.source){z.value="Choose the source collection (Source tab) to see results.",J.replaceChildren(),ee.value="";return}const a=T.peek()||e.pipeline.stages.length,s=t.find(c=>c.index<a);if(s){z.value=`Fix stage ${s.index+1} first: ${s.error}`;return}z.value="Running\u2026";try{const c=await Fe("/views/_preview",{method:"POST",body:JSON.stringify({source:e.pipeline.source,connection:e.connection,stages:e.pipeline.stages.slice(0,a),limit:Math.min(e.display.pageSize||10,25)})});if(o!==ue||!j())return;U=c.results||[],z.value=U.length?"":"No rows come out of the pipeline.",ee.value=`${c.total} row${c.total===1?"":"s"}${c.total>U.length?` - the first ${U.length}`:""}${T.peek()?` \xB7 up to stage ${T.peek()}`:""}`,nt(U,e)}catch(c){if(o!==ue)return;z.value="",ee.value="",J.replaceChildren(),re.value=`The pipeline did not run: ${c.message||c}`}}function nt(e,t){if(J.replaceChildren(),!e.length)return;const o=Et(e,!T.peek()&&t.display.mode==="table"?t.display.columns:[]),a=l("table");a.append(l("thead",{},l("tr",{},...o.map(c=>l("th",{text:c.label})))));const s=l("tbody");e.forEach((c,r)=>{const n=l("tr",{tabindex:"0","data-i":String(r)});for(const p of o){const oe=wt(c,p.key);n.append(l("td",{text:oe,title:oe}))}s.append(n)}),a.append(s),J.append(a)}d(M.effect(()=>{const{payload:e}=y.value;T.value;const t=P({s:e.pipeline,c:e.connection,d:e.display.columns,m:e.display.mode,p:e.display.pageSize});clearTimeout(de),ce&&(de=setTimeout(()=>{j()&&t&&pe()},600))})),d(()=>clearTimeout(de));const lt=E.contextMenu(J,{cursor:"glyph",match:"tbody tr",inherit:!1,density:"compact",items:e=>[{header:`Row ${Number(e.dataset.i)+1}`},{label:"Copy as JSON",icon:"copy",action:()=>st(JSON.stringify(U[Number(e.dataset.i)],null,2),"Row copied.")}]});d(()=>lt?.destroy?.());function st(e,t){navigator.clipboard?.writeText(e).then(()=>E.toast(t,{type:"success"}),()=>E.toast("Copy failed.",{type:"error"}))}const Te=E.tabs(i.querySelector(".vex-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),it=[...i.querySelectorAll(".vex-tabs > .tab-list > .tab-item")],ve=4,Ae=()=>{Te.activate?.(ve),fe(ve)};function fe(e){ce=e===ve,ce&&pe()}it.forEach((e,t)=>e.addEventListener("click",()=>fe(t)));function rt(e){const t=V().indexOf(e),o=V().length;return[{header:`Stage ${t+1} \xB7 ${e.dataset.stageType}`},{label:"Move up",icon:"arrow-up",disabled:t===0,action:()=>me(t,-1)},{label:"Move down",icon:"arrow-down",disabled:t===o-1,action:()=>me(t,1)},{label:"Duplicate",icon:"copy",action:()=>{const a=qe(e);if(a.error){E.toast(`Fix this stage first: ${a.error}`,{type:"warning"});return}Ee({type:a.type,config:a.config},e).querySelector(".vex-stage-head")?.focus()}},{label:"Results up to here",icon:"play",action:()=>{T.value=t+1,Ae()}},{type:"divider"},{label:"Delete stage",icon:"trash",danger:!0,action:()=>{e.remove(),g()}}]}function me(e,t){const o=He(V(),e,t);o.forEach(a=>k.append(a)),g(),o[e+t]?.querySelector(".vex-stage-head")?.focus()}const ct=E.contextMenu(k,{cursor:"glyph",match:".stage-card",inherit:!1,density:"compact",items:e=>rt(e)});d(()=>ct?.destroy?.());const Ne=e=>{const t=e.target.closest(".vex-kebab");t&&ae(t)},Oe=e=>{const t=e.target.closest(".vex-stage-head");if(t){if(e.key==="F10"&&e.shiftKey)e.preventDefault(),ae(t);else if(e.altKey&&(e.key==="ArrowUp"||e.key==="ArrowDown")){e.preventDefault();const o=V().indexOf(t.closest(".stage-card"));me(o,e.key==="ArrowUp"?-1:1)}}};k.addEventListener("click",Ne),k.addEventListener("keydown",Oe),d(()=>{k.removeEventListener("click",Ne),k.removeEventListener("keydown",Oe)});const ut=E.contextMenu(x,{cursor:"glyph",match:".col-row",inherit:!1,density:"compact",items:e=>{const t=le(),o=t.indexOf(e),a=s=>{He(t,o,s).forEach(r=>B.before(r)),g()};return[{header:e.querySelector(".col-label")?.value||e.querySelector(".col-key")?.value||"Column"},{label:"Move up",icon:"arrow-up",disabled:o===0,action:()=>a(-1)},{label:"Move down",icon:"arrow-down",disabled:o===t.length-1,action:()=>a(1)},{type:"divider"},{label:"Remove column",icon:"trash",danger:!0,action:()=>{e.remove(),g()}}]}});d(()=>ut?.destroy?.());const De=e=>{const t=e.target.closest(".vex-kebab");t&&ae(t)},Ie=e=>{const t=e.target.closest(".col-row");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),ae(t))};x.addEventListener("click",De),x.addEventListener("keydown",Ie),d(()=>{x.removeEventListener("click",De),x.removeEventListener("keydown",Ie)});function dt(e){const t=e.querySelector(".vex-val"),o=t.querySelector(".match-val")?.value??"";t.replaceChildren(ke(e.querySelector(".match-field")?.value,e.querySelector(".match-op")?.value,o))}const te=e=>{if(!e.target.closest?.(".vex-results")){if(e.type==="change"&&e.target.matches?.(".match-field, .match-op")&&dt(e.target.closest(".match-condition-row")),e.type==="change"&&e.target.matches?.("select.col-key")){const t=e.target.closest(".col-row")?.querySelector(".col-label"),o=L.find(a=>`data.${a.name}`===e.target.value);t&&!t.value&&o&&(t.value=o.label||o.name)}e.target.id==="view-source"&&e.type==="change"&&Me(e.target.value).then(()=>{j()&&(tt(),le().length||Ce(null),g())}),["view-display-mode","view-rowlevel-enabled","view-rowlevel-mode"].includes(e.target.id)&&Le(),g()}};i.addEventListener("input",te),i.addEventListener("change",te);const Ke=e=>{e.target.closest?.("input, textarea, select")&&e.stopPropagation()};i.addEventListener("contextmenu",Ke),d(()=>{i.removeEventListener("input",te),i.removeEventListener("change",te),i.removeEventListener("contextmenu",Ke)}),i.querySelector("#add-stage-btn").addEventListener("click",()=>{Ee({type:i.querySelector("#add-stage-type").value||"$match",config:{}}).querySelector("select, textarea, input")?.focus()});async function ye(){if(X.peek())return;const{payload:e}=y.peek();if(Be(H.peek())){const o=["source","pipeline","display","access"].findIndex(a=>H.peek()[a].length);o>=0&&(Te.activate?.(o),fe(o)),E.toast(`Not saved - ${Re.peek()}`,{type:"warning",duration:6e3});return}X.value=!0;try{if(b){const a=await C.views.create(e);O.value=P(e);try{S.remove?S.remove(w(null)):S.set(w(null),null)}catch{}he("#/views"),E.toast(`Created "${a.title}".`,{type:"success"}),R.navigate(`/views/edit/${encodeURIComponent(a.slug)}`);return}const o=await C.views.update(f,e);N=o||N,W=o?.meta||W,O.value=P(Q().payload);try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}he("#/views"),E.toast("Saved.",{type:"success"})}catch(o){E.toast(`Not saved: ${o.message||o}`,{type:"error"})}finally{X.value=!1}}async function pt(){if(await E.confirm("Throw away the changes you have not saved?")){await se(N),O.value=P(Q().payload);try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}}}async function vt(){if(!(b||!N)&&await E.confirm(`Delete the view "${At(N.title||f)}"? Pages that show it with [view] will show nothing. This cannot be undone.`))try{await C.views.delete(f),O.value=P(y.peek().payload);try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}he("#/views"),E.toast("View deleted.",{type:"success"}),R.navigate("/views")}catch(e){E.toast(`Not deleted: ${e.message||e}`,{type:"error"})}}const ft=M.applyBindings({dirty:Z,saving:X,errs:H,stageCount:ot,errorSummary:Re,slugHint:at,upTo:T,previewMeta:ee,previewNote:z,previewError:re},i,{methods:{save:()=>ye(),discard:()=>pt(),runPreview:()=>pe(),allStages:()=>{T.value=0}}});d(()=>ft.dispose()),d(ht(i,{position:"bottom"})),Y(v,{id:"view-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>ye()}),Y(v,{id:"view-editor-results",icon:"play",title:"Results",onClick:()=>{T.value=0,Ae()}}),b||(Y(v,{id:"view-editor-preview",icon:"eye",title:"Open the preview page",onClick:()=>{location.hash=`#/views/${encodeURIComponent(f)}/preview`}}),Y(v,{id:"view-editor-delete",icon:"trash",title:"Delete this view",onClick:()=>vt()})),Y(v,{id:"view-editor-all",icon:"list",title:"All views",onClick:()=>{location.hash="#/views"}});const Pe=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),ye())};if(document.addEventListener("keydown",Pe,!0),d(()=>document.removeEventListener("keydown",Pe,!0)),await se(N),!j())return;O.value=P(Q().payload);const je=()=>{if(Z.peek())try{S.set(w(f),{payload:y.peek().payload,base:O.peek(),at:new Date().toISOString()})}catch{}},Ve=e=>{Z.peek()&&(je(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Ve),d(()=>window.removeEventListener("beforeunload",Ve)),A={path:Ge,dirty:()=>Z.peek(),dispose(){ge=!0,je(),q.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(i);let K=null;try{K=S.get(w(f))}catch{}if(K?.payload){try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}const e=K.base&&K.base!==O.peek(),t=K.at?` (${D(K.at).format("D MMM HH:mm")})`:"",o=b?"the new view you started":"this view",a=e?`You left unsaved changes to ${o}${t}, but the saved view has changed since. Restore your version anyway?`:`You left unsaved changes to ${o}${t}. Restore them?`;if(await E.confirm(a)&&j()){const s=b?K.payload.slug:f;await se({...K.payload,slug:s})}}}};
1
+ import{api as C,apiRequest as _e}from"../api.js";import{addBannerAction as Y}from"../lib/plugin-chrome.js";import{helpPopovers as gt}from"../lib/help-popover.js";import{openMenuAt as ae,sidebarCount as he}from"../lib/tool-kit.js";import{OPERATORS as wt,STAGE_TYPES as Be,adminTierRoles as bt,cellText as St,checkView as kt,draftKey as w,errorCount as He,isGuided as xt,matchToRows as Et,move as Je,parseStageJson as qt,resultColumns as $t,rowsToMatch as Ct,rowsToSort as Mt,slugify as Ye,snapshot as P,sortToRows as Rt,stageSummary as Lt,stageText as Tt}from"../lib/view-editor-arrange.js";const At=/^\/views\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,ne=v=>String(v||"").split("?")[0].replace(/\/$/,""),Nt=["super-admin","admin","user"],Ot=v=>String(v??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function s(v,m={},...r){const h=document.createElement(v);for(const[q,d]of Object.entries(m))d==null||d===!1||(q==="class"?h.className=d:q==="text"?h.textContent=d:h.setAttribute(q,d===!0?"":d));for(const q of r)q&&h.append(q);return h}let A=null,Ge=!1;function Dt(){Ge||!window.R?.use||(Ge=!0,R.use((v,m,r)=>{const h=ne(m?.path);return!(At.test(m?.path||"")&&ne(v?.path)!==h)||!A?.dirty()?r():E.confirm("This view has unsaved changes. Leave anyway? They are kept as a draft for your next visit.").then(d=>{d?r():history.replaceState(null,"",`#${m.path}`)})}),M.subscribe("router:afterChange",({to:v}={})=>{if(A&&ne(v?.path)!==A.path){const m=A;A=null,m.dispose()}}))}function It(v,m){const r=v.querySelector(".dm-plugin-banner-name");if(!r)return;const h=r.querySelector(".dm-plugin-banner-sep");if(h){for(;h.nextSibling;)h.nextSibling.remove();h.after(document.createTextNode(m))}}export const viewEditorView={templateUrl:"/admin/js/templates/view-editor.html",async onMount(v){if(Dt(),A){const e=A;A=null,e.dispose()}const m=v.get(0),r=m.querySelector(".vex")||m,h=$(r),q=[],d=e=>(q.push(e),e);let ge=!1;const j=()=>!ge&&r.isConnected,we=location.hash.match(/^#\/views\/edit\/([^/?#]+)/),f=we?decodeURIComponent(we[1]):null,b=!f,We=ne(location.hash.slice(1));let L=[],N=null,be=[],Se=[];const ke=E.loader(m,{type:"dots"});try{const[e,t,o,n,l,c]=await Promise.all([C.collections.list().catch(()=>[]),C.collections.getConnections().catch(()=>({})),C.blocks.list().catch(()=>[]),C.projects.list().catch(()=>[]),_e("/collections/roles/entries?limit=1000").catch(()=>null),C.views.list().catch(()=>[])]);be=(c||[]).map(a=>a.slug),F(r.querySelector("#view-source"),(e||[]).map(a=>[a.slug,`${a.title||a.slug} (${a.slug})`])),F(r.querySelector("#view-connection"),Object.keys(t||{}).filter(a=>a!=="default").map(a=>[a,a])),F(r.querySelector("#view-block-name"),(o||[]).map(a=>[a.name,`${a.name}.html`])),F(r.querySelector("#view-project"),(n||[]).map(a=>[a.slug,a.name||a.slug]));const i=(l?.entries||l?.data||[]).map(a=>a.data||a).filter(a=>a?.name).sort((a,p)=>(a.level??9)-(p.level??9)).map(a=>({name:a.name,label:a.label||a.name,level:a.level}));if(Se=bt(i),Qe(i.length?i:Nt.map(a=>({name:a,label:a}))),F(r.querySelector("#add-stage-type"),Be.map(a=>[a.type,`${a.type} - ${a.label}`])),!b&&(N=await C.views.get(f),!N))throw new Error("View not found")}catch(e){ke.destroy(),E.toast(e.message==="View not found"?"That view does not exist.":`Could not load the view: ${e.message||e}`,{type:"error"}),R.navigate("/views");return}if(ke.destroy(),!j())return;function F(e,t){if(e)for(const[o,n]of t)e.querySelector(`option[value="${CSS.escape(o)}"]`)||e.append(s("option",{value:o,text:n}))}function Qe(e){const t=r.querySelector("#view-roles-checkboxes");t.replaceChildren();for(const o of e)t.append(s("label",{},s("input",{type:"checkbox",class:"view-role-cb",value:o.name}),document.createTextNode(o.label)))}function Xe(){const e=(L||[]).map(t=>({value:`data.${t.name}`,label:`${t.label||t.name} (${t.name})`}));return e.push({value:"__meta.createdAt",label:"Created (meta)"},{value:"__meta.updatedAt",label:"Updated (meta)"}),e}function _(e,t,o,n){const l=s("select",{class:`form-input form-input--sm ${o||""}`,"aria-label":n});for(const c of e)l.append(s("option",{value:c.value,text:c.label}));return t&&!e.some(c=>String(c.value)===String(t))&&l.append(s("option",{value:t,text:t})),l.value=t??"",l}function G(e,t){return _([{value:"",label:"- field -"},...Xe()],e,t,"Field")}function xe(e,t,o){const n=(L||[]).find(i=>`data.${i.name}`===e);if(n?.type==="select"&&(t==="eq"||t==="ne")&&n.options?.length){const i=n.options.map(a=>typeof a=="string"?{value:a,label:a}:{value:a.value??"",label:a.label||a.value}).filter(a=>a.value&&a.value!=="undefined");return _([{value:"",label:"- value -"},...i],o,"match-val","Value")}const l=t==="in"?"a, b, c":t==="contains"?"text (a pattern)":["gt","lt","gte","lte"].includes(t)?"0":"value",c=s("input",{type:"text",class:"form-input form-input--sm match-val",placeholder:l,"aria-label":"Value"});return c.value=o??"",c}function Ze(e,t){const{logic:o,rows:n}=Et(t),l=_([{value:"and",label:"ALL conditions (and)"},{value:"or",label:"ANY condition (or)"}],o,"match-logic","Match"),c=s("div",{class:"vex-conds"}),i=s("button",{type:"button",class:"btn btn-sm btn-ghost"},s("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Condition")),a=(p={field:"",op:"eq",val:""})=>{const oe=G(p.field,"match-field"),yt=_(wt,p.op,"match-op","Operator"),ht=s("span",{class:"vex-val"},xe(p.field,p.op,p.val)),Ue=s("button",{type:"button",class:"vex-x",title:"Remove this condition","aria-label":"Remove this condition",text:"\xD7"});Ue.addEventListener("click",()=>{Fe.remove(),g()});const Fe=s("div",{class:"vex-cond match-condition-row"},oe,yt,ht,Ue);c.append(Fe)};i.addEventListener("click",()=>{a(),g()}),e.append(s("div",{class:"vex-logic"},s("span",{class:"vex-muted",text:"Keep rows matching"}),l),c,i),(n.length?n:[void 0]).forEach(p=>a(p))}function et(e,t){const o=s("div",{class:"vex-conds"}),n=s("button",{type:"button",class:"btn btn-sm btn-ghost"},s("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Sort field")),l=(i={field:"",dir:1})=>{const a=s("button",{type:"button",class:"vex-x",title:"Remove","aria-label":"Remove this sort",text:"\xD7"}),p=s("div",{class:"vex-sort-row sort-row"},G(i.field,"sort-field"),_([{value:"1",label:"\u2191 Ascending"},{value:"-1",label:"\u2193 Descending"}],String(i.dir),"sort-dir","Direction"),a);a.addEventListener("click",()=>{p.remove(),g()}),o.append(p)};n.addEventListener("click",()=>{l(),g()}),e.append(o,n);const c=Rt(t);(c.length?c:[void 0]).forEach(i=>l(i))}const k=r.querySelector("#pipeline-stages-list");let tt=0;function Ee(e){const t=s("div",{class:"vex-stage stage-card","data-stage-type":e.type,"data-sid":String(++tt)}),o=s("div",{class:"vex-stage-head",tabindex:"0"},s("span",{class:"vex-stage-no"}),s("code",{class:"vex-stage-type",text:e.type}),s("span",{class:"vex-stage-sum"}),s("button",{type:"button",class:"vex-kebab","aria-label":`Actions for this ${e.type} stage`,title:"Actions",text:"\u22EE"})),n=s("div",{class:"vex-stage-body"});if(xt(e.type)){t.dataset.guided="true";const l=s("div",{class:"stage-guided"});n.append(l),e.type==="$match"?Ze(l,e.config):et(l,e.config)}else{const l=Be.find(i=>i.type===e.type),c=s("textarea",{class:"form-input stage-config",rows:"5",spellcheck:"false","aria-label":`${e.type} settings`,placeholder:l?.example||"{}"});c.value=Tt(e.type,e.config),n.append(s("small",{class:"vex-hint",text:`The inside of ${e.type} only - e.g. ${(l?.example||"{}").replace(/\s+/g," ")}`}),c,s("small",{class:"vex-stage-err",hidden:!0}))}return t.append(o,n),t}function qe(e,t=null){const o=Ee(e);return t?t.after(o):k.append(o),I.scan(o),g(),o}function $e(e){const t=e.dataset.stageType;if(e.dataset.guided){const l=e.querySelector(".stage-guided");if(t==="$match"){const i=[...l.querySelectorAll(".match-condition-row")].map(a=>({field:a.querySelector(".match-field")?.value,op:a.querySelector(".match-op")?.value,val:a.querySelector(".match-val")?.value}));return{type:t,config:Ct(l.querySelector(".match-logic")?.value,i),error:""}}const c=[...l.querySelectorAll(".sort-row")].map(i=>({field:i.querySelector(".sort-field")?.value,dir:i.querySelector(".sort-dir")?.value}));return{type:t,config:Mt(c),error:""}}const{config:o,error:n}=qt(t,e.querySelector(".stage-config")?.value);return{type:t,config:o,error:n}}const V=()=>[...k.querySelectorAll(":scope > .stage-card")],x=r.querySelector("#view-columns-builder");let B=null;function Ce(e={key:"",label:""}){const t=L.length?G(e.key,"col-key"):s("input",{type:"text",class:"form-input form-input--sm col-key font-mono",placeholder:"data.field","aria-label":"Field"});L.length||(t.value=e.key||"");const o=s("input",{type:"text",class:"form-input form-input--sm col-label",placeholder:"Heading","aria-label":"Heading"});o.value=e.label||"";const n=s("button",{type:"button",class:"vex-kebab","aria-label":"Actions for this column",title:"Actions",text:"\u22EE"});return s("div",{class:"vex-col col-row",tabindex:"0"},t,o,n)}function Me(e){x.replaceChildren();const t=e??L.map(o=>({key:`data.${o.name}`,label:o.label||o.name}));for(const o of t)x.append(Ce(o));B=s("button",{type:"button",class:"btn btn-sm btn-ghost vex-col-add"},s("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Column")),B.addEventListener("click",()=>{B.before(Ce()),I.scan(x),g()}),x.append(B),I.scan(x)}const le=()=>[...x.querySelectorAll(":scope > .col-row")];async function Re(e){if(!e){L=[];return}try{L=(await C.collections.get(e))?.fields||[]}catch{L=[]}}function ot(){for(const e of r.querySelectorAll(".match-field, .sort-field, select.col-key")){const t=e.value;e.replaceWith(G(t,[...e.classList].find(o=>["match-field","sort-field","col-key"].includes(o))))}}const u=e=>r.querySelector(`#${e}`);let W={};function Q(){const e=[],t=[];V().forEach((i,a)=>{const p=$e(i);p.error&&t.push({index:a,type:p.type,error:p.error}),e.push({type:p.type,config:p.error?{}:p.config})});const o=le().map(i=>({key:i.querySelector(".col-key")?.value?.trim(),label:i.querySelector(".col-label")?.value?.trim()})).filter(i=>i.key).map(i=>({key:i.key,label:i.label||i.key})),n=u("view-rowlevel-enabled").checked,l=u("view-rowlevel-mode").value||"owner";return{payload:{title:u("view-title").value.trim(),slug:u("view-slug").value.trim()||void 0,description:u("view-description").value.trim(),...u("view-bundled").checked?{bundled:!0}:{},connection:u("view-connection").value||"default",pipeline:{source:u("view-source").value,stages:e},display:{mode:u("view-display-mode").value||"table",columns:o,pageSize:Number.parseInt(u("view-page-size").value,10),block:u("view-block-name").value||""},access:{roles:[...r.querySelectorAll(".view-role-cb:checked")].map(i=>i.value),public:u("view-public").checked,rowLevel:n?{mode:l,userKey:u("view-rowlevel-userkey").value||"id",...l==="field"?{field:u("view-rowlevel-field").value.trim()}:{}}:null},meta:{...W,project:u("view-project").value||null}},stageErrors:t}}async function se(e){W=e?.meta||{},u("view-title").value=e?.title||"",u("view-slug").value=e?.slug||"",u("view-description").value=e?.description||"",u("view-source").value=e?.pipeline?.source||"",u("view-bundled").checked=!!e?.bundled,u("view-project").value=e?.meta?.project||"",u("view-connection").value=e?.connection||"default",u("view-display-mode").value=e?.display?.mode||"table",u("view-page-size").value=String(e?.display?.pageSize??25),u("view-block-name").value=e?.display?.block||"";const t=Array.isArray(e?.access?.roles)?e.access.roles:[],o=t.length||e?.access?.public?t:Se;r.querySelectorAll(".view-role-cb").forEach(l=>{l.checked=o.includes(l.value)});for(const l of o)r.querySelector(`.view-role-cb[value="${CSS.escape(l)}"]`)||r.querySelector("#view-roles-checkboxes").append(s("label",{title:"This role no longer exists"},s("input",{type:"checkbox",class:"view-role-cb",value:l,checked:!0}),document.createTextNode(`${l} (missing)`)));u("view-public").checked=!!e?.access?.public;const n=e?.access?.rowLevel;u("view-rowlevel-enabled").checked=!!n,u("view-rowlevel-mode").value=n?.mode||"owner",u("view-rowlevel-userkey").value=n?.userKey||"id",u("view-rowlevel-field").value=n?.field||"",await Re(e?.pipeline?.source||""),k.replaceChildren();for(const l of e?.pipeline?.stages||[])k.append(Ee(l));I.scan(k),Me(e?e.display?.columns||[]:null),Le(),g()}function Le(){const e=u("view-display-mode").value||"table";r.querySelector("#view-columns-section").hidden=e!=="table",r.querySelector("#view-block-section").hidden=e!=="block";const t=u("view-rowlevel-enabled").checked;r.querySelector("#view-rowlevel-config").hidden=!t,r.querySelector("#view-rowlevel-field-group").hidden=!t||u("view-rowlevel-mode").value!=="field",u("view-slug").readOnly=!b}const ie=M.observable(0);function g(){ie.value=ie.peek()+1}const y=M.computed(()=>(ie.value,Q())),O=M.observable(""),X=M.observable(!1),H=M.computed(()=>kt(y.value.payload,{stageErrors:y.value.stageErrors,isNew:b,taken:be})),Z=M.computed(()=>{if(P(y.value.payload)===O.value)return!1;if(b){const t=y.value.payload;return!!(t.title||t.description||t.pipeline.source||t.pipeline.stages.length)}return!0}),at=M.computed(()=>y.value.payload.pipeline.stages.length),Te=M.computed(()=>{const e=He(H.value);if(!e)return"";const t=Object.values(H.value).flat()[0];return e===1?t:`${t} (+${e-1} more)`}),nt=M.computed(()=>{if(y.value,!b)return"Cannot change once the view exists.";const e=y.value.payload,t=e.slug?Ye(e.slug):Ye(e.title);return t?`Saved as "${t}".`:""});d(M.effect(()=>{const{payload:e,stageErrors:t}=y.value,o=new Set(t.map(n=>n.index));V().forEach((n,l)=>{n.querySelector(".vex-stage-no").textContent=String(l+1);const c=t.find(a=>a.index===l);n.querySelector(".vex-stage-sum").textContent=c?"":Lt(e.pipeline.stages[l]),n.classList.toggle("has-error",o.has(l));const i=n.querySelector(".vex-stage-err");i&&(i.hidden=!c,i.textContent=c?.error||"")})})),d(M.effect(()=>{const e=y.value.payload.title;It(m,e||(b?"New view":f))}));const J=r.querySelector(".vex-results"),T=M.observable(0),ee=M.observable(""),z=M.observable("Open this tab to run the pipeline."),re=M.observable("");let U=[],ce=!1,ue=0,de=null;async function pe(){const{payload:e,stageErrors:t}=y.peek(),o=++ue;if(re.value="",!e.pipeline.source){z.value="Choose the source collection (Source tab) to see results.",J.replaceChildren(),ee.value="";return}const n=T.peek()||e.pipeline.stages.length,l=t.find(c=>c.index<n);if(l){z.value=`Fix stage ${l.index+1} first: ${l.error}`;return}z.value="Running\u2026";try{const c=await _e("/views/_preview",{method:"POST",body:JSON.stringify({source:e.pipeline.source,connection:e.connection,stages:e.pipeline.stages.slice(0,n),limit:Math.min(e.display.pageSize||10,25)})});if(o!==ue||!j())return;U=c.results||[],z.value=U.length?"":"No rows come out of the pipeline.",ee.value=`${c.total} row${c.total===1?"":"s"}${c.total>U.length?` - the first ${U.length}`:""}${T.peek()?` \xB7 up to stage ${T.peek()}`:""}`,lt(U,e)}catch(c){if(o!==ue)return;z.value="",ee.value="",J.replaceChildren(),re.value=`The pipeline did not run: ${c.message||c}`}}function lt(e,t){if(J.replaceChildren(),!e.length)return;const o=$t(e,!T.peek()&&t.display.mode==="table"?t.display.columns:[]),n=s("table");n.append(s("thead",{},s("tr",{},...o.map(c=>s("th",{text:c.label})))));const l=s("tbody");e.forEach((c,i)=>{const a=s("tr",{tabindex:"0","data-i":String(i)});for(const p of o){const oe=St(c,p.key);a.append(s("td",{text:oe,title:oe}))}l.append(a)}),n.append(l),J.append(n)}d(M.effect(()=>{const{payload:e}=y.value;T.value;const t=P({s:e.pipeline,c:e.connection,d:e.display.columns,m:e.display.mode,p:e.display.pageSize});clearTimeout(de),ce&&(de=setTimeout(()=>{j()&&t&&pe()},600))})),d(()=>clearTimeout(de));const st=E.contextMenu(J,{cursor:"glyph",match:"tbody tr",inherit:!1,density:"compact",items:e=>[{header:`Row ${Number(e.dataset.i)+1}`},{label:"Copy as JSON",icon:"copy",action:()=>it(JSON.stringify(U[Number(e.dataset.i)],null,2),"Row copied.")}]});d(()=>st?.destroy?.());function it(e,t){navigator.clipboard?.writeText(e).then(()=>E.toast(t,{type:"success"}),()=>E.toast("Copy failed.",{type:"error"}))}const Ae=E.tabs(r.querySelector(".vex-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),rt=[...r.querySelectorAll(".vex-tabs > .tab-list > .tab-item")],ve=4,Ne=()=>{Ae.activate?.(ve),fe(ve)};function fe(e){ce=e===ve,ce&&pe()}rt.forEach((e,t)=>e.addEventListener("click",()=>fe(t)));function ct(e){const t=V().indexOf(e),o=V().length;return[{header:`Stage ${t+1} \xB7 ${e.dataset.stageType}`},{label:"Move up",icon:"arrow-up",disabled:t===0,action:()=>me(t,-1)},{label:"Move down",icon:"arrow-down",disabled:t===o-1,action:()=>me(t,1)},{label:"Duplicate",icon:"copy",action:()=>{const n=$e(e);if(n.error){E.toast(`Fix this stage first: ${n.error}`,{type:"warning"});return}qe({type:n.type,config:n.config},e).querySelector(".vex-stage-head")?.focus()}},{label:"Results up to here",icon:"play",action:()=>{T.value=t+1,Ne()}},{type:"divider"},{label:"Delete stage",icon:"trash",danger:!0,action:()=>{e.remove(),g()}}]}function me(e,t){const o=Je(V(),e,t);o.forEach(n=>k.append(n)),g(),o[e+t]?.querySelector(".vex-stage-head")?.focus()}const ut=E.contextMenu(k,{cursor:"glyph",match:".stage-card",inherit:!1,density:"compact",items:e=>ct(e)});d(()=>ut?.destroy?.());const Oe=e=>{const t=e.target.closest(".vex-kebab");t&&ae(t)},De=e=>{const t=e.target.closest(".vex-stage-head");if(t){if(e.key==="F10"&&e.shiftKey)e.preventDefault(),ae(t);else if(e.altKey&&(e.key==="ArrowUp"||e.key==="ArrowDown")){e.preventDefault();const o=V().indexOf(t.closest(".stage-card"));me(o,e.key==="ArrowUp"?-1:1)}}};k.addEventListener("click",Oe),k.addEventListener("keydown",De),d(()=>{k.removeEventListener("click",Oe),k.removeEventListener("keydown",De)});const dt=E.contextMenu(x,{cursor:"glyph",match:".col-row",inherit:!1,density:"compact",items:e=>{const t=le(),o=t.indexOf(e),n=l=>{Je(t,o,l).forEach(i=>B.before(i)),g()};return[{header:e.querySelector(".col-label")?.value||e.querySelector(".col-key")?.value||"Column"},{label:"Move up",icon:"arrow-up",disabled:o===0,action:()=>n(-1)},{label:"Move down",icon:"arrow-down",disabled:o===t.length-1,action:()=>n(1)},{type:"divider"},{label:"Remove column",icon:"trash",danger:!0,action:()=>{e.remove(),g()}}]}});d(()=>dt?.destroy?.());const Ie=e=>{const t=e.target.closest(".vex-kebab");t&&ae(t)},Ke=e=>{const t=e.target.closest(".col-row");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),ae(t))};x.addEventListener("click",Ie),x.addEventListener("keydown",Ke),d(()=>{x.removeEventListener("click",Ie),x.removeEventListener("keydown",Ke)});function pt(e){const t=e.querySelector(".vex-val"),o=t.querySelector(".match-val")?.value??"";t.replaceChildren(xe(e.querySelector(".match-field")?.value,e.querySelector(".match-op")?.value,o))}const te=e=>{if(!e.target.closest?.(".vex-results")){if(e.type==="change"&&e.target.matches?.(".match-field, .match-op")&&pt(e.target.closest(".match-condition-row")),e.type==="change"&&e.target.matches?.("select.col-key")){const t=e.target.closest(".col-row")?.querySelector(".col-label"),o=L.find(n=>`data.${n.name}`===e.target.value);t&&!t.value&&o&&(t.value=o.label||o.name)}e.target.id==="view-source"&&e.type==="change"&&Re(e.target.value).then(()=>{j()&&(ot(),le().length||Me(null),g())}),["view-display-mode","view-rowlevel-enabled","view-rowlevel-mode"].includes(e.target.id)&&Le(),g()}};r.addEventListener("input",te),r.addEventListener("change",te);const Pe=e=>{e.target.closest?.("input, textarea, select")&&e.stopPropagation()};r.addEventListener("contextmenu",Pe),d(()=>{r.removeEventListener("input",te),r.removeEventListener("change",te),r.removeEventListener("contextmenu",Pe)}),r.querySelector("#add-stage-btn").addEventListener("click",()=>{qe({type:r.querySelector("#add-stage-type").value||"$match",config:{}}).querySelector("select, textarea, input")?.focus()});async function ye(){if(X.peek())return;const{payload:e}=y.peek();if(He(H.peek())){const o=["source","pipeline","display","access"].findIndex(n=>H.peek()[n].length);o>=0&&(Ae.activate?.(o),fe(o)),E.toast(`Not saved - ${Te.peek()}`,{type:"warning",duration:6e3});return}X.value=!0;try{if(b){const n=await C.views.create(e);O.value=P(e);try{S.remove?S.remove(w(null)):S.set(w(null),null)}catch{}he("#/views"),E.toast(`Created "${n.title}".`,{type:"success"}),R.navigate(`/views/edit/${encodeURIComponent(n.slug)}`);return}const o=await C.views.update(f,e);N=o||N,W=o?.meta||W,O.value=P(Q().payload);try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}he("#/views"),E.toast("Saved.",{type:"success"})}catch(o){E.toast(`Not saved: ${o.message||o}`,{type:"error"})}finally{X.value=!1}}async function vt(){if(await E.confirm("Throw away the changes you have not saved?")){await se(N),O.value=P(Q().payload);try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}}}async function ft(){if(!(b||!N)&&await E.confirm(`Delete the view "${Ot(N.title||f)}"? Pages that show it with [view] will show nothing. This cannot be undone.`))try{await C.views.delete(f),O.value=P(y.peek().payload);try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}he("#/views"),E.toast("View deleted.",{type:"success"}),R.navigate("/views")}catch(e){E.toast(`Not deleted: ${e.message||e}`,{type:"error"})}}const mt=M.applyBindings({dirty:Z,saving:X,errs:H,stageCount:at,errorSummary:Te,slugHint:nt,upTo:T,previewMeta:ee,previewNote:z,previewError:re},r,{methods:{save:()=>ye(),discard:()=>vt(),runPreview:()=>pe(),allStages:()=>{T.value=0}}});d(()=>mt.dispose()),d(gt(r,{position:"bottom"})),Y(v,{id:"view-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>ye()}),Y(v,{id:"view-editor-results",icon:"play",title:"Results",onClick:()=>{T.value=0,Ne()}}),b||(Y(v,{id:"view-editor-preview",icon:"eye",title:"Open the preview page",onClick:()=>{location.hash=`#/views/${encodeURIComponent(f)}/preview`}}),Y(v,{id:"view-editor-delete",icon:"trash",title:"Delete this view",onClick:()=>ft()})),Y(v,{id:"view-editor-all",icon:"list",title:"All views",onClick:()=>{location.hash="#/views"}});const je=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),ye())};if(document.addEventListener("keydown",je,!0),d(()=>document.removeEventListener("keydown",je,!0)),await se(N),!j())return;O.value=P(Q().payload);const Ve=()=>{if(Z.peek())try{S.set(w(f),{payload:y.peek().payload,base:O.peek(),at:new Date().toISOString()})}catch{}},ze=e=>{Z.peek()&&(Ve(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",ze),d(()=>window.removeEventListener("beforeunload",ze)),A={path:We,dirty:()=>Z.peek(),dispose(){ge=!0,Ve(),q.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(r);let K=null;try{K=S.get(w(f))}catch{}if(K?.payload){try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}const e=K.base&&K.base!==O.peek(),t=K.at?` (${D(K.at).format("D MMM HH:mm")})`:"",o=b?"the new view you started":"this view",n=e?`You left unsaved changes to ${o}${t}, but the saved view has changed since. Restore your version anyway?`:`You left unsaved changes to ${o}${t}. Restore them?`;if(await E.confirm(n)&&j()){const l=b?K.payload.slug:f;await se({...K.payload,slug:l})}}}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.91.1",
3
+ "version": "0.92.1",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -0,0 +1,96 @@
1
+ # feedback - AI Assistant Guide
2
+
3
+ ## Purpose
4
+
5
+ Site admins tell Domma (the vendor) what is broken, missing or good, from their own admin, and see
6
+ what became of it. Free tier: it ships in the CMS (synced by `make sync-free-tier`).
7
+
8
+ ONE plugin, TWO sides, chosen at registration by where it runs:
9
+
10
+ - **sender** - any site the manager started (it has `MANAGER_URL` + `FLEET_TOKEN` in its environment,
11
+ set by site-manager's processManager). The screen is "Send feedback" plus this site's reports.
12
+ - **receiver** - the manager (dcm). Detected by `plugins/site-manager/services/fleetTokens.js` existing
13
+ beside this plugin (`lib/receiver.js isManager()`). The screen is the inbox of every site's reports.
14
+
15
+ A site the manager did not start (a standalone install) has nowhere to send to: the screen says so,
16
+ `POST /send` answers 409.
17
+
18
+ ## Files
19
+
20
+ - `plugin.js` - both route sets; which one registers is decided once at boot (`options.mode` overrides in tests)
21
+ - `lib/shape.js` - PURE: types, severities, statuses, `cleanSubmission`, `buildPayload`, `receivedReport`,
22
+ `senderView` (the whitelist), `cleanUpdate`, `statusFromIssue`, `createThrottle`, `issueDescription`
23
+ - `lib/sender.js` - the outbox (`data/outbox.json`), `managerApi` (fetch, injectable), `flush`, `mergeReports`
24
+ - `lib/receiver.js` - the `feedback-reports` collection, token -> slug, site names, patches with history
25
+ - `collections/feedback-reports.json` - schema, created by `ensureCollection()` on the manager only
26
+ - `admin/views/feedback.js` - the shell: asks `/mode`, loads `send.js` or `inbox.js`
27
+ - `admin/lib/kit.js`, `admin/templates/feedback.html`, `admin/css/index.css`
28
+ - `tests/shape.test.js` (pure), `tests/api.test.js` (a sender app wired into a receiver app via inject)
29
+
30
+ ## Routes (`/api/plugins/feedback`)
31
+
32
+ Sender (permission `feedback` action `send`):
33
+
34
+ | Method | Path | Purpose |
35
+ |---|---|---|
36
+ | GET | `/mode` | `{mode:'sender', connected, site}` |
37
+ | GET | `/meta` | types, statuses, severities |
38
+ | GET | `/count` | sidebar badge: replies not yet opened (from the outbox copy - never calls the manager) |
39
+ | GET | `/sent` | sends anything waiting, then the manager's `/fleet/mine` merged with the outbox |
40
+ | POST | `/send` | a report: written to the outbox FIRST, then sent; answers `state: sent|waiting` |
41
+ | PUT | `/sent/:id/seen` | `{replyAt}` - this reply has been read |
42
+
43
+ Receiver (inbox routes: permission `feedback` action `manage`):
44
+
45
+ | Method | Path | Purpose |
46
+ |---|---|---|
47
+ | POST | `/fleet/submit` | a site's report. **No session**: `Authorization: Bearer <fleet token>` names the site. bodyLimit 32 KB, per-site throttle |
48
+ | GET | `/fleet/mine` | that site's reports, `senderView()` fields only |
49
+ | GET | `/mode` | `{mode:'receiver', waypoint}` |
50
+ | GET | `/count` | badge: status `new` (danger tone if any is a blocker) |
51
+ | GET | `/reports` · `/reports/:id` | the inbox; reading one freshens its Waypoint status |
52
+ | PUT | `/reports/:id` | `{status?, reply?, notes?}` |
53
+ | DELETE | `/reports/:id` | gone for the site too |
54
+ | GET | `/waypoint` | `{available, projects}` |
55
+ | POST | `/reports/:id/promote` | `{projectId, type, priority}` -> a Waypoint issue; 503 without Waypoint, 409 if already linked |
56
+
57
+ ## Storage
58
+
59
+ - Manager: collection `feedback-reports` (file adapter), NOT `feedback` - that slug is the starter preset
60
+ every site has for visitor comments. All four API verbs are OFF (a collection's default is a PUBLIC
61
+ read, and these carry admins' names and emails). Record: site, ref, type, title, description, where,
62
+ severity, cmsVersion, userId/userName/userEmail, status, reply, replyAt, notes, issueId, issueKey,
63
+ issueStatus, history[].
64
+ - Site: `data/outbox.json` - `{items: [{ref, state: waiting|sent|refused, payload, remoteId, reply, replyAt,
65
+ lastError}], seen: {reportId: replyAt}}`. The engine updater preserves plugin `data/`.
66
+
67
+ ## Waypoint Pro integration (feature-detected both ways; neither imports the other)
68
+
69
+ - Waypoint sets `globalThis.dommaWaypoint = {version: 1, listProjects(), createIssue({projectId, title,
70
+ description, type, priority, reporterName, source: {plugin: 'feedback', id, site, url}}), getIssue(idOrKey)}`.
71
+ - Feedback (receiver) sets `globalThis.dommaFeedback = {version: 1, issueUpdated({sourceId, issue: {id, key,
72
+ statusName, statusCategory}})}`. Waypoint calls it when a linked issue moves.
73
+ - `statusFromIssue`: todo -> planned, doing -> in-progress, done -> done; a report set to `declined`
74
+ (shown "Not planned") by hand keeps it.
75
+ - The inbox offers issue types bug/story/task and priorities highest..lowest as plain strings.
76
+
77
+ ## Gotchas
78
+
79
+ - **Who sent it is never the browser's word.** The sender builds the payload from `request.user`; the
80
+ manager attributes a report to the slug the fleet TOKEN resolves to and ignores any `site` in the body.
81
+ A site can still vouch for any of its own admins - it cannot speak for another site.
82
+ - **Tokens rotate whenever either end restarts** (fleetTokens.js is memory-only), so a send can meet a
83
+ 401 or ECONNREFUSED for a few seconds. That is why the outbox exists: a report is "sent or waiting",
84
+ never lost. Waiting ones go on the next `/sent` and on the timer (`syncMinutes`, default 60, which also
85
+ notices new replies and raises a `feedback:reply` notification). A retry carries the same `ref`, and the
86
+ manager answers the existing report (200) instead of making a second.
87
+ - **The global rate limiter exempts loopback**, which is where every site calls from, so `/fleet/submit`
88
+ has its own per-site throttle (`perSiteMax` in `perSiteMinutes`, default 10 in 10). A deduplicated
89
+ retry counts too.
90
+ - **Help popovers are wired BEFORE the row template goes in.** `helpPopovers()` scans icons; a template
91
+ scanned while its icon is still `{{icon}}` is marked `data-icon-processed`, every row cloned from it
92
+ inherits the mark and stays blank. Rows are rescanned (after `M.flush()`) whenever they change.
93
+ - **`[hidden]` needs its own rule.** `data-bind-hidden` sets the attribute, and any `display` rule
94
+ (`.fb-note` is flex) beats the browser's. `index.css` carries `.fb [hidden] {display: none !important}`.
95
+ - Only this machine's sites can reach dcm (the fleet token lives in this manager's memory). Sites on other
96
+ machines would need their own manager to relay with an installation key; not built.
@@ -0,0 +1,80 @@
1
+ /* Feedback - admin styles. Theme tokens only (every one below is defined by every Domma theme). */
2
+
3
+ /* data-bind-hidden sets [hidden]; any display rule below would otherwise beat it. */
4
+ .fb [hidden], .fb-detail [hidden], .fb-form [hidden] { display: none !important; }
5
+
6
+ .fb { max-width: 60rem; }
7
+ .fb-card { background: var(--dm-surface); border: 1px solid var(--dm-border); border-radius: var(--dm-radius-lg); padding: 1rem 1.1rem; }
8
+ .fb-bar { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: .8rem; }
9
+ .fb-spacer { flex: 1; }
10
+ .fb-intro { display: flex; flex-direction: column; gap: .15rem; }
11
+ .fb-intro span { color: var(--dm-text-secondary); font-size: var(--dm-font-size-sm); }
12
+
13
+ .fb-note { margin: .5rem 0; color: var(--dm-text-secondary); font-size: var(--dm-font-size-sm); display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
14
+ .fb-note.is-bad { color: var(--dm-danger); }
15
+ .fb-note.is-warn { color: var(--dm-text); background: color-mix(in srgb, var(--dm-warning) 14%, transparent); border-radius: var(--dm-radius-md); padding: .45rem .7rem; }
16
+ .fb-hint { margin: .2rem 0 0; color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
17
+
18
+ .fb-empty-state { text-align: center; padding: 2.5rem 1rem; color: var(--dm-text-secondary); }
19
+ .fb-empty-state h3 { margin: .6rem 0 .4rem; color: var(--dm-text); }
20
+ .fb-empty-state p { max-width: 34rem; margin: 0 auto; }
21
+
22
+ /* The status chips and filters */
23
+ .fb-seg { display: inline-flex; flex-wrap: wrap; gap: .25rem; }
24
+ .fb-seg button { border: 1px solid var(--dm-border); background: none; color: var(--dm-text-secondary); border-radius: var(--dm-radius-full);
25
+ padding: .2rem .7rem; font-size: var(--dm-font-size-sm); cursor: pointer; }
26
+ .fb-seg button b { font-weight: 600; margin-left: .2rem; }
27
+ .fb-seg button.is-on { background: var(--dm-primary-light); color: var(--dm-text); border-color: var(--dm-primary); }
28
+ .fb-filter { width: auto; min-width: 10rem; }
29
+ .fb-search { flex: 1; min-width: 12rem; }
30
+
31
+ /* Rows */
32
+ .fb-list { display: flex; flex-direction: column; }
33
+ .fb-row { display: flex; align-items: center; gap: .75rem; padding: .6rem .5rem; border-top: 1px solid var(--dm-border); cursor: pointer; border-radius: var(--dm-radius-md); }
34
+ .fb-row:first-child { border-top: 0; }
35
+ .fb-row:hover, .fb-row:focus-visible { background: var(--dm-hover-bg); outline: none; }
36
+ .fb-type { display: inline-flex; color: var(--dm-text-secondary); }
37
+ .fb-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
38
+ .fb-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
39
+ .fb-main small { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
40
+ .fb-key { font-family: var(--dm-font-mono, monospace); font-size: var(--dm-font-size-xs); color: var(--dm-text-secondary); }
41
+ .fb-dot { width: .55rem; height: .55rem; border-radius: 50%; background: var(--dm-primary); flex: none; }
42
+ .fb-kebab { border: 0; background: none; color: var(--dm-text-secondary); cursor: pointer; font-size: 1.1rem; padding: 0 .35rem; border-radius: var(--dm-radius-md); }
43
+ .fb-kebab:hover { background: var(--dm-hover-bg); color: var(--dm-text); }
44
+
45
+ .fb-chip { font-size: var(--dm-font-size-xs); font-weight: 600; padding: .12rem .55rem; border-radius: var(--dm-radius-full); white-space: nowrap;
46
+ background: color-mix(in srgb, var(--dm-text-secondary) 14%, transparent); color: var(--dm-text); }
47
+ .fb-chip.is-info { background: color-mix(in srgb, var(--dm-info) 18%, transparent); }
48
+ .fb-chip.is-warning { background: color-mix(in srgb, var(--dm-warning) 22%, transparent); }
49
+ .fb-chip.is-success { background: color-mix(in srgb, var(--dm-success) 20%, transparent); }
50
+ .fb-chip.is-bad { background: color-mix(in srgb, var(--dm-danger) 20%, transparent); }
51
+
52
+ /* Forms and panels */
53
+ .fb-form { display: flex; flex-direction: column; gap: .35rem; }
54
+ .fb-form .form-label { margin-top: .5rem; }
55
+ .fb-types { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: .4rem; }
56
+ .fb-types button { display: flex; align-items: center; gap: .45rem; border: 1px solid var(--dm-border); background: none; color: var(--dm-text);
57
+ border-radius: var(--dm-radius-md); padding: .5rem .6rem; cursor: pointer; text-align: left; font-size: var(--dm-font-size-sm); }
58
+ .fb-types button:hover { background: var(--dm-hover-bg); }
59
+ .fb-types button.is-on { border-color: var(--dm-primary); background: var(--dm-primary-light); }
60
+ .fb-actions { display: flex; gap: .5rem; align-items: center; margin-top: .8rem; }
61
+
62
+ .fb-detail h4 { margin: 1.2rem 0 .4rem; display: flex; align-items: center; gap: .4rem; }
63
+ .fb-detail h4 small { color: var(--dm-text-secondary); font-weight: 400; }
64
+ .fb-facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: .5rem 1rem; margin: 0; }
65
+ .fb-facts dt { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
66
+ .fb-facts dd { margin: 0; overflow-wrap: anywhere; }
67
+ .fb-text { white-space: pre-wrap; overflow-wrap: anywhere; background: var(--dm-background); border: 1px solid var(--dm-border);
68
+ border-radius: var(--dm-radius-md); padding: .7rem .8rem; }
69
+ .fb-reply { border-color: var(--dm-primary); }
70
+ .fb-waypoint { margin-top: 1rem; padding: .7rem .8rem; border: 1px dashed var(--dm-border); border-radius: var(--dm-radius-md); }
71
+ .fb-waypoint h4 { margin-top: 0; }
72
+ .fb-grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .6rem; }
73
+ .fb-history { list-style: none; margin: 0; padding: 0; font-size: var(--dm-font-size-sm); }
74
+ .fb-history li { display: flex; justify-content: space-between; gap: 1rem; padding: .3rem 0; border-top: 1px solid var(--dm-border); }
75
+ .fb-history small { color: var(--dm-text-secondary); }
76
+
77
+ @media (max-width: 640px) {
78
+ .fb-grid3 { grid-template-columns: 1fr; }
79
+ .fb-row { flex-wrap: wrap; }
80
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Feedback admin - what both screens share: the admin's helpers (loaded at run
3
+ * time; a plugin never imports admin/ statically - the updater replaces it),
4
+ * the API, escaping, dates and slideovers.
5
+ *
6
+ * @module feedback/admin/lib/kit
7
+ */
8
+
9
+ export const SIDEBAR_URL = '#/plugins/feedback';
10
+ export const BASE = '/api/plugins/feedback';
11
+
12
+ let kit = null;
13
+
14
+ /** tool-kit, plugin-chrome and help-popover, once, with stand-ins for an older admin. */
15
+ export function loadKit() {
16
+ if (!kit) {
17
+ kit = Promise.all([
18
+ import('/admin/js/lib/tool-kit.js').catch(() => ({})),
19
+ import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
20
+ import('/admin/js/lib/help-popover.js').catch(() => ({}))
21
+ ]).then(([tk, chrome, help]) => ({
22
+ onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
23
+ openMenuAt: tk.openMenuAt || ((el) => {
24
+ const r = el.getBoundingClientRect();
25
+ el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
26
+ }),
27
+ viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
28
+ addBannerAction: chrome.addBannerAction || (() => null),
29
+ helpPopovers: help.helpPopovers || (() => () => {})
30
+ }));
31
+ }
32
+ return kit;
33
+ }
34
+
35
+ const seg = (id) => encodeURIComponent(String(id));
36
+ export const api = {
37
+ mode: () => H.get(`${BASE}/mode`),
38
+ meta: () => H.get(`${BASE}/meta`),
39
+ sent: () => H.get(`${BASE}/sent`),
40
+ send: (d) => H.post(`${BASE}/send`, d),
41
+ seen: (id, replyAt) => H.put(`${BASE}/sent/${seg(id)}/seen`, {replyAt}),
42
+ reports: () => H.get(`${BASE}/reports`),
43
+ report: (id) => H.get(`${BASE}/reports/${seg(id)}`),
44
+ update: (id, d) => H.put(`${BASE}/reports/${seg(id)}`, d),
45
+ remove: (id) => H.delete(`${BASE}/reports/${seg(id)}`),
46
+ waypoint: () => H.get(`${BASE}/waypoint`),
47
+ promote: (id, d) => H.post(`${BASE}/reports/${seg(id)}/promote`, d)
48
+ };
49
+
50
+ /** E.confirm and slideover titles render HTML: user text goes in escaped. */
51
+ export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
52
+
53
+ /** A small DOM tree from an HTML string that holds NO unescaped user data. */
54
+ export function fragment(html) {
55
+ const t = document.createElement('template');
56
+ t.innerHTML = html.trim();
57
+ return t.content.firstElementChild;
58
+ }
59
+
60
+ export const store = {
61
+ get(k) { try { return S.get(k); } catch { return null; } },
62
+ set(k, v) { try { S.set(k, v); } catch { /* a convenience */ } }
63
+ };
64
+
65
+ /** The sidebar re-asks the Feedback badge. */
66
+ export function refreshBadge() {
67
+ try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: SIDEBAR_URL}})); } catch { /* no sidebar */ }
68
+ }
69
+
70
+ /** "3 min ago", "26 Sep". */
71
+ export function when(iso) {
72
+ const t = new Date(iso).getTime();
73
+ if (!Number.isFinite(t)) return '';
74
+ const s = (Date.now() - t) / 1000;
75
+ if (s < 60) return 'just now';
76
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
77
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
78
+ if (s < 86400 * 6) return `${Math.round(s / 86400)} d ago`;
79
+ return new Date(t).toLocaleDateString(undefined, {day: 'numeric', month: 'short', ...(s > 86400 * 300 ? {year: 'numeric'} : {})});
80
+ }
81
+
82
+ /** Full date and time, for a detail panel. */
83
+ export const stamp = (iso) => {
84
+ const t = new Date(iso);
85
+ return Number.isFinite(t.getTime()) ? t.toLocaleString(undefined, {dateStyle: 'medium', timeStyle: 'short'}) : '';
86
+ };
87
+
88
+ /**
89
+ * A slideover holding `html`, bound to `model`. Everything is disposed and the
90
+ * element removed on close.
91
+ *
92
+ * @returns {Promise<{so, panel, close: () => void}>}
93
+ */
94
+ export async function openPanel({title, size = 'md', html, model = {}, methods = {}, cleanup = () => {}}) {
95
+ const {onceClosed, helpPopovers} = await loadKit();
96
+ const so = E.slideover({title: esc(title), size, position: 'right'});
97
+ const panel = fragment(html);
98
+ so.element.appendChild(panel);
99
+ const disposeHelp = helpPopovers(panel, {position: 'bottom'});
100
+ const handle = M.applyBindings(model, panel, {methods});
101
+ onceClosed(so, () => { cleanup(); handle.dispose(); disposeHelp(); });
102
+ I.scan(panel);
103
+ so.open();
104
+ return {so, panel, close: () => so.close()};
105
+ }
@@ -0,0 +1 @@
1
+ <div class="fb" data-feedback-admin></div>
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Feedback - one sidebar entry, two screens, decided by the server:
3
+ *
4
+ * on a site Send feedback, and what this site has sent (send.js)
5
+ * on the manager the inbox of every site's feedback (inbox.js)
6
+ *
7
+ * #/plugins/feedback the screen
8
+ * #/plugins/feedback/<id> the same, with that report open (a notification's link)
9
+ *
10
+ * Path segments, not ?query: the admin router 404s a query on a fresh load.
11
+ */
12
+
13
+ import {api, loadKit} from '../lib/kit.js';
14
+
15
+ const ROUTE = /^#\/plugins\/feedback(\/|$)/;
16
+ const V = '1.0.0';
17
+ let lifetime = null;
18
+
19
+ export const feedbackView = {
20
+ templateUrl: '/plugins/feedback/admin/templates/feedback.html',
21
+
22
+ async onMount($container) {
23
+ const kit = await loadKit();
24
+ lifetime ??= kit.viewLifetime(ROUTE);
25
+ if (!ROUTE.test(location.hash)) return;
26
+ lifetime.reset();
27
+ const scope = (fn) => { lifetime.add(fn); return fn; };
28
+ const container = $container.get(0);
29
+ const root = container.querySelector('[data-feedback-admin]') || container;
30
+ const mountedFor = location.hash;
31
+ const here = () => location.hash === mountedFor && root.isConnected;
32
+ const m = location.hash.match(/^#\/plugins\/feedback\/([^/?#]+)/);
33
+ const id = m ? decodeURIComponent(m[1]) : '';
34
+
35
+ const fail = (text) => {
36
+ root.innerHTML = '';
37
+ const p = document.createElement('p');
38
+ p.className = 'fb-note is-bad';
39
+ p.textContent = text;
40
+ root.appendChild(p);
41
+ };
42
+
43
+ let mode;
44
+ let meta;
45
+ try {
46
+ [mode, meta] = await Promise.all([api.mode(), api.meta()]);
47
+ } catch (err) {
48
+ fail(`Feedback could not load: ${err.message || err}`);
49
+ return;
50
+ }
51
+ if (!here()) return;
52
+
53
+ const screen = mode.mode === 'receiver' ? 'inbox' : 'send';
54
+ try {
55
+ const mod = await import(`/plugins/feedback/admin/views/${screen}.js?v=${V}`);
56
+ if (!here()) return;
57
+ const cleanup = await mod.mount(root, {kit, scope, here, container, $container, mode, meta, id});
58
+ if (typeof cleanup === 'function') scope(cleanup);
59
+ } catch (err) {
60
+ fail(`This screen could not load: ${err.message || err}`);
61
+ }
62
+ }
63
+ };