smoodly 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +167 -18
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/CanvasOverlay.d.ts +21 -0
  4. package/dist/admin/editor/CanvasOverlay.js +39 -0
  5. package/dist/admin/editor/EditorView.js +56 -237
  6. package/dist/admin/editor/InsertPicker.d.ts +18 -0
  7. package/dist/admin/editor/InsertPicker.js +18 -0
  8. package/dist/admin/editor/NoticePane.d.ts +4 -0
  9. package/dist/admin/editor/NoticePane.js +4 -0
  10. package/dist/admin/editor/Outline.d.ts +18 -0
  11. package/dist/admin/editor/Outline.js +12 -0
  12. package/dist/admin/editor/locale-url.d.ts +12 -0
  13. package/dist/admin/editor/locale-url.js +32 -0
  14. package/dist/admin/editor/overlay-geometry.d.ts +14 -0
  15. package/dist/admin/editor/overlay-geometry.js +27 -0
  16. package/dist/admin/editor/useCanvasBridge.d.ts +15 -0
  17. package/dist/admin/editor/useCanvasBridge.js +53 -0
  18. package/dist/admin/editor/usePageLocale.d.ts +23 -0
  19. package/dist/admin/editor/usePageLocale.js +88 -0
  20. package/dist/admin/forms/CropEditor.d.ts +10 -0
  21. package/dist/admin/forms/CropEditor.js +61 -0
  22. package/dist/admin/forms/FieldWidget.js +8 -26
  23. package/dist/admin/forms/MediaGrid.d.ts +11 -0
  24. package/dist/admin/forms/MediaGrid.js +16 -0
  25. package/dist/admin/forms/MediaPicker.d.ts +8 -0
  26. package/dist/admin/forms/MediaPicker.js +44 -0
  27. package/dist/admin/forms/MediaWidget.d.ts +9 -0
  28. package/dist/admin/forms/MediaWidget.js +83 -0
  29. package/dist/admin/forms/RichtextToolbar.d.ts +20 -0
  30. package/dist/admin/forms/RichtextToolbar.js +37 -0
  31. package/dist/admin/forms/RichtextWidget.d.ts +5 -0
  32. package/dist/admin/forms/RichtextWidget.js +123 -0
  33. package/dist/admin/forms/assets-context.d.ts +19 -0
  34. package/dist/admin/forms/assets-context.js +35 -0
  35. package/dist/admin/forms/crop-editor.d.ts +13 -0
  36. package/dist/admin/forms/crop-editor.js +72 -0
  37. package/dist/admin/forms/measure.d.ts +2 -0
  38. package/dist/admin/forms/measure.js +39 -0
  39. package/dist/admin/forms/richtext-extensions.d.ts +5 -0
  40. package/dist/admin/forms/richtext-extensions.js +74 -0
  41. package/dist/admin/forms/richtext-paste.d.ts +22 -0
  42. package/dist/admin/forms/richtext-paste.js +59 -0
  43. package/dist/admin/forms/upload.d.ts +17 -0
  44. package/dist/admin/forms/upload.js +33 -0
  45. package/dist/admin/index.d.ts +1 -2
  46. package/dist/admin/index.js +0 -1
  47. package/dist/admin/next/create-admin.d.ts +3 -7
  48. package/dist/admin/next/create-admin.js +14 -5
  49. package/dist/admin/next/op-handler.d.ts +3 -0
  50. package/dist/admin/next/op-handler.js +1 -0
  51. package/dist/admin/ops-impl.d.ts +3 -0
  52. package/dist/admin/ops-impl.js +83 -2
  53. package/dist/admin/ops.d.ts +27 -0
  54. package/dist/admin/richtext-doc.d.ts +7 -0
  55. package/dist/admin/richtext-doc.js +18 -0
  56. package/dist/admin/serialize.d.ts +5 -0
  57. package/dist/admin/serialize.js +1 -0
  58. package/dist/admin/shell/AdminApp.d.ts +1 -2
  59. package/dist/admin/shell/AdminApp.js +18 -19
  60. package/dist/admin/shell/EntriesList.js +3 -2
  61. package/dist/admin/shell/EntryForm.js +8 -24
  62. package/dist/admin/shell/IconRail.d.ts +7 -0
  63. package/dist/admin/shell/IconRail.js +73 -0
  64. package/dist/admin/shell/ListView.js +3 -1
  65. package/dist/admin/shell/MediaScreen.d.ts +8 -0
  66. package/dist/admin/shell/MediaScreen.js +86 -0
  67. package/dist/admin/shell/PagesList.js +3 -2
  68. package/dist/admin/shell/RouteLink.d.ts +4 -0
  69. package/dist/admin/shell/RouteLink.js +12 -0
  70. package/dist/admin/shell/SharedForm.js +3 -21
  71. package/dist/admin/shell/media-list.d.ts +8 -0
  72. package/dist/admin/shell/media-list.js +14 -0
  73. package/dist/admin/shell/router.d.ts +37 -0
  74. package/dist/admin/shell/router.js +84 -0
  75. package/dist/admin/shell/session.js +3 -1
  76. package/dist/admin/ui/Modal.d.ts +8 -0
  77. package/dist/admin/ui/Modal.js +15 -0
  78. package/dist/admin/ui/primitives.d.ts +3 -2
  79. package/dist/admin/ui/primitives.js +8 -6
  80. package/dist/admin/ui/theme.d.ts +1 -1
  81. package/dist/admin/ui/theme.js +133 -24
  82. package/dist/admin/validate.js +16 -0
  83. package/dist/asset-store.d.ts +86 -0
  84. package/dist/asset-store.js +141 -0
  85. package/dist/collections.d.ts +0 -14
  86. package/dist/collections.js +2 -10
  87. package/dist/config.d.ts +12 -0
  88. package/dist/config.js +17 -5
  89. package/dist/fields.d.ts +10 -11
  90. package/dist/fields.js +16 -2
  91. package/dist/image/SmoodlyImage.d.ts +18 -0
  92. package/dist/image/SmoodlyImage.js +16 -0
  93. package/dist/image/SmoodlyVideo.d.ts +7 -0
  94. package/dist/image/SmoodlyVideo.js +5 -0
  95. package/dist/image/crop-math.d.ts +14 -0
  96. package/dist/image/crop-math.js +38 -0
  97. package/dist/image/index.d.ts +6 -0
  98. package/dist/image/index.js +5 -0
  99. package/dist/image/loaders.d.ts +15 -0
  100. package/dist/image/loaders.js +5 -0
  101. package/dist/image/next-config.d.ts +15 -0
  102. package/dist/image/next-config.js +27 -0
  103. package/dist/image-dimensions.d.ts +4 -0
  104. package/dist/image-dimensions.js +25 -0
  105. package/dist/index.d.ts +12 -4
  106. package/dist/index.js +8 -2
  107. package/dist/media.d.ts +108 -0
  108. package/dist/media.js +129 -0
  109. package/dist/refs.d.ts +2 -0
  110. package/dist/refs.js +25 -4
  111. package/dist/resolve.d.ts +19 -4
  112. package/dist/resolve.js +55 -2
  113. package/dist/site.d.ts +2 -0
  114. package/dist/site.js +21 -3
  115. package/dist/sql-space.d.ts +8 -1
  116. package/dist/sql-space.js +28 -0
  117. package/dist/sql.js +30 -0
  118. package/dist/store.js +3 -0
  119. package/dist/supabase-asset-store.d.ts +21 -0
  120. package/dist/supabase-asset-store.js +170 -0
  121. package/dist/toolset.d.ts +30 -0
  122. package/dist/toolset.js +49 -0
  123. package/package.json +15 -1
  124. package/dist/admin/richtext.d.ts +0 -16
  125. package/dist/admin/richtext.js +0 -98
