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