@momentumcms/admin 0.2.0 → 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 (24) hide show
  1. package/fesm2022/{momentumcms-admin-array-field.component-CT5NlIEv.mjs → momentumcms-admin-array-field.component-Bjlcczwg.mjs} +2 -2
  2. package/fesm2022/{momentumcms-admin-array-field.component-CT5NlIEv.mjs.map → momentumcms-admin-array-field.component-Bjlcczwg.mjs.map} +1 -1
  3. package/fesm2022/{momentumcms-admin-blocks-field.component-Cz7HmuBK.mjs → momentumcms-admin-blocks-field.component-4vLqDGbB.mjs} +2 -2
  4. package/fesm2022/{momentumcms-admin-blocks-field.component-Cz7HmuBK.mjs.map → momentumcms-admin-blocks-field.component-4vLqDGbB.mjs.map} +1 -1
  5. package/fesm2022/{momentumcms-admin-collapsible-field.component-CtwrGQvg.mjs → momentumcms-admin-collapsible-field.component-63-9kSgm.mjs} +2 -2
  6. package/fesm2022/{momentumcms-admin-collapsible-field.component-CtwrGQvg.mjs.map → momentumcms-admin-collapsible-field.component-63-9kSgm.mjs.map} +1 -1
  7. package/fesm2022/{momentumcms-admin-global-edit.page-BBUtWCSl.mjs → momentumcms-admin-global-edit.page-DSnkwdgn.mjs} +2 -2
  8. package/fesm2022/{momentumcms-admin-global-edit.page-BBUtWCSl.mjs.map → momentumcms-admin-global-edit.page-DSnkwdgn.mjs.map} +1 -1
  9. package/fesm2022/{momentumcms-admin-group-field.component-BZeG8Oqy.mjs → momentumcms-admin-group-field.component-B48_zbo0.mjs} +2 -2
  10. package/fesm2022/{momentumcms-admin-group-field.component-BZeG8Oqy.mjs.map → momentumcms-admin-group-field.component-B48_zbo0.mjs.map} +1 -1
  11. package/fesm2022/{momentumcms-admin-momentumcms-admin-o0FbJXZN.mjs → momentumcms-admin-momentumcms-admin-D_47TVaR.mjs} +1531 -749
  12. package/fesm2022/momentumcms-admin-momentumcms-admin-D_47TVaR.mjs.map +1 -0
  13. package/fesm2022/{momentumcms-admin-relationship-field.component-BuxtRs2_.mjs → momentumcms-admin-relationship-field.component-D-UQgd7m.mjs} +2 -2
  14. package/fesm2022/{momentumcms-admin-relationship-field.component-BuxtRs2_.mjs.map → momentumcms-admin-relationship-field.component-D-UQgd7m.mjs.map} +1 -1
  15. package/fesm2022/{momentumcms-admin-rich-text-field.component-DKQ6pwp7.mjs → momentumcms-admin-rich-text-field.component-BC8pRU89.mjs} +2 -2
  16. package/fesm2022/{momentumcms-admin-rich-text-field.component-DKQ6pwp7.mjs.map → momentumcms-admin-rich-text-field.component-BC8pRU89.mjs.map} +1 -1
  17. package/fesm2022/{momentumcms-admin-row-field.component-ks3FXd4B.mjs → momentumcms-admin-row-field.component--EOPGDtM.mjs} +2 -2
  18. package/fesm2022/{momentumcms-admin-row-field.component-ks3FXd4B.mjs.map → momentumcms-admin-row-field.component--EOPGDtM.mjs.map} +1 -1
  19. package/fesm2022/{momentumcms-admin-tabs-field.component-mZ4dpZoD.mjs → momentumcms-admin-tabs-field.component-B4X73eCM.mjs} +2 -2
  20. package/fesm2022/{momentumcms-admin-tabs-field.component-mZ4dpZoD.mjs.map → momentumcms-admin-tabs-field.component-B4X73eCM.mjs.map} +1 -1
  21. package/fesm2022/momentumcms-admin.mjs +1 -1
  22. package/package.json +1 -1
  23. package/types/momentumcms-admin.d.ts +92 -29
  24. package/fesm2022/momentumcms-admin-momentumcms-admin-o0FbJXZN.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-BBUtWCSl.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
  *
@@ -4129,121 +4217,739 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
4129
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"] }] } });
4130
4218
 
4131
4219
  /**
4132
- * Entity Form Widget
4220
+ * Media Preview Component
4133
4221
  *
4134
- * 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
4135
4228
  *
4136
4229
  * @example
4137
4230
  * ```html
4138
- * <mcms-entity-form
4139
- * [collection]="postsCollection"
4140
- * entityId="123"
4141
- * mode="edit"
4142
- * (saved)="onSaved($event)"
4143
- * (cancelled)="onCancel()"
4231
+ * <mcms-media-preview
4232
+ * [media]="mediaDocument"
4233
+ * [size]="'md'"
4144
4234
  * />
4145
4235
  * ```
4146
4236
  */