@@ -74,7 +74,7 @@ const SCALE = `
74
74
 
75
75
  --radius-xs:1px; --radius-sm:3px; --radius-md:4px; --radius-round:999px;
76
76
  --control-h:28px; --control-h-lg:36px; --row-h:38px; --topbar-h:48px;
77
- --nav-w:200px; --inspector-w:300px;
77
+ --nav-w:72px; --inspector-w:380px;
78
78
  --border:1px solid var(--line);
79
79
  --outline-selection:1.5px solid var(--selection);
80
80
  `;
@@ -113,13 +113,61 @@ body:has(.sm-admin){margin:0}
113
113
  /* ── shell ─────────────────────────────────────────────────────────── */
114
114
  .sm-shell{display:grid;grid-template-columns:var(--nav-w) 1fr;height:100vh;height:100dvh;overflow:hidden}
115
115
  .sm-shell>.sm-main{min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden}
116
- .sm-nav{
117
- display:flex;flex-direction:column;gap:24px;padding:16px 12px;
118
- border-right:var(--border);background:var(--surface);min-width:0;overflow:auto;
119
- }
120
- .sm-nav__logo{height:18px;width:auto;align-self:flex-start;filter:var(--logo-filter);flex:none}
121
- .sm-nav>.sm-nav__logo{margin-left:10px}
122
- .sm-nav__list{display:flex;flex-direction:column;gap:1px}
116
+ .sm-wordmark{height:18px;width:auto;filter:var(--logo-filter);flex:none}
117
+ /* The design system's IconRail (components/navigation/IconRail.html),
118
+ ported as-is: a 72px rail, 18px Lucide icons over a 10px label, a
119
+ 40×32 active tile, the app icon on top and the avatar at the bottom. */
120
+ .sm-rail{
121
+ width:var(--nav-w);height:100%;display:flex;flex-direction:column;align-items:center;gap:14px;
122
+ padding:14px 6px 12px;background:var(--surface);border-right:var(--border);color:var(--text);
123
+ font:500 10px/1 var(--font-sans);position:relative;
124
+ }
125
+ .sm-rail__logo{width:28px;height:28px;display:block;flex:none}
126
+ .sm-rail__list{display:flex;flex-direction:column;gap:6px;width:100%;margin:0;padding:0;list-style:none}
127
+ .sm-rail__item{position:relative}
128
+ .sm-rail__btn{
129
+ appearance:none;background:none;border:0;margin:0;padding:2px 0;width:100%;display:flex;flex-direction:column;
130
+ align-items:center;gap:5px;color:inherit;font:inherit;cursor:pointer;border-radius:var(--radius-md);text-decoration:none;
131
+ }
132
+ .sm-rail__btn:hover{color:inherit}
133
+ .sm-rail__btn:focus-visible{outline:2px solid var(--accent);outline-offset:-2px;box-shadow:none}
134
+ .sm-rail__tile{position:relative;width:40px;height:32px;border-radius:var(--radius-md);display:grid;place-items:center;transition:background .12s}
135
+ .sm-rail__btn:hover .sm-rail__tile{background:var(--surface-inset)}
136
+ .sm-rail__btn[aria-current="page"],.sm-rail__btn[aria-expanded="true"]{color:var(--nav-active-fg);font-weight:600}
137
+ .sm-rail__btn[aria-current="page"]:hover,.sm-rail__btn[aria-expanded="true"]:hover{color:var(--nav-active-fg)}
138
+ .sm-rail__btn[aria-current="page"] .sm-rail__tile,.sm-rail__btn[aria-expanded="true"] .sm-rail__tile{background:var(--nav-active-bg)}
139
+ .sm-rail__tile svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round}
140
+ .sm-rail__chev{position:absolute;right:3px;bottom:2px;width:8px;height:8px}
141
+ .sm-rail__chev svg{width:8px;height:8px;stroke-width:3}
142
+ .sm-rail__label{letter-spacing:.01em;white-space:nowrap}
143
+ /* Not greyed: the reference renders every inactive item at --text and marks
144
+ the unavailable ones only by being inert (no pointer, no hover). */
145
+ .sm-rail__btn[aria-disabled="true"]{cursor:default}
146
+ .sm-rail__btn[aria-disabled="true"]:hover .sm-rail__tile{background:transparent}
147
+ .sm-rail__foot{margin-top:auto;display:flex;flex-direction:column;align-items:center}
148
+ .sm-rail__user{
149
+ width:26px;height:26px;border-radius:var(--radius-round);background:var(--accent-soft);color:var(--accent);
150
+ display:grid;place-items:center;font-size:var(--text-sm);font-weight:600;border:0;cursor:pointer;padding:0;
151
+ }
152
+ .sm-rail__user:focus-visible{outline:2px solid var(--accent);outline-offset:1px;box-shadow:none}
153
+ /* Flyout: 6px right of the rail, aligned with its item (the user menu aligns to the bottom). */
154
+ .sm-rail__menu{
155
+ position:absolute;left:calc(100% + 6px);top:0;z-index:20;width:168px;padding:6px;margin:0;list-style:none;
156
+ background:var(--surface);border:var(--border);border-radius:var(--radius-md);box-shadow:0 4px 20px rgba(0,0,0,.14);
157
+ display:none;flex-direction:column;gap:1px;font-size:var(--text-md);font-weight:500;color:var(--text);
158
+ }
159
+ .sm-rail__menu[data-open="true"]{display:flex}
160
+ .sm-rail__menu--up{top:auto;bottom:0}
161
+ .sm-rail__menu-title{font-size:var(--text-2xs);font-weight:600;letter-spacing:var(--tracking-label);text-transform:uppercase;color:var(--text-muted);padding:6px 8px 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
162
+ .sm-rail__menu-item{
163
+ appearance:none;background:none;border:0;width:100%;display:flex;align-items:center;justify-content:space-between;
164
+ padding:6px 8px;border-radius:var(--radius-sm);color:inherit;font:inherit;cursor:pointer;text-align:left;text-decoration:none;
165
+ }
166
+ .sm-rail__menu-item:hover,.sm-rail__menu-item:focus-visible{background:var(--surface-inset);color:inherit;outline:0;box-shadow:none}
167
+ .sm-rail__menu-item[aria-current="page"],.sm-rail__menu-item[aria-current="page"]:hover{background:var(--nav-active-bg);color:var(--nav-active-fg);font-weight:600}
168
+ .sm-when-light{display:var(--when-light);align-items:center;gap:8px}
169
+ .sm-when-dark{display:var(--when-dark);align-items:center;gap:8px}
170
+ /* .sm-nav-item survives as the inspector's link style (EditorView). */
123
171
  .sm-nav-item{
124
172
  display:block;padding:6px 10px;border-radius:var(--radius-md);line-height:1.3;
125
173
  color:var(--text);font-weight:500;text-decoration:none;cursor:pointer;
@@ -127,22 +175,6 @@ body:has(.sm-admin){margin:0}
127
175
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
128
176
  }
129
177
  .sm-nav-item:hover{background:var(--surface-inset);color:var(--text)}
130
- .sm-nav-item[aria-current="page"]{background:var(--nav-active-bg);color:var(--nav-active-fg);font-weight:600}
131
- .sm-nav-item[aria-current="page"]:hover{background:var(--nav-active-bg);color:var(--nav-active-fg)}
132
- /* Not greyed: the reference renders every inactive item at --text and marks
133
- the unavailable ones only by being inert (no pointer, no hover). */
134
- .sm-nav-item[aria-disabled="true"]{cursor:default}
135
- .sm-nav-item[aria-disabled="true"]:hover{background:transparent}
136
- .sm-nav__foot{margin-top:auto;display:flex;flex-direction:column;gap:2px}
137
- .sm-nav__user{display:flex;align-items:center;gap:10px;padding:6px 10px;min-width:0}
138
- .sm-nav-item--icon{display:flex;align-items:center;gap:8px}
139
- .sm-when-light{display:var(--when-light);align-items:center;gap:8px}
140
- .sm-when-dark{display:var(--when-dark);align-items:center;gap:8px}
141
- .sm-avatar{
142
- width:24px;height:24px;flex:none;border-radius:var(--radius-round);
143
- background:var(--accent-soft);color:var(--accent);
144
- display:grid;place-items:center;font-size:var(--text-sm);font-weight:600;
145
- }
146
178
 
147
179
  /* ── top bar ───────────────────────────────────────────────────────── */
148
180
  .sm-topbar{
@@ -231,6 +263,32 @@ body:has(.sm-admin){margin:0}
231
263
  .sm-inputwrap>.sm-input:hover,.sm-inputwrap>.sm-input:focus{background:transparent;box-shadow:none}
232
264
  .sm-prefix{color:var(--text-muted);font-family:var(--font-mono);font-size:var(--text-sm);flex:none}
233
265
  .sm-error{color:var(--danger);font-size:var(--text-sm)}
266
+ /* ── richtext ──────────────────────────────────────────────────────── */
267
+ .sm-richtext{display:flex;flex-direction:column;gap:6px;min-width:0}
268
+ .sm-richtext__bar{display:flex;flex-wrap:wrap;align-items:center;gap:2px}
269
+ .sm-richtext__sep{width:1px;height:16px;margin:0 4px;background:var(--line)}
270
+ .sm-richtext__bar .sm-iconbtn[aria-pressed="true"]{background:var(--press);color:var(--text)}
271
+ .sm-richtext__link{display:flex;align-items:center;gap:6px}
272
+ .sm-richtext__content{
273
+ min-height:110px;padding:8px 10px;border:1.5px solid transparent;border-radius:var(--radius-md);
274
+ background:var(--surface-inset);color:var(--text);font-size:var(--text-md);line-height:1.5;outline:0;
275
+ }
276
+ .sm-richtext__content:hover{background:var(--control-hover)}
277
+ .sm-richtext__content:focus{background:var(--input-bg);border-color:var(--selection);box-shadow:var(--focus-ring)}
278
+ .sm-richtext__content>*+*{margin-top:.5em}
279
+ .sm-richtext__content p,.sm-richtext__content h1,.sm-richtext__content h2,.sm-richtext__content h3,
280
+ .sm-richtext__content h4,.sm-richtext__content h5,.sm-richtext__content h6,
281
+ .sm-richtext__content ul,.sm-richtext__content ol,.sm-richtext__content blockquote{margin:0}
282
+ .sm-richtext__content h1,.sm-richtext__content h2,.sm-richtext__content h3,
283
+ .sm-richtext__content h4,.sm-richtext__content h5,.sm-richtext__content h6{font-weight:600;line-height:1.25}
284
+ .sm-richtext__content h1{font-size:var(--text-3xl)}
285
+ .sm-richtext__content h2{font-size:var(--text-2xl)}
286
+ .sm-richtext__content h3{font-size:var(--text-xl)}
287
+ .sm-richtext__content h4,.sm-richtext__content h5,.sm-richtext__content h6{font-size:var(--text-lg)}
288
+ .sm-richtext__content ul,.sm-richtext__content ol{padding-left:18px}
289
+ .sm-richtext__content li+li{margin-top:.25em}
290
+ .sm-richtext__content blockquote{padding-left:10px;border-left:2px solid var(--line);color:var(--text-muted)}
291
+ .sm-richtext__content a{color:var(--accent);text-decoration:underline}
234
292
 
235
293
  .sm-group{display:flex;flex-direction:column;gap:10px;padding:2px 0 0 10px;border-left:2px solid var(--line)}
236
294
  .sm-list{display:flex;flex-direction:column;gap:6px;align-items:stretch}
@@ -393,6 +451,57 @@ body:has(.sm-admin){margin:0}
393
451
  border:1px dashed var(--line-dashed);border-radius:var(--radius-md);
394
452
  display:flex;align-items:center;justify-content:center;gap:8px;color:var(--text-muted);
395
453
  }
454
+
455
+ /* ── media ─────────────────────────────────────────────────────────── */
456
+ .sm-modal{position:fixed;inset:0;z-index:70;background:rgba(0,0,0,.45);display:grid;place-items:center;padding:24px}
457
+ .sm-modal__panel{
458
+ max-width:100%;max-height:100%;display:flex;flex-direction:column;background:var(--surface);
459
+ border:var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-pop);overflow:hidden;
460
+ }
461
+ .sm-modal__head{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:var(--border)}
462
+ .sm-modal__body{padding:12px;overflow:auto;display:flex;flex-direction:column;gap:10px;min-height:0}
463
+ .sm-modal__foot{display:flex;justify-content:flex-end;gap:8px;padding:10px 12px;border-top:var(--border)}
464
+ .sm-picker-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
465
+ .sm-dropzone{
466
+ border:1px dashed var(--line-dashed);border-radius:var(--radius-md);color:var(--text-muted);
467
+ display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px;text-align:center;
468
+ }
469
+ .sm-dropzone--bar{padding:8px;font-size:var(--text-sm)}
470
+ .sm-mediagrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:10px}
471
+ .sm-mediacard{
472
+ display:flex;flex-direction:column;gap:4px;padding:6px;background:transparent;border:1.5px solid transparent;
473
+ border-radius:var(--radius-md);cursor:pointer;text-align:left;color:var(--text);font:inherit;min-width:0;
474
+ }
475
+ .sm-mediacard:hover{background:var(--control-hover)}
476
+ .sm-mediacard[aria-pressed="true"]{border-color:var(--selection);background:var(--accent-soft)}
477
+ .sm-mediacard__thumb{display:block;aspect-ratio:4/3;border-radius:var(--radius-sm);overflow:hidden;background:var(--surface-inset)}
478
+ .sm-mediacard__img{width:100%;height:100%;object-fit:cover;display:block}
479
+ .sm-thumb--chip{display:grid;place-items:center;width:100%;height:100%;font-weight:600;color:var(--text-muted);font-size:var(--text-sm)}
480
+ .sm-mediacard__name{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
481
+ .sm-mediacard__meta{font-size:var(--text-sm);color:var(--text-muted)}
482
+ .sm-media{display:flex;flex-direction:column;gap:6px}
483
+ .sm-media__thumb{position:relative;overflow:hidden;border-radius:var(--radius-md);background:var(--surface-inset)}
484
+ .sm-media__thumb>img,.sm-media__thumb>video{position:absolute;max-width:none;display:block}
485
+ .sm-media__alt{font-size:var(--text-sm);color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
486
+ .sm-media__actions{display:flex;gap:6px}
487
+ .sm-media__chip{display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--surface-inset);border-radius:var(--radius-md)}
488
+ .sm-crop{display:grid;grid-template-columns:1fr 240px;gap:14px;min-height:0}
489
+ .sm-crop__stage{position:relative;user-select:none;touch-action:none;background:var(--surface-inset);border-radius:var(--radius-md);overflow:hidden}
490
+ .sm-crop__stage>img{display:block;width:100%;height:auto;pointer-events:none}
491
+ .sm-crop__shade{position:absolute;inset:0;background:rgba(0,0,0,.45);pointer-events:none}
492
+ .sm-crop__rect{position:absolute;outline:1.5px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.4);cursor:move;background:transparent}
493
+ .sm-crop__handle{position:absolute;width:12px;height:12px;background:#fff;border:1px solid rgba(0,0,0,.4);border-radius:2px}
494
+ .sm-crop__handle--nw{left:-6px;top:-6px;cursor:nwse-resize}.sm-crop__handle--ne{right:-6px;top:-6px;cursor:nesw-resize}
495
+ .sm-crop__handle--sw{left:-6px;bottom:-6px;cursor:nesw-resize}.sm-crop__handle--se{right:-6px;bottom:-6px;cursor:nwse-resize}
496
+ .sm-crop__hotspot{position:absolute;width:22px;height:22px;margin:-11px 0 0 -11px;border-radius:50%;border:2px solid #fff;background:rgba(0,56,177,.55);cursor:grab;box-shadow:0 0 0 1px rgba(0,0,0,.4)}
497
+ .sm-crop__side{display:flex;flex-direction:column;gap:10px}
498
+ .sm-crop__preview{position:relative;overflow:hidden;border-radius:var(--radius-md);background:var(--surface-inset)}
499
+ .sm-crop__preview>img{position:absolute;max-width:none;display:block}
500
+ .sm-mediascreen{display:grid;grid-template-columns:1fr 300px;flex:1;min-height:0}
501
+ .sm-mediascreen__grid{padding:12px;overflow:auto}
502
+ .sm-mediascreen__inspector{border-left:var(--border);padding:14px;overflow:auto;display:flex;flex-direction:column;gap:10px}
503
+ .sm-facts{display:grid;grid-template-columns:auto 1fr;gap:4px 10px;font-size:var(--text-sm);color:var(--text-muted)}
504
+ .sm-usage{display:flex;flex-direction:column;gap:4px}
396
505
  `;
397
506
  /** Where the theme toggle's choice is remembered, browser-side only. */
398
507
  export const THEME_KEY = "smoodly-theme";
@@ -1,3 +1,8 @@
1
+ // MVP validation, keyed by inert descriptor data (the design rule: logic
2
+ // lives admin/server-side). Entry saves validate; page drafts do NOT
3
+ // (drafts must save freely) — publish validates every node. Objects and
4
+ // lists recurse; error paths are dotted (`cta.href`, `points.2.heading`).
5
+ import { hasText } from "./richtext-doc.js";
1
6
  const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
2
7
  const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
3
8
  const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
@@ -11,6 +16,17 @@ function validateValue(path, value, d, errors) {
11
16
  errors.push({ field: path, message: "Required." });
12
17
  return;
13
18
  }
19
+ if (d.type === "richtext" && !hasText(value)) {
20
+ if (!mayBeAbsent(d))
21
+ errors.push({ field: path, message: "Required." });
22
+ return;
23
+ }
24
+ if (d.type === "image" || d.type === "video" || d.type === "file") {
25
+ if (typeof value !== "object" || Array.isArray(value) || typeof value.asset !== "string") {
26
+ errors.push({ field: path, message: "Must be an uploaded file." });
27
+ }
28
+ return;
29
+ }
14
30
  if (d.type === "list") {
15
31
  if (!Array.isArray(value)) {
16
32
  errors.push({ field: path, message: "Must be a list." });
@@ -0,0 +1,86 @@
1
+ import type { AssetKind, AssetMeta, AssetRecord } from "./media.ts";
2
+ import { MemoryRefIndex } from "./ref-index.ts";
3
+ export type UploadTicket = {
4
+ id: string;
5
+ uploadUrl: string;
6
+ token: string;
7
+ };
8
+ export type FinishInput = {
9
+ width?: number;
10
+ height?: number;
11
+ duration?: number;
12
+ posterId?: string;
13
+ };
14
+ export type AssetQuery = {
15
+ kind?: AssetKind;
16
+ search?: string;
17
+ cursor?: string;
18
+ limit?: number;
19
+ };
20
+ export type AssetPage = {
21
+ items: AssetRecord[];
22
+ nextCursor: string | null;
23
+ };
24
+ export type BeginUploadInput = {
25
+ filename: string;
26
+ mime: string;
27
+ size: number;
28
+ createdBy?: string;
29
+ };
30
+ export type CreateAssetInput = BeginUploadInput & AssetMeta & {
31
+ bytes: Uint8Array;
32
+ width?: number;
33
+ height?: number;
34
+ duration?: number;
35
+ posterId?: string;
36
+ };
37
+ export declare const PAGE_SIZE = 60;
38
+ export interface AssetStore {
39
+ /** A pending row and a signed upload URL for `<space>/<id>.<ext>`. */
40
+ beginUpload(input: BeginUploadInput): Promise<UploadTicket>;
41
+ /** Marks the row ready with what the browser measured. */
42
+ finishUpload(id: string, dims: FinishInput): Promise<AssetRecord>;
43
+ /** Server-side: bytes in, a ready row out (the seed, scripts). */
44
+ create(input: CreateAssetInput): Promise<AssetRecord>;
45
+ /** Any status — the widget shows a pending row as "uploading". */
46
+ get(id: string): Promise<AssetRecord | null>;
47
+ /** Ready rows only, keyed by id — the resolver's fetcher. */
48
+ getMany(ids: string[]): Promise<Record<string, AssetRecord>>;
49
+ /** Ready rows, newest first, `PAGE_SIZE` per page; `cursor` is the last row's createdAt. */
50
+ list(query?: AssetQuery): Promise<AssetPage>;
51
+ update(id: string, meta: AssetMeta): Promise<AssetRecord>;
52
+ /** Refused while any refs row points at the asset; a video's poster goes with it. Idempotent. */
53
+ delete(id: string): Promise<void>;
54
+ }
55
+ export declare const assetErrors: {
56
+ notFound: (id: string) => Error;
57
+ notPending: (id: string) => Error;
58
+ referenced: (id: string, n: number) => Error;
59
+ };
60
+ export declare const kindOf: (mime: string) => AssetKind;
61
+ /** The object's extension: the filename's when it has a sane one, else the mime's, else "bin". */
62
+ export declare function extensionFor(filename: string, mime: string): string;
63
+ export declare const objectPath: (space: string, id: string, filename: string, mime: string) => string;
64
+ /** The public URL; an SVG carries ?download= so the bucket origin never renders it. */
65
+ export declare const withDownload: (url: string, mime: string) => string;
66
+ export declare class MemoryAssetStore implements AssetStore {
67
+ private refs;
68
+ private rows;
69
+ private objects;
70
+ private baseUrl;
71
+ private space;
72
+ constructor(refs?: MemoryRefIndex, options?: {
73
+ baseUrl?: string;
74
+ space?: string;
75
+ });
76
+ private url;
77
+ private row;
78
+ beginUpload(input: BeginUploadInput): Promise<UploadTicket>;
79
+ finishUpload(id: string, dims: FinishInput): Promise<AssetRecord>;
80
+ create(input: CreateAssetInput): Promise<AssetRecord>;
81
+ get(id: string): Promise<AssetRecord | null>;
82
+ getMany(ids: string[]): Promise<Record<string, AssetRecord>>;
83
+ list(query?: AssetQuery): Promise<AssetPage>;
84
+ update(id: string, meta: AssetMeta): Promise<AssetRecord>;
85
+ delete(id: string): Promise<void>;
86
+ }
@@ -0,0 +1,141 @@
1
+ import { MemoryRefIndex } from "./ref-index.js";
2
+ import { DEFAULT_SPACE_ID } from "./sql-space.js";
3
+ import { ASSET_COLLECTION } from "./media.js";
4
+ export const PAGE_SIZE = 60;
5
+ export const assetErrors = {
6
+ notFound: (id) => new Error(`smoodly: no asset "${id}".`),
7
+ notPending: (id) => new Error(`smoodly: asset "${id}" is not pending.`),
8
+ referenced: (id, n) => new Error(`smoodly: "asset/${id}" is referenced by ${n} document(s) — remove those references first.`),
9
+ };
10
+ export const kindOf = (mime) => mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file";
11
+ const BY_MIME = {
12
+ "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "image/avif": "avif", "image/gif": "gif",
13
+ "image/svg+xml": "svg", "video/mp4": "mp4", "video/webm": "webm", "application/pdf": "pdf",
14
+ };
15
+ /** The object's extension: the filename's when it has a sane one, else the mime's, else "bin". */
16
+ export function extensionFor(filename, mime) {
17
+ const m = /\.([a-z0-9]{1,8})$/i.exec(filename);
18
+ if (m)
19
+ return m[1].toLowerCase();
20
+ return BY_MIME[mime] ?? "bin";
21
+ }
22
+ export const objectPath = (space, id, filename, mime) => `${space}/${id}.${extensionFor(filename, mime)}`;
23
+ /** The public URL; an SVG carries ?download= so the bucket origin never renders it. */
24
+ export const withDownload = (url, mime) => (mime === "image/svg+xml" ? `${url}?download=` : url);
25
+ const lower = (s) => (s ?? "").toLowerCase();
26
+ export class MemoryAssetStore {
27
+ constructor(refs = new MemoryRefIndex(), options = {}) {
28
+ this.refs = refs;
29
+ this.rows = new Map();
30
+ this.objects = new Map();
31
+ this.baseUrl = options.baseUrl ?? "http://localhost/media";
32
+ this.space = options.space ?? DEFAULT_SPACE_ID;
33
+ }
34
+ url(path, mime) {
35
+ return withDownload(`${this.baseUrl}/${path}`, mime);
36
+ }
37
+ row(input, status) {
38
+ const id = crypto.randomUUID();
39
+ const path = objectPath(this.space, id, input.filename, input.mime);
40
+ const now = Date.now();
41
+ return {
42
+ id, kind: kindOf(input.mime), status, path, mime: input.mime, size: input.size,
43
+ width: null, height: null, duration: null, posterId: null, filename: input.filename,
44
+ title: null, alt: null, caption: null, credit: null, url: this.url(path, input.mime), createdAt: now, updatedAt: now,
45
+ };
46
+ }
47
+ async beginUpload(input) {
48
+ const row = this.row(input, "pending");
49
+ this.rows.set(row.id, row);
50
+ return { id: row.id, uploadUrl: `memory://upload/${row.path}`, token: "memory" };
51
+ }
52
+ async finishUpload(id, dims) {
53
+ const row = this.rows.get(id);
54
+ if (!row)
55
+ throw assetErrors.notFound(id);
56
+ if (row.status !== "pending")
57
+ throw assetErrors.notPending(id);
58
+ const next = {
59
+ ...row, status: "ready", width: dims.width ?? null, height: dims.height ?? null,
60
+ duration: dims.duration ?? null, posterId: dims.posterId ?? null, updatedAt: Date.now(),
61
+ };
62
+ this.rows.set(id, next);
63
+ return structuredClone(next);
64
+ }
65
+ async create(input) {
66
+ const row = {
67
+ ...this.row(input, "ready"),
68
+ width: input.width ?? null, height: input.height ?? null, duration: input.duration ?? null,
69
+ posterId: input.posterId ?? null, title: input.title ?? null, alt: input.alt ?? null,
70
+ caption: input.caption ?? null, credit: input.credit ?? null,
71
+ };
72
+ // Distinct createdAt per row keeps "newest first" and the cursor honest
73
+ // when two creates land in one millisecond.
74
+ const last = [...this.rows.values()].reduce((m, r) => Math.max(m, r.createdAt), 0);
75
+ if (row.createdAt <= last) {
76
+ row.createdAt = last + 1;
77
+ row.updatedAt = row.createdAt;
78
+ }
79
+ this.rows.set(row.id, row);
80
+ this.objects.set(row.path, input.bytes);
81
+ return structuredClone(row);
82
+ }
83
+ async get(id) {
84
+ const row = this.rows.get(id);
85
+ return row ? structuredClone(row) : null;
86
+ }
87
+ async getMany(ids) {
88
+ const out = {};
89
+ for (const id of ids) {
90
+ const row = this.rows.get(id);
91
+ if (row?.status === "ready")
92
+ out[id] = structuredClone(row);
93
+ }
94
+ return out;
95
+ }
96
+ async list(query = {}) {
97
+ const limit = query.limit ?? PAGE_SIZE;
98
+ const q = lower(query.search);
99
+ const cursor = query.cursor ? Number(query.cursor) : Infinity;
100
+ const all = [...this.rows.values()]
101
+ .filter((r) => r.status === "ready")
102
+ .filter((r) => !query.kind || r.kind === query.kind)
103
+ .filter((r) => !q || lower(r.title).includes(q) || lower(r.filename).includes(q) || lower(r.alt).includes(q))
104
+ .filter((r) => r.createdAt < cursor)
105
+ .sort((a, b) => b.createdAt - a.createdAt);
106
+ const items = all.slice(0, limit).map((r) => structuredClone(r));
107
+ return { items, nextCursor: all.length > limit ? String(items[items.length - 1].createdAt) : null };
108
+ }
109
+ async update(id, meta) {
110
+ const row = this.rows.get(id);
111
+ if (!row)
112
+ throw assetErrors.notFound(id);
113
+ const next = {
114
+ ...row,
115
+ ...(meta.title !== undefined ? { title: meta.title } : {}),
116
+ ...(meta.alt !== undefined ? { alt: meta.alt } : {}),
117
+ ...(meta.caption !== undefined ? { caption: meta.caption } : {}),
118
+ ...(meta.credit !== undefined ? { credit: meta.credit } : {}),
119
+ updatedAt: Math.max(Date.now(), row.updatedAt),
120
+ };
121
+ this.rows.set(id, next);
122
+ return structuredClone(next);
123
+ }
124
+ async delete(id) {
125
+ const row = this.rows.get(id);
126
+ if (!row)
127
+ return;
128
+ const usage = this.refs.to(ASSET_COLLECTION, id);
129
+ if (usage.length > 0)
130
+ throw assetErrors.referenced(id, usage.length);
131
+ if (row.posterId) {
132
+ const poster = this.rows.get(row.posterId);
133
+ if (poster) {
134
+ this.rows.delete(poster.id);
135
+ this.objects.delete(poster.path);
136
+ }
137
+ }
138
+ this.rows.delete(id);
139
+ this.objects.delete(row.path);
140
+ }
141
+ }
@@ -53,17 +53,3 @@ export declare function collection<F extends BuilderMap>(input: {
53
53
  };
54
54
  versions?: boolean;
55
55
  }): CollectionSchema<F>;
