@oxygen-cms/ui 1.9.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/src/CrudApi.js +39 -0
  3. package/src/PagesApi.js +2 -0
  4. package/src/PartialsApi.js +7 -0
  5. package/src/components/ContentResourceEdit.vue +964 -0
  6. package/src/components/{PageEdit.vue → PageEditWysiwyg.vue} +1 -1
  7. package/src/components/ResourceList.vue +8 -3
  8. package/src/components/VersionsDrawer.vue +386 -0
  9. package/src/components/content/PartialNodeView.vue +1 -1
  10. package/src/components/pages/CreatePageDropdown.vue +5 -5
  11. package/src/components/pages/PageActions.vue +67 -0
  12. package/src/components/pages/PageChooseParent.vue +170 -0
  13. package/src/components/pages/PageEdit.vue +149 -0
  14. package/src/components/pages/PageList.vue +1 -1
  15. package/src/components/{PageNestedRow.vue → pages/PageNestedRow.vue} +3 -3
  16. package/src/components/{PageStatusIcon.vue → pages/PageStatusIcon.vue} +1 -1
  17. package/src/components/{PageTable.vue → pages/PageTable.vue} +5 -5
  18. package/src/components/partials/CreatePartialDropdown.vue +1 -1
  19. package/src/components/{PartialActions.vue → partials/PartialActions.vue} +1 -1
  20. package/src/components/partials/PartialEdit.vue +49 -0
  21. package/src/components/{PartialList.vue → partials/PartialList.vue} +3 -3
  22. package/src/components/{PartialStatusIcon.vue → partials/PartialStatusIcon.vue} +1 -1
  23. package/src/components/{PartialTable.vue → partials/PartialTable.vue} +1 -1
  24. package/src/icons.js +11 -2
  25. package/src/modules/PagesPartials.js +11 -16
  26. package/src/components/PageActions.vue +0 -151
  27. /package/src/components/{PageNestedPagination.vue → pages/PageNestedPagination.vue} +0 -0
