@momentumcms/admin 0.5.1 → 0.5.2

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 (24) hide show
  1. package/fesm2022/{momentumcms-admin-array-field.component-pqA3_nC8.mjs → momentumcms-admin-array-field.component-DH6vaHO-.mjs} +2 -2
  2. package/fesm2022/{momentumcms-admin-array-field.component-pqA3_nC8.mjs.map → momentumcms-admin-array-field.component-DH6vaHO-.mjs.map} +1 -1
  3. package/fesm2022/{momentumcms-admin-blocks-field.component-88TEhVm4.mjs → momentumcms-admin-blocks-field.component-BxJRfiV3.mjs} +2 -2
  4. package/fesm2022/{momentumcms-admin-blocks-field.component-88TEhVm4.mjs.map → momentumcms-admin-blocks-field.component-BxJRfiV3.mjs.map} +1 -1
  5. package/fesm2022/{momentumcms-admin-collapsible-field.component-D5Jc8h2Q.mjs → momentumcms-admin-collapsible-field.component-CsjYCkGw.mjs} +2 -2
  6. package/fesm2022/{momentumcms-admin-collapsible-field.component-D5Jc8h2Q.mjs.map → momentumcms-admin-collapsible-field.component-CsjYCkGw.mjs.map} +1 -1
  7. package/fesm2022/{momentumcms-admin-global-edit.page-D4QNE5AM.mjs → momentumcms-admin-global-edit.page-CmLAM17O.mjs} +2 -2
  8. package/fesm2022/{momentumcms-admin-global-edit.page-D4QNE5AM.mjs.map → momentumcms-admin-global-edit.page-CmLAM17O.mjs.map} +1 -1
  9. package/fesm2022/{momentumcms-admin-group-field.component-Cenc5zMW.mjs → momentumcms-admin-group-field.component-CMKcqfjy.mjs} +2 -2
  10. package/fesm2022/{momentumcms-admin-group-field.component-Cenc5zMW.mjs.map → momentumcms-admin-group-field.component-CMKcqfjy.mjs.map} +1 -1
  11. package/fesm2022/{momentumcms-admin-momentumcms-admin-5WigESOC.mjs → momentumcms-admin-momentumcms-admin-BTZEdMNj.mjs} +782 -23
  12. package/fesm2022/momentumcms-admin-momentumcms-admin-BTZEdMNj.mjs.map +1 -0
  13. package/fesm2022/{momentumcms-admin-relationship-field.component-DlCdpcRy.mjs → momentumcms-admin-relationship-field.component-DNZUCENa.mjs} +8 -3
  14. package/fesm2022/{momentumcms-admin-relationship-field.component-DlCdpcRy.mjs.map → momentumcms-admin-relationship-field.component-DNZUCENa.mjs.map} +1 -1
  15. package/fesm2022/{momentumcms-admin-rich-text-field.component-BUziCgyn.mjs → momentumcms-admin-rich-text-field.component-BVAQkX3O.mjs} +2 -2
  16. package/fesm2022/{momentumcms-admin-rich-text-field.component-BUziCgyn.mjs.map → momentumcms-admin-rich-text-field.component-BVAQkX3O.mjs.map} +1 -1
  17. package/fesm2022/{momentumcms-admin-row-field.component-fFTcYU-P.mjs → momentumcms-admin-row-field.component-0F6cnUK_.mjs} +2 -2
  18. package/fesm2022/{momentumcms-admin-row-field.component-fFTcYU-P.mjs.map → momentumcms-admin-row-field.component-0F6cnUK_.mjs.map} +1 -1
  19. package/fesm2022/{momentumcms-admin-tabs-field.component-D_T_JZej.mjs → momentumcms-admin-tabs-field.component-qYlbl8Ud.mjs} +2 -2
  20. package/fesm2022/{momentumcms-admin-tabs-field.component-D_T_JZej.mjs.map → momentumcms-admin-tabs-field.component-qYlbl8Ud.mjs.map} +1 -1
  21. package/fesm2022/momentumcms-admin.mjs +1 -1
  22. package/package.json +1 -1
  23. package/types/momentumcms-admin.d.ts +121 -58
  24. package/fesm2022/momentumcms-admin-momentumcms-admin-5WigESOC.mjs.map +0 -1
@@ -1134,7 +1134,7 @@ function momentumAdminRoutes(configOrOptions) {
1134
1134
  // Global edit
1135
1135
  {
1136
1136
  path: 'globals/:slug',
1137
- loadComponent: () => import('./momentumcms-admin-global-edit.page-D4QNE5AM.mjs').then((m) => m.GlobalEditPage),
1137
+ loadComponent: () => import('./momentumcms-admin-global-edit.page-CmLAM17O.mjs').then((m) => m.GlobalEditPage),
1138
1138
  canDeactivate: [unsavedChangesGuard],
1139
1139
  },
1140
1140
  // Plugin-registered routes
@@ -4837,6 +4837,427 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
4837
4837
  }]
4838
4838
  }], ctorParameters: () => [], propDecorators: { fileInputRef: [{ type: i0.ViewChild, args: ['fileInput', { isSignal: true }] }], uploadConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "uploadConfig", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], pendingFile: [{ type: i0.Input, args: [{ isSignal: true, alias: "pendingFile", required: false }] }], isUploading: [{ type: i0.Input, args: [{ isSignal: true, alias: "isUploading", required: false }] }], uploadProgress: [{ type: i0.Input, args: [{ isSignal: true, alias: "uploadProgress", required: false }] }], error: [{ type: i0.Input, args: [{ isSignal: true, alias: "error", required: false }] }], existingMedia: [{ type: i0.Input, args: [{ isSignal: true, alias: "existingMedia", required: false }] }], fileSelected: [{ type: i0.Output, args: ["fileSelected"] }], fileRemoved: [{ type: i0.Output, args: ["fileRemoved"] }] } });
4839
4839
 