56
- export type Toolset = {
57
- marks?: string[];
58
- nodes?: string[];
59
- elements?: string[];
60
- };
61
- export declare function toolset(input: {
62
- marks?: string[];
63
- nodes?: string[];
64
- elements?: {
65
- schema: {
66
- name: string;
67
- };
68
- }[];
69
- }): Toolset;
@@ -5,6 +5,8 @@ export const collectionDepth = (c) => c.tree?.depth ?? 1;
5
5
  export function collection(input) {
6
6
  if (!input?.name)
7
7
  throw new Error("smoodly: a collection requires a name.");
8
+ if (["shared", "asset", "media"].includes(input.name))
9
+ throw new Error(`smoodly: "${input.name}" is a reserved name.`);
8
10
  if (input.tree !== undefined && (!Number.isInteger(input.tree.depth) || input.tree.depth < 1)) {
9
11
  throw new Error(`smoodly: collection "${input.name}" declares an invalid tree depth.`);
10
12
  }
@@ -45,13 +47,3 @@ export function collection(input) {
45
47
  admin: input.admin,
46
48
  };
47
49
  }
48
- export function toolset(input) {
49
- const out = {};
50
- if (input.marks)
51
- out.marks = input.marks;
52
- if (input.nodes)
53
- out.nodes = input.nodes;
54
- if (input.elements)
55
- out.elements = input.elements.map((e) => e.schema.name);
56
- return out;
57
- }
package/dist/config.d.ts CHANGED
@@ -50,6 +50,11 @@ export type SmoodlyConfig = {
50
50
  database?: {
51
51
  provider: string;
52
52
  };
53
+ /** Upload limits (spec 2026-09-08 media §10): bytes per file and the accepted mime types. */
54
+ media?: {
55
+ maxSize?: number;
56
+ accept?: string[];
57
+ };
53
58
  };