4147
- class EntityFormWidget {
4148
- api = injectMomentumAPI();
4149
- injector = inject(Injector);
4150
- versionService = inject(VersionService);
4151
- collectionAccess = inject(CollectionAccessService);
4152
- feedback = inject(FeedbackService);
4153
- router = inject(Router);
4154
- liveAnnouncer = inject(LiveAnnouncer);
4155
- /** The collection configuration */
4156
- collection = input.required(...(ngDevMode ? [{ debugName: "collection" }] : []));
4157
- /** Entity ID for edit mode (undefined for create) */
4158
- entityId = input(undefined, ...(ngDevMode ? [{ debugName: "entityId" }] : []));
4159
- /** Form mode */
4160
- mode = input('create', ...(ngDevMode ? [{ debugName: "mode" }] : []));
4161
- /** Base path for navigation */
4162
- basePath = input('/admin/collections', ...(ngDevMode ? [{ debugName: "basePath" }] : []));
4163
- /** Whether to show breadcrumbs */
4164
- showBreadcrumbs = input(true, ...(ngDevMode ? [{ debugName: "showBreadcrumbs" }] : []));
4165
- /** When true, prevents router navigation after save/cancel (used in entity sheet) */
4166
- suppressNavigation = input(false, ...(ngDevMode ? [{ debugName: "suppressNavigation" }] : []));
4167
- /** When true, uses the global API instead of collection API (singleton mode) */
4168
- isGlobal = input(false, ...(ngDevMode ? [{ debugName: "isGlobal" }] : []));
4169
- /** The global slug (used when isGlobal is true) */
4170
- globalSlug = input(undefined, ...(ngDevMode ? [{ debugName: "globalSlug" }] : []));
4171
- /** Outputs */
4172
- saved = output();
4173
- cancelled = output();
4174
- saveError = output();
4175
- modeChange = output();
4176
- draftSaved = output();
4177
- /** Model signal — the single source of truth for form data */
4178
- formModel = signal({}, ...(ngDevMode ? [{ debugName: "formModel" }] : []));
4179
- /** Alias for backward compatibility (CollectionEditPage reads formData) */
4180
- formData = this.formModel;
4181
- /** Signal forms tree created once when collection is available */
4182
- entityForm = signal(null, ...(ngDevMode ? [{ debugName: "entityForm" }] : []));
4183
- /** Original data for edit mode */
4184
- originalData = signal(null, ...(ngDevMode ? [{ debugName: "originalData" }] : []));
4185
- /** UI state */
4186
- isLoading = signal(false, ...(ngDevMode ? [{ debugName: "isLoading" }] : []));
4187
- isSubmitting = signal(false, ...(ngDevMode ? [{ debugName: "isSubmitting" }] : []));
4188
- isSavingDraft = signal(false, ...(ngDevMode ? [{ debugName: "isSavingDraft" }] : []));
4189
- formError = signal(null, ...(ngDevMode ? [{ debugName: "formError" }] : []));
4190
- /** Whether the form has been set up */
4191
- formCreated = false;
4192
- /** Whether the form has unsaved changes (from signal forms dirty tracking) */
4193
- isDirty = computed(() => {
4194
- const ef = this.entityForm();
4195
- return ef ? ef().dirty() : false;
4196
- }, ...(ngDevMode ? [{ debugName: "isDirty" }] : []));
4197
- /** Computed collection label */
4198
- collectionLabel = computed(() => {
4199
- const col = this.collection();
4200
- return humanizeFieldName(col.labels?.plural || col.slug);
4201
- }, ...(ngDevMode ? [{ debugName: "collectionLabel" }] : []));
4202
- /** Computed collection label singular */
4203
- collectionLabelSingular = computed(() => {
4204
- const col = this.collection();
4205
- return humanizeFieldName(col.labels?.singular || col.slug);
4206
- }, ...(ngDevMode ? [{ debugName: "collectionLabelSingular" }] : []));
4207
- /** Dashboard path (remove /collections from base path) */
4208
- dashboardPath = computed(() => {
4209
- const base = this.basePath();
4210
- return base.replace(/\/collections$/, '');
4211
- }, ...(ngDevMode ? [{ debugName: "dashboardPath" }] : []));
4212
- /** Collection list path */
4213
- collectionListPath = computed(() => {
4214
- return `${this.basePath()}/${this.collection().slug}`;
4215
- }, ...(ngDevMode ? [{ debugName: "collectionListPath" }] : []));
4216
- /** Page title for breadcrumb */
4217
- pageTitle = computed(() => {
4218
- if (this.isGlobal()) {
4219
- 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;
4220
4298
  }
4221
- const currentMode = this.mode();
4222
- if (currentMode === 'create') {
4223
- return `Create ${this.collectionLabelSingular()}`;
4299
+ if (mimeType.startsWith('audio/')) {
4300
+ return heroMusicalNote;
4224
4301
  }
4225
- const data = this.formModel();
4226
- const titleFields = ['title', 'name', 'label', 'subject'];
4227
- for (const field of titleFields) {
4228
- if (data[field] && typeof data[field] === 'string') {
4229
- return data[field];
4230
- }
4302
+ if (mimeType === 'application/pdf') {
4303
+ return heroDocumentText;
4231
4304
  }
4232
- return `Edit ${this.collectionLabelSingular()}`;
4233
- }, ...(ngDevMode ? [{ debugName: "pageTitle" }] : []));
4234
- /** Visible fields (excluding hidden ones and those failing admin.condition) */
4235
- visibleFields = computed(() => {
4236
- const col = this.collection();
4237
- const data = this.formModel();
4238
- return col.fields.filter((field) => {
4239
- if (field.admin?.hidden)
4240
- return false;
4241
- if (field.admin?.condition && !field.admin.condition(data))
4242
- return false;
4243
- return true;
4244
- });
4245
- }, ...(ngDevMode ? [{ debugName: "visibleFields" }] : []));
4246
- /** Whether user can edit */
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) => {
4945
+ if (field.admin?.hidden)
4946
+ return false;
4947
+ if (field.admin?.condition && !field.admin.condition(data))
4948
+ return false;
4949
+ return true;
4950
+ });
4951
+ }, ...(ngDevMode ? [{ debugName: "visibleFields" }] : []));
4952
+ /** Whether user can edit */
4247
4953
  canEdit = computed(() => {
4248
4954
  return this.collectionAccess.canUpdate(this.collection().slug);
4249
4955
  }, ...(ngDevMode ? [{ debugName: "canEdit" }] : []));
@@ -4364,9 +5070,62 @@ class EntityFormWidget {
4364
5070
  this.isLoading.set(false);
4365
5071
  }
4366
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
+ }
4367
5125
  /**
4368
5126
  * Handle form submission using Angular Signal Forms submit().
4369
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.
4370
5129
  */