4840
+ /**
4841
+ * Normalize a click position within a container to a 0-1 focal point.
4842
+ */
4843
+ function normalizeFocalPoint(click, container) {
4844
+ if (container.width <= 0 || container.height <= 0) {
4845
+ return { x: 0.5, y: 0.5 };
4846
+ }
4847
+ return clampFocalPoint({
4848
+ x: click.x / container.width,
4849
+ y: click.y / container.height,
4850
+ });
4851
+ }
4852
+ /**
4853
+ * Clamp a focal point to the valid 0-1 range on both axes.
4854
+ */
4855
+ function clampFocalPoint(point) {
4856
+ return {
4857
+ x: Math.max(0, Math.min(1, point.x)),
4858
+ y: Math.max(0, Math.min(1, point.y)),
4859
+ };
4860
+ }
4861
+ /**
4862
+ * Convert a normalized focal point to a CSS `object-position` string.
4863
+ *
4864
+ * @example focalPointToCssPosition({ x: 0.25, y: 0.75 }) → '25% 75%'
4865
+ */
4866
+ function focalPointToCssPosition(point) {
4867
+ const fp = point ?? { x: 0.5, y: 0.5 };
4868
+ return `${Math.round(fp.x * 100)}% ${Math.round(fp.y * 100)}%`;
4869
+ }
4870
+
4871
+ /**
4872
+ * Calculate the crop region for a cover-fit preview.
4873
+ * Same math as the server-side crop-calculator, but for browser-side preview.
4874
+ */
4875
+ function calculatePreviewCrop(source, target, focalPoint) {
4876
+ const fp = focalPoint ?? { x: 0.5, y: 0.5 };
4877
+ const scale = Math.max(target.width / source.width, target.height / source.height);
4878
+ const cropW = Math.round(target.width / scale);
4879
+ const cropH = Math.round(target.height / scale);
4880
+ const focalX = fp.x * source.width;
4881
+ const focalY = fp.y * source.height;
4882
+ let x = Math.round(focalX - cropW / 2);
4883
+ let y = Math.round(focalY - cropH / 2);
4884
+ x = Math.max(0, Math.min(x, source.width - cropW));
4885
+ y = Math.max(0, Math.min(y, source.height - cropH));
4886
+ return { x, y, width: cropW, height: cropH };
4887
+ }
4888
+
4889
+ /**
4890
+ * Focal Point Picker Component
4891
+ *
4892
+ * Displays an image with an interactive crosshair overlay.
4893
+ * Click anywhere on the image to set the focal point.
4894
+ * Optionally shows crop preview outlines for configured image sizes.
4895
+ *
4896
+ * @example
4897
+ * ```html
4898
+ * <mcms-focal-point-picker
4899
+ * [imageUrl]="mediaUrl"
4900
+ * [focalPoint]="{ x: 0.5, y: 0.5 }"
4901
+ * [imageSizes]="configuredSizes"
4902
+ * [naturalWidth]="800"
4903
+ * [naturalHeight]="600"
4904
+ * (focalPointChange)="onFocalPointChange($event)"
4905
+ * />
4906
+ * ```
4907
+ */
4908
+ class FocalPointPickerComponent {
4909
+ imageElRef = viewChild('imageEl', ...(ngDevMode ? [{ debugName: "imageElRef" }] : []));
4910
+ /** URL of the image to display */
4911
+ imageUrl = input.required(...(ngDevMode ? [{ debugName: "imageUrl" }] : []));
4912
+ /** Current focal point (0-1 normalized) */
4913
+ focalPoint = input({ x: 0.5, y: 0.5 }, ...(ngDevMode ? [{ debugName: "focalPoint" }] : []));
4914
+ /** Alt text for the image */
4915
+ alt = input('', ...(ngDevMode ? [{ debugName: "alt" }] : []));
4916
+ /** Natural width of the source image (for crop preview calculations) */
4917
+ naturalWidth = input(0, ...(ngDevMode ? [{ debugName: "naturalWidth" }] : []));
4918
+ /** Natural height of the source image (for crop preview calculations) */
4919
+ naturalHeight = input(0, ...(ngDevMode ? [{ debugName: "naturalHeight" }] : []));
4920
+ /** Configured image sizes for crop preview outlines */
4921
+ imageSizes = input([], ...(ngDevMode ? [{ debugName: "imageSizes" }] : []));
4922
+ /** Emitted when the focal point changes */
4923
+ focalPointChange = output();
4924
+ /** Focal point X as percentage */
4925
+ focalPointX = computed(() => Math.round(this.focalPoint().x * 100), ...(ngDevMode ? [{ debugName: "focalPointX" }] : []));
4926
+ /** Focal point Y as percentage */
4927
+ focalPointY = computed(() => Math.round(this.focalPoint().y * 100), ...(ngDevMode ? [{ debugName: "focalPointY" }] : []));
4928
+ /** CSS object-position string */
4929
+ cssPosition = computed(() => focalPointToCssPosition(this.focalPoint()), ...(ngDevMode ? [{ debugName: "cssPosition" }] : []));
4930
+ /** Human-readable position label */
4931
+ positionLabel = computed(() => {
4932
+ const fp = this.focalPoint();
4933
+ return `${Math.round(fp.x * 100)}% x ${Math.round(fp.y * 100)}%`;
4934
+ }, ...(ngDevMode ? [{ debugName: "positionLabel" }] : []));
4935
+ /** Crop preview data for each configured size */
4936
+ cropPreviews = computed(() => {
4937
+ const w = this.naturalWidth();
4938
+ const h = this.naturalHeight();
4939
+ const fp = this.focalPoint();
4940
+ const sizes = this.imageSizes();
4941
+ if (!w || !h || sizes.length === 0)
4942
+ return [];
4943
+ return sizes
4944
+ .filter((s) => s.width && s.height && s.fit === 'cover')
4945
+ .map((s) => {
4946
+ const crop = calculatePreviewCrop({ width: w, height: h }, { width: s.width ?? 0, height: s.height ?? 0 }, fp);
4947
+ return {
4948
+ name: s.name,
4949
+ leftPct: (crop.x / w) * 100,
4950
+ topPct: (crop.y / h) * 100,
4951
+ widthPct: (crop.width / w) * 100,
4952
+ heightPct: (crop.height / h) * 100,
4953
+ };
4954
+ });
4955
+ }, ...(ngDevMode ? [{ debugName: "cropPreviews" }] : []));
4956
+ /**
4957
+ * Handle click on the image overlay.
4958
+ */
4959
+ onClick(event) {
4960
+ const el = this.imageElRef();
4961
+ if (!el)
4962
+ return;
4963
+ const rect = el.nativeElement.getBoundingClientRect();
4964
+ if (rect.width <= 0 || rect.height <= 0)
4965
+ return;
4966
+ const x = event.clientX - rect.left;
4967
+ const y = event.clientY - rect.top;
4968
+ const fp = normalizeFocalPoint({ x, y }, { width: rect.width, height: rect.height });
4969
+ this.focalPointChange.emit(fp);
4970
+ }
4971
+ /**
4972
+ * Reset focal point to center.
4973
+ */
4974
+ onResetCenter() {
4975
+ this.focalPointChange.emit({ x: 0.5, y: 0.5 });
4976
+ }
4977
+ /**
4978
+ * Nudge focal point by a small amount (keyboard navigation).
4979
+ */
4980
+ onNudge(dx, dy, event) {
4981
+ event.preventDefault();
4982
+ const fp = this.focalPoint();
4983
+ const newFp = {
4984
+ x: Math.max(0, Math.min(1, fp.x + dx)),
4985
+ y: Math.max(0, Math.min(1, fp.y + dy)),
4986
+ };
4987
+ this.focalPointChange.emit(newFp);
4988
+ }
4989
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FocalPointPickerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4990
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: FocalPointPickerComponent, isStandalone: true, selector: "mcms-focal-point-picker", inputs: { imageUrl: { classPropertyName: "imageUrl", publicName: "imageUrl", isSignal: true, isRequired: true, transformFunction: null }, focalPoint: { classPropertyName: "focalPoint", publicName: "focalPoint", isSignal: true, isRequired: false, transformFunction: null }, alt: { classPropertyName: "alt", publicName: "alt", isSignal: true, isRequired: false, transformFunction: null }, naturalWidth: { classPropertyName: "naturalWidth", publicName: "naturalWidth", isSignal: true, isRequired: false, transformFunction: null }, naturalHeight: { classPropertyName: "naturalHeight", publicName: "naturalHeight", isSignal: true, isRequired: false, transformFunction: null }, imageSizes: { classPropertyName: "imageSizes", publicName: "imageSizes", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { focalPointChange: "focalPointChange" }, host: { classAttribute: "block" }, viewQueries: [{ propertyName: "imageElRef", first: true, predicate: ["imageEl"], descendants: true, isSignal: true }], ngImport: i0, template: `
4991
+ <div class="space-y-3">
4992
+ <div class="relative inline-block overflow-hidden rounded-lg border border-mcms-border">
4993
+ <!-- Image -->
4994
+ <img
4995
+ #imageEl
4996
+ [src]="imageUrl()"
4997
+ [alt]="alt() || 'Image with focal point selector'"
4998
+ class="block max-h-96 max-w-full"
4999
+ [style.object-position]="cssPosition()"
5000
+ />
5001
+
5002
+ <!-- Clickable overlay -->
5003
+ <div
5004
+ class="absolute inset-0 cursor-crosshair"
5005
+ role="button"
5006
+ tabindex="0"
5007
+ [attr.aria-label]="'Set focal point. Current position: ' + positionLabel()"
5008
+ (click)="onClick($event)"
5009
+ (keydown.enter)="onResetCenter()"
5010
+ (keydown.space)="onResetCenter()"
5011
+ (keydown.ArrowLeft)="onNudge(-0.05, 0, $event)"
5012
+ (keydown.ArrowRight)="onNudge(0.05, 0, $event)"
5013
+ (keydown.ArrowUp)="onNudge(0, -0.05, $event)"
5014
+ (keydown.ArrowDown)="onNudge(0, 0.05, $event)"
5015
+ >
5016
+ <!-- Crosshair lines -->
5017
+ <div
5018
+ class="pointer-events-none absolute h-px w-full bg-white/70"
5019
+ [style.top.%]="focalPointY()"
5020
+ ></div>
5021
+ <div
5022
+ class="pointer-events-none absolute w-px h-full bg-white/70"
5023
+ [style.left.%]="focalPointX()"
5024
+ ></div>
5025
+
5026
+ <!-- Focal point dot -->
5027
+ <div
5028
+ class="pointer-events-none absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-mcms-primary shadow-md"
5029
+ [style.left.%]="focalPointX()"
5030
+ [style.top.%]="focalPointY()"
5031
+ aria-hidden="true"
5032
+ ></div>
5033
+
5034
+ <!-- Crop preview outlines -->
5035
+ @for (preview of cropPreviews(); track preview.name) {
5036
+ <div
5037
+ class="pointer-events-none absolute border border-dashed border-yellow-400/60"
5038
+ [style.left.%]="preview.leftPct"
5039
+ [style.top.%]="preview.topPct"
5040
+ [style.width.%]="preview.widthPct"
5041
+ [style.height.%]="preview.heightPct"
5042
+ [attr.aria-label]="'Crop preview for ' + preview.name"
5043
+ >
5044
+ <span class="absolute -top-5 left-0 text-xs text-yellow-400 drop-shadow-sm">
5045
+ {{ preview.name }}
5046
+ </span>
5047
+ </div>
5048
+ }
5049
+ </div>
5050
+ </div>
5051
+
5052
+ <!-- Coordinates display -->
5053
+ <p class="text-xs text-mcms-muted-foreground" aria-live="polite">
5054
+ Focal point: {{ positionLabel() }}
5055
+ <button
5056
+ class="ml-2 underline hover:text-mcms-foreground"
5057
+ type="button"
5058
+ (click)="onResetCenter()"
5059
+ aria-label="Reset focal point to center"
5060
+ >
5061
+ Reset to center
5062
+ </button>
5063
+ </p>
5064
+ </div>
5065
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
5066
+ }
5067
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FocalPointPickerComponent, decorators: [{
5068
+ type: Component,
5069
+ args: [{
5070
+ selector: 'mcms-focal-point-picker',
5071
+ changeDetection: ChangeDetectionStrategy.OnPush,
5072
+ host: { class: 'block' },
5073
+ template: `
5074
+ <div class="space-y-3">
5075
+ <div class="relative inline-block overflow-hidden rounded-lg border border-mcms-border">
5076
+ <!-- Image -->
5077
+ <img
5078
+ #imageEl
5079
+ [src]="imageUrl()"
5080
+ [alt]="alt() || 'Image with focal point selector'"
5081
+ class="block max-h-96 max-w-full"
5082
+ [style.object-position]="cssPosition()"
5083
+ />
5084
+
5085
+ <!-- Clickable overlay -->
5086
+ <div
5087
+ class="absolute inset-0 cursor-crosshair"
5088
+ role="button"
5089
+ tabindex="0"
5090
+ [attr.aria-label]="'Set focal point. Current position: ' + positionLabel()"
5091
+ (click)="onClick($event)"
5092
+ (keydown.enter)="onResetCenter()"
5093
+ (keydown.space)="onResetCenter()"
5094
+ (keydown.ArrowLeft)="onNudge(-0.05, 0, $event)"
5095
+ (keydown.ArrowRight)="onNudge(0.05, 0, $event)"
5096
+ (keydown.ArrowUp)="onNudge(0, -0.05, $event)"
5097
+ (keydown.ArrowDown)="onNudge(0, 0.05, $event)"
5098
+ >
5099
+ <!-- Crosshair lines -->
5100
+ <div
5101
+ class="pointer-events-none absolute h-px w-full bg-white/70"
5102
+ [style.top.%]="focalPointY()"
5103
+ ></div>
5104
+ <div
5105
+ class="pointer-events-none absolute w-px h-full bg-white/70"
5106
+ [style.left.%]="focalPointX()"
5107
+ ></div>
5108
+
5109
+ <!-- Focal point dot -->
5110
+ <div
5111
+ class="pointer-events-none absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-mcms-primary shadow-md"
5112
+ [style.left.%]="focalPointX()"
5113
+ [style.top.%]="focalPointY()"
5114
+ aria-hidden="true"
5115
+ ></div>
5116
+
5117
+ <!-- Crop preview outlines -->
5118
+ @for (preview of cropPreviews(); track preview.name) {
5119
+ <div
5120
+ class="pointer-events-none absolute border border-dashed border-yellow-400/60"
5121
+ [style.left.%]="preview.leftPct"
5122
+ [style.top.%]="preview.topPct"
5123
+ [style.width.%]="preview.widthPct"
5124
+ [style.height.%]="preview.heightPct"
5125
+ [attr.aria-label]="'Crop preview for ' + preview.name"
5126
+ >
5127
+ <span class="absolute -top-5 left-0 text-xs text-yellow-400 drop-shadow-sm">
5128
+ {{ preview.name }}
5129
+ </span>
5130
+ </div>
5131
+ }
5132
+ </div>
5133
+ </div>
5134
+
5135
+ <!-- Coordinates display -->
5136
+ <p class="text-xs text-mcms-muted-foreground" aria-live="polite">
5137
+ Focal point: {{ positionLabel() }}
5138
+ <button
5139
+ class="ml-2 underline hover:text-mcms-foreground"
5140
+ type="button"
5141
+ (click)="onResetCenter()"
5142
+ aria-label="Reset focal point to center"
5143
+ >
5144
+ Reset to center
5145
+ </button>
5146
+ </p>
5147
+ </div>
5148
+ `,
5149
+ }]
5150
+ }], propDecorators: { imageElRef: [{ type: i0.ViewChild, args: ['imageEl', { isSignal: true }] }], imageUrl: [{ type: i0.Input, args: [{ isSignal: true, alias: "imageUrl", required: true }] }], focalPoint: [{ type: i0.Input, args: [{ isSignal: true, alias: "focalPoint", required: false }] }], alt: [{ type: i0.Input, args: [{ isSignal: true, alias: "alt", required: false }] }], naturalWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "naturalWidth", required: false }] }], naturalHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "naturalHeight", required: false }] }], imageSizes: [{ type: i0.Input, args: [{ isSignal: true, alias: "imageSizes", required: false }] }], focalPointChange: [{ type: i0.Output, args: ["focalPointChange"] }] } });
5151
+
5152
+ /**
5153
+ * Displays generated image size variants as a grid of thumbnail cards.
5154
+ *
5155
+ * @example
5156
+ * ```html
5157
+ * <mcms-image-variants-display [sizes]="entity.sizes" />
5158
+ * ```
5159
+ */
5160
+ class ImageVariantsDisplay {
5161
+ /** The sizes record from the media document (e.g., entity['sizes']) */
5162
+ sizes = input(null, ...(ngDevMode ? [{ debugName: "sizes" }] : []));
5163
+ /** Parsed variant entries */
5164
+ variants = computed(() => {
5165
+ const sizes = this.sizes();
5166
+ if (!sizes || typeof sizes !== 'object')
5167
+ return [];
5168
+ return Object.entries(sizes)
5169
+ .filter((entry) => {
5170
+ const v = entry[1];
5171
+ return v != null && typeof v === 'object' && 'width' in v && 'path' in v;
5172
+ })
5173
+ .map(([name, data]) => ({
5174
+ name,
5175
+ ...data,
5176
+ }));
5177
+ }, ...(ngDevMode ? [{ debugName: "variants" }] : []));
5178
+ /** Build URL for a variant image */
5179
+ variantUrl(variant) {
5180
+ if (variant.url)
5181
+ return variant.url;
5182
+ if (variant.path)
5183
+ return `/api/media/file/${variant.path}`;
5184
+ return '';
5185
+ }
5186
+ /** Format file size for display */
5187
+ formatFileSize(bytes) {
5188
+ if (!bytes)
5189
+ return '';
5190
+ if (bytes >= 1024 * 1024)
5191
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
5192
+ if (bytes >= 1024)
5193
+ return `${(bytes / 1024).toFixed(1)} KB`;
5194
+ return `${bytes} bytes`;
5195
+ }
5196
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ImageVariantsDisplay, deps: [], target: i0.ɵɵFactoryTarget.Component });
5197
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: ImageVariantsDisplay, isStandalone: true, selector: "mcms-image-variants-display", inputs: { sizes: { classPropertyName: "sizes", publicName: "sizes", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "block" }, ngImport: i0, template: `
5198
+ @if (variants().length > 0) {
5199
+ <div class="space-y-3">
5200
+ <p class="text-sm font-medium">Generated Sizes</p>
5201
+ <div class="grid gap-3 grid-cols-2 sm:grid-cols-3">
5202
+ @for (variant of variants(); track variant.name) {
5203
+ <div class="rounded-lg border border-mcms-border bg-mcms-card overflow-hidden">
5204
+ <img
5205
+ [src]="variantUrl(variant)"
5206
+ [alt]="variant.name + ' variant'"
5207
+ class="h-24 w-full object-cover bg-mcms-muted"
5208
+ />
5209
+ <div class="p-2">
5210
+ <p class="text-xs font-medium">{{ variant.name }}</p>
5211
+ <p class="text-xs text-mcms-muted-foreground">
5212
+ {{ variant.width }} &times; {{ variant.height }}
5213
+ </p>
5214
+ <p class="text-xs text-mcms-muted-foreground">
5215
+ {{ formatFileSize(variant.filesize) }}
5216
+ </p>
5217
+ </div>
5218
+ </div>
5219
+ }
5220
+ </div>
5221
+ </div>
5222
+ }
5223
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
5224
+ }
5225
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ImageVariantsDisplay, decorators: [{
5226
+ type: Component,
5227
+ args: [{
5228
+ selector: 'mcms-image-variants-display',
5229
+ changeDetection: ChangeDetectionStrategy.OnPush,
5230
+ host: { class: 'block' },
5231
+ template: `
5232
+ @if (variants().length > 0) {
5233
+ <div class="space-y-3">
5234
+ <p class="text-sm font-medium">Generated Sizes</p>
5235
+ <div class="grid gap-3 grid-cols-2 sm:grid-cols-3">
5236
+ @for (variant of variants(); track variant.name) {
5237
+ <div class="rounded-lg border border-mcms-border bg-mcms-card overflow-hidden">
5238
+ <img
5239
+ [src]="variantUrl(variant)"
5240
+ [alt]="variant.name + ' variant'"
5241
+ class="h-24 w-full object-cover bg-mcms-muted"
5242
+ />
5243
+ <div class="p-2">
5244
+ <p class="text-xs font-medium">{{ variant.name }}</p>
5245
+ <p class="text-xs text-mcms-muted-foreground">
5246
+ {{ variant.width }} &times; {{ variant.height }}
5247
+ </p>
5248
+ <p class="text-xs text-mcms-muted-foreground">
5249
+ {{ formatFileSize(variant.filesize) }}
5250
+ </p>
5251
+ </div>
5252
+ </div>
5253
+ }
5254
+ </div>
5255
+ </div>
5256
+ }
5257
+ `,
5258
+ }]
5259
+ }], propDecorators: { sizes: [{ type: i0.Input, args: [{ isSignal: true, alias: "sizes", required: false }] }] } });
5260
+
4840
5261
  /**
4841
5262
  * Entity Form Widget
4842
5263
  *
@@ -4902,8 +5323,87 @@ class EntityFormWidget {
4902
5323
  isUploadingFile = signal(false, ...(ngDevMode ? [{ debugName: "isUploadingFile" }] : []));
4903
5324
  uploadFileProgress = signal(0, ...(ngDevMode ? [{ debugName: "uploadFileProgress" }] : []));
4904
5325
  uploadFileError = signal(null, ...(ngDevMode ? [{ debugName: "uploadFileError" }] : []));
5326
+ /** Preview URL for the pending image file (focal point picker) */
5327
+ pendingFileUrl = signal(null, ...(ngDevMode ? [{ debugName: "pendingFileUrl" }] : []));
5328
+ /** Detected dimensions of the pending image */
5329
+ pendingImageDimensions = signal({
5330
+ width: 0,
5331
+ height: 0,
5332
+ }, ...(ngDevMode ? [{ debugName: "pendingImageDimensions" }] : []));
4905
5333
  /** Whether the collection is an upload collection */
