@momentumcms/admin 0.1.10 → 0.3.0

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 (27) hide show
  1. package/fesm2022/{momentumcms-admin-array-field.component-BZva87Sh.mjs → momentumcms-admin-array-field.component-Bjlcczwg.mjs} +2 -2
  2. package/fesm2022/{momentumcms-admin-array-field.component-BZva87Sh.mjs.map → momentumcms-admin-array-field.component-Bjlcczwg.mjs.map} +1 -1
  3. package/fesm2022/{momentumcms-admin-blocks-field.component-CIxpyKAV.mjs → momentumcms-admin-blocks-field.component-4vLqDGbB.mjs} +2 -2
  4. package/fesm2022/{momentumcms-admin-blocks-field.component-CIxpyKAV.mjs.map → momentumcms-admin-blocks-field.component-4vLqDGbB.mjs.map} +1 -1
  5. package/fesm2022/{momentumcms-admin-collapsible-field.component-BpmaUKom.mjs → momentumcms-admin-collapsible-field.component-63-9kSgm.mjs} +3 -9
  6. package/fesm2022/momentumcms-admin-collapsible-field.component-63-9kSgm.mjs.map +1 -0
  7. package/fesm2022/{momentumcms-admin-global-edit.page-976NlNjw.mjs → momentumcms-admin-global-edit.page-DSnkwdgn.mjs} +2 -2
  8. package/fesm2022/{momentumcms-admin-global-edit.page-976NlNjw.mjs.map → momentumcms-admin-global-edit.page-DSnkwdgn.mjs.map} +1 -1
  9. package/fesm2022/{momentumcms-admin-group-field.component-Bgy_tQOG.mjs → momentumcms-admin-group-field.component-B48_zbo0.mjs} +2 -2
  10. package/fesm2022/momentumcms-admin-group-field.component-B48_zbo0.mjs.map +1 -0
  11. package/fesm2022/{momentumcms-admin-momentumcms-admin-TvEIOeYg.mjs → momentumcms-admin-momentumcms-admin-D_47TVaR.mjs} +1543 -751
  12. package/fesm2022/momentumcms-admin-momentumcms-admin-D_47TVaR.mjs.map +1 -0
  13. package/fesm2022/{momentumcms-admin-relationship-field.component-s46Lu33u.mjs → momentumcms-admin-relationship-field.component-D-UQgd7m.mjs} +2 -2
  14. package/fesm2022/{momentumcms-admin-relationship-field.component-s46Lu33u.mjs.map → momentumcms-admin-relationship-field.component-D-UQgd7m.mjs.map} +1 -1
  15. package/fesm2022/{momentumcms-admin-rich-text-field.component-Djv7tDS2.mjs → momentumcms-admin-rich-text-field.component-BC8pRU89.mjs} +2 -2
  16. package/fesm2022/{momentumcms-admin-rich-text-field.component-Djv7tDS2.mjs.map → momentumcms-admin-rich-text-field.component-BC8pRU89.mjs.map} +1 -1
  17. package/fesm2022/{momentumcms-admin-row-field.component-Dc5vqRQ8.mjs → momentumcms-admin-row-field.component--EOPGDtM.mjs} +2 -2
  18. package/fesm2022/{momentumcms-admin-row-field.component-Dc5vqRQ8.mjs.map → momentumcms-admin-row-field.component--EOPGDtM.mjs.map} +1 -1
  19. package/fesm2022/{momentumcms-admin-tabs-field.component-CdZoCrvw.mjs → momentumcms-admin-tabs-field.component-B4X73eCM.mjs} +73 -11
  20. package/fesm2022/momentumcms-admin-tabs-field.component-B4X73eCM.mjs.map +1 -0
  21. package/fesm2022/momentumcms-admin.mjs +1 -1
  22. package/package.json +1 -1
  23. package/types/momentumcms-admin.d.ts +92 -29
  24. package/fesm2022/momentumcms-admin-collapsible-field.component-BpmaUKom.mjs.map +0 -1
  25. package/fesm2022/momentumcms-admin-group-field.component-Bgy_tQOG.mjs.map +0 -1
  26. package/fesm2022/momentumcms-admin-momentumcms-admin-TvEIOeYg.mjs.map +0 -1
  27. package/fesm2022/momentumcms-admin-tabs-field.component-CdZoCrvw.mjs.map +0 -1
@@ -1,19 +1,18 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, signal, computed, Injectable, PLATFORM_ID, InjectionToken, makeStateKey, TransferState, DestroyRef, effect, input, ChangeDetectionStrategy, Component, output, Injector, untracked, runInInjectionContext, afterNextRender, viewChild, model, forwardRef, makeEnvironmentProviders, ENVIRONMENT_INITIALIZER } from '@angular/core';
2
+ import { inject, signal, computed, Injectable, PLATFORM_ID, InjectionToken, makeStateKey, TransferState, DestroyRef, effect, input, ChangeDetectionStrategy, Component, output, viewChild, Injector, untracked, runInInjectionContext, afterNextRender, model, forwardRef, makeEnvironmentProviders, ENVIRONMENT_INITIALIZER } from '@angular/core';
3
3
  import { DOCUMENT, isPlatformBrowser, isPlatformServer, NgComponentOutlet, DatePipe } from '@angular/common';
4
4
  import { Router, NavigationEnd, ActivatedRoute, RouterOutlet, RouterLink } from '@angular/router';
5
5
  import { HttpClient, HttpContextToken, HttpResponse, HttpErrorResponse, HttpRequest, HttpEventType, HttpParams } from '@angular/common/http';
6
6
  import { firstValueFrom, tap, catchError, throwError, Subject, finalize, Observable, of, filter, take } from 'rxjs';
7
- import { ToastService, ConfirmationService, DIALOG_DATA, Dialog, DialogHeader, DialogTitle, DialogContent, DialogFooter, DialogClose, Button, Badge, Skeleton, DialogService, Card, CardHeader, CardContent, Separator, CardFooter, Spinner, Alert, Breadcrumbs, BreadcrumbItem, BreadcrumbSeparator, FieldDisplay, Sidebar, SidebarNav, SidebarNavItem, SidebarSection, Avatar, AvatarFallback, DropdownMenu, DropdownMenuItem, DropdownSeparator, DropdownLabel, DropdownTrigger, SidebarService, SidebarTrigger, ToastContainer, Input, McmsFormField, DialogRef, DataTable, Label, Select, CardTitle, CardDescription, Textarea, Pagination, SearchInput, PopoverTrigger, PopoverContent, Command, CommandInput, CommandList, CommandGroup, CommandItem, CommandEmpty, Checkbox, Progress } from '@momentumcms/ui';
7
+ import { ToastService, ConfirmationService, DIALOG_DATA, Dialog, DialogHeader, DialogTitle, DialogContent, DialogFooter, DialogClose, Button, Badge, Skeleton, DialogService, Card, CardHeader, CardContent, Separator, Progress, CardFooter, Spinner, Alert, Breadcrumbs, BreadcrumbItem, BreadcrumbSeparator, FieldDisplay, Sidebar, SidebarNav, SidebarNavItem, SidebarSection, Avatar, AvatarFallback, DropdownMenu, DropdownMenuItem, DropdownSeparator, DropdownLabel, DropdownTrigger, SidebarService, SidebarTrigger, ToastContainer, Input, McmsFormField, DialogRef, DataTable, Label, Select, CardTitle, CardDescription, Textarea, Pagination, SearchInput, PopoverTrigger, PopoverContent, Command, CommandInput, CommandList, CommandGroup, CommandItem, CommandEmpty, Checkbox } from '@momentumcms/ui';
8
8
  import { map } from 'rxjs/operators';
9
9
  import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
10
10
  import * as i1 from '@angular/cdk/a11y';
11
11
  import { LiveAnnouncer, A11yModule } from '@angular/cdk/a11y';
12
- import { flattenDataFields, humanizeFieldName, getSoftDeleteField } from '@momentumcms/core';
12
+ import { flattenDataFields, humanizeFieldName, isUploadCollection, getSoftDeleteField } from '@momentumcms/core';
13
13
  import { NgIcon, provideIcons } from '@ng-icons/core';
14
- import { heroXMark, heroPuzzlePiece, heroCog6Tooth, heroDocumentText, heroChartBarSquare, heroChevronUpDown, heroBolt, heroFolder, heroDocument, heroPhoto, heroUsers, heroNewspaper, heroSquares2x2, heroEye, heroFilm, heroMusicalNote, heroArchiveBox, heroPencilSquare, heroArrowDownTray, heroTrash, heroCloudArrowUp, heroChevronRight, heroChevronDown, heroChevronUp, heroPlus } from '@ng-icons/heroicons/outline';
14
+ import { heroFilm, heroMusicalNote, heroDocumentText, heroArchiveBox, heroPhoto, heroDocument, heroCloudArrowUp, heroXMark, heroPuzzlePiece, heroCog6Tooth, heroChartBarSquare, heroChevronUpDown, heroBolt, heroFolder, heroUsers, heroNewspaper, heroSquares2x2, heroEye, heroPencilSquare, heroArrowDownTray, heroTrash, heroChevronRight, heroChevronDown, heroChevronUp, heroPlus } from '@ng-icons/heroicons/outline';
15
15
  import { required, validate, applyEach, apply, email, min, max, minLength, maxLength, form, submit } from '@angular/forms/signals';
16
- import { DomSanitizer } from '@angular/platform-browser';
17
16
  import { moveItemInArray, CdkDropList, CdkDrag, CdkDragPlaceholder } from '@angular/cdk/drag-drop';
18
17
 
19
18
  /**
@@ -1135,7 +1134,7 @@ function momentumAdminRoutes(configOrOptions) {
1135
1134
  // Global edit
1136
1135
  {
1137
1136
  path: 'globals/:slug',
1138
- loadComponent: () => import('./momentumcms-admin-global-edit.page-976NlNjw.mjs').then((m) => m.GlobalEditPage),
1137
+ loadComponent: () => import('./momentumcms-admin-global-edit.page-DSnkwdgn.mjs').then((m) => m.GlobalEditPage),
1139
1138
  canDeactivate: [unsavedChangesGuard],
1140
1139
  },
1141
1140
  // Plugin-registered routes
@@ -1539,6 +1538,95 @@ class UploadService {
1539
1538
  });
1540
1539
  return subject.asObservable();
1541
1540
  }
1541
+ /**
1542
+ * Upload a file to a specific upload collection.
1543
+ * POSTs multipart/form-data to /api/{collectionSlug} with file + additional fields.
1544
+ *
1545
+ * @param collectionSlug - Target collection (e.g., 'media', 'documents')
1546
+ * @param file - The file to upload
1547
+ * @param fields - Additional form fields to include (e.g., alt, title)
1548
+ * @returns Observable emitting upload progress
1549
+ */
1550
+ uploadToCollection(collectionSlug, file, fields) {
1551
+ const subject = new Subject();
1552
+ const formData = new FormData();
1553
+ formData.append('file', file);
1554
+ if (fields) {
1555
+ for (const [key, value] of Object.entries(fields)) {
1556
+ formData.append(key, value);
1557
+ }
1558
+ }
1559
+ const initialProgress = {
1560
+ status: 'pending',
1561
+ progress: 0,
1562
+ file,
1563
+ };
1564
+ this.updateActiveUpload(file, initialProgress);
1565
+ subject.next(initialProgress);
1566
+ const url = `/api/${collectionSlug}`;
1567
+ const request = new HttpRequest('POST', url, formData, {
1568
+ reportProgress: true,
1569
+ });
1570
+ this.http
1571
+ .request(request)
1572
+ .pipe(finalize(() => {
1573
+ const uploads = new Map(this.activeUploadsSignal());
1574
+ uploads.delete(file);
1575
+ this.activeUploadsSignal.set(uploads);
1576
+ subject.complete();
1577
+ }))
1578
+ .subscribe({
1579
+ next: (event) => {
1580
+ if (event.type === HttpEventType.UploadProgress) {
1581
+ const progress = event.total
1582
+ ? Math.round((100 * event.loaded) / event.total)
1583
+ : 0;
1584
+ const uploadingProgress = {
1585
+ status: 'uploading',
1586
+ progress,
1587
+ file,
1588
+ };
1589
+ this.updateActiveUpload(file, uploadingProgress);
1590
+ subject.next(uploadingProgress);
1591
+ }
1592
+ else if (event.type === HttpEventType.Response) {
1593
+ const body = event.body;
1594
+ if (body?.doc) {
1595
+ const completeProgress = {
1596
+ status: 'complete',
1597
+ progress: 100,
1598
+ file,
1599
+ result: body.doc,
1600
+ };
1601
+ this.updateActiveUpload(file, completeProgress);
1602
+ subject.next(completeProgress);
1603
+ }
1604
+ else {
1605
+ const errorProgress = {
1606
+ status: 'error',
1607
+ progress: 0,
1608
+ file,
1609
+ error: body?.error ?? 'Upload failed',
1610
+ };
1611
+ this.updateActiveUpload(file, errorProgress);
1612
+ subject.next(errorProgress);
1613
+ }
1614
+ }
1615
+ },
1616
+ error: (error) => {
1617
+ const errorProgress = {
1618
+ status: 'error',
1619
+ progress: 0,
1620
+ file,
1621
+ error: error.message ?? 'Upload failed',
1622
+ };
1623
+ this.updateActiveUpload(file, errorProgress);
1624
+ subject.next(errorProgress);
1625
+ subject.error(error);
1626
+ },
1627
+ });
1628
+ return subject.asObservable();
1629
+ }
1542
1630
  /**
1543
1631
  * Upload multiple files.
1544
1632
  *
@@ -3530,15 +3618,20 @@ class FieldRenderer {
3530
3618
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: FieldRenderer, deps: [], target: i0.ɵɵFactoryTarget.Component });
3531
3619
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: FieldRenderer, isStandalone: true, selector: "mcms-field-renderer", inputs: { field: { classPropertyName: "field", publicName: "field", isSignal: true, isRequired: true, transformFunction: null }, formNode: { classPropertyName: "formNode", publicName: "formNode", isSignal: true, isRequired: false, transformFunction: null }, formTree: { classPropertyName: "formTree", publicName: "formTree", isSignal: true, isRequired: false, transformFunction: null }, formModel: { classPropertyName: "formModel", publicName: "formModel", isSignal: true, isRequired: false, transformFunction: null }, mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, path: { classPropertyName: "path", publicName: "path", isSignal: true, isRequired: true, transformFunction: null } }, host: { classAttribute: "block" }, ngImport: i0, template: `
3532
3620
  @if (resolvedComponent()) {
3533
- <ng-container
3534
- *ngComponentOutlet="resolvedComponent(); inputs: rendererInputs()"
3535
- />
3621
+ <ng-container *ngComponentOutlet="resolvedComponent(); inputs: rendererInputs()" />
3536
3622
  } @else if (loadError()) {
3537
- <div role="alert" class="rounded-md border border-destructive/50 bg-destructive/10 p-2 text-sm text-destructive">
3623
+ <div
3624
+ role="alert"
3625
+ class="rounded-md border border-destructive/50 bg-destructive/10 p-2 text-sm text-destructive"
3626
+ >
3538
3627
  Failed to load field renderer
3539
3628
  </div>
3540
3629
  } @else {
3541
- <div role="status" aria-label="Loading field" class="h-10 animate-pulse rounded-md bg-muted"></div>
3630
+ <div
3631
+ role="status"
3632
+ aria-label="Loading field"
3633
+ class="h-10 animate-pulse rounded-md bg-muted"
3634
+ ></div>
3542
3635
  }
3543
3636
  `, isInline: true, dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3544
3637
  }
@@ -3551,15 +3644,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
3551
3644
  host: { class: 'block' },
3552
3645
  template: `
3553
3646
  @if (resolvedComponent()) {
3554
- <ng-container
3555
- *ngComponentOutlet="resolvedComponent(); inputs: rendererInputs()"
3556
- />
3647
+ <ng-container *ngComponentOutlet="resolvedComponent(); inputs: rendererInputs()" />
3557
3648
  } @else if (loadError()) {
3558
- <div role="alert" class="rounded-md border border-destructive/50 bg-destructive/10 p-2 text-sm text-destructive">
3649
+ <div
3650
+ role="alert"
3651
+ class="rounded-md border border-destructive/50 bg-destructive/10 p-2 text-sm text-destructive"
3652
+ >
3559
3653
  Failed to load field renderer
3560
3654
  </div>
3561
3655
  } @else {
3562
- <div role="status" aria-label="Loading field" class="h-10 animate-pulse rounded-md bg-muted"></div>
3656
+ <div
3657
+ role="status"
3658
+ aria-label="Loading field"
3659
+ class="h-10 animate-pulse rounded-md bg-muted"
3660
+ ></div>
3563
3661
  }
3564
3662
  `,
3565
3663
  }]
@@ -4119,113 +4217,731 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
4119
4217
  }], ctorParameters: () => [], propDecorators: { collection: [{ type: i0.Input, args: [{ isSignal: true, alias: "collection", required: true }] }], documentId: [{ type: i0.Input, args: [{ isSignal: true, alias: "documentId", required: true }] }], documentLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "documentLabel", required: false }] }], restored: [{ type: i0.Output, args: ["restored"] }] } });
4120
4218
 
4121
4219
  /**
4122
- * Entity Form Widget
4220
+ * Media Preview Component
4123
4221
  *
4124
- * Dynamic form for create/edit operations using Angular Signal Forms.
4222
+ * Displays a preview of media based on its type:
4223
+ * - Images: Thumbnail preview
4224
+ * - Videos: Video icon with optional poster
4225
+ * - Audio: Audio icon
4226
+ * - Documents: Document icon
4227
+ * - Other: Generic file icon
4125
4228
  *
4126
4229
  * @example
4127
4230
  * ```html
4128
- * <mcms-entity-form
4129
- * [collection]="postsCollection"
4130
- * entityId="123"
4131
- * mode="edit"
4132
- * (saved)="onSaved($event)"
4133
- * (cancelled)="onCancel()"
4231
+ * <mcms-media-preview
4232
+ * [media]="mediaDocument"
4233
+ * [size]="'md'"
4134
4234
  * />
4135
4235
  * ```
4136
4236
  */