4371
5130
  async onSubmit() {
4372
5131
  const ef = this.entityForm();
@@ -4379,7 +5138,7 @@ class EntityFormWidget {
4379
5138
  this.formError.set(null);
4380
5139
  try {
4381
5140
  const slug = this.collection().slug;
4382
- const data = this.formModel();
5141
+ const data = this.normalizeUploadFieldValues(this.formModel());
4383
5142
  let result;
4384
5143
  if (this.isGlobal()) {
4385
5144
  // Global mode: always update (singleton)
@@ -4387,6 +5146,10 @@ class EntityFormWidget {
4387
5146
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
4388
5147
  result = await this.api.global(gSlug).update(data);
4389
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
+ }
4390
5153
  else if (this.mode() === 'create') {
4391
5154
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
4392
5155
  result = await this.api.collection(slug).create(data);
@@ -4400,6 +5163,7 @@ class EntityFormWidget {
4400
5163
  }
4401
5164
  this.originalData.set(result);
4402
5165
  this.formModel.set({ ...result });
5166
+ this.pendingFile.set(null);
4403
5167
  ef().reset();
4404
5168
  this.saved.emit(result);
4405
5169
  if (!this.suppressNavigation() && !this.isGlobal()) {
@@ -4415,6 +5179,7 @@ class EntityFormWidget {
4415
5179
  }
4416
5180
  finally {
4417
5181
  this.isSubmitting.set(false);
5182
+ this.isUploadingFile.set(false);
4418
5183
  }
4419
5184
  });
4420
5185
  // submit() didn't call the callback — form is invalid
@@ -4423,6 +5188,85 @@ class EntityFormWidget {
4423
5188
  void this.liveAnnouncer.announce('Form submission failed. Please fix the errors above before submitting.', 'assertive');
4424
5189
  }
4425
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
+ }
4426
5270
  /**
4427
5271
  * Handle cancel.
4428
5272
  */
@@ -4532,6 +5376,20 @@ class EntityFormWidget {
4532
5376
  </mcms-alert>
4533
5377
  }
4534
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
+
4535
5393
  <div class="space-y-6">
4536
5394
  @for (field of visibleFields(); track field.name) {
4537
5395
  <mcms-field-renderer
@@ -4600,7 +5458,7 @@ class EntityFormWidget {
4600
5458
  </div>
4601
5459
  }
4602
5460
  </div>
4603
- `, 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 });
4604
5462
  }
4605
5463
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: EntityFormWidget, decorators: [{
4606
5464
  type: Component,
@@ -4618,6 +5476,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
4618
5476
  BreadcrumbItem,
4619
5477
  BreadcrumbSeparator,
4620
5478
  VersionHistoryWidget,
5479
+ CollectionUploadZoneComponent,
4621
5480
  ],
4622
5481
  changeDetection: ChangeDetectionStrategy.OnPush,
4623
5482
  host: { class: 'block' },
@@ -4679,6 +5538,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
4679
5538
  </mcms-alert>
4680
5539
  }
4681
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
+
4682
5555
  <div class="space-y-6">
4683
5556
  @for (field of visibleFields(); track field.name) {
4684
5557
  <mcms-field-renderer
@@ -5087,15 +5960,33 @@ class EntityViewWidget {
5087
5960
  const e = this.entity();
5088
5961
  if (!e || !col.admin?.preview)
5089
5962
  return null;
5090
- if (typeof col.admin.preview === 'function') {
5963
+ const preview = col.admin.preview;
5964
+ if (typeof preview === 'function') {
5091
5965
  try {
5092
5966
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- T extends Entity with index signature
5093
- return col.admin.preview(e);
5967
+ return preview(e);
5094
5968
  }
5095
5969
  catch {
5096
5970
  return null;
5097
5971
  }
5098
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
+ }
5099
5990
  return null;
5100
5991
  }, ...(ngDevMode ? [{ debugName: "previewUrl" }] : []));
5101
5992
  constructor() {
@@ -5327,44 +6218,64 @@ class EntityViewWidget {
5327
6218
  this.loadEntity(this.collection().slug, this.entityId());
5328
6219
  }
5329
6220
  /**
5330
- * Resolve relationship field values from IDs to display labels.
6221
+ * Resolve relationship and upload field values from IDs to display labels.
5331
6222
  */
5332
6223
  resolveRelationships(entity) {
5333
6224
  const fields = this.collection().fields;
5334
6225
  const resolved = new Map();
5335
6226
  const promises = [];
5336
6227
  for (const field of fields) {
5337
- if (field.type !== 'relationship')
5338
- continue;
5339
- const rawValue = entity[field.name];
5340
- if (!rawValue || typeof rawValue !== 'string')
5341
- continue;
5342
- const config = field.collection();
5343
- if (!isRecord(config) || typeof config['slug'] !== 'string')
5344
- continue;
5345
- const relSlug = config['slug'];
5346
- const titleField = getTitleField(config);
5347
- promises.push(this.api
5348
- .collection(relSlug)
5349
- .findById(rawValue)
5350
- .then((doc) => {
5351
- if (doc) {
5352
- if (titleField !== 'id') {
5353
- const titleValue = doc[titleField];
5354
- if (typeof titleValue === 'string') {
5355
- resolved.set(field.name, titleValue);
5356
- 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
+ }
5357
6248
  }
6249
+ resolved.set(field.name, String(doc['id'] ?? rawValue));
5358
6250
  }
5359
- resolved.set(field.name, String(doc['id'] ?? rawValue));
5360
- }
5361
- else {
6251
+ else {
6252
+ resolved.set(field.name, 'Unknown');
6253
+ }
6254
+ })
6255
+ .catch(() => {
5362
6256
  resolved.set(field.name, 'Unknown');
5363
- }
5364
- })
5365
- .catch(() => {
5366
- resolved.set(field.name, 'Unknown');
5367
- }));
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
+ }
5368
6279
  }
5369
6280
  if (promises.length > 0) {
5370
6281
  Promise.all(promises).then(() => {
@@ -5890,6 +6801,47 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
5890
6801
  }]
5891
6802
  }] });
5892
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
+
5893
6845
  /**
5894
6846
  * Admin Sidebar Widget
5895
6847
  *
@@ -5935,27 +6887,7 @@ class AdminSidebarWidget {
5935
6887
  /** Computed collections base path */
5936
6888
  collectionsBasePath = computed(() => `${this.basePath()}/collections`, ...(ngDevMode ? [{ debugName: "collectionsBasePath" }] : []));
5937
6889
  /** Collections grouped by admin.group field */
5938
- collectionGroups = computed(() => {
5939
- const collections = this.collections();
5940
- const DEFAULT_GROUP = 'Collections';
5941
- const groupMap = new Map();
5942
- for (const c of collections) {
5943
- const name = c.admin?.group ?? DEFAULT_GROUP;
5944
- const list = groupMap.get(name) ?? [];
5945
- list.push(c);
5946
- groupMap.set(name, list);
5947
- }
5948
- // Named groups first (in order of first appearance), default last
5949
- const groups = [];
5950
- for (const [name, colls] of groupMap) {
5951
- if (name !== DEFAULT_GROUP)
5952
- groups.push({ name, collections: colls });
5953
- }
5954
- const defaultGroup = groupMap.get(DEFAULT_GROUP);
5955
- if (defaultGroup)
5956
- groups.push({ name: DEFAULT_GROUP, collections: defaultGroup });
5957
- return groups;
5958
- }, ...(ngDevMode ? [{ debugName: "collectionGroups" }] : []));
6890
+ collectionGroups = computed(() => groupCollections(this.collections()), ...(ngDevMode ? [{ debugName: "collectionGroups" }] : []));
5959
6891
  /** Globals grouped by admin.group field */
5960
6892
  globalGroups = computed(() => {
5961
6893
  const globals = this.globals();
@@ -6109,7 +7041,7 @@ class AdminSidebarWidget {
6109
7041
  />
6110
7042
 
6111
7043
  <!-- Collection Sections (grouped by admin.group) -->
6112
- @for (group of collectionGroups(); track group.name) {
7044
+ @for (group of collectionGroups(); track group.id) {
6113
7045
  <mcms-sidebar-section [title]="group.name">
6114
7046
  @for (collection of group.collections; track collection.slug) {
6115
7047
  <mcms-sidebar-nav-item
@@ -6147,6 +7079,7 @@ class AdminSidebarWidget {
6147
7079
  [label]="route.label"
6148
7080
  [href]="basePath() + '/' + route.path"
6149
7081
  [icon]="route.icon"
7082
+ [exact]="true"
6150
7083
  />
6151
7084
  }
6152
7085
  </mcms-sidebar-section>
@@ -6280,7 +7213,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
6280
7213
  />
6281
7214
 
6282
7215
  <!-- Collection Sections (grouped by admin.group) -->
6283
- @for (group of collectionGroups(); track group.name) {
7216
+ @for (group of collectionGroups(); track group.id) {
6284
7217
  <mcms-sidebar-section [title]="group.name">
6285
7218
  @for (collection of group.collections; track collection.slug) {
6286
7219
  <mcms-sidebar-nav-item
@@ -6318,6 +7251,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
6318
7251
  [label]="route.label"
6319
7252
  [href]="basePath() + '/' + route.path"
6320
7253
  [icon]="route.icon"
7254
+ [exact]="true"
6321
7255
  />
6322
7256
  }
6323
7257
  </mcms-sidebar-section>
@@ -7417,6 +8351,8 @@ class DashboardPage {
7417
8351
  // Filter to only accessible, non-hidden collections
7418
8352
  return all.filter((c) => !c.admin?.hidden && accessible.includes(c.slug));
7419
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" }] : []));
7420
8356
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: DashboardPage, deps: [], target: i0.ɵɵFactoryTarget.Component });
7421
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: `
7422
8358
  <header class="mb-10">
@@ -7424,41 +8360,50 @@ class DashboardPage {
7424
8360
  <p class="text-muted-foreground mt-3 text-lg">Manage your content and collections</p>
7425
8361
  </header>
7426
8362
 
7427
- <section>
7428
- <h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
7429
- Collections
7430
- </h2>
7431
- <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
7432
- @for (collection of collections(); track collection.slug) {
7433
- <mcms-collection-card [collection]="collection" [basePath]="basePath" />
7434
- } @empty {
7435
- <div
7436
- class="col-span-full flex flex-col items-center justify-center p-16 bg-card/50 rounded-xl border border-dashed border-border/60"
7437
- >
7438
- <div class="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
7439
- <svg
7440
- aria-hidden="true"
7441
- class="w-8 h-8 text-muted-foreground"
7442
- fill="none"
7443
- viewBox="0 0 24 24"
7444
- stroke="currentColor"
7445
- >
7446
- <path
7447
- stroke-linecap="round"
7448
- stroke-linejoin="round"
7449
- stroke-width="1.5"
7450
- 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"
7451
- />
7452
- </svg>
7453
- </div>
7454
- <p class="text-foreground font-medium text-lg">No collections configured</p>
7455
- <p class="text-sm text-muted-foreground mt-2 text-center max-w-sm">
7456
- Add collections to your configuration to start managing content.
7457
- </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>
7458
8383
  </div>
7459
- }
7460
- </div>
7461
- </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
+ }
7462
8407
  `, isInline: true, dependencies: [{ kind: "component", type: CollectionCardWidget, selector: "mcms-collection-card", inputs: ["collection", "basePath", "showDocumentCount"], outputs: ["viewAll"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7463
8408
  }
7464
8409
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: DashboardPage, decorators: [{
@@ -7474,41 +8419,50 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
7474
8419
  <p class="text-muted-foreground mt-3 text-lg">Manage your content and collections</p>
7475
8420
  </header>
7476
8421
 
7477
- <section>
7478
- <h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
7479
- Collections
7480
- </h2>
7481
- <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
7482
- @for (collection of collections(); track collection.slug) {
7483
- <mcms-collection-card [collection]="collection" [basePath]="basePath" />
7484
- } @empty {
7485
- <div
7486
- 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"
7487
8455
  >
7488
- <div class="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
7489
- <svg
7490
- aria-hidden="true"
7491
- class="w-8 h-8 text-muted-foreground"
7492
- fill="none"
7493
- viewBox="0 0 24 24"
7494
- stroke="currentColor"
7495
- >
7496
- <path
7497
- stroke-linecap="round"
7498
- stroke-linejoin="round"
7499
- stroke-width="1.5"
7500
- 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"
7501
- />
7502
- </svg>
7503
- </div>
7504
- <p class="text-foreground font-medium text-lg">No collections configured</p>
7505
- <p class="text-sm text-muted-foreground mt-2 text-center max-w-sm">
7506
- Add collections to your configuration to start managing content.
7507
- </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
+ }
7508
8462
  </div>
7509
- }
7510
- </div>
7511
- </section>
8463
+ </section>
8464
+ }
8465
+ }
7512
8466
  `,
7513
8467
  }]
7514
8468
  }] });