4906
5334
  isUploadCol = computed(() => isUploadCollection(this.collection()), ...(ngDevMode ? [{ debugName: "isUploadCol" }] : []));
5335
+ /** Whether the current file (pending or existing) is an image */
5336
+ isImageFile = computed(() => {
5337
+ const file = this.pendingFile();
5338
+ if (file)
5339
+ return file.type.startsWith('image/');
5340
+ if (this.isUploadCol() && this.mode() !== 'create') {
5341
+ const mimeType = this.formModel()['mimeType'];
5342
+ return typeof mimeType === 'string' && mimeType.startsWith('image/');
5343
+ }
5344
+ return false;
5345
+ }, ...(ngDevMode ? [{ debugName: "isImageFile" }] : []));
5346
+ /** Image sizes from collection upload config */
5347
+ uploadImageSizes = computed(() => {
5348
+ return this.collection().upload?.imageSizes ?? [];
5349
+ }, ...(ngDevMode ? [{ debugName: "uploadImageSizes" }] : []));
5350
+ /** Image URL for the focal point picker */
5351
+ focalPointImageUrl = computed(() => {
5352
+ const fileUrl = this.pendingFileUrl();
5353
+ if (fileUrl)
5354
+ return fileUrl;
5355
+ const model = this.formModel();
5356
+ if (typeof model['url'] === 'string' && model['url'])
5357
+ return model['url'];
5358
+ if (typeof model['path'] === 'string' && model['path'])
5359
+ return `/api/media/file/${model['path']}`;
5360
+ return '';
5361
+ }, ...(ngDevMode ? [{ debugName: "focalPointImageUrl" }] : []));
5362
+ /** Current focal point value */
5363
+ currentFocalPoint = computed(() => {
5364
+ const fp = this.formModel()['focalPoint'];
5365
+ if (fp != null && typeof fp === 'object' && !Array.isArray(fp)) {
5366
+ const obj = fp; // eslint-disable-line @typescript-eslint/consistent-type-assertions
5367
+ const x = obj['x'];
5368
+ const y = obj['y'];
5369
+ if (typeof x === 'number' && typeof y === 'number') {
5370
+ return { x, y };
5371
+ }
5372
+ }
5373
+ return { x: 0.5, y: 0.5 };
5374
+ }, ...(ngDevMode ? [{ debugName: "currentFocalPoint" }] : []));
5375
+ /** Natural image dimensions (pending file detection or existing media) */
5376
+ imageNaturalDimensions = computed(() => {
5377
+ if (this.pendingFile())
5378
+ return this.pendingImageDimensions();
5379
+ const model = this.formModel();
5380
+ return {
5381
+ width: typeof model['width'] === 'number' ? model['width'] : 0,
5382
+ height: typeof model['height'] === 'number' ? model['height'] : 0,
5383
+ };
5384
+ }, ...(ngDevMode ? [{ debugName: "imageNaturalDimensions" }] : []));
5385
+ /** Alt text for the focal point picker */
5386
+ focalPointAlt = computed(() => {
5387
+ const model = this.formModel();
5388
+ const alt = model['alt'];
5389
+ if (typeof alt === 'string' && alt)
5390
+ return alt;
5391
+ const fn = model['filename'];
5392
+ if (typeof fn === 'string')
5393
+ return fn;
5394
+ return '';
5395
+ }, ...(ngDevMode ? [{ debugName: "focalPointAlt" }] : []));
5396
+ /** Generated image sizes from the form model */
5397
+ formModelSizes = computed(() => {
5398
+ const sizes = this.formModel()['sizes'];
5399
+ if (sizes != null &&
5400
+ typeof sizes === 'object' &&
5401
+ !Array.isArray(sizes) &&
5402
+ Object.keys(sizes).length > 0) {
5403
+ return sizes; // eslint-disable-line @typescript-eslint/consistent-type-assertions
5404
+ }
5405
+ return null;
5406
+ }, ...(ngDevMode ? [{ debugName: "formModelSizes" }] : []));
4907
5407
  /** Whether the form has been set up */
