domma-cms 0.25.7 → 0.25.8
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/CLAUDE.md +2 -2
- package/admin/js/app.js +1 -1
- package/admin/js/templates/component-editor.html +7 -0
- package/admin/js/views/component-editor.js +4 -4
- package/admin/js/views/components.js +5 -5
- package/admin/js/views/project-detail.js +1 -1
- package/package.json +1 -1
- package/server/routes/api/components.js +16 -4
- package/server/services/components.js +25 -8
- package/server/services/projects.js +26 -4
package/CLAUDE.md
CHANGED
|
@@ -262,9 +262,9 @@ The admin sidebar is now menu-data-driven — no more hardcoded `sidebar-config.
|
|
|
262
262
|
|
|
263
263
|
## Projects
|
|
264
264
|
|
|
265
|
-
Projects group related artefacts under a named slug — sidebar navigation, per-user access scope, and a tag the scaffolder stamps on recipe-produced artefacts. Cross-cutting: touches
|
|
265
|
+
Projects group related artefacts under a named slug — sidebar navigation, per-user access scope, and a tag the scaffolder stamps on recipe-produced artefacts. Cross-cutting: touches `pages`, `collections`, `forms`, `actions`, `menus`, `blocks`, `components`, `views`, `roles`, `users`, and API endpoints.
|
|
266
266
|
|
|
267
|
-
**Data model.** Project records live in the preset collection `content/collections/projects/` (same pattern as `user-types` and `roles`). Each record has `slug`, `name`, `description`, `icon`, `rootUrl`, `sortOrder` (plus system-only `protected`). The slug is immutable.
|
|
267
|
+
**Data model.** Project records live in the preset collection `content/collections/projects/` (same pattern as `user-types` and `roles`). Each record has `slug`, `name`, `description`, `icon`, `rootUrl`, `sortOrder` (plus system-only `protected`). The slug is immutable. Artefact types (pages, collections, forms, actions, menus, blocks, components, views, roles, users, API endpoints) accept an optional `meta.project: '<slug>'` field — set it to file the artefact under a project; omit it and the artefact belongs to the **core** project by exclusion. (Components store it in their `.meta.json` sidecar, exactly like blocks.)
|
|
268
268
|
|
|
269
269
|
**The core project.** Seeded at boot (`seedCoreProject()`, create-if-absent) with `slug: 'core'`, `rootUrl: '/'`, `protected: true`. It owns everything not claimed by another project — by resolution fallback, never by stamping: `resolveArtefactProject(artefact)` returns `meta.project || 'core'`, and `getProjectForPage` falls back to `'core'` when no `rootUrl` prefix matches. Core is **not removable** (delete and untag-all are refused at service and route level; `CORE_PROJECT_SLUG` constant, same pattern as `BASE_ROLE_NAMES`), its `rootUrl`/`protected` are locked, and it is **never an access boundary** — core artefacts are visible to all users regardless of `projects: []` scope.
|
|
270
270
|
|
package/admin/js/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{views as B}from"./views/index.js";import{api as l,getUser as c,isAuthenticated as m,logout as W}from"./api.js";import{installHttpInterceptor as x}from"./http-interceptor.js";$(()=>{x(),(async()=>{try{const t=m()?await l.settings.get():null;Domma.theme.init({theme:t?.adminTheme||"charcoal-dark",persist:!0})}catch{Domma.theme.init({theme:"charcoal-dark",persist:!0})}})();const k=["jb-company","jb-agent","jb-candidate"],A=["/job-board","/my-profile"];function h(t){return t&&k.includes(t.role)}function w(t){return A.some(e=>t===e||t.startsWith(e+"/"))}R.use(async(t,e,o)=>{if(t.path==="/login"||t.path==="/reset-password")return o();if(!m()){R.navigate("/login");return}if(h(c())&&!w(t.path)){R.navigate("/job-board");return}o()});async function v(){try{return(await l.get("/auth/permissions")).permissions||[]}catch{return[]}}async function b(t){const{renderAdminSidebar:e}=await import("./lib/sidebar-renderer.js");await e({mount:"#admin-sidebar",permissions:t})}M.subscribe("router:afterChange",({to:t})=>{(t.path==="/login"||t.path==="/reset-password")&&n&&(clearInterval(n),n=null)});const N=[{path:"/",view:"dashboard",title:"Dashboard - Domma CMS"},{path:"/pages",view:"pages",title:"Pages - Domma CMS"},{path:"/pages/new",view:"pageEditor",title:"New Page - Domma CMS"},{path:"/pages/edit/*",view:"pageEditor",title:"Edit Page - Domma CMS"},{path:"/media",view:"media",title:"Media - Domma CMS"},{path:"/menus",view:"menus",title:"Menus - Domma CMS"},{path:"/menus/new",view:"menuEditor",title:"New Menu - Domma CMS"},{path:"/menus/edit/:slug",view:"menuEditor",title:"Edit Menu - Domma CMS"},{path:"/menu-locations",view:"menuLocations",title:"Menu Locations - Domma CMS"},{path:"/navigation",view:"menusRedirect",title:"Menus - Domma CMS"},{path:"/layouts",view:"layouts",title:"Layouts - Domma CMS"},{path:"/settings",view:"settings",title:"Settings - Domma CMS"},{path:"/users",view:"users",title:"Users - Domma CMS"},{path:"/users/new",view:"userEditor",title:"New User - Domma CMS"},{path:"/users/edit/:id",view:"userEditor",title:"Edit User - Domma CMS"},{path:"/plugins",view:"plugins",title:"Plugins - Domma CMS"},{path:"/documentation",view:"documentation",title:"Usage - Domma CMS"},{path:"/tutorials",view:"tutorials",title:"Tutorials - Domma CMS"},{path:"/api-reference",view:"apiReference",title:"API Reference - Domma CMS"},{path:"/collections",view:"collections",title:"Collections - Domma CMS"},{path:"/collections/new",view:"collectionEditor",title:"New Collection - Domma CMS"},{path:"/collections/edit/:slug",view:"collectionEditor",title:"Edit Collection - Domma CMS"},{path:"/collections/:slug/entries",view:"collectionEntries",title:"Entries - Domma CMS"},{path:"/forms",view:"forms",title:"Forms - Domma CMS"},{path:"/forms/new",view:"formEditor",title:"New Form - Domma CMS"},{path:"/forms/edit/:slug",view:"formEditor",title:"Edit Form - Domma CMS"},{path:"/forms/:slug/submissions",view:"formSubmissions",title:"Submissions - Domma CMS"},{path:"/views",view:"viewsList",title:"Views - Domma CMS"},{path:"/views/new",view:"viewEditor",title:"New View - Domma CMS"},{path:"/views/edit/:slug",view:"viewEditor",title:"Edit View - Domma CMS"},{path:"/views/:slug/preview",view:"viewPreview",title:"View Preview - Domma CMS"},{path:"/actions",view:"actionsList",title:"Actions - Domma CMS"},{path:"/actions/new",view:"actionEditor",title:"New Action - Domma CMS"},{path:"/actions/edit/:slug",view:"actionEditor",title:"Edit Action - Domma CMS"},{path:"/pro/docs",view:"proDocs",title:"Pro Documentation - Domma CMS"},{path:"/blocks",view:"blocks",title:"Blocks - Domma CMS"},{path:"/blocks/new",view:"blockEditor",title:"New Block - Domma CMS"},{path:"/blocks/edit/:name",view:"blockEditor",title:"Edit Block - Domma CMS"},{path:"/components",view:"components",title:"Components - Domma CMS"},{path:"/components/new",view:"componentEditor",title:"New Component - Domma CMS"},{path:"/components/edit/:name",view:"componentEditor",title:"Edit Component - Domma CMS"},{path:"/my-profile",view:"myProfile",title:"My Profile - Domma CMS"},{path:"/roles",view:"roles",title:"Roles & Permissions - Domma CMS"},{path:"/roles/edit/:id",view:"roleEditor",title:"Edit Role - Domma CMS"},{path:"/effects",view:"effects",title:"Effects - Domma CMS"},{path:"/system/notifications",view:"notifications",title:"Notifications - Domma CMS"},{path:"/api-tokens",view:"apiTokens",title:"API Tokens - Domma CMS"},{path:"/api-endpoints",view:"apiEndpoints",title:"API Builder - Domma CMS"},{path:"/api-endpoints/new",view:"apiEndpointEditor",title:"New API Endpoint - Domma CMS"},{path:"/api-endpoints/edit/:id",view:"apiEndpointEditor",title:"Edit API Endpoint - Domma CMS"},{path:"/projects",view:"projects",title:"Projects - Domma CMS"},{path:"/projects/new",view:"projectEditor",title:"New Project - Domma CMS"},{path:"/projects/edit/:slug",view:"projectEditor",title:"Edit Project - Domma CMS"},{path:"/projects/:slug/settings",view:"projectSettings",title:"Project settings - Domma CMS"},{path:"/projects/:slug/pages",view:"pages",title:"Project pages - Domma CMS"},{path:"/projects/:slug/collections",view:"collections",title:"Project collections - Domma CMS"},{path:"/projects/:slug/forms",view:"forms",title:"Project forms - Domma CMS"},{path:"/projects/:slug/actions",view:"actionsList",title:"Project actions - Domma CMS"},{path:"/projects/:slug/menus",view:"menus",title:"Project menus - Domma CMS"},{path:"/projects/:slug/blocks",view:"blocks",title:"Project blocks - Domma CMS"},{path:"/projects/:slug/views",view:"viewsList",title:"Project views - Domma CMS"},{path:"/projects/:slug/roles",view:"roles",title:"Project roles - Domma CMS"},{path:"/projects/:slug/users",view:"users",title:"Project users - Domma CMS"},{path:"/projects/:slug/apis",view:"apiEndpoints",title:"Project APIs - Domma CMS"},{path:"/projects/:slug",view:"projectDetail",title:"Project - Domma CMS"},{path:"/login",view:"login",title:"Sign in - Domma CMS",onEnter:()=>{$("#admin-sidebar").hide(),$("#admin-topbar").hide()}},{path:"/reset-password",view:"login",title:"Reset Password - Domma CMS",onEnter:()=>{$("#admin-sidebar").hide(),$("#admin-topbar").hide()}}];M.subscribe("router:afterChange",async({to:t,from:e})=>{if(!(t.path==="/login"||t.path==="/reset-password")){if(h(c())&&!w(t.path)){R.navigate("/job-board");return}if($("#admin-sidebar").show(),$("#admin-topbar").show(),e?.path==="/login"||e?.path==="/reset-password"){$("#topbar-user-name").remove(),await j();const o=await v();b(o);try{const i=await l.system.notifications.list().catch(()=>[]),s=(Array.isArray(i)?i:[]).filter(a=>a.unread&&["warning","critical"].includes(a.data?.severity)).slice(0,3);for(const a of s){const r=p(a.data?.title||""),d=p((a.data?.body||"").slice(0,120));E.toast(`${r} \u2014 ${d}`,{type:a.data?.severity==="critical"?"error":"warning",duration:0})}}catch{}}y()}}),M.subscribe("router:afterChange",()=>{setTimeout(()=>{$(".btn-primary, .btn-danger").length&&Domma.effects.reveal(".btn-primary, .btn-danger",{animation:"fade",stagger:40,duration:300})},50)});const L=["#field-project","#menu-project","#collection-project","#endpoint-project","#action-project","#view-project","#block-project","#role-project",'[name="project"]','[name="ownedByProject"]'];function I(t){return/^\/(pages|menus|collections|forms|actions|views|blocks|users|roles|api-endpoints)\/(new|edit\/)/.test(t)}M.subscribe("router:afterChange",({to:t})=>{const e=t?String(t.path).split("?")[0]:"";if(!t||!I(e))return;const i=new URLSearchParams(location.hash.split("?")[1]||"").get("project");if(!i||!/\/new$/.test(e))return;let s=0;const a=60,r=1500,d=()=>{s+=a;for(const T of L){const u=document.querySelector(T);if(!u)continue;if(Array.from(u.options||[]).find(_=>_.value===i)){u.value=i,u.dispatchEvent(new Event("change",{bubbles:!0}));return}}s<r&&setTimeout(d,a)};setTimeout(d,a)});const C="cms_card_states",f=S.get(C)||{};M.subscribe("router:afterChange",()=>{setTimeout(()=>{$("#view-container .card-collapsible").each(function(){const t=$(this).find(".card-header h2, .card-header h3").first().text().trim();t&&f[t]==="collapsed"&&$(this).addClass("card-collapsed")})},200)}),$("#view-container").on("click",".card-collapsible .card-header",function(t){if($(t.target).closest("button, a").length)return;const e=$(this).closest(".card");e.toggleClass("card-collapsed");const o=$(this).find("h2, h3").first().text().trim();o&&(f[o]=e.hasClass("card-collapsed")?"collapsed":"open",S.set(C,f))}),document.addEventListener("keydown",t=>{if(!(t.ctrlKey||t.metaKey)||t.key!=="s"||window.location.hash==="#/login"||window.location.hash.startsWith("#/reset-password"))return;const e=document.querySelector("#view-container .view-header button.btn-primary");e&&(t.preventDefault(),e.click())});const D={...B},g=[...N];async function j(){if(m())try{const t=await l.plugins.adminConfig();t.routes?.length&&g.push(...t.routes);for(const[e,o]of Object.entries(t.views||{}))try{const i=await import(`/plugins/${o.entry}`);D[e]=i[o.exportName]}catch{}for(const{id:e,href:o}of t.css||[])if(!document.getElementById(e)){const i=document.createElement("link");i.id=e,i.rel="stylesheet",i.href=o,document.head.appendChild(i)}}catch{}}function y(){const t=c();if(!t||$("#topbar-user-name").length)return;const o={"super-admin":"Super Admin",admin:"Admin",user:"User"}[t.role]||t.role;$("#topbar-user").html(`
|
|
1
|
+
import{views as B}from"./views/index.js";import{api as l,getUser as c,isAuthenticated as m,logout as W}from"./api.js";import{installHttpInterceptor as x}from"./http-interceptor.js";$(()=>{x(),(async()=>{try{const t=m()?await l.settings.get():null;Domma.theme.init({theme:t?.adminTheme||"charcoal-dark",persist:!0})}catch{Domma.theme.init({theme:"charcoal-dark",persist:!0})}})();const k=["jb-company","jb-agent","jb-candidate"],A=["/job-board","/my-profile"];function h(t){return t&&k.includes(t.role)}function w(t){return A.some(e=>t===e||t.startsWith(e+"/"))}R.use(async(t,e,o)=>{if(t.path==="/login"||t.path==="/reset-password")return o();if(!m()){R.navigate("/login");return}if(h(c())&&!w(t.path)){R.navigate("/job-board");return}o()});async function v(){try{return(await l.get("/auth/permissions")).permissions||[]}catch{return[]}}async function b(t){const{renderAdminSidebar:e}=await import("./lib/sidebar-renderer.js");await e({mount:"#admin-sidebar",permissions:t})}M.subscribe("router:afterChange",({to:t})=>{(t.path==="/login"||t.path==="/reset-password")&&n&&(clearInterval(n),n=null)});const N=[{path:"/",view:"dashboard",title:"Dashboard - Domma CMS"},{path:"/pages",view:"pages",title:"Pages - Domma CMS"},{path:"/pages/new",view:"pageEditor",title:"New Page - Domma CMS"},{path:"/pages/edit/*",view:"pageEditor",title:"Edit Page - Domma CMS"},{path:"/media",view:"media",title:"Media - Domma CMS"},{path:"/menus",view:"menus",title:"Menus - Domma CMS"},{path:"/menus/new",view:"menuEditor",title:"New Menu - Domma CMS"},{path:"/menus/edit/:slug",view:"menuEditor",title:"Edit Menu - Domma CMS"},{path:"/menu-locations",view:"menuLocations",title:"Menu Locations - Domma CMS"},{path:"/navigation",view:"menusRedirect",title:"Menus - Domma CMS"},{path:"/layouts",view:"layouts",title:"Layouts - Domma CMS"},{path:"/settings",view:"settings",title:"Settings - Domma CMS"},{path:"/users",view:"users",title:"Users - Domma CMS"},{path:"/users/new",view:"userEditor",title:"New User - Domma CMS"},{path:"/users/edit/:id",view:"userEditor",title:"Edit User - Domma CMS"},{path:"/plugins",view:"plugins",title:"Plugins - Domma CMS"},{path:"/documentation",view:"documentation",title:"Usage - Domma CMS"},{path:"/tutorials",view:"tutorials",title:"Tutorials - Domma CMS"},{path:"/api-reference",view:"apiReference",title:"API Reference - Domma CMS"},{path:"/collections",view:"collections",title:"Collections - Domma CMS"},{path:"/collections/new",view:"collectionEditor",title:"New Collection - Domma CMS"},{path:"/collections/edit/:slug",view:"collectionEditor",title:"Edit Collection - Domma CMS"},{path:"/collections/:slug/entries",view:"collectionEntries",title:"Entries - Domma CMS"},{path:"/forms",view:"forms",title:"Forms - Domma CMS"},{path:"/forms/new",view:"formEditor",title:"New Form - Domma CMS"},{path:"/forms/edit/:slug",view:"formEditor",title:"Edit Form - Domma CMS"},{path:"/forms/:slug/submissions",view:"formSubmissions",title:"Submissions - Domma CMS"},{path:"/views",view:"viewsList",title:"Views - Domma CMS"},{path:"/views/new",view:"viewEditor",title:"New View - Domma CMS"},{path:"/views/edit/:slug",view:"viewEditor",title:"Edit View - Domma CMS"},{path:"/views/:slug/preview",view:"viewPreview",title:"View Preview - Domma CMS"},{path:"/actions",view:"actionsList",title:"Actions - Domma CMS"},{path:"/actions/new",view:"actionEditor",title:"New Action - Domma CMS"},{path:"/actions/edit/:slug",view:"actionEditor",title:"Edit Action - Domma CMS"},{path:"/pro/docs",view:"proDocs",title:"Pro Documentation - Domma CMS"},{path:"/blocks",view:"blocks",title:"Blocks - Domma CMS"},{path:"/blocks/new",view:"blockEditor",title:"New Block - Domma CMS"},{path:"/blocks/edit/:name",view:"blockEditor",title:"Edit Block - Domma CMS"},{path:"/components",view:"components",title:"Components - Domma CMS"},{path:"/components/new",view:"componentEditor",title:"New Component - Domma CMS"},{path:"/components/edit/:name",view:"componentEditor",title:"Edit Component - Domma CMS"},{path:"/my-profile",view:"myProfile",title:"My Profile - Domma CMS"},{path:"/roles",view:"roles",title:"Roles & Permissions - Domma CMS"},{path:"/roles/edit/:id",view:"roleEditor",title:"Edit Role - Domma CMS"},{path:"/effects",view:"effects",title:"Effects - Domma CMS"},{path:"/system/notifications",view:"notifications",title:"Notifications - Domma CMS"},{path:"/api-tokens",view:"apiTokens",title:"API Tokens - Domma CMS"},{path:"/api-endpoints",view:"apiEndpoints",title:"API Builder - Domma CMS"},{path:"/api-endpoints/new",view:"apiEndpointEditor",title:"New API Endpoint - Domma CMS"},{path:"/api-endpoints/edit/:id",view:"apiEndpointEditor",title:"Edit API Endpoint - Domma CMS"},{path:"/projects",view:"projects",title:"Projects - Domma CMS"},{path:"/projects/new",view:"projectEditor",title:"New Project - Domma CMS"},{path:"/projects/edit/:slug",view:"projectEditor",title:"Edit Project - Domma CMS"},{path:"/projects/:slug/settings",view:"projectSettings",title:"Project settings - Domma CMS"},{path:"/projects/:slug/pages",view:"pages",title:"Project pages - Domma CMS"},{path:"/projects/:slug/collections",view:"collections",title:"Project collections - Domma CMS"},{path:"/projects/:slug/forms",view:"forms",title:"Project forms - Domma CMS"},{path:"/projects/:slug/actions",view:"actionsList",title:"Project actions - Domma CMS"},{path:"/projects/:slug/menus",view:"menus",title:"Project menus - Domma CMS"},{path:"/projects/:slug/blocks",view:"blocks",title:"Project blocks - Domma CMS"},{path:"/projects/:slug/components",view:"components",title:"Project components - Domma CMS"},{path:"/projects/:slug/views",view:"viewsList",title:"Project views - Domma CMS"},{path:"/projects/:slug/roles",view:"roles",title:"Project roles - Domma CMS"},{path:"/projects/:slug/users",view:"users",title:"Project users - Domma CMS"},{path:"/projects/:slug/apis",view:"apiEndpoints",title:"Project APIs - Domma CMS"},{path:"/projects/:slug",view:"projectDetail",title:"Project - Domma CMS"},{path:"/login",view:"login",title:"Sign in - Domma CMS",onEnter:()=>{$("#admin-sidebar").hide(),$("#admin-topbar").hide()}},{path:"/reset-password",view:"login",title:"Reset Password - Domma CMS",onEnter:()=>{$("#admin-sidebar").hide(),$("#admin-topbar").hide()}}];M.subscribe("router:afterChange",async({to:t,from:e})=>{if(!(t.path==="/login"||t.path==="/reset-password")){if(h(c())&&!w(t.path)){R.navigate("/job-board");return}if($("#admin-sidebar").show(),$("#admin-topbar").show(),e?.path==="/login"||e?.path==="/reset-password"){$("#topbar-user-name").remove(),await j();const o=await v();b(o);try{const i=await l.system.notifications.list().catch(()=>[]),s=(Array.isArray(i)?i:[]).filter(a=>a.unread&&["warning","critical"].includes(a.data?.severity)).slice(0,3);for(const a of s){const r=p(a.data?.title||""),d=p((a.data?.body||"").slice(0,120));E.toast(`${r} \u2014 ${d}`,{type:a.data?.severity==="critical"?"error":"warning",duration:0})}}catch{}}y()}}),M.subscribe("router:afterChange",()=>{setTimeout(()=>{$(".btn-primary, .btn-danger").length&&Domma.effects.reveal(".btn-primary, .btn-danger",{animation:"fade",stagger:40,duration:300})},50)});const L=["#field-project","#menu-project","#collection-project","#endpoint-project","#action-project","#view-project","#block-project","#component-project","#role-project",'[name="project"]','[name="ownedByProject"]'];function I(t){return/^\/(pages|menus|collections|forms|actions|views|blocks|components|users|roles|api-endpoints)\/(new|edit\/)/.test(t)}M.subscribe("router:afterChange",({to:t})=>{const e=t?String(t.path).split("?")[0]:"";if(!t||!I(e))return;const i=new URLSearchParams(location.hash.split("?")[1]||"").get("project");if(!i||!/\/new$/.test(e))return;let s=0;const a=60,r=1500,d=()=>{s+=a;for(const T of L){const u=document.querySelector(T);if(!u)continue;if(Array.from(u.options||[]).find(_=>_.value===i)){u.value=i,u.dispatchEvent(new Event("change",{bubbles:!0}));return}}s<r&&setTimeout(d,a)};setTimeout(d,a)});const C="cms_card_states",f=S.get(C)||{};M.subscribe("router:afterChange",()=>{setTimeout(()=>{$("#view-container .card-collapsible").each(function(){const t=$(this).find(".card-header h2, .card-header h3").first().text().trim();t&&f[t]==="collapsed"&&$(this).addClass("card-collapsed")})},200)}),$("#view-container").on("click",".card-collapsible .card-header",function(t){if($(t.target).closest("button, a").length)return;const e=$(this).closest(".card");e.toggleClass("card-collapsed");const o=$(this).find("h2, h3").first().text().trim();o&&(f[o]=e.hasClass("card-collapsed")?"collapsed":"open",S.set(C,f))}),document.addEventListener("keydown",t=>{if(!(t.ctrlKey||t.metaKey)||t.key!=="s"||window.location.hash==="#/login"||window.location.hash.startsWith("#/reset-password"))return;const e=document.querySelector("#view-container .view-header button.btn-primary");e&&(t.preventDefault(),e.click())});const D={...B},g=[...N];async function j(){if(m())try{const t=await l.plugins.adminConfig();t.routes?.length&&g.push(...t.routes);for(const[e,o]of Object.entries(t.views||{}))try{const i=await import(`/plugins/${o.entry}`);D[e]=i[o.exportName]}catch{}for(const{id:e,href:o}of t.css||[])if(!document.getElementById(e)){const i=document.createElement("link");i.id=e,i.rel="stylesheet",i.href=o,document.head.appendChild(i)}}catch{}}function y(){const t=c();if(!t||$("#topbar-user-name").length)return;const o={"super-admin":"Super Admin",admin:"Admin",user:"User"}[t.role]||t.role;$("#topbar-user").html(`
|
|
2
2
|
<span id="topbar-user-name" class="topbar-user-name">${p(t.name)}</span>
|
|
3
3
|
<span class="topbar-role-badge topbar-role-badge--${p(t.role)}">${o}</span>
|
|
4
4
|
`),$("#topbar-actions").html(`
|
|
@@ -28,6 +28,13 @@
|
|
|
28
28
|
<label class="form-label">Name <span style="color:var(--dm-danger,#f87171);">*</span></label>
|
|
29
29
|
<input id="component-name" type="text" class="form-input" placeholder="e.g. pricing-table">
|
|
30
30
|
<small class="text-muted">Registered as <code id="component-tag-hint"><dm-…></code>. Lowercase letters, digits, and hyphens. Cannot be changed after creation.</small>
|
|
31
|
+
<div class="mt-3">
|
|
32
|
+
<label class="form-label">Project</label>
|
|
33
|
+
<select id="component-project" class="form-input">
|
|
34
|
+
<option value="">— none —</option>
|
|
35
|
+
</select>
|
|
36
|
+
<small class="text-muted">Tag this component to a project for grouping.</small>
|
|
37
|
+
</div>
|
|
31
38
|
<div class="mt-3">
|
|
32
39
|
<label class="form-check-label" title="Included in fresh installs via the seed script">
|
|
33
40
|
<input id="component-bundled" type="checkbox" class="form-check"> Bundled
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as u}from"../api.js";import{createSimpleEditor as S}from"../lib/simple-editor.js";let p=null,b=null,f=null,l={},h={};const c={};export const componentEditorView={templateUrl:"/admin/js/templates/component-editor.html",async onMount(e){p=null,f=null,l={},h={};for(const t of Object.keys(c))c[t]?.destroy?.(),delete c[t];const n=window.location.hash.match(/\/components\/edit\/([^/?#]+)/);n&&(p=decodeURIComponent(n[1])),A(e),await T(e),L(e),z(e),await D(e);const o=e.find("#component-preview-iframe").get(0);o.contentDocument?.readyState==="complete"?v(e):o.addEventListener("load",()=>v(e),{once:!0}),Domma.icons.scan(e.get(0))}};async function T(e){const n=e.find("#component-name").get(0),o=e.find("#component-tag-hint").get(0),t=e.find("#component-editor-title").get(0),s=e.find("#export-component-btn").get(0),a=()=>{const i=(n.value||"").trim()||"\u2026";o.textContent=`<dm-${i}>`},r=e.find("#component-project").get(0);if(r)try{const i=await u.projects.list();for(const m of i){const d=document.createElement("option");d.value=m.slug,d.textContent=m.name||m.slug,r.appendChild(d)}}catch{}if(p){t.textContent="Edit Component",n.value=p,n.disabled=!0,s&&(s.style.display="");try{const i=await u.components.get(p);w(e,i.source),e.find("#component-bundled").prop("checked",!!i.bundled),h=i.meta||{},r&&(r.value=i.meta?.project||"")}catch(i){E.toast(i.message||"Component not found.",{type:"error"}),R.navigate("/components");return}}else t.textContent="New Component",w(e,V);a(),n.addEventListener("input",a)}const V=`<template>
|
|
2
2
|
<div>Hello, <span>{{name}}</span>.</div>
|
|
3
3
|
</template>
|
|
4
4
|
<props>
|
|
@@ -14,7 +14,7 @@ export default {
|
|
|
14
14
|
<style>
|
|
15
15
|
div { font-family: system-ui, sans-serif; }
|
|
16
16
|
</style>
|
|
17
|
-
`;function L(e){const n=e.find(".component-tab").get()||[],o=e.find(".component-tab-pane").get()||[];for(const t of n)t.addEventListener("click",()=>{for(const s of n)s.classList.toggle("active",s===t);for(const s of o)s.hidden=s.dataset.pane!==t.dataset.tab})}function I(e){const n=o=>{const t=e.match(new RegExp(`<${o}>([\\s\\S]*?)</${o}>`));return t?t[1].replace(/^\n/,"").replace(/\n$/,""):""};return{template:n("template"),props:n("props"),script:n("script"),style:n("style")}}function
|
|
17
|
+
`;function L(e){const n=e.find(".component-tab").get()||[],o=e.find(".component-tab-pane").get()||[];for(const t of n)t.addEventListener("click",()=>{for(const s of n)s.classList.toggle("active",s===t);for(const s of o)s.hidden=s.dataset.pane!==t.dataset.tab})}function I(e){const n=o=>{const t=e.match(new RegExp(`<${o}>([\\s\\S]*?)</${o}>`));return t?t[1].replace(/^\n/,"").replace(/\n$/,""):""};return{template:n("template"),props:n("props"),script:n("script"),style:n("style")}}function M(e){return[`<template>
|
|
18
18
|
${e.template}
|
|
19
19
|
</template>`,`<props>
|
|
20
20
|
${e.props}
|
|
@@ -24,5 +24,5 @@ ${e.script}
|
|
|
24
24
|
${e.style}
|
|
25
25
|
</style>`:""].filter(Boolean).join(`
|
|
26
26
|
`)+`
|
|
27
|
-
`}function
|
|
28
|
-
`);
|
|
27
|
+
`}function w(e,n){const o=I(n);c.template?.setValue(o.template),c.props?.setValue(o.props),c.script?.setValue(o.script),c.style?.setValue(o.style)}function N(e){return M({template:c.template?.getValue()??"",props:c.props?.getValue()??"",script:c.script?.getValue()??"",style:c.style?.getValue()??""})}const O=["template","props","script","style"];function A(e){const n=()=>{b&&clearTimeout(b),b=setTimeout(()=>v(e),300)};for(const o of O){const t=e.find(`#component-src-${o}`).get(0);t&&(c[o]=S(t,{initialValue:"",onChange:n}))}}async function v(e){const n=(e.find("#component-name").val()||"").trim()||"preview",o=N(e);g(e,"compiling");let t;try{t=await u.components.compile(n,o)}catch(s){g(e,"error",s.message||"Compile request failed.");return}if(t.errors?.length){const s=t.errors.map(a=>`[${a.type}] ${a.message}`).join(`
|
|
28
|
+
`);g(e,"error",s),f=null;return}f=t.js,g(e,"ok"),P(e),B(e,{compiledJs:f,tagName:`dm-${n}`,props:l})}let y=null;function B(e,n){const o=e.find("#component-preview-iframe").get(0);if(!o)return;if(!o.dataset.dmMounted){o.dataset.dmMounted="true",C(e,{type:"mount",payload:n});return}y=n;const t=()=>{o.removeEventListener("load",t),y&&(C(e,{type:"mount",payload:y}),y=null)};o.addEventListener("load",t),o.src=o.src}function g(e,n,o=""){const t=e.find("#component-compile-indicator").get(0),s=e.find("#component-compile-status").get(0),a=e.find("#component-compile-errors").get(0),r=e.find("#save-component-btn").get(0);n==="compiling"?(t.style.background="#888",s.textContent="Compiling\u2026",a.hidden=!0,r&&(r.disabled=!0)):n==="ok"?(t.style.background="#3bb273",s.textContent="Compile OK",a.hidden=!0,r&&(r.disabled=!1)):(t.style.background="#c92a2a",s.textContent="Compile failed \u2014 see below",a.hidden=!1,a.textContent=o,r&&(r.disabled=!0))}function j(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function k(e,n,o=!1){j(e);const t=document.createElement("p");if(t.className="text-muted",t.style.cssText="font-size:.8rem;margin:0;",o){t.appendChild(document.createTextNode("Invalid "));const s=document.createElement("code");s.textContent="<props>",t.appendChild(s),t.appendChild(document.createTextNode(" JSON \u2014 fix the tab above to see the props panel."))}else t.textContent=n;e.appendChild(t)}function P(e){const n=e.find("#component-preview-props-panel").get(0);if(!n)return;let o={};try{o=JSON.parse(c.props?.getValue()||"{}")}catch{k(n,"",!0);return}const t={};j(n);const s=Object.entries(o);if(s.length===0){k(n,"No props declared."),l={};return}for(const[a,r]of s){const i=document.createElement("div");i.style.cssText="display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;";const m=document.createElement("label");m.style.cssText="font-size:.75rem;color:var(--dm-text-muted,#aaa);min-width:120px;",m.textContent=r.label||a,i.appendChild(m);const d=F(r,a,()=>{l[a]=J(d,r),C(e,{type:"update",payload:{props:l}})}),x=a in l?l[a]:r.default!==void 0?r.default:_(r.type);U(d,r,x),t[a]=x,i.appendChild(d),n.appendChild(i)}l=t}function F(e,n,o){let t;return e.type==="number"?(t=document.createElement("input"),t.type="number",t.className="form-input form-input--sm"):e.type==="boolean"?(t=document.createElement("input"),t.type="checkbox",t.className="form-check"):e.type==="array"||e.type==="object"?(t=document.createElement("textarea"),t.rows=2,t.className="form-input form-input--sm",t.style.fontFamily="var(--dm-font-mono, monospace)"):(t=document.createElement("input"),t.type="text",t.className="form-input form-input--sm"),t.style.flex="1",t.dataset.propName=n,t.addEventListener("input",o),t.addEventListener("change",o),t}function J(e,n){if(n.type==="number")return e.value===""?null:Number(e.value);if(n.type==="boolean")return!!e.checked;if(n.type==="array"||n.type==="object")try{return JSON.parse(e.value||"null")}catch{return null}return e.value}function U(e,n,o){n.type==="boolean"?e.checked=!!o:n.type==="array"||n.type==="object"?e.value=JSON.stringify(o??null):e.value=o??""}function _(e){return e==="number"?0:e==="boolean"?!1:e==="array"?[]:e==="object"?{}:""}function C(e,n){const o=e.find("#component-preview-iframe").get(0);o?.contentWindow&&o.contentWindow.postMessage(n,"*")}function z(e){const n=e.find("#save-component-btn").get(0),o=e.find("#export-component-btn").get(0);n&&n.addEventListener("click",()=>q(e)),o&&o.addEventListener("click",async()=>{if(p)try{await u.components.exportBundle(p)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}})}async function q(e){const n=(e.find("#component-name").val()||"").trim();if(!n){E.toast("Component name is required.",{type:"warning"});return}if(!/^[a-z][a-z0-9-]*$/.test(n)){E.toast("Name must start with a letter and contain only lowercase letters, digits, and hyphens.",{type:"warning"});return}const o=N(e),t=!!e.find("#component-bundled").is(":checked"),s={...h||{}};delete s.bundled;const a=(e.find("#component-project").val()||"").trim();a?s.project=a:delete s.project;const r=e.find("#save-component-btn").get(0);r&&(r.disabled=!0);try{await u.components.put(n,{source:o,bundled:t,meta:s}),E.toast(p?"Component updated.":"Component created.",{type:"success"}),p||(p=n,R.navigate(`/components/edit/${encodeURIComponent(n)}`))}catch(i){E.toast(i.message||"Failed to save component.",{type:"error"})}finally{r&&(r.disabled=!1)}}async function D(e){if(p)try{const n=await u.components.list(),o=e.find("#component-quick-switch").get(0);if(!o||!n?.length)return;const t=[...n].sort((s,a)=>s.name.localeCompare(a.name));for(const s of t){const a=document.createElement("option");a.value=s.name,a.textContent=s.name,s.name===p&&(a.selected=!0),o.appendChild(a)}o.style.display="",o.addEventListener("change",()=>{const s=o.value;s&&s!==p&&R.navigate(`/components/edit/${encodeURIComponent(s)}`)})}catch{}}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import{api as i}from"../api.js";function
|
|
2
|
-
<a href="#/components/edit/${
|
|
3
|
-
<button class="btn btn-sm btn-ghost js-export-component" data-name="${
|
|
4
|
-
${
|
|
5
|
-
</div>`}}]}),
|
|
1
|
+
import{api as i}from"../api.js";import{filterByProject as u,getProjectFromHash as f}from"../lib/project-context.js";function p(t){return String(t??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}function g(t){for(;t.firstChild;)t.removeChild(t.firstChild)}export const componentsView={templateUrl:"/admin/js/templates/components.html",async onMount(t){y(),await l(t),k(t),Domma.icons.scan(t.get(0))}};let d=!1;function y(){d||(d=!0,I.register("download",{viewBox:"0 0 24 24",paths:["M12 3v12","M7 10l5 5 5-5","M5 19h14"],stroke:"currentColor",fill:"none",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"}),I.register("upload",{viewBox:"0 0 24 24",paths:["M12 21V9","M7 14l5-5 5 5","M5 5h14"],stroke:"currentColor",fill:"none",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"}),I.register("plus",{viewBox:"0 0 24 24",paths:["M12 5v14","M5 12h14"],stroke:"currentColor",fill:"none",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"}))}async function l(t){const o=t.find("#components-table-container").get(0);if(!o)return;let r=[];try{r=await i.components.list()}catch(e){g(o);const n=document.createElement("p");n.className="text-muted",n.textContent=`Failed to load components: ${e.message}`,o.appendChild(n);return}const s=f();s&&(r=u(r,s)),T.create(o,{data:r,emptyMessage:'No components yet. Click "New Component" to create your first one.',columns:[{key:"name",title:"Name",render:(e,n)=>{const a=n.origin==="plugin"?' <span class="badge badge-secondary" title="Provided by a plugin \u2014 read-only">plugin</span>':"";return`<a href="#/components/edit/${p(e)}">${p(e)}.dmc</a>${a}`}},{key:"props",title:"Props",render:e=>{const n=Object.keys(e||{});return n.length===0?'<span class="text-muted">\u2014</span>':n.map(a=>`<span class="badge badge-secondary" style="margin-right:.25rem;">${p(a)}</span>`).join("")}},{key:"size",title:"Size",render:e=>`${e} B`},{key:"updatedAt",title:"Updated",render:e=>e?new Date(e).toLocaleString():"\u2014"},{key:"name",title:"Actions",render:(e,n)=>{const a=p(e),m=n.origin==="plugin"?"":`<button class="btn btn-sm btn-danger js-delete-component" data-name="${a}" data-tooltip="Delete"><span data-icon="trash"></span></button>`;return`<div style="display:flex;gap:.4rem;justify-content:flex-end;">
|
|
2
|
+
<a href="#/components/edit/${a}" class="btn btn-sm btn-ghost" data-tooltip="Edit"><span data-icon="edit"></span></a>
|
|
3
|
+
<button class="btn btn-sm btn-ghost js-export-component" data-name="${a}" data-tooltip="Export as .dmcomponent.json"><span data-icon="download"></span></button>
|
|
4
|
+
${m}
|
|
5
|
+
</div>`}}]}),o.querySelectorAll(".js-delete-component").forEach(e=>{e.addEventListener("click",async()=>{await h(e.dataset.name,t)})}),o.querySelectorAll(".js-export-component").forEach(e=>{e.addEventListener("click",async()=>{try{await i.components.exportBundle(e.dataset.name)}catch(n){E.toast(n.message||"Export failed.",{type:"error"})}})}),Domma.icons.scan(o),o.querySelectorAll("[data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})})}async function h(t,o){if(await E.confirm(`Delete component "${t}"? This cannot be undone.`))try{await i.components.delete(t),E.toast("Component deleted.",{type:"success"}),await l(o)}catch(s){E.toast(s.message||"Failed to delete component.",{type:"error"})}}function k(t){const o=t.find("#import-component-btn").get(0),r=t.find("#import-component-file").get(0);!o||!r||(o.addEventListener("click",()=>r.click()),r.addEventListener("change",async()=>{const s=r.files?.[0];if(!s)return;r.value="";let e;try{const n=await s.text();e=JSON.parse(n)}catch{E.toast("Not a valid .dmcomponent.json file (could not parse JSON).",{type:"error"});return}if(!e||typeof e!="object"||!e.name||typeof e.source!="string"){E.toast("Bundle is missing a name or source field.",{type:"error"});return}try{const n=await i.components.importBundle(e);E.toast(`Imported "${n.name}".`,{type:"success"}),await l(t)}catch(n){if(n.code==="CONFLICT"){if(!await E.confirm(`A component named "${n.name}" already exists. Overwrite it with the imported version?`)){E.toast("Import cancelled.",{type:"info"});return}try{const c=await i.components.importBundle(e,{overwrite:!0});E.toast(`Overwrote "${c.name}".`,{type:"success"}),await l(t)}catch(c){E.toast(c.message||"Import failed.",{type:"error"})}return}E.toast(n.message||"Import failed.",{type:"error"})}}))}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{api as b}from"../api.js";import{openQuickCreate as N,INLINE_TYPES as U}from"../lib/project-quick-create.js";function
|
|
1
|
+
import{api as b}from"../api.js";import{openQuickCreate as N,INLINE_TYPES as U}from"../lib/project-quick-create.js";function a(e){return String(e??"").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:"components",label:"Components",singular:"Component",icon:"component",color:"#fb923c",path:"/components/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"},{key:"apis",label:"APIs",singular:"API Endpoint",icon:"code",color:"#a3e635",path:"/api-endpoints/new"}];function A(e){if(!e)return null;const l=new Date(e).getTime();if(Number.isNaN(l))return null;const s=Math.max(0,Math.round((Date.now()-l)/1e3)),n=Math.round(s/60),t=Math.round(n/60),c=Math.round(t/24);if(s<45)return"just now";if(n<60)return`${n} min${n===1?"":"s"} ago`;if(t<24)return`${t} hour${t===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(e,l){if(!e)return null;const s=new Date(e);if(Number.isNaN(s.getTime()))return null;try{return D(e).format(l)}catch{return s.toLocaleDateString()}}function m({icon:e,color:l,key:s,value:n,title:t}){if(n==null||n==="")return"";const c=`color:${l};background:${l}1f;border-color:${l}40`,r=t?` title="${a(t)}"`:"";return`<span class="pd-meta-badge" style="${c}"${r}><span data-icon="${a(e)}"></span><span class="pd-meta-k">${a(s)}</span><span class="pd-meta-v">${a(n)}</span></span>`}export const projectDetailView={templateUrl:"/admin/js/templates/project-detail.html",async onMount(e){const s=location.hash.split("?")[0].match(/^#\/projects\/([^/]+)$/),n=s?decodeURIComponent(s[1]):null;if(!n){location.hash="#/projects";return}const t=await b.projects.get(n).catch(()=>null);if(!t){E.toast("Project not found.",{type:"error"}),location.hash="#/projects";return}const c=encodeURIComponent(n);e.find("#pd-name").text(t.name||n),e.find("#pd-icon").attr("data-icon",t.icon||"folder"),e.find("#pd-desc").text(t.description||""),e.find("#pd-settings").attr("href","#/projects/"+c+"/settings");const r=[m({icon:"calendar",color:"#5b8cff",key:"Created",value:k(t.createdAt,"D MMM YYYY")}),m({icon:"user",color:"#7c6af7",key:"Creator",value:t.createdBy||"Unknown"}),m({icon:"clock",color:"#34d399",key:"Updated",value:A(t.updatedAt),title:k(t.updatedAt,"D MMM YYYY, HH:mm")})].join("");e.find("#pd-meta").html(r,{safe:!1}),Domma.icons.scan(e.find(".pd-hero").get(0));const i=e.find("#pd-counts"),w=e.find("#pd-total"),j={slug:n,name:t.name||n,rootUrl:t.rootUrl||""};async function h(){const d=await b.projects.artefacts(n).catch(()=>({}));let p=0;const f=Y.map(o=>{const u=Array.isArray(d[o.key])?d[o.key].length:0;p+=u;const M=U.includes(o.key),g=`<span class="pd-tile-chip" style="color:${o.color};background:${o.color}1f"><span data-icon="${a(o.icon)}"></span></span>`,y=`<span class="pd-tile-body"><span class="pd-tile-count">${u}</span><span class="pd-tile-label">${a(o.label)}</span></span>`,C=u>0?`<a class="pd-tile-link" href="#/projects/${c}/${a(o.key)}">${g}${y}</a>`:`<div class="pd-tile-link pd-tile-link--empty">${g}${y}</div>`,v=`<button class="pd-tile-add" data-type="${a(o.key)}" data-inline="${M?"1":""}" data-path="${a(o.path)}" title="New ${a(o.singular)}" aria-label="New ${a(o.singular)}"><span data-icon="plus"></span></button>`;return`<div class="pd-tile${u>0?" pd-tile--link":""}" style="--c:${o.color}">${C}${v}</div>`});i.html(`<div class="pd-tiles">${f.join("")}</div>`,{safe:!1}),w.text(p===1?"1 artefact":`${p} artefacts`),Domma.icons.scan(i.get(0))}await h(),i.off("click",".pd-tile-add").on("click",".pd-tile-add",function(){const d=$(this).data("type");if($(this).data("inline")){N({type:d,project:j,onCreated:h});return}const p=$(this).data("path"),f=String(p).includes("?")?"&":"?";location.hash="#"+p+f+"project="+encodeURIComponent(n)}),Domma.icons.scan(e.get(0))}};
|
package/package.json
CHANGED
|
@@ -45,15 +45,27 @@ export async function componentsRoutes(fastify) {
|
|
|
45
45
|
}
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
fastify.get('/components', canRead, () =>
|
|
48
|
+
fastify.get('/components', canRead, async (request) => {
|
|
49
|
+
// listComponents() surfaces each component's meta sidecar inline, so we
|
|
50
|
+
// can scope by project without a per-component re-fetch.
|
|
51
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
52
|
+
const all = await listComponents();
|
|
53
|
+
return all.filter(c => canSeeArtefact(request.user, c));
|
|
54
|
+
});
|
|
49
55
|
|
|
50
56
|
fastify.get('/components/:name', canRead, async (request, reply) => {
|
|
51
|
-
|
|
57
|
+
let component;
|
|
58
|
+
try { component = await getComponent(request.params.name); }
|
|
52
59
|
catch (err) {
|
|
53
60
|
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
54
61
|
if (err.code === 'ENOENT') return reply.status(404).send({error: 'Component not found'});
|
|
55
62
|
throw err;
|
|
56
63
|
}
|
|
64
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
65
|
+
if (!canSeeArtefact(request.user, component)) {
|
|
66
|
+
return reply.status(403).send({error: 'Access denied for this project'});
|
|
67
|
+
}
|
|
68
|
+
return component;
|
|
57
69
|
});
|
|
58
70
|
|
|
59
71
|
fastify.post('/components/compile', canUpdate, async (request, reply) => {
|
|
@@ -90,10 +102,10 @@ export async function componentsRoutes(fastify) {
|
|
|
90
102
|
|
|
91
103
|
fastify.put('/components/:name', canUpdate, async (request, reply) => {
|
|
92
104
|
const {name} = request.params;
|
|
93
|
-
const {source, bundled} = request.body || {};
|
|
105
|
+
const {source, bundled, meta} = request.body || {};
|
|
94
106
|
if (typeof source !== 'string') return reply.status(400).send({error: 'source (string) is required'});
|
|
95
107
|
try {
|
|
96
|
-
const result = await saveComponent(name, source, {bundled: !!bundled});
|
|
108
|
+
const result = await saveComponent(name, source, {bundled: !!bundled, meta});
|
|
97
109
|
if (!result.success) return reply.status(400).send({error: 'Compile failed', errors: result.errors});
|
|
98
110
|
return result;
|
|
99
111
|
} catch (err) {
|
|
@@ -233,9 +233,12 @@ export async function listComponents() {
|
|
|
233
233
|
const name = file.slice(0, -4);
|
|
234
234
|
const stat = await fs.stat(path.join(COMPONENTS_DIR, file)).catch(() => null);
|
|
235
235
|
let bundled = false;
|
|
236
|
+
let meta = null;
|
|
236
237
|
try {
|
|
237
|
-
const
|
|
238
|
-
bundled = !!
|
|
238
|
+
const m = JSON.parse(await fs.readFile(componentMetaPath(name), 'utf8'));
|
|
239
|
+
bundled = !!m.bundled;
|
|
240
|
+
// Surface the full sidecar so callers can filter by meta.project etc.
|
|
241
|
+
meta = m;
|
|
239
242
|
} catch { /* no meta */ }
|
|
240
243
|
let props = {};
|
|
241
244
|
try {
|
|
@@ -250,6 +253,7 @@ export async function listComponents() {
|
|
|
250
253
|
bundled,
|
|
251
254
|
props,
|
|
252
255
|
origin: 'file',
|
|
256
|
+
...(meta && {meta}),
|
|
253
257
|
});
|
|
254
258
|
}
|
|
255
259
|
|
|
@@ -285,16 +289,19 @@ export async function getComponent(name) {
|
|
|
285
289
|
try {
|
|
286
290
|
const source = await fs.readFile(componentFilePath(name), 'utf8');
|
|
287
291
|
let bundled = false;
|
|
292
|
+
let meta = null;
|
|
288
293
|
try {
|
|
289
|
-
const
|
|
290
|
-
bundled = !!
|
|
294
|
+
const m = JSON.parse(await fs.readFile(componentMetaPath(name), 'utf8'));
|
|
295
|
+
bundled = !!m.bundled;
|
|
296
|
+
// Preserve the full meta sidecar so callers can read meta.project etc.
|
|
297
|
+
meta = m;
|
|
291
298
|
} catch { /* no meta */ }
|
|
292
299
|
let props = {};
|
|
293
300
|
try {
|
|
294
301
|
const parts = parseDmcSource(source);
|
|
295
302
|
props = validatePropsJson(parts.props);
|
|
296
303
|
} catch { /* return malformed source for editing; UI flags it */ }
|
|
297
|
-
return {name, source, props, bundled};
|
|
304
|
+
return {name, source, props, bundled, ...(meta && {meta})};
|
|
298
305
|
} catch (err) {
|
|
299
306
|
if (err.code === 'ENOENT') {
|
|
300
307
|
const nf = new Error('Component not found');
|
|
@@ -313,9 +320,10 @@ export async function getComponent(name) {
|
|
|
313
320
|
* @param {string} source
|
|
314
321
|
* @param {object} [opts]
|
|
315
322
|
* @param {boolean} [opts.bundled] - Mark as bundled via a .meta.json companion.
|
|
323
|
+
* @param {object} [opts.meta] - Extra sidecar fields (e.g. {project: '<slug>'}).
|
|
316
324
|
* @returns {Promise<{success: boolean, name: string, errors: Array}>}
|
|
317
325
|
*/
|
|
318
|
-
export async function saveComponent(name, source, {bundled = false} = {}) {
|
|
326
|
+
export async function saveComponent(name, source, {bundled = false, meta} = {}) {
|
|
319
327
|
assertValidName(name);
|
|
320
328
|
if (getPluginComponents().some(pc => pc.name === name)) {
|
|
321
329
|
const e = new Error('That name is registered by a plugin and cannot be edited from the admin.');
|
|
@@ -328,8 +336,17 @@ export async function saveComponent(name, source, {bundled = false} = {}) {
|
|
|
328
336
|
await fs.mkdir(COMPONENTS_DIR, {recursive: true});
|
|
329
337
|
await fs.writeFile(componentFilePath(name), source, 'utf8');
|
|
330
338
|
const metaPath = componentMetaPath(name);
|
|
331
|
-
|
|
332
|
-
|
|
339
|
+
// Merge caller-supplied sidecar fields (eg meta.project) with the bundled
|
|
340
|
+
// flag. `bundled` stays authoritative from its own param, so strip any
|
|
341
|
+
// stale copy carried in `meta` before re-applying it.
|
|
342
|
+
const sidecar = {...(meta && typeof meta === 'object' ? meta : {})};
|
|
343
|
+
delete sidecar.bundled;
|
|
344
|
+
if (bundled) sidecar.bundled = true;
|
|
345
|
+
if (Object.keys(sidecar).length > 0) {
|
|
346
|
+
await fs.writeFile(metaPath, JSON.stringify(sidecar, null, 2) + '\n', 'utf8');
|
|
347
|
+
} else {
|
|
348
|
+
await fs.unlink(metaPath).catch(() => {});
|
|
349
|
+
}
|
|
333
350
|
invalidateCache(name);
|
|
334
351
|
await _refreshSanitiserAllowlist();
|
|
335
352
|
return {success: true, name, errors: []};
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Projects service.
|
|
3
3
|
*
|
|
4
4
|
* Projects group related artefacts (Pages, Collections, Forms, Actions,
|
|
5
|
-
* Menus, Blocks, Views, Roles, Users) under a
|
|
5
|
+
* Menus, Blocks, Components, Views, Roles, Users, API endpoints) under a
|
|
6
|
+
* named slug. Membership is
|
|
6
7
|
* a metadata tag (`meta.project: '<slug>'`) on each artefact — no
|
|
7
8
|
* filesystem isolation. Projects also act as a permission boundary:
|
|
8
9
|
* users with `projects: []` set only see their projects' artefacts plus
|
|
@@ -332,12 +333,12 @@ export async function getProjectForPage(urlPath, explicitProject) {
|
|
|
332
333
|
* `getArtefactsForProject('core')` enumerates everything unclaimed.
|
|
333
334
|
*
|
|
334
335
|
* @param {string} projectSlug
|
|
335
|
-
* @returns {Promise<{pages:object[], collections:object[], forms:object[], actions:object[], menus:object[], blocks:object[], views:object[], roles:object[], users:object[]}>}
|
|
336
|
+
* @returns {Promise<{pages:object[], collections:object[], forms:object[], actions:object[], menus:object[], blocks:object[], components:object[], views:object[], roles:object[], users:object[], apis:object[]}>}
|
|
336
337
|
*/
|
|
337
338
|
export async function getArtefactsForProject(projectSlug) {
|
|
338
339
|
const out = {
|
|
339
340
|
pages: [], collections: [], forms: [], actions: [],
|
|
340
|
-
menus: [], blocks: [], views: [], roles: [], users: [], apis: []
|
|
341
|
+
menus: [], blocks: [], components: [], views: [], roles: [], users: [], apis: []
|
|
341
342
|
};
|
|
342
343
|
|
|
343
344
|
try {
|
|
@@ -380,6 +381,15 @@ export async function getArtefactsForProject(projectSlug) {
|
|
|
380
381
|
}
|
|
381
382
|
} catch { /* skip */ }
|
|
382
383
|
|
|
384
|
+
try {
|
|
385
|
+
// listComponents() surfaces each component's meta sidecar, so no
|
|
386
|
+
// per-component read is needed. (Components are keyed by `name`.)
|
|
387
|
+
const {listComponents} = await import('./components.js');
|
|
388
|
+
for (const c of await listComponents()) {
|
|
389
|
+
if (resolveArtefactProject(c) === projectSlug) out.components.push(c);
|
|
390
|
+
}
|
|
391
|
+
} catch { /* skip */ }
|
|
392
|
+
|
|
383
393
|
try {
|
|
384
394
|
const {listViews} = await import('./views.js');
|
|
385
395
|
for (const v of await listViews()) {
|
|
@@ -447,7 +457,7 @@ export async function untagAllForProject(projectSlug) {
|
|
|
447
457
|
}
|
|
448
458
|
const counts = {
|
|
449
459
|
pages: 0, collections: 0, forms: 0, actions: 0,
|
|
450
|
-
menus: 0, blocks: 0, views: 0, roles: 0, users: 0, apis: 0
|
|
460
|
+
menus: 0, blocks: 0, components: 0, views: 0, roles: 0, users: 0, apis: 0
|
|
451
461
|
};
|
|
452
462
|
const grouped = await getArtefactsForProject(projectSlug);
|
|
453
463
|
|
|
@@ -509,6 +519,18 @@ export async function untagAllForProject(projectSlug) {
|
|
|
509
519
|
}
|
|
510
520
|
} catch { /* skip */ }
|
|
511
521
|
|
|
522
|
+
try {
|
|
523
|
+
const {getComponent, saveComponent} = await import('./components.js');
|
|
524
|
+
for (const c of grouped.components) {
|
|
525
|
+
const full = await getComponent(c.name).catch(() => null);
|
|
526
|
+
if (!full) continue;
|
|
527
|
+
const meta = {...(full.meta || {})};
|
|
528
|
+
delete meta.project;
|
|
529
|
+
await saveComponent(c.name, full.source, {bundled: full.bundled, meta});
|
|
530
|
+
counts.components++;
|
|
531
|
+
}
|
|
532
|
+
} catch { /* skip */ }
|
|
533
|
+
|
|
512
534
|
try {
|
|
513
535
|
const {readView, writeView} = await import('./views.js');
|
|
514
536
|
for (const v of grouped.views) {
|