54
59
  export type ResolvedConfig = SmoodlyConfig & {
55
60
  homePageSlug: string;
@@ -57,6 +62,13 @@ export type ResolvedConfig = SmoodlyConfig & {
57
62
  pageDepth: number;
58
63
  /** The section's styles with the config standard set merged in (section keys win). */
59
64
  resolvedStyles(sectionName: string): Record<string, Descriptor>;
65
+ media: {
66
+ maxSize: number;
67
+ accept: string[];
68
+ };
60
69
  };
70
+ /** `shared`: the placement node type and an admin route; `asset`: the refs
71
+ * target of every media edge; `media`: the admin's Media screen route. */
72
+ export declare const RESERVED_NAMES: readonly string[];
61
73
  export declare function defineConfig(config: SmoodlyConfig): ResolvedConfig;
62
74
  export {};
package/dist/config.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { assertSegment, fixedPageSegments, perLocaleSegments } from "./paths.js";
2
- const RESERVED_NAME = "shared"; // the placement node type and the admin route
2
+ import { DEFAULT_MEDIA } from "./media.js";
3
+ /** `shared`: the placement node type and an admin route; `asset`: the refs
4
+ * target of every media edge; `media`: the admin's Media screen route. */
5
+ export const RESERVED_NAMES = ["shared", "asset", "media"];
6
+ const isReserved = (name) => RESERVED_NAMES.includes(name);
3
7
  function assertUniqueNames(config) {
4
8
  const seen = new Set();
5
9
  const sectionNameList = (config.registry.sections ?? []).map((s) => s.schema.name);
@@ -11,8 +15,8 @@ function assertUniqueNames(config) {
11
15
  ...(config.registry.pages ?? []).filter((p) => p.schema.kind !== "entryPage").map((p) => p.schema.name),
12
16
  ];
13
17
  for (const name of all) {
14
- if (name === RESERVED_NAME)
15
- throw new Error(`smoodly: "${RESERVED_NAME}" is a reserved name.`);
18
+ if (isReserved(name))
19
+ throw new Error(`smoodly: "${name}" is a reserved name.`);
16
20
  if (seen.has(name))
17
21
  throw new Error(`smoodly: duplicate registered name "${name}".`);
18
22
  seen.add(name);
@@ -29,8 +33,8 @@ function assertUniqueNames(config) {
29
33
  const sharedSeen = new Set();
30
34
  for (const item of config.registry.shared ?? []) {
31
35
  const ownSection = item.name === item.section && sectionNames.has(item.name);
32
- if (item.name === RESERVED_NAME)
33
- throw new Error(`smoodly: "${RESERVED_NAME}" is a reserved name.`);
36
+ if (isReserved(item.name))
37
+ throw new Error(`smoodly: "${item.name}" is a reserved name.`);
34
38
  if (sharedSeen.has(item.name) || (seen.has(item.name) && !ownSection)) {
35
39
  throw new Error(`smoodly: duplicate registered name "${item.name}".`);
36
40
  }
@@ -143,10 +147,18 @@ export function defineConfig(config) {
143
147
  }
144
148
  const standard = Object.fromEntries(Object.entries(config.styles?.sections ?? {}).map(([k, b]) => [k, b.descriptor]));
145
149
  const byName = new Map((config.registry.sections ?? []).map((s) => [s.schema.name, s.schema]));
150
+ const media = {
151
+ maxSize: config.media?.maxSize ?? DEFAULT_MEDIA.maxSize,
152
+ accept: config.media?.accept ? [...config.media.accept] : [...DEFAULT_MEDIA.accept],
153
+ };
154
+ if (!Number.isInteger(media.maxSize) || media.maxSize <= 0) {
155
+ throw new Error("smoodly: media.maxSize must be a positive integer of bytes.");
156
+ }
146
157
  return {
147
158
  ...config,
148
159
  homePageSlug,
149
160
  pageDepth: depth,
161
+ media,
150
162
  resolvedStyles(sectionName) {
151
163
  const schema = byName.get(sectionName);
152
164
  if (!schema)
package/dist/fields.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type Toolset } from "./toolset.ts";
2
+ import { type ResolvedFile, type ResolvedImage, type ResolvedVideo } from "./media.ts";
1
3
  export type Descriptor = {
2
4
  type: string;
3
5
  [key: string]: unknown;
@@ -48,18 +50,15 @@ export declare const f: {
48
50
  }) => FieldBuilder<string, {}>;
49
51
  date: () => FieldBuilder<string, {}>;
50
52
  number: () => FieldBuilder<number, {}>;
51
- image: () => FieldBuilder<{
52
- url: string;
53
- alt?: string;
54
- }, {}>;
55
- video: () => FieldBuilder<{
56
- url: string;
57
- }, {}>;
58
- file: () => FieldBuilder<{
59
- url: string;
60
- }, {}>;
53
+ /** `aspect`: one `"w:h"` or several the crop tool may lock to — a per-use
54
+ * constraint, so the component knows its box before the image loads. */
55
+ image: (opts?: {
56
+ aspect?: string | string[];
57
+ }) => FieldBuilder<ResolvedImage, {}>;
58
+ video: () => FieldBuilder<ResolvedVideo, {}>;
59
+ file: () => FieldBuilder<ResolvedFile, {}>;
61
60
  richtext: (opts?: {
62
- toolset?: unknown;
61
+ toolset?: Toolset;
63
62
  }) => FieldBuilder<unknown, {}>;
64
63
  dataAttributes: () => FieldBuilder<Record<string, string>, {}>;
65
64
  select: <const O extends readonly string[]>(options: O) => FieldBuilder<O[number], {}>;
package/dist/fields.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Field builders — inert descriptors by design rule (see DESIGN.md §3):
2
2
  // f.text().max(40) is plain data; validation logic lives server/admin-side,
3
3
  // keyed by descriptor type. Chaining is immutable.
4
+ import { DEFAULT_TOOLSET } from "./toolset.js";
5
+ import { parseAspect } from "./media.js";
4
6
  function builder(descriptor) {
5
7
  const chain = (patch) => builder({ ...descriptor, ...patch });
6
8
  return {
@@ -55,10 +57,22 @@ export const f = {
55
57
  slug: (opts) => builder({ type: "slug", ...(opts?.from ? { from: opts.from } : {}) }),
56
58
  date: () => builder({ type: "date" }),
57
59
  number: () => builder({ type: "number" }),
58
- image: () => builder({ type: "image" }),
60
+ /** `aspect`: one `"w:h"` or several the crop tool may lock to — a per-use
61
+ * constraint, so the component knows its box before the image loads. */
62
+ image: (opts) => {
63
+ const aspect = opts?.aspect;
64
+ if (aspect !== undefined) {
65
+ const list = Array.isArray(aspect) ? aspect : [aspect];
66
+ if (list.length === 0)
67
+ throw new Error('smoodly: f.image aspect must name at least one "w:h" ratio.');
68
+ for (const a of list)
69
+ parseAspect(a);
70
+ }
71
+ return builder({ type: "image", ...(aspect !== undefined ? { aspect } : {}) });
72
+ },
59
73
  video: () => builder({ type: "video" }),
60
74
  file: () => builder({ type: "file" }),
61
- richtext: (opts) => builder({ type: "richtext", ...(opts?.toolset !== undefined ? { toolset: opts.toolset } : {}) }),
75
+ richtext: (opts) => builder({ type: "richtext", toolset: opts?.toolset ?? DEFAULT_TOOLSET }),
62
76
  dataAttributes: () => builder({ type: "dataAttributes" }),
63
77
  select: (options) => builder({ type: "select", options: [...options] }),
64
78
  object: (shape) => {
@@ -0,0 +1,18 @@
1
+ import type { CSSProperties, ReactElement } from "react";
2
+ import type { ResolvedImage } from "../media.ts";
3
+ import type { ImageLoader } from "./loaders.ts";
4
+ export type SmoodlyImageProps = {
5
+ image: ResolvedImage;
6
+ /** Overrides the value's aspect for this placement (`"21:9"`). */
7
+ aspect?: string;
8
+ sizes?: string;
9
+ priority?: boolean;
10
+ loading?: "eager" | "lazy";
11
+ className?: string;
12
+ style?: CSSProperties;
13
+ /** Overrides the resolved alt (rarely right — the editor owns it). */
14
+ alt?: string;
15
+ loader?: ImageLoader;
16
+ unoptimized?: boolean;
17
+ };
18
+ export declare function SmoodlyImage({ image, aspect, sizes, priority, loading, className, style, alt, loader, unoptimized }: SmoodlyImageProps): ReactElement;