@@ -0,0 +1,964 @@
1
+ <template>
2
+ <div v-hotkey="keymap" class="edit-container is-flex is-flex-direction-column" :class="{ 'is-fullscreen': isFullscreen }">
3
+
4
+ <!-- Version Warning Banner -->
5
+ <div v-if="!isFullscreen && !loading && editingNonHead" :class="(editOverrideConfirmed ? 'has-background-info-light' : 'has-background-warning') + ' px-4 py-4'">
6
+ <div class="is-flex is-align-items-center">
7
+ <b-icon icon="exclamation-triangle" class="mr-2"></b-icon>
8
+ You're viewing a version from {{ formatDate(model.updatedAt) }}. This is not the current version.
9
+ <b-button class="ml-2" type="is-text is-warning" size="is-small" icon-left="pencil-alt" @click="navigateToHeadVersion">Edit Latest</b-button>
10
+ </div>
11
+ </div>
12
+
13
+ <!-- Trashed Warning Banner -->
14
+ <div v-if="!isFullscreen && !loading && isTrashed" class="has-background-grey-light px-4 py-4">
15
+ <div class="is-flex is-align-items-center">
16
+ <b-icon icon="trash" class="mr-2"></b-icon>
17
+ This {{ displayName.toLowerCase() }} is in the trash. Editing trashed items is supported, but not recommended.
18
+ <b-button rounded class="ml-2" type="is-dark" size="is-small" icon-left="recycle" @click="restoreResource">Restore</b-button>
19
+ </div>
20
+ </div>
21
+
22
+ <!-- Header Bar -->
23
+ <div v-if="!isFullscreen" class="header-bar px-4 py-3 is-flex is-align-items-center" style="border-bottom: 1px solid #dbdbdb;">
24
+ <b-button rounded icon-left="angle-left" @click="goBack">Back</b-button>
25
+
26
+ <div class="mx-4 is-flex-grow-1">
27
+ <transition name="fade" mode="out-in">
28
+ <b-skeleton v-if="loading" key="skeleton" width="200px"></b-skeleton>
29
+ <div v-else-if="!editingTitle" key="display" class="title-display">
30
+ <span class="title is-4">{{ model.title || ('Untitled ' + displayName) }}</span>
31
+ <b-button rounded size="is-small" type="is-light" icon-left="pencil-alt" @click="startEditingTitle"></b-button>
32
+ <b-tag v-if="isHeadVersion" type="is-success" size="is-small" class="ml-2" icon="star">
33
+ Latest version
34
+ </b-tag>
35
+ <!-- SLOT: Resource-specific title tags (e.g., "Sent" tag for emails) -->
36
+ <slot
37
+ name="title-tags"
38
+ :model="model"
39
+ :is-dirty="isDirty"
40
+ :loading="loading"
41
+ ></slot>
42
+ <b-tag v-if="isDirty" type="is-info">Unsaved changes</b-tag>
43
+ </div>
44
+ <b-field v-else key="editing" expanded class="title-editing">
45
+ <b-input
46
+ ref="titleInput"
47
+ v-model="editingTitleValue"
48
+ :placeholder="displayName + ' Title'"
49
+ expanded
50
+ class="mr-2"
51
+ @keyup.enter.native="finishEditingTitle"
52
+ @keyup.esc.native="cancelEditingTitle"
53
+ />
54
+ <b-field>
55
+ <p class="control">
56
+ <b-button type="is-primary" @click="finishEditingTitle">Done</b-button>
57
+ </p>
58
+ <p class="control">
59
+ <b-button @click="cancelEditingTitle">Cancel</b-button>
60
+ </p>
61
+ </b-field>
62
+ </b-field>
63
+ </transition>
64
+ </div>
65
+
66
+ <div class="is-flex" style="gap: 0.5rem;">
67
+ <b-button
68
+ v-if="!loading && hasPublish"
69
+ :disabled="isPublished"
70
+ :type="isPublished ? '' : 'is-success'"
71
+ icon-left="globe-asia"
72
+ @click="isPublished ? null : publish()"
73
+ >
74
+ {{ isPublished ? 'Published' : 'Publish' }}
75
+ </b-button>
76
+
77
+ <!-- SLOT: Resource-specific top-bar actions (e.g., send button for emails) -->
78
+ <slot
79
+ name="top-bar-actions"
80
+ :model="model"
81
+ :is-published="isPublished"
82
+ :is-dirty="isDirty"
83
+ :loading="loading"
84
+ ></slot>
85
+
86
+ <b-button
87
+ v-if="!loading"
88
+ icon-left="eye"
89
+ :disabled="isInViewMode"
90
+ @click="viewFullscreen"
91
+ >
92
+ View
93
+ </b-button>
94
+ <b-field>
95
+ <p class="control">
96
+ <b-button icon-left="cog" @click="openSettings">{{ settingsLabel || (displayName + ' Settings') }}</b-button>
97
+ </p>
98
+ <p v-if="hasVersions" class="control">
99
+ <b-button icon-left="history" @click="openVersionDrawer">Versions</b-button>
100
+ </p>
101
+ <p class="control">
102
+ <b-dropdown position="is-bottom-left" aria-role="menu">
103
+ <template #trigger>
104
+ <b-button icon-left="bars"></b-button>
105
+ </template>
106
+
107
+ <!-- SLOT: Resource-specific dropdown items (e.g., "View on Site" for pages) -->
108
+ <slot
109
+ name="dropdown-actions"
110
+ :model="model"
111
+ :is-published="isPublished"
112
+ ></slot>
113
+
114
+ <b-dropdown-item v-if="hasVersions" aria-role="menuitem" @click="saveAsNewVersion">
115
+ <b-icon icon="plus"></b-icon>
116
+ New Version
117
+ </b-dropdown-item>
118
+ <b-dropdown-item v-if="!isTrashed" aria-role="menuitem" @click="deleteResource">
119
+ <b-icon icon="trash"></b-icon>
120
+ Delete
121
+ </b-dropdown-item>
122
+ <b-dropdown-item v-if="isTrashed" aria-role="menuitem" @click="forceDeleteResource">
123
+ <b-icon icon="trash"></b-icon>
124
+ Delete Forever
125
+ </b-dropdown-item>
126
+ <b-dropdown-item v-if="isTrashed" aria-role="menuitem" @click="restoreResource">
127
+ <b-icon icon="recycle"></b-icon>
128
+ Restore
129
+ </b-dropdown-item>
130
+ </b-dropdown>
131
+ </p>
132
+ </b-field>
133
+ </div>
134
+ </div>
135
+
136
+ <!-- SLOT: Inline settings (shown between toolbar and editor) -->
137
+ <slot
138
+ name="inline-settings"
139
+ v-if="!isFullscreen"
140
+ :model="model"
141
+ :is-dirty="isDirty"
142
+ :server-model="serverModel"
143
+ :loading="loading"
144
+ ></slot>
145
+
146
+ <!-- Editor Toolbar -->
147
+ <div v-if="!loading" class="has-background-white-bis px-4 py-3 is-flex is-align-items-center" style="border-bottom: 1px solid #dbdbdb; gap: 1rem;">
148
+ <b-field class="mb-0">
149
+ <p class="control">
150
+ <b-button
151
+ :type="editorMode === 'code' ? 'is-dark' : ''"
152
+ @click="switchEditorMode('code')"
153
+ >
154
+ Code
155
+ </b-button></p>
156
+ <p class="control">
157
+ <b-button
158
+ :type="editorMode === 'split' ? 'is-dark' : ''"
159
+ @click="switchEditorMode('split')"
160
+ >
161
+ Split
162
+ </b-button></p>
163
+ <p class="control">
164
+ <b-button
165
+ :type="editorMode === 'preview' ? 'is-dark' : ''"
166
+ @click="switchEditorMode('preview')"
167
+ >
168
+ Preview
169
+ </b-button></p>
170
+ </b-field>
171
+
172
+ <b-button icon-left="image" :disabled="editorMode === 'preview'" @click="isMediaModalActive = true">
173
+ Insert Photo or File
174
+ </b-button>
175
+
176
+ <b-switch v-if="hasFullPagePreview" :value="renderLayout" size="is-small" @input="updateQueryParam('fullPage', $event)">
177
+ Preview full page
178
+ </b-switch>
179
+
180
+ <b-switch
181
+ :value="isFullscreen" size="is-small" @input="updateQueryParam('fullscreen', $event)"
182
+ >Focus</b-switch>
183
+
184
+ <div class="is-flex-grow-1"></div>
185
+
186
+ <b-field>
187
+ <p class="control">
188
+ <b-dropdown position="is-bottom-left" aria-role="menu">
189
+ <template #trigger>
190
+ <b-button :label="versionStrategyLabel" icon-right="caret-down" :disabled="!isDirty" />
191
+ </template>
192
+ <b-dropdown-item aria-role="menuitem" @click="versionStrategy = 'guess'">
193
+ Create New Version if Needed
194
+ </b-dropdown-item>
195
+ <b-dropdown-item aria-role="menuitem" @click="versionStrategy = 'new'">
196
+ Save as New Version
197
+ </b-dropdown-item>
198
+ <b-dropdown-item aria-role="menuitem" @click="versionStrategy = 'overwrite'">
199
+ Overwrite Current Version
200
+ </b-dropdown-item>
201
+ </b-dropdown>
202
+ </p>
203
+ <p class="control">
204
+ <b-button
205
+ type="is-primary"
206
+ icon-left="save"
207
+ :loading="saving"
208
+ :disabled="!isDirty"
209
+ @click="save"
210
+ >
211
+ Save
212
+ </b-button>
213
+ </p>
214
+ </b-field>
215
+ </div>
216
+
217
+ <!-- Fullscreen Loading -->
218
+ <b-loading :active="loading" :is-full-page="true"></b-loading>
219
+
220
+ <!-- Editor Area -->
221
+ <div class="editor-area is-flex-grow-1 is-relative">
222
+ <!-- Code Mode -->
223
+ <CodeEditor
224
+ v-if="editorMode === 'code'"
225
+ :key="'code-' + resourceId"
226
+ ref="codeEditor"
227
+ v-model="model.content"
228
+ lang="twig"
229
+ height="100%"
230
+ />
231
+
232
+ <!-- Preview Mode -->
233
+ <iframe
234
+ v-if="editorMode === 'preview'"
235
+ ref="previewIframe"
236
+ :srcdoc="previewHtml"
237
+ class="preview-iframe"
238
+ frameborder="0"
239
+ ></iframe>
240
+
241
+ <!-- Split Mode -->
242
+ <div v-if="editorMode === 'split'" class="split-mode is-flex">
243
+ <div class="split-left" style="flex: 1; border-right: 1px solid #dbdbdb;">
244
+ <CodeEditor
245
+ :key="'split-' + resourceId"
246
+ ref="splitCodeEditor"
247
+ v-model="model.content"
248
+ lang="twig"
249
+ height="100%"
250
+ />
251
+ </div>
252
+ <div class="split-right" style="flex: 1;">
253
+ <iframe
254
+ ref="splitPreviewIframe"
255
+ :srcdoc="previewHtml"
256
+ class="preview-iframe"
257
+ frameborder="0"
258
+ ></iframe>
259
+ </div>
260
+ </div>
261
+ </div>
262
+
263
+
264
+ <!-- Settings Drawer -->
265
+ <transition name="slide-right">
266
+ <div v-if="editSettingsModalActive" class="settings-drawer">
267
+ <div class="drawer-overlay" @click="updateQueryParam('settings', false)"></div>
268
+ <div class="drawer-content" :style="{ width: settingsDrawerWidth }">
269
+ <div class="drawer-header px-4 py-3 has-background-light" style="border-bottom: 1px solid #dbdbdb;">
270
+ <div class="is-flex is-align-items-center" style="gap: 0.5rem;">
271
+ <h2 class="title is-5 mb-0 is-flex-grow-1">{{ settingsLabel || (displayName + ' Settings') }}</h2>
272
+ <b-button
273
+ type="is-primary"
274
+ icon-left="save"
275
+ size="is-small"
276
+ :loading="saving"
277
+ :disabled="!isDirty"
278
+ @click="save"
279
+ >
280
+ Save
281
+ </b-button>
282
+ <b-button icon-left="times" size="is-small" @click="updateQueryParam('settings', false)">Close</b-button>
283
+ </div>
284
+ </div>
285
+
286
+ <div class="drawer-body is-relative">
287
+ <b-loading :active="loading" :is-full-page="false"></b-loading>
288
+ <div v-if="!loading" class="px-4 py-4">
289
+ <!-- SLOT: Resource-specific settings fields (e.g., slug for pages, key for partials) -->
290
+ <slot
291
+ name="settings-drawer-fields"
292
+ :model="model"
293
+ :is-dirty="isDirty"
294
+ :server-model="serverModel"
295
+ ></slot>
296
+ </div>
297
+ </div>
298
+ </div>
299
+ </div>
300
+ </transition>
301
+
302
+ <!-- Media Insert Modal -->
303
+ <MediaInsertModal
304
+ :active="isMediaModalActive"
305
+ :multiselect-allowed="false"
306
+ @close="isMediaModalActive = false"
307
+ @select="onMediaInserted"
308
+ />
309
+
310
+ <!-- Versions Drawer -->
311
+ <VersionsDrawer
312
+ v-if="hasVersions"
313
+ ref="versionsDrawer"
314
+ :active="isVersionDrawerActive"
315
+ :resource-id="resourceId"
316
+ :current-version-id="model ? model.id : null"
317
+ :resource-api="resourceApi"
318
+ :published-stage="publishedStage"
319
+ :has-version-actions="hasVersionActions"
320
+ :has-publish="hasPublish"
321
+ @update:active="updateQueryParam('versions', $event)"
322
+ @navigate="navigateToVersion"
323
+ @publish="onVersionPublish"
324
+ @make-head="onMakeHeadVersion"
325
+ >
326
+ <!-- Pass through the version-dropdown-actions slot from parent -->
327
+ <template #version-dropdown-actions="slotProps">
328
+ <slot name="version-dropdown-actions" v-bind="slotProps"></slot>
329
+ </template>
330
+ </VersionsDrawer>
331
+ </div>
332
+ </template>
333
+
334
+ <script>
335
+ import CodeEditor from './CodeEditor.vue';
336
+ import MediaInsertModal from './media/MediaInsertModal.vue';
337
+ import VersionsDrawer from './VersionsDrawer.vue';
338
+ import { morphToNotification, getApiHost } from '../api.js';
339
+ import { checkForUnsavedChanges } from '../unsavedChanges.js';
340
+
341
+ export default {
342
+ name: "ContentResourceEdit",
343
+ components: { CodeEditor, MediaInsertModal, VersionsDrawer },
344
+ beforeRouteLeave(to, from, next) {
345
+ checkForUnsavedChanges(this.model, this.serverModel, this.$buefy, next);
346
+ },
347
+ props: {
348
+ // API instance
349
+ resourceApi: {
350
+ type: Object,
351
+ required: true,
352
+ validator: (api) => {
353
+ // Ensure it has the CrudApi interface
354
+ return typeof api.get === 'function' &&
355
+ typeof api.update === 'function' &&
356
+ typeof api.listVersions === 'function';
357
+ }
358
+ },
359
+
360
+ // Display/routing configuration (flat props like ResourceList.vue)
361
+ displayName: {
362
+ type: String,
363
+ required: true // e.g., 'Page', 'Partial'
364
+ },
365
+ settingsLabel: {
366
+ type: String,
367
+ required: false, // e.g., 'TryBooking Settings' - defaults to '{displayName} Settings'
368
+ default: null
369
+ },
370
+ routePrefix: {
371
+ type: String,
372
+ required: true // e.g., 'pages', 'partials'
373
+ },
374
+ listRouteName: {
375
+ type: String,
376
+ required: true // e.g., 'pages.list'
377
+ },
378
+ trashRouteName: {
379
+ type: String,
380
+ required: true // e.g., 'pages.trash'
381
+ },
382
+
383
+ // Stage configuration
384
+ publishedStage: {
385
+ type: Number,
386
+ required: false, // The stage value that means "published" (optional if no publish)
387
+ default: null
388
+ },
389
+
390
+ // Feature flags (only for structural features that affect template)
391
+ hasSlug: {
392
+ type: Boolean,
393
+ default: false
394
+ },
395
+ hasHierarchy: {
396
+ type: Boolean,
397
+ default: false
398
+ },
399
+ hasVersionActions: {
400
+ type: Boolean,
401
+ default: false // Whether versions have resource-specific actions (e.g., "View on Site")
402
+ },
403
+ hasPublish: {
404
+ type: Boolean,
405
+ default: true // Whether this resource type supports publish/stage functionality
406
+ },
407
+ hasVersions: {
408
+ type: Boolean,
409
+ default: true // Whether this resource type supports versioning
410
+ },
411
+ hasFullPagePreview: {
412
+ type: Boolean,
413
+ default: false // Whether "Preview full page" toggle makes sense (Pages, Emails)
414
+ },
415
+ settingsDrawerWidth: {
416
+ type: String,
417
+ default: '500px' // Width of settings drawer (e.g., '1000px' for wider panels)
418
+ }
419
+ },
420
+ data() {
421
+ return {
422
+ loading: true,
423
+ saving: false,
424
+ model: {
425
+ content: '',
426
+ title: '',
427
+ slug: '',
428
+ slugPart: '',
429
+ description: '',
430
+ tags: [],
431
+ meta: '',
432
+ options: '{}',
433
+ stage: 0,
434
+ headVersion: null,
435
+ deletedAt: null
436
+ },
437
+ serverModel: null,
438
+ isMediaModalActive: false,
439
+ versions: [],
440
+ versionsLoading: false,
441
+ versionStrategy: 'guess',
442
+ editingNonHead: false,
443
+ editOverrideConfirmed: false,
444
+ previewHtml: '',
445
+ previewDebounceTimer: null,
446
+ editingTitle: false,
447
+ editingTitleValue: ''
448
+ }
449
+ },
450
+ computed: {
451
+ resourceId() {
452
+ return parseInt(this.$route.params.id);
453
+ },
454
+ isHeadVersion() {
455
+ // headVersion is null for head versions, or an object/ID for historical versions
456
+ if (!this.model) return false;
457
+ const hv = this.model.headVersion;
458
+ return hv === null || hv === undefined;
459
+ },
460
+ isPublished() {
461
+ if (!this.hasPublish || this.publishedStage === null) {
462
+ return false; // No publish functionality
463
+ }
464
+ return this.model && this.model.stage === this.publishedStage;
465
+ },
466
+ isTrashed() {
467
+ return this.model && this.model.deletedAt !== null && this.model.deletedAt !== undefined;
468
+ },
469
+ isDirty() {
470
+ // Delegate to the API class which knows which fields to check
471
+ return this.resourceApi.constructor.isDirty(this.model, this.serverModel);
472
+ },
473
+ needsEditConfirmation() {
474
+ return !this.isHeadVersion && !this.editOverrideConfirmed;
475
+ },
476
+ versionStrategyLabel() {
477
+ switch(this.versionStrategy) {
478
+ case 'new': return 'Save as New Version';
479
+ case 'overwrite': return 'Overwrite Current Version';
480
+ default: return 'Create New Version if Needed';
481
+ }
482
+ },
483
+ versionStrategyIcon() {
484
+ switch(this.versionStrategy) {
485
+ case 'new': return 'plus';
486
+ case 'overwrite': return 'save';
487
+ default: return 'caret-up';
488
+ }
489
+ },
490
+ parentSlug() {
491
+ if (!this.model) return '';
492
+ // If parent exists, return its slug, otherwise return empty string (root level)
493
+ return this.model.parent ? this.model.parent.slug : '';
494
+ },
495
+ defaultEditorMode() {
496
+ // Get from user preferences, default to 'split'
497
+ const preferredMode = this.$store.getters.userPreferences.get('editor.defaultMode');
498
+ if (preferredMode) {
499
+ return preferredMode;
500
+ }
501
+ return 'split';
502
+ },
503
+ isInViewMode() {
504
+ // View mode is: preview + fullPage + fullscreen
505
+ return this.editorMode === 'preview' && this.renderLayout === true && this.fullscreen;
506
+ },
507
+ // Query-string-based computed properties (single source of truth)
508
+ editorMode() {
509
+ const mode = this.$route.query.mode;
510
+ const validModes = ['code', 'split', 'preview'];
511
+ if (validModes.includes(mode)) {
512
+ return mode;
513
+ }
514
+ return this.defaultEditorMode;
515
+ },
516
+ renderLayout() {
517
+ return this.$route.query.fullPage === 'true';
518
+ },
519
+ isFullscreen() {
520
+ return this.$route.query.fullscreen === 'true';
521
+ },
522
+ isVersionDrawerActive() {
523
+ return this.$route.query.versions === 'true';
524
+ },
525
+ editSettingsModalActive() {
526
+ return this.$route.query.settings === 'true';
527
+ },
528
+ keymap() {
529
+ return {
530
+ 'ctrl+s': this.saveHotkey,
531
+ };
532
+ }
533
+ },
534
+ watch: {
535
+ 'model.content': {
536
+ handler(newVal) {
537
+ console.log('model.content changed, length:', newVal ? newVal.length : 0, 'mode:', this.editorMode);
538
+ if (this.editorMode === 'split' || this.editorMode === 'preview') {
539
+ this.debouncedRefreshPreview();
540
+ }
541
+ }
542
+ },
543
+ '$route.params.id'() {
544
+ // Reload when route changes (switching between versions)
545
+ this.editOverrideConfirmed = false; // Reset edit confirmation when switching
546
+ this.fetchData();
547
+ },
548
+ '$route.query': {
549
+ handler(newQuery, oldQuery) {
550
+ // Refresh preview when mode or fullPage changes (e.g., via browser back/forward)
551
+ const modeChanged = newQuery.mode !== oldQuery.mode;
552
+ const fullPageChanged = newQuery.fullPage !== oldQuery.fullPage;
553
+
554
+ if (modeChanged || fullPageChanged) {
555
+ if (this.editorMode === 'split' || this.editorMode === 'preview') {
556
+ this.refreshPreview();
557
+ }
558
+ }
559
+ },
560
+ deep: true
561
+ }
562
+ },
563
+ mounted() {
564
+ this.fetchData();
565
+ },
566
+ beforeDestroy() {
567
+ if (this.previewDebounceTimer) {
568
+ clearTimeout(this.previewDebounceTimer);
569
+ }
570
+ },
571
+ methods: {
572
+ async saveHotkey(event) {
573
+ event.preventDefault();
574
+ if (this.isDirty) {
575
+ await this.save();
576
+ }
577
+ },
578
+ async fetchData() {
579
+ this.loading = true;
580
+ try {
581
+ const response = await this.resourceApi.get(this.resourceId);
582
+ this.setModel(response.item);
583
+ this.editingNonHead = !this.isHeadVersion;
584
+ } catch (error) {
585
+ console.error('Failed to fetch resource:', error);
586
+ }
587
+ },
588
+ setModel(model) {
589
+ this.loading = false;
590
+ this.model = { ...model };
591
+ this.serverModel = { ...model };
592
+ },
593
+ async save() {
594
+ this.saving = true;
595
+ try {
596
+ // Strip parent field for hierarchical resources - it should only be updated via move operation
597
+ const saveData = { ...this.model };
598
+ if (this.hasHierarchy && saveData.parent !== undefined) {
599
+ delete saveData.parent;
600
+ }
601
+
602
+ const response = await this.resourceApi.update(saveData);
603
+ this.setModel(response.item);
604
+ this.$buefy.toast.open(morphToNotification(response));
605
+ } catch (error) {
606
+ console.error('Save failed:', error);
607
+ } finally {
608
+ this.saving = false;
609
+ }
610
+ },
611
+ async publish() {
612
+ if (!this.hasPublish) {
613
+ console.warn('Publish called but hasPublish is false');
614
+ return;
615
+ }
616
+ try {
617
+ const updatedModel = await this.resourceApi.publish(this.model.id);
618
+ this.setModel(updatedModel);
619
+ // Refresh versions list if drawer is open
620
+ if (this.$refs.versionsDrawer && this.isVersionDrawerActive) {
621
+ await this.$refs.versionsDrawer.loadVersions();
622
+ }
623
+ } catch (error) {
624
+ console.error('Publish failed:', error);
625
+ }
626
+ },
627
+ async refreshPreview() {
628
+ if (!this.model) return;
629
+
630
+ try {
631
+ // Use the API endpoint for content preview
632
+ const url = getApiHost() + 'oxygen/api/' + this.routePrefix + '/content/' + this.model.id;
633
+ const response = await this.resourceApi.request('post')
634
+ .withJson({
635
+ content: this.model.content,
636
+ renderLayout: this.renderLayout
637
+ })
638
+ .fetchRaw(url);
639
+
640
+ this.previewHtml = await response.text();
641
+ } catch (error) {
642
+ console.error('Preview refresh failed:', error);
643
+ this.previewHtml = '<div style="padding: 2rem; color: red;">Preview failed to load</div>';
644
+ }
645
+ },
646
+ debouncedRefreshPreview() {
647
+ if (this.previewDebounceTimer) {
648
+ clearTimeout(this.previewDebounceTimer);
649
+ }
650
+ this.previewDebounceTimer = setTimeout(() => {
651
+ console.log('Debounced preview refresh triggered');
652
+ this.refreshPreview();
653
+ }, 1000);
654
+ },
655
+ onMediaInserted(files) {
656
+ if (files.length === 0) return;
657
+
658
+ const file = files[0];
659
+ const snippet = `{{ media('${file.slug}') }}`;
660
+
661
+ // Get the appropriate editor ref based on mode
662
+ const editorRef = this.editorMode === 'split' ? this.$refs.splitCodeEditor : this.$refs.codeEditor;
663
+
664
+ if (editorRef && editorRef.$refs.ace) {
665
+ const editor = editorRef.$refs.ace.editor;
666
+ editor.insert(snippet);
667
+ editor.focus();
668
+ }
669
+
670
+ this.isMediaModalActive = false;
671
+ },
672
+ toggleFullscreen() {
673
+ this.updateQueryParam('fullscreen', !this.isFullscreen);
674
+ },
675
+ openVersionDrawer() {
676
+ this.updateQueryParam('versions', true);
677
+ },
678
+ viewFullscreen() {
679
+ // Update query params to enter fullscreen preview mode with full page layout
680
+ const query = {
681
+ ...this.$route.query,
682
+ fullscreen: 'true',
683
+ fullPage: 'true',
684
+ mode: 'preview',
685
+ versions: 'false'
686
+ };
687
+ this.$router.replace({ query }).catch(() => {});
688
+ },
689
+ saveAsNewVersion() {
690
+ this.versionStrategy = 'new';
691
+ this.save();
692
+ },
693
+ async deleteResource() {
694
+ // Soft delete - no confirmation needed
695
+ await this.resourceApi.deleteAndNotify(this.model.id);
696
+ await this.fetchData();
697
+ },
698
+ async forceDeleteResource() {
699
+ // Permanent delete - uses confirmForceDelete which has built-in confirmation
700
+ await this.resourceApi.confirmForceDelete(this.model.id);
701
+ this.$router.push({ name: this.listRouteName });
702
+ },
703
+ navigateToVersion(versionId, options = {}) {
704
+ const query = {};
705
+
706
+ // Handle versions drawer state
707
+ if (options.versions !== undefined) {
708
+ // Explicitly set versions state if provided
709
+ query.versions = options.versions.toString();
710
+ } else if (this.isVersionDrawerActive && !options.fullscreen) {
711
+ // Otherwise, preserve versions=true unless it's a fullscreen view action
712
+ query.versions = 'true';
713
+ }
714
+
715
+ // Add any additional query params from options
716
+ if (options.fullscreen !== undefined) {
717
+ query.fullscreen = options.fullscreen.toString();
718
+ }
719
+ if (options.fullPage !== undefined) {
720
+ query.fullPage = options.fullPage.toString();
721
+ }
722
+ if (options.mode) {
723
+ query.mode = options.mode;
724
+ }
725
+
726
+ this.$router.push({
727
+ name: this.routePrefix + '.edit',
728
+ params: { id: versionId },
729
+ query
730
+ });
731
+ },
732
+ navigateToHeadVersion() {
733
+ if (this.model.headVersion) {
734
+ this.$router.push({ name: this.routePrefix + '.edit', params: { id: this.model.headVersion } });
735
+ }
736
+ },
737
+ async onVersionPublish(versionId) {
738
+ if (!this.hasPublish) {
739
+ console.warn('onVersionPublish called but hasPublish is false');
740
+ return;
741
+ }
742
+ await this.resourceApi.publish(versionId);
743
+ // Always refresh the current page since publishing one version might unpublish another
744
+ await this.fetchData();
745
+ },
746
+ async onMakeHeadVersion(versionId) {
747
+ await this.resourceApi.makeHeadVersion(versionId);
748
+ await this.fetchData();
749
+ },
750
+ formatDate(dateString) {
751
+ return new Date(dateString).toLocaleDateString();
752
+ },
753
+ updateQueryParam(key, value) {
754
+ const query = { ...this.$route.query };
755
+ // Handle different value types
756
+ if (typeof value === 'boolean') {
757
+ query[key] = value.toString();
758
+ } else if (typeof value === 'string') {
759
+ query[key] = value;
760
+ } else if (value === null || value === undefined) {
761
+ delete query[key];
762
+ } else {
763
+ query[key] = String(value);
764
+ }
765
+ // Only update if query actually changed
766
+ if (JSON.stringify(query) !== JSON.stringify(this.$route.query)) {
767
+ this.$router.replace({ query }).catch(() => {});
768
+ }
769
+ },
770
+ switchEditorMode(newMode) {
771
+ // Check if we need confirmation before switching to an editable mode
772
+ if (this.needsEditConfirmation && (newMode === 'code' || newMode === 'split')) {
773
+ this.$buefy.dialog.confirm({
774
+ title: 'Edit Historical Version',
775
+ message: `You are about to edit a historical version of this ${this.displayName.toLowerCase()}. This is not the current version. Are you sure you want to continue?`,
776
+ confirmText: 'Edit Anyway',
777
+ type: 'is-warning',
778
+ onConfirm: () => {
779
+ this.editOverrideConfirmed = true;
780
+ this.updateQueryParam('mode', newMode);
781
+ }
782
+ });
783
+ } else {
784
+ this.updateQueryParam('mode', newMode);
785
+ }
786
+ },
787
+ openSettings() {
788
+ // Check if we need confirmation before opening settings
789
+ if (this.needsEditConfirmation) {
790
+ this.$buefy.dialog.confirm({
791
+ title: 'Edit Historical Version',
792
+ message: `You are about to edit a historical version of this ${this.displayName.toLowerCase()}. This is not the current version. Are you sure you want to continue?`,
793
+ confirmText: 'Edit Anyway',
794
+ type: 'is-warning',
795
+ onConfirm: () => {
796
+ this.editOverrideConfirmed = true;
797
+ this.updateQueryParam('settings', true);
798
+ }
799
+ });
800
+ } else {
801
+ this.updateQueryParam('settings', true);
802
+ }
803
+ },
804
+ goBack() {
805
+ if (this.isTrashed) {
806
+ this.$router.push({ name: this.trashRouteName });
807
+ } else {
808
+ this.$router.push({ name: this.listRouteName });
809
+ }
810
+ },
811
+ async restoreResource() {
812
+ try {
813
+ await this.resourceApi.restoreAndNotify(this.model.id);
814
+ await this.fetchData();
815
+ } catch (error) {
816
+ console.error('Restore failed:', error);
817
+ }
818
+ },
819
+ startEditingTitle() {
820
+ this.editingTitleValue = this.model.title;
821
+ this.editingTitle = true;
822
+ this.$nextTick(() => {
823
+ if (this.$refs.titleInput) {
824
+ this.$refs.titleInput.focus();
825
+ }
826
+ });
827
+ },
828
+ finishEditingTitle() {
829
+ this.model.title = this.editingTitleValue;
830
+ this.editingTitle = false;
831
+ },
832
+ cancelEditingTitle() {
833
+ this.editingTitle = false;
834
+ this.editingTitleValue = '';
835
+ }
836
+ }
837
+ }
838
+ </script>
839
+
840
+ <style scoped>
841
+ .edit-container {
842
+ background: white;
843
+ height: 100%;
844
+ overflow-y: scroll;
845
+ }
846
+
847
+ .edit-container.is-fullscreen {
848
+ position: fixed;
849
+ top: 0;
850
+ left: 0;
851
+ right: 0;
852
+ bottom: 0;
853
+ z-index: 9999;
854
+ background: white;
855
+ }
856
+
857
+ .title-display {
858
+ display: flex;
859
+ align-items: center;
860
+ gap: 0.5rem;
861
+ }
862
+
863
+ .title-display .title {
864
+ margin: 0;
865
+ }
866
+
867
+ .title-editing {
868
+ display: flex;
869
+ gap: 0.5rem;
870
+ align-items: center;
871
+ max-width: 40rem;
872
+ }
873
+
874
+ .editor-area {
875
+ min-height: 40rem;
876
+ overflow: hidden;
877
+ }
878
+
879
+ .preview-iframe {
880
+ width: 100%;
881
+ height: 100%;
882
+ border: none;
883
+ background: white;
884
+ }
885
+
886
+ .split-mode {
887
+ gap: 0;
888
+ height: 100%;
889
+ }
890
+
891
+ /* Fade transition for mode switching */
892
+ .fade-enter-active, .fade-leave-active {
893
+ transition: opacity 0.2s ease;
894
+ }
895
+
896
+ .fade-enter, .fade-leave-to {
897
+ opacity: 0;
898
+ }
899
+
900
+ /* Settings drawer styles */
901
+ .settings-drawer {
902
+ position: fixed;
903
+ top: 0;
904
+ left: 0;
905
+ right: 0;
906
+ bottom: 0;
907
+ z-index: 40;
908
+ pointer-events: none;
909
+ }
910
+
911
+ .settings-drawer .drawer-overlay {
912
+ position: absolute;
913
+ top: 0;
914
+ left: 0;
915
+ right: 0;
916
+ bottom: 0;
917
+ background: rgba(0, 0, 0, 0.5);
918
+ pointer-events: all;
919
+ }
920
+
921
+ .settings-drawer .drawer-content {
922
+ position: absolute;
923
+ top: 0;
924
+ right: 0;
925
+ bottom: 0;
926
+ width: 500px;
927
+ max-width: 90vw;
928
+ background: white;
929
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
930
+ display: flex;
931
+ flex-direction: column;
932
+ pointer-events: all;
933
+ }
934
+
935
+ .settings-drawer .drawer-header {
936
+ flex-shrink: 0;
937
+ }
938
+
939
+ .settings-drawer .drawer-body {
940
+ flex: 1;
941
+ overflow-y: auto;
942
+ }
943
+
944
+ /* Slide transition from right */
945
+ .slide-right-enter-active,
946
+ .slide-right-leave-active {
947
+ transition: transform 0.3s ease;
948
+ }
949
+
950
+ .slide-right-enter-active .drawer-overlay,
951
+ .slide-right-leave-active .drawer-overlay {
952
+ transition: opacity 0.3s ease;
953
+ }
954
+
955
+ .slide-right-enter .drawer-content,
956
+ .slide-right-leave-to .drawer-content {
957
+ transform: translateX(100%);
958
+ }
959
+
960
+ .slide-right-enter .drawer-overlay,
961
+ .slide-right-leave-to .drawer-overlay {
962
+ opacity: 0;
963
+ }
964
+ </style>