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 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 nine services (`pages`, `collections`, `forms`, `actions`, `menus`, `blocks`, `views`, `roles`, `users`).
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. Nine artefact types 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.
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">&lt;dm-…&gt;</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 c}from"../api.js";import{createSimpleEditor as S}from"../lib/simple-editor.js";let i=null,b=null,d=null,l={};const p={};export const componentEditorView={templateUrl:"/admin/js/templates/component-editor.html",async onMount(e){i=null,d=null,l={};for(const t of Object.keys(p))p[t]?.destroy?.(),delete p[t];const n=window.location.hash.match(/\/components\/edit\/([^/?#]+)/);n&&(i=decodeURIComponent(n[1])),O(e),await T(e),L(e),_(e),await q(e);const o=e.find("#component-preview-iframe").get(0);o.contentDocument?.readyState==="complete"?h(e):o.addEventListener("load",()=>h(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 r=(n.value||"").trim()||"\u2026";o.textContent=`<dm-${r}>`};if(i){t.textContent="Edit Component",n.value=i,n.disabled=!0,s&&(s.style.display="");try{const r=await c.components.get(i);x(e,r.source),e.find("#component-bundled").prop("checked",!!r.bundled)}catch(r){E.toast(r.message||"Component not found.",{type:"error"}),R.navigate("/components");return}}else t.textContent="New Component",x(e,V);a(),n.addEventListener("input",a)}const V=`<template>
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 j(e){return[`<template>
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 x(e,n){const o=I(n);p.template?.setValue(o.template),p.props?.setValue(o.props),p.script?.setValue(o.script),p.style?.setValue(o.style)}function w(e){return j({template:p.template?.getValue()??"",props:p.props?.getValue()??"",script:p.script?.getValue()??"",style:p.style?.getValue()??""})}const M=["template","props","script","style"];function O(e){const n=()=>{b&&clearTimeout(b),b=setTimeout(()=>h(e),300)};for(const o of M){const t=e.find(`#component-src-${o}`).get(0);t&&(p[o]=S(t,{initialValue:"",onChange:n}))}}async function h(e){const n=(e.find("#component-name").val()||"").trim()||"preview",o=w(e);f(e,"compiling");let t;try{t=await c.components.compile(n,o)}catch(s){f(e,"error",s.message||"Compile request failed.");return}if(t.errors?.length){const s=t.errors.map(a=>`[${a.type}] ${a.message}`).join(`
28
- `);f(e,"error",s),d=null;return}d=t.js,f(e,"ok"),B(e),A(e,{compiledJs:d,tagName:`dm-${n}`,props:l})}let u=null;function A(e,n){const o=e.find("#component-preview-iframe").get(0);if(!o)return;if(!o.dataset.dmMounted){o.dataset.dmMounted="true",v(e,{type:"mount",payload:n});return}u=n;const t=()=>{o.removeEventListener("load",t),u&&(v(e,{type:"mount",payload:u}),u=null)};o.addEventListener("load",t),o.src=o.src}function f(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 N(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function k(e,n,o=!1){N(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 B(e){const n=e.find("#component-preview-props-panel").get(0);if(!n)return;let o={};try{o=JSON.parse(p.props?.getValue()||"{}")}catch{k(n,"",!0);return}const t={};N(n);const s=Object.entries(o);if(s.length===0){k(n,"No props declared."),l={};return}for(const[a,r]of s){const m=document.createElement("div");m.style.cssText="display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;";const y=document.createElement("label");y.style.cssText="font-size:.75rem;color:var(--dm-text-muted,#aaa);min-width:120px;",y.textContent=r.label||a,m.appendChild(y);const g=P(r,a,()=>{l[a]=F(g,r),v(e,{type:"update",payload:{props:l}})}),C=a in l?l[a]:r.default!==void 0?r.default:U(r.type);J(g,r,C),t[a]=C,m.appendChild(g),n.appendChild(m)}l=t}function P(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 F(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 J(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 U(e){return e==="number"?0:e==="boolean"?!1:e==="array"?[]:e==="object"?{}:""}function v(e,n){const o=e.find("#component-preview-iframe").get(0);o?.contentWindow&&o.contentWindow.postMessage(n,"*")}function _(e){const n=e.find("#save-component-btn").get(0),o=e.find("#export-component-btn").get(0);n&&n.addEventListener("click",()=>z(e)),o&&o.addEventListener("click",async()=>{if(i)try{await c.components.exportBundle(i)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}})}async function z(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=w(e),t=!!e.find("#component-bundled").is(":checked"),s=e.find("#save-component-btn").get(0);s&&(s.disabled=!0);try{await c.components.put(n,{source:o,bundled:t}),E.toast(i?"Component updated.":"Component created.",{type:"success"}),i||(i=n,R.navigate(`/components/edit/${encodeURIComponent(n)}`))}catch(a){E.toast(a.message||"Failed to save component.",{type:"error"})}finally{s&&(s.disabled=!1)}}async function q(e){if(i)try{const n=await c.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===i&&(a.selected=!0),o.appendChild(a)}o.style.display="",o.addEventListener("change",()=>{const s=o.value;s&&s!==i&&R.navigate(`/components/edit/${encodeURIComponent(s)}`)})}catch{}}
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 c(t){return String(t??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function m(t){for(;t.firstChild;)t.removeChild(t.firstChild)}export const componentsView={templateUrl:"/admin/js/templates/components.html",async onMount(t){u(),await p(t),g(t),Domma.icons.scan(t.get(0))}};let d=!1;function u(){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 p(t){const r=t.find("#components-table-container").get(0);if(!r)return;let a=[];try{a=await i.components.list()}catch(e){m(r);const n=document.createElement("p");n.className="text-muted",n.textContent=`Failed to load components: ${e.message}`,r.appendChild(n);return}T.create(r,{data:a,emptyMessage:'No components yet. Click "New Component" to create your first one.',columns:[{key:"name",title:"Name",render:(e,n)=>{const o=n.origin==="plugin"?' <span class="badge badge-secondary" title="Provided by a plugin \u2014 read-only">plugin</span>':"";return`<a href="#/components/edit/${c(e)}">${c(e)}.dmc</a>${o}`}},{key:"props",title:"Props",render:e=>{const n=Object.keys(e||{});return n.length===0?'<span class="text-muted">\u2014</span>':n.map(o=>`<span class="badge badge-secondary" style="margin-right:.25rem;">${c(o)}</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 o=c(e),s=n.origin==="plugin"?"":`<button class="btn btn-sm btn-danger js-delete-component" data-name="${o}" 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/${o}" 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="${o}" data-tooltip="Export as .dmcomponent.json"><span data-icon="download"></span></button>
4
- ${s}
5
- </div>`}}]}),r.querySelectorAll(".js-delete-component").forEach(e=>{e.addEventListener("click",async()=>{await f(e.dataset.name,t)})}),r.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(r),r.querySelectorAll("[data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})})}async function f(t,r){if(await E.confirm(`Delete component "${t}"? This cannot be undone.`))try{await i.components.delete(t),E.toast("Component deleted.",{type:"success"}),await p(r)}catch(e){E.toast(e.message||"Failed to delete component.",{type:"error"})}}function g(t){const r=t.find("#import-component-btn").get(0),a=t.find("#import-component-file").get(0);!r||!a||(r.addEventListener("click",()=>a.click()),a.addEventListener("change",async()=>{const e=a.files?.[0];if(!e)return;a.value="";let n;try{const o=await e.text();n=JSON.parse(o)}catch{E.toast("Not a valid .dmcomponent.json file (could not parse JSON).",{type:"error"});return}if(!n||typeof n!="object"||!n.name||typeof n.source!="string"){E.toast("Bundle is missing a name or source field.",{type:"error"});return}try{const o=await i.components.importBundle(n);E.toast(`Imported "${o.name}".`,{type:"success"}),await p(t)}catch(o){if(o.code==="CONFLICT"){if(!await E.confirm(`A component named "${o.name}" already exists. Overwrite it with the imported version?`)){E.toast("Import cancelled.",{type:"info"});return}try{const s=await i.components.importBundle(n,{overwrite:!0});E.toast(`Overwrote "${s.name}".`,{type:"success"}),await p(t)}catch(s){E.toast(s.message||"Import failed.",{type:"error"})}return}E.toast(o.message||"Import failed.",{type:"error"})}}))}
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}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 o(e){return String(e??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}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"},{key:"apis",label:"APIs",singular:"API Endpoint",icon:"code",color:"#a3e635",path:"/api-endpoints/new"}];function A(e){if(!e)return null;const c=new Date(e).getTime();if(Number.isNaN(c))return null;const s=Math.max(0,Math.round((Date.now()-c)/1e3)),n=Math.round(s/60),t=Math.round(n/60),l=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(l<30)return`${l} day${l===1?"":"s"} ago`;const r=Math.round(l/30);if(r<12)return`${r} month${r===1?"":"s"} ago`;const i=Math.round(l/365);return`${i} year${i===1?"":"s"} ago`}function k(e,c){if(!e)return null;const s=new Date(e);if(Number.isNaN(s.getTime()))return null;try{return D(e).format(c)}catch{return s.toLocaleDateString()}}function m({icon:e,color:c,key:s,value:n,title:t}){if(n==null||n==="")return"";const l=`color:${c};background:${c}1f;border-color:${c}40`,r=t?` title="${o(t)}"`:"";return`<span class="pd-meta-badge" style="${l}"${r}><span data-icon="${o(e)}"></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(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 l=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/"+l+"/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(a=>{const u=Array.isArray(d[a.key])?d[a.key].length:0;p+=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/${l}/${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(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))}};
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.25.7",
3
+ "version": "0.25.8",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -45,15 +45,27 @@ export async function componentsRoutes(fastify) {
45
45
  }
46
46
  });
47
47
 
48
- fastify.get('/components', canRead, () => listComponents());
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
- try { return await getComponent(request.params.name); }
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 meta = JSON.parse(await fs.readFile(componentMetaPath(name), 'utf8'));
238
- bundled = !!meta.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 meta = JSON.parse(await fs.readFile(componentMetaPath(name), 'utf8'));
290
- bundled = !!meta.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
- if (bundled) await fs.writeFile(metaPath, JSON.stringify({bundled: true}, null, 2) + '\n', 'utf8');
332
- else await fs.unlink(metaPath).catch(() => {});
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 named slug. Membership is
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) {