@@ -8864,15 +9818,109 @@ var collectionList_page = /*#__PURE__*/Object.freeze({
8864
9818
  CollectionListPage: CollectionListPage
8865
9819
  });
8866
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
+
8867
9906
  /**
8868
9907
  * Live Preview Widget
8869
9908
  *
8870
9909
  * Displays an iframe that shows a live preview of the document being edited.
8871
- * 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().
8872
9921
  */
8873
9922
  class LivePreviewComponent {
8874
9923
  document = inject(DOCUMENT);
8875
- sanitizer = inject(DomSanitizer);
8876
9924
  destroyRef = inject(DestroyRef);
8877
9925
  /** Preview configuration from collection admin config */
8878
9926
  preview = input.required(...(ngDevMode ? [{ debugName: "preview" }] : []));
@@ -8888,8 +9936,8 @@ class LivePreviewComponent {
8888
9936
  deviceSize = signal('desktop', ...(ngDevMode ? [{ debugName: "deviceSize" }] : []));
8889
9937
  /** Refresh counter to force iframe reload */
8890
9938
  refreshCounter = signal(0, ...(ngDevMode ? [{ debugName: "refreshCounter" }] : []));
8891
- /** Reference to the iframe element */
8892
- 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" }] : []));
8893
9941
  /** Compute the raw preview URL */
8894
9942
  previewUrl = computed(() => {
8895
9943
  // Force recomputation on refresh
@@ -8906,18 +9954,25 @@ class LivePreviewComponent {
8906
9954
  return null;
8907
9955
  }
8908
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
+ }
8909
9971
  if (previewConfig === true && id) {
8910
9972
  return `/api/${slug}/${id}/preview`;
8911
9973
  }
8912
9974
  return null;
8913
9975
  }, ...(ngDevMode ? [{ debugName: "previewUrl" }] : []));
8914
- /** Sanitized preview URL for iframe binding */
8915
- safePreviewUrl = computed(() => {
8916
- const url = this.previewUrl();
8917
- if (!url)
8918
- return null;
8919
- return this.sanitizer.bypassSecurityTrustResourceUrl(url);
8920
- }, ...(ngDevMode ? [{ debugName: "safePreviewUrl" }] : []));
8921
9976
  /** Computed iframe width based on device size */
8922
9977
  iframeWidth = computed(() => {
8923
9978
  switch (this.deviceSize()) {
@@ -8929,21 +9984,57 @@ class LivePreviewComponent {
8929
9984
  return '100%';
8930
9985
  }
8931
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" }] : []));
8932
9997
  /** Debounce timer for postMessage updates */
8933
9998
  debounceTimer = undefined;
8934
9999
  constructor() {
8935
- // 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).
8936
10027
  effect(() => {
8937
10028
  const data = this.documentData();
8938
- const frame = this.previewFrame();
8939
- if (!frame?.nativeElement?.contentWindow)
10029
+ const iframeRef = this.previewIframe();
10030
+ if (!iframeRef?.nativeElement.contentWindow)
8940
10031
  return;
8941
10032
  // Debounce to avoid thrashing
8942
10033
  if (this.debounceTimer) {
8943
10034
  clearTimeout(this.debounceTimer);
8944
10035
  }
8945
10036
  this.debounceTimer = this.document.defaultView?.setTimeout(() => {
8946
- const iframeWindow = frame.nativeElement.contentWindow;
10037
+ const iframeWindow = iframeRef.nativeElement.contentWindow;
8947
10038
  if (iframeWindow) {
8948
10039
  const targetOrigin = this.document.defaultView?.location?.origin ?? '';
8949
10040
  iframeWindow.postMessage({ type: 'momentum-preview-update', data }, targetOrigin);
@@ -8979,7 +10070,7 @@ class LivePreviewComponent {
8979
10070
  this.refreshCounter.update((c) => c + 1);
8980
10071
  }
8981
10072
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: LivePreviewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
8982
- 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: `
8983
10074
  <!-- Preview toolbar -->
8984
10075
  <div class="flex items-center gap-2 px-4 py-2 border-b border-border bg-muted/50">
8985
10076
  <span class="text-sm font-medium text-foreground">Preview</span>
@@ -9047,15 +10138,14 @@ class LivePreviewComponent {
9047
10138
 
9048
10139
  <!-- Preview iframe container -->
9049
10140
  <div class="flex-1 overflow-auto bg-muted/30 flex justify-center p-4">
9050
- @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(). -->
9051
10144
  <iframe
9052
- #previewFrame
9053
- [src]="url"
9054
- [style.width]="iframeWidth()"
9055
- class="h-full bg-white border border-border rounded-md shadow-sm transition-[width] duration-300"
9056
- sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
10145
+ #previewIframe
9057
10146
  title="Live document preview"
9058
10147
  data-testid="preview-iframe"
10148
+ class="h-full bg-white border border-border rounded-md shadow-sm transition-[width] duration-300"
9059
10149
  ></iframe>
9060
10150
  } @else {
9061
10151
  <div class="flex items-center justify-center h-full text-muted-foreground text-sm">
@@ -9133,255 +10223,31 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
9133
10223
  (click)="refreshPreview()"
9134
10224
  data-testid="preview-refresh"
9135
10225
  aria-label="Refresh preview"
9136
- >
9137
- ↻ Refresh
9138
- </button>
9139
- </div>
9140
-
9141
- <!-- Preview iframe container -->
9142
- <div class="flex-1 overflow-auto bg-muted/30 flex justify-center p-4">
9143
- @if (safePreviewUrl(); as url) {
9144
- <iframe
9145
- #previewFrame
9146
- [src]="url"
9147
- [style.width]="iframeWidth()"
9148
- class="h-full bg-white border border-border rounded-md shadow-sm transition-[width] duration-300"
9149
- sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
9150
- title="Live document preview"
9151
- data-testid="preview-iframe"
9152
- ></iframe>
9153
- } @else {
9154
- <div class="flex items-center justify-center h-full text-muted-foreground text-sm">
9155
- Preview not available
9156
- </div>
9157
- }
9158
- </div>
9159
- `,
9160
- }]
9161
- }], 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 }] }] } });
9162
-
9163
- /**
9164
- * Collection View Page Component
9165
- *
9166
- * Displays a read-only view of a document using the EntityViewWidget.
9167
- * When preview is enabled, shows a toggleable live preview panel.
9168
- */
9169
- class CollectionViewPage {
9170
- route = inject(ActivatedRoute);
9171
- router = inject(Router);
9172
- basePath = '/admin/collections';
9173
- /** Whether the live preview panel is visible */
9174
- showPreview = signal(true, ...(ngDevMode ? [{ debugName: "showPreview" }] : []));
9175
- /** Reference to the entity view widget to read its entity data */
9176
- entityViewRef = viewChild('entityView', ...(ngDevMode ? [{ debugName: "entityViewRef" }] : []));
9177
- // Reactive slug signal that updates when route params change
9178
- slug = toSignal(this.route.paramMap.pipe(map((params) => params.get('slug') ?? '')), { initialValue: this.route.snapshot.paramMap.get('slug') ?? '' });
9179
- // Reactive entity ID signal
9180
- entityId = toSignal(this.route.paramMap.pipe(map((params) => params.get('id') ?? '')), {
9181
- initialValue: this.route.snapshot.paramMap.get('id') ?? '',
9182
- });
9183
- collection = computed(() => {
9184
- const currentSlug = this.slug();
9185
- if (!currentSlug)
9186
- return undefined;
9187
- const collections = getCollectionsFromRouteData(this.route.parent?.snapshot.data);
9188
- return collections.find((c) => c.slug === currentSlug);
9189
- }, ...(ngDevMode ? [{ debugName: "collection" }] : []));
9190
- /** Preview config from collection admin settings */
9191
- previewConfig = computed(() => {
9192
- const col = this.collection();
9193
- return col?.admin?.preview || undefined;
9194
- }, ...(ngDevMode ? [{ debugName: "previewConfig" }] : []));
9195
- /** Entity data from the entity view widget (for live preview) */
9196
- viewEntityData = computed(() => {
9197
- const view = this.entityViewRef();
9198
- const entity = view?.entity();
9199
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- T extends Entity with index signature
9200
- return entity ?? {};
9201
- }, ...(ngDevMode ? [{ debugName: "viewEntityData" }] : []));
9202
- onEdit(entity) {
9203
- const col = this.collection();
9204
- if (col) {
9205
- this.router.navigate([this.basePath, col.slug, entity.id, 'edit']);
9206
- }
9207
- }
9208
- onDelete(_entity) {
9209
- // Navigation is handled by EntityViewWidget
9210
- }
9211
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: CollectionViewPage, deps: [], target: i0.ɵɵFactoryTarget.Component });
9212
- 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: `
9213
- @if (collection(); as col) {
9214
- @if (entityId(); as id) {
9215
- @if (previewConfig(); as preview) {
9216
- @if (showPreview()) {
9217
- <!-- Split layout: entity view + preview -->
9218
- <div class="flex gap-0 h-[calc(100vh-64px)]" data-testid="preview-layout">
9219
- <div class="flex-1 overflow-y-auto p-6">
9220
- <mcms-entity-view
9221
- #entityView
9222
- [collection]="col"
9223
- [entityId]="id"
9224
- [basePath]="basePath"
9225
- (edit)="onEdit($event)"
9226
- (delete_)="onDelete($event)"
9227
- >
9228
- <div entityViewHeaderExtra class="mt-3">
9229
- <button
9230
- mcms-button
9231
- variant="ghost"
9232
- size="sm"
9233
- data-testid="preview-toggle"
9234
- (click)="showPreview.set(false)"
9235
- >
9236
- Hide Preview
9237
- </button>
9238
- </div>
9239
- </mcms-entity-view>
9240
- </div>
9241
- <div class="w-[50%] min-w-[400px] max-w-[720px]">
9242
- <mcms-live-preview
9243
- [preview]="preview"
9244
- [documentData]="viewEntityData()"
9245
- [collectionSlug]="col.slug"
9246
- [entityId]="id"
9247
- />
9248
- </div>
9249
- </div>
9250
- } @else {
9251
- <!-- Full-width view (preview hidden) -->
9252
- <mcms-entity-view
9253
- #entityView
9254
- [collection]="col"
9255
- [entityId]="id"
9256
- [basePath]="basePath"
9257
- (edit)="onEdit($event)"
9258
- (delete_)="onDelete($event)"
9259
- >
9260
- <div entityViewHeaderExtra class="mt-3">
9261
- <button
9262
- mcms-button
9263
- variant="ghost"
9264
- size="sm"
9265
- data-testid="preview-toggle"
9266
- (click)="showPreview.set(true)"
9267
- >
9268
- Show Preview
9269
- </button>
9270
- </div>
9271
- </mcms-entity-view>
9272
- }
9273
- } @else {
9274
- <!-- No preview configured -->
9275
- <mcms-entity-view
9276
- #entityView
9277
- [collection]="col"
9278
- [entityId]="id"
9279
- [basePath]="basePath"
9280
- (edit)="onEdit($event)"
9281
- (delete_)="onDelete($event)"
9282
- />
9283
- }
9284
- } @else {
9285
- <div class="p-12 text-center text-muted-foreground">Entity ID not provided</div>
9286
- }
9287
- } @else {
9288
- <div class="p-12 text-center text-muted-foreground">Collection not found</div>
9289
- }
9290
- `, 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 });
9291
- }
9292
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: CollectionViewPage, decorators: [{
9293
- type: Component,
9294
- args: [{
9295
- selector: 'mcms-collection-view',
9296
- imports: [EntityViewWidget, LivePreviewComponent, Button],
9297
- changeDetection: ChangeDetectionStrategy.OnPush,
9298
- host: { class: 'block' },
9299
- template: `
9300
- @if (collection(); as col) {
9301
- @if (entityId(); as id) {
9302
- @if (previewConfig(); as preview) {
9303
- @if (showPreview()) {
9304
- <!-- Split layout: entity view + preview -->
9305
- <div class="flex gap-0 h-[calc(100vh-64px)]" data-testid="preview-layout">
9306
- <div class="flex-1 overflow-y-auto p-6">
9307
- <mcms-entity-view
9308
- #entityView
9309
- [collection]="col"
9310
- [entityId]="id"
9311
- [basePath]="basePath"
9312
- (edit)="onEdit($event)"
9313
- (delete_)="onDelete($event)"
9314
- >
9315
- <div entityViewHeaderExtra class="mt-3">
9316
- <button
9317
- mcms-button
9318
- variant="ghost"
9319
- size="sm"
9320
- data-testid="preview-toggle"
9321
- (click)="showPreview.set(false)"
9322
- >
9323
- Hide Preview
9324
- </button>
9325
- </div>
9326
- </mcms-entity-view>
9327
- </div>
9328
- <div class="w-[50%] min-w-[400px] max-w-[720px]">
9329
- <mcms-live-preview
9330
- [preview]="preview"
9331
- [documentData]="viewEntityData()"
9332
- [collectionSlug]="col.slug"
9333
- [entityId]="id"
9334
- />
9335
- </div>
9336
- </div>
9337
- } @else {
9338
- <!-- Full-width view (preview hidden) -->
9339
- <mcms-entity-view
9340
- #entityView
9341
- [collection]="col"
9342
- [entityId]="id"
9343
- [basePath]="basePath"
9344
- (edit)="onEdit($event)"
9345
- (delete_)="onDelete($event)"
9346
- >
9347
- <div entityViewHeaderExtra class="mt-3">
9348
- <button
9349
- mcms-button
9350
- variant="ghost"
9351
- size="sm"
9352
- data-testid="preview-toggle"
9353
- (click)="showPreview.set(true)"
9354
- >
9355
- Show Preview
9356
- </button>
9357
- </div>
9358
- </mcms-entity-view>
9359
- }
9360
- } @else {
9361
- <!-- No preview configured -->
9362
- <mcms-entity-view
9363
- #entityView
9364
- [collection]="col"
9365
- [entityId]="id"
9366
- [basePath]="basePath"
9367
- (edit)="onEdit($event)"
9368
- (delete_)="onDelete($event)"
9369
- />
9370
- }
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>
9371
10242
  } @else {
9372
- <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>
9373
10246
  }
9374
- } @else {
9375
- <div class="p-12 text-center text-muted-foreground">Collection not found</div>
9376
- }
10247
+ </div>
9377
10248
  `,
9378
10249
  }]
9379
- }], propDecorators: { entityViewRef: [{ type: i0.ViewChild, args: ['entityView', { isSignal: true }] }] } });
9380
-
9381
- var collectionView_page = /*#__PURE__*/Object.freeze({
9382
- __proto__: null,
9383
- CollectionViewPage: CollectionViewPage
9384
- });
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 }] }] } });
9385
10251
 