4908
5408
  formCreated = false;
4909
5409
  /** Whether the form has unsaved changes (from signal forms dirty tracking) */
@@ -5002,16 +5502,46 @@ class EntityFormWidget {
5002
5502
  this.loadGlobal(gSlug);
5003
5503
  }
5004
5504
  else if (currentMode === 'create' || !id) {
5005
- this.formModel.set(createInitialFormData(col));
5006
- const ef = this.entityForm();
5007
- if (ef)
5008
- ef().reset();
5505
+ // Guard: don't reset formModel if the user has already selected a file.
5506
+ // On Analog (SSR), reactive route signals may re-emit after hydration,
5507
+ // causing this effect to re-run and wipe user input.
5508
+ const hasPendingFile = untracked(() => this.pendingFile());
5509
+ if (!hasPendingFile) {
5510
+ this.formModel.set(createInitialFormData(col));
5511
+ const ef = this.entityForm();
5512
+ if (ef)
5513
+ ef().reset();
5514
+ }
5009
5515
  }
5010
5516
  else {
5011
5517
  this.loadEntity(col.slug, id);
5012
5518
  }
5013
5519
  }
5014
5520
  });
5521
+ // Manage preview URL for focal point picker and detect image dimensions
5522
+ effect((onCleanup) => {
5523
+ const file = this.pendingFile();
5524
+ if (file && file.type.startsWith('image/')) {
5525
+ const url = URL.createObjectURL(file);
5526
+ this.pendingFileUrl.set(url);
5527
+ let cancelled = false;
5528
+ onCleanup(() => {
5529
+ cancelled = true;
5530
+ URL.revokeObjectURL(url);
5531
+ });
5532
+ const img = new Image();
5533
+ img.onload = () => {
5534
+ if (!cancelled) {
5535
+ this.pendingImageDimensions.set({ width: img.naturalWidth, height: img.naturalHeight });
5536
+ }
5537
+ };
5538
+ img.src = url;
5539
+ }
5540
+ else {
5541
+ this.pendingFileUrl.set(null);
5542
+ this.pendingImageDimensions.set({ width: 0, height: 0 });
5543
+ }
5544
+ });
5015
5545
  }
5016
5546
  /**
5017
5547
  * Get a FieldTree node for a top-level field by name.
@@ -5120,6 +5650,14 @@ class EntityFormWidget {
5120
5650
  data['filesize'] = file.size;
5121
5651
  this.formModel.set(data);
5122
5652
  }
5653
+ /**
5654
+ * Handle focal point change from the picker.
5655
+ */
5656
+ onFocalPointChange(fp) {
5657
+ const data = { ...this.formModel() };
5658
+ data['focalPoint'] = fp;
5659
+ this.formModel.set(data);
5660
+ }
5123
5661
  /**
5124
5662
  * Handle file removed from the upload zone.
5125
5663
  */
