domma-cms 1.6.49 → 1.6.51
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/admin/js/api.js +1 -1
- package/admin/js/lib/block-editor-arrange.js +1 -1
- package/admin/js/lib/blocks-arrange.js +1 -1
- package/admin/js/lib/list-tags-arrange.js +1 -0
- package/admin/js/lib/list-tags.js +37 -0
- package/admin/js/lib/pages-arrange.js +1 -1
- package/admin/js/templates/blocks.html +9 -1
- package/admin/js/templates/page-editor.html +7 -0
- package/admin/js/templates/pages.html +7 -1
- package/admin/js/views/blocks.js +5 -4
- package/admin/js/views/page-editor.js +7 -7
- package/admin/js/views/pages.js +4 -3
- package/package.json +1 -1
- package/server/routes/api/list-tags.js +85 -0
- package/server/routes/api/pages.js +2 -0
- package/server/server.js +2 -0
- package/server/services/blocks.js +33 -0
- package/server/services/content.js +49 -0
- package/server/services/listTags.js +220 -0
- package/server/services/versions.js +24 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as b}from"../api.js";import{CardBuilder as vt}from"../lib/card-builder.js";import{TimelineBuilder as ft}from"../lib/timeline-builder.js";import{attachEditorKeybindings as mt,createToolbar as gt,insertAtCursor as bt}from"../lib/markdown-toolbar.js";import{EffectsBuilder as ht}from"../lib/effects-builder.js";import{attachContextMenu as yt}from"../lib/shortcode-context-menu.js";import{registerCustomInserters as wt}from"../lib/shortcode-insert.js";import{populateThemeSelect as kt}from"../lib/themes.js";import{createFoldingManager as Et}from"../lib/editor-folding.js";import{openScribeComposer as Mt}from"../lib/scribe-composer.js";import{openCrudTutorial as St}from"../lib/crud-tutorial.js";import{addBannerAction as $}from"../lib/plugin-chrome.js";import{helpPopovers as Ke}from"../lib/help-popover.js";import{onceClosed as ye,openMenuAt as Ue}from"../lib/tool-kit.js";import{pickMedia as Tt}from"../lib/media-picker.js";import{openPageClone as Ct}from"../lib/page-clone.js";import{openEditorReference as _e}from"../lib/page-editor-reference.js";import{cleanType as X,DEFAULT_TYPE as Z,EDITOR_FONTS as xt,fontHref as Pt,isDefaultType as Lt,LINE_HEIGHTS as At,SIZES as Ot,typeVars as Je}from"../lib/editor-type.js";import{getProjectFromHash as Dt}from"../lib/project-context.js";import{SCREEN as Rt,bannerSection as Nt,draftIsStale as Ft,draftKey as It,initialProject as Bt,makeDraft as $t,problems as we,readDraft as zt,snapshot as z}from"../lib/page-editor-arrange.js";import{fmtDateTime as Q}from"../lib/dates.js";function ee(s){const r=document.createElement("template");return r.innerHTML=s.trim(),r.content.firstElementChild}const jt=Q,We=s=>Q(s.createdAt)+(s.label?` - ${s.label}`:""),Vt=`
|
|
2
2
|
<div class="pe-share">
|
|
3
3
|
<p class="form-hint" style="margin-top:0">Anyone with the link can view this page even while it is unpublished, without
|
|
4
4
|
signing in. The link works for this page only, expires on its own, and can be revoked at any time.</p>
|
|
@@ -30,7 +30,7 @@ import{api as g}from"../api.js";import{CardBuilder as lt}from"../lib/card-builde
|
|
|
30
30
|
.pe-share [hidden] { display: none !important; }
|
|
31
31
|
.pe-share-row { display: flex; align-items: center; gap: .6rem; padding: .5rem 0; border-bottom: 1px solid var(--dm-border-light); }
|
|
32
32
|
.pe-share-text { flex: 1 1 auto; min-width: 0; font-size: .82rem; }
|
|
33
|
-
</style>`;function
|
|
33
|
+
</style>`;function Ge(s){const r=Pt(s);if(!r||document.querySelector(`link[data-pe-font="${CSS.escape(s)}"]`))return;const l=Object.assign(document.createElement("link"),{rel:"stylesheet",href:r});l.dataset.peFont=s,document.head.appendChild(l)}const qt=`
|
|
34
34
|
<div class="pe-settings">
|
|
35
35
|
<p class="pe-settings-note">How the editor looks to you - kept in this browser, for every page.</p>
|
|
36
36
|
<label class="form-label" for="pe-set-font">Font <span data-help="The lettering in the editor (not on the site). Monospace keeps every character the same width, so shortcodes and Markdown line up."></span></label>
|
|
@@ -61,17 +61,17 @@ The quick brown fox - 0O 1lI {} [] () => ==
|
|
|
61
61
|
.pe-settings-sample { white-space: pre-wrap; padding: .75rem 1rem; border-radius: var(--dm-radius-md); border: 1px solid var(--dm-border);
|
|
62
62
|
background: var(--dm-background-alt); font-family: var(--pe-font); font-size: var(--pe-size); line-height: var(--pe-line); }
|
|
63
63
|
.pe-settings-actions { display: flex; gap: .5rem; border-top: 1px solid var(--dm-border); padding-top: .75rem; }
|
|
64
|
-
</style>`;function
|
|
64
|
+
</style>`;function Ht(s){const r=E.slideover({title:"Editor settings",size:"sm",position:"right"}),l=ee(`<div>${qt}</div>`),o=X(s.peek()),i={font:M.observable(o.font),size:M.observable(String(o.size)),line:M.observable(String(o.line)),wrap:M.observable(o.wrap)},f=M.computed(()=>X({font:i.font.value,size:i.size.value,line:i.line.value,wrap:i.wrap.value}));i.isDefault=M.computed(()=>Lt(f.value));const p=l.querySelector("#pe-set-font");for(const[v,T]of[["mono","Monospace"],["sans","Other"],["serif",null]]){const F=xt.filter(m=>m.kind===v),V=T?Object.assign(document.createElement("optgroup"),{label:T}):p.querySelector("optgroup:last-of-type");V.append(...F.map(m=>new Option(m.label,m.id))),T&&p.appendChild(V)}l.querySelector("#pe-set-size").append(...Ot.map(v=>new Option(`${v}px`,String(v)))),l.querySelector("#pe-set-line").append(...At.map(v=>new Option({1.3:"Tight",1.45:"Snug",1.6:"Normal",1.8:"Relaxed",2:"Double"}[v]||String(v),String(v))));const c=l.querySelector(".pe-settings-sample"),u=M.effect(()=>{const v=f.value;Ge(v.font);for(const[T,F]of Object.entries(Je(v)))c.style.setProperty(T,F);c.style.whiteSpace=v.wrap?"pre-wrap":"pre",c.style.overflowX=v.wrap?"":"auto",JSON.stringify(v)!==JSON.stringify(X(s.peek()))&&(s.value=v,te("type",v))});i.reset=()=>{i.font.value=Z.font,i.size.value=String(Z.size),i.line.value=String(Z.line),i.wrap.value=Z.wrap},i.done=()=>r.close(),r.element.appendChild(l);const C=M.applyBindings(i,l),d=Ke(l);return ye(r,()=>{C.dispose(),d(),u?.()}),Domma.icons?.scan?.(l),r.open(),r}async function Kt(s){const r=E.slideover({title:"Share preview link",size:"md",position:"right"}),l=ee(`<div>${Vt}</div>`),o={expiry:M.observable("7d"),note:M.observable(""),creating:M.observable(!1),url:M.observable(""),links:M.observable([]),loaded:M.observable(!1),listError:M.observable("")},i=async()=>{try{const p=await b.pages.previewLinks.list(s);o.links.value=p.filter(c=>!c.revokedAt&&!c.expired).map(c=>({id:c.id,title:c.label||"Untitled link",meta:`Expires ${jt(c.expiresAt)} - ${c.useCount===1?"1 view":`${c.useCount||0} views`}`})),o.listError.value=""}catch(p){o.listError.value="Could not load existing links: "+(p.message||"unknown error")}finally{o.loaded.value=!0}};o.create=async()=>{o.creating.value=!0;try{const p=await b.pages.previewLinks.create(s,o.expiry.peek(),o.note.peek().trim());o.url.value=p.url,o.note.value="",E.toast("Share link created.",{type:"success"}),await i(),l.querySelector(".pe-share-url")?.select()}catch(p){E.toast("Could not create link: "+(p.message||"unknown error"),{type:"error"})}finally{o.creating.value=!1}},o.copy=async()=>{try{await navigator.clipboard.writeText(o.url.peek()),E.toast("Link copied to clipboard.",{type:"success"})}catch{l.querySelector(".pe-share-url")?.select(),E.toast("Press Ctrl+C to copy the selected link.",{type:"info"})}},o.revoke=async p=>{if(await E.confirm("Revoke this link? Anyone holding it loses access immediately."))try{await b.pages.previewLinks.revoke(p),E.toast("Link revoked.",{type:"success"}),await i()}catch(c){E.toast("Could not revoke: "+(c.message||"unknown error"),{type:"error"})}};const f=M.applyBindings(o,l);ye(r,()=>f.dispose()),r.element.appendChild(l),r.open(),await i()}function Ut(s){return new Promise(r=>{const l=E.slideover({title:"Save Named Version",size:"sm",position:"right"}),o=ee(`
|
|
65
65
|
<div style="padding:1rem">
|
|
66
66
|
<label class="form-label" for="pe-version-label">Version Label</label>
|
|
67
67
|
<input id="pe-version-label" type="text" class="form-input" style="margin-bottom:.75rem"
|
|
68
68
|
placeholder="e.g. Initial design, Before restructure" data-model="label.value" data-on-keydown="key">
|
|
69
69
|
<button type="button" class="btn btn-primary" data-on-click="save" data-bind-disabled="saving.value">Save Version</button>
|
|
70
|
-
</div>`);let i=!1;const f={label:M.observable(""),saving:M.observable(!1)};f.save=async()=>{f.saving.value=!0;try{await
|
|
70
|
+
</div>`);let i=!1;const f={label:M.observable(""),saving:M.observable(!1)};f.save=async()=>{f.saving.value=!0;try{await b.versions.create(s,f.label.peek().trim()||null),E.toast("Named version saved.",{type:"success"}),i=!0,l.close()}catch(c){E.toast("Failed to save version: "+c.message,{type:"error"})}finally{f.saving.value=!1}},f.key=c=>{c.key==="Enter"&&(c.preventDefault(),f.save())};const p=M.applyBindings(f,o);ye(l,()=>{p.dispose(),r(i)}),l.element.appendChild(o),l.open(),setTimeout(()=>o.querySelector("input")?.focus(),100)})}let w=null,Ye=!1;function _t(){Ye||!window.R?.use||(Ye=!0,R.use((s,r,l)=>{const o=r?.path||"",i=s?.path||"",f=Rt.test(o)&&i!==o;return f&&w?.path===o&&w.cloneTarget===i?(w.cloneTarget=null,w.keepDraft(),l()):!f||!w||w.path!==o||!w.dirty()?l():E.confirm("You have unsaved changes to this page. Leave anyway? They are kept as a draft for your next visit.").then(p=>{p?(w?.keepDraft(),l()):history.replaceState(null,"","#"+o)})}),M.subscribe("router:afterChange",({to:s}={})=>{if(w&&(s?.path||"")!==w.path){const r=w;w=null,r.dispose()}}))}function Jt(s){return s.split(`
|
|
71
71
|
`).map(r=>r.trimEnd()).join(`
|
|
72
72
|
`).replace(/\n{3,}/g,`
|
|
73
73
|
|
|
74
|
-
`)}const
|
|
74
|
+
`)}const Xe="editor_prefs";function j(){return S.get(Xe)||{viewMode:"split",fullscreen:!1}}function te(s,r){const l=j();l[s]=r,S.set(Xe,l)}const Wt=["edit","details","seo","dconfig","live","history"],Ze=["split","write","preview"],Gt=s=>s.replace(/<\/head>/i,"<style>body.dm-cloaked{opacity:1 !important;transition:none !important}</style></head>");function Qe(s,r,l=()=>{}){let o=0;try{o=s.contentWindow.scrollY||0}catch{}s.addEventListener("load",()=>{try{s.contentWindow.scrollTo(0,o)}catch{}l()},{once:!0}),s.srcdoc=r}export const pageEditorView={templateUrl:"/admin/js/templates/page-editor.html",async onMount(s){if(_t(),w){const e=w;w=null,e.dispose()}const r=window.location.hash.replace(/^#/,""),l=[],o=e=>(l.push(e),e);let i=()=>!1,f=()=>{};const p={path:r,dirty:()=>i(),keepDraft:()=>f(),dispose:()=>{l.splice(0).forEach(e=>{try{e()}catch{}})}};w=p;const c=s.get(0),u=()=>w===p&&c.isConnected,C=window.location.hash.match(/#\/pages\/edit(\/[^?]*)/),d=C?C[1]:null,v=!!d,T=E.loader(c,{type:"dots"}),[F,V,m,ae,ke,et,tt]=await Promise.all([b.layouts.get().catch(()=>({})),b.settings.get().catch(()=>({})),v?b.pages.get(d).catch(()=>null):null,v?b.pages.list().catch(()=>[]):[],b.projects.list().catch(()=>[]),b.pages.tags().catch(()=>[]),b.listTags.list("pages").then(e=>e.map(t=>t.name)).catch(()=>[])]);if(!u()){T.destroy();return}if(T.destroy(),v&&!m){E.toast("Page not found.",{type:"error"}),R.navigate("/pages");return}const y=e=>c.querySelector(e);if(kt(y("#field-theme"),{includeDefault:!0}),await ea(y("#field-visibility"),m?.visibility),!u())return;const at=Qt(y("#field-layout"),F,m?.layout||"default");for(const e of ke)y("#field-project").append(new Option(e.name||e.slug,e.slug));const Ee=[...Array.isArray(ae)?ae:[]].sort((e,t)=>(e.title||e.urlPath).localeCompare(t.title||t.urlPath));for(const e of Ee)y("#quick-switch").append(new Option(`${e.title||"Untitled"} (${e.urlPath})`,e.urlPath,!1,e.urlPath===d));const q=j(),a={urlPath:M.observable(d||""),title:M.observable(m?.title||""),description:M.observable(m?.description||""),layout:M.observable(at),status:M.observable(m?.status||"draft"),sortOrder:M.observable(String(m?.sortOrder??99)),showInNav:M.observable(!!m?.showInNav),sidebar:M.observable(!!m?.sidebar),breadcrumbs:M.observable(m?m.breadcrumbs!==!1:!0),bundled:M.observable(!!m?.bundled),tags:M.observable(Array.isArray(m?.tags)?m.tags:[]),adminTags:M.observable(Array.isArray(m?.adminTags)?m.adminTags:[]),category:M.observable(m?.category||""),visibility:M.observable(typeof m?.visibility=="string"&&m.visibility?m.visibility:"public"),project:M.observable(Bt({isEdit:v,page:m,projects:ke,fromHash:Dt()||"",urlPath:d||""})),theme:M.observable(m?.theme||""),seoTitle:M.observable(m?.seo?.title||""),seoDesc:M.observable(m?.seo?.description||""),dconfig:M.observable(m?.dconfig?JSON.stringify(m.dconfig,null,2):""),css:M.observable(""),cssOpened:M.observable(!1)},n={bodyRev:M.observable(0),base:M.observable(""),saving:M.observable(!1),tab:M.observable("edit"),viewMode:M.observable(Ze.includes(q.viewMode)?q.viewMode:"split"),fullscreen:M.observable(!!q.fullscreen),cssMode:M.observable(!1),folded:M.observable(!1),liveStatus:M.observable("Showing unsaved changes"),liveWidth:M.observable("full"),versions:M.observable([]),histLoading:M.observable(!1),histError:M.observable(""),compare:M.observable(null)},k=y("#markdown-editor"),I=y("#css-editor");k.value=m?.content||"";const L=document.createElement("div");L.className="editor-line-numbers editor-line-numbers--foldable",k.parentElement.insertBefore(L,k);const x=Et(k,L,{onFoldChange:()=>{}});let D=!1;x.setGuard(e=>{D=e});const B=()=>x.getUnfoldedContent(),Me=M.observable(X(q.type));o(M.effect(()=>{const e=Me.value,t=y("#editor-body");if(t){Ge(e.font);for(const[g,h]of Object.entries(Je(e)))t.style.setProperty(g,h);t.classList.toggle("pe-nowrap",!e.wrap),k.wrap=e.wrap?"soft":"off",I.wrap=e.wrap?"soft":"off"}}));let Se=null;o(()=>{try{Se?.close()}catch{}});const A=()=>(n.bodyRev.value,{urlPath:a.urlPath.value.trim(),title:a.title.value,description:a.description.value,layout:a.layout.value,status:a.status.value,sortOrder:a.sortOrder.value,showInNav:a.showInNav.value,sidebar:a.sidebar.value,breadcrumbs:a.breadcrumbs.value,bundled:a.bundled.value,tags:a.tags.value,adminTags:a.adminTags.value,category:a.category.value,visibility:a.visibility.value,project:a.project.value,theme:a.theme.value,seoTitle:a.seoTitle.value,seoDesc:a.seoDesc.value,dconfig:a.dconfig.value,body:B(),css:a.cssOpened.value?a.css.value:null}),st=M.computed(()=>z(A())!==n.base.value),ot=M.computed(()=>we({urlPath:a.urlPath.value,dconfig:a.dconfig.value})[0]?.text||""),nt=M.computed(()=>n.versions.value.some(e=>e.checked.value)),rt=M.computed(()=>n.versions.value.length>0&&n.versions.value.every(e=>e.checked.value)),it=M.computed(()=>{const e=n.versions.value.filter(t=>t.checked.value).length;return e>0&&e<n.versions.value.length});i=()=>z(A())!==n.base.peek();let Te=JSON.stringify(a.adminTags.peek());const H=e=>({title:a.title.peek().trim()||"Untitled",description:a.description.peek().trim(),layout:a.layout.peek(),status:a.status.peek(),sortOrder:parseInt(String(a.sortOrder.peek()),10)||99,showInNav:a.showInNav.peek(),sidebar:a.sidebar.peek(),...a.breadcrumbs.peek()?{}:{breadcrumbs:!1},...a.bundled.peek()?{bundled:!0}:{},tags:a.tags.peek(),...v&&JSON.stringify(a.adminTags.peek())===Te?{}:{adminTags:a.adminTags.peek()},category:a.category.peek().trim()||null,visibility:a.visibility.peek()||"public",project:a.project.peek()||"",seo:{title:a.seoTitle.peek().trim(),description:a.seoDesc.peek().trim()},dconfig:e,...a.theme.peek()?{theme:a.theme.peek()}:{}}),se=()=>{const e=a.dconfig.peek().trim();if(!e)return null;try{return JSON.parse(e)}catch{return null}},Ce=()=>a.urlPath.peek().trim()||d||"/",K=y("#markdown-preview");let xe="";b.settings.getCustomCss().then(({css:e})=>{xe=e||""}).catch(()=>{});const Pe=()=>{const e=K?.contentDocument;if(!e?.head)return;const t=a.cssOpened.peek()?a.css.peek():"";if(!t)return;let g=e.getElementById("preview-custom-css");g||(g=e.createElement("style"),g.id="preview-custom-css",e.head.appendChild(g)),g.textContent=t};let oe=null;const U=()=>{clearTimeout(oe),oe=setTimeout(async()=>{if(!(!K||n.viewMode.peek()==="write"))try{const{html:e}=await b.pages.previewFull(Ce(),H(se()),B());u()&&Qe(K,Gt(e),Pe)}catch(e){K.srcdoc='<!doctype html><meta charset="utf-8"><body style="font:14px system-ui;padding:1rem;color:#b91c1c;">Preview failed: '+String(e?.message||"unknown error").replace(/[<&]/g,t=>t==="<"?"<":"&")+"</body>"}},500)},Le=y("#live-preview-frame");let ne=!0,re=null,ie=!1;const le=async()=>{if(!(ie||!Le)){ie=!0,n.liveStatus.value="Rendering\u2026";try{const{html:e}=await b.pages.previewFull(Ce(),H(se()),B());if(!u())return;Qe(Le,e),ne=!1,n.liveStatus.value="Showing unsaved changes"}catch(e){n.liveStatus.value="Preview failed - "+(e?.message||"unknown error")}finally{ie=!1}}},Ae=()=>{ne=!0,n.tab.peek()==="live"&&(clearTimeout(re),re=setTimeout(le,600))};o(()=>{clearTimeout(oe),clearTimeout(re)});const ce=gt(s.find("#markdown-editor"),s.find("#editor-toolbar"),{spacerDefault:V?.layoutOptions?.spacerSize??40});yt(k);const de=c.querySelector(".editor-toolbar-right");de.querySelectorAll(".editor-view-btn[data-mode]").forEach(e=>{const t=e.getAttribute("data-mode");e.classList.remove("active"),e.setAttribute("data-bind-class",`viewMode.value === '${t}' && 'active'`),e.setAttribute("data-on-click",`setMode('${t}')`)}),y("#fullscreen-btn")?.setAttribute("data-on-click","toggleFullscreen");const lt=ee(`
|
|
75
75
|
<span class="pe-toolbar-extras" style="display:contents">
|
|
76
76
|
<button type="button" class="editor-view-btn" data-tip="Edit Custom CSS" data-on-click="toggleCss"
|
|
77
77
|
data-bind-class="cssMode.value && 'active'"><span>CSS</span></button>
|
|
@@ -83,5 +83,5 @@ The quick brown fox - 0O 1lI {} [] () => ==
|
|
|
83
83
|
<span class="pe-fold-icon" hidden data-bind-hidden="!folded.value"><span data-icon="chevrons-right" data-icon-size="10"></span></span>
|
|
84
84
|
</button>
|
|
85
85
|
<span class="editor-toolbar-sep"></span>
|
|
86
|
-
</span>`);
|
|
87
|
-
`).map((
|
|
86
|
+
</span>`);de.prepend(lt);const Oe=e=>(Array.isArray(e)?e:[]).map(t=>({value:t,label:t})),De=y("#field-tags");let _=null;_=De?E.pillbox(De,{data:Oe(et),value:a.tags.peek(),creatable:!0,searchable:!0,placeholder:"Add tag\u2026",onChange:()=>{_&&(a.tags.value=[..._.getValue()])}}):null;const Re=y("#field-admin-tags");let J=null;J=Re?E.pillbox(Re,{data:Oe(tt),value:a.adminTags.peek(),creatable:!0,searchable:!0,placeholder:"Add admin tag\u2026",onChange:()=>{J&&(a.adminTags.value=[...J.getValue()])}}):null;let pe=!1,ue=null;const W=async()=>{pe=!0,n.histLoading.value=!0,n.histError.value="";try{const e=(await b.versions.list(d)).versions||[];if(!u())return;n.versions.value=e.map(t=>({...t,when:Q(t.createdAt),author:t.author||"",label:t.label||"",checked:M.observable(!1)}))}catch(e){n.histError.value="Failed to load versions: "+e.message}finally{n.histLoading.value=!1}},ve=e=>n.versions.peek().find(t=>t.filename===e),fe=async e=>{try{const t=await b.versions.get(d,e.filename);ue=e.filename,n.compare.value={label:We(e),old:t.content,current:B()}}catch(t){E.toast("Failed to load version: "+t.message,{type:"error"})}},Ne=async e=>{if(!(!e||!await E.confirm("Restore this version? The current page content will be overwritten (a pre-restore snapshot will be saved automatically).")))try{await b.versions.restore(d,e),E.toast("Page restored. Reloading editor\u2026",{type:"success"}),R.navigate("/pages"),setTimeout(()=>R.navigate(`/pages/edit${d}`),300)}catch(t){E.toast("Restore failed: "+t.message,{type:"error"})}},ct=async e=>{if(await E.confirm("Delete this version? This cannot be undone."))try{await b.versions.delete(d,e.filename),E.toast("Version deleted.",{type:"success"}),await W()}catch(t){E.toast("Failed to delete: "+t.message,{type:"error"})}},Fe=()=>{n.bodyRev.value=n.bodyRev.peek()+1},Ie=()=>{a.cssOpened.peek()||(a.css.value=xe,a.cssOpened.value=!0,mt(I))},Be=e=>{for(const t of["urlPath","title","description","layout","status","sortOrder","category","visibility","project","theme","seoTitle","seoDesc","dconfig"])a[t].value=e[t]==null?"":String(e[t]);for(const t of["showInNav","sidebar","breadcrumbs","bundled"])a[t].value=!!e[t];a.tags.value=Array.isArray(e.tags)?e.tags:[],_?.setValue(a.tags.peek()),Array.isArray(e.adminTags)&&(a.adminTags.value=e.adminTags),J?.setValue(a.adminTags.peek()),x.unfoldAll(),D=!0,k.value=e.body==null?"":String(e.body),D=!1,x.refresh(),e.css!=null&&(Ie(),a.css.value=String(e.css)),Fe()};n.base.value=z(A());const G=It(d||"");f=()=>{if(i())try{S.set(G,$t(A(),n.base.peek()))}catch{}};const me=()=>{try{S.remove?S.remove(G):S.set(G,null)}catch{}},$e=e=>{i()&&(f(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",$e),o(()=>window.removeEventListener("beforeunload",$e));const ze=async()=>{if(n.saving.peek())return;const e=A(),t=we(e);if(t.length){E.toast(t[0].text,{type:"warning"}),c.querySelectorAll("#editor-meta-tabs .tab-item")[Wt.indexOf(t[0].field==="dconfig"?"dconfig":"details")]?.click();return}const g=(e.dconfig||"").trim(),h=H(g?JSON.parse(g):null),P=a.cssOpened.peek();n.saving.value=!0;try{if(v){const O={frontmatter:h,body:e.body};e.urlPath!==d&&(O.newUrlPath=e.urlPath),await b.pages.update(d,O)}else await b.pages.create({urlPath:e.urlPath,frontmatter:h,body:e.body});P&&await b.settings.saveCustomCss(a.css.peek()),E.toast(P?"Page and CSS saved.":"Page saved.",{type:"success"}),me(),Te=JSON.stringify(a.adminTags.peek()),n.base.value=z(A()),v?e.urlPath!==d&&R.navigate(`/pages/edit${e.urlPath}`):R.navigate("/pages")}catch(O){E.toast(`Save failed: ${O.message||"Unknown error"}`,{type:"error"})}finally{n.saving.value=!1}},je=c.querySelector(".dm-plugin-body")||c,dt={...a,...n,dirty:st,problem:ot,anySelected:nt,allSelected:rt,someSelected:it,isEdit:v,savedPath:d||"",hasPages:Ee.length>0},pt={save:()=>ze(),discard:async()=>{!i()||!await E.confirm("Discard the changes you have not saved?")||(Be(JSON.parse(n.base.peek())),me())},switchPage:e=>{const t=e?.target?.value;t&&R.navigate("/pages/edit"+t)},showTab:e=>{n.tab.value=e,e==="live"&&ne&&le(),e==="history"&&v&&!pe&&W()},setMode:e=>{n.cssMode.peek()&&(n.cssMode.value=!1),n.viewMode.value=e,te("viewMode",e),e!=="write"&&U()},toggleFullscreen:()=>{n.fullscreen.value=!n.fullscreen.peek(),te("fullscreen",n.fullscreen.peek())},toggleCss:()=>{n.cssMode.peek()?(n.cssMode.value=!1,n.viewMode.value=Ze.includes(j().viewMode)?j().viewMode:"split",x.refresh()):(Ie(),n.cssMode.value=!0,n.viewMode.value="split")},formatMarkdown:()=>{const e=k.selectionStart,t=Jt(B());x.unfoldAll(),D=!0,k.value=t,D=!1,k.selectionStart=k.selectionEnd=Math.min(e,t.length),k.dispatchEvent(new Event("input")),x.refresh(),E.toast("Markdown formatted.",{type:"success"})},toggleFold:()=>{n.folded.peek()?x.unfoldAll():x.foldAll(),n.folded.value=!n.folded.peek()},setWidth:e=>{n.liveWidth.value=e;const t=y("#live-preview-stage");t&&(t.scrollTop=0)},refreshLive:()=>le(),share:async()=>{if(!v){E.toast("Save the page first - a share link needs a page to point at.",{type:"warning"});return}await Kt(d)},toggleAll:e=>{const t=!!e?.target?.checked;n.versions.peek().forEach(g=>{g.checked.value=t})},deleteSelected:async()=>{const e=n.versions.peek().filter(g=>g.checked.peek()).map(g=>g.filename),t=e.length;if(!(!t||!await E.confirm(`Delete ${t} version${t>1?"s":""}? This cannot be undone.`)))try{await b.versions.bulkDelete(d,e),E.toast(`${t} version${t>1?"s":""} deleted.`,{type:"success"}),await W()}catch(g){E.toast("Delete failed: "+g.message,{type:"error"})}},saveNamed:async()=>{await Ut(d)&&u()&&(n.tab.peek()==="history"?await W():pe=!1)},closeCompare:()=>{n.compare.value=null,ue=null},restoreCompared:()=>Ne(ue)};de.querySelectorAll("[data-tip]").forEach(e=>E.tooltip(e,{content:e.getAttribute("data-tip"),position:"top"}));const ut=M.applyBindings(dt,je,{methods:pt});o(()=>ut.dispose()),E.tabs(y("#editor-meta-tabs"));let ge=null;o(()=>clearTimeout(ge)),k.addEventListener("input",()=>{x.refresh(),L.scrollTop=k.scrollTop,!D&&(clearTimeout(ge),ge=setTimeout(Fe,120),U(),Ae())}),k.addEventListener("scroll",()=>{L.scrollTop=k.scrollTop}),I.addEventListener("scroll",()=>{L.scrollTop=I.scrollTop});let Ve=!0;o(M.effect(()=>{for(const e of Object.keys(a))e!=="css"&&e!=="cssOpened"&&a[e].value;if(n.bodyRev.value,Ve){Ve=!1,U();return}U(),Ae()}));let be=null;if(o(()=>clearTimeout(be)),o(M.effect(()=>{const e=a.css.value;n.cssMode.value&&(Yt(L,e,I),clearTimeout(be),be=setTimeout(Pe,400))})),v){const e=y(".pe-version-list"),t=E.contextMenu(e,{cursor:"glyph",match:"tr[data-file]",inherit:!1,density:"compact",items:g=>{const h=ve(g.dataset.file);return h?[{header:We(h)},{label:"Compare with the editor",icon:"columns",action:()=>fe(h)},{label:"Restore this version\u2026",icon:"rotate-ccw",action:()=>Ne(h.filename)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>ct(h)}]:[]}});o(()=>t?.destroy?.()),e.addEventListener("click",g=>{const h=g.target.closest("tr[data-file]");if(!h||g.target.matches("input"))return;const P=g.target.closest(".pe-kebab");if(P){Ue(P);return}const O=ve(h.dataset.file);O&&fe(O)}),e.addEventListener("keydown",g=>{const h=g.target.closest("tr[data-file]");if(h){if(g.key==="F10"&&g.shiftKey)g.preventDefault(),Ue(h);else if(g.key==="Enter"&&g.target===h){const P=ve(h.dataset.file);P&&fe(P)}}})}Xt(y("#editor-body"),y("#editor-divider"),o),wt({image:e=>(n.cssMode.value=!1,Zt(e)),scribe:e=>Mt(e),card:e=>vt.open(e,{onMediaBrowse:()=>b.media.list().catch(()=>[])}),timeline:e=>ft.open(e,{onPreview:async t=>{try{return await b.pages.preview(t)}catch{return null}},onListCollections:()=>b.collections.list().catch(()=>[]),onGetCollection:t=>b.collections.get(t).catch(()=>null)})}),ce.onEffects(e=>ht.open(e)),ce.onHelp(()=>_e()),ce.onTutorial(e=>St(e));const qe=e=>{(e.ctrlKey||e.metaKey)&&!e.shiftKey&&!e.altKey&&e.key.toLowerCase()==="s"&&u()&&(e.preventDefault(),ze())};document.addEventListener("keydown",qe),o(()=>document.removeEventListener("keydown",qe));const Y=c.querySelector(".dm-plugin-banner-name");o(M.effect(()=>{const e=Nt(v,a.title.value,d);if(!u()||(document.title=`${e} - Domma CMS`,!Y))return;const t=Y.lastChild;t&&t.nodeType===3&&Y.querySelector(".dm-plugin-banner-sep")?t.textContent=e:Y.textContent=`Pages \u203A ${e}`})),$(s,{id:"page-editor-back",icon:"arrow-left",title:"Back to Pages",onClick:()=>R.navigate("/pages")}),$(s,{id:"page-editor-help",icon:"help-circle",title:"Editor reference",onClick:()=>_e()}),v&&$(s,{id:"page-editor-view",icon:"external-link",title:"View on the site",onClick:()=>window.open(a.urlPath.peek()||d,"_blank","noopener")});const he=$(s,{id:"page-editor-clone",icon:"copy",title:"Clone page",onClick:()=>{if(!v||n.saving.peek()||!u())return;const e=A(),t=we({...e,urlPath:d});if(t.length){E.toast(t[0].text,{type:"warning"});return}const g=Ct({page:m,frontmatter:{...H(se()),adminTags:a.adminTags.peek()},body:e.body,source:d,pages:ae,onCloned:h=>{u()&&(p.cloneTarget=`/pages/edit${h}`,R.navigate(p.cloneTarget))}});o(()=>g.close())}});he&&(he.disabled=!v,v||(he.title="Save the page before cloning it")),$(s,{id:"page-editor-settings",icon:"settings",title:"Editor settings - font, size, spacing",onClick:()=>{Se=Ht(Me)}}),o(Ke(je,{position:"bottom"})),Domma.icons.scan();let He=null;try{He=S.get(G)}catch{}const N=zt(He);if(N&&(me(),z(N.state)!==n.base.peek())){const e=N.at?` (${Q(N.at)})`:"",t=Ft(N,n.base.peek())?" The saved page has changed since - restoring puts your version back over it (the saved one stays in History).":"";await E.confirm(`You left unsaved changes to this page${e}. Restore them?${t}`)&&u()&&Be(N.state)}}};function Yt(s,r,l){s.replaceChildren(...String(r).split(`
|
|
87
|
+
`).map((o,i)=>{const f=document.createElement("span");f.className="editor-line-number-row";const p=document.createElement("span");p.className="fold-toggle fold-toggle--empty";const c=document.createElement("span");return c.className="editor-line-num",c.textContent=String(i+1),f.append(p,c),f})),s.scrollTop=l.scrollTop}function Xt(s,r,l){if(!s||!r)return;const o=15,i=85,f=d=>Math.min(i,Math.max(o,d));let p=f(parseFloat(j().splitRatio)||50);const c=d=>{s.style.setProperty("--dm-editor-split",p+"%"),r.setAttribute("aria-valuenow",String(Math.round(p))),d&&te("splitRatio",p)};c(!1);const u=d=>{const v=s.getBoundingClientRect();v.width&&(p=f((d.clientX-v.left)/v.width*100)),c(!1)},C=()=>{document.removeEventListener("pointermove",u),document.removeEventListener("pointerup",C),s.classList.remove("is-resizing"),r.classList.remove("is-dragging"),c(!0)};l(C),r.addEventListener("pointerdown",d=>{window.matchMedia("(max-width: 768px)").matches||(d.preventDefault(),s.classList.add("is-resizing"),r.classList.add("is-dragging"),document.addEventListener("pointermove",u),document.addEventListener("pointerup",C))}),r.addEventListener("dblclick",()=>{p=50,c(!0)}),r.addEventListener("keydown",d=>{const v=d.shiftKey?10:2;let T=p;if(d.key==="ArrowLeft")T=p-v;else if(d.key==="ArrowRight")T=p+v;else if(d.key==="Home")T=o;else if(d.key==="End")T=i;else if(d.key==="Enter"||d.key===" ")T=50;else return;d.preventDefault(),p=f(T),c(!0)})}async function Zt(s){const r=s.selectionStart,l=s.selectionEnd,o=await Tt({title:"Upload or insert an image"});if(!o||!s.isConnected)return;let i=o.split("/").pop().split("?")[0];try{i=decodeURIComponent(i)}catch{}const f=i.replace(/[\\[\]]/g,"\\$&"),p=o.replace(/\(/g,"%28").replace(/\)/g,"%29").replace(/ /g,"%20");s.setSelectionRange(r,l),bt(s,``),s.dispatchEvent(new Event("input",{bubbles:!0}))}function Qt(s,r,l){const o=Object.entries(r||{});return s?(s.replaceChildren(...o.map(([i,f])=>new Option(f?.label||i,i))),o.some(([i])=>i===l)?l:o[0]?.[0]||l):l}async function ea(s,r){if(!s)return;let l=[];try{const u=await b.get("/collections/roles/entries?limit=100");l=Array.isArray(u)?u:u?.entries||[]}catch{}const o=l.map(u=>({name:u.data?.name,label:u.data?.label||u.data?.name,level:u.data?.level??99})).filter(u=>u.name&&u.level!==0).sort((u,C)=>C.level-u.level),i=(u,C)=>Object.assign(document.createElement("option"),{value:u,textContent:C}),f=()=>{typeof r=="string"&&r&&![...s.options].some(u=>u.value===r)&&s.append(i(r,`${r} (custom)`))};if(!o.length)return f();const p=document.createElement("optgroup");p.label="Role and above",o.forEach(u=>p.append(i(u.name,`${u.label} and above`)));const c=document.createElement("optgroup");c.label="Role only",o.forEach(u=>c.append(i(`=${u.name}`,`${u.label} only`))),s.replaceChildren(i("public","Public - everyone"),p,c,i("private","Private - super-admin only")),f()}
|
package/admin/js/views/pages.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as p}from"../api.js";import{SHAPES as Le,whileLoading as Ne}from"../lib/loading.js";import{addBannerAction as ne}from"../lib/plugin-chrome.js";import{helpPopovers as oe}from"../lib/help-popover.js";import{onceClosed as Re,openMenuAt as ie,settingsStore as je,sidebarCount as re,viewLifetime as Be}from"../lib/tool-kit.js";import{filterByProject as le,getProjectFromHash as Ke}from"../lib/project-context.js";import{arrangePages as qe,checkPrune as He,normaliseSettings as ce,pageRow as Ie,statusCounts as Ue,treeRows as _e}from"../lib/pages-arrange.js";import{UNTAGGED as Fe,activeNote as Oe,askTagName as Ve,openTagSettings as ze,same as de,tagCounts as Ge,tagMenuItems as We,tagsOf as Ye}from"../lib/list-tags.js";const pe=/^#\/(pages|projects\/[^/?#]+\/pages)\/?(\?.*)?$/,ue="#/pages",D="pages.prune.draft",Je=`
|
|
2
2
|
<div class="pgx-row" data-path="{{urlPath}}" tabindex="0" data-bind-class="rowCls">
|
|
3
3
|
<span class="pgx-main"><strong>{{title}}</strong><small>{{meta}} <span class="pgx-tags">{{tagsText}}</span></small></span>
|
|
4
4
|
<span class="pgx-chips">
|
|
5
5
|
<span class="pgx-chip" data-bind-hidden="!visibility" title="Who can see it">{{visibility}}</span>
|
|
6
|
+
<span class="pgx-chip is-tag" data-bind-hidden="!listTags" title="Tags">{{listTags}}</span>
|
|
6
7
|
<span class="pgx-chip is-info" data-bind-hidden="!project" title="Project">{{project}}</span>
|
|
7
8
|
<span class="pgx-chip" data-bind-hidden="!bundled" title="Shipped with the CMS">Bundled</span>
|
|
8
9
|
<span class="pgx-chip is-warn" data-bind-hidden="!plugin" title="Managed by this plugin">{{plugin}}</span>
|
|
9
10
|
<span class="pgx-chip" data-bind-class="statusCls">{{statusText}}</span>
|
|
10
11
|
</span>
|
|
11
12
|
<button type="button" class="pgx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
|
|
12
|
-
</div>`,
|
|
13
|
+
</div>`,T=r=>String(r??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function Qe(r){const l=document.createElement("template");return l.innerHTML=r.trim(),l.content.firstElementChild}const ge=je("pages.settings",ce),i=Be(pe),Xe=r=>`#/pages/edit${r}`;export const pagesView={templateUrl:"/admin/js/templates/pages.html",async onMount(r){i.reset();const l=r.get(0).querySelector(".pgx")||r.get(0),L=()=>pe.test(location.hash)&&l.isConnected,d=Ke(),x=ge.read(),o=M.observable([]),_=M.observable(0),F=M.observable(!0),N=M.observable(""),h=M.observable(x.status),R=M.observable(x.sort),b=M.observable(x.mode),O=M.observable(""),u=M.observable(x.tag),y=M.observable([]);i.add(M.effect(()=>ge.write(ce({status:h.value,sort:R.value,mode:b.value,tag:u.value}))));const C=M.observable([]);i.add(M.effect(()=>{C.value=qe(o.value,{status:h.value,query:O.value,sort:R.value,tag:u.value})}));const V=M.observable([]);i.add(M.effect(()=>{V.value=C.value.map(e=>Ie(e))}));const he=M.computed(()=>Ue(o.value)),me=M.computed(()=>o.value.length?"No pages match.":"No pages yet - New page in the banner makes one."),ve=M.computed(()=>{const e=_.value;return!d||!e?"":`Showing only pages in ${d}. ${e} other page${e===1?" is":"s are"} filed elsewhere.`}),j=M.computed(()=>Ge(y.value,o.value)),fe=M.computed(()=>Oe(u.value,C.value.length,"pages")),z=e=>o.peek().find(t=>t.urlPath===e);async function be(){try{const e=await Ne(l.querySelector(".dm-loading-slot"),p.pages.list(),Le.rows);if(!L())return;const t=Array.isArray(e)?e:[],a=d?le(t,d):t;_.value=t.length-a.length,o.value=a,N.value="",await G()}catch(e){N.value=`Could not load the pages: ${e.message||e}`}finally{F.value=!1}}async function G(){try{y.value=(await p.listTags.list("pages")).map(t=>t.name)}catch{}const e=u.peek();e&&e!==Fe&&!j.peek().some(t=>de(t.name,e))&&(u.value="")}async function W(e,t){try{const a=await p.listTags.changeItem("pages",e.urlPath,t);o.value=o.peek().map(s=>s.urlPath===e.urlPath?{...s,adminTags:a}:s),a.some(s=>!y.peek().some(v=>de(v,s)))&&(y.value=[...y.peek(),...a])}catch(a){E.toast(`Tags not saved: ${a.message||a}`,{type:"error"})}}async function ye(){const e=await p.pages.list(),t=Array.isArray(e)?e:[];o.value=d?le(t,d):t,await G()}const we=()=>ze({kind:"pages",noun:"pages",title:"Pages settings - tags",tags:j,active:u,reload:ye}),$=e=>{location.hash=Xe(e)},ke=()=>{location.hash=d?`#/pages/new?project=${encodeURIComponent(d)}`:"#/pages/new"};async function Pe(e){const t=e.plugin?`"${T(e.title||e.urlPath)}" is managed by the <strong>${T(e.plugin)}</strong> plugin - deleting it may stop that plugin working. Delete it anyway?`:`Delete the page at <strong>${T(e.urlPath)}</strong>? This cannot be undone.`;if(await E.confirm(t))try{await p.pages.delete(e.urlPath),E.toast("Page deleted.",{type:"success"}),o.value=o.peek().filter(a=>a.urlPath!==e.urlPath),re(ue)}catch(a){E.toast(`Not deleted: ${a.message||a}`,{type:"error"})}}async function Me(e){const t=new URL(e.urlPath,location.origin).href;try{await navigator.clipboard.writeText(t),E.toast("Address copied.",{type:"success",duration:2e3})}catch{E.toast(t,{type:"info",duration:8e3})}}async function B(e,t){if(e.status!==t)try{const a=await p.pages.get(e.urlPath);await p.pages.update(e.urlPath,{frontmatter:{status:t},body:a.content||""}),o.value=o.peek().map(s=>s.urlPath===e.urlPath?{...s,status:t}:s),E.toast(t==="published"?"Page published.":t==="review"?"Page moved to review.":"Page moved to drafts.",{type:"success"}),re(ue)}catch(a){E.toast(`Could not change page status: ${a.message||a}`,{type:"error"})}}let g=null;const K=new Set;let Y=!1;function J(e,t=null){const a=e.versionCount||0,s=M.observable(String(t?.keep??Math.min(5,a))),v=M.observable(!1),k=M.computed(()=>He(s.value,a)),ee={keep:s,saving:v,err:M.computed(()=>k.value.error),summary:M.computed(()=>{const n=k.value;return n.error?"":n.deletes?`Deletes the ${n.deletes} oldest, keeps the newest ${Math.min(n.keep,a)}.`:"Nothing to delete - that keeps them all."}),cannot:M.computed(()=>v.value||!!k.value.error||k.value.deletes===0),intro:`${e.title||e.urlPath} has ${a} saved revision${a===1?"":"s"} - earlier versions you can compare with and restore from the editor's History.`},Se=String(s.peek()),te=()=>String(s.peek())!==Se||!!t,c=E.slideover({title:`Trim revisions - ${T(e.title||e.urlPath)}`,size:"sm",position:"right"}),f=Qe(`
|
|
13
14
|
<div class="pgx-form">
|
|
14
15
|
<p class="pgx-note" data-bind-text="intro"></p>
|
|
15
16
|
<div>
|
|
@@ -25,4 +26,4 @@ import{api as y}from"../api.js";import{SHAPES as ke,whileLoading as Pe}from"../l
|
|
|
25
26
|
<button type="button" class="btn btn-sm btn-danger" data-on-click="trim" data-bind-disabled="cannot.value">
|
|
26
27
|
<span data-icon="archive" data-icon-size="13"></span> Trim</button>
|
|
27
28
|
</div>
|
|
28
|
-
</div>`);async function
|
|
29
|
+
</div>`);async function ae(){if(!ee.cannot.peek()){v.value=!0;try{const n=await p.versions.prune(e.urlPath,k.peek().keep);E.toast(`Removed ${n.deleted} revision${n.deleted===1?"":"s"}; kept ${n.kept}.`,{type:"success"}),o.value=o.peek().map(P=>P.urlPath===e.urlPath?{...P,versionCount:n.kept}:P),H=!0,c.close()}catch(n){E.toast(n.message||"Could not trim the revisions.",{type:"error"})}finally{v.value=!1}}}c.element.appendChild(f);const Ce=oe(f),$e=M.applyBindings(ee,f,{methods:{trim:ae,cancel:()=>c.close()}}),Ae=n=>{n.key==="Enter"&&n.target.id==="pgx-f-keep"&&(n.preventDefault(),ae())};f.addEventListener("keydown",Ae);const se={path:e.urlPath,dirty:te,values:()=>({keep:s.peek()})};g=se,K.add(c),Re(c,()=>{$e.dispose(),Ce(),K.delete(c),g===se&&(g=null)});const De=c.close.bind(c);let H=!1,U=!1;c.close=async n=>{if(!H&&n!==!0&&!Y&&te()){if(U)return;U=!0;const P=await E.confirm("Discard the change you have not applied?");if(U=!1,!P)return}return H=!0,De()},I.scan(f),c.open(),f.querySelector("#pgx-f-keep")?.select()}function Ee(e){const t=z(e);if(!t)return[];const a=t.versionCount||0;return[{header:t.title||t.urlPath},{label:"Edit",icon:"edit",action:()=>$(t.urlPath)},{label:t.status==="published"?"View on the site":"Preview (draft)",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{label:"Copy address",icon:"copy",action:()=>Me(t)},{label:"Transition",icon:"circle",submenu:[{label:"Published",disabled:t.status==="published",action:()=>B(t,"published")},{label:"Draft",disabled:t.status==="draft",action:()=>B(t,"draft")},{label:"Review",disabled:t.status==="review",action:()=>B(t,"review")}]},{type:"divider"},...We({all:j.peek().map(s=>s.name),current:Ye(t),change:s=>W(t,s),newTag:async()=>{const s=await Ve();s&&await W(t,{add:[s]})}}),{type:"divider"},{label:a?`Trim revisions (${a})\u2026`:"No revisions to trim",icon:"archive",disabled:!a,action:()=>J(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>Pe(t)}]}const m=l.querySelector(".pgx-list");m.innerHTML=Je;const Te=M.applyBindings({pages:o,loading:F,loadError:N,status:h,sort:R,mode:b,query:O,rows:V,counts:he,emptyText:me,projectNote:ve,tagNote:fe},l,{methods:{showAllTags:()=>{u.value=""},showAll:()=>{h.value="all"},showPublished:()=>{h.value="published"},showDrafts:()=>{h.value="draft"},showList:()=>{b.value="list"},showTree:()=>{b.value="tree"}}});i.add(()=>Te.dispose()),i.add(oe(l,{position:"bottom"}));const q=l.querySelector(".pgx-tree");let A=null;i.add(M.effect(()=>{const e=b.value==="tree",t=e?_e(C.value):null;try{A?.destroy?.()}catch{}A=null,q.replaceChildren(),!(!e||!t.length)&&(A=E.treeView(q,{data:t,idKey:"id",parentKey:"parent_id",labelKey:"name",iconKey:"icon",expandedByDefault:!0,onSelect:a=>$(a)}),I.scan(q))})),i.add(()=>{try{A?.destroy?.()}catch{}});const xe=E.contextMenu(m,{cursor:"glyph",match:".pgx-row",inherit:!1,density:"compact",items:e=>Ee(e.dataset.path)});i.add(()=>xe?.destroy?.());const Q=e=>{const t=e.target.closest(".pgx-row");if(!t)return;const a=e.target.closest(".pgx-kebab");if(a){ie(a);return}$(t.dataset.path)},X=e=>{const t=e.target.closest(".pgx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ie(t)):e.key==="Enter"&&e.target===t&&$(t.dataset.path))};m.addEventListener("click",Q),m.addEventListener("keydown",X),i.add(()=>{m.removeEventListener("click",Q),m.removeEventListener("keydown",X)}),ne(r,{id:"pages-settings",icon:"settings",title:"Pages settings - tags",onClick:we}),ne(r,{id:"pages-new",icon:"plus",title:"New page",onClick:ke});const Z=e=>{g?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Z),i.add(()=>window.removeEventListener("beforeunload",Z)),i.add(()=>{if(g?.dirty())try{S.set(D,{path:g.path,values:g.values(),at:new Date().toISOString()})}catch{}Y=!0,[...K].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(l),await be(),!L())return;let w=null;try{w=S.get(D)}catch{}if(w?.path&&w.values){try{S.remove?S.remove(D):S.set(D,null)}catch{}const e=z(w.path);e&&(e.versionCount||0)>0&&await E.confirm(`You left trimming the revisions of "${T(e.title||e.urlPath)}" unfinished. Pick up where you left off?`)&&L()&&J(e,w.values)}}};
|
package/package.json
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List tags - admin-only labels on pages and blocks (services/listTags.js).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/list-tags/:kind - {tags: [{name, count}]}
|
|
5
|
+
* POST /api/list-tags/:kind - {name}: create a tag
|
|
6
|
+
* PUT /api/list-tags/:kind/item - {id, tags} replaces one item's tags; {id, add, remove} patches them
|
|
7
|
+
* POST /api/list-tags/:kind/rename - {from, to}
|
|
8
|
+
* POST /api/list-tags/:kind/delete - {name}: take it off every item
|
|
9
|
+
*
|
|
10
|
+
* :kind is `pages` or `blocks`; both sit under the pages permission, as the
|
|
11
|
+
* blocks routes do. Names travel in bodies, not paths, so any spelling works.
|
|
12
|
+
* Every change is limited to the items the caller's projects let them see.
|
|
13
|
+
*/
|
|
14
|
+
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
15
|
+
import {addTag, changeItemTags, deleteTag, listTags, renameTag} from '../../services/listTags.js';
|
|
16
|
+
import {canSeeArtefact, canSeePage} from '../../services/projects.js';
|
|
17
|
+
import {getBlock} from '../../services/blocks.js';
|
|
18
|
+
import {getPage} from '../../services/content.js';
|
|
19
|
+
|
|
20
|
+
/** Visibility of one item from what the listers return. */
|
|
21
|
+
function visibleTo(user, kind) {
|
|
22
|
+
return kind === 'pages'
|
|
23
|
+
? (p) => canSeeArtefact(user, {meta: {project: p.resolvedProject}})
|
|
24
|
+
: (b) => canSeeArtefact(user, b);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fail(reply, err) {
|
|
28
|
+
if (err.code === 'BAD_KIND') return reply.status(404).send({error: err.message});
|
|
29
|
+
if (err.code === 'BAD_TAG' || err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
30
|
+
if (err.code === 'ENOENT') return reply.status(404).send({error: 'Not found'});
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function listTagsRoutes(fastify) {
|
|
35
|
+
const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
|
|
36
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update')]};
|
|
37
|
+
|
|
38
|
+
fastify.get('/list-tags/:kind', canRead, async (request, reply) => {
|
|
39
|
+
try {
|
|
40
|
+
return {tags: await listTags(request.params.kind, visibleTo(request.user, request.params.kind))};
|
|
41
|
+
} catch (err) { return fail(reply, err); }
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
fastify.post('/list-tags/:kind', canUpdate, async (request, reply) => {
|
|
45
|
+
try {
|
|
46
|
+
return {name: await addTag(request.params.kind, request.body?.name)};
|
|
47
|
+
} catch (err) { return fail(reply, err); }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
fastify.put('/list-tags/:kind/item', canUpdate, async (request, reply) => {
|
|
51
|
+
const {kind} = request.params;
|
|
52
|
+
const {id, tags, add, remove} = request.body || {};
|
|
53
|
+
if (typeof id !== 'string' || !id) return reply.status(400).send({error: 'id is required'});
|
|
54
|
+
const lists = [tags, add, remove].filter(v => v !== undefined);
|
|
55
|
+
if (!lists.length || !lists.every(Array.isArray)) return reply.status(400).send({error: 'send tags, or add/remove, as arrays'});
|
|
56
|
+
try {
|
|
57
|
+
if (kind === 'pages') {
|
|
58
|
+
const page = await getPage(id);
|
|
59
|
+
if (!page) return reply.status(404).send({error: 'Page not found'});
|
|
60
|
+
if (!(await canSeePage(request.user, id, page.project))) return reply.status(403).send({error: 'Access denied for this project'});
|
|
61
|
+
} else if (kind === 'blocks') {
|
|
62
|
+
const block = await getBlock(id).catch(() => null);
|
|
63
|
+
if (!block) return reply.status(404).send({error: 'Block not found'});
|
|
64
|
+
if (!canSeeArtefact(request.user, block)) return reply.status(403).send({error: 'Access denied for this project'});
|
|
65
|
+
}
|
|
66
|
+
return {tags: await changeItemTags(kind, id, Array.isArray(tags) ? {tags} : {add, remove})};
|
|
67
|
+
} catch (err) { return fail(reply, err); }
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
fastify.post('/list-tags/:kind/rename', canUpdate, async (request, reply) => {
|
|
71
|
+
const {from, to} = request.body || {};
|
|
72
|
+
if (typeof from !== 'string' || !from) return reply.status(400).send({error: 'from is required'});
|
|
73
|
+
try {
|
|
74
|
+
return await renameTag(request.params.kind, from, to, visibleTo(request.user, request.params.kind));
|
|
75
|
+
} catch (err) { return fail(reply, err); }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
fastify.post('/list-tags/:kind/delete', canUpdate, async (request, reply) => {
|
|
79
|
+
const {name} = request.body || {};
|
|
80
|
+
if (typeof name !== 'string' || !name) return reply.status(400).send({error: 'name is required'});
|
|
81
|
+
try {
|
|
82
|
+
return await deleteTag(request.params.kind, name, visibleTo(request.user, request.params.kind));
|
|
83
|
+
} catch (err) { return fail(reply, err); }
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -181,6 +181,8 @@ export async function pagesRoutes(fastify) {
|
|
|
181
181
|
category: p.category || null,
|
|
182
182
|
visibility: p.visibility || 'public',
|
|
183
183
|
tags: p.tags || [],
|
|
184
|
+
// Admin-only list tags (services/listTags.js) - not the public `tags`.
|
|
185
|
+
adminTags: Array.isArray(p.adminTags) ? p.adminTags : [],
|
|
184
186
|
project: p.project ?? null,
|
|
185
187
|
resolvedProject: p.resolvedProject ?? null,
|
|
186
188
|
// The list's "managed by a plugin" chip and delete warning read these;
|
package/server/server.js
CHANGED
|
@@ -532,6 +532,7 @@ const { formsRoutes } = await import('./routes/api/forms.js');
|
|
|
532
532
|
const { viewsRoutes } = await import('./routes/api/views.js');
|
|
533
533
|
const { actionsRoutes } = await import('./routes/api/actions.js');
|
|
534
534
|
const {blocksRoutes} = await import('./routes/api/blocks.js');
|
|
535
|
+
const {listTagsRoutes} = await import('./routes/api/list-tags.js');
|
|
535
536
|
const {componentsRoutes} = await import('./routes/api/components.js');
|
|
536
537
|
const {versionsRoutes} = await import('./routes/api/versions.js');
|
|
537
538
|
const {effectsRoutes} = await import('./routes/api/effects.js');
|
|
@@ -568,6 +569,7 @@ await app.register(formsRoutes, { prefix: '/api' });
|
|
|
568
569
|
await app.register(viewsRoutes, { prefix: '/api' });
|
|
569
570
|
await app.register(actionsRoutes, { prefix: '/api' });
|
|
570
571
|
await app.register(blocksRoutes, {prefix: '/api'});
|
|
572
|
+
await app.register(listTagsRoutes, {prefix: '/api'});
|
|
571
573
|
await app.register(componentsRoutes, {prefix: '/api'});
|
|
572
574
|
await app.register(versionsRoutes, {prefix: '/api'});
|
|
573
575
|
await app.register(effectsRoutes, {prefix: '/api'});
|
|
@@ -334,9 +334,17 @@ export async function saveBlock(name, content, {bundled, css, meta} = {}) {
|
|
|
334
334
|
await fs.mkdir(BLOCKS_DIR, {recursive: true});
|
|
335
335
|
await fs.writeFile(blockFilePath(name), content, 'utf8');
|
|
336
336
|
const metaPath = blockMetaPath(name);
|
|
337
|
+
// List tags (services/listTags.js) are set from the Blocks list, not by a
|
|
338
|
+
// content save: a caller that does not send `adminTags` keeps the stored
|
|
339
|
+
// ones, or an editor opened before a tagging would save over it.
|
|
340
|
+
let keptTags;
|
|
341
|
+
if (!(meta && typeof meta === 'object' && 'adminTags' in meta)) {
|
|
342
|
+
try { keptTags = JSON.parse(await fs.readFile(metaPath, 'utf8'))?.adminTags; } catch { /* no sidecar */ }
|
|
343
|
+
}
|
|
337
344
|
// Merge sidecar meta - bundled flag plus any caller-supplied fields (eg meta.project).
|
|
338
345
|
const sidecar = {
|
|
339
346
|
...(meta && typeof meta === 'object' ? meta : {}),
|
|
347
|
+
...(Array.isArray(keptTags) && keptTags.length ? {adminTags: keptTags} : {}),
|
|
340
348
|
...(bundled ? {bundled: true} : {})
|
|
341
349
|
};
|
|
342
350
|
if (Object.keys(sidecar).length > 0) {
|
|
@@ -358,6 +366,31 @@ export async function saveBlock(name, content, {bundled, css, meta} = {}) {
|
|
|
358
366
|
return {success: true, name};
|
|
359
367
|
}
|
|
360
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Merge keys into a block's sidecar meta without touching its HTML or CSS.
|
|
371
|
+
* A key set to undefined is removed; an emptied sidecar is deleted, as
|
|
372
|
+
* saveBlock does. No cache invalidation: the sidecar is admin metadata
|
|
373
|
+
* (project, list tags) that rendering never reads.
|
|
374
|
+
*
|
|
375
|
+
* @param {string} name
|
|
376
|
+
* @param {object} patch
|
|
377
|
+
* @returns {Promise<object>} the sidecar as written
|
|
378
|
+
* @throws {Error} With code INVALID_NAME, or ENOENT when the block is not found
|
|
379
|
+
*/
|
|
380
|
+
export async function setBlockMeta(name, patch) {
|
|
381
|
+
assertValidName(name);
|
|
382
|
+
await fs.access(blockFilePath(name));
|
|
383
|
+
let meta = {};
|
|
384
|
+
try { meta = JSON.parse(await fs.readFile(blockMetaPath(name), 'utf8')) || {}; } catch { /* no sidecar yet */ }
|
|
385
|
+
for (const [k, v] of Object.entries(patch || {})) {
|
|
386
|
+
if (v === undefined) delete meta[k];
|
|
387
|
+
else meta[k] = v;
|
|
388
|
+
}
|
|
389
|
+
if (Object.keys(meta).length) await fs.writeFile(blockMetaPath(name), JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
|
390
|
+
else await fs.unlink(blockMetaPath(name)).catch(() => {});
|
|
391
|
+
return meta;
|
|
392
|
+
}
|
|
393
|
+
|
|
361
394
|
/**
|
|
362
395
|
* Delete a block and its companion files (.css, .meta.json).
|
|
363
396
|
* Missing companions are ignored; only the primary .html file triggers ENOENT.
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import fs from 'fs/promises';
|
|
7
7
|
import path from 'path';
|
|
8
|
+
import matter from 'gray-matter';
|
|
8
9
|
import {parseMarkdown, serialiseMarkdown} from './markdown.js';
|
|
9
10
|
import {config} from '../config.js';
|
|
10
11
|
import {hooks} from './hooks.js';
|
|
@@ -141,6 +142,21 @@ function cleanTags(tags) {
|
|
|
141
142
|
)];
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The admin-only list tags (services/listTags.js) as the page editor sends
|
|
147
|
+
* them: the same cleaning as listTags' (case-insensitive duplicates go), and
|
|
148
|
+
* an empty list leaves no key behind.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} meta - frontmatter, changed in place
|
|
151
|
+
*/
|
|
152
|
+
function cleanAdminTags(meta) {
|
|
153
|
+
if (!('adminTags' in meta)) return;
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
const list = cleanTags(meta.adminTags).filter(t => !seen.has(t.toLowerCase()) && seen.add(t.toLowerCase()));
|
|
156
|
+
if (list.length) meta.adminTags = list;
|
|
157
|
+
else delete meta.adminTags;
|
|
158
|
+
}
|
|
159
|
+
|
|
144
160
|
export async function createPage(urlPath, frontmatter, body) {
|
|
145
161
|
const filePath = urlPathToFilePath(urlPath);
|
|
146
162
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
@@ -164,6 +180,7 @@ export async function createPage(urlPath, frontmatter, body) {
|
|
|
164
180
|
updatedAt: now
|
|
165
181
|
};
|
|
166
182
|
if (meta.project === '' || meta.project === undefined) delete meta.project;
|
|
183
|
+
cleanAdminTags(meta);
|
|
167
184
|
|
|
168
185
|
await fs.writeFile(filePath, serialiseMarkdown(meta, body || ''), 'utf8');
|
|
169
186
|
const page = await readPageFile(filePath);
|
|
@@ -197,6 +214,7 @@ export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
|
|
|
197
214
|
// Only when tags are part of this save: a caller that sends no tags is not
|
|
198
215
|
// asking for the page's tags to be emptied.
|
|
199
216
|
if ('tags' in meta) meta.tags = cleanTags(meta.tags);
|
|
217
|
+
cleanAdminTags(meta);
|
|
200
218
|
|
|
201
219
|
// `seo` is merged, not replaced: the page editor sends only title and
|
|
202
220
|
// description, and a save from it must not wipe what the SEO Tool set
|
|
@@ -227,6 +245,37 @@ export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
|
|
|
227
245
|
return page;
|
|
228
246
|
}
|
|
229
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Merge keys into a page's frontmatter as metadata, not as an edit: no
|
|
250
|
+
* revision, updatedAt unchanged, body byte-for-byte as it was. For admin-only
|
|
251
|
+
* keys (list tags) that rendering never reads. A key set to undefined is
|
|
252
|
+
* removed.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} urlPath
|
|
255
|
+
* @param {object} patch
|
|
256
|
+
* @returns {Promise<void>}
|
|
257
|
+
* @throws {Error} With code ENOENT when the page does not exist
|
|
258
|
+
*/
|
|
259
|
+
export async function setPageFrontmatter(urlPath, patch) {
|
|
260
|
+
const filePath = await resolveExistingFilePath(urlPath);
|
|
261
|
+
if (!filePath) {
|
|
262
|
+
const err = new Error(`Page not found: ${urlPath}`);
|
|
263
|
+
err.code = 'ENOENT';
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
267
|
+
// gray-matter caches parses by input string and hands back the same
|
|
268
|
+
// object: copy before changing it.
|
|
269
|
+
const parsed = matter(raw);
|
|
270
|
+
const data = {...parsed.data};
|
|
271
|
+
for (const [k, v] of Object.entries(patch || {})) {
|
|
272
|
+
if (v === undefined) delete data[k];
|
|
273
|
+
else data[k] = v;
|
|
274
|
+
}
|
|
275
|
+
await fs.writeFile(filePath, serialiseMarkdown(data, parsed.content), 'utf8');
|
|
276
|
+
await cache.invalidateTags(['pages:list']);
|
|
277
|
+
}
|
|
278
|
+
|
|
230
279
|
/**
|
|
231
280
|
* Rename (move) a page to a new URL path.
|
|
232
281
|
* Creates any required parent directories for the destination.
|