4137
- class EntityFormWidget {
4138
- api = injectMomentumAPI();
4139
- injector = inject(Injector);
4140
- versionService = inject(VersionService);
4141
- collectionAccess = inject(CollectionAccessService);
4142
- feedback = inject(FeedbackService);
4143
- router = inject(Router);
4144
- liveAnnouncer = inject(LiveAnnouncer);
4145
- /** The collection configuration */
4146
- collection = input.required(...(ngDevMode ? [{ debugName: "collection" }] : []));
4147
- /** Entity ID for edit mode (undefined for create) */
4148
- entityId = input(undefined, ...(ngDevMode ? [{ debugName: "entityId" }] : []));
4149
- /** Form mode */
4150
- mode = input('create', ...(ngDevMode ? [{ debugName: "mode" }] : []));
4151
- /** Base path for navigation */
4152
- basePath = input('/admin/collections', ...(ngDevMode ? [{ debugName: "basePath" }] : []));
4153
- /** Whether to show breadcrumbs */
4154
- showBreadcrumbs = input(true, ...(ngDevMode ? [{ debugName: "showBreadcrumbs" }] : []));
4155
- /** When true, prevents router navigation after save/cancel (used in entity sheet) */
4156
- suppressNavigation = input(false, ...(ngDevMode ? [{ debugName: "suppressNavigation" }] : []));
4157
- /** When true, uses the global API instead of collection API (singleton mode) */
4158
- isGlobal = input(false, ...(ngDevMode ? [{ debugName: "isGlobal" }] : []));
4159
- /** The global slug (used when isGlobal is true) */
4160
- globalSlug = input(undefined, ...(ngDevMode ? [{ debugName: "globalSlug" }] : []));
4161
- /** Outputs */
4162
- saved = output();
4163
- cancelled = output();
4164
- saveError = output();
4165
- modeChange = output();
4166
- draftSaved = output();
4167
- /** Model signal — the single source of truth for form data */
4168
- formModel = signal({}, ...(ngDevMode ? [{ debugName: "formModel" }] : []));
4169
- /** Alias for backward compatibility (CollectionEditPage reads formData) */
4170
- formData = this.formModel;
4171
- /** Signal forms tree created once when collection is available */
4172
- entityForm = signal(null, ...(ngDevMode ? [{ debugName: "entityForm" }] : []));
4173
- /** Original data for edit mode */
4174
- originalData = signal(null, ...(ngDevMode ? [{ debugName: "originalData" }] : []));
4175
- /** UI state */
4176
- isLoading = signal(false, ...(ngDevMode ? [{ debugName: "isLoading" }] : []));
4177
- isSubmitting = signal(false, ...(ngDevMode ? [{ debugName: "isSubmitting" }] : []));
4178
- isSavingDraft = signal(false, ...(ngDevMode ? [{ debugName: "isSavingDraft" }] : []));
4179
- formError = signal(null, ...(ngDevMode ? [{ debugName: "formError" }] : []));
4180
- /** Whether the form has been set up */
4181
- formCreated = false;
4182
- /** Whether the form has unsaved changes (from signal forms dirty tracking) */
4183
- isDirty = computed(() => {
4184
- const ef = this.entityForm();
4185
- return ef ? ef().dirty() : false;
4186
- }, ...(ngDevMode ? [{ debugName: "isDirty" }] : []));
4187
- /** Computed collection label */
4188
- collectionLabel = computed(() => {
4189
- const col = this.collection();
4190
- return humanizeFieldName(col.labels?.plural || col.slug);
4191
- }, ...(ngDevMode ? [{ debugName: "collectionLabel" }] : []));
4192
- /** Computed collection label singular */
4193
- collectionLabelSingular = computed(() => {
4194
- const col = this.collection();
4195
- return humanizeFieldName(col.labels?.singular || col.slug);
4196
- }, ...(ngDevMode ? [{ debugName: "collectionLabelSingular" }] : []));
4197
- /** Dashboard path (remove /collections from base path) */
4198
- dashboardPath = computed(() => {
4199
- const base = this.basePath();
4200
- return base.replace(/\/collections$/, '');
4201
- }, ...(ngDevMode ? [{ debugName: "dashboardPath" }] : []));
4202
- /** Collection list path */
4203
- collectionListPath = computed(() => {
4204
- return `${this.basePath()}/${this.collection().slug}`;
4205
- }, ...(ngDevMode ? [{ debugName: "collectionListPath" }] : []));
4206
- /** Page title for breadcrumb */
4207
- pageTitle = computed(() => {
4208
- if (this.isGlobal()) {
4209
- return this.collectionLabelSingular();
4237
+ class MediaPreviewComponent {
4238
+ /** Media data to preview */
4239
+ media = input(null, ...(ngDevMode ? [{ debugName: "media" }] : []));
4240
+ /** Size of the preview */
4241
+ size = input('md', ...(ngDevMode ? [{ debugName: "size" }] : []));
4242
+ /** Custom class override */
4243
+ class = input('', ...(ngDevMode ? [{ debugName: "class" }] : []));
4244
+ /** Whether to show rounded corners */
4245
+ rounded = input(true, ...(ngDevMode ? [{ debugName: "rounded" }] : []));
4246
+ /** Host classes */
4247
+ hostClasses = computed(() => {
4248
+ const sizeClass = this.sizeClasses()[this.size()];
4249
+ const roundedClass = this.rounded() ? 'rounded-md overflow-hidden' : '';
4250
+ return `inline-block ${sizeClass} ${roundedClass} ${this.class()}`;
4251
+ }, ...(ngDevMode ? [{ debugName: "hostClasses" }] : []));
4252
+ /** Size classes map */
4253
+ sizeClasses = computed(() => ({
4254
+ xs: 'h-8 w-8',
4255
+ sm: 'h-12 w-12',
4256
+ md: 'h-20 w-20',
4257
+ lg: 'h-32 w-32',
4258
+ xl: 'h-48 w-48',
4259
+ }), ...(ngDevMode ? [{ debugName: "sizeClasses" }] : []));
4260
+ /** Icon size classes */
4261
+ iconClasses = computed(() => {
4262
+ const sizes = {
4263
+ xs: 'text-lg',
4264
+ sm: 'text-xl',
4265
+ md: 'text-3xl',
4266
+ lg: 'text-4xl',
4267
+ xl: 'text-6xl',
4268
+ };
4269
+ return `${sizes[this.size()]} text-mcms-muted-foreground`;
4270
+ }, ...(ngDevMode ? [{ debugName: "iconClasses" }] : []));
4271
+ /** Whether the media is an image */
4272
+ isImage = computed(() => {
4273
+ const mimeType = this.media()?.mimeType ?? '';
4274
+ return mimeType.startsWith('image/');
4275
+ }, ...(ngDevMode ? [{ debugName: "isImage" }] : []));
4276
+ /** Whether the media is a video */
4277
+ isVideo = computed(() => {
4278
+ const mimeType = this.media()?.mimeType ?? '';
4279
+ return mimeType.startsWith('video/');
4280
+ }, ...(ngDevMode ? [{ debugName: "isVideo" }] : []));
4281
+ /** Whether the media is audio */
4282
+ isAudio = computed(() => {
4283
+ const mimeType = this.media()?.mimeType ?? '';
4284
+ return mimeType.startsWith('audio/');
4285
+ }, ...(ngDevMode ? [{ debugName: "isAudio" }] : []));
4286
+ /** Image URL for preview */
4287
+ imageUrl = computed(() => {
4288
+ const media = this.media();
4289
+ if (!media)
4290
+ return '';
4291
+ return media.url ?? `/api/media/file/${media.path}`;
4292
+ }, ...(ngDevMode ? [{ debugName: "imageUrl" }] : []));
4293
+ /** Icon name based on media type */
4294
+ iconName = computed(() => {
4295
+ const mimeType = this.media()?.mimeType ?? '';
4296
+ if (mimeType.startsWith('video/')) {
4297
+ return heroFilm;
4210
4298
  }
4211
- const currentMode = this.mode();
4212
- if (currentMode === 'create') {
4213
- return `Create ${this.collectionLabelSingular()}`;
4299
+ if (mimeType.startsWith('audio/')) {
4300
+ return heroMusicalNote;
4214
4301
  }
4215
- const data = this.formModel();
4216
- const titleFields = ['title', 'name', 'label', 'subject'];
4217
- for (const field of titleFields) {
4218
- if (data[field] && typeof data[field] === 'string') {
4219
- return data[field];
4220
- }
4302
+ if (mimeType === 'application/pdf') {
4303
+ return heroDocumentText;
4221
4304
  }
4222
- return `Edit ${this.collectionLabelSingular()}`;
4223
- }, ...(ngDevMode ? [{ debugName: "pageTitle" }] : []));
4224
- /** Visible fields (excluding hidden ones and those failing admin.condition) */
4225
- visibleFields = computed(() => {
4226
- const col = this.collection();
4227
- const data = this.formModel();
4228
- return col.fields.filter((field) => {
4305
+ if (mimeType.startsWith('application/zip') || mimeType.includes('compressed')) {
4306
+ return heroArchiveBox;
4307
+ }
4308
+ if (mimeType.startsWith('image/')) {
4309
+ return heroPhoto;
4310
+ }
4311
+ return heroDocument;
4312
+ }, ...(ngDevMode ? [{ debugName: "iconName" }] : []));
4313
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: MediaPreviewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4314
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: MediaPreviewComponent, isStandalone: true, selector: "mcms-media-preview", inputs: { media: { classPropertyName: "media", publicName: "media", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, rounded: { classPropertyName: "rounded", publicName: "rounded", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "hostClasses()" } }, ngImport: i0, template: `
4315
+ @if (isImage()) {
4316
+ <img
4317
+ [src]="imageUrl()"
4318
+ [alt]="media()?.alt ?? media()?.filename ?? 'Media preview'"
4319
+ class="h-full w-full object-cover"
4320
+ />
4321
+ } @else {
4322
+ <div class="flex h-full w-full items-center justify-center bg-mcms-muted">
4323
+ <ng-icon [name]="iconName()" [class]="iconClasses()" />
4324
+ </div>
4325
+ }
4326
+ `, isInline: true, dependencies: [{ kind: "component", type: NgIcon, selector: "ng-icon", inputs: ["name", "svg", "size", "strokeWidth", "color"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4327
+ }
4328
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: MediaPreviewComponent, decorators: [{
4329
+ type: Component,
4330
+ args: [{
4331
+ selector: 'mcms-media-preview',
4332
+ host: {
4333
+ '[class]': 'hostClasses()',
4334
+ },
4335
+ template: `
4336
+ @if (isImage()) {
4337
+ <img
4338
+ [src]="imageUrl()"
4339
+ [alt]="media()?.alt ?? media()?.filename ?? 'Media preview'"
4340
+ class="h-full w-full object-cover"
4341
+ />
4342
+ } @else {
4343
+ <div class="flex h-full w-full items-center justify-center bg-mcms-muted">
4344
+ <ng-icon [name]="iconName()" [class]="iconClasses()" />
4345
+ </div>
4346
+ }
4347
+ `,
4348
+ imports: [NgIcon],
4349
+ changeDetection: ChangeDetectionStrategy.OnPush,
4350
+ }]
4351
+ }], propDecorators: { media: [{ type: i0.Input, args: [{ isSignal: true, alias: "media", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], rounded: [{ type: i0.Input, args: [{ isSignal: true, alias: "rounded", required: false }] }] } });
4352
+
4353
+ /**
4354
+ * Upload zone component for upload collections.
4355
+ * Shows a drag-and-drop zone above the form fields.
4356
+ * When a file is selected, emits the file for the parent entity form to handle.
4357
+ */
4358
+ /**
4359
+ * Get string property from an unknown object.
4360
+ */
4361
+ function getStringProp$1(obj, key) {
4362
+ if (typeof obj !== 'object' || obj === null)
4363
+ return undefined;
4364
+ if (!(key in obj))
4365
+ return undefined;
4366
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
4367
+ const value = obj[key]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
4368
+ return typeof value === 'string' ? value : undefined;
4369
+ }
4370
+ /**
4371
+ * Get HTMLInputElement safely from event.
4372
+ */
4373
+ function getInputFromEvent$1(event) {
4374
+ const target = event.target;
4375
+ if (target instanceof HTMLInputElement) {
4376
+ return target;
4377
+ }
4378
+ return null;
4379
+ }
4380
+ class CollectionUploadZoneComponent {
4381
+ /** Reference to the hidden file input */
4382
+ fileInputRef = viewChild('fileInput', ...(ngDevMode ? [{ debugName: "fileInputRef" }] : []));
4383
+ /** Upload config from the collection */
4384
+ uploadConfig = input(undefined, ...(ngDevMode ? [{ debugName: "uploadConfig" }] : []));
4385
+ /** Whether the zone is disabled */
4386
+ disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
4387
+ /** Currently selected file (read from parent) */
4388
+ pendingFile = input(null, ...(ngDevMode ? [{ debugName: "pendingFile" }] : []));
4389
+ /** Whether an upload is in progress */
4390
+ isUploading = input(false, ...(ngDevMode ? [{ debugName: "isUploading" }] : []));
4391
+ /** Upload progress percentage */
4392
+ uploadProgress = input(0, ...(ngDevMode ? [{ debugName: "uploadProgress" }] : []));
4393
+ /** Error message */
4394
+ error = input(null, ...(ngDevMode ? [{ debugName: "error" }] : []));
4395
+ /** Existing media data for edit mode (filename, mimeType, path, url) */
4396
+ existingMedia = input(null, ...(ngDevMode ? [{ debugName: "existingMedia" }] : []));
4397
+ /** Emitted when a file is selected */
4398
+ fileSelected = output();
4399
+ /** Emitted when the file is removed */
4400
+ fileRemoved = output();
4401
+ /** Icons */
4402
+ uploadIcon = heroCloudArrowUp;
4403
+ xMarkIcon = heroXMark;
4404
+ /** Drag state */
4405
+ isDragging = signal(false, ...(ngDevMode ? [{ debugName: "isDragging" }] : []));
4406
+ /** Cached object URL for file preview (managed to prevent memory leaks) */
4407
+ previewUrl = signal(null, ...(ngDevMode ? [{ debugName: "previewUrl" }] : []));
4408
+ constructor() {
4409
+ // Manage object URL lifecycle: create when file changes, revoke old one
4410
+ effect((onCleanup) => {
4411
+ const file = this.pendingFile();
4412
+ if (file) {
4413
+ const url = URL.createObjectURL(file);
4414
+ this.previewUrl.set(url);
4415
+ onCleanup(() => URL.revokeObjectURL(url));
4416
+ }
4417
+ else {
4418
+ this.previewUrl.set(null);
4419
+ }
4420
+ });
4421
+ }
4422
+ /** Preview data for the pending file */
4423
+ previewData = computed(() => {
4424
+ const file = this.pendingFile();
4425
+ if (!file)
4426
+ return null;
4427
+ return {
4428
+ mimeType: file.type,
4429
+ filename: file.name,
4430
+ url: this.previewUrl() ?? undefined,
4431
+ };
4432
+ }, ...(ngDevMode ? [{ debugName: "previewData" }] : []));
4433
+ /** Preview data for existing media (edit mode) */
4434
+ existingMediaPreview = computed(() => {
4435
+ const media = this.existingMedia();
4436
+ if (!media)
4437
+ return null;
4438
+ return {
4439
+ url: getStringProp$1(media, 'url'),
4440
+ path: getStringProp$1(media, 'path'),
4441
+ mimeType: getStringProp$1(media, 'mimeType'),
4442
+ filename: getStringProp$1(media, 'filename'),
4443
+ alt: getStringProp$1(media, 'alt'),
4444
+ };
4445
+ }, ...(ngDevMode ? [{ debugName: "existingMediaPreview" }] : []));
4446
+ /** Filename of existing media */
4447
+ existingFilename = computed(() => {
4448
+ return getStringProp$1(this.existingMedia(), 'filename') ?? 'Uploaded file';
4449
+ }, ...(ngDevMode ? [{ debugName: "existingFilename" }] : []));
4450
+ /** MIME type of existing media */
4451
+ existingMimeType = computed(() => {
4452
+ return getStringProp$1(this.existingMedia(), 'mimeType') ?? '';
4453
+ }, ...(ngDevMode ? [{ debugName: "existingMimeType" }] : []));
4454
+ /** MIME types hint for display */
4455
+ mimeTypesHint = computed(() => {
4456
+ const mimeTypes = this.uploadConfig()?.mimeTypes;
4457
+ if (!mimeTypes || mimeTypes.length === 0)
4458
+ return null;
4459
+ const simplified = mimeTypes.map((type) => {
4460
+ if (type === 'image/*')
4461
+ return 'Images';
4462
+ if (type === 'video/*')
4463
+ return 'Videos';
4464
+ if (type === 'audio/*')
4465
+ return 'Audio';
4466
+ if (type === 'application/pdf')
4467
+ return 'PDF';
4468
+ return type;
4469
+ });
4470
+ return `Allowed: ${simplified.join(', ')}`;
4471
+ }, ...(ngDevMode ? [{ debugName: "mimeTypesHint" }] : []));
4472
+ /** Max size hint for display */
4473
+ maxSizeHint = computed(() => {
4474
+ const maxSize = this.uploadConfig()?.maxFileSize;
4475
+ if (!maxSize)
4476
+ return null;
4477
+ if (maxSize >= 1024 * 1024 * 1024) {
4478
+ return `${(maxSize / (1024 * 1024 * 1024)).toFixed(1)} GB`;
4479
+ }
4480
+ if (maxSize >= 1024 * 1024) {
4481
+ return `${(maxSize / (1024 * 1024)).toFixed(1)} MB`;
4482
+ }
4483
+ if (maxSize >= 1024) {
4484
+ return `${(maxSize / 1024).toFixed(1)} KB`;
4485
+ }
4486
+ return `${maxSize} bytes`;
4487
+ }, ...(ngDevMode ? [{ debugName: "maxSizeHint" }] : []));
4488
+ /** Accept attribute for file input */
4489
+ acceptAttribute = computed(() => {
4490
+ const mimeTypes = this.uploadConfig()?.mimeTypes;
4491
+ if (!mimeTypes || mimeTypes.length === 0)
4492
+ return '*/*';
4493
+ return mimeTypes.join(',');
4494
+ }, ...(ngDevMode ? [{ debugName: "acceptAttribute" }] : []));
4495
+ /**
4496
+ * Format file size for display.
4497
+ */
4498
+ formatFileSize(bytes) {
4499
+ if (bytes >= 1024 * 1024 * 1024) {
4500
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
4501
+ }
4502
+ if (bytes >= 1024 * 1024) {
4503
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
4504
+ }
4505
+ if (bytes >= 1024) {
4506
+ return `${(bytes / 1024).toFixed(1)} KB`;
4507
+ }
4508
+ return `${bytes} bytes`;
4509
+ }
4510
+ /**
4511
+ * Handle drag over event.
4512
+ */
4513
+ onDragOver(event) {
4514
+ event.preventDefault();
4515
+ event.stopPropagation();
4516
+ if (!this.disabled()) {
4517
+ this.isDragging.set(true);
4518
+ }
4519
+ }
4520
+ /**
4521
+ * Handle drag leave event.
4522
+ */
4523
+ onDragLeave(event) {
4524
+ event.preventDefault();
4525
+ event.stopPropagation();
4526
+ this.isDragging.set(false);
4527
+ }
4528
+ /**
4529
+ * Handle file drop.
4530
+ */
4531
+ onDrop(event) {
4532
+ event.preventDefault();
4533
+ event.stopPropagation();
4534
+ this.isDragging.set(false);
4535
+ if (this.disabled())
4536
+ return;
4537
+ const files = event.dataTransfer?.files;
4538
+ if (files && files.length > 0) {
4539
+ this.fileSelected.emit(files[0]);
4540
+ }
4541
+ }
4542
+ /**
4543
+ * Trigger hidden file input click.
4544
+ */
4545
+ triggerFileInput() {
4546
+ if (this.disabled())
4547
+ return;
4548
+ const ref = this.fileInputRef();
4549
+ if (ref) {
4550
+ ref.nativeElement.click();
4551
+ }
4552
+ }
4553
+ /**
4554
+ * Handle file selection from input.
4555
+ */
4556
+ onFileSelected(event) {
4557
+ const input = getInputFromEvent$1(event);
4558
+ if (!input)
4559
+ return;
4560
+ const files = input.files;
4561
+ if (files && files.length > 0) {
4562
+ this.fileSelected.emit(files[0]);
4563
+ }
4564
+ // Reset input so same file can be selected again
4565
+ input.value = '';
4566
+ }
4567
+ /**
4568
+ * Remove the pending file.
4569
+ */
4570
+ removeFile() {
4571
+ this.fileRemoved.emit();
4572
+ }
4573
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: CollectionUploadZoneComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4574
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: CollectionUploadZoneComponent, isStandalone: true, selector: "mcms-collection-upload-zone", inputs: { uploadConfig: { classPropertyName: "uploadConfig", publicName: "uploadConfig", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, pendingFile: { classPropertyName: "pendingFile", publicName: "pendingFile", isSignal: true, isRequired: false, transformFunction: null }, isUploading: { classPropertyName: "isUploading", publicName: "isUploading", isSignal: true, isRequired: false, transformFunction: null }, uploadProgress: { classPropertyName: "uploadProgress", publicName: "uploadProgress", isSignal: true, isRequired: false, transformFunction: null }, error: { classPropertyName: "error", publicName: "error", isSignal: true, isRequired: false, transformFunction: null }, existingMedia: { classPropertyName: "existingMedia", publicName: "existingMedia", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { fileSelected: "fileSelected", fileRemoved: "fileRemoved" }, host: { classAttribute: "block mb-6" }, viewQueries: [{ propertyName: "fileInputRef", first: true, predicate: ["fileInput"], descendants: true, isSignal: true }], ngImport: i0, template: `
4575
+ @if (pendingFile()) {
4576
+ <!-- File selected preview -->
4577
+ <div class="rounded-lg border border-mcms-border bg-mcms-card p-4">
4578
+ <div class="flex items-center gap-4">
4579
+ <mcms-media-preview [media]="previewData()" size="lg" />
4580
+ <div class="min-w-0 flex-1">
4581
+ <p class="truncate text-sm font-medium">{{ pendingFile()!.name }}</p>
4582
+ <p class="text-xs text-mcms-muted-foreground">
4583
+ {{ formatFileSize(pendingFile()!.size) }} &middot; {{ pendingFile()!.type }}
4584
+ </p>
4585
+ @if (isUploading()) {
4586
+ <mcms-progress [value]="uploadProgress()" class="mt-2" />
4587
+ <p class="mt-1 text-xs text-mcms-muted-foreground">
4588
+ {{ uploadProgress() }}% uploaded
4589
+ </p>
4590
+ }
4591
+ </div>
4592
+ @if (!isUploading()) {
4593
+ <button
4594
+ mcms-button
4595
+ variant="ghost"
4596
+ size="sm"
4597
+ type="button"
4598
+ (click)="removeFile()"
4599
+ aria-label="Remove selected file"
4600
+ >
4601
+ <ng-icon [name]="xMarkIcon" class="h-4 w-4" />
4602
+ </button>
4603
+ }
4604
+ </div>
4605
+ </div>
4606
+ } @else if (existingMediaPreview()) {
4607
+ <!-- Existing file preview (edit mode) -->
4608
+ <div class="rounded-lg border border-mcms-border bg-mcms-card p-4">
4609
+ <div class="flex items-center gap-4">
4610
+ <mcms-media-preview [media]="existingMediaPreview()" size="lg" />
4611
+ <div class="min-w-0 flex-1">
4612
+ <p class="truncate text-sm font-medium">{{ existingFilename() }}</p>
4613
+ <p class="text-xs text-mcms-muted-foreground">{{ existingMimeType() }}</p>
4614
+ </div>
4615
+ @if (!disabled()) {
4616
+ <button
4617
+ mcms-button
4618
+ variant="ghost"
4619
+ size="sm"
4620
+ type="button"
4621
+ (click)="triggerFileInput()"
4622
+ aria-label="Replace file"
4623
+ >
4624
+ Replace
4625
+ </button>
4626
+ }
4627
+ </div>
4628
+ <input
4629
+ #fileInput
4630
+ type="file"
4631
+ class="sr-only"
4632
+ [accept]="acceptAttribute()"
4633
+ [disabled]="disabled()"
4634
+ (change)="onFileSelected($event)"
4635
+ aria-label="Choose file to upload"
4636
+ />
4637
+ </div>
4638
+ } @else {
4639
+ <!-- Drop zone -->
4640
+ <div
4641
+ class="relative rounded-lg border-2 border-dashed transition-colors"
4642
+ [class.border-mcms-border]="!isDragging()"
4643
+ [class.border-mcms-primary]="isDragging()"
4644
+ [class.bg-mcms-primary/5]="isDragging()"
4645
+ [class.cursor-pointer]="!disabled()"
4646
+ [class.opacity-50]="disabled()"
4647
+ tabindex="0"
4648
+ role="button"
4649
+ [attr.aria-disabled]="disabled()"
4650
+ aria-label="Upload file. Drag and drop or click to browse."
4651
+ (dragover)="onDragOver($event)"
4652
+ (dragleave)="onDragLeave($event)"
4653
+ (drop)="onDrop($event)"
4654
+ (click)="triggerFileInput()"
4655
+ (keydown.enter)="triggerFileInput()"
4656
+ (keydown.space)="triggerFileInput()"
4657
+ >
4658
+ <div class="flex flex-col items-center justify-center gap-2 p-8">
4659
+ <ng-icon [name]="uploadIcon" class="h-10 w-10 text-mcms-muted-foreground" aria-hidden="true" />
4660
+ <div class="text-center">
4661
+ <p class="text-sm font-medium">
4662
+ @if (isDragging()) {
4663
+ Drop file here
4664
+ } @else {
4665
+ Drag & drop or click to upload
4666
+ }
4667
+ </p>
4668
+ @if (mimeTypesHint()) {
4669
+ <p class="mt-1 text-xs text-mcms-muted-foreground">
4670
+ {{ mimeTypesHint() }}
4671
+ </p>
4672
+ }
4673
+ @if (maxSizeHint()) {
4674
+ <p class="text-xs text-mcms-muted-foreground">
4675
+ Max size: {{ maxSizeHint() }}
4676
+ </p>
4677
+ }
4678
+ </div>
4679
+ </div>
4680
+ <input
4681
+ #fileInput
4682
+ type="file"
4683
+ class="sr-only"
4684
+ [accept]="acceptAttribute()"
4685
+ [disabled]="disabled()"
4686
+ (change)="onFileSelected($event)"
4687
+ aria-label="Choose file to upload"
4688
+ />
4689
+ </div>
4690
+ }
4691
+
4692
+ @if (error()) {
4693
+ <p class="mt-1 text-sm text-mcms-destructive">{{ error() }}</p>
4694
+ }
4695
+ `, isInline: true, dependencies: [{ kind: "component", type: Button, selector: "button[mcms-button], a[mcms-button]", inputs: ["variant", "size", "disabled", "loading", "ariaLabel", "class"] }, { kind: "component", type: Progress, selector: "mcms-progress", inputs: ["value", "max", "class"] }, { kind: "component", type: NgIcon, selector: "ng-icon", inputs: ["name", "svg", "size", "strokeWidth", "color"] }, { kind: "component", type: MediaPreviewComponent, selector: "mcms-media-preview", inputs: ["media", "size", "class", "rounded"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4696
+ }
4697
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: CollectionUploadZoneComponent, decorators: [{
4698
+ type: Component,
4699
+ args: [{
4700
+ selector: 'mcms-collection-upload-zone',
4701
+ imports: [Button, Progress, NgIcon, MediaPreviewComponent],
4702
+ changeDetection: ChangeDetectionStrategy.OnPush,
4703
+ host: { class: 'block mb-6' },
4704
+ template: `
4705
+ @if (pendingFile()) {
4706
+ <!-- File selected preview -->
4707
+ <div class="rounded-lg border border-mcms-border bg-mcms-card p-4">
4708
+ <div class="flex items-center gap-4">
4709
+ <mcms-media-preview [media]="previewData()" size="lg" />
4710
+ <div class="min-w-0 flex-1">
4711
+ <p class="truncate text-sm font-medium">{{ pendingFile()!.name }}</p>
4712
+ <p class="text-xs text-mcms-muted-foreground">
4713
+ {{ formatFileSize(pendingFile()!.size) }} &middot; {{ pendingFile()!.type }}
4714
+ </p>
4715
+ @if (isUploading()) {
4716
+ <mcms-progress [value]="uploadProgress()" class="mt-2" />
4717
+ <p class="mt-1 text-xs text-mcms-muted-foreground">
4718
+ {{ uploadProgress() }}% uploaded
4719
+ </p>
4720
+ }
4721
+ </div>
4722
+ @if (!isUploading()) {
4723
+ <button
4724
+ mcms-button
4725
+ variant="ghost"
4726
+ size="sm"
4727
+ type="button"
4728
+ (click)="removeFile()"
4729
+ aria-label="Remove selected file"
4730
+ >
4731
+ <ng-icon [name]="xMarkIcon" class="h-4 w-4" />
4732
+ </button>
4733
+ }
4734
+ </div>
4735
+ </div>
4736
+ } @else if (existingMediaPreview()) {
4737
+ <!-- Existing file preview (edit mode) -->
4738
+ <div class="rounded-lg border border-mcms-border bg-mcms-card p-4">
4739
+ <div class="flex items-center gap-4">
4740
+ <mcms-media-preview [media]="existingMediaPreview()" size="lg" />
4741
+ <div class="min-w-0 flex-1">
4742
+ <p class="truncate text-sm font-medium">{{ existingFilename() }}</p>
4743
+ <p class="text-xs text-mcms-muted-foreground">{{ existingMimeType() }}</p>
4744
+ </div>
4745
+ @if (!disabled()) {
4746
+ <button
4747
+ mcms-button
4748
+ variant="ghost"
4749
+ size="sm"
4750
+ type="button"
4751
+ (click)="triggerFileInput()"
4752
+ aria-label="Replace file"
4753
+ >
4754
+ Replace
4755
+ </button>
4756
+ }
4757
+ </div>
4758
+ <input
4759
+ #fileInput
4760
+ type="file"
4761
+ class="sr-only"
4762
+ [accept]="acceptAttribute()"
4763
+ [disabled]="disabled()"
4764
+ (change)="onFileSelected($event)"
4765
+ aria-label="Choose file to upload"
4766
+ />
4767
+ </div>
4768
+ } @else {
4769
+ <!-- Drop zone -->
4770
+ <div
4771
+ class="relative rounded-lg border-2 border-dashed transition-colors"
4772
+ [class.border-mcms-border]="!isDragging()"
4773
+ [class.border-mcms-primary]="isDragging()"
4774
+ [class.bg-mcms-primary/5]="isDragging()"
4775
+ [class.cursor-pointer]="!disabled()"
4776
+ [class.opacity-50]="disabled()"
4777
+ tabindex="0"
4778
+ role="button"
4779
+ [attr.aria-disabled]="disabled()"
4780
+ aria-label="Upload file. Drag and drop or click to browse."
4781
+ (dragover)="onDragOver($event)"
4782
+ (dragleave)="onDragLeave($event)"
4783
+ (drop)="onDrop($event)"
4784
+ (click)="triggerFileInput()"
4785
+ (keydown.enter)="triggerFileInput()"
4786
+ (keydown.space)="triggerFileInput()"
4787
+ >
4788
+ <div class="flex flex-col items-center justify-center gap-2 p-8">
4789
+ <ng-icon [name]="uploadIcon" class="h-10 w-10 text-mcms-muted-foreground" aria-hidden="true" />
4790
+ <div class="text-center">
4791
+ <p class="text-sm font-medium">
4792
+ @if (isDragging()) {
4793
+ Drop file here
4794
+ } @else {
4795
+ Drag & drop or click to upload
4796
+ }
4797
+ </p>
4798
+ @if (mimeTypesHint()) {
4799
+ <p class="mt-1 text-xs text-mcms-muted-foreground">
4800
+ {{ mimeTypesHint() }}
4801
+ </p>
4802
+ }
4803
+ @if (maxSizeHint()) {
4804
+ <p class="text-xs text-mcms-muted-foreground">
4805
+ Max size: {{ maxSizeHint() }}
4806
+ </p>
4807
+ }
4808
+ </div>
4809
+ </div>
4810
+ <input
4811
+ #fileInput
4812
+ type="file"
4813
+ class="sr-only"
4814
+ [accept]="acceptAttribute()"
4815
+ [disabled]="disabled()"
4816
+ (change)="onFileSelected($event)"
4817
+ aria-label="Choose file to upload"
4818
+ />
4819
+ </div>
4820
+ }
4821
+
4822
+ @if (error()) {
4823
+ <p class="mt-1 text-sm text-mcms-destructive">{{ error() }}</p>
4824
+ }
4825
+ `,
4826
+ }]
4827
+ }], 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"] }] } });
4828
+
4829
+ /**
4830
+ * Entity Form Widget
4831
+ *
4832
+ * Dynamic form for create/edit operations using Angular Signal Forms.
4833
+ *
4834
+ * @example
4835
+ * ```html
4836
+ * <mcms-entity-form
4837
+ * [collection]="postsCollection"
4838
+ * entityId="123"
4839
+ * mode="edit"
4840
+ * (saved)="onSaved($event)"
4841
+ * (cancelled)="onCancel()"
4842
+ * />
4843
+ * ```
4844
+ */
4845
+ class EntityFormWidget {
4846
+ api = injectMomentumAPI();
4847
+ injector = inject(Injector);
4848
+ uploadService = inject(UploadService);
4849
+ versionService = inject(VersionService);
4850
+ collectionAccess = inject(CollectionAccessService);
4851
+ feedback = inject(FeedbackService);
4852
+ router = inject(Router);
4853
+ liveAnnouncer = inject(LiveAnnouncer);
4854
+ /** The collection configuration */
4855
+ collection = input.required(...(ngDevMode ? [{ debugName: "collection" }] : []));
4856
+ /** Entity ID for edit mode (undefined for create) */
4857
+ entityId = input(undefined, ...(ngDevMode ? [{ debugName: "entityId" }] : []));
4858
+ /** Form mode */
4859
+ mode = input('create', ...(ngDevMode ? [{ debugName: "mode" }] : []));
4860
+ /** Base path for navigation */
4861
+ basePath = input('/admin/collections', ...(ngDevMode ? [{ debugName: "basePath" }] : []));
4862
+ /** Whether to show breadcrumbs */
4863
+ showBreadcrumbs = input(true, ...(ngDevMode ? [{ debugName: "showBreadcrumbs" }] : []));
4864
+ /** When true, prevents router navigation after save/cancel (used in entity sheet) */
4865
+ suppressNavigation = input(false, ...(ngDevMode ? [{ debugName: "suppressNavigation" }] : []));
4866
+ /** When true, uses the global API instead of collection API (singleton mode) */
4867
+ isGlobal = input(false, ...(ngDevMode ? [{ debugName: "isGlobal" }] : []));
4868
+ /** The global slug (used when isGlobal is true) */
4869
+ globalSlug = input(undefined, ...(ngDevMode ? [{ debugName: "globalSlug" }] : []));
4870
+ /** Outputs */
4871
+ saved = output();
4872
+ cancelled = output();
4873
+ saveError = output();
4874
+ modeChange = output();
4875
+ draftSaved = output();
4876
+ /** Model signal — the single source of truth for form data */
4877
+ formModel = signal({}, ...(ngDevMode ? [{ debugName: "formModel" }] : []));
4878
+ /** Alias for backward compatibility (CollectionEditPage reads formData) */
4879
+ formData = this.formModel;
4880
+ /** Signal forms tree — created once when collection is available */
4881
+ entityForm = signal(null, ...(ngDevMode ? [{ debugName: "entityForm" }] : []));
4882
+ /** Original data for edit mode */
4883
+ originalData = signal(null, ...(ngDevMode ? [{ debugName: "originalData" }] : []));
4884
+ /** UI state */
4885
+ isLoading = signal(false, ...(ngDevMode ? [{ debugName: "isLoading" }] : []));
4886
+ isSubmitting = signal(false, ...(ngDevMode ? [{ debugName: "isSubmitting" }] : []));
4887
+ isSavingDraft = signal(false, ...(ngDevMode ? [{ debugName: "isSavingDraft" }] : []));
4888
+ formError = signal(null, ...(ngDevMode ? [{ debugName: "formError" }] : []));
4889
+ /** Upload collection state */
4890
+ pendingFile = signal(null, ...(ngDevMode ? [{ debugName: "pendingFile" }] : []));
4891
+ isUploadingFile = signal(false, ...(ngDevMode ? [{ debugName: "isUploadingFile" }] : []));
4892
+ uploadFileProgress = signal(0, ...(ngDevMode ? [{ debugName: "uploadFileProgress" }] : []));
4893
+ uploadFileError = signal(null, ...(ngDevMode ? [{ debugName: "uploadFileError" }] : []));
4894
+ /** Whether the collection is an upload collection */
4895
+ isUploadCol = computed(() => isUploadCollection(this.collection()), ...(ngDevMode ? [{ debugName: "isUploadCol" }] : []));
4896
+ /** Whether the form has been set up */
4897
+ formCreated = false;
4898
+ /** Whether the form has unsaved changes (from signal forms dirty tracking) */
4899
+ isDirty = computed(() => {
4900
+ const ef = this.entityForm();
4901
+ return ef ? ef().dirty() : false;
4902
+ }, ...(ngDevMode ? [{ debugName: "isDirty" }] : []));
4903
+ /** Computed collection label */
4904
+ collectionLabel = computed(() => {
4905
+ const col = this.collection();
4906
+ return humanizeFieldName(col.labels?.plural || col.slug);
4907
+ }, ...(ngDevMode ? [{ debugName: "collectionLabel" }] : []));
4908
+ /** Computed collection label singular */
4909
+ collectionLabelSingular = computed(() => {
4910
+ const col = this.collection();
4911
+ return humanizeFieldName(col.labels?.singular || col.slug);
4912
+ }, ...(ngDevMode ? [{ debugName: "collectionLabelSingular" }] : []));
4913
+ /** Dashboard path (remove /collections from base path) */
4914
+ dashboardPath = computed(() => {
4915
+ const base = this.basePath();
4916
+ return base.replace(/\/collections$/, '');
4917
+ }, ...(ngDevMode ? [{ debugName: "dashboardPath" }] : []));
4918
+ /** Collection list path */
4919
+ collectionListPath = computed(() => {
4920
+ return `${this.basePath()}/${this.collection().slug}`;
4921
+ }, ...(ngDevMode ? [{ debugName: "collectionListPath" }] : []));
4922
+ /** Page title for breadcrumb */
4923
+ pageTitle = computed(() => {
4924
+ if (this.isGlobal()) {
4925
+ return this.collectionLabelSingular();
4926
+ }
4927
+ const currentMode = this.mode();
4928
+ if (currentMode === 'create') {
4929
+ return `Create ${this.collectionLabelSingular()}`;
4930
+ }
4931
+ const data = this.formModel();
4932
+ const titleFields = ['title', 'name', 'label', 'subject'];
4933
+ for (const field of titleFields) {
4934
+ if (data[field] && typeof data[field] === 'string') {
4935
+ return data[field];
4936
+ }
4937
+ }
4938
+ return `Edit ${this.collectionLabelSingular()}`;
4939
+ }, ...(ngDevMode ? [{ debugName: "pageTitle" }] : []));
4940
+ /** Visible fields (excluding hidden ones and those failing admin.condition) */
4941
+ visibleFields = computed(() => {
4942
+ const col = this.collection();
4943
+ const data = this.formModel();
4944
+ return col.fields.filter((field) => {
4229
4945
  if (field.admin?.hidden)
4230
4946
  return false;
4231
4947
  if (field.admin?.condition && !field.admin.condition(data))
@@ -4354,9 +5070,62 @@ class EntityFormWidget {
4354
5070
  this.isLoading.set(false);
4355
5071
  }
4356
5072
  }
5073
+ /**
5074
+ * Handle file selected in the upload zone.
5075
+ * Auto-populates metadata fields in the form model.
5076
+ */
5077
+ onFileSelected(file) {
5078
+ this.pendingFile.set(file);
5079
+ this.uploadFileError.set(null);
5080
+ // Validate file against collection upload config
5081
+ const uploadConfig = this.collection().upload;
5082
+ if (uploadConfig) {
5083
+ // Validate size
5084
+ if (uploadConfig.maxFileSize && file.size > uploadConfig.maxFileSize) {
5085
+ this.uploadFileError.set(`File size exceeds maximum allowed size`);
5086
+ this.pendingFile.set(null);
5087
+ return;
5088
+ }
5089
+ // Validate MIME type
5090
+ if (uploadConfig.mimeTypes && uploadConfig.mimeTypes.length > 0) {
5091
+ const isAllowed = uploadConfig.mimeTypes.some((pattern) => {
5092
+ if (pattern.endsWith('/*')) {
5093
+ const prefix = pattern.slice(0, -1);
5094
+ return file.type.startsWith(prefix);
5095
+ }
5096
+ return file.type === pattern;
5097
+ });
5098
+ if (!isAllowed) {
5099
+ this.uploadFileError.set(`File type "${file.type}" is not allowed`);
5100
+ this.pendingFile.set(null);
5101
+ return;
5102
+ }
5103
+ }
5104
+ }
5105
+ // Auto-populate metadata fields in form model
5106
+ const data = { ...this.formModel() };
5107
+ data['filename'] = file.name;
5108
+ data['mimeType'] = file.type;
5109
+ data['filesize'] = file.size;
5110
+ this.formModel.set(data);
5111
+ }
5112
+ /**
5113
+ * Handle file removed from the upload zone.
5114
+ */
5115
+ onFileRemoved() {
5116
+ this.pendingFile.set(null);
5117
+ this.uploadFileError.set(null);
5118
+ // Clear auto-populated metadata
5119
+ const data = { ...this.formModel() };
5120
+ data['filename'] = '';
5121
+ data['mimeType'] = '';
5122
+ data['filesize'] = null;
5123
+ this.formModel.set(data);
5124
+ }
4357
5125
  /**
4358
5126
  * Handle form submission using Angular Signal Forms submit().
4359
5127
  * submit() marks all fields as touched, then only calls the callback if valid.
5128
+ * For upload collections with a pending file, uses multipart upload via UploadService.
4360
5129
  */
4361
5130
  async onSubmit() {
4362
5131
  const ef = this.entityForm();
@@ -4369,7 +5138,7 @@ class EntityFormWidget {
4369
5138
  this.formError.set(null);
4370
5139
  try {
4371
5140
  const slug = this.collection().slug;
4372
- const data = this.formModel();
5141
+ const data = this.normalizeUploadFieldValues(this.formModel());
4373
5142
  let result;
4374
5143
  if (this.isGlobal()) {
4375
5144
  // Global mode: always update (singleton)
@@ -4377,6 +5146,10 @@ class EntityFormWidget {
4377
5146
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
4378
5147
  result = await this.api.global(gSlug).update(data);
4379
5148
  }
5149
+ else if (this.isUploadCol() && this.pendingFile()) {
5150
+ // Upload collection with a pending file: multipart upload
5151
+ result = await this.submitUploadCollection(slug, data);
5152
+ }
4380
5153
  else if (this.mode() === 'create') {
4381
5154
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
4382
5155
  result = await this.api.collection(slug).create(data);
@@ -4390,6 +5163,7 @@ class EntityFormWidget {
4390
5163
  }
4391
5164
  this.originalData.set(result);
4392
5165
  this.formModel.set({ ...result });
5166
+ this.pendingFile.set(null);
4393
5167
  ef().reset();
4394
5168
  this.saved.emit(result);
4395
5169
  if (!this.suppressNavigation() && !this.isGlobal()) {
@@ -4405,6 +5179,7 @@ class EntityFormWidget {
4405
5179
  }
4406
5180
  finally {
4407
5181
  this.isSubmitting.set(false);
5182
+ this.isUploadingFile.set(false);
4408
5183
  }
4409
5184
  });
4410
5185
  // submit() didn't call the callback — form is invalid
@@ -4413,6 +5188,85 @@ class EntityFormWidget {
4413
5188
  void this.liveAnnouncer.announce('Form submission failed. Please fix the errors above before submitting.', 'assertive');
4414
5189
  }
4415
5190
  }
5191
+ /**
5192
+ * For upload/relationship fields, the form may store a full document object
5193
+ * (e.g., after upload completes) but the DB expects just the UUID.
5194
+ * Extract the `id` property from any upload field values that are objects.
5195
+ */
5196
+ normalizeUploadFieldValues(data) {
5197
+ const fields = this.collection().fields;
5198
+ const result = { ...data };
5199
+ for (const field of fields) {
5200
+ if (field.type === 'upload' && result[field.name] != null) {
5201
+ const val = result[field.name];
5202
+ if (typeof val === 'object' && val !== null) {
5203
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
5204
+ const obj = val; // eslint-disable-line @typescript-eslint/consistent-type-assertions
5205
+ if (typeof obj['id'] === 'string') {
5206
+ result[field.name] = obj['id'];
5207
+ }
5208
+ }
5209
+ }
5210
+ }
5211
+ return result;
5212
+ }
5213
+ /**
5214
+ * Submit an upload collection form with file via multipart.
5215
+ * Converts non-file form fields to string key-value pairs for the FormData.
5216
+ */
5217
+ submitUploadCollection(slug, data) {
5218
+ return new Promise((resolve, reject) => {
5219
+ const file = this.pendingFile();
5220
+ if (!file) {
5221
+ reject(new Error('No file selected'));
5222
+ return;
5223
+ }
5224
+ // Convert form data to string fields for FormData
5225
+ // Exclude auto-populated file metadata fields (they come from the server)
5226
+ const excludeFields = new Set([
5227
+ 'filename', 'mimeType', 'filesize', 'path', 'url',
5228
+ 'id', 'createdAt', 'updatedAt',
5229
+ ]);
5230
+ const fields = {};
5231
+ for (const [key, value] of Object.entries(data)) {
5232
+ if (excludeFields.has(key))
5233
+ continue;
5234
+ if (value === null || value === undefined || value === '')
5235
+ continue;
5236
+ if (typeof value === 'object') {
5237
+ // Skip empty objects/arrays; serialize non-empty ones as JSON
5238
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
5239
+ const isEmptyObject = !Array.isArray(value) && Object.keys(value).length === 0;
5240
+ if ((Array.isArray(value) && value.length === 0) || isEmptyObject)
5241
+ continue;
5242
+ fields[key] = JSON.stringify(value);
5243
+ }
5244
+ else {
5245
+ fields[key] = String(value);
5246
+ }
5247
+ }
5248
+ this.isUploadingFile.set(true);
5249
+ this.uploadFileProgress.set(0);
5250
+ this.uploadService.uploadToCollection(slug, file, fields).subscribe({
5251
+ next: (progress) => {
5252
+ this.uploadFileProgress.set(progress.progress);
5253
+ if (progress.status === 'complete' && progress.result) {
5254
+ this.isUploadingFile.set(false);
5255
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
5256
+ resolve(progress.result);
5257
+ }
5258
+ else if (progress.status === 'error') {
5259
+ this.isUploadingFile.set(false);
5260
+ reject(new Error(progress.error ?? 'Upload failed'));
5261
+ }
5262
+ },
5263
+ error: (err) => {
5264
+ this.isUploadingFile.set(false);
5265
+ reject(err);
5266
+ },
5267
+ });
5268
+ });
5269
+ }
4416
5270
  /**
4417
5271
  * Handle cancel.
4418
5272
  */
@@ -4522,6 +5376,20 @@ class EntityFormWidget {
4522
5376
  </mcms-alert>
4523
5377
  }
4524
5378
 
5379
+ @if (isUploadCol()) {
5380
+ <mcms-collection-upload-zone
5381
+ [uploadConfig]="collection().upload"
5382
+ [pendingFile]="pendingFile()"
5383
+ [existingMedia]="mode() === 'edit' && !pendingFile() ? formModel() : null"
5384
+ [disabled]="mode() === 'view'"
5385
+ [isUploading]="isUploadingFile()"
5386
+ [uploadProgress]="uploadFileProgress()"
5387
+ [error]="uploadFileError()"
5388
+ (fileSelected)="onFileSelected($event)"
5389
+ (fileRemoved)="onFileRemoved()"
5390
+ />
5391
+ }
5392
+
4525
5393
  <div class="space-y-6">
4526
5394
  @for (field of visibleFields(); track field.name) {
4527
5395
  <mcms-field-renderer
@@ -4590,7 +5458,7 @@ class EntityFormWidget {
4590
5458
  </div>
4591
5459
  }
4592
5460
  </div>
4593
- `, 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"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5461
+ `, 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 });
4594
5462
  }
4595
5463
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: EntityFormWidget, decorators: [{
4596
5464
  type: Component,
@@ -4608,6 +5476,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
4608
5476
  BreadcrumbItem,
4609
5477
  BreadcrumbSeparator,
4610
5478
  VersionHistoryWidget,
5479
+ CollectionUploadZoneComponent,
4611
5480
  ],
4612
5481
  changeDetection: ChangeDetectionStrategy.OnPush,
4613
5482
  host: { class: 'block' },
@@ -4669,6 +5538,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
4669
5538
  </mcms-alert>
4670
5539
  }
4671
5540
 
5541
+ @if (isUploadCol()) {
5542
+ <mcms-collection-upload-zone
5543
+ [uploadConfig]="collection().upload"
5544
+ [pendingFile]="pendingFile()"
5545
+ [existingMedia]="mode() === 'edit' && !pendingFile() ? formModel() : null"
5546
+ [disabled]="mode() === 'view'"
5547
+ [isUploading]="isUploadingFile()"
5548
+ [uploadProgress]="uploadFileProgress()"
5549
+ [error]="uploadFileError()"
5550
+ (fileSelected)="onFileSelected($event)"
5551
+ (fileRemoved)="onFileRemoved()"
5552
+ />
5553
+ }
5554
+
4672
5555
  <div class="space-y-6">
4673
5556
  @for (field of visibleFields(); track field.name) {
4674
5557
  <mcms-field-renderer
@@ -5077,15 +5960,33 @@ class EntityViewWidget {
5077
5960
  const e = this.entity();
5078
5961
  if (!e || !col.admin?.preview)
5079
5962
  return null;
5080
- if (typeof col.admin.preview === 'function') {
5963
+ const preview = col.admin.preview;
5964
+ if (typeof preview === 'function') {
5081
5965
  try {
5082
5966
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- T extends Entity with index signature
5083
- return col.admin.preview(e);
5967
+ return preview(e);
5084
5968
  }
5085
5969
  catch {
5086
5970
  return null;
5087
5971
  }
5088
5972
  }
5973
+ // String template: interpolate {fieldName} placeholders with entity data
5974
+ if (typeof preview === 'string') {
5975
+ let hasEmpty = false;
5976
+ const url = preview.replace(/\{(\w+)\}/g, (_, field) => {
5977
+ const val = e[field];
5978
+ if (val == null || val === '') {
5979
+ hasEmpty = true;
5980
+ return '';
5981
+ }
5982
+ return String(val);
5983
+ });
5984
+ return hasEmpty ? null : url;
5985
+ }
5986
+ // Boolean true: use the server-rendered preview API endpoint
5987
+ if (preview === true) {
5988
+ return `/api/${col.slug}/${String(e.id)}/preview`;
5989
+ }
5089
5990
  return null;
5090
5991
  }, ...(ngDevMode ? [{ debugName: "previewUrl" }] : []));
5091
5992
  constructor() {
@@ -5317,44 +6218,64 @@ class EntityViewWidget {
5317
6218
  this.loadEntity(this.collection().slug, this.entityId());
5318
6219
  }
5319
6220
  /**
5320
- * Resolve relationship field values from IDs to display labels.
6221
+ * Resolve relationship and upload field values from IDs to display labels.
5321
6222
  */
5322
6223
  resolveRelationships(entity) {
5323
6224
  const fields = this.collection().fields;
5324
6225
  const resolved = new Map();
5325
6226
  const promises = [];
5326
6227
  for (const field of fields) {
5327
- if (field.type !== 'relationship')
5328
- continue;
5329
- const rawValue = entity[field.name];
5330
- if (!rawValue || typeof rawValue !== 'string')
5331
- continue;
5332
- const config = field.collection();
5333
- if (!isRecord(config) || typeof config['slug'] !== 'string')
5334
- continue;
5335
- const relSlug = config['slug'];
5336
- const titleField = getTitleField(config);
5337
- promises.push(this.api
5338
- .collection(relSlug)
5339
- .findById(rawValue)
5340
- .then((doc) => {
5341
- if (doc) {
5342
- if (titleField !== 'id') {
5343
- const titleValue = doc[titleField];
5344
- if (typeof titleValue === 'string') {
5345
- resolved.set(field.name, titleValue);
5346
- return;
6228
+ if (field.type === 'relationship') {
6229
+ const rawValue = entity[field.name];
6230
+ if (!rawValue || typeof rawValue !== 'string')
6231
+ continue;
6232
+ const config = field.collection();
6233
+ if (!isRecord(config) || typeof config['slug'] !== 'string')
6234
+ continue;
6235
+ const relSlug = config['slug'];
6236
+ const titleField = getTitleField(config);
6237
+ promises.push(this.api
6238
+ .collection(relSlug)
6239
+ .findById(rawValue)
6240
+ .then((doc) => {
6241
+ if (doc) {
6242
+ if (titleField !== 'id') {
6243
+ const titleValue = doc[titleField];
6244
+ if (typeof titleValue === 'string') {
6245
+ resolved.set(field.name, titleValue);
6246
+ return;
6247
+ }
5347
6248
  }
6249
+ resolved.set(field.name, String(doc['id'] ?? rawValue));
5348
6250
  }
5349
- resolved.set(field.name, String(doc['id'] ?? rawValue));
5350
- }
5351
- else {
6251
+ else {
6252
+ resolved.set(field.name, 'Unknown');
6253
+ }
6254
+ })
6255
+ .catch(() => {
5352
6256
  resolved.set(field.name, 'Unknown');
5353
- }
5354
- })
5355
- .catch(() => {
5356
- resolved.set(field.name, 'Unknown');
5357
- }));
6257
+ }));
6258
+ }
6259
+ else if (field.type === 'upload') {
6260
+ const rawValue = entity[field.name];
6261
+ if (!rawValue || typeof rawValue !== 'string')
6262
+ continue;
6263
+ const relSlug = field.relationTo;
6264
+ promises.push(this.api
6265
+ .collection(relSlug)
6266
+ .findById(rawValue)
6267
+ .then((doc) => {
6268
+ if (doc && typeof doc['filename'] === 'string') {
6269
+ resolved.set(field.name, doc['filename']);
6270
+ }
6271
+ else {
6272
+ resolved.set(field.name, rawValue);
6273
+ }
6274
+ })
6275
+ .catch(() => {
6276
+ resolved.set(field.name, rawValue);
6277
+ }));
6278
+ }
5358
6279
  }
5359
6280
  if (promises.length > 0) {
5360
6281
  Promise.all(promises).then(() => {
@@ -5880,6 +6801,47 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
5880
6801
  }]
5881
6802
  }] });
5882
6803
 
6804
+ const DEFAULT_GROUP = 'Collections';
6805
+ /**
6806
+ * Slugify a group name into a valid HTML id attribute value.
6807
+ * Lowercases, replaces non-alphanumeric runs with hyphens, trims leading/trailing hyphens.
6808
+ */
6809
+ function slugify(name) {
6810
+ return name
6811
+ .toLowerCase()
6812
+ .replace(/[^a-z0-9]+/g, '-')
6813
+ .replace(/^-|-$/g, '');
6814
+ }
6815
+ /**
6816
+ * Group collections by their `admin.group` field.
6817
+ * Named groups appear first (in order of first appearance), the default "Collections" group last.
6818
+ * Each group includes a slugified `id` safe for use as an HTML id attribute.
6819
+ */
6820
+ function groupCollections(collections) {
6821
+ const groupMap = new Map();
6822
+ for (const c of collections) {
6823
+ const name = c.admin?.group ?? DEFAULT_GROUP;
6824
+ const list = groupMap.get(name) ?? [];
6825
+ list.push(c);
6826
+ groupMap.set(name, list);
6827
+ }
6828
+ const groups = [];
6829
+ for (const [name, colls] of groupMap) {
6830
+ if (name !== DEFAULT_GROUP) {
6831
+ groups.push({ id: `group-${slugify(name)}`, name, collections: colls });
6832
+ }
6833
+ }
6834
+ const defaultGroup = groupMap.get(DEFAULT_GROUP);
6835
+ if (defaultGroup) {
6836
+ groups.push({
6837
+ id: `group-${slugify(DEFAULT_GROUP)}`,
6838
+ name: DEFAULT_GROUP,
6839
+ collections: defaultGroup,
6840
+ });
6841
+ }
6842
+ return groups;
6843
+ }
6844
+
5883
6845
  /**
5884
6846
  * Admin Sidebar Widget
5885
6847
  *
@@ -5925,27 +6887,7 @@ class AdminSidebarWidget {
5925
6887
  /** Computed collections base path */
5926
6888
  collectionsBasePath = computed(() => `${this.basePath()}/collections`, ...(ngDevMode ? [{ debugName: "collectionsBasePath" }] : []));
5927
6889
  /** Collections grouped by admin.group field */
5928
- collectionGroups = computed(() => {
5929
- const collections = this.collections();
5930
- const DEFAULT_GROUP = 'Collections';
5931
- const groupMap = new Map();
5932
- for (const c of collections) {
5933
- const name = c.admin?.group ?? DEFAULT_GROUP;
5934
- const list = groupMap.get(name) ?? [];
5935
- list.push(c);
5936
- groupMap.set(name, list);
5937
- }
5938
- // Named groups first (in order of first appearance), default last
5939
- const groups = [];
5940
- for (const [name, colls] of groupMap) {
5941
- if (name !== DEFAULT_GROUP)
5942
- groups.push({ name, collections: colls });
5943
- }
5944
- const defaultGroup = groupMap.get(DEFAULT_GROUP);
5945
- if (defaultGroup)
5946
- groups.push({ name: DEFAULT_GROUP, collections: defaultGroup });
5947
- return groups;
5948
- }, ...(ngDevMode ? [{ debugName: "collectionGroups" }] : []));
6890
+ collectionGroups = computed(() => groupCollections(this.collections()), ...(ngDevMode ? [{ debugName: "collectionGroups" }] : []));
5949
6891
  /** Globals grouped by admin.group field */
5950
6892
  globalGroups = computed(() => {
5951
6893
  const globals = this.globals();
@@ -6099,7 +7041,7 @@ class AdminSidebarWidget {
6099
7041
  />
6100
7042
 
6101
7043
  <!-- Collection Sections (grouped by admin.group) -->
6102
- @for (group of collectionGroups(); track group.name) {
7044
+ @for (group of collectionGroups(); track group.id) {
6103
7045
  <mcms-sidebar-section [title]="group.name">
6104
7046
  @for (collection of group.collections; track collection.slug) {
6105
7047
  <mcms-sidebar-nav-item
@@ -6137,6 +7079,7 @@ class AdminSidebarWidget {
6137
7079
  [label]="route.label"
6138
7080
  [href]="basePath() + '/' + route.path"
6139
7081
  [icon]="route.icon"
7082
+ [exact]="true"
6140
7083
  />
6141
7084
  }
6142
7085
  </mcms-sidebar-section>
@@ -6270,7 +7213,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
6270
7213
  />
6271
7214
 
6272
7215
  <!-- Collection Sections (grouped by admin.group) -->
6273
- @for (group of collectionGroups(); track group.name) {
7216
+ @for (group of collectionGroups(); track group.id) {
6274
7217
  <mcms-sidebar-section [title]="group.name">
6275
7218
  @for (collection of group.collections; track collection.slug) {
6276
7219
  <mcms-sidebar-nav-item
@@ -6308,6 +7251,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
6308
7251
  [label]="route.label"
6309
7252
  [href]="basePath() + '/' + route.path"
6310
7253
  [icon]="route.icon"
7254
+ [exact]="true"
6311
7255
  />
6312
7256
  }
6313
7257
  </mcms-sidebar-section>
@@ -7407,6 +8351,8 @@ class DashboardPage {
7407
8351
  // Filter to only accessible, non-hidden collections
7408
8352
  return all.filter((c) => !c.admin?.hidden && accessible.includes(c.slug));
7409
8353
  }, ...(ngDevMode ? [{ debugName: "collections" }] : []));
8354
+ /** Visible collections grouped by admin.group. Named groups first, default last. */
8355
+ collectionGroups = computed(() => groupCollections(this.collections()), ...(ngDevMode ? [{ debugName: "collectionGroups" }] : []));
7410
8356
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: DashboardPage, deps: [], target: i0.ɵɵFactoryTarget.Component });
7411
8357
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: DashboardPage, isStandalone: true, selector: "mcms-dashboard", host: { classAttribute: "block max-w-6xl" }, ngImport: i0, template: `
7412
8358
  <header class="mb-10">
@@ -7414,41 +8360,50 @@ class DashboardPage {
7414
8360
  <p class="text-muted-foreground mt-3 text-lg">Manage your content and collections</p>
7415
8361
  </header>
7416
8362
 
7417
- <section>
7418
- <h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
7419
- Collections
7420
- </h2>
7421
- <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
7422
- @for (collection of collections(); track collection.slug) {
7423
- <mcms-collection-card [collection]="collection" [basePath]="basePath" />
7424
- } @empty {
7425
- <div
7426
- class="col-span-full flex flex-col items-center justify-center p-16 bg-card/50 rounded-xl border border-dashed border-border/60"
7427
- >
7428
- <div class="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
7429
- <svg
7430
- aria-hidden="true"
7431
- class="w-8 h-8 text-muted-foreground"
7432
- fill="none"
7433
- viewBox="0 0 24 24"
7434
- stroke="currentColor"
7435
- >
7436
- <path
7437
- stroke-linecap="round"
7438
- stroke-linejoin="round"
7439
- stroke-width="1.5"
7440
- d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
7441
- />
7442
- </svg>
7443
- </div>
7444
- <p class="text-foreground font-medium text-lg">No collections configured</p>
7445
- <p class="text-sm text-muted-foreground mt-2 text-center max-w-sm">
7446
- Add collections to your configuration to start managing content.
7447
- </p>
8363
+ @if (collectionGroups().length === 0) {
8364
+ <section aria-label="Collections">
8365
+ <div
8366
+ class="flex flex-col items-center justify-center p-16 bg-card/50 rounded-xl border border-dashed border-border/60"
8367
+ >
8368
+ <div class="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
8369
+ <svg
8370
+ aria-hidden="true"
8371
+ class="w-8 h-8 text-muted-foreground"
8372
+ fill="none"
8373
+ viewBox="0 0 24 24"
8374
+ stroke="currentColor"
8375
+ >
8376
+ <path
8377
+ stroke-linecap="round"
8378
+ stroke-linejoin="round"
8379
+ stroke-width="1.5"
8380
+ d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
8381
+ />
8382
+ </svg>
7448
8383
  </div>
7449
- }
7450
- </div>
7451
- </section>
8384
+ <p class="text-foreground font-medium text-lg">No collections configured</p>
8385
+ <p class="text-sm text-muted-foreground mt-2 text-center max-w-sm">
8386
+ Add collections to your configuration to start managing content.
8387
+ </p>
8388
+ </div>
8389
+ </section>
8390
+ } @else {
8391
+ @for (group of collectionGroups(); track group.id) {
8392
+ <section class="mb-10" [attr.aria-labelledby]="group.id">
8393
+ <h2
8394
+ [id]="group.id"
8395
+ class="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4"
8396
+ >
8397
+ {{ group.name }}
8398
+ </h2>
8399
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
8400
+ @for (collection of group.collections; track collection.slug) {
8401
+ <mcms-collection-card [collection]="collection" [basePath]="basePath" />
8402
+ }
8403
+ </div>
8404
+ </section>
8405
+ }
8406
+ }
7452
8407
  `, isInline: true, dependencies: [{ kind: "component", type: CollectionCardWidget, selector: "mcms-collection-card", inputs: ["collection", "basePath", "showDocumentCount"], outputs: ["viewAll"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7453
8408
  }
7454
8409
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: DashboardPage, decorators: [{
@@ -7464,41 +8419,50 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
7464
8419
  <p class="text-muted-foreground mt-3 text-lg">Manage your content and collections</p>
7465
8420
  </header>
7466
8421
 
7467
- <section>
7468
- <h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
7469
- Collections
7470
- </h2>
7471
- <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
7472
- @for (collection of collections(); track collection.slug) {
7473
- <mcms-collection-card [collection]="collection" [basePath]="basePath" />
7474
- } @empty {
7475
- <div
7476
- class="col-span-full flex flex-col items-center justify-center p-16 bg-card/50 rounded-xl border border-dashed border-border/60"
8422
+ @if (collectionGroups().length === 0) {
8423
+ <section aria-label="Collections">
8424
+ <div
8425
+ class="flex flex-col items-center justify-center p-16 bg-card/50 rounded-xl border border-dashed border-border/60"
8426
+ >
8427
+ <div class="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
8428
+ <svg
8429
+ aria-hidden="true"
8430
+ class="w-8 h-8 text-muted-foreground"
8431
+ fill="none"
8432
+ viewBox="0 0 24 24"
8433
+ stroke="currentColor"
8434
+ >
8435
+ <path
8436
+ stroke-linecap="round"
8437
+ stroke-linejoin="round"
8438
+ stroke-width="1.5"
8439
+ d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
8440
+ />
8441
+ </svg>
8442
+ </div>
8443
+ <p class="text-foreground font-medium text-lg">No collections configured</p>
8444
+ <p class="text-sm text-muted-foreground mt-2 text-center max-w-sm">
8445
+ Add collections to your configuration to start managing content.
8446
+ </p>
8447
+ </div>
8448
+ </section>
8449
+ } @else {
8450
+ @for (group of collectionGroups(); track group.id) {
8451
+ <section class="mb-10" [attr.aria-labelledby]="group.id">
8452
+ <h2
8453
+ [id]="group.id"
8454
+ class="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4"
7477
8455
  >
7478
- <div class="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
7479
- <svg
7480
- aria-hidden="true"
7481
- class="w-8 h-8 text-muted-foreground"
7482
- fill="none"
7483
- viewBox="0 0 24 24"
7484
- stroke="currentColor"
7485
- >
7486
- <path
7487
- stroke-linecap="round"
7488
- stroke-linejoin="round"
7489
- stroke-width="1.5"
7490
- d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
7491
- />
7492
- </svg>
7493
- </div>
7494
- <p class="text-foreground font-medium text-lg">No collections configured</p>
7495
- <p class="text-sm text-muted-foreground mt-2 text-center max-w-sm">
7496
- Add collections to your configuration to start managing content.
7497
- </p>
8456
+ {{ group.name }}
8457
+ </h2>
8458
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
8459
+ @for (collection of group.collections; track collection.slug) {
8460
+ <mcms-collection-card [collection]="collection" [basePath]="basePath" />
8461
+ }
7498
8462
  </div>
7499
- }
7500
- </div>
7501
- </section>
8463
+ </section>
8464
+ }
8465
+ }
7502
8466
  `,
7503
8467
  }]
7504
8468
  }] });
@@ -8854,15 +9818,109 @@ var collectionList_page = /*#__PURE__*/Object.freeze({
8854
9818
  CollectionListPage: CollectionListPage
8855
9819
  });
8856
9820
 
9821
+ /**
9822
+ * Collection View Page Component
9823
+ *
9824
+ * Displays a read-only view of a document using the EntityViewWidget.
9825
+ * Preview is available via the "Open Page" link in the entity header
9826
+ * (opens in a new tab). Live preview iframe is only on the edit page,
9827
+ * since the view page is read-only and has no changes to preview live.
9828
+ */
9829
+ class CollectionViewPage {
9830
+ route = inject(ActivatedRoute);
9831
+ router = inject(Router);
9832
+ basePath = '/admin/collections';
9833
+ // Reactive slug signal that updates when route params change
9834
+ slug = toSignal(this.route.paramMap.pipe(map((params) => params.get('slug') ?? '')), { initialValue: this.route.snapshot.paramMap.get('slug') ?? '' });
9835
+ // Reactive entity ID signal
9836
+ entityId = toSignal(this.route.paramMap.pipe(map((params) => params.get('id') ?? '')), {
9837
+ initialValue: this.route.snapshot.paramMap.get('id') ?? '',
9838
+ });
9839
+ collection = computed(() => {
9840
+ const currentSlug = this.slug();
9841
+ if (!currentSlug)
9842
+ return undefined;
9843
+ const collections = getCollectionsFromRouteData(this.route.parent?.snapshot.data);
9844
+ return collections.find((c) => c.slug === currentSlug);
9845
+ }, ...(ngDevMode ? [{ debugName: "collection" }] : []));
9846
+ onEdit(entity) {
9847
+ const col = this.collection();
9848
+ if (col) {
9849
+ this.router.navigate([this.basePath, col.slug, entity.id, 'edit']);
9850
+ }
9851
+ }
9852
+ onDelete(_entity) {
9853
+ // Navigation is handled by EntityViewWidget
9854
+ }
9855
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: CollectionViewPage, deps: [], target: i0.ɵɵFactoryTarget.Component });
9856
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: CollectionViewPage, isStandalone: true, selector: "mcms-collection-view", host: { classAttribute: "block" }, ngImport: i0, template: `
9857
+ @if (collection(); as col) {
9858
+ @if (entityId(); as id) {
9859
+ <mcms-entity-view
9860
+ [collection]="col"
9861
+ [entityId]="id"
9862
+ [basePath]="basePath"
9863
+ (edit)="onEdit($event)"
9864
+ (delete_)="onDelete($event)"
9865
+ />
9866
+ } @else {
9867
+ <div class="p-12 text-center text-muted-foreground">Entity ID not provided</div>
9868
+ }
9869
+ } @else {
9870
+ <div class="p-12 text-center text-muted-foreground">Collection not found</div>
9871
+ }
9872
+ `, isInline: true, dependencies: [{ kind: "component", type: EntityViewWidget, selector: "mcms-entity-view", inputs: ["collection", "entityId", "basePath", "showBreadcrumbs", "fieldConfigs", "actions", "showVersionHistory", "suppressNavigation"], outputs: ["edit", "statusChanged", "delete_", "actionClick"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
9873
+ }
9874
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: CollectionViewPage, decorators: [{
9875
+ type: Component,
9876
+ args: [{
9877
+ selector: 'mcms-collection-view',
9878
+ imports: [EntityViewWidget],
9879
+ changeDetection: ChangeDetectionStrategy.OnPush,
9880
+ host: { class: 'block' },
9881
+ template: `
9882
+ @if (collection(); as col) {
9883
+ @if (entityId(); as id) {
9884
+ <mcms-entity-view
9885
+ [collection]="col"
9886
+ [entityId]="id"
9887
+ [basePath]="basePath"
9888
+ (edit)="onEdit($event)"
9889
+ (delete_)="onDelete($event)"
9890
+ />
9891
+ } @else {
9892
+ <div class="p-12 text-center text-muted-foreground">Entity ID not provided</div>
9893
+ }
9894
+ } @else {
9895
+ <div class="p-12 text-center text-muted-foreground">Collection not found</div>
9896
+ }
9897
+ `,
9898
+ }]
9899
+ }] });
9900
+
9901
+ var collectionView_page = /*#__PURE__*/Object.freeze({
9902
+ __proto__: null,
9903
+ CollectionViewPage: CollectionViewPage
9904
+ });
9905
+
8857
9906
  /**
8858
9907
  * Live Preview Widget
8859
9908
  *
8860
9909
  * Displays an iframe that shows a live preview of the document being edited.
8861
- * Sends form data to the iframe via postMessage on each change.
9910
+ *
9911
+ * Two modes based on preview config type:
9912
+ * - `preview: true` (server-rendered HTML): iframe loads API endpoint with scripts enabled
9913
+ * for postMessage live updates.
9914
+ * - `preview: string/function` (URL-based): iframe loads the page URL with scripts DISABLED.
9915
+ * This prevents loading a second Angular app instance (with Vite HMR, SSR hydration, etc.)
9916
+ * which causes tab crashes in dev mode. The SSR-rendered HTML displays correctly without JS.
9917
+ * Use the Refresh button to see form changes reflected in the preview.
9918
+ *
9919
+ * The iframe is declared statically in the template (no dynamic bindings) to avoid NG0910.
9920
+ * Its src/sandbox attributes are set via nativeElement in an effect().
8862
9921
  */
8863
9922
  class LivePreviewComponent {
8864
9923
  document = inject(DOCUMENT);
8865
- sanitizer = inject(DomSanitizer);
8866
9924
  destroyRef = inject(DestroyRef);
8867
9925
  /** Preview configuration from collection admin config */
8868
9926
  preview = input.required(...(ngDevMode ? [{ debugName: "preview" }] : []));
@@ -8878,8 +9936,8 @@ class LivePreviewComponent {
8878
9936
  deviceSize = signal('desktop', ...(ngDevMode ? [{ debugName: "deviceSize" }] : []));
8879
9937
  /** Refresh counter to force iframe reload */
8880
9938
  refreshCounter = signal(0, ...(ngDevMode ? [{ debugName: "refreshCounter" }] : []));
8881
- /** Reference to the iframe element */
8882
- previewFrame = viewChild('previewFrame', ...(ngDevMode ? [{ debugName: "previewFrame" }] : []));
9939
+ /** Reference to the static iframe element (available when previewUrl is non-null) */
9940
+ previewIframe = viewChild('previewIframe', ...(ngDevMode ? [{ debugName: "previewIframe" }] : []));
8883
9941
  /** Compute the raw preview URL */
8884
9942
  previewUrl = computed(() => {
8885
9943
  // Force recomputation on refresh
@@ -8896,18 +9954,25 @@ class LivePreviewComponent {
8896
9954
  return null;
8897
9955
  }
8898
9956
  }
9957
+ // URL template string: interpolate {fieldName} placeholders with form data
9958
+ // Return null if any placeholder resolves to empty (data not yet loaded)
9959
+ if (typeof previewConfig === 'string') {
9960
+ let hasEmptyField = false;
9961
+ const url = previewConfig.replace(/\{(\w+)\}/g, (_, field) => {
9962
+ const val = data[field];
9963
+ if (val == null || val === '') {
9964
+ hasEmptyField = true;
9965
+ return '';
9966
+ }
9967
+ return String(val);
9968
+ });
9969
+ return hasEmptyField ? null : url;
9970
+ }
8899
9971
  if (previewConfig === true && id) {
8900
9972
  return `/api/${slug}/${id}/preview`;
8901
9973
  }
8902
9974
  return null;
8903
9975
  }, ...(ngDevMode ? [{ debugName: "previewUrl" }] : []));
8904
- /** Sanitized preview URL for iframe binding */
8905
- safePreviewUrl = computed(() => {
8906
- const url = this.previewUrl();
8907
- if (!url)
8908
- return null;
8909
- return this.sanitizer.bypassSecurityTrustResourceUrl(url);
8910
- }, ...(ngDevMode ? [{ debugName: "safePreviewUrl" }] : []));
8911
9976
  /** Computed iframe width based on device size */
8912
9977
  iframeWidth = computed(() => {
8913
9978
  switch (this.deviceSize()) {
@@ -8919,21 +9984,57 @@ class LivePreviewComponent {
8919
9984
  return '100%';
8920
9985
  }
8921
9986
  }, ...(ngDevMode ? [{ debugName: "iframeWidth" }] : []));
9987
+ /** Sandbox attribute value based on preview mode */
9988
+ sandboxValue = computed(() => {
9989
+ const previewConfig = this.preview();
9990
+ if (previewConfig === true) {
9991
+ // Server-rendered HTML: scripts needed for postMessage live updates
9992
+ return 'allow-same-origin allow-scripts allow-popups allow-forms';
9993
+ }
9994
+ // URL-based preview: no scripts to prevent full Angular app from loading
9995
+ return 'allow-same-origin allow-popups allow-forms';
9996
+ }, ...(ngDevMode ? [{ debugName: "sandboxValue" }] : []));
8922
9997
  /** Debounce timer for postMessage updates */
8923
9998
  debounceTimer = undefined;
8924
9999
  constructor() {
8925
- // Send form data to iframe via postMessage whenever data changes
10000
+ // Effect 1: Set iframe src and sandbox when URL or sandbox config changes.
10001
+ // Uses untracked() for iframeWidth so device size toggles don't trigger a reload.
10002
+ effect(() => {
10003
+ const iframeRef = this.previewIframe();
10004
+ if (!iframeRef)
10005
+ return;
10006
+ const iframe = iframeRef.nativeElement;
10007
+ const url = this.previewUrl();
10008
+ if (!url)
10009
+ return;
10010
+ iframe.setAttribute('sandbox', this.sandboxValue());
10011
+ iframe.src = url;
10012
+ // Set initial width without tracking the signal
10013
+ iframe.style.width = untracked(() => this.iframeWidth());
10014
+ });
10015
+ // Effect 2: Update iframe width only (no reload).
10016
+ // Changing CSS width on an iframe does not trigger navigation.
10017
+ effect(() => {
10018
+ const iframeRef = this.previewIframe();
10019
+ if (!iframeRef)
10020
+ return;
10021
+ iframeRef.nativeElement.style.width = this.iframeWidth();
10022
+ });
10023
+ // Send form data to iframe via postMessage whenever data changes.
10024
+ // Only effective for server-rendered previews (preview: true) where
10025
+ // allow-scripts is enabled. URL-based previews have scripts disabled
10026
+ // so the postMessage is a no-op (which is fine).
8926
10027
  effect(() => {
8927
10028
  const data = this.documentData();
8928
- const frame = this.previewFrame();
8929
- if (!frame?.nativeElement?.contentWindow)
10029
+ const iframeRef = this.previewIframe();
10030
+ if (!iframeRef?.nativeElement.contentWindow)
8930
10031
  return;
8931
10032
  // Debounce to avoid thrashing
8932
10033
  if (this.debounceTimer) {
8933
10034
  clearTimeout(this.debounceTimer);
8934
10035
  }
8935
10036
  this.debounceTimer = this.document.defaultView?.setTimeout(() => {
8936
- const iframeWindow = frame.nativeElement.contentWindow;
10037
+ const iframeWindow = iframeRef.nativeElement.contentWindow;
8937
10038
  if (iframeWindow) {
8938
10039
  const targetOrigin = this.document.defaultView?.location?.origin ?? '';
8939
10040
  iframeWindow.postMessage({ type: 'momentum-preview-update', data }, targetOrigin);
@@ -8969,7 +10070,7 @@ class LivePreviewComponent {
8969
10070
  this.refreshCounter.update((c) => c + 1);
8970
10071
  }
8971
10072
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: LivePreviewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
8972
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: LivePreviewComponent, isStandalone: true, selector: "mcms-live-preview", inputs: { preview: { classPropertyName: "preview", publicName: "preview", isSignal: true, isRequired: true, transformFunction: null }, documentData: { classPropertyName: "documentData", publicName: "documentData", isSignal: true, isRequired: true, transformFunction: null }, collectionSlug: { classPropertyName: "collectionSlug", publicName: "collectionSlug", isSignal: true, isRequired: true, transformFunction: null }, entityId: { classPropertyName: "entityId", publicName: "entityId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { editBlockRequest: "editBlockRequest" }, host: { classAttribute: "flex flex-col h-full border-l border-border" }, viewQueries: [{ propertyName: "previewFrame", first: true, predicate: ["previewFrame"], descendants: true, isSignal: true }], ngImport: i0, template: `
10073
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: LivePreviewComponent, isStandalone: true, selector: "mcms-live-preview", inputs: { preview: { classPropertyName: "preview", publicName: "preview", isSignal: true, isRequired: true, transformFunction: null }, documentData: { classPropertyName: "documentData", publicName: "documentData", isSignal: true, isRequired: true, transformFunction: null }, collectionSlug: { classPropertyName: "collectionSlug", publicName: "collectionSlug", isSignal: true, isRequired: true, transformFunction: null }, entityId: { classPropertyName: "entityId", publicName: "entityId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { editBlockRequest: "editBlockRequest" }, host: { classAttribute: "flex flex-col h-full border-l border-border" }, viewQueries: [{ propertyName: "previewIframe", first: true, predicate: ["previewIframe"], descendants: true, isSignal: true }], ngImport: i0, template: `
8973
10074
  <!-- Preview toolbar -->
8974
10075
  <div class="flex items-center gap-2 px-4 py-2 border-b border-border bg-muted/50">
8975
10076
  <span class="text-sm font-medium text-foreground">Preview</span>
@@ -9037,15 +10138,14 @@ class LivePreviewComponent {
9037
10138
 
9038
10139
  <!-- Preview iframe container -->
9039
10140
  <div class="flex-1 overflow-auto bg-muted/30 flex justify-center p-4">
9040
- @if (safePreviewUrl(); as url) {
10141
+ @if (previewUrl()) {
10142
+ <!-- Static iframe with no dynamic bindings (avoids NG0910).
10143
+ src/sandbox/width are set via nativeElement in an effect(). -->
9041
10144
  <iframe
9042
- #previewFrame
9043
- [src]="url"
9044
- [style.width]="iframeWidth()"
9045
- class="h-full bg-white border border-border rounded-md shadow-sm transition-[width] duration-300"
9046
- sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
10145
+ #previewIframe
9047
10146
  title="Live document preview"
9048
10147
  data-testid="preview-iframe"
10148
+ class="h-full bg-white border border-border rounded-md shadow-sm transition-[width] duration-300"
9049
10149
  ></iframe>
9050
10150
  } @else {
9051
10151
  <div class="flex items-center justify-center h-full text-muted-foreground text-sm">
@@ -9123,255 +10223,31 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
9123
10223
  (click)="refreshPreview()"
9124
10224
  data-testid="preview-refresh"
9125
10225
  aria-label="Refresh preview"
9126
- >
9127
- ↻ Refresh
9128
- </button>
9129
- </div>
9130
-
9131
- <!-- Preview iframe container -->
9132
- <div class="flex-1 overflow-auto bg-muted/30 flex justify-center p-4">
9133
- @if (safePreviewUrl(); as url) {
9134
- <iframe
9135
- #previewFrame
9136
- [src]="url"
9137
- [style.width]="iframeWidth()"
9138
- class="h-full bg-white border border-border rounded-md shadow-sm transition-[width] duration-300"
9139
- sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
9140
- title="Live document preview"
9141
- data-testid="preview-iframe"
9142
- ></iframe>
9143
- } @else {
9144
- <div class="flex items-center justify-center h-full text-muted-foreground text-sm">
9145
- Preview not available
9146
- </div>
9147
- }
9148
- </div>
9149
- `,
9150
- }]
9151
- }], ctorParameters: () => [], propDecorators: { preview: [{ type: i0.Input, args: [{ isSignal: true, alias: "preview", required: true }] }], documentData: [{ type: i0.Input, args: [{ isSignal: true, alias: "documentData", required: true }] }], collectionSlug: [{ type: i0.Input, args: [{ isSignal: true, alias: "collectionSlug", required: true }] }], entityId: [{ type: i0.Input, args: [{ isSignal: true, alias: "entityId", required: false }] }], editBlockRequest: [{ type: i0.Output, args: ["editBlockRequest"] }], previewFrame: [{ type: i0.ViewChild, args: ['previewFrame', { isSignal: true }] }] } });
9152
-
9153
- /**
9154
- * Collection View Page Component
9155
- *
9156
- * Displays a read-only view of a document using the EntityViewWidget.
9157
- * When preview is enabled, shows a toggleable live preview panel.
9158
- */
9159
- class CollectionViewPage {
9160
- route = inject(ActivatedRoute);
9161
- router = inject(Router);
9162
- basePath = '/admin/collections';
9163
- /** Whether the live preview panel is visible */
9164
- showPreview = signal(true, ...(ngDevMode ? [{ debugName: "showPreview" }] : []));
9165
- /** Reference to the entity view widget to read its entity data */
9166
- entityViewRef = viewChild('entityView', ...(ngDevMode ? [{ debugName: "entityViewRef" }] : []));
9167
- // Reactive slug signal that updates when route params change
9168
- slug = toSignal(this.route.paramMap.pipe(map((params) => params.get('slug') ?? '')), { initialValue: this.route.snapshot.paramMap.get('slug') ?? '' });
9169
- // Reactive entity ID signal
9170
- entityId = toSignal(this.route.paramMap.pipe(map((params) => params.get('id') ?? '')), {
9171
- initialValue: this.route.snapshot.paramMap.get('id') ?? '',
9172
- });
9173
- collection = computed(() => {
9174
- const currentSlug = this.slug();
9175
- if (!currentSlug)
9176
- return undefined;
9177
- const collections = getCollectionsFromRouteData(this.route.parent?.snapshot.data);
9178
- return collections.find((c) => c.slug === currentSlug);
9179
- }, ...(ngDevMode ? [{ debugName: "collection" }] : []));
9180
- /** Preview config from collection admin settings */
9181
- previewConfig = computed(() => {
9182
- const col = this.collection();
9183
- return col?.admin?.preview || undefined;
9184
- }, ...(ngDevMode ? [{ debugName: "previewConfig" }] : []));
9185
- /** Entity data from the entity view widget (for live preview) */
9186
- viewEntityData = computed(() => {
9187
- const view = this.entityViewRef();
9188
- const entity = view?.entity();
9189
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- T extends Entity with index signature
9190
- return entity ?? {};
9191
- }, ...(ngDevMode ? [{ debugName: "viewEntityData" }] : []));
9192
- onEdit(entity) {
9193
- const col = this.collection();
9194
- if (col) {
9195
- this.router.navigate([this.basePath, col.slug, entity.id, 'edit']);
9196
- }
9197
- }
9198
- onDelete(_entity) {
9199
- // Navigation is handled by EntityViewWidget
9200
- }
9201
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: CollectionViewPage, deps: [], target: i0.ɵɵFactoryTarget.Component });
9202
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: CollectionViewPage, isStandalone: true, selector: "mcms-collection-view", host: { classAttribute: "block" }, viewQueries: [{ propertyName: "entityViewRef", first: true, predicate: ["entityView"], descendants: true, isSignal: true }], ngImport: i0, template: `
9203
- @if (collection(); as col) {
9204
- @if (entityId(); as id) {
9205
- @if (previewConfig(); as preview) {
9206
- @if (showPreview()) {
9207
- <!-- Split layout: entity view + preview -->
9208
- <div class="flex gap-0 h-[calc(100vh-64px)]" data-testid="preview-layout">
9209
- <div class="flex-1 overflow-y-auto p-6">
9210
- <mcms-entity-view
9211
- #entityView
9212
- [collection]="col"
9213
- [entityId]="id"
9214
- [basePath]="basePath"
9215
- (edit)="onEdit($event)"
9216
- (delete_)="onDelete($event)"
9217
- >
9218
- <div entityViewHeaderExtra class="mt-3">
9219
- <button
9220
- mcms-button
9221
- variant="ghost"
9222
- size="sm"
9223
- data-testid="preview-toggle"
9224
- (click)="showPreview.set(false)"
9225
- >
9226
- Hide Preview
9227
- </button>
9228
- </div>
9229
- </mcms-entity-view>
9230
- </div>
9231
- <div class="w-[50%] min-w-[400px] max-w-[720px]">
9232
- <mcms-live-preview
9233
- [preview]="preview"
9234
- [documentData]="viewEntityData()"
9235
- [collectionSlug]="col.slug"
9236
- [entityId]="id"
9237
- />
9238
- </div>
9239
- </div>
9240
- } @else {
9241
- <!-- Full-width view (preview hidden) -->
9242
- <mcms-entity-view
9243
- #entityView
9244
- [collection]="col"
9245
- [entityId]="id"
9246
- [basePath]="basePath"
9247
- (edit)="onEdit($event)"
9248
- (delete_)="onDelete($event)"
9249
- >
9250
- <div entityViewHeaderExtra class="mt-3">
9251
- <button
9252
- mcms-button
9253
- variant="ghost"
9254
- size="sm"
9255
- data-testid="preview-toggle"
9256
- (click)="showPreview.set(true)"
9257
- >
9258
- Show Preview
9259
- </button>
9260
- </div>
9261
- </mcms-entity-view>
9262
- }
9263
- } @else {
9264
- <!-- No preview configured -->
9265
- <mcms-entity-view
9266
- #entityView
9267
- [collection]="col"
9268
- [entityId]="id"
9269
- [basePath]="basePath"
9270
- (edit)="onEdit($event)"
9271
- (delete_)="onDelete($event)"
9272
- />
9273
- }
9274
- } @else {
9275
- <div class="p-12 text-center text-muted-foreground">Entity ID not provided</div>
9276
- }
9277
- } @else {
9278
- <div class="p-12 text-center text-muted-foreground">Collection not found</div>
9279
- }
9280
- `, isInline: true, dependencies: [{ kind: "component", type: EntityViewWidget, selector: "mcms-entity-view", inputs: ["collection", "entityId", "basePath", "showBreadcrumbs", "fieldConfigs", "actions", "showVersionHistory", "suppressNavigation"], outputs: ["edit", "statusChanged", "delete_", "actionClick"] }, { kind: "component", type: LivePreviewComponent, selector: "mcms-live-preview", inputs: ["preview", "documentData", "collectionSlug", "entityId"], outputs: ["editBlockRequest"] }, { kind: "component", type: Button, selector: "button[mcms-button], a[mcms-button]", inputs: ["variant", "size", "disabled", "loading", "ariaLabel", "class"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
9281
- }
9282
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: CollectionViewPage, decorators: [{
9283
- type: Component,
9284
- args: [{
9285
- selector: 'mcms-collection-view',
9286
- imports: [EntityViewWidget, LivePreviewComponent, Button],
9287
- changeDetection: ChangeDetectionStrategy.OnPush,
9288
- host: { class: 'block' },
9289
- template: `
9290
- @if (collection(); as col) {
9291
- @if (entityId(); as id) {
9292
- @if (previewConfig(); as preview) {
9293
- @if (showPreview()) {
9294
- <!-- Split layout: entity view + preview -->
9295
- <div class="flex gap-0 h-[calc(100vh-64px)]" data-testid="preview-layout">
9296
- <div class="flex-1 overflow-y-auto p-6">
9297
- <mcms-entity-view
9298
- #entityView
9299
- [collection]="col"
9300
- [entityId]="id"
9301
- [basePath]="basePath"
9302
- (edit)="onEdit($event)"
9303
- (delete_)="onDelete($event)"
9304
- >
9305
- <div entityViewHeaderExtra class="mt-3">
9306
- <button
9307
- mcms-button
9308
- variant="ghost"
9309
- size="sm"
9310
- data-testid="preview-toggle"
9311
- (click)="showPreview.set(false)"
9312
- >
9313
- Hide Preview
9314
- </button>
9315
- </div>
9316
- </mcms-entity-view>
9317
- </div>
9318
- <div class="w-[50%] min-w-[400px] max-w-[720px]">
9319
- <mcms-live-preview
9320
- [preview]="preview"
9321
- [documentData]="viewEntityData()"
9322
- [collectionSlug]="col.slug"
9323
- [entityId]="id"
9324
- />
9325
- </div>
9326
- </div>
9327
- } @else {
9328
- <!-- Full-width view (preview hidden) -->
9329
- <mcms-entity-view
9330
- #entityView
9331
- [collection]="col"
9332
- [entityId]="id"
9333
- [basePath]="basePath"
9334
- (edit)="onEdit($event)"
9335
- (delete_)="onDelete($event)"
9336
- >
9337
- <div entityViewHeaderExtra class="mt-3">
9338
- <button
9339
- mcms-button
9340
- variant="ghost"
9341
- size="sm"
9342
- data-testid="preview-toggle"
9343
- (click)="showPreview.set(true)"
9344
- >
9345
- Show Preview
9346
- </button>
9347
- </div>
9348
- </mcms-entity-view>
9349
- }
9350
- } @else {
9351
- <!-- No preview configured -->
9352
- <mcms-entity-view
9353
- #entityView
9354
- [collection]="col"
9355
- [entityId]="id"
9356
- [basePath]="basePath"
9357
- (edit)="onEdit($event)"
9358
- (delete_)="onDelete($event)"
9359
- />
9360
- }
10226
+ >
10227
+ ↻ Refresh
10228
+ </button>
10229
+ </div>
10230
+
10231
+ <!-- Preview iframe container -->
10232
+ <div class="flex-1 overflow-auto bg-muted/30 flex justify-center p-4">
10233
+ @if (previewUrl()) {
10234
+ <!-- Static iframe with no dynamic bindings (avoids NG0910).
10235
+ src/sandbox/width are set via nativeElement in an effect(). -->
10236
+ <iframe
10237
+ #previewIframe
10238
+ title="Live document preview"
10239
+ data-testid="preview-iframe"
10240
+ class="h-full bg-white border border-border rounded-md shadow-sm transition-[width] duration-300"
10241
+ ></iframe>
9361
10242
  } @else {
9362
- <div class="p-12 text-center text-muted-foreground">Entity ID not provided</div>
10243
+ <div class="flex items-center justify-center h-full text-muted-foreground text-sm">
10244
+ Preview not available
10245
+ </div>
9363
10246
  }
9364
- } @else {
9365
- <div class="p-12 text-center text-muted-foreground">Collection not found</div>
9366
- }
10247
+ </div>
9367
10248
  `,
9368
10249
  }]
9369
- }], propDecorators: { entityViewRef: [{ type: i0.ViewChild, args: ['entityView', { isSignal: true }] }] } });
9370
-
9371
- var collectionView_page = /*#__PURE__*/Object.freeze({
9372
- __proto__: null,
9373
- CollectionViewPage: CollectionViewPage
9374
- });
10250
+ }], ctorParameters: () => [], propDecorators: { preview: [{ type: i0.Input, args: [{ isSignal: true, alias: "preview", required: true }] }], documentData: [{ type: i0.Input, args: [{ isSignal: true, alias: "documentData", required: true }] }], collectionSlug: [{ type: i0.Input, args: [{ isSignal: true, alias: "collectionSlug", required: true }] }], entityId: [{ type: i0.Input, args: [{ isSignal: true, alias: "entityId", required: false }] }], editBlockRequest: [{ type: i0.Output, args: ["editBlockRequest"] }], previewIframe: [{ type: i0.ViewChild, args: ['previewIframe', { isSignal: true }] }] } });
9375
10251
 
9376
10252
  /**
9377
10253
  * Block Edit Dialog
@@ -10432,140 +11308,6 @@ var setup_page = /*#__PURE__*/Object.freeze({
10432
11308
  SetupPage: SetupPage
10433
11309
  });
10434
11310
 
10435
- /**
10436
- * Media Preview Component
10437
- *
10438
- * Displays a preview of media based on its type:
10439
- * - Images: Thumbnail preview
10440
- * - Videos: Video icon with optional poster
10441
- * - Audio: Audio icon
10442
- * - Documents: Document icon
10443
- * - Other: Generic file icon
10444
- *
10445
- * @example
10446
- * ```html
10447
- * <mcms-media-preview
10448
- * [media]="mediaDocument"
10449
- * [size]="'md'"
10450
- * />
10451
- * ```
10452
- */
10453
- class MediaPreviewComponent {
10454
- /** Media data to preview */
10455
- media = input(null, ...(ngDevMode ? [{ debugName: "media" }] : []));
10456
- /** Size of the preview */
10457
- size = input('md', ...(ngDevMode ? [{ debugName: "size" }] : []));
10458
- /** Custom class override */
10459
- class = input('', ...(ngDevMode ? [{ debugName: "class" }] : []));
10460
- /** Whether to show rounded corners */
10461
- rounded = input(true, ...(ngDevMode ? [{ debugName: "rounded" }] : []));
10462
- /** Host classes */
10463
- hostClasses = computed(() => {
10464
- const sizeClass = this.sizeClasses()[this.size()];
10465
- const roundedClass = this.rounded() ? 'rounded-md overflow-hidden' : '';
10466
- return `inline-block ${sizeClass} ${roundedClass} ${this.class()}`;
10467
- }, ...(ngDevMode ? [{ debugName: "hostClasses" }] : []));
10468
- /** Size classes map */
10469
- sizeClasses = computed(() => ({
10470
- xs: 'h-8 w-8',
10471
- sm: 'h-12 w-12',
10472
- md: 'h-20 w-20',
10473
- lg: 'h-32 w-32',
10474
- xl: 'h-48 w-48',
10475
- }), ...(ngDevMode ? [{ debugName: "sizeClasses" }] : []));
10476
- /** Icon size classes */
10477
- iconClasses = computed(() => {
10478
- const sizes = {
10479
- xs: 'text-lg',
10480
- sm: 'text-xl',
10481
- md: 'text-3xl',
10482
- lg: 'text-4xl',
10483
- xl: 'text-6xl',
10484
- };
10485
- return `${sizes[this.size()]} text-mcms-muted-foreground`;
10486
- }, ...(ngDevMode ? [{ debugName: "iconClasses" }] : []));
10487
- /** Whether the media is an image */
10488
- isImage = computed(() => {
10489
- const mimeType = this.media()?.mimeType ?? '';
10490
- return mimeType.startsWith('image/');
10491
- }, ...(ngDevMode ? [{ debugName: "isImage" }] : []));
10492
- /** Whether the media is a video */
10493
- isVideo = computed(() => {
10494
- const mimeType = this.media()?.mimeType ?? '';
10495
- return mimeType.startsWith('video/');
10496
- }, ...(ngDevMode ? [{ debugName: "isVideo" }] : []));
10497
- /** Whether the media is audio */
10498
- isAudio = computed(() => {
10499
- const mimeType = this.media()?.mimeType ?? '';
10500
- return mimeType.startsWith('audio/');
10501
- }, ...(ngDevMode ? [{ debugName: "isAudio" }] : []));
10502
- /** Image URL for preview */
10503
- imageUrl = computed(() => {
10504
- const media = this.media();
10505
- if (!media)
10506
- return '';
10507
- return media.url ?? `/api/media/file/${media.path}`;
10508
- }, ...(ngDevMode ? [{ debugName: "imageUrl" }] : []));
10509
- /** Icon name based on media type */
10510
- iconName = computed(() => {
10511
- const mimeType = this.media()?.mimeType ?? '';
10512
- if (mimeType.startsWith('video/')) {
10513
- return heroFilm;
10514
- }
10515
- if (mimeType.startsWith('audio/')) {
10516
- return heroMusicalNote;
10517
- }
10518
- if (mimeType === 'application/pdf') {
10519
- return heroDocumentText;
10520
- }
10521
- if (mimeType.startsWith('application/zip') || mimeType.includes('compressed')) {
10522
- return heroArchiveBox;
10523
- }
10524
- if (mimeType.startsWith('image/')) {
10525
- return heroPhoto;
10526
- }
10527
- return heroDocument;
10528
- }, ...(ngDevMode ? [{ debugName: "iconName" }] : []));
10529
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: MediaPreviewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
10530
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: MediaPreviewComponent, isStandalone: true, selector: "mcms-media-preview", inputs: { media: { classPropertyName: "media", publicName: "media", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, rounded: { classPropertyName: "rounded", publicName: "rounded", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "hostClasses()" } }, ngImport: i0, template: `
10531
- @if (isImage()) {
10532
- <img
10533
- [src]="imageUrl()"
10534
- [alt]="media()?.alt ?? media()?.filename ?? 'Media preview'"
10535
- class="h-full w-full object-cover"
10536
- />
10537
- } @else {
10538
- <div class="flex h-full w-full items-center justify-center bg-mcms-muted">
10539
- <ng-icon [name]="iconName()" [class]="iconClasses()" />
10540
- </div>
10541
- }
10542
- `, isInline: true, dependencies: [{ kind: "component", type: NgIcon, selector: "ng-icon", inputs: ["name", "svg", "size", "strokeWidth", "color"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
10543
- }
10544
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: MediaPreviewComponent, decorators: [{
10545
- type: Component,
10546
- args: [{
10547
- selector: 'mcms-media-preview',
10548
- host: {
10549
- '[class]': 'hostClasses()',
10550
- },
10551
- template: `
10552
- @if (isImage()) {
10553
- <img
10554
- [src]="imageUrl()"
10555
- [alt]="media()?.alt ?? media()?.filename ?? 'Media preview'"
10556
- class="h-full w-full object-cover"
10557
- />
10558
- } @else {
10559
- <div class="flex h-full w-full items-center justify-center bg-mcms-muted">
10560
- <ng-icon [name]="iconName()" [class]="iconClasses()" />
10561
- </div>
10562
- }
10563
- `,
10564
- imports: [NgIcon],
10565
- changeDetection: ChangeDetectionStrategy.OnPush,
10566
- }]
10567
- }], propDecorators: { media: [{ type: i0.Input, args: [{ isSignal: true, alias: "media", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], rounded: [{ type: i0.Input, args: [{ isSignal: true, alias: "rounded", required: false }] }] } });
10568
-
10569
11311
  /**
10570
11312
  * Type guard to check if a value has the shape of a MediaEditItem.
10571
11313
  */
@@ -12593,18 +13335,18 @@ function provideMomentumFieldRenderers() {
12593
13335
  registry.register('checkbox', () => Promise.resolve().then(function () { return checkboxField_component; }).then((m) => m.CheckboxFieldRenderer));
12594
13336
  registry.register('date', () => Promise.resolve().then(function () { return dateField_component; }).then((m) => m.DateFieldRenderer));
12595
13337
  registry.register('upload', () => Promise.resolve().then(function () { return uploadField_component; }).then((m) => m.UploadFieldRenderer));
12596
- registry.register('richText', () => import('./momentumcms-admin-rich-text-field.component-Djv7tDS2.mjs').then((m) => m.RichTextFieldRenderer));
13338
+ registry.register('richText', () => import('./momentumcms-admin-rich-text-field.component-BC8pRU89.mjs').then((m) => m.RichTextFieldRenderer));
12597
13339
  // Layout field renderers (support nested field rendering)
12598
- registry.register('group', () => import('./momentumcms-admin-group-field.component-Bgy_tQOG.mjs').then((m) => m.GroupFieldRenderer));
12599
- registry.register('array', () => import('./momentumcms-admin-array-field.component-BZva87Sh.mjs').then((m) => m.ArrayFieldRenderer));
12600
- registry.register('blocks', () => import('./momentumcms-admin-blocks-field.component-CIxpyKAV.mjs').then((m) => m.BlocksFieldRenderer));
13340
+ registry.register('group', () => import('./momentumcms-admin-group-field.component-B48_zbo0.mjs').then((m) => m.GroupFieldRenderer));
13341
+ registry.register('array', () => import('./momentumcms-admin-array-field.component-Bjlcczwg.mjs').then((m) => m.ArrayFieldRenderer));
13342
+ registry.register('blocks', () => import('./momentumcms-admin-blocks-field.component-4vLqDGbB.mjs').then((m) => m.BlocksFieldRenderer));
12601
13343
  // Visual block editor variant (blocks field with admin.editor === 'visual')
12602
13344
  registry.register('blocks-visual', () => Promise.resolve().then(function () { return visualBlockEditor_component; }).then((m) => m.VisualBlockEditorComponent));
12603
- registry.register('relationship', () => import('./momentumcms-admin-relationship-field.component-s46Lu33u.mjs').then((m) => m.RelationshipFieldRenderer));
13345
+ registry.register('relationship', () => import('./momentumcms-admin-relationship-field.component-D-UQgd7m.mjs').then((m) => m.RelationshipFieldRenderer));
12604
13346
  // Layout-only renderers (tabs, collapsible, row)
12605
- registry.register('tabs', () => import('./momentumcms-admin-tabs-field.component-CdZoCrvw.mjs').then((m) => m.TabsFieldRenderer));
12606
- registry.register('collapsible', () => import('./momentumcms-admin-collapsible-field.component-BpmaUKom.mjs').then((m) => m.CollapsibleFieldRenderer));
12607
- registry.register('row', () => import('./momentumcms-admin-row-field.component-Dc5vqRQ8.mjs').then((m) => m.RowFieldRenderer));
13347
+ registry.register('tabs', () => import('./momentumcms-admin-tabs-field.component-B4X73eCM.mjs').then((m) => m.TabsFieldRenderer));
13348
+ registry.register('collapsible', () => import('./momentumcms-admin-collapsible-field.component-63-9kSgm.mjs').then((m) => m.CollapsibleFieldRenderer));
13349
+ registry.register('row', () => import('./momentumcms-admin-row-field.component--EOPGDtM.mjs').then((m) => m.RowFieldRenderer));
12608
13350
  };
12609
13351
  },
12610
13352
  },
@@ -13446,39 +14188,39 @@ class MediaPickerDialog {
13446
14188
  this.isLoading.set(true);
13447
14189
  try {
13448
14190
  const collection = this.api.collection(this.collectionSlug());
13449
- const whereClause = {};
13450
- // Add search filter
14191
+ // Fetch all media — DB adapter does not support complex where operators
14192
+ const result = await collection.find({
14193
+ page,
14194
+ limit: this.limit(),
14195
+ });
14196
+ let items = toMediaItems(result.docs);
14197
+ // Client-side search filter
13451
14198
  if (search) {
13452
- whereClause['filename'] = { contains: search };
14199
+ const lowerSearch = search.toLowerCase();
14200
+ items = items.filter((m) => m.filename.toLowerCase().includes(lowerSearch));
13453
14201
  }
13454
- // Add MIME type filter
14202
+ // Client-side MIME type filter
13455
14203
  const mimeTypes = this.data?.mimeTypes;
13456
14204
  if (mimeTypes && mimeTypes.length > 0) {
13457
- // For simple types like 'image/*', filter by prefix
13458
- const prefixes = mimeTypes.filter((t) => t.endsWith('/*')).map((t) => t.slice(0, -2));
13459
- const exactTypes = mimeTypes.filter((t) => !t.endsWith('/*'));
13460
- if (prefixes.length > 0 || exactTypes.length > 0) {
13461
- const orConditions = [];
13462
- for (const prefix of prefixes) {
13463
- orConditions.push({ mimeType: { startsWith: prefix } });
13464
- }
13465
- for (const type of exactTypes) {
13466
- orConditions.push({ mimeType: { equals: type } });
14205
+ items = items.filter((m) => mimeTypes.some((pattern) => {
14206
+ if (pattern.endsWith('/*')) {
14207
+ return m.mimeType.startsWith(pattern.slice(0, -1));
13467
14208
  }
13468
- if (orConditions.length > 0) {
13469
- whereClause['or'] = orConditions;
13470
- }
13471
- }
14209
+ return m.mimeType === pattern;
14210
+ }));
14211
+ }
14212
+ this.mediaItems.set(items);
14213
+ // When client-side filtering is active, use filtered counts
14214
+ // to avoid pagination showing incorrect totals
14215
+ const hasClientFilter = !!search || (mimeTypes && mimeTypes.length > 0);
14216
+ if (hasClientFilter) {
14217
+ this.totalDocs.set(items.length);
14218
+ this.totalPages.set(1); // Client-filtered results are always a single page
14219
+ }
14220
+ else {
14221
+ this.totalDocs.set(result.totalDocs);
14222
+ this.totalPages.set(result.totalPages);
13472
14223
  }
13473
- const result = await collection.find({
13474
- where: Object.keys(whereClause).length > 0 ? whereClause : undefined,
13475
- page,
13476
- limit: this.limit(),
13477
- sort: '-createdAt',
13478
- });
13479
- this.mediaItems.set(toMediaItems(result.docs));
13480
- this.totalDocs.set(result.totalDocs);
13481
- this.totalPages.set(result.totalPages);
13482
14224
  }
13483
14225
  catch (error) {
13484
14226
  console.error('Failed to load media:', error);
@@ -13751,9 +14493,10 @@ function getInputFromEvent(event) {
13751
14493
  * a FieldTree node's FieldState rather than event-based I/O.
13752
14494
  */
13753
14495
  class UploadFieldRenderer {
13754
- document = inject(DOCUMENT);
14496
+ fileInputRef = viewChild('fileInput', ...(ngDevMode ? [{ debugName: "fileInputRef" }] : []));
13755
14497
  uploadService = inject(UploadService);
13756
14498
  dialogService = inject(DialogService);
14499
+ api = injectMomentumAPI();
13757
14500
  /** Field definition */
13758
14501
  field = input.required(...(ngDevMode ? [{ debugName: "field" }] : []));
13759
14502
  /** Signal forms FieldTree node for this field */
@@ -13775,6 +14518,43 @@ class UploadFieldRenderer {
13775
14518
  uploadingFilename = signal('', ...(ngDevMode ? [{ debugName: "uploadingFilename" }] : []));
13776
14519
  uploadError = signal(null, ...(ngDevMode ? [{ debugName: "uploadError" }] : []));
13777
14520
  uploadingFile = signal(null, ...(ngDevMode ? [{ debugName: "uploadingFile" }] : []));
14521
+ /** Resolved media document fetched from API when value is just an ID string */
14522
+ resolvedMedia = signal(null, ...(ngDevMode ? [{ debugName: "resolvedMedia" }] : []));
14523
+ resolvedMediaId = null;
14524
+ constructor() {
14525
+ // When the value is a string (media ID), fetch the full media document for preview
14526
+ effect(() => {
14527
+ const val = this.currentValue();
14528
+ if (typeof val === 'string' && val !== '') {
14529
+ // Avoid re-fetching the same ID
14530
+ const id = val;
14531
+ if (untracked(() => this.resolvedMediaId) === id)
14532
+ return;
14533
+ this.resolvedMediaId = id;
14534
+ const relationTo = untracked(() => this.uploadField().relationTo);
14535
+ this.api
14536
+ .collection(relationTo)
14537
+ .findById(id)
14538
+ .then((doc) => {
14539
+ if (doc) {
14540
+ this.resolvedMedia.set(doc);
14541
+ }
14542
+ })
14543
+ .catch(() => {
14544
+ // Silently fail — preview will show placeholder
14545
+ });
14546
+ }
14547
+ else if (typeof val === 'object' && val !== null) {
14548
+ // Already a full document, clear any stale resolved data
14549
+ this.resolvedMedia.set(null);
14550
+ this.resolvedMediaId = null;
14551
+ }
14552
+ else {
14553
+ this.resolvedMedia.set(null);
14554
+ this.resolvedMediaId = null;
14555
+ }
14556
+ });
14557
+ }
13778
14558
  /** Unique field ID */
13779
14559
  fieldId = computed(() => `field-${this.path().replace(/\./g, '-')}`, ...(ngDevMode ? [{ debugName: "fieldId" }] : []));
13780
14560
  /** Computed label */
@@ -13804,32 +14584,35 @@ class UploadFieldRenderer {
13804
14584
  const val = this.currentValue();
13805
14585
  return val !== null && val !== undefined && val !== '';
13806
14586
  }, ...(ngDevMode ? [{ debugName: "hasValue" }] : []));
14587
+ /** Effective media document — full object from value, or resolved from API */
14588
+ effectiveMedia = computed(() => {
14589
+ const val = this.currentValue();
14590
+ if (typeof val === 'object' && val !== null)
14591
+ return val;
14592
+ // Value is a string ID — use resolved media if available
14593
+ return this.resolvedMedia();
14594
+ }, ...(ngDevMode ? [{ debugName: "effectiveMedia" }] : []));
13807
14595
  /** Media preview data from value */
13808
14596
  mediaPreviewData = computed(() => {
13809
- const val = this.currentValue();
13810
- if (!val)
14597
+ const media = this.effectiveMedia();
14598
+ if (!media)
13811
14599
  return null;
13812
- // If value is a full document object
13813
- if (typeof val === 'object' && val !== null) {
14600
+ if (typeof media === 'object' && media !== null) {
13814
14601
  return {
13815
- url: getStringProp(val, 'url'),
13816
- path: getStringProp(val, 'path'),
13817
- mimeType: getStringProp(val, 'mimeType'),
13818
- filename: getStringProp(val, 'filename'),
13819
- alt: getStringProp(val, 'alt'),
14602
+ url: getStringProp(media, 'url'),
14603
+ path: getStringProp(media, 'path'),
14604
+ mimeType: getStringProp(media, 'mimeType'),
14605
+ filename: getStringProp(media, 'filename'),
14606
+ alt: getStringProp(media, 'alt'),
13820
14607
  };
13821
14608
  }
13822
- // If value is just an ID, we can't preview without fetching
13823
- // Return a placeholder
13824
- return {
13825
- path: String(val),
13826
- };
14609
+ return null;
13827
14610
  }, ...(ngDevMode ? [{ debugName: "mediaPreviewData" }] : []));
13828
14611
  /** Media filename from value */
13829
14612
  mediaFilename = computed(() => {
13830
- const val = this.currentValue();
13831
- if (typeof val === 'object' && val !== null) {
13832
- return getStringProp(val, 'filename') ?? 'Selected media';
14613
+ const media = this.effectiveMedia();
14614
+ if (typeof media === 'object' && media !== null) {
14615
+ return getStringProp(media, 'filename') ?? 'Selected media';
13833
14616
  }
13834
14617
  return 'Selected media';
13835
14618
  }, ...(ngDevMode ? [{ debugName: "mediaFilename" }] : []));
@@ -13930,11 +14713,19 @@ class UploadFieldRenderer {
13930
14713
  triggerFileInput() {
13931
14714
  if (this.isDisabled())
13932
14715
  return;
13933
- const input = this.document.querySelector(`#${this.fieldId()} input[type="file"]`);
13934
- if (input instanceof HTMLInputElement) {
13935
- input.click();
14716
+ const ref = this.fileInputRef();
14717
+ if (ref) {
14718
+ ref.nativeElement.click();
13936
14719
  }
13937
14720
  }
14721
+ /**
14722
+ * Handle Space keydown on drop zone.
14723
+ * Prevents default scroll behavior and triggers file input.
14724
+ */
14725
+ onDropZoneSpace(event) {
14726
+ event.preventDefault();
14727
+ this.triggerFileInput();
14728
+ }
13938
14729
  /**
13939
14730
  * Handle file selection from input.
13940
14731
  */
@@ -13979,7 +14770,8 @@ class UploadFieldRenderer {
13979
14770
  this.uploadProgress.set(0);
13980
14771
  this.uploadingFilename.set(file.name);
13981
14772
  this.uploadingFile.set(file);
13982
- this.uploadService.upload(file).subscribe({
14773
+ const relationTo = this.uploadField().relationTo;
14774
+ this.uploadService.uploadToCollection(relationTo, file).subscribe({
13983
14775
  next: (progress) => {
13984
14776
  this.uploadProgress.set(progress.progress);
13985
14777
  if (progress.status === 'complete' && progress.result) {
@@ -14037,7 +14829,7 @@ class UploadFieldRenderer {
14037
14829
  }
14038
14830
  }
14039
14831
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: UploadFieldRenderer, deps: [], target: i0.ɵɵFactoryTarget.Component });
14040
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: UploadFieldRenderer, isStandalone: true, selector: "mcms-upload-field-renderer", inputs: { field: { classPropertyName: "field", publicName: "field", isSignal: true, isRequired: true, transformFunction: null }, formNode: { classPropertyName: "formNode", publicName: "formNode", isSignal: true, isRequired: false, transformFunction: null }, mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, path: { classPropertyName: "path", publicName: "path", isSignal: true, isRequired: true, transformFunction: null } }, host: { classAttribute: "block" }, ngImport: i0, template: `
14832
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: UploadFieldRenderer, isStandalone: true, selector: "mcms-upload-field-renderer", inputs: { field: { classPropertyName: "field", publicName: "field", isSignal: true, isRequired: true, transformFunction: null }, formNode: { classPropertyName: "formNode", publicName: "formNode", isSignal: true, isRequired: false, transformFunction: null }, mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, path: { classPropertyName: "path", publicName: "path", isSignal: true, isRequired: true, transformFunction: null } }, host: { classAttribute: "block" }, viewQueries: [{ propertyName: "fileInputRef", first: true, predicate: ["fileInput"], descendants: true, isSignal: true }], ngImport: i0, template: `
14041
14833
  <mcms-form-field
14042
14834
  [id]="fieldId()"
14043
14835
  [required]="required()"
@@ -14095,7 +14887,7 @@ class UploadFieldRenderer {
14095
14887
  </div>
14096
14888
  </div>
14097
14889
  } @else {
14098
- <!-- Drop zone -->
14890
+ <!-- Drop zone — keyboard-accessible for WCAG 2.1 SC 2.1.1 -->
14099
14891
  <div
14100
14892
  class="relative rounded-lg border-2 border-dashed transition-colors"
14101
14893
  [class.border-mcms-border]="!isDragging()"
@@ -14103,16 +14895,16 @@ class UploadFieldRenderer {
14103
14895
  [class.bg-mcms-primary/5]="isDragging()"
14104
14896
  [class.cursor-pointer]="!isDisabled()"
14105
14897
  [class.opacity-50]="isDisabled()"
14106
- tabindex="0"
14107
14898
  role="button"
14899
+ tabindex="0"
14900
+ [attr.aria-label]="'Upload file for ' + label()"
14108
14901
  [attr.aria-disabled]="isDisabled()"
14109
- [attr.aria-label]="'Upload ' + label() + ' file. Drag and drop or click to browse.'"
14110
14902
  (dragover)="onDragOver($event)"
14111
14903
  (dragleave)="onDragLeave($event)"
14112
14904
  (drop)="onDrop($event)"
14113
14905
  (click)="triggerFileInput()"
14114
14906
  (keydown.enter)="triggerFileInput()"
14115
- (keydown.space)="triggerFileInput()"
14907
+ (keydown.space)="onDropZoneSpace($event)"
14116
14908
  >
14117
14909
  <div class="flex flex-col items-center justify-center gap-2 p-8">
14118
14910
  <ng-icon [name]="uploadIcon" class="h-10 w-10 text-mcms-muted-foreground" aria-hidden="true" />
@@ -14135,31 +14927,31 @@ class UploadFieldRenderer {
14135
14927
  </p>
14136
14928
  }
14137
14929
  </div>
14138
- @if (!isDisabled()) {
14139
- <div class="mt-2 flex gap-2">
14140
- <button
14141
- mcms-button
14142
- variant="outline"
14143
- size="sm"
14144
- type="button"
14145
- (click)="$event.stopPropagation(); openMediaPicker()"
14146
- >
14147
- <ng-icon [name]="photoIcon" class="h-4 w-4" />
14148
- Select from library
14149
- </button>
14150
- </div>
14151
- }
14152
14930
  </div>
14153
- <input
14154
- #fileInput
14155
- type="file"
14156
- class="sr-only"
14157
- [accept]="acceptAttribute()"
14158
- [disabled]="isDisabled()"
14159
- (change)="onFileSelected($event)"
14160
- [attr.aria-label]="'Choose file for ' + label()"
14161
- />
14162
14931
  </div>
14932
+ @if (!isDisabled()) {
14933
+ <div class="mt-2 flex gap-2">
14934
+ <button
14935
+ mcms-button
14936
+ variant="outline"
14937
+ size="sm"
14938
+ type="button"
14939
+ (click)="openMediaPicker()"
14940
+ >
14941
+ <ng-icon [name]="photoIcon" class="h-4 w-4" />
14942
+ Select from library
14943
+ </button>
14944
+ </div>
14945
+ }
14946
+ <input
14947
+ #fileInput
14948
+ type="file"
14949
+ class="sr-only"
14950
+ [accept]="acceptAttribute()"
14951
+ [disabled]="isDisabled()"
14952
+ (change)="onFileSelected($event)"
14953
+ [attr.aria-label]="'Choose file for ' + label()"
14954
+ />
14163
14955
  }
14164
14956
 
14165
14957
  @if (uploadError()) {
@@ -14233,7 +15025,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
14233
15025
  </div>
14234
15026
  </div>
14235
15027
  } @else {
14236
- <!-- Drop zone -->
15028
+ <!-- Drop zone — keyboard-accessible for WCAG 2.1 SC 2.1.1 -->
14237
15029
  <div
14238
15030
  class="relative rounded-lg border-2 border-dashed transition-colors"
14239
15031
  [class.border-mcms-border]="!isDragging()"
@@ -14241,16 +15033,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
14241
15033
  [class.bg-mcms-primary/5]="isDragging()"
14242
15034
  [class.cursor-pointer]="!isDisabled()"
14243
15035
  [class.opacity-50]="isDisabled()"
14244
- tabindex="0"
14245
15036
  role="button"
15037
+ tabindex="0"
15038
+ [attr.aria-label]="'Upload file for ' + label()"
14246
15039
  [attr.aria-disabled]="isDisabled()"
14247
- [attr.aria-label]="'Upload ' + label() + ' file. Drag and drop or click to browse.'"
14248
15040
  (dragover)="onDragOver($event)"
14249
15041
  (dragleave)="onDragLeave($event)"
14250
15042
  (drop)="onDrop($event)"
14251
15043
  (click)="triggerFileInput()"
14252
15044
  (keydown.enter)="triggerFileInput()"
14253
- (keydown.space)="triggerFileInput()"
15045
+ (keydown.space)="onDropZoneSpace($event)"
14254
15046
  >
14255
15047
  <div class="flex flex-col items-center justify-center gap-2 p-8">
14256
15048
  <ng-icon [name]="uploadIcon" class="h-10 w-10 text-mcms-muted-foreground" aria-hidden="true" />
@@ -14273,31 +15065,31 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
14273
15065
  </p>
14274
15066
  }
14275
15067
  </div>
14276
- @if (!isDisabled()) {
14277
- <div class="mt-2 flex gap-2">
14278
- <button
14279
- mcms-button
14280
- variant="outline"
14281
- size="sm"
14282
- type="button"
14283
- (click)="$event.stopPropagation(); openMediaPicker()"
14284
- >
14285
- <ng-icon [name]="photoIcon" class="h-4 w-4" />
14286
- Select from library
14287
- </button>
14288
- </div>
14289
- }
14290
15068
  </div>
14291
- <input
14292
- #fileInput
14293
- type="file"
14294
- class="sr-only"
14295
- [accept]="acceptAttribute()"
14296
- [disabled]="isDisabled()"
14297
- (change)="onFileSelected($event)"
14298
- [attr.aria-label]="'Choose file for ' + label()"
14299
- />
14300
15069
  </div>
15070
+ @if (!isDisabled()) {
15071
+ <div class="mt-2 flex gap-2">
15072
+ <button
15073
+ mcms-button
15074
+ variant="outline"
15075
+ size="sm"
15076
+ type="button"
15077
+ (click)="openMediaPicker()"
15078
+ >
15079
+ <ng-icon [name]="photoIcon" class="h-4 w-4" />
15080
+ Select from library
15081
+ </button>
15082
+ </div>
15083
+ }
15084
+ <input
15085
+ #fileInput
15086
+ type="file"
15087
+ class="sr-only"
15088
+ [accept]="acceptAttribute()"
15089
+ [disabled]="isDisabled()"
15090
+ (change)="onFileSelected($event)"
15091
+ [attr.aria-label]="'Choose file for ' + label()"
15092
+ />
14301
15093
  }
14302
15094
 
14303
15095
  @if (uploadError()) {
@@ -14306,7 +15098,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
14306
15098
  </mcms-form-field>
14307
15099
  `,
14308
15100
  }]
14309
- }], propDecorators: { field: [{ type: i0.Input, args: [{ isSignal: true, alias: "field", required: true }] }], formNode: [{ type: i0.Input, args: [{ isSignal: true, alias: "formNode", required: false }] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], path: [{ type: i0.Input, args: [{ isSignal: true, alias: "path", required: true }] }] } });
15101
+ }], ctorParameters: () => [], propDecorators: { fileInputRef: [{ type: i0.ViewChild, args: ['fileInput', { isSignal: true }] }], field: [{ type: i0.Input, args: [{ isSignal: true, alias: "field", required: true }] }], formNode: [{ type: i0.Input, args: [{ isSignal: true, alias: "formNode", required: false }] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], path: [{ type: i0.Input, args: [{ isSignal: true, alias: "path", required: true }] }] } });
14310
15102
 
14311
15103
  var uploadField_component = /*#__PURE__*/Object.freeze({
14312
15104
  __proto__: null,
@@ -14320,4 +15112,4 @@ var uploadField_component = /*#__PURE__*/Object.freeze({
14320
15112
  */
14321
15113
 
14322
15114
  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 };
14323
- //# sourceMappingURL=momentumcms-admin-momentumcms-admin-TvEIOeYg.mjs.map
15115
+ //# sourceMappingURL=momentumcms-admin-momentumcms-admin-D_47TVaR.mjs.map