@@ -5395,7 +5933,7 @@ class EntityFormWidget {
5395
5933
  <mcms-collection-upload-zone
5396
5934
  [uploadConfig]="collection().upload"
5397
5935
  [pendingFile]="pendingFile()"
5398
- [existingMedia]="mode() === 'edit' && !pendingFile() ? formModel() : null"
5936
+ [existingMedia]="mode() !== 'create' && !pendingFile() ? formModel() : null"
5399
5937
  [disabled]="mode() === 'view'"
5400
5938
  [isUploading]="isUploadingFile()"
5401
5939
  [uploadProgress]="uploadFileProgress()"
@@ -5405,6 +5943,27 @@ class EntityFormWidget {
5405
5943
  />
5406
5944
  }
5407
5945
 
5946
+ @if (isUploadCol() && isImageFile() && focalPointImageUrl()) {
5947
+ <div class="mb-6" [class.pointer-events-none]="mode() === 'view'">
5948
+ <p class="mb-2 text-sm font-medium">Focal Point</p>
5949
+ <mcms-focal-point-picker
5950
+ [imageUrl]="focalPointImageUrl()"
5951
+ [focalPoint]="currentFocalPoint()"
5952
+ [naturalWidth]="imageNaturalDimensions().width"
5953
+ [naturalHeight]="imageNaturalDimensions().height"
5954
+ [alt]="focalPointAlt()"
5955
+ [imageSizes]="uploadImageSizes()"
5956
+ (focalPointChange)="onFocalPointChange($event)"
5957
+ />
5958
+ </div>
5959
+ }
5960
+
5961
+ @if (isUploadCol() && formModelSizes()) {
5962
+ <div class="mb-6">
5963
+ <mcms-image-variants-display [sizes]="formModelSizes()" />
5964
+ </div>
5965
+ }
5966
+
5408
5967
  <div class="space-y-6">
5409
5968
  @for (field of visibleFields(); track field.name) {
5410
5969
  <mcms-field-renderer
@@ -5473,7 +6032,7 @@ class EntityFormWidget {
5473
6032
  </div>
5474
6033
  }
5475
6034
  </div>
5476
- `, isInline: true, dependencies: [{ kind: "component", type: Card, selector: "mcms-card" }, { kind: "component", type: CardContent, selector: "mcms-card-content" }, { kind: "component", type: CardFooter, selector: "mcms-card-footer" }, { kind: "component", type: Button, selector: "button[mcms-button], a[mcms-button]", inputs: ["variant", "size", "disabled", "loading", "ariaLabel", "class"] }, { kind: "component", type: Spinner, selector: "mcms-spinner", inputs: ["size", "label", "class"] }, { kind: "component", type: Alert, selector: "mcms-alert", inputs: ["variant", "class"] }, { kind: "component", type: FieldRenderer, selector: "mcms-field-renderer", inputs: ["field", "formNode", "formTree", "formModel", "mode", "path"] }, { kind: "component", type: Breadcrumbs, selector: "mcms-breadcrumbs", inputs: ["class"] }, { kind: "component", type: BreadcrumbItem, selector: "mcms-breadcrumb-item", inputs: ["href", "current", "class"] }, { kind: "component", type: BreadcrumbSeparator, selector: "mcms-breadcrumb-separator", inputs: ["class"] }, { kind: "component", type: VersionHistoryWidget, selector: "mcms-version-history", inputs: ["collection", "documentId", "documentLabel"], outputs: ["restored"] }, { kind: "component", type: CollectionUploadZoneComponent, selector: "mcms-collection-upload-zone", inputs: ["uploadConfig", "disabled", "pendingFile", "isUploading", "uploadProgress", "error", "existingMedia"], outputs: ["fileSelected", "fileRemoved"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6035
+ `, isInline: true, dependencies: [{ kind: "component", type: Card, selector: "mcms-card" }, { kind: "component", type: CardContent, selector: "mcms-card-content" }, { kind: "component", type: CardFooter, selector: "mcms-card-footer" }, { kind: "component", type: Button, selector: "button[mcms-button], a[mcms-button]", inputs: ["variant", "size", "disabled", "loading", "ariaLabel", "class"] }, { kind: "component", type: Spinner, selector: "mcms-spinner", inputs: ["size", "label", "class"] }, { kind: "component", type: Alert, selector: "mcms-alert", inputs: ["variant", "class"] }, { kind: "component", type: FieldRenderer, selector: "mcms-field-renderer", inputs: ["field", "formNode", "formTree", "formModel", "mode", "path"] }, { kind: "component", type: Breadcrumbs, selector: "mcms-breadcrumbs", inputs: ["class"] }, { kind: "component", type: BreadcrumbItem, selector: "mcms-breadcrumb-item", inputs: ["href", "current", "class"] }, { kind: "component", type: BreadcrumbSeparator, selector: "mcms-breadcrumb-separator", inputs: ["class"] }, { kind: "component", type: VersionHistoryWidget, selector: "mcms-version-history", inputs: ["collection", "documentId", "documentLabel"], outputs: ["restored"] }, { kind: "component", type: CollectionUploadZoneComponent, selector: "mcms-collection-upload-zone", inputs: ["uploadConfig", "disabled", "pendingFile", "isUploading", "uploadProgress", "error", "existingMedia"], outputs: ["fileSelected", "fileRemoved"] }, { kind: "component", type: FocalPointPickerComponent, selector: "mcms-focal-point-picker", inputs: ["imageUrl", "focalPoint", "alt", "naturalWidth", "naturalHeight", "imageSizes"], outputs: ["focalPointChange"] }, { kind: "component", type: ImageVariantsDisplay, selector: "mcms-image-variants-display", inputs: ["sizes"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5477
6036
  }
5478
6037
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: EntityFormWidget, decorators: [{
5479
6038
  type: Component,
@@ -5492,6 +6051,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
5492
6051
  BreadcrumbSeparator,
5493
6052
  VersionHistoryWidget,
5494
6053
  CollectionUploadZoneComponent,
6054
+ FocalPointPickerComponent,
6055
+ ImageVariantsDisplay,
5495
6056
  ],
5496
6057
  changeDetection: ChangeDetectionStrategy.OnPush,
5497
6058
  host: { class: 'block' },
@@ -5557,7 +6118,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
5557
6118
  <mcms-collection-upload-zone
5558
6119
  [uploadConfig]="collection().upload"
5559
6120
  [pendingFile]="pendingFile()"
5560
- [existingMedia]="mode() === 'edit' && !pendingFile() ? formModel() : null"
6121
+ [existingMedia]="mode() !== 'create' && !pendingFile() ? formModel() : null"
5561
6122
  [disabled]="mode() === 'view'"
5562
6123
  [isUploading]="isUploadingFile()"
5563
6124
  [uploadProgress]="uploadFileProgress()"
@@ -5567,6 +6128,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
5567
6128
  />
5568
6129
  }
5569
6130
 
6131
+ @if (isUploadCol() && isImageFile() && focalPointImageUrl()) {
6132
+ <div class="mb-6" [class.pointer-events-none]="mode() === 'view'">
6133
+ <p class="mb-2 text-sm font-medium">Focal Point</p>
6134
+ <mcms-focal-point-picker
6135
+ [imageUrl]="focalPointImageUrl()"
6136
+ [focalPoint]="currentFocalPoint()"
6137
+ [naturalWidth]="imageNaturalDimensions().width"
6138
+ [naturalHeight]="imageNaturalDimensions().height"
6139
+ [alt]="focalPointAlt()"
6140
+ [imageSizes]="uploadImageSizes()"
6141
+ (focalPointChange)="onFocalPointChange($event)"
6142
+ />
6143
+ </div>
6144
+ }
6145
+
6146
+ @if (isUploadCol() && formModelSizes()) {
6147
+ <div class="mb-6">
6148
+ <mcms-image-variants-display [sizes]="formModelSizes()" />
6149
+ </div>
6150
+ }
6151
+
5570
6152
  <div class="space-y-6">
5571
6153
  @for (field of visibleFields(); track field.name) {
5572
6154
  <mcms-field-renderer
@@ -5945,6 +6527,81 @@ class EntityViewWidget {
5945
6527
  canDelete = computed(() => {
5946
6528
  return this.collectionAccess.canDelete(this.collection().slug);
5947
6529
  }, ...(ngDevMode ? [{ debugName: "canDelete" }] : []));
6530
+ /** Whether this collection is an upload collection */
6531
+ isUploadCol = computed(() => isUploadCollection(this.collection()), ...(ngDevMode ? [{ debugName: "isUploadCol" }] : []));
6532
+ /** Whether the entity is an image */
6533
+ isEntityImage = computed(() => {
6534
+ const e = this.entity();
6535
+ if (!e)
6536
+ return false;
6537
+ const mimeType = e['mimeType'];
6538
+ return typeof mimeType === 'string' && mimeType.startsWith('image/');
6539
+ }, ...(ngDevMode ? [{ debugName: "isEntityImage" }] : []));
6540
+ /** Media URL for preview */
6541
+ entityMediaUrl = computed(() => {
6542
+ const e = this.entity();
6543
+ if (!e)
6544
+ return '';
6545
+ if (typeof e['url'] === 'string' && e['url'])
6546
+ return e['url'];
6547
+ if (typeof e['path'] === 'string' && e['path'])
6548
+ return `/api/media/file/${e['path']}`;
6549
+ return '';
6550
+ }, ...(ngDevMode ? [{ debugName: "entityMediaUrl" }] : []));
6551
+ /** Focal point from entity data */
6552
+ entityFocalPoint = computed(() => {
6553
+ const e = this.entity();
6554
+ if (!e)
6555
+ return { x: 0.5, y: 0.5 };
6556
+ const fp = e['focalPoint'];
6557
+ if (fp != null && typeof fp === 'object' && !Array.isArray(fp)) {
6558
+ const obj = fp; // eslint-disable-line @typescript-eslint/consistent-type-assertions
6559
+ const x = obj['x'];
6560
+ const y = obj['y'];
6561
+ if (typeof x === 'number' && typeof y === 'number')
6562
+ return { x, y };
6563
+ }
6564
+ return { x: 0.5, y: 0.5 };
6565
+ }, ...(ngDevMode ? [{ debugName: "entityFocalPoint" }] : []));
6566
+ /** Image dimensions from entity data */
6567
+ entityDimensions = computed(() => {
6568
+ const e = this.entity();
6569
+ return {
6570
+ width: typeof e?.['width'] === 'number' ? e['width'] : 0,
6571
+ height: typeof e?.['height'] === 'number' ? e['height'] : 0,
6572
+ };
6573
+ }, ...(ngDevMode ? [{ debugName: "entityDimensions" }] : []));
6574
+ /** Image sizes from collection upload config */
6575
+ viewImageSizes = computed(() => {
6576
+ return this.collection().upload?.imageSizes ?? [];
6577
+ }, ...(ngDevMode ? [{ debugName: "viewImageSizes" }] : []));
6578
+ /** Generated image sizes from entity data */
6579
+ entitySizes = computed(() => {
6580
+ const e = this.entity();
6581
+ if (!e)
6582
+ return null;
6583
+ const sizes = e['sizes'];
6584
+ if (sizes != null &&
6585
+ typeof sizes === 'object' &&
6586
+ !Array.isArray(sizes) &&
6587
+ Object.keys(sizes).length > 0) {
6588
+ return sizes; // eslint-disable-line @typescript-eslint/consistent-type-assertions
6589
+ }
6590
+ return null;
6591
+ }, ...(ngDevMode ? [{ debugName: "entitySizes" }] : []));
6592
+ /** Media preview data for non-image files */
6593
+ entityMediaPreview = computed(() => {
6594
+ const e = this.entity();
6595
+ if (!e)
6596
+ return null;
6597
+ return {
6598
+ url: typeof e['url'] === 'string' ? e['url'] : undefined,
6599
+ path: typeof e['path'] === 'string' ? e['path'] : undefined,
6600
+ mimeType: typeof e['mimeType'] === 'string' ? e['mimeType'] : undefined,
6601
+ filename: typeof e['filename'] === 'string' ? e['filename'] : undefined,
6602
+ alt: typeof e['alt'] === 'string' ? e['alt'] : undefined,
6603
+ };
6604
+ }, ...(ngDevMode ? [{ debugName: "entityMediaPreview" }] : []));
5948
6605
  /** Whether collection has soft delete enabled */
5949
6606
  hasSoftDelete = computed(() => !!this.collection().softDelete, ...(ngDevMode ? [{ debugName: "hasSoftDelete" }] : []));
5950
6607
  /** Whether the current entity is soft-deleted */
@@ -6400,6 +7057,28 @@ class EntityViewWidget {
6400
7057
  {{ loadError() }}
6401
7058
  </mcms-alert>
6402
7059
  } @else if (entity()) {
7060
+ @if (isUploadCol() && entityMediaUrl()) {
7061
+ <div class="mb-6">
7062
+ @if (isEntityImage()) {
7063
+ <div class="pointer-events-none">
7064
+ <mcms-focal-point-picker
7065
+ [imageUrl]="entityMediaUrl()"
7066
+ [focalPoint]="entityFocalPoint()"
7067
+ [naturalWidth]="entityDimensions().width"
7068
+ [naturalHeight]="entityDimensions().height"
7069
+ [imageSizes]="viewImageSizes()"
7070
+ />
7071
+ </div>
7072
+ } @else {
7073
+ <mcms-media-preview [media]="entityMediaPreview()" size="xl" />
7074
+ }
7075
+ </div>
7076
+ }
7077
+ @if (isUploadCol() && entitySizes()) {
7078
+ <div class="mb-6">
7079
+ <mcms-image-variants-display [sizes]="entitySizes()" />
7080
+ </div>
7081
+ }
6403
7082
  <div class="grid gap-6 md:grid-cols-2">
6404
7083
  @for (field of visibleFields(); track field.name) {
6405
7084
  <mcms-field-display
@@ -6448,7 +7127,7 @@ class EntityViewWidget {
6448
7127
  </div>
6449
7128
  }
6450
7129
  </div>
6451
- `, isInline: true, dependencies: [{ kind: "component", type: Card, selector: "mcms-card" }, { kind: "component", type: CardContent, selector: "mcms-card-content" }, { kind: "component", type: CardFooter, selector: "mcms-card-footer" }, { kind: "component", type: Button, selector: "button[mcms-button], a[mcms-button]", inputs: ["variant", "size", "disabled", "loading", "ariaLabel", "class"] }, { kind: "component", type: Alert, selector: "mcms-alert", inputs: ["variant", "class"] }, { kind: "component", type: Skeleton, selector: "mcms-skeleton", inputs: ["class"] }, { kind: "component", type: FieldDisplay, selector: "mcms-field-display", inputs: ["value", "type", "label", "format", "emptyText", "badgeConfig", "openInNewTab", "maxItems", "class", "fieldMeta", "numberFormat", "dateFormat"] }, { kind: "component", type: Breadcrumbs, selector: "mcms-breadcrumbs", inputs: ["class"] }, { kind: "component", type: BreadcrumbItem, selector: "mcms-breadcrumb-item", inputs: ["href", "current", "class"] }, { kind: "component", type: BreadcrumbSeparator, selector: "mcms-breadcrumb-separator", inputs: ["class"] }, { kind: "component", type: VersionHistoryWidget, selector: "mcms-version-history", inputs: ["collection", "documentId", "documentLabel"], outputs: ["restored"] }, { kind: "component", type: PublishControlsWidget, selector: "mcms-publish-controls", inputs: ["collection", "documentId", "documentLabel", "initialStatus"], outputs: ["statusChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7130
+ `, isInline: true, dependencies: [{ kind: "component", type: Card, selector: "mcms-card" }, { kind: "component", type: CardContent, selector: "mcms-card-content" }, { kind: "component", type: CardFooter, selector: "mcms-card-footer" }, { kind: "component", type: Button, selector: "button[mcms-button], a[mcms-button]", inputs: ["variant", "size", "disabled", "loading", "ariaLabel", "class"] }, { kind: "component", type: Alert, selector: "mcms-alert", inputs: ["variant", "class"] }, { kind: "component", type: Skeleton, selector: "mcms-skeleton", inputs: ["class"] }, { kind: "component", type: FieldDisplay, selector: "mcms-field-display", inputs: ["value", "type", "label", "format", "emptyText", "badgeConfig", "openInNewTab", "maxItems", "class", "fieldMeta", "numberFormat", "dateFormat"] }, { kind: "component", type: Breadcrumbs, selector: "mcms-breadcrumbs", inputs: ["class"] }, { kind: "component", type: BreadcrumbItem, selector: "mcms-breadcrumb-item", inputs: ["href", "current", "class"] }, { kind: "component", type: BreadcrumbSeparator, selector: "mcms-breadcrumb-separator", inputs: ["class"] }, { kind: "component", type: VersionHistoryWidget, selector: "mcms-version-history", inputs: ["collection", "documentId", "documentLabel"], outputs: ["restored"] }, { kind: "component", type: PublishControlsWidget, selector: "mcms-publish-controls", inputs: ["collection", "documentId", "documentLabel", "initialStatus"], outputs: ["statusChanged"] }, { kind: "component", type: MediaPreviewComponent, selector: "mcms-media-preview", inputs: ["media", "size", "class", "rounded"] }, { kind: "component", type: FocalPointPickerComponent, selector: "mcms-focal-point-picker", inputs: ["imageUrl", "focalPoint", "alt", "naturalWidth", "naturalHeight", "imageSizes"], outputs: ["focalPointChange"] }, { kind: "component", type: ImageVariantsDisplay, selector: "mcms-image-variants-display", inputs: ["sizes"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6452
7131
  }
6453
7132
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: EntityViewWidget, decorators: [{
6454
7133
  type: Component,
@@ -6467,6 +7146,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
6467
7146
  BreadcrumbSeparator,
6468
7147
  VersionHistoryWidget,
6469
7148
  PublishControlsWidget,
7149
+ MediaPreviewComponent,
7150
+ FocalPointPickerComponent,
7151
+ ImageVariantsDisplay,
6470
7152
  ],
6471
7153
  changeDetection: ChangeDetectionStrategy.OnPush,
6472
7154
  host: { class: 'block' },
@@ -6570,6 +7252,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
6570
7252
  {{ loadError() }}
6571
7253
  </mcms-alert>
6572
7254
  } @else if (entity()) {
7255
+ @if (isUploadCol() && entityMediaUrl()) {
7256
+ <div class="mb-6">
7257
+ @if (isEntityImage()) {
7258
+ <div class="pointer-events-none">
7259
+ <mcms-focal-point-picker
7260
+ [imageUrl]="entityMediaUrl()"
7261
+ [focalPoint]="entityFocalPoint()"
7262
+ [naturalWidth]="entityDimensions().width"
7263
+ [naturalHeight]="entityDimensions().height"
7264
+ [imageSizes]="viewImageSizes()"
7265
+ />
7266
+ </div>
7267
+ } @else {
7268
+ <mcms-media-preview [media]="entityMediaPreview()" size="xl" />
7269
+ }
7270
+ </div>
7271
+ }
7272
+ @if (isUploadCol() && entitySizes()) {
7273
+ <div class="mb-6">
7274
+ <mcms-image-variants-display [sizes]="entitySizes()" />
7275
+ </div>
7276
+ }
6573
7277
  <div class="grid gap-6 md:grid-cols-2">
6574
7278
  @for (field of visibleFields(); track field.name) {
6575
7279
  <mcms-field-display
@@ -11452,14 +12156,27 @@ class MediaEditDialog {
11452
12156
  data = inject(DIALOG_DATA);
11453
12157
  api = injectMomentumAPI();
11454
12158
  media = this.data.media;
12159
+ imageSizes = this.data.imageSizes ?? [];
11455
12160
  filename = signal(this.data.media.filename, ...(ngDevMode ? [{ debugName: "filename" }] : []));
11456
12161
  altText = signal(this.data.media.alt ?? '', ...(ngDevMode ? [{ debugName: "altText" }] : []));
12162
+ focalPointValue = signal(this.data.media.focalPoint ?? { x: 0.5, y: 0.5 }, ...(ngDevMode ? [{ debugName: "focalPointValue" }] : []));
11457
12163
  isSaving = signal(false, ...(ngDevMode ? [{ debugName: "isSaving" }] : []));
11458
12164
  saveError = signal(null, ...(ngDevMode ? [{ debugName: "saveError" }] : []));
11459
12165
  formattedSize = formatFileSize(this.data.media.filesize);
12166
+ isImage = computed(() => this.media.mimeType.startsWith('image/'), ...(ngDevMode ? [{ debugName: "isImage" }] : []));
12167
+ imageUrl = computed(() => {
12168
+ return this.media.url ?? `/api/media/file/${this.media.path}`;
12169
+ }, ...(ngDevMode ? [{ debugName: "imageUrl" }] : []));
11460
12170
  hasChanges = computed(() => {
11461
- return this.filename() !== this.media.filename || this.altText() !== (this.media.alt ?? '');
12171
+ const fpChanged = this.focalPointValue().x !== (this.media.focalPoint?.x ?? 0.5) ||
12172
+ this.focalPointValue().y !== (this.media.focalPoint?.y ?? 0.5);
12173
+ return (this.filename() !== this.media.filename ||
12174
+ this.altText() !== (this.media.alt ?? '') ||
12175
+ fpChanged);
11462
12176
  }, ...(ngDevMode ? [{ debugName: "hasChanges" }] : []));
12177
+ onFocalPointChange(fp) {
12178
+ this.focalPointValue.set(fp);
12179
+ }
11463
12180
  /**
11464
12181
  * Save media metadata changes via API.
11465
12182
  */
@@ -11467,10 +12184,14 @@ class MediaEditDialog {
11467
12184
  this.isSaving.set(true);
11468
12185
  this.saveError.set(null);
11469
12186
  try {
11470
- const result = await this.api.collection('media').update(this.media.id, {
12187
+ const updateData = {
11471
12188
  filename: this.filename(),
11472
12189
  alt: this.altText(),
11473
- });
12190
+ };
12191
+ if (this.isImage()) {
12192
+ updateData['focalPoint'] = this.focalPointValue();
12193
+ }
12194
+ const result = await this.api.collection('media').update(this.media.id, updateData);
11474
12195
  if (isMediaEditItem(result)) {
11475
12196
  this.dialogRef.close({ updated: true, media: result });
11476
12197
  }
@@ -11527,6 +12248,21 @@ class MediaEditDialog {
11527
12248
  </div>
11528
12249
  </div>
11529
12250
 
12251
+ @if (isImage()) {
12252
+ <div class="mt-4">
12253
+ <p class="mb-2 text-sm font-medium">Focal Point</p>
12254
+ <mcms-focal-point-picker
12255
+ [imageUrl]="imageUrl()"
12256
+ [focalPoint]="focalPointValue()"
12257
+ [naturalWidth]="media.width ?? 0"
12258
+ [naturalHeight]="media.height ?? 0"
12259
+ [alt]="media.alt ?? media.filename"
12260
+ [imageSizes]="imageSizes"
12261
+ (focalPointChange)="onFocalPointChange($event)"
12262
+ />
12263
+ </div>
12264
+ }
12265
+
11530
12266
  @if (saveError()) {
11531
12267
  <mcms-alert variant="destructive" class="mt-4">
11532
12268
  {{ saveError() }}
@@ -11544,7 +12280,7 @@ class MediaEditDialog {
11544
12280
  </button>
11545
12281
  </mcms-dialog-footer>
11546
12282
  </mcms-dialog>
11547
- `, isInline: true, dependencies: [{ kind: "component", type: Button, selector: "button[mcms-button], a[mcms-button]", inputs: ["variant", "size", "disabled", "loading", "ariaLabel", "class"] }, { kind: "component", type: Dialog, selector: "mcms-dialog", inputs: ["class"] }, { kind: "component", type: DialogHeader, selector: "mcms-dialog-header" }, { kind: "component", type: DialogTitle, selector: "mcms-dialog-title", inputs: ["id"] }, { kind: "component", type: DialogContent, selector: "mcms-dialog-content" }, { kind: "component", type: DialogFooter, selector: "mcms-dialog-footer" }, { kind: "directive", type: DialogClose, selector: "[mcmsDialogClose]", inputs: ["mcmsDialogClose"] }, { kind: "component", type: Input, selector: "mcms-input", inputs: ["value", "disabled", "errors", "touched", "invalid", "readonly", "required", "type", "id", "name", "placeholder", "autocomplete", "ariaLabel", "describedBy", "min", "max", "step"], outputs: ["valueChange", "blurred"] }, { kind: "component", type: Textarea, selector: "mcms-textarea", inputs: ["value", "disabled", "errors", "touched", "invalid", "readonly", "required", "id", "name", "placeholder", "rows", "ariaLabel", "describedBy"], outputs: ["valueChange", "blurred"] }, { kind: "component", type: Label, selector: "mcms-label", inputs: ["for", "required", "disabled", "class"] }, { kind: "component", type: Spinner, selector: "mcms-spinner", inputs: ["size", "label", "class"] }, { kind: "component", type: Alert, selector: "mcms-alert", inputs: ["variant", "class"] }, { kind: "component", type: MediaPreviewComponent, selector: "mcms-media-preview", inputs: ["media", "size", "class", "rounded"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
12283
+ `, isInline: true, dependencies: [{ kind: "component", type: Button, selector: "button[mcms-button], a[mcms-button]", inputs: ["variant", "size", "disabled", "loading", "ariaLabel", "class"] }, { kind: "component", type: Dialog, selector: "mcms-dialog", inputs: ["class"] }, { kind: "component", type: DialogHeader, selector: "mcms-dialog-header" }, { kind: "component", type: DialogTitle, selector: "mcms-dialog-title", inputs: ["id"] }, { kind: "component", type: DialogContent, selector: "mcms-dialog-content" }, { kind: "component", type: DialogFooter, selector: "mcms-dialog-footer" }, { kind: "directive", type: DialogClose, selector: "[mcmsDialogClose]", inputs: ["mcmsDialogClose"] }, { kind: "component", type: Input, selector: "mcms-input", inputs: ["value", "disabled", "errors", "touched", "invalid", "readonly", "required", "type", "id", "name", "placeholder", "autocomplete", "ariaLabel", "describedBy", "min", "max", "step"], outputs: ["valueChange", "blurred"] }, { kind: "component", type: Textarea, selector: "mcms-textarea", inputs: ["value", "disabled", "errors", "touched", "invalid", "readonly", "required", "id", "name", "placeholder", "rows", "ariaLabel", "describedBy"], outputs: ["valueChange", "blurred"] }, { kind: "component", type: Label, selector: "mcms-label", inputs: ["for", "required", "disabled", "class"] }, { kind: "component", type: Spinner, selector: "mcms-spinner", inputs: ["size", "label", "class"] }, { kind: "component", type: Alert, selector: "mcms-alert", inputs: ["variant", "class"] }, { kind: "component", type: MediaPreviewComponent, selector: "mcms-media-preview", inputs: ["media", "size", "class", "rounded"] }, { kind: "component", type: FocalPointPickerComponent, selector: "mcms-focal-point-picker", inputs: ["imageUrl", "focalPoint", "alt", "naturalWidth", "naturalHeight", "imageSizes"], outputs: ["focalPointChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
11548
12284
  }
11549
12285
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: MediaEditDialog, decorators: [{
11550
12286
  type: Component,
@@ -11564,6 +12300,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
11564
12300
  Spinner,
11565
12301
  Alert,
11566
12302
  MediaPreviewComponent,
12303
+ FocalPointPickerComponent,
11567
12304
  ],
11568
12305
  changeDetection: ChangeDetectionStrategy.OnPush,
11569
12306
  host: { style: 'display: block; width: 100%' },
@@ -11608,6 +12345,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
11608
12345
  </div>
11609
12346
  </div>
11610
12347
 
12348
+ @if (isImage()) {
12349
+ <div class="mt-4">
12350
+ <p class="mb-2 text-sm font-medium">Focal Point</p>
12351
+ <mcms-focal-point-picker
12352
+ [imageUrl]="imageUrl()"
12353
+ [focalPoint]="focalPointValue()"
12354
+ [naturalWidth]="media.width ?? 0"
12355
+ [naturalHeight]="media.height ?? 0"
12356
+ [alt]="media.alt ?? media.filename"
12357
+ [imageSizes]="imageSizes"
12358
+ (focalPointChange)="onFocalPointChange($event)"
12359
+ />
12360
+ </div>
12361
+ }
12362
+
11611
12363
  @if (saveError()) {
11612
12364
  <mcms-alert variant="destructive" class="mt-4">
11613
12365
  {{ saveError() }}
@@ -11668,6 +12420,7 @@ function getInputElement(event) {
11668
12420
  */
11669
12421
  class MediaLibraryPage {
11670
12422
  document = inject(DOCUMENT);
12423
+ route = inject(ActivatedRoute);
11671
12424
  api = injectMomentumAPI();
11672
12425
  uploadService = inject(UploadService);
11673
12426
  feedback = inject(FeedbackService);
@@ -11675,6 +12428,12 @@ class MediaLibraryPage {
11675
12428
  dialog = inject(DialogService);
11676
12429
  destroyRef = inject(DestroyRef);
11677
12430
  uploadSubscriptions = [];
12431
+ /** Image sizes config from the media collection (for crop previews in edit dialog) */
12432
+ mediaImageSizes = (() => {
12433
+ const collections = getCollectionsFromRouteData(this.route.parent?.snapshot.data);
12434
+ const mediaColl = collections.find((c) => c.slug === 'media');
12435
+ return mediaColl?.upload?.imageSizes ?? [];
12436
+ })();
11678
12437
  /** Internal state */
11679
12438
  isLoading = signal(true, ...(ngDevMode ? [{ debugName: "isLoading" }] : []));
11680
12439
  mediaItems = signal([], ...(ngDevMode ? [{ debugName: "mediaItems" }] : []));
@@ -11826,7 +12585,7 @@ class MediaLibraryPage {
11826
12585
  */
11827
12586
  editMedia(media) {
11828
12587
  const dialogRef = this.dialog.open(MediaEditDialog, {
11829
- data: { media },
12588
+ data: { media, imageSizes: this.mediaImageSizes },
11830
12589
  width: '36rem',
11831
12590
  });
11832
12591
  dialogRef.afterClosed.subscribe((result) => {
@@ -13454,18 +14213,18 @@ function provideMomentumFieldRenderers() {
13454
14213
  registry.register('checkbox', () => Promise.resolve().then(function () { return checkboxField_component; }).then((m) => m.CheckboxFieldRenderer));
13455
14214
  registry.register('date', () => Promise.resolve().then(function () { return dateField_component; }).then((m) => m.DateFieldRenderer));
13456
14215
  registry.register('upload', () => Promise.resolve().then(function () { return uploadField_component; }).then((m) => m.UploadFieldRenderer));
13457
- registry.register('richText', () => import('./momentumcms-admin-rich-text-field.component-BUziCgyn.mjs').then((m) => m.RichTextFieldRenderer));
14216
+ registry.register('richText', () => import('./momentumcms-admin-rich-text-field.component-BVAQkX3O.mjs').then((m) => m.RichTextFieldRenderer));
13458
14217
  // Layout field renderers (support nested field rendering)
13459
- registry.register('group', () => import('./momentumcms-admin-group-field.component-Cenc5zMW.mjs').then((m) => m.GroupFieldRenderer));
13460
- registry.register('array', () => import('./momentumcms-admin-array-field.component-pqA3_nC8.mjs').then((m) => m.ArrayFieldRenderer));
13461
- registry.register('blocks', () => import('./momentumcms-admin-blocks-field.component-88TEhVm4.mjs').then((m) => m.BlocksFieldRenderer));
14218
+ registry.register('group', () => import('./momentumcms-admin-group-field.component-CMKcqfjy.mjs').then((m) => m.GroupFieldRenderer));
14219
+ registry.register('array', () => import('./momentumcms-admin-array-field.component-DH6vaHO-.mjs').then((m) => m.ArrayFieldRenderer));
14220
+ registry.register('blocks', () => import('./momentumcms-admin-blocks-field.component-BxJRfiV3.mjs').then((m) => m.BlocksFieldRenderer));
13462
14221
  // Visual block editor variant (blocks field with admin.editor === 'visual')
13463
14222
  registry.register('blocks-visual', () => Promise.resolve().then(function () { return visualBlockEditor_component; }).then((m) => m.VisualBlockEditorComponent));
13464
- registry.register('relationship', () => import('./momentumcms-admin-relationship-field.component-DlCdpcRy.mjs').then((m) => m.RelationshipFieldRenderer));
14223
+ registry.register('relationship', () => import('./momentumcms-admin-relationship-field.component-DNZUCENa.mjs').then((m) => m.RelationshipFieldRenderer));
13465
14224
  // Layout-only renderers (tabs, collapsible, row)
13466
- registry.register('tabs', () => import('./momentumcms-admin-tabs-field.component-D_T_JZej.mjs').then((m) => m.TabsFieldRenderer));
13467
- registry.register('collapsible', () => import('./momentumcms-admin-collapsible-field.component-D5Jc8h2Q.mjs').then((m) => m.CollapsibleFieldRenderer));
13468
- registry.register('row', () => import('./momentumcms-admin-row-field.component-fFTcYU-P.mjs').then((m) => m.RowFieldRenderer));
14225
+ registry.register('tabs', () => import('./momentumcms-admin-tabs-field.component-qYlbl8Ud.mjs').then((m) => m.TabsFieldRenderer));
14226
+ registry.register('collapsible', () => import('./momentumcms-admin-collapsible-field.component-CsjYCkGw.mjs').then((m) => m.CollapsibleFieldRenderer));
14227
+ registry.register('row', () => import('./momentumcms-admin-row-field.component-0F6cnUK_.mjs').then((m) => m.RowFieldRenderer));
13469
14228
  };
13470
14229
  },
13471
14230
  },
@@ -15245,4 +16004,4 @@ var uploadField_component = /*#__PURE__*/Object.freeze({
15245
16004
  */
15246
16005
 
15247
16006
  export { adminGuard as $, AdminShellComponent as A, BlockEditDialog as B, CheckboxFieldRenderer as C, DashboardPage as D, EntityFormWidget as E, FieldRenderer as F, MediaLibraryPage as G, MediaPickerDialog as H, MediaPreviewComponent as I, MomentumApiService as J, MomentumAuthService as K, LivePreviewComponent as L, MOMENTUM_API as M, NumberFieldRenderer as N, ResetPasswordPage as O, PublishControlsWidget as P, SKIP_AUTO_TOAST as Q, ResetPasswordFormComponent as R, SHEET_QUERY_PARAMS as S, SelectFieldRenderer as T, SetupPage as U, TextFieldRenderer as V, UploadFieldRenderer as W, UploadService as X, VersionHistoryWidget as Y, VersionService as Z, VisualBlockEditorComponent as _, getFieldNodeState as a, authGuard as a0, collectionAccessGuard as a1, crudToastInterceptor as a2, guestGuard as a3, injectHasAnyRole as a4, injectHasRole as a5, injectIsAdmin as a6, injectIsAuthenticated as a7, injectMomentumAPI as a8, injectTypedMomentumAPI as a9, injectUser as aa, injectUserRole as ab, injectVersionService as ac, momentumAdminRoutes as ad, provideFieldRenderer as ae, provideMomentumAPI as af, provideMomentumFieldRenderers as ag, setupGuard as ah, unsavedChangesGuard as ai, getSubNode as b, getFieldDefaultValue as c, EntitySheetService as d, getTitleField as e, AdminSidebarWidget as f, getGlobalsFromRouteData as g, BlockInserterComponent as h, isRecord as i, BlockWrapperComponent as j, CollectionAccessService as k, CollectionCardWidget as l, CollectionEditPage as m, normalizeBlockDefaults as n, CollectionListPage as o, CollectionViewPage as p, DateFieldRenderer as q, EntityListWidget as r, EntityViewWidget as s, FeedbackService as t, FieldRendererRegistry as u, ForgotPasswordFormComponent as v, ForgotPasswordPage as w, LoginPage as x, MOMENTUM_API_CONTEXT as y, McmsThemeService as z };
15248
- //# sourceMappingURL=momentumcms-admin-momentumcms-admin-5WigESOC.mjs.map
16007
+ //# sourceMappingURL=momentumcms-admin-momentumcms-admin-BTZEdMNj.mjs.map