domma-cms 0.22.1 → 0.22.4
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/lib/project-quick-create.js +2 -0
- package/admin/js/templates/project-detail.html +140 -38
- package/admin/js/views/menu-editor.js +8 -8
- package/admin/js/views/project-detail.js +1 -4
- package/package.json +1 -1
- package/public/js/menu-decor.mjs +1 -0
- package/scripts/build.js +263 -246
- package/scripts/verify-assets.mjs +190 -0
- package/server/routes/api/blocks.js +2 -2
- package/server/routes/api/collections.js +2 -2
- package/server/routes/api/pages.js +2 -0
- package/server/routes/api/projects.js +2 -1
- package/server/services/projects.js +10 -9
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{api as g}from"../api.js";const C={pages:"Page",collections:"Collection",menus:"Menu",blocks:"Block"};export const INLINE_TYPES=Object.keys(C);function d(n){return String(n||"").toLowerCase().trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")}function L(n){return String(n??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}function l(n,t=""){const e=document.createElement("input");return e.type="text",e.className="form-input",e.placeholder=n,e.value=t,e}function T(n,t,e){const r=document.createElement("div");r.style.cssText="display:flex;flex-direction:column;gap:.35rem;";const a=document.createElement("label");if(a.className="form-label",a.textContent=n,r.appendChild(a),r.appendChild(t),e){const o=document.createElement("small");o.style.cssText="color:var(--text-muted,#888);font-size:.78rem;",o.textContent=e,r.appendChild(o)}return r}function x(n,t){const e=document.createElement("button");return e.type="button",e.className=t,e.textContent=n,e}function v(n,t,e=""){let r=!1;t.addEventListener("input",()=>{r=!0}),n.addEventListener("input",()=>{r||(t.value=e+d(n.value))})}const k={pages(n){const t=l("e.g. Squad List"),e=(n.rootUrl||"").replace(/\/+$/,""),r=l(e?`${e}/squad-list`:"/squad-list");return v(t,r,e+"/"),{rows:[["Title",t,"Shown as the page heading and in the pages list."],["URL path",r,e?`Keeping it under ${e}/ files the page in this project automatically.`:"Where the page lives, e.g. /squad-list."]],async submit(){const a=t.value.trim();if(!a)throw new Error("Title is required.");let o=r.value.trim()||"/"+d(a);return o.startsWith("/")||(o="/"+o),await g.pages.create({urlPath:o,frontmatter:{title:a,project:n.slug},body:""}),{editorHash:"#/pages/edit"+o,label:a}}}},collections(n){const t=l("e.g. Players"),e=l("players");return v(t,e),{rows:[["Title",t,"Display name for the collection."],["Slug",e,"Used in URLs and the API. Lowercase letters, numbers and hyphens."]],note:"Creates an empty collection tagged to this project \u2014 add fields in the editor.",async submit(){const r=t.value.trim();if(!r)throw new Error("Title is required.");const a=d(e.value||r);if(!a)throw new Error("A valid slug is required.");return await g.collections.create({title:r,slug:a,fields:[],meta:{project:n.slug}}),{editorHash:"#/collections/edit/"+encodeURIComponent(a),label:r}}}},menus(n){const t=l("e.g. Tournament Nav"),e=l("tournament-nav");return v(t,e),{rows:[["Name",t,"Display name for the menu."],["Slug",e,'Referenced by [menu slug="\u2026"] and menu locations.']],note:"Creates an empty menu tagged to this project \u2014 add items in the editor.",async submit(){const r=t.value.trim();if(!r)throw new Error("Name is required.");const a=d(e.value||r);if(!a)throw new Error("A valid slug is required.");return await g.menus.create({slug:a,name:r,items:[],meta:{project:n.slug}}),{editorHash:"#/menus/edit/"+encodeURIComponent(a),label:r}}}},blocks(n){const t=l("e.g. score-banner");return{rows:[["Name",t,'Lowercase letters, numbers and hyphens. Embed with [block name="\u2026"].']],note:"Creates a placeholder block tagged to this project \u2014 edit its HTML in the editor.",async submit(){const e=d(t.value);if(!e||!/^[a-z0-9][a-z0-9-]*$/.test(e))throw new Error("Name must be lowercase letters, numbers and hyphens.");return await g.blocks.put(e,{content:`<!-- ${e} -->
|
|
2
|
+
`,meta:{project:n.slug}}),{editorHash:"#/blocks/edit/"+encodeURIComponent(e),label:e}}}}};export function openQuickCreate({type:n,project:t,onCreated:e}){const r=k[n];if(!r)return;const a=r(t),o=C[n],f=E.slideover({title:`New ${o}`,size:"lg",position:"right"}),i=document.createElement("div");i.style.cssText="display:flex;flex-direction:column;gap:1rem;padding:1.25rem;";const h=document.createElement("p");if(h.style.cssText="margin:0;color:var(--text-muted,#888);font-size:.9rem;",h.innerHTML=`Filed under <strong>${L(t.name||t.slug)}</strong>.`,i.appendChild(h),a.rows.forEach(([s,c,w])=>i.appendChild(T(s,c,w))),a.note){const s=document.createElement("p");s.style.cssText="margin:0;color:var(--text-muted,#888);font-size:.82rem;",s.textContent=a.note,i.appendChild(s)}const u=document.createElement("div");u.style.cssText="display:flex;gap:.5rem;flex-wrap:wrap;padding-top:.75rem;border-top:1px solid var(--border-color,rgba(255,255,255,.1));";const m=x("Create","btn btn-primary"),p=x("Create & edit","btn btn-secondary");u.appendChild(m),u.appendChild(p),i.appendChild(u);let b=!1;async function y(s){if(!b){b=!0,m.disabled=p.disabled=!0;try{const{editorHash:c,label:w}=await a.submit();E.toast(`${o} \u201C${w}\u201D created.`,{type:"success"}),f.close(),typeof e=="function"&&await e(),s&&c&&(location.hash=c)}catch(c){E.toast(c?.message||`Failed to create ${o.toLowerCase()}.`,{type:"error"}),b=!1,m.disabled=p.disabled=!1}}}m.addEventListener("click",()=>y(!1)),p.addEventListener("click",()=>y(!0)),i.addEventListener("keydown",s=>{s.key==="Enter"&&s.target.tagName==="INPUT"&&(s.preventDefault(),y(!1))}),f.setContent(i),f.open(),setTimeout(()=>{const s=i.querySelector("input");s&&s.focus()},60)}
|
|
@@ -1,50 +1,152 @@
|
|
|
1
1
|
<style>
|
|
2
|
-
.
|
|
2
|
+
.pd-wrap { display: flex; flex-direction: column; gap: 1.5rem; }
|
|
3
|
+
|
|
4
|
+
/* ── Hero ─────────────────────────────────────────────────────────── */
|
|
5
|
+
.pd-hero {
|
|
6
|
+
display: flex;
|
|
7
|
+
gap: 1.25rem;
|
|
8
|
+
align-items: flex-start;
|
|
9
|
+
padding: 1.5rem;
|
|
10
|
+
border: 1px solid var(--border-color, rgba(255,255,255,.08));
|
|
11
|
+
border-radius: 14px;
|
|
12
|
+
background:
|
|
13
|
+
radial-gradient(120% 160% at 0% 0%, rgba(91,140,255,.12), transparent 55%),
|
|
14
|
+
var(--card-bg, rgba(255,255,255,.04));
|
|
15
|
+
position: relative;
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
}
|
|
18
|
+
.pd-hero::before {
|
|
19
|
+
content: '';
|
|
20
|
+
position: absolute;
|
|
21
|
+
top: 0; bottom: 0; left: 0;
|
|
22
|
+
width: 4px;
|
|
23
|
+
background: linear-gradient(180deg, #5b8cff, #7c6af7);
|
|
24
|
+
}
|
|
25
|
+
.pd-hero-icon {
|
|
26
|
+
width: 64px; height: 64px;
|
|
27
|
+
flex-shrink: 0;
|
|
28
|
+
border-radius: 16px;
|
|
29
|
+
display: flex; align-items: center; justify-content: center;
|
|
30
|
+
background: linear-gradient(135deg, rgba(91,140,255,.28), rgba(124,106,247,.28));
|
|
31
|
+
color: #cfe0ff;
|
|
32
|
+
box-shadow: 0 6px 18px rgba(91,140,255,.18);
|
|
33
|
+
}
|
|
34
|
+
.pd-hero-icon svg, .pd-hero-icon span[data-icon] { width: 32px; height: 32px; }
|
|
35
|
+
.pd-hero-main { flex: 1; min-width: 0; }
|
|
36
|
+
.pd-hero-top {
|
|
37
|
+
display: flex; align-items: flex-start; justify-content: space-between;
|
|
38
|
+
gap: 1rem; flex-wrap: wrap;
|
|
39
|
+
}
|
|
40
|
+
.pd-title { font-size: 1.6rem; font-weight: 700; margin: 0; line-height: 1.15; }
|
|
41
|
+
.pd-actions { display: flex; gap: .5rem; flex-shrink: 0; }
|
|
42
|
+
.pd-desc { color: var(--text-muted, #888); margin: .5rem 0 0; max-width: 64ch; }
|
|
43
|
+
.pd-desc:empty { display: none; }
|
|
44
|
+
|
|
45
|
+
/* ── Meta badges ──────────────────────────────────────────────────── */
|
|
46
|
+
.pd-meta { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: 1.1rem; }
|
|
47
|
+
.pd-meta-badge {
|
|
48
|
+
display: inline-flex; align-items: center; gap: .45rem;
|
|
49
|
+
padding: .4rem .7rem;
|
|
50
|
+
border-radius: 999px;
|
|
51
|
+
font-size: .8rem; line-height: 1;
|
|
52
|
+
border: 1px solid transparent;
|
|
53
|
+
}
|
|
54
|
+
.pd-meta-badge svg, .pd-meta-badge span[data-icon] { width: 14px; height: 14px; }
|
|
55
|
+
.pd-meta-k { color: var(--text-muted, #999); font-weight: 500; }
|
|
56
|
+
.pd-meta-v { font-weight: 600; }
|
|
57
|
+
|
|
58
|
+
/* ── Section header ───────────────────────────────────────────────── */
|
|
59
|
+
.pd-section-head {
|
|
60
|
+
display: flex; align-items: baseline; gap: .6rem;
|
|
61
|
+
margin: 0 0 .35rem;
|
|
62
|
+
}
|
|
63
|
+
.pd-section-title {
|
|
64
|
+
font-size: 1rem; font-weight: 600; margin: 0;
|
|
65
|
+
display: flex; align-items: center; gap: .5rem;
|
|
66
|
+
}
|
|
67
|
+
.pd-section-title svg, .pd-section-title span[data-icon] {
|
|
68
|
+
width: 17px; height: 17px; color: var(--text-muted, #888);
|
|
69
|
+
}
|
|
70
|
+
.pd-total { font-size: .8rem; color: var(--text-muted, #888); }
|
|
71
|
+
.pd-hint { color: var(--text-muted, #888); margin: 0 0 .9rem; font-size: .82rem; }
|
|
72
|
+
|
|
73
|
+
/* ── Artefact stat tiles (each with a + to create one) ────────────── */
|
|
74
|
+
.pd-tiles {
|
|
3
75
|
display: grid;
|
|
4
|
-
grid-template-columns: repeat(auto-fill, minmax(
|
|
5
|
-
gap:
|
|
76
|
+
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
|
|
77
|
+
gap: .85rem;
|
|
6
78
|
}
|
|
7
|
-
.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
79
|
+
.pd-tile {
|
|
80
|
+
position: relative;
|
|
81
|
+
border: 1px solid var(--border-color, rgba(255,255,255,.08));
|
|
82
|
+
border-left: 3px solid var(--c, #5b8cff);
|
|
83
|
+
border-radius: 11px;
|
|
84
|
+
background: var(--card-bg, rgba(255,255,255,.04));
|
|
85
|
+
transition: transform .14s ease, box-shadow .14s ease, border-color .14s ease;
|
|
86
|
+
}
|
|
87
|
+
.pd-tile--link:hover {
|
|
88
|
+
transform: translateY(-2px);
|
|
89
|
+
box-shadow: 0 8px 22px rgba(0,0,0,.22);
|
|
90
|
+
border-color: var(--c, #5b8cff);
|
|
91
|
+
}
|
|
92
|
+
.pd-tile-link {
|
|
93
|
+
display: flex; align-items: center; gap: .85rem;
|
|
94
|
+
padding: .9rem 1rem; padding-right: 2.6rem; /* room for the + */
|
|
95
|
+
text-decoration: none; color: inherit;
|
|
12
96
|
}
|
|
13
|
-
.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
97
|
+
.pd-tile-link--empty { opacity: .55; }
|
|
98
|
+
.pd-tile-chip {
|
|
99
|
+
width: 40px; height: 40px; flex-shrink: 0;
|
|
100
|
+
border-radius: 10px;
|
|
101
|
+
display: flex; align-items: center; justify-content: center;
|
|
17
102
|
}
|
|
18
|
-
.
|
|
19
|
-
|
|
20
|
-
|
|
103
|
+
.pd-tile-chip svg, .pd-tile-chip span[data-icon] { width: 20px; height: 20px; }
|
|
104
|
+
.pd-tile-body { display: flex; flex-direction: column; min-width: 0; }
|
|
105
|
+
.pd-tile-count { font-size: 1.5rem; font-weight: 700; line-height: 1; }
|
|
106
|
+
.pd-tile-label {
|
|
107
|
+
font-size: .78rem; color: var(--text-muted, #888);
|
|
108
|
+
margin-top: .2rem; text-transform: uppercase; letter-spacing: .03em;
|
|
21
109
|
}
|
|
110
|
+
.pd-tile-add {
|
|
111
|
+
position: absolute; top: 8px; right: 8px;
|
|
112
|
+
width: 26px; height: 26px; border-radius: 7px;
|
|
113
|
+
display: flex; align-items: center; justify-content: center;
|
|
114
|
+
border: 1px solid var(--border-color, rgba(255,255,255,.12));
|
|
115
|
+
background: var(--card-bg, rgba(255,255,255,.05));
|
|
116
|
+
color: var(--c, #5b8cff);
|
|
117
|
+
cursor: pointer; opacity: .7;
|
|
118
|
+
transition: opacity .14s ease, background .14s ease, border-color .14s ease, transform .14s ease;
|
|
119
|
+
}
|
|
120
|
+
.pd-tile-add:hover {
|
|
121
|
+
opacity: 1; transform: scale(1.08);
|
|
122
|
+
border-color: var(--c, #5b8cff);
|
|
123
|
+
background: rgba(255,255,255,.09);
|
|
124
|
+
}
|
|
125
|
+
.pd-tile-add svg, .pd-tile-add span[data-icon] { width: 15px; height: 15px; }
|
|
22
126
|
</style>
|
|
23
127
|
|
|
24
|
-
<div class="
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
<
|
|
28
|
-
|
|
128
|
+
<div class="pd-wrap">
|
|
129
|
+
<div class="pd-hero">
|
|
130
|
+
<div class="pd-hero-icon"><span id="pd-icon" data-icon="folder"></span></div>
|
|
131
|
+
<div class="pd-hero-main">
|
|
132
|
+
<div class="pd-hero-top">
|
|
133
|
+
<h2 class="pd-title"><span id="pd-name">Project</span></h2>
|
|
134
|
+
<div class="pd-actions">
|
|
135
|
+
<a class="btn btn-secondary btn-sm" href="#/projects"><span data-icon="arrow-left"></span> Projects</a>
|
|
136
|
+
<a class="btn btn-secondary btn-sm" id="pd-settings"><span data-icon="settings"></span> Settings</a>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
<p id="pd-desc" class="pd-desc"></p>
|
|
140
|
+
<div id="pd-meta" class="pd-meta"></div>
|
|
141
|
+
</div>
|
|
29
142
|
</div>
|
|
30
|
-
</div>
|
|
31
|
-
<p id="pd-desc" class="text-muted mb-4"></p>
|
|
32
143
|
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
144
|
+
<section>
|
|
145
|
+
<div class="pd-section-head">
|
|
146
|
+
<h3 class="pd-section-title"><span data-icon="layers"></span> Artefacts in this project</h3>
|
|
147
|
+
<span id="pd-total" class="pd-total"></span>
|
|
148
|
+
</div>
|
|
149
|
+
<p class="pd-hint">Click the <strong>+</strong> on any card to create one, filed under this project automatically.</p>
|
|
38
150
|
<div id="pd-counts"></div>
|
|
39
|
-
</
|
|
40
|
-
</div>
|
|
41
|
-
|
|
42
|
-
<div class="card">
|
|
43
|
-
<div class="card-header">
|
|
44
|
-
<span data-icon="plus-circle"></span> Quick add
|
|
45
|
-
</div>
|
|
46
|
-
<div class="card-body">
|
|
47
|
-
<p class="text-muted mb-3" style="margin-top:0;">Create a new artefact and have it tagged to this project automatically.</p>
|
|
48
|
-
<div id="pd-quick-add"></div>
|
|
49
|
-
</div>
|
|
151
|
+
</section>
|
|
50
152
|
</div>
|
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import{api as
|
|
2
|
-
<div class="menu-tree-row menu-tree-row--separator" data-id="${
|
|
1
|
+
import{api as w}from"../api.js";import{colourToCss as U}from"/public/js/menu-decor.mjs";import{openIconPicker as V}from"../lib/card-builder.js";let G=1;const T=()=>"i_"+G++,K=760;function q(e,l=null,o=[]){for(const n of e||[]){const t=T();if(n&&n.type==="separator"){o.push({id:t,parentId:l,type:"separator"});continue}o.push({id:t,parentId:l,text:n.text||"",url:n.url||"",icon:n.icon||"",visibility:n.visibility||"",permission:n.permission||"",hidden:!!n.hidden,bundled:!!n.bundled,badge:n.badge?{...n.badge}:null,pill:n.pill?{...n.pill}:null,colour:n.colour||""}),Array.isArray(n.items)&&n.items.length&&q(n.items,t,o)}return o}function X(e){const l=new Map;for(const n of e)l.has(n.parentId)||l.set(n.parentId,[]),l.get(n.parentId).push(n);function o(n){return(l.get(n)||[]).map(t=>{if(t.type==="separator")return{type:"separator"};const r=o(t.id);return{text:(t.text||"").trim(),url:(t.url||"").trim(),...t.icon&&{icon:t.icon.trim()},...t.visibility&&{visibility:t.visibility},...t.permission&&{permission:t.permission},...t.hidden&&{hidden:!0},...t.bundled&&{bundled:!0},...t.badge&&(t.badge.text||t.badge.countFrom)?{badge:{...t.badge.text&&{text:t.badge.text},...t.badge.countFrom&&{countFrom:t.badge.countFrom},...t.badge.variant&&{variant:t.badge.variant}}}:{},...t.pill&&t.pill.variant?{pill:{style:t.pill.style||"filled",variant:t.pill.variant}}:{},...t.colour&&{colour:t.colour},...r.length&&{items:r}}})}return o(null)}function N(e){return String(e||"").replace(/&/g,"&").replace(/"/g,""").replace(/</g,"<")}function J(e,l){let o=0,n=l.find(t=>t.id===e);for(;n&&n.parentId;)o++,n=l.find(t=>t.id===n.parentId);return o}function k(e,l,o=()=>""){e.html("");function n(t){for(const r of l.filter(u=>u.parentId===t)){const u=J(r.id,l);if(r.type==="separator"){e.append(`
|
|
2
|
+
<div class="menu-tree-row menu-tree-row--separator" data-id="${r.id}" style="margin-left:${u*28}px">
|
|
3
3
|
<span class="menu-tree-grip" data-icon="grip-vertical"></span>
|
|
4
4
|
<hr class="me-sep-line">
|
|
5
5
|
<button class="btn btn-sm btn-danger me-remove" data-tooltip="Remove"><span data-icon="trash"></span></button>
|
|
6
6
|
</div>
|
|
7
|
-
`);continue}const S=
|
|
8
|
-
<div class="menu-tree-row menu-tree-row--slim${
|
|
7
|
+
`);continue}const S=r.badge&&(r.badge.text||r.badge.countFrom)?`<span class="dm-menu-badge">${N(String(r.badge.text||"#"))}</span>`:"",y=U(r.colour),_=y?` style="color:${N(y)}"`:"";e.append(`
|
|
8
|
+
<div class="menu-tree-row menu-tree-row--slim${r.hidden?" menu-tree-row--hidden":""}" data-id="${r.id}" style="margin-left:${u*28}px">
|
|
9
9
|
<span class="menu-tree-grip" data-icon="grip-vertical"></span>
|
|
10
|
-
<span class="me-row-label"${
|
|
11
|
-
<span class="me-row-url">${N(
|
|
10
|
+
<span class="me-row-label"${_}>${N(r.text||"(untitled)")}${S}</span>
|
|
11
|
+
<span class="me-row-url">${N(r.url||"")}</span>
|
|
12
12
|
<button class="btn btn-sm btn-ghost me-outdent" data-tooltip="Outdent"><span data-icon="chevron-left"></span></button>
|
|
13
13
|
<button class="btn btn-sm btn-ghost me-indent" data-tooltip="Indent"><span data-icon="chevron-right"></span></button>
|
|
14
14
|
<button class="btn btn-sm btn-ghost me-up" data-tooltip="Move up"><span data-icon="arrow-up"></span></button>
|
|
15
15
|
<button class="btn btn-sm btn-ghost me-down" data-tooltip="Move down"><span data-icon="arrow-down"></span></button>
|
|
16
|
-
<button class="btn btn-sm btn-ghost me-hidden ${
|
|
16
|
+
<button class="btn btn-sm btn-ghost me-hidden ${r.hidden?"active":""}" data-tooltip="${r.hidden?"Show":"Hide"}"><span data-icon="eye-off"></span></button>
|
|
17
17
|
<button class="btn btn-sm btn-secondary me-edit" data-tooltip="Edit"><span data-icon="edit"></span></button>
|
|
18
18
|
<button class="btn btn-sm btn-danger me-remove" data-tooltip="Remove"><span data-icon="trash"></span></button>
|
|
19
19
|
</div>
|
|
20
|
-
`),n(c.id)}}n(null),Domma.icons.scan(t.get(0)),t.get(0).querySelectorAll("[data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})})}function F(t,s){t.find(".menu-tree-row").each(function(){const l=$(this).data("id"),n=s.find(g=>g.id===l);if(!n||n.type==="separator")return;const e=$(this).find(".me-text");e.length&&(n.text=e.val());const c=$(this).find(".me-url");c.length&&(n.url=c.val());const p=$(this).find(".me-icon");p.length&&(n.icon=p.val());const S=$(this).find(".me-vis");S.length&&(n.visibility=S.val());const w=$(this).find(".me-perm");w.length&&(n.permission=w.val())})}function et(t,s){const l={};for(const n of s){const e=t.querySelector(`[name="${n}"]`);e&&(l[n]=e.type==="checkbox"?e.checked:e.value)}return l}function b(t,s={},...l){const n=document.createElement(t);Object.assign(n,s);for(const e of l)e!=null&&n.append(e);return n}function x(t,s){return b("label",{className:"form-field"},b("span",{className:"form-label",textContent:t}),s)}function O(t,s){const l=b("select",{className:"form-select"});return t.forEach(([n,e])=>l.add(new Option(e,n))),l.value=s??"",l}function R(t,s){const l=typeof s=="string"&&s.startsWith("#"),n=O([["","\u2014 none \u2014"],["primary","primary"],["success","success"],["danger","danger"],["warning","warning"],["info","info"],["neutral","neutral"],["__custom__","Custom\u2026"]],l?"__custom__":s||""),e=b("input",{type:"color",className:"form-input me-colour-hex",value:l?s:"#000000"});e.style.display=l?"":"none",n.addEventListener("change",()=>{e.style.display=n.value==="__custom__"?"":"none"});const c=x(t,n);return c.append(e),c._read=()=>n.value==="__custom__"?e.value:n.value,c}function J(t,s){const l=b("input",{className:"form-input me-icon",name:"icon",value:s||""}),n=b("span",{className:"me-icon-preview"});s&&n.setAttribute("data-icon",s);const e=b("button",{type:"button",className:"btn btn-sm btn-ghost",textContent:"Browse\u2026"}),c=p=>{p?n.setAttribute("data-icon",p):n.removeAttribute("data-icon"),Domma.icons.scan(n.parentNode||n)};return e.addEventListener("click",()=>U(p=>{l.value=p,c(p)})),l.addEventListener("input",()=>c(l.value.trim())),x(t,b("div",{className:"me-icon-field"},l,e,n))}function T(...t){return b("div",{className:"me-field-grid"},...t)}async function Q(t,s,l,n){const e=v=>b("h4",{className:"me-form-section",textContent:v}),c=x("Label",b("input",{className:"form-input",name:"text",value:t.text||""})),p=x("URL",b("input",{className:"form-input",name:"url",value:t.url||""})),S=J("Icon",t.icon||""),w=x("Visibility",b("input",{className:"form-input",name:"visibility",value:t.visibility||""})),g=O([["","\u2014 no permission gate \u2014"],...(s||[]).map(v=>[v.key,v.label||v.key])],t.permission||"");g.name="permission";const i=x("Permission",g),m=x("Badge text",b("input",{className:"form-input",name:"badgeText",value:t.badge?.text||""})),P=O([["","\u2014 none \u2014"],...(l||[]).map(v=>[v.slug,v.title||v.slug])],t.badge?.countFrom||"");P.name="badgeCount";const A=x("Badge count from",P),j=R("Badge colour",t.badge?.variant||""),a=O([["link","Link"],["pill","Pill"]],t.pill?"pill":"link");a.name="renderAs";const o=x("Render as",a),d=x("Pill style",O([["filled","Filled"],["outline","Outline"]],t.pill?.style||"filled"));d.querySelector("select").name="pillStyle";const f=R("Pill colour",t.pill?.variant||""),r=b("div",{className:"me-pill-wrap"},T(d,f));r.style.display=t.pill?"":"none",a.addEventListener("change",()=>{r.style.display=a.value==="pill"?"":"none"});const u=R("Text colour",t.colour||""),h=b("button",{className:"btn btn-primary",textContent:"Apply"}),C=b("div",{className:"me-item-form"},e("Link"),c,p,S,e("Access"),T(w,i),e("Badge"),T(m,j),A,e("Appearance"),o,r,u,h),_=E.slideover({title:"Edit menu item",size:"lg",position:"right"});_.element.appendChild(C),_.open();const B=_.element.closest(".dm-slideover")||_.element.querySelector(".dm-slideover")||_.element;B.style.setProperty("width",G+"px","important"),B.style.setProperty("max-width","95vw","important"),Domma.icons.scan(C);const k=v=>C.querySelector(`[name="${v}"]`)?.value||"";h.addEventListener("click",()=>{t.text=k("text"),t.url=k("url"),t.icon=k("icon"),t.visibility=k("visibility"),t.permission=k("permission");const v=k("badgeText"),L=k("badgeCount"),W=j._read();if(t.badge=v||L?{...v&&{text:v},...L&&{countFrom:L},...W&&{variant:W}}:null,k("renderAs")==="pill"){const D=f._read();t.pill={style:k("pillStyle")||"filled",...D&&{variant:D}}}else t.pill=null;t.colour=u._read(),_.close(),n()})}export const menuEditorView={templateUrl:"/admin/js/templates/menu-editor.html",async onMount(t){const s=location.hash.match(/^#\/menus\/edit\/(.+)$/),l=s?decodeURIComponent(s[1]):null,n=!l;let e;if(n)e={slug:"",name:"",description:"",items:[]};else try{e=await I.menus.get(l)}catch(a){E.toast(`Failed to load menu: ${a.message||a}`,{type:"error"}),location.hash="#/menus";return}const c=await I.projects.list().catch(()=>[]),p=await H.get("/api/auth/permissions-registry").catch(()=>({resources:[]})),S=await I.collections.list().catch(()=>[]),w='<option value="">\u2014 no permission gate \u2014</option>',g=a=>w+(p.resources||[]).map(o=>{const d=(a||"")===o.key?" selected":"";return`<option value="${o.key}"${d}>${o.label||o.key}</option>`}).join("");t.find("#me-title").text(n?"New menu":`Edit "${e.slug}"`);let i=q(e.items);const m=t.find("#me-items-list");y(m,i,g),t.find("#me-add-item").on("click",()=>{F(m,i),i.push({id:z(),parentId:null,text:"",url:"/",icon:"",visibility:"",permission:"",hidden:!1,bundled:!1}),y(m,i,g)}),t.find("#me-add-separator").on("click",()=>{F(m,i),i.push({id:z(),parentId:null,type:"separator"}),y(m,i,g)}),t.off("click",".me-remove").on("click",".me-remove",function(){const a=$(this).closest(".menu-tree-row").data("id");i=i.filter(o=>o.id!==a&&o.parentId!==a),y(m,i,g)}),t.off("click",".me-indent").on("click",".me-indent",function(){F(m,i);const a=$(this).closest(".menu-tree-row").data("id"),o=i.find(r=>r.id===a);if(!o)return;const d=i.filter(r=>r.parentId===o.parentId),f=d.findIndex(r=>r.id===a);f<=0||(o.parentId=d[f-1].id,y(m,i,g))}),t.off("click",".me-outdent").on("click",".me-outdent",function(){F(m,i);const a=$(this).closest(".menu-tree-row").data("id"),o=i.find(f=>f.id===a);if(!o||o.parentId==null)return;const d=i.find(f=>f.id===o.parentId);o.parentId=d?d.parentId:null,y(m,i,g)}),t.off("click",".me-up").on("click",".me-up",function(){F(m,i);const a=$(this).closest(".menu-tree-row").data("id"),o=i.filter(u=>u.parentId===i.find(h=>h.id===a)?.parentId),d=o.findIndex(u=>u.id===a);if(d<=0)return;const f=i.indexOf(o[d]),r=i.indexOf(o[d-1]);[i[f],i[r]]=[i[r],i[f]],y(m,i,g)}),t.off("click",".me-down").on("click",".me-down",function(){F(m,i);const a=$(this).closest(".menu-tree-row").data("id"),o=i.filter(u=>u.parentId===i.find(h=>h.id===a)?.parentId),d=o.findIndex(u=>u.id===a);if(d===-1||d>=o.length-1)return;const f=i.indexOf(o[d]),r=i.indexOf(o[d+1]);[i[f],i[r]]=[i[r],i[f]],y(m,i,g)}),t.off("click",".me-hidden").on("click",".me-hidden",function(){F(m,i);const a=$(this).closest(".menu-tree-row").data("id"),o=i.find(d=>d.id===a);o&&(o.hidden=!o.hidden),y(m,i,g)}),t.off("click",".me-edit").on("click",".me-edit",function(){const a=$(this).closest(".menu-tree-row").data("id"),o=i.find(d=>d.id===a);!o||o.type==="separator"||Q(o,p.resources||[],S,()=>y(m,i,g))}),E.tabs(t.find("#me-tabs").get(0)),t.find("#me-variant").val(e.variant||""),t.find("#me-position").val(e.position||""),t.find("#me-fontFamily").val(e.style?.fontFamily||""),t.find("#me-fontSize").val(e.style?.fontSize||""),t.find("#me-fontWeight").val(e.style?.fontWeight||""),t.find("#me-letterSpacing").val(e.style?.letterSpacing||""),t.find("#me-iconSize").val(e.style?.iconSize||"");const{getProjectFromHash:P}=await import("../lib/project-context.js"),A=n?P():null,j=t.find("#me-project");j.html('<option value="">\u2014 none \u2014</option>'+c.map(a=>`<option value="${N(a.slug)}">${N(a.name||a.slug)}</option>`).join("")),t.find("#me-slug").val(e.slug||""),t.find("#me-name").val(e.name||""),t.find("#me-description").val(e.description||""),j.val(e.meta?.project||A||""),Domma.icons.scan(t.get(0)),t.find("#me-save").on("click",async()=>{F(m,i);const a={variant:t.find("#me-variant").val(),position:t.find("#me-position").val(),fontFamily:t.find("#me-fontFamily").val(),fontSize:t.find("#me-fontSize").val(),fontWeight:t.find("#me-fontWeight").val(),letterSpacing:t.find("#me-letterSpacing").val(),iconSize:t.find("#me-iconSize").val()},o={slug:t.find("#me-slug").val().trim(),name:t.find("#me-name").val().trim(),description:t.find("#me-description").val().trim(),project:t.find("#me-project").val()},d=["fontFamily","fontSize","fontWeight","letterSpacing","iconSize"],f=Object.fromEntries(Object.entries(a).filter(([u,h])=>d.includes(u)&&h)),r={slug:o.slug,name:o.name,description:o.description,...a.variant&&{variant:a.variant},...a.position&&{position:a.position},...Object.keys(f).length&&{style:f},items:K(i),meta:{...e.meta||{},project:o.project||null}};try{if(n)await I.menus.create(r);else if(r.slug!==e.slug){await I.menus.create(r);const u=await I.menuLocations.get();let h=!1;for(const[C,_]of Object.entries(u))_===e.slug&&(u[C]=r.slug,h=!0);h&&await I.menuLocations.save(u),await I.menus.remove(e.slug)}else await I.menus.update(e.slug,r);E.toast("Saved.",{type:"success"}),location.hash="#/menus/edit/"+encodeURIComponent(r.slug)}catch(u){E.toast(`Save failed: ${u.message||u}`,{type:"error"})}})}};
|
|
20
|
+
`),n(r.id)}}n(null),Domma.icons.scan(e.get(0)),e.get(0).querySelectorAll("[data-tooltip]").forEach(t=>{E.tooltip(t,{content:t.getAttribute("data-tooltip"),position:"top"})})}function F(e,l){e.find(".menu-tree-row").each(function(){const o=$(this).data("id"),n=l.find(_=>_.id===o);if(!n||n.type==="separator")return;const t=$(this).find(".me-text");t.length&&(n.text=t.val());const r=$(this).find(".me-url");r.length&&(n.url=r.val());const u=$(this).find(".me-icon");u.length&&(n.icon=u.val());const S=$(this).find(".me-vis");S.length&&(n.visibility=S.val());const y=$(this).find(".me-perm");y.length&&(n.permission=y.val())})}function ie(e,l){const o={};for(const n of l){const t=e.querySelector(`[name="${n}"]`);t&&(o[n]=t.type==="checkbox"?t.checked:t.value)}return o}function b(e,l={},...o){const n=document.createElement(e);Object.assign(n,l);for(const t of o)t!=null&&n.append(t);return n}function I(e,l,o){const n=b("label",{className:"form-field"},b("span",{className:"form-label",textContent:e}),l);return o&&n.append(b("small",{className:"form-hint",textContent:o,style:"display:block;font-size:.78em;opacity:.7;margin-top:.25rem;line-height:1.35"})),n}function Q(e,l){const o=[["","\u2014 Everyone \u2014"],["public","Public \u2014 anyone, incl. logged-out"],...(l||[]).map(t=>[t.name,`${t.label||t.name} and above`]),["private","Private \u2014 super-admin only"]];e&&!o.some(([t])=>t===e)&&o.push([e,`${e} (custom)`]);const n=j(o,e||"");return n.name="visibility",n}function j(e,l){const o=b("select",{className:"form-select"});return e.forEach(([n,t])=>o.add(new Option(t,n))),o.value=l??"",o}function B(e,l){const o=typeof l=="string"&&l.startsWith("#"),n=j([["","\u2014 none \u2014"],["primary","primary"],["success","success"],["danger","danger"],["warning","warning"],["info","info"],["neutral","neutral"],["__custom__","Custom\u2026"]],o?"__custom__":l||""),t=b("input",{type:"color",className:"form-input me-colour-hex",value:o?l:"#000000"});t.style.display=o?"":"none",n.addEventListener("change",()=>{t.style.display=n.value==="__custom__"?"":"none"});const r=I(e,n);return r.append(t),r._read=()=>n.value==="__custom__"?t.value:n.value,r}function Y(e,l){const o=b("input",{className:"form-input me-icon",name:"icon",value:l||""}),n=b("span",{className:"me-icon-preview"});l&&n.setAttribute("data-icon",l);const t=b("button",{type:"button",className:"btn btn-sm btn-ghost",textContent:"Browse\u2026"}),r=u=>{u?n.setAttribute("data-icon",u):n.removeAttribute("data-icon"),Domma.icons.scan(n.parentNode||n)};return t.addEventListener("click",()=>V(u=>{o.value=u,r(u)})),o.addEventListener("input",()=>r(o.value.trim())),I(e,b("div",{className:"me-icon-field"},o,t,n))}function W(...e){return b("div",{className:"me-field-grid"},...e)}async function Z(e,l,o,n,t){const r=v=>b("h4",{className:"me-form-section",textContent:v}),u=I("Label",b("input",{className:"form-input",name:"text",value:e.text||""}),"The text shown for this item."),S=I("URL",b("input",{className:"form-input",name:"url",value:e.url||""}),"Where it links. Must start with /, #, https://, or mailto: . Leave blank for a parent that only groups sub-items."),y=Y("Icon",e.icon||""),_=I("Visibility",Q(e.visibility||"",n),"Who can see this item, by role seniority. Picking a role shows it to that role and anyone more senior. Blank = everyone."),C=j([["","\u2014 no permission gate \u2014"],...(l||[]).map(v=>[v.key,v.label||v.key])],e.permission||"");C.name="permission";const z=I("Permission",C,"Hide unless the viewer holds this specific permission. Independent of Visibility \u2014 both must pass."),h=I("Badge text",b("input",{className:"form-input",name:"badgeText",value:e.badge?.text||""})),i=j([["","\u2014 none \u2014"],...(o||[]).map(v=>[v.slug,v.title||v.slug])],e.badge?.countFrom||"");i.name="badgeCount";const m=I("Badge count from",i,"Show a live count of this collection\u2019s entries as the badge."),A=B("Badge colour",e.badge?.variant||""),P=j([["link","Link"],["pill","Pill"]],e.pill?"pill":"link");P.name="renderAs";const O=I("Render as",P,"Pill draws the item as a rounded chip. Pill styling applies to top-level navbar items."),s=I("Pill style",j([["filled","Filled"],["outline","Outline"]],e.pill?.style||"filled"));s.querySelector("select").name="pillStyle";const a=B("Pill colour",e.pill?.variant||""),c=b("div",{className:"me-pill-wrap"},W(s,a));c.style.display=e.pill?"":"none",P.addEventListener("change",()=>{c.style.display=P.value==="pill"?"":"none"});const f=B("Text colour",e.colour||""),d=b("button",{className:"btn btn-primary",textContent:"Apply"}),p=b("div",{className:"me-item-form"},r("Link"),u,S,y,r("Access"),W(_,z),r("Badge"),W(h,A),m,r("Appearance"),O,c,f,d),g=E.slideover({title:"Edit menu item",size:"lg",position:"right"});g.element.appendChild(p),g.open();const L=g.element.closest(".dm-slideover")||g.element.querySelector(".dm-slideover")||g.element;L.style.setProperty("width",K+"px","important"),L.style.setProperty("max-width","95vw","important"),Domma.icons.scan(p);const x=v=>p.querySelector(`[name="${v}"]`)?.value||"";d.addEventListener("click",()=>{e.text=x("text"),e.url=x("url"),e.icon=x("icon"),e.visibility=x("visibility"),e.permission=x("permission");const v=x("badgeText"),R=x("badgeCount"),D=A._read();if(e.badge=v||R?{...v&&{text:v},...R&&{countFrom:R},...D&&{variant:D}}:null,x("renderAs")==="pill"){const M=a._read();e.pill={style:x("pillStyle")||"filled",...M&&{variant:M}}}else e.pill=null;e.colour=f._read(),g.close(),t()})}export const menuEditorView={templateUrl:"/admin/js/templates/menu-editor.html",async onMount(e){const l=location.hash.match(/^#\/menus\/edit\/(.+)$/),o=l?decodeURIComponent(l[1]):null,n=!o;let t;if(n)t={slug:"",name:"",description:"",items:[]};else try{t=await w.menus.get(o)}catch(s){E.toast(`Failed to load menu: ${s.message||s}`,{type:"error"}),location.hash="#/menus";return}const r=await w.projects.list().catch(()=>[]),u=await H.get("/api/auth/permissions-registry").catch(()=>({resources:[]})),S=await w.collections.list().catch(()=>[]),y=await w.get("/collections/roles/entries?limit=100").catch(()=>null),C=(Array.isArray(y)?y:y?.entries||[]).map(s=>({name:s.data?.name,label:s.data?.label,level:s.data?.level??99})).filter(s=>s.name).sort((s,a)=>a.level-s.level),z='<option value="">\u2014 no permission gate \u2014</option>',h=s=>z+(u.resources||[]).map(a=>{const c=(s||"")===a.key?" selected":"";return`<option value="${a.key}"${c}>${a.label||a.key}</option>`}).join("");e.find("#me-title").text(n?"New menu":`Edit "${t.slug}"`);let i=q(t.items);const m=e.find("#me-items-list");k(m,i,h),e.find("#me-add-item").on("click",()=>{F(m,i),i.push({id:T(),parentId:null,text:"",url:"/",icon:"",visibility:"",permission:"",hidden:!1,bundled:!1}),k(m,i,h)}),e.find("#me-add-separator").on("click",()=>{F(m,i),i.push({id:T(),parentId:null,type:"separator"}),k(m,i,h)}),e.off("click",".me-remove").on("click",".me-remove",function(){const s=$(this).closest(".menu-tree-row").data("id");i=i.filter(a=>a.id!==s&&a.parentId!==s),k(m,i,h)}),e.off("click",".me-indent").on("click",".me-indent",function(){F(m,i);const s=$(this).closest(".menu-tree-row").data("id"),a=i.find(d=>d.id===s);if(!a)return;const c=i.filter(d=>d.parentId===a.parentId),f=c.findIndex(d=>d.id===s);f<=0||(a.parentId=c[f-1].id,k(m,i,h))}),e.off("click",".me-outdent").on("click",".me-outdent",function(){F(m,i);const s=$(this).closest(".menu-tree-row").data("id"),a=i.find(f=>f.id===s);if(!a||a.parentId==null)return;const c=i.find(f=>f.id===a.parentId);a.parentId=c?c.parentId:null,k(m,i,h)}),e.off("click",".me-up").on("click",".me-up",function(){F(m,i);const s=$(this).closest(".menu-tree-row").data("id"),a=i.filter(p=>p.parentId===i.find(g=>g.id===s)?.parentId),c=a.findIndex(p=>p.id===s);if(c<=0)return;const f=i.indexOf(a[c]),d=i.indexOf(a[c-1]);[i[f],i[d]]=[i[d],i[f]],k(m,i,h)}),e.off("click",".me-down").on("click",".me-down",function(){F(m,i);const s=$(this).closest(".menu-tree-row").data("id"),a=i.filter(p=>p.parentId===i.find(g=>g.id===s)?.parentId),c=a.findIndex(p=>p.id===s);if(c===-1||c>=a.length-1)return;const f=i.indexOf(a[c]),d=i.indexOf(a[c+1]);[i[f],i[d]]=[i[d],i[f]],k(m,i,h)}),e.off("click",".me-hidden").on("click",".me-hidden",function(){F(m,i);const s=$(this).closest(".menu-tree-row").data("id"),a=i.find(c=>c.id===s);a&&(a.hidden=!a.hidden),k(m,i,h)}),e.off("click",".me-edit").on("click",".me-edit",function(){const s=$(this).closest(".menu-tree-row").data("id"),a=i.find(c=>c.id===s);!a||a.type==="separator"||Z(a,u.resources||[],S,C,()=>k(m,i,h))}),E.tabs(e.find("#me-tabs").get(0)),e.find("#me-variant").val(t.variant||""),e.find("#me-position").val(t.position||""),e.find("#me-fontFamily").val(t.style?.fontFamily||""),e.find("#me-fontSize").val(t.style?.fontSize||""),e.find("#me-fontWeight").val(t.style?.fontWeight||""),e.find("#me-letterSpacing").val(t.style?.letterSpacing||""),e.find("#me-iconSize").val(t.style?.iconSize||"");const{getProjectFromHash:A}=await import("../lib/project-context.js"),P=n?A():null,O=e.find("#me-project");O.html('<option value="">\u2014 none \u2014</option>'+r.map(s=>`<option value="${N(s.slug)}">${N(s.name||s.slug)}</option>`).join("")),e.find("#me-slug").val(t.slug||""),e.find("#me-name").val(t.name||""),e.find("#me-description").val(t.description||""),O.val(t.meta?.project||P||""),Domma.icons.scan(e.get(0)),e.find("#me-save").on("click",async()=>{F(m,i);const s={variant:e.find("#me-variant").val(),position:e.find("#me-position").val(),fontFamily:e.find("#me-fontFamily").val(),fontSize:e.find("#me-fontSize").val(),fontWeight:e.find("#me-fontWeight").val(),letterSpacing:e.find("#me-letterSpacing").val(),iconSize:e.find("#me-iconSize").val()},a={slug:e.find("#me-slug").val().trim(),name:e.find("#me-name").val().trim(),description:e.find("#me-description").val().trim(),project:e.find("#me-project").val()},c=["fontFamily","fontSize","fontWeight","letterSpacing","iconSize"],f=Object.fromEntries(Object.entries(s).filter(([p,g])=>c.includes(p)&&g)),d={slug:a.slug,name:a.name,description:a.description,...s.variant&&{variant:s.variant},...s.position&&{position:s.position},...Object.keys(f).length&&{style:f},items:X(i),meta:{...t.meta||{},project:a.project||null}};try{if(n)await w.menus.create(d);else if(d.slug!==t.slug){await w.menus.create(d);const p=await w.menuLocations.get();let g=!1;for(const[L,x]of Object.entries(p))x===t.slug&&(p[L]=d.slug,g=!0);g&&await w.menuLocations.save(p),await w.menus.remove(t.slug)}else await w.menus.update(t.slug,d);E.toast("Saved.",{type:"success"}),location.hash="#/menus/edit/"+encodeURIComponent(d.slug)}catch(p){E.toast(`Save failed: ${p.message||p}`,{type:"error"})}})}};
|
|
@@ -1,4 +1 @@
|
|
|
1
|
-
import{api as
|
|
2
|
-
<strong>${o(h[e])}</strong>
|
|
3
|
-
<span class="badge badge-${n>0?"info":"secondary"}">${n}</span>
|
|
4
|
-
</div>${f}</div>`});m.html(`<div class="grid grid-cols-3" style="gap:1rem;">${u.join("")}</div>`);const p=t.find("#pd-quick-add");p.html(`<div class="dm-qa-grid">${j.map(e=>`<button class="btn btn-primary pd-qa-btn" data-path="${o(e.path)}" data-project="${o(s)}"><span data-icon="${o(e.icon)}"></span> ${o(e.text)}</button>`).join("")}</div>`),Domma.icons.scan(t.get(0)),p.off("click",".pd-qa-btn").on("click",".pd-qa-btn",function(){const e=$(this).data("path"),n=$(this).data("project");try{sessionStorage.setItem("__projectContext",n)}catch{}location.hash="#"+e})}};
|
|
1
|
+
import{api as b}from"../api.js";import{openQuickCreate as N,INLINE_TYPES as U}from"../lib/project-quick-create.js";function o(t){return String(t??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}const Y=[{key:"pages",label:"Pages",singular:"Page",icon:"file-text",color:"#5b8cff",path:"/pages/new"},{key:"collections",label:"Collections",singular:"Collection",icon:"database",color:"#7c6af7",path:"/collections/new"},{key:"forms",label:"Forms",singular:"Form",icon:"layout",color:"#34d399",path:"/forms/new"},{key:"actions",label:"Actions",singular:"Action",icon:"zap",color:"#fbbf24",path:"/actions/new"},{key:"menus",label:"Menus",singular:"Menu",icon:"menu",color:"#22d3ee",path:"/menus/new"},{key:"blocks",label:"Blocks",singular:"Block",icon:"box",color:"#f472b6",path:"/blocks/new"},{key:"views",label:"Views",singular:"View",icon:"eye",color:"#2dd4bf",path:"/views/new"},{key:"roles",label:"Roles",singular:"Role",icon:"shield",color:"#f87171",path:"/roles"},{key:"users",label:"Users",singular:"User",icon:"users",color:"#818cf8",path:"/users/new"}];function x(t){if(!t)return null;const l=new Date(t).getTime();if(Number.isNaN(l))return null;const s=Math.max(0,Math.round((Date.now()-l)/1e3)),n=Math.round(s/60),e=Math.round(n/60),c=Math.round(e/24);if(s<45)return"just now";if(n<60)return`${n} min${n===1?"":"s"} ago`;if(e<24)return`${e} hour${e===1?"":"s"} ago`;if(c<30)return`${c} day${c===1?"":"s"} ago`;const r=Math.round(c/30);if(r<12)return`${r} month${r===1?"":"s"} ago`;const i=Math.round(c/365);return`${i} year${i===1?"":"s"} ago`}function k(t,l){if(!t)return null;const s=new Date(t);if(Number.isNaN(s.getTime()))return null;try{return D(t).format(l)}catch{return s.toLocaleDateString()}}function m({icon:t,color:l,key:s,value:n,title:e}){if(n==null||n==="")return"";const c=`color:${l};background:${l}1f;border-color:${l}40`,r=e?` title="${o(e)}"`:"";return`<span class="pd-meta-badge" style="${c}"${r}><span data-icon="${o(t)}"></span><span class="pd-meta-k">${o(s)}</span><span class="pd-meta-v">${o(n)}</span></span>`}export const projectDetailView={templateUrl:"/admin/js/templates/project-detail.html",async onMount(t){const s=location.hash.split("?")[0].match(/^#\/projects\/([^/]+)$/),n=s?decodeURIComponent(s[1]):null;if(!n){location.hash="#/projects";return}const e=await b.projects.get(n).catch(()=>null);if(!e){E.toast("Project not found.",{type:"error"}),location.hash="#/projects";return}const c=encodeURIComponent(n);t.find("#pd-name").text(e.name||n),t.find("#pd-icon").attr("data-icon",e.icon||"folder"),t.find("#pd-desc").text(e.description||""),t.find("#pd-settings").attr("href","#/projects/"+c+"/settings");const r=[m({icon:"calendar",color:"#5b8cff",key:"Created",value:k(e.createdAt,"D MMM YYYY")}),m({icon:"user",color:"#7c6af7",key:"Creator",value:e.createdBy||"Unknown"}),m({icon:"clock",color:"#34d399",key:"Updated",value:x(e.updatedAt),title:k(e.updatedAt,"D MMM YYYY, HH:mm")})].join("");t.find("#pd-meta").html(r,{safe:!1}),Domma.icons.scan(t.find(".pd-hero").get(0));const i=t.find("#pd-counts"),w=t.find("#pd-total"),j={slug:n,name:e.name||n,rootUrl:e.rootUrl||""};async function h(){const p=await b.projects.artefacts(n).catch(()=>({}));let d=0;const f=Y.map(a=>{const u=Array.isArray(p[a.key])?p[a.key].length:0;d+=u;const M=U.includes(a.key),g=`<span class="pd-tile-chip" style="color:${a.color};background:${a.color}1f"><span data-icon="${o(a.icon)}"></span></span>`,y=`<span class="pd-tile-body"><span class="pd-tile-count">${u}</span><span class="pd-tile-label">${o(a.label)}</span></span>`,C=u>0?`<a class="pd-tile-link" href="#/projects/${c}/${o(a.key)}">${g}${y}</a>`:`<div class="pd-tile-link pd-tile-link--empty">${g}${y}</div>`,v=`<button class="pd-tile-add" data-type="${o(a.key)}" data-inline="${M?"1":""}" data-path="${o(a.path)}" title="New ${o(a.singular)}" aria-label="New ${o(a.singular)}"><span data-icon="plus"></span></button>`;return`<div class="pd-tile${u>0?" pd-tile--link":""}" style="--c:${a.color}">${C}${v}</div>`});i.html(`<div class="pd-tiles">${f.join("")}</div>`,{safe:!1}),w.text(d===1?"1 artefact":`${d} artefacts`),Domma.icons.scan(i.get(0))}await h(),i.off("click",".pd-tile-add").on("click",".pd-tile-add",function(){const p=$(this).data("type");if($(this).data("inline")){N({type:p,project:j,onCreated:h});return}const d=$(this).data("path"),f=String(d).includes("?")?"&":"?";location.hash="#"+d+f+"project="+encodeURIComponent(n)}),Domma.icons.scan(t.get(0))}};
|
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const PRESET_COLOUR_VARS={primary:"--dm-primary",success:"--dm-success",danger:"--dm-danger",warning:"--dm-warning",info:"--dm-info",neutral:"--dm-text-muted"};const o=/^#[0-9a-fA-F]{6}$/;export function colourToCss(t){return typeof t!="string"?"":PRESET_COLOUR_VARS[t]?`var(${PRESET_COLOUR_VARS[t]})`:o.test(t)?t:""}export function makeBadgeEl(t){if(!t||t.text==null||t.text==="")return null;const n=document.createElement("span");n.className="dm-menu-badge",n.textContent=String(t.text);const r=t.variant?colourToCss(t.variant):"";return r&&(n.style.background=r,n.style.color="#fff"),n}export function applyColour(t,n){const r=colourToCss(n);r&&(t.style.color=r)}export function applyPill(t,n){if(!n)return;t.classList.add("dm-nav-pill");const r=n.variant?colourToCss(n.variant):"";n.style==="outline"?(t.classList.add("dm-nav-pill--outline"),r&&(t.style.borderColor=r,t.style.color=r)):r&&(t.style.background=r,t.style.color="#fff")}
|
package/scripts/build.js
CHANGED
|
@@ -1,246 +1,263 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Build script — creates a publishable staging copy in _publish/
|
|
3
|
-
* Browser-facing JS/CSS is minified; server-side code is copied as-is.
|
|
4
|
-
* Run: node scripts/build.js
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import {cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync} from 'fs';
|
|
8
|
-
import {readdir} from 'fs/promises';
|
|
9
|
-
import {createHash} from 'crypto';
|
|
10
|
-
import {dirname, join, relative} from 'path';
|
|
11
|
-
import * as esbuild from 'esbuild';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
'plugins
|
|
43
|
-
'plugins
|
|
44
|
-
'plugins
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
'
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Build script — creates a publishable staging copy in _publish/
|
|
3
|
+
* Browser-facing JS/CSS is minified; server-side code is copied as-is.
|
|
4
|
+
* Run: node scripts/build.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync} from 'fs';
|
|
8
|
+
import {readdir} from 'fs/promises';
|
|
9
|
+
import {createHash} from 'crypto';
|
|
10
|
+
import {dirname, join, relative} from 'path';
|
|
11
|
+
import * as esbuild from 'esbuild';
|
|
12
|
+
import {verifyAssetImports, formatReport} from './verify-assets.mjs';
|
|
13
|
+
|
|
14
|
+
const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
|
|
15
|
+
const OUT = join(ROOT, '_publish');
|
|
16
|
+
|
|
17
|
+
// Files/dirs copied verbatim (server-side, config, docs)
|
|
18
|
+
const COPY_AS_IS = [
|
|
19
|
+
'server',
|
|
20
|
+
'bin',
|
|
21
|
+
'config',
|
|
22
|
+
'scripts',
|
|
23
|
+
'package.json',
|
|
24
|
+
'public/js/package.json',
|
|
25
|
+
'README.md',
|
|
26
|
+
'CLAUDE.md',
|
|
27
|
+
'LICENSE',
|
|
28
|
+
'.npmignore',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Glob patterns for browser-facing assets to minify. NOTE: both .js AND .mjs
|
|
32
|
+
// must be listed — the browser ESM modules served raw (e.g. public/js/*.mjs)
|
|
33
|
+
// use the .mjs extension, and omitting it silently drops those files from the
|
|
34
|
+
// published package (this is the bug that shipped 0.22.1 without menu-decor.mjs).
|
|
35
|
+
const MINIFY_PATTERNS = [
|
|
36
|
+
'admin/js/**/*.js',
|
|
37
|
+
'admin/js/**/*.mjs',
|
|
38
|
+
'admin/css/**/*.css',
|
|
39
|
+
'public/js/**/*.js',
|
|
40
|
+
'public/js/**/*.mjs',
|
|
41
|
+
'public/css/**/*.css',
|
|
42
|
+
'plugins/*/public/**/*.js',
|
|
43
|
+
'plugins/*/public/**/*.mjs',
|
|
44
|
+
'plugins/*/public/**/*.css',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// Files within plugins that are copied as-is (server-side plugin code)
|
|
48
|
+
const PLUGIN_AS_IS_PATTERNS = [
|
|
49
|
+
'plugins/**/plugin.js',
|
|
50
|
+
'plugins/**/config.js',
|
|
51
|
+
'plugins/**/routes/**/*.js',
|
|
52
|
+
'plugins/**/services/**/*.js',
|
|
53
|
+
'plugins/**/views/**/*.js',
|
|
54
|
+
'plugins/**/*.json',
|
|
55
|
+
'plugins/**/*.html',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// HTML templates in admin (copied as-is)
|
|
59
|
+
const ADMIN_HTML_PATTERNS = [
|
|
60
|
+
'admin/**/*.html',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Recursively list all files under a directory, returning paths relative to ROOT.
|
|
65
|
+
* @param {string} dir - Absolute path to the directory to scan.
|
|
66
|
+
* @returns {Promise<string[]>}
|
|
67
|
+
*/
|
|
68
|
+
async function listFilesRecursive(dir) {
|
|
69
|
+
const entries = await readdir(dir, {recursive: true, withFileTypes: true});
|
|
70
|
+
return entries
|
|
71
|
+
.filter(e => e.isFile())
|
|
72
|
+
.map(e => relative(ROOT, join(e.parentPath ?? e.path, e.name)));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Match files relative to ROOT against a simple glob pattern.
|
|
77
|
+
* Supports `**` (any path segment), `*` (any non-separator chars), and `{a,b}` alternation.
|
|
78
|
+
* @param {string} pattern
|
|
79
|
+
* @returns {Promise<string[]>}
|
|
80
|
+
*/
|
|
81
|
+
async function collectFiles(pattern) {
|
|
82
|
+
// Expand top-level {a,b,...} alternation into individual patterns
|
|
83
|
+
const braceMatch = pattern.match(/^\{([^}]+)\}\/(.+)$/);
|
|
84
|
+
if (braceMatch) {
|
|
85
|
+
const alts = braceMatch[1].split(',');
|
|
86
|
+
const rest = braceMatch[2];
|
|
87
|
+
const results = await Promise.all(alts.map(a => collectFiles(a + '/' + rest)));
|
|
88
|
+
return [...new Set(results.flat())];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Determine the top-level directory from the pattern (part before first wildcard)
|
|
92
|
+
const parts = pattern.split('/');
|
|
93
|
+
let baseDir = ROOT;
|
|
94
|
+
for (const part of parts) {
|
|
95
|
+
if (part.includes('*') || part.includes('{')) break;
|
|
96
|
+
baseDir = join(baseDir, part);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!existsSync(baseDir)) return [];
|
|
100
|
+
|
|
101
|
+
const allFiles = await listFilesRecursive(baseDir);
|
|
102
|
+
|
|
103
|
+
// Convert glob pattern to a RegExp
|
|
104
|
+
const reStr = pattern
|
|
105
|
+
.replace(/[.+^${}()|[\]\\]/g, (c) => (c === '{' || c === '}' ? c : '\\' + c))
|
|
106
|
+
.replace(/\{([^}]+)\}/g, (_, alts) => '(?:' + alts.split(',').join('|') + ')')
|
|
107
|
+
.replace(/\*\*\//g, '(?:[^/]+/){0,}') // **/ → zero-or-more path segments
|
|
108
|
+
.replace(/\*\*/g, '(.+)')
|
|
109
|
+
.replace(/(?<!\()\*/g, '([^/]+)');
|
|
110
|
+
const re = new RegExp('^' + reStr + '$');
|
|
111
|
+
|
|
112
|
+
return allFiles.filter(f => re.test(f.replace(/\\/g, '/')));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function minifyFile(relPath, loader) {
|
|
116
|
+
const src = readFileSync(join(ROOT, relPath), 'utf8');
|
|
117
|
+
const result = await esbuild.transform(src, {
|
|
118
|
+
loader,
|
|
119
|
+
minify: true,
|
|
120
|
+
target: 'es2020',
|
|
121
|
+
});
|
|
122
|
+
const outPath = join(OUT, relPath);
|
|
123
|
+
mkdirSync(dirname(outPath), {recursive: true});
|
|
124
|
+
writeFileSync(outPath, result.code);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Compute a short content hash for a file, used for cache-busting view entries.
|
|
129
|
+
* Returns the first 8 hex chars of SHA-256, or null if the file can't be read.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} filePath - Absolute path to the file
|
|
132
|
+
* @returns {string|null}
|
|
133
|
+
*/
|
|
134
|
+
function contentHash(filePath) {
|
|
135
|
+
try {
|
|
136
|
+
const content = readFileSync(filePath);
|
|
137
|
+
return createHash('sha256').update(content).digest('hex').slice(0, 8);
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Rewrite admin.views entry paths in every plugin.json under _publish/plugins/
|
|
145
|
+
* with a content-hash version (?v=<hash>), eliminating the need for manual ?v=N bumps.
|
|
146
|
+
*/
|
|
147
|
+
async function stampPluginVersions() {
|
|
148
|
+
const pluginJsonFiles = await collectFiles('plugins/*/plugin.json');
|
|
149
|
+
for (const rel of pluginJsonFiles) {
|
|
150
|
+
const outPath = join(OUT, rel);
|
|
151
|
+
if (!existsSync(outPath)) continue;
|
|
152
|
+
|
|
153
|
+
let manifest;
|
|
154
|
+
try { manifest = JSON.parse(readFileSync(outPath, 'utf8')); } catch { continue; }
|
|
155
|
+
if (!manifest.admin?.views) continue;
|
|
156
|
+
|
|
157
|
+
let changed = false;
|
|
158
|
+
for (const [viewName, viewDef] of Object.entries(manifest.admin.views)) {
|
|
159
|
+
const entryBase = viewDef.entry.split('?')[0];
|
|
160
|
+
const entryFilePath = join(OUT, 'plugins', entryBase);
|
|
161
|
+
const hash = contentHash(entryFilePath);
|
|
162
|
+
if (hash) {
|
|
163
|
+
manifest.admin.views[viewName] = { ...viewDef, entry: `${entryBase}?v=${hash}` };
|
|
164
|
+
changed = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (changed) writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function build() {
|
|
173
|
+
// Clean and recreate _publish/
|
|
174
|
+
if (existsSync(OUT)) rmSync(OUT, {recursive: true, force: true});
|
|
175
|
+
mkdirSync(OUT, {recursive: true});
|
|
176
|
+
|
|
177
|
+
// Copy server-side dirs/files verbatim
|
|
178
|
+
for (const item of COPY_AS_IS) {
|
|
179
|
+
const src = join(ROOT, item);
|
|
180
|
+
if (existsSync(src)) {
|
|
181
|
+
const dest = join(OUT, item);
|
|
182
|
+
mkdirSync(dirname(dest), {recursive: true});
|
|
183
|
+
cpSync(src, dest, {recursive: true});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Strip prepublishOnly from _publish/package.json — the guard is for the
|
|
188
|
+
// source tree only; running npm publish inside _publish/ must succeed.
|
|
189
|
+
const pkgPath = join(OUT, 'package.json');
|
|
190
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
191
|
+
delete pkg.scripts?.prepublishOnly;
|
|
192
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
193
|
+
|
|
194
|
+
// Copy plugin server-side code verbatim
|
|
195
|
+
const pluginAsIs = await collectFiles('{' + PLUGIN_AS_IS_PATTERNS.join(',') + '}');
|
|
196
|
+
for (const rel of pluginAsIs) {
|
|
197
|
+
const outPath = join(OUT, rel);
|
|
198
|
+
mkdirSync(dirname(outPath), {recursive: true});
|
|
199
|
+
cpSync(join(ROOT, rel), outPath);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Copy admin HTML templates verbatim
|
|
203
|
+
const adminHtml = await collectFiles(ADMIN_HTML_PATTERNS[0]);
|
|
204
|
+
for (const rel of adminHtml) {
|
|
205
|
+
const outPath = join(OUT, rel);
|
|
206
|
+
mkdirSync(dirname(outPath), {recursive: true});
|
|
207
|
+
cpSync(join(ROOT, rel), outPath);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Copy Domma tools assets (gitignored but tracked for distribution)
|
|
211
|
+
const dommaTools = [
|
|
212
|
+
'admin/dist/domma/domma-tools.css',
|
|
213
|
+
'admin/dist/domma/domma-tools.min.js',
|
|
214
|
+
];
|
|
215
|
+
for (const rel of dommaTools) {
|
|
216
|
+
const src = join(ROOT, rel);
|
|
217
|
+
if (existsSync(src)) {
|
|
218
|
+
const outPath = join(OUT, rel);
|
|
219
|
+
mkdirSync(dirname(outPath), {recursive: true});
|
|
220
|
+
cpSync(src, outPath);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Minify browser-facing JS and CSS
|
|
225
|
+
let jsCount = 0;
|
|
226
|
+
let cssCount = 0;
|
|
227
|
+
|
|
228
|
+
// Collect all minify targets, deduplicate against plugin as-is
|
|
229
|
+
const pluginAsIsSet = new Set(pluginAsIs);
|
|
230
|
+
|
|
231
|
+
for (const pattern of MINIFY_PATTERNS) {
|
|
232
|
+
const files = await collectFiles(pattern);
|
|
233
|
+
for (const rel of files) {
|
|
234
|
+
if (pluginAsIsSet.has(rel)) continue; // skip plugin server-side JS
|
|
235
|
+
const loader = rel.endsWith('.css') ? 'css' : 'js';
|
|
236
|
+
await minifyFile(rel, loader);
|
|
237
|
+
if (loader === 'js') jsCount++;
|
|
238
|
+
else cssCount++;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const totalCopied = COPY_AS_IS.filter(i => existsSync(join(ROOT, i))).length
|
|
243
|
+
+ pluginAsIs.length + adminHtml.length;
|
|
244
|
+
|
|
245
|
+
await stampPluginVersions();
|
|
246
|
+
|
|
247
|
+
console.log(`Built into _publish/ — JS: ${jsCount} minified, CSS: ${cssCount} minified, ${totalCopied} entries copied`);
|
|
248
|
+
|
|
249
|
+
// Integrity gate — fail the build (and therefore `make pack` / `make
|
|
250
|
+
// release-npm`) if the staged set ships any ESM import pointing at a file
|
|
251
|
+
// that isn't there. This is what stops a "shipped code that imports a missing
|
|
252
|
+
// file" release (the 0.22.1 menu-decor.mjs incident) from ever publishing.
|
|
253
|
+
const integrity = await verifyAssetImports({projectRoot: OUT});
|
|
254
|
+
console.log(formatReport(integrity, '_publish/'));
|
|
255
|
+
if (!integrity.ok) {
|
|
256
|
+
throw new Error(`asset integrity check failed — ${integrity.missing.length} dangling import(s); refusing to package a broken release`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
build().catch(err => {
|
|
261
|
+
console.error('Build failed:', err.message);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset Import Verifier — build/publish gate
|
|
3
|
+
*
|
|
4
|
+
* Statically resolves every local ESM import under the browser-facing code
|
|
5
|
+
* (admin/ + public/) against the files that actually exist, and fails if any
|
|
6
|
+
* specifier points at a missing file ("dangling import").
|
|
7
|
+
*
|
|
8
|
+
* Why this exists
|
|
9
|
+
* ---------------
|
|
10
|
+
* The admin SPA and public site ship UNMINIFIED-then-minified ESM that browsers
|
|
11
|
+
* import natively (no bundler). If a release ships consumer code that imports a
|
|
12
|
+
* new module but forgets to include the module file (as 0.22.1 did with
|
|
13
|
+
* /public/js/menu-decor.mjs), every page 404s on the import and the whole site
|
|
14
|
+
* breaks — with no signal but a browser console error, and it cascades to every
|
|
15
|
+
* downstream project that copies these dirs on update. A bundler would catch
|
|
16
|
+
* this at build time; because we don't bundle, this gate stands in for that.
|
|
17
|
+
*
|
|
18
|
+
* Usage
|
|
19
|
+
* -----
|
|
20
|
+
* node scripts/verify-assets.mjs [root] # default root: repo root
|
|
21
|
+
* Exits 1 (with a report) on any dangling import, 0 otherwise. Wired into
|
|
22
|
+
* scripts/build.js so `make pack` / `make release-npm` cannot ship a broken set.
|
|
23
|
+
*
|
|
24
|
+
* Scope (deliberately conservative — zero false positives is the whole point):
|
|
25
|
+
* only STATIC, LOCAL specifiers with an explicit extension are checked — the
|
|
26
|
+
* only kind this codebase emits. Bare (npm) specifiers, URLs, dynamic/template
|
|
27
|
+
* specifiers, commented-out imports, and extension-less paths are all ignored.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import fs from 'node:fs/promises';
|
|
32
|
+
import { existsSync } from 'node:fs';
|
|
33
|
+
|
|
34
|
+
const SCAN_EXTS = new Set(['.js', '.mjs']);
|
|
35
|
+
const CHECK_EXTS = new Set(['.js', '.mjs', '.css']);
|
|
36
|
+
|
|
37
|
+
const FROM_RE = /\bfrom\s*(['"])([^'"]+)\1/g;
|
|
38
|
+
const SIDE_EFFECT_RE = /(?:^|[;{}\s])import\s*(['"])([^'"]+)\1/g;
|
|
39
|
+
const DYNAMIC_RE = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
|
|
40
|
+
|
|
41
|
+
function isCheckableSpecifier(spec) {
|
|
42
|
+
if (typeof spec !== 'string' || !spec) return false;
|
|
43
|
+
if (!(spec.startsWith('/') || spec.startsWith('./') || spec.startsWith('../'))) return false;
|
|
44
|
+
const clean = spec.replace(/[?#].*$/, '');
|
|
45
|
+
return CHECK_EXTS.has(path.extname(clean).toLowerCase());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveSpecifier(spec, importer, projectRoot) {
|
|
49
|
+
const clean = spec.replace(/[?#].*$/, '');
|
|
50
|
+
if (clean.startsWith('/')) return path.join(projectRoot, clean); // served /public/... → root/public/...
|
|
51
|
+
return path.resolve(path.dirname(importer), clean);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Strip // and /* *\/ comments while preserving string-literal bodies, so a
|
|
56
|
+
* commented-out import isn't flagged and a "https://…" URL isn't mistaken for a
|
|
57
|
+
* comment. Both are false positives, fatal for a publish gate.
|
|
58
|
+
*/
|
|
59
|
+
function stripComments(source) {
|
|
60
|
+
let out = '';
|
|
61
|
+
let state = 'code';
|
|
62
|
+
for (let i = 0; i < source.length; i++) {
|
|
63
|
+
const c = source[i];
|
|
64
|
+
const n = source[i + 1];
|
|
65
|
+
switch (state) {
|
|
66
|
+
case 'code':
|
|
67
|
+
if (c === '/' && n === '/') { state = 'line'; i++; }
|
|
68
|
+
else if (c === '/' && n === '*') { state = 'block'; i++; }
|
|
69
|
+
else if (c === "'") { state = 'single'; out += c; }
|
|
70
|
+
else if (c === '"') { state = 'double'; out += c; }
|
|
71
|
+
else if (c === '`') { state = 'template'; out += c; }
|
|
72
|
+
else out += c;
|
|
73
|
+
break;
|
|
74
|
+
case 'line':
|
|
75
|
+
if (c === '\n') { state = 'code'; out += c; }
|
|
76
|
+
break;
|
|
77
|
+
case 'block':
|
|
78
|
+
if (c === '*' && n === '/') { state = 'code'; i++; }
|
|
79
|
+
break;
|
|
80
|
+
case 'single':
|
|
81
|
+
case 'double':
|
|
82
|
+
case 'template': {
|
|
83
|
+
out += c;
|
|
84
|
+
const quote = state === 'single' ? "'" : state === 'double' ? '"' : '`';
|
|
85
|
+
if (c === '\\') { out += source[i + 1] ?? ''; i++; }
|
|
86
|
+
else if (c === quote) state = 'code';
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function extractSpecifiers(rawSource) {
|
|
95
|
+
const source = stripComments(rawSource);
|
|
96
|
+
const out = new Set();
|
|
97
|
+
for (const re of [FROM_RE, SIDE_EFFECT_RE, DYNAMIC_RE]) {
|
|
98
|
+
re.lastIndex = 0;
|
|
99
|
+
let m;
|
|
100
|
+
while ((m = re.exec(source)) !== null) {
|
|
101
|
+
if (isCheckableSpecifier(m[2])) out.add(m[2]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function listScannable(dir) {
|
|
108
|
+
const result = [];
|
|
109
|
+
let entries;
|
|
110
|
+
try {
|
|
111
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
112
|
+
} catch {
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
const full = path.join(dir, entry.name);
|
|
117
|
+
let isDir = entry.isDirectory();
|
|
118
|
+
let isFile = entry.isFile();
|
|
119
|
+
if (entry.isSymbolicLink()) {
|
|
120
|
+
try {
|
|
121
|
+
const st = await fs.stat(full);
|
|
122
|
+
isDir = st.isDirectory();
|
|
123
|
+
isFile = st.isFile();
|
|
124
|
+
} catch { continue; }
|
|
125
|
+
}
|
|
126
|
+
if (isDir) {
|
|
127
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
128
|
+
result.push(...await listScannable(full));
|
|
129
|
+
} else if (isFile && SCAN_EXTS.has(path.extname(entry.name).toLowerCase())) {
|
|
130
|
+
result.push(full);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function verifyAssetImports({ projectRoot, roots = ['admin', 'public'] }) {
|
|
137
|
+
const missing = [];
|
|
138
|
+
let scanned = 0;
|
|
139
|
+
for (const rel of roots) {
|
|
140
|
+
const rootAbs = path.join(projectRoot, rel);
|
|
141
|
+
if (!existsSync(rootAbs)) continue;
|
|
142
|
+
for (const file of await listScannable(rootAbs)) {
|
|
143
|
+
scanned++;
|
|
144
|
+
let source;
|
|
145
|
+
try { source = await fs.readFile(file, 'utf8'); } catch { continue; }
|
|
146
|
+
for (const spec of extractSpecifiers(source)) {
|
|
147
|
+
const resolved = resolveSpecifier(spec, file, projectRoot);
|
|
148
|
+
if (!existsSync(resolved)) {
|
|
149
|
+
missing.push({
|
|
150
|
+
importer: path.relative(projectRoot, file),
|
|
151
|
+
specifier: spec,
|
|
152
|
+
resolved: path.relative(projectRoot, resolved),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { ok: missing.length === 0, scanned, missing };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function formatReport(result, label) {
|
|
162
|
+
if (result.ok) {
|
|
163
|
+
return `[verify-assets] OK — ${result.scanned} files scanned, all local imports resolve (${label}).`;
|
|
164
|
+
}
|
|
165
|
+
const lines = [
|
|
166
|
+
'',
|
|
167
|
+
' ╔════════════════════════════════════════════════════════════════╗',
|
|
168
|
+
' ║ ASSET INTEGRITY FAILURE — dangling front-end imports detected ║',
|
|
169
|
+
' ╚════════════════════════════════════════════════════════════════╝',
|
|
170
|
+
` Context: ${label}`,
|
|
171
|
+
` ${result.missing.length} import(s) reference files that do not exist:`,
|
|
172
|
+
'',
|
|
173
|
+
];
|
|
174
|
+
for (const m of result.missing) {
|
|
175
|
+
lines.push(` ✗ ${m.importer}`);
|
|
176
|
+
lines.push(` imports "${m.specifier}" → missing ${m.resolved}`);
|
|
177
|
+
}
|
|
178
|
+
lines.push('');
|
|
179
|
+
lines.push(' A page that loads one of these modules will 404 and fail to render.');
|
|
180
|
+
lines.push(' Add the missing file (or remove the import) before building/publishing.');
|
|
181
|
+
lines.push('');
|
|
182
|
+
return lines.join('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
186
|
+
const projectRoot = path.resolve(process.argv[2] || process.cwd());
|
|
187
|
+
const result = await verifyAssetImports({ projectRoot });
|
|
188
|
+
console.log(formatReport(result, projectRoot));
|
|
189
|
+
process.exit(result.ok ? 0 : 1);
|
|
190
|
+
}
|
|
@@ -115,12 +115,12 @@ export async function blocksRoutes(fastify) {
|
|
|
115
115
|
// Create or update block
|
|
116
116
|
fastify.put('/blocks/:name', canUpdate, async (request, reply) => {
|
|
117
117
|
const {name} = request.params;
|
|
118
|
-
const {content, bundled, css} = request.body || {};
|
|
118
|
+
const {content, bundled, css, meta} = request.body || {};
|
|
119
119
|
if (typeof content !== 'string') return reply.status(400).send({error: 'content (string) is required'});
|
|
120
120
|
if (css !== undefined && typeof css !== 'string') return reply.status(400).send({error: 'css must be a string'});
|
|
121
121
|
|
|
122
122
|
try {
|
|
123
|
-
return await saveBlock(name, content, {bundled: !!bundled, css});
|
|
123
|
+
return await saveBlock(name, content, {bundled: !!bundled, css, meta});
|
|
124
124
|
} catch (err) {
|
|
125
125
|
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
126
126
|
if (err.code === 'CSS_TOO_LARGE') return reply.status(400).send({error: err.message});
|
|
@@ -174,10 +174,10 @@ export async function collectionsRoutes(fastify) {
|
|
|
174
174
|
});
|
|
175
175
|
|
|
176
176
|
fastify.post('/collections', canCreate, async (request, reply) => {
|
|
177
|
-
const {title, slug, description, fields, api, storage} = request.body || {};
|
|
177
|
+
const {title, slug, description, fields, api, storage, meta} = request.body || {};
|
|
178
178
|
if (!title) return reply.status(400).send({ error: 'title is required' });
|
|
179
179
|
try {
|
|
180
|
-
const schema = await createCollection({title, slug, description, fields, api, storage});
|
|
180
|
+
const schema = await createCollection({title, slug, description, fields, api, storage, meta});
|
|
181
181
|
await ensureFormForCollection(schema);
|
|
182
182
|
return reply.status(201).send(schema);
|
|
183
183
|
} catch (err) {
|
|
@@ -51,6 +51,8 @@ export async function pagesRoutes(fastify) {
|
|
|
51
51
|
category: p.category || null,
|
|
52
52
|
visibility: p.visibility || 'public',
|
|
53
53
|
tags: p.tags || [],
|
|
54
|
+
project: p.project ?? null,
|
|
55
|
+
resolvedProject: p.resolvedProject ?? null,
|
|
54
56
|
updatedAt: p.updatedAt,
|
|
55
57
|
createdAt: p.createdAt,
|
|
56
58
|
versionCount: counts[i]
|
|
@@ -54,7 +54,8 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
54
54
|
|
|
55
55
|
fastify.post('/projects', canCreate, async (request, reply) => {
|
|
56
56
|
try {
|
|
57
|
-
const
|
|
57
|
+
const creator = request.user?.name || request.user?.email || null;
|
|
58
|
+
const project = await createProject({...(request.body || {}), createdBy: creator});
|
|
58
59
|
await cache.invalidateTags(['projects']);
|
|
59
60
|
return reply.status(201).send(project);
|
|
60
61
|
} catch (err) {
|
|
@@ -66,6 +66,7 @@ export async function getProject(slug) {
|
|
|
66
66
|
* Create a new project. Refuses on slug collision or validation failure.
|
|
67
67
|
*
|
|
68
68
|
* @param {object} input
|
|
69
|
+
* @param {string} [input.createdBy] Display name of the creator (optional).
|
|
69
70
|
* @returns {Promise<object>} The persisted project record.
|
|
70
71
|
* @throws {Error} If validation fails or the slug already exists.
|
|
71
72
|
*/
|
|
@@ -83,6 +84,7 @@ export async function createProject(input) {
|
|
|
83
84
|
icon: input.icon || 'folder',
|
|
84
85
|
...(input.rootUrl != null && {rootUrl: input.rootUrl}),
|
|
85
86
|
sortOrder: Number.isFinite(input.sortOrder) ? input.sortOrder : 0,
|
|
87
|
+
...(typeof input.createdBy === 'string' && input.createdBy.trim() && {createdBy: input.createdBy.trim()}),
|
|
86
88
|
createdAt: now,
|
|
87
89
|
updatedAt: now
|
|
88
90
|
};
|
|
@@ -286,10 +288,11 @@ export async function getArtefactsForProject(projectSlug) {
|
|
|
286
288
|
} catch { /* skip */ }
|
|
287
289
|
|
|
288
290
|
try {
|
|
289
|
-
|
|
291
|
+
// listBlocks() already surfaces each block's meta sidecar, so no
|
|
292
|
+
// per-block read is needed. (Blocks are keyed by `name`, not `slug`.)
|
|
293
|
+
const {listBlocks} = await import('./blocks.js');
|
|
290
294
|
for (const b of await listBlocks()) {
|
|
291
|
-
|
|
292
|
-
if (full?.meta?.project === projectSlug) out.blocks.push(b);
|
|
295
|
+
if (b?.meta?.project === projectSlug) out.blocks.push(b);
|
|
293
296
|
}
|
|
294
297
|
} catch { /* skip */ }
|
|
295
298
|
|
|
@@ -393,16 +396,14 @@ export async function untagAllForProject(projectSlug) {
|
|
|
393
396
|
} catch { /* skip */ }
|
|
394
397
|
|
|
395
398
|
try {
|
|
396
|
-
const {
|
|
399
|
+
const {getBlock, saveBlock} = await import('./blocks.js');
|
|
397
400
|
for (const b of grouped.blocks) {
|
|
398
|
-
const full =
|
|
401
|
+
const full = await getBlock(b.name).catch(() => null);
|
|
399
402
|
if (!full) continue;
|
|
400
403
|
const meta = {...(full.meta || {})};
|
|
401
404
|
delete meta.project;
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
counts.blocks++;
|
|
405
|
-
}
|
|
405
|
+
await saveBlock(b.name, full.content, {bundled: full.bundled, css: full.css, meta});
|
|
406
|
+
counts.blocks++;
|
|
406
407
|
}
|
|
407
408
|
} catch { /* skip */ }
|
|
408
409
|
|