9386
10252
  /**
9387
10253
  * Block Edit Dialog
@@ -10442,140 +11308,6 @@ var setup_page = /*#__PURE__*/Object.freeze({
10442
11308
  SetupPage: SetupPage
10443
11309
  });
10444
11310
 
10445
- /**
10446
- * Media Preview Component
10447
- *
10448
- * Displays a preview of media based on its type:
10449
- * - Images: Thumbnail preview
10450
- * - Videos: Video icon with optional poster
10451
- * - Audio: Audio icon
10452
- * - Documents: Document icon
10453
- * - Other: Generic file icon
10454
- *
10455
- * @example
10456
- * ```html
10457
- * <mcms-media-preview
10458
- * [media]="mediaDocument"
10459
- * [size]="'md'"
10460
- * />
10461
- * ```
10462
- */
10463
- class MediaPreviewComponent {
10464
- /** Media data to preview */
10465
- media = input(null, ...(ngDevMode ? [{ debugName: "media" }] : []));
10466
- /** Size of the preview */
10467
- size = input('md', ...(ngDevMode ? [{ debugName: "size" }] : []));
10468
- /** Custom class override */
10469
- class = input('', ...(ngDevMode ? [{ debugName: "class" }] : []));
10470
- /** Whether to show rounded corners */
10471
- rounded = input(true, ...(ngDevMode ? [{ debugName: "rounded" }] : []));
10472
- /** Host classes */
10473
- hostClasses = computed(() => {
10474
- const sizeClass = this.sizeClasses()[this.size()];
10475
- const roundedClass = this.rounded() ? 'rounded-md overflow-hidden' : '';
10476
- return `inline-block ${sizeClass} ${roundedClass} ${this.class()}`;
10477
- }, ...(ngDevMode ? [{ debugName: "hostClasses" }] : []));
10478
- /** Size classes map */
10479
- sizeClasses = computed(() => ({
10480
- xs: 'h-8 w-8',
10481
- sm: 'h-12 w-12',
10482
- md: 'h-20 w-20',
10483
- lg: 'h-32 w-32',
10484
- xl: 'h-48 w-48',
10485
- }), ...(ngDevMode ? [{ debugName: "sizeClasses" }] : []));
10486
- /** Icon size classes */
10487
- iconClasses = computed(() => {
10488
- const sizes = {
10489
- xs: 'text-lg',
10490
- sm: 'text-xl',
10491
- md: 'text-3xl',
10492
- lg: 'text-4xl',
10493
- xl: 'text-6xl',
10494
- };
10495
- return `${sizes[this.size()]} text-mcms-muted-foreground`;
10496
- }, ...(ngDevMode ? [{ debugName: "iconClasses" }] : []));
10497
- /** Whether the media is an image */
10498
- isImage = computed(() => {
10499
- const mimeType = this.media()?.mimeType ?? '';
10500
- return mimeType.startsWith('image/');
10501
- }, ...(ngDevMode ? [{ debugName: "isImage" }] : []));
10502
- /** Whether the media is a video */
10503
- isVideo = computed(() => {
10504
- const mimeType = this.media()?.mimeType ?? '';
10505
- return mimeType.startsWith('video/');
10506
- }, ...(ngDevMode ? [{ debugName: "isVideo" }] : []));
10507
- /** Whether the media is audio */
10508
- isAudio = computed(() => {
10509
- const mimeType = this.media()?.mimeType ?? '';
10510
- return mimeType.startsWith('audio/');
10511
- }, ...(ngDevMode ? [{ debugName: "isAudio" }] : []));
10512
- /** Image URL for preview */
10513
- imageUrl = computed(() => {
10514
- const media = this.media();
10515
- if (!media)
10516
- return '';
10517
- return media.url ?? `/api/media/file/${media.path}`;
10518
- }, ...(ngDevMode ? [{ debugName: "imageUrl" }] : []));
10519
- /** Icon name based on media type */
10520
- iconName = computed(() => {
10521
- const mimeType = this.media()?.mimeType ?? '';
10522
- if (mimeType.startsWith('video/')) {
10523
- return heroFilm;
10524
- }
10525
- if (mimeType.startsWith('audio/')) {
10526
- return heroMusicalNote;
10527
- }
10528
- if (mimeType === 'application/pdf') {
10529
- return heroDocumentText;
10530
- }
10531
- if (mimeType.startsWith('application/zip') || mimeType.includes('compressed')) {
10532
- return heroArchiveBox;
10533
- }
10534
- if (mimeType.startsWith('image/')) {
10535
- return heroPhoto;
10536
- }
10537
- return heroDocument;
10538
- }, ...(ngDevMode ? [{ debugName: "iconName" }] : []));
10539
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: MediaPreviewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
10540
- 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: `
10541
- @if (isImage()) {
10542
- <img
10543
- [src]="imageUrl()"
10544
- [alt]="media()?.alt ?? media()?.filename ?? 'Media preview'"
10545
- class="h-full w-full object-cover"
10546
- />
10547
- } @else {
10548
- <div class="flex h-full w-full items-center justify-center bg-mcms-muted">
10549
- <ng-icon [name]="iconName()" [class]="iconClasses()" />
10550
- </div>
10551
- }
10552
- `, isInline: true, dependencies: [{ kind: "component", type: NgIcon, selector: "ng-icon", inputs: ["name", "svg", "size", "strokeWidth", "color"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
10553
- }
10554
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: MediaPreviewComponent, decorators: [{
10555
- type: Component,
10556
- args: [{
10557
- selector: 'mcms-media-preview',
10558
- host: {
10559
- '[class]': 'hostClasses()',
10560
- },
10561
- template: `
10562
- @if (isImage()) {
10563
- <img
10564
- [src]="imageUrl()"
10565
- [alt]="media()?.alt ?? media()?.filename ?? 'Media preview'"
10566
- class="h-full w-full object-cover"
10567
- />
10568
- } @else {
10569
- <div class="flex h-full w-full items-center justify-center bg-mcms-muted">
10570
- <ng-icon [name]="iconName()" [class]="iconClasses()" />
10571
- </div>
10572
- }
10573
- `,
10574
- imports: [NgIcon],
10575
- changeDetection: ChangeDetectionStrategy.OnPush,
10576
- }]
10577
- }], 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 }] }] } });
10578
-
10579
11311
  /**
10580
11312
  * Type guard to check if a value has the shape of a MediaEditItem.
10581
11313
  */
@@ -12603,18 +13335,18 @@ function provideMomentumFieldRenderers() {
12603
13335
  registry.register('checkbox', () => Promise.resolve().then(function () { return checkboxField_component; }).then((m) => m.CheckboxFieldRenderer));
12604
13336
  registry.register('date', () => Promise.resolve().then(function () { return dateField_component; }).then((m) => m.DateFieldRenderer));
12605
13337
  registry.register('upload', () => Promise.resolve().then(function () { return uploadField_component; }).then((m) => m.UploadFieldRenderer));
12606
- registry.register('richText', () => import('./momentumcms-admin-rich-text-field.component-DKQ6pwp7.mjs').then((m) => m.RichTextFieldRenderer));
13338
+ registry.register('richText', () => import('./momentumcms-admin-rich-text-field.component-BC8pRU89.mjs').then((m) => m.RichTextFieldRenderer));
12607
13339
  // Layout field renderers (support nested field rendering)
12608
- registry.register('group', () => import('./momentumcms-admin-group-field.component-BZeG8Oqy.mjs').then((m) => m.GroupFieldRenderer));
12609
- registry.register('array', () => import('./momentumcms-admin-array-field.component-CT5NlIEv.mjs').then((m) => m.ArrayFieldRenderer));
12610
- registry.register('blocks', () => import('./momentumcms-admin-blocks-field.component-Cz7HmuBK.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));
12611
13343
  // Visual block editor variant (blocks field with admin.editor === 'visual')
12612
13344
  registry.register('blocks-visual', () => Promise.resolve().then(function () { return visualBlockEditor_component; }).then((m) => m.VisualBlockEditorComponent));
12613
- registry.register('relationship', () => import('./momentumcms-admin-relationship-field.component-BuxtRs2_.mjs').then((m) => m.RelationshipFieldRenderer));
13345
+ registry.register('relationship', () => import('./momentumcms-admin-relationship-field.component-D-UQgd7m.mjs').then((m) => m.RelationshipFieldRenderer));
12614
13346
  // Layout-only renderers (tabs, collapsible, row)
12615
- registry.register('tabs', () => import('./momentumcms-admin-tabs-field.component-mZ4dpZoD.mjs').then((m) => m.TabsFieldRenderer));
12616
- registry.register('collapsible', () => import('./momentumcms-admin-collapsible-field.component-CtwrGQvg.mjs').then((m) => m.CollapsibleFieldRenderer));
12617
- registry.register('row', () => import('./momentumcms-admin-row-field.component-ks3FXd4B.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));
12618
13350
  };
12619
13351
  },
12620
13352
  },
@@ -13456,39 +14188,39 @@ class MediaPickerDialog {
13456
14188
  this.isLoading.set(true);
13457
14189
  try {
13458
14190
  const collection = this.api.collection(this.collectionSlug());
13459
- const whereClause = {};
13460
- // 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
13461
14198
  if (search) {
13462
- whereClause['filename'] = { contains: search };
14199
+ const lowerSearch = search.toLowerCase();
14200
+ items = items.filter((m) => m.filename.toLowerCase().includes(lowerSearch));
13463
14201
  }
13464
- // Add MIME type filter
14202
+ // Client-side MIME type filter
13465
14203
  const mimeTypes = this.data?.mimeTypes;
13466
14204
  if (mimeTypes && mimeTypes.length > 0) {
13467
- // For simple types like 'image/*', filter by prefix
13468
- const prefixes = mimeTypes.filter((t) => t.endsWith('/*')).map((t) => t.slice(0, -2));
13469
- const exactTypes = mimeTypes.filter((t) => !t.endsWith('/*'));
13470
- if (prefixes.length > 0 || exactTypes.length > 0) {
13471
- const orConditions = [];
13472
- for (const prefix of prefixes) {
13473
- orConditions.push({ mimeType: { startsWith: prefix } });
13474
- }
13475
- for (const type of exactTypes) {
13476
- 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));
13477
14208
  }
13478
- if (orConditions.length > 0) {
13479
- whereClause['or'] = orConditions;
13480
- }
13481
- }
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);
13482
14223
  }
13483
- const result = await collection.find({
13484
- where: Object.keys(whereClause).length > 0 ? whereClause : undefined,
13485
- page,
13486
- limit: this.limit(),
13487
- sort: '-createdAt',
13488
- });
13489
- this.mediaItems.set(toMediaItems(result.docs));
13490
- this.totalDocs.set(result.totalDocs);
13491
- this.totalPages.set(result.totalPages);
13492
14224
  }
13493
14225
  catch (error) {
13494
14226
  console.error('Failed to load media:', error);
@@ -13761,9 +14493,10 @@ function getInputFromEvent(event) {
13761
14493
  * a FieldTree node's FieldState rather than event-based I/O.
13762
14494
  */
13763
14495
  class UploadFieldRenderer {
13764
- document = inject(DOCUMENT);
14496
+ fileInputRef = viewChild('fileInput', ...(ngDevMode ? [{ debugName: "fileInputRef" }] : []));
13765
14497
  uploadService = inject(UploadService);
13766
14498
  dialogService = inject(DialogService);
14499
+ api = injectMomentumAPI();
13767
14500
  /** Field definition */
13768
14501
  field = input.required(...(ngDevMode ? [{ debugName: "field" }] : []));
13769
14502
  /** Signal forms FieldTree node for this field */
@@ -13785,6 +14518,43 @@ class UploadFieldRenderer {
13785
14518
  uploadingFilename = signal('', ...(ngDevMode ? [{ debugName: "uploadingFilename" }] : []));
13786
14519
  uploadError = signal(null, ...(ngDevMode ? [{ debugName: "uploadError" }] : []));
13787
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
+ }
13788
14558
  /** Unique field ID */
13789
14559
  fieldId = computed(() => `field-${this.path().replace(/\./g, '-')}`, ...(ngDevMode ? [{ debugName: "fieldId" }] : []));
13790
14560
  /** Computed label */
@@ -13814,32 +14584,35 @@ class UploadFieldRenderer {
13814
14584
  const val = this.currentValue();
13815
14585
  return val !== null && val !== undefined && val !== '';
13816
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" }] : []));
13817
14595
  /** Media preview data from value */
13818
14596
  mediaPreviewData = computed(() => {
13819
- const val = this.currentValue();
13820
- if (!val)
14597
+ const media = this.effectiveMedia();
14598
+ if (!media)
13821
14599
  return null;
13822
- // If value is a full document object
13823
- if (typeof val === 'object' && val !== null) {
14600
+ if (typeof media === 'object' && media !== null) {
13824
14601
  return {
13825
- url: getStringProp(val, 'url'),
13826
- path: getStringProp(val, 'path'),
13827
- mimeType: getStringProp(val, 'mimeType'),
13828
- filename: getStringProp(val, 'filename'),
13829
- 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'),
13830
14607
  };
13831
14608
  }
13832
- // If value is just an ID, we can't preview without fetching
13833
- // Return a placeholder
13834
- return {
13835
- path: String(val),
13836
- };
14609
+ return null;
13837
14610
  }, ...(ngDevMode ? [{ debugName: "mediaPreviewData" }] : []));
13838
14611
  /** Media filename from value */
13839
14612
  mediaFilename = computed(() => {
13840
- const val = this.currentValue();
13841
- if (typeof val === 'object' && val !== null) {
13842
- 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';
13843
14616
  }
13844
14617
  return 'Selected media';
13845
14618
  }, ...(ngDevMode ? [{ debugName: "mediaFilename" }] : []));
@@ -13940,11 +14713,19 @@ class UploadFieldRenderer {
13940
14713
  triggerFileInput() {
13941
14714
  if (this.isDisabled())
13942
14715
  return;
13943
- const input = this.document.querySelector(`#${this.fieldId()} input[type="file"]`);
13944
- if (input instanceof HTMLInputElement) {
13945
- input.click();
14716
+ const ref = this.fileInputRef();
14717
+ if (ref) {
14718
+ ref.nativeElement.click();
13946
14719
  }
13947
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
+ }
13948
14729
  /**
13949
14730
  * Handle file selection from input.
13950
14731
  */
@@ -13989,7 +14770,8 @@ class UploadFieldRenderer {
13989
14770
  this.uploadProgress.set(0);
13990
14771
  this.uploadingFilename.set(file.name);
13991
14772
  this.uploadingFile.set(file);
13992
- this.uploadService.upload(file).subscribe({
14773
+ const relationTo = this.uploadField().relationTo;
14774
+ this.uploadService.uploadToCollection(relationTo, file).subscribe({
13993
14775
  next: (progress) => {
13994
14776
  this.uploadProgress.set(progress.progress);
13995
14777
  if (progress.status === 'complete' && progress.result) {
@@ -14047,7 +14829,7 @@ class UploadFieldRenderer {
14047
14829
  }
14048
14830
  }
14049
14831
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: UploadFieldRenderer, deps: [], target: i0.ɵɵFactoryTarget.Component });
14050
- 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: `
14051
14833
  <mcms-form-field
14052
14834
  [id]="fieldId()"
14053
14835
  [required]="required()"
@@ -14105,7 +14887,7 @@ class UploadFieldRenderer {
14105
14887
  </div>
14106
14888
  </div>
14107
14889
  } @else {
14108
- <!-- Drop zone -->
14890
+ <!-- Drop zone — keyboard-accessible for WCAG 2.1 SC 2.1.1 -->
14109
14891
  <div
14110
14892
  class="relative rounded-lg border-2 border-dashed transition-colors"
14111
14893
  [class.border-mcms-border]="!isDragging()"
@@ -14113,16 +14895,16 @@ class UploadFieldRenderer {
14113
14895
  [class.bg-mcms-primary/5]="isDragging()"
14114
14896
  [class.cursor-pointer]="!isDisabled()"
14115
14897
  [class.opacity-50]="isDisabled()"
14116
- tabindex="0"
14117
14898
  role="button"
14899
+ tabindex="0"
14900
+ [attr.aria-label]="'Upload file for ' + label()"
14118
14901
  [attr.aria-disabled]="isDisabled()"
14119
- [attr.aria-label]="'Upload ' + label() + ' file. Drag and drop or click to browse.'"
14120
14902
  (dragover)="onDragOver($event)"
14121
14903
  (dragleave)="onDragLeave($event)"
14122
14904
  (drop)="onDrop($event)"
14123
14905
  (click)="triggerFileInput()"
14124
14906
  (keydown.enter)="triggerFileInput()"
14125
- (keydown.space)="triggerFileInput()"
14907
+ (keydown.space)="onDropZoneSpace($event)"
14126
14908
  >
14127
14909
  <div class="flex flex-col items-center justify-center gap-2 p-8">
14128
14910
  <ng-icon [name]="uploadIcon" class="h-10 w-10 text-mcms-muted-foreground" aria-hidden="true" />
@@ -14145,31 +14927,31 @@ class UploadFieldRenderer {
14145
14927
  </p>
14146
14928
  }
14147
14929
  </div>
14148
- @if (!isDisabled()) {
14149
- <div class="mt-2 flex gap-2">
14150
- <button
14151
- mcms-button
14152
- variant="outline"
14153
- size="sm"
14154
- type="button"
14155
- (click)="$event.stopPropagation(); openMediaPicker()"
14156
- >
14157
- <ng-icon [name]="photoIcon" class="h-4 w-4" />
14158
- Select from library
14159
- </button>
14160
- </div>
14161
- }
14162
14930
  </div>
14163
- <input
14164
- #fileInput
14165
- type="file"
14166
- class="sr-only"
14167
- [accept]="acceptAttribute()"
14168
- [disabled]="isDisabled()"
14169
- (change)="onFileSelected($event)"
14170
- [attr.aria-label]="'Choose file for ' + label()"
14171
- />
14172
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
+ />
14173
14955
  }
14174
14956
 
14175
14957
  @if (uploadError()) {
@@ -14243,7 +15025,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
14243
15025
  </div>
14244
15026
  </div>
14245
15027
  } @else {
14246
- <!-- Drop zone -->
15028
+ <!-- Drop zone — keyboard-accessible for WCAG 2.1 SC 2.1.1 -->
14247
15029
  <div
14248
15030
  class="relative rounded-lg border-2 border-dashed transition-colors"
14249
15031
  [class.border-mcms-border]="!isDragging()"
@@ -14251,16 +15033,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
14251
15033
  [class.bg-mcms-primary/5]="isDragging()"
14252
15034
  [class.cursor-pointer]="!isDisabled()"
14253
15035
  [class.opacity-50]="isDisabled()"
14254
- tabindex="0"
14255
15036
  role="button"
15037
+ tabindex="0"
15038
+ [attr.aria-label]="'Upload file for ' + label()"
14256
15039
  [attr.aria-disabled]="isDisabled()"
14257
- [attr.aria-label]="'Upload ' + label() + ' file. Drag and drop or click to browse.'"
14258
15040
  (dragover)="onDragOver($event)"
14259
15041
  (dragleave)="onDragLeave($event)"
14260
15042
  (drop)="onDrop($event)"
14261
15043
  (click)="triggerFileInput()"
14262
15044
  (keydown.enter)="triggerFileInput()"
14263
- (keydown.space)="triggerFileInput()"
15045
+ (keydown.space)="onDropZoneSpace($event)"
14264
15046
  >
14265
15047
  <div class="flex flex-col items-center justify-center gap-2 p-8">
14266
15048
  <ng-icon [name]="uploadIcon" class="h-10 w-10 text-mcms-muted-foreground" aria-hidden="true" />
@@ -14283,31 +15065,31 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
14283
15065
  </p>
14284
15066
  }
14285
15067
  </div>
14286
- @if (!isDisabled()) {
14287
- <div class="mt-2 flex gap-2">
14288
- <button
14289
- mcms-button
14290
- variant="outline"
14291
- size="sm"
14292
- type="button"
14293
- (click)="$event.stopPropagation(); openMediaPicker()"
14294
- >
14295
- <ng-icon [name]="photoIcon" class="h-4 w-4" />
14296
- Select from library
14297
- </button>
14298
- </div>
14299
- }
14300
15068
  </div>
14301
- <input
14302
- #fileInput
14303
- type="file"
14304
- class="sr-only"
14305
- [accept]="acceptAttribute()"
14306
- [disabled]="isDisabled()"
14307
- (change)="onFileSelected($event)"
14308
- [attr.aria-label]="'Choose file for ' + label()"
14309
- />
14310
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
+ />
14311
15093
  }
14312
15094
 
14313
15095
  @if (uploadError()) {
@@ -14316,7 +15098,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
14316
15098
  </mcms-form-field>
14317
15099
  `,
14318
15100
  }]
14319
- }], 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 }] }] } });
14320
15102
 
14321
15103
  var uploadField_component = /*#__PURE__*/Object.freeze({
14322
15104
  __proto__: null,
@@ -14330,4 +15112,4 @@ var uploadField_component = /*#__PURE__*/Object.freeze({
14330
15112
  */
14331
15113
 
14332
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 };
14333
- //# sourceMappingURL=momentumcms-admin-momentumcms-admin-o0FbJXZN.mjs.map
15115
+ //# sourceMappingURL=momentumcms-admin-momentumcms-admin-D_47TVaR.mjs.map