@oxygen-cms/ui 1.8.2 → 1.9.1

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.
@@ -16,12 +16,12 @@ jobs:
16
16
 
17
17
  strategy:
18
18
  matrix:
19
- node-version: [16.x]
19
+ node-version: [20.x]
20
20
 
21
21
  steps:
22
- - uses: actions/checkout@v2
22
+ - uses: actions/checkout@v4
23
23
  - name: Use Node.js ${{ matrix.node-version }}
24
- uses: actions/setup-node@v1
24
+ uses: actions/setup-node@v4
25
25
  with:
26
26
  node-version: ${{ matrix.node-version }}
27
27
  - run: npm ci
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-cms/ui",
3
- "version": "1.8.2",
3
+ "version": "1.9.1",
4
4
  "description": "Various utilities for UI-building in Vue.js",
5
5
  "main": "none",
6
6
  "repository": {
@@ -15,12 +15,18 @@
15
15
  :to="group.listAction">
16
16
  <template #label>
17
17
  {{ groupLabel }}
18
- <b-button v-if="userPermissions.has(group.addPermission)"
19
- tag="router-link"
20
- type="is-text"
21
- class="is-pulled-right show-if-active"
22
- :icon-right="group.addIcon"
23
- :to="group.addAction"></b-button>
18
+ <span v-if="userPermissions.has(group.addPermission) && group.addDropdownComponent"
19
+ class="is-pulled-right show-if-active add-dropdown-trigger"
20
+ @click.stop.prevent
21
+ @mousedown.stop.prevent>
22
+ <component :is="group.addDropdownComponent"
23
+ :minimal="true" />
24
+ </span>
25
+ <router-link v-else-if="userPermissions.has(group.addPermission)"
26
+ class="is-pulled-right show-if-active"
27
+ :to="group.addAction">
28
+ <MinimalDropdownButton :icon="group.addIcon" />
29
+ </router-link>
24
30
  </template>
25
31
  <b-menu-item v-for="(item, itemLabel) in itemsWithPermission(group.items)" :key="itemLabel" :label="itemLabel" tag="router-link" :to="item.to"></b-menu-item>
26
32
  </b-menu-item>
@@ -35,8 +41,11 @@
35
41
  </template>
36
42
 
37
43
  <script>
44
+ import MinimalDropdownButton from "./MinimalDropdownButton.vue";
45
+
38
46
  export default {
39
47
  name: "MainMenu",
48
+ components: { MinimalDropdownButton },
40
49
  props: {
41
50
  items: {
42
51
  type: Object,
@@ -0,0 +1,36 @@
1
+ <template>
2
+ <b-button
3
+ type="is-text"
4
+ :icon-right="icon"
5
+ class="minimal-button">
6
+ </b-button>
7
+ </template>
8
+
9
+ <script>
10
+ export default {
11
+ name: "MinimalDropdownButton",
12
+ props: {
13
+ icon: {
14
+ type: String,
15
+ required: true
16
+ }
17
+ }
18
+ }
19
+ </script>
20
+
21
+ <style scoped lang="scss">
22
+ @import '../styles/variables.scss';
23
+
24
+ .minimal-button {
25
+ padding: 0 0.5em;
26
+ height: auto;
27
+ }
28
+
29
+ .button.is-text.minimal-button {
30
+ background-color: $grey-lighter;
31
+ }
32
+
33
+ .button.is-text.minimal-button:hover {
34
+ background-color: darken($grey-lighter, 5%);
35
+ }
36
+ </style>
@@ -20,7 +20,7 @@
20
20
  paddingless>
21
21
  <div class="modal-card" style="width: auto; overflow: visible">
22
22
  <header class="modal-card-head">
23
- <p class="modal-card-title">Parent page for "{{ item.title }}"</p>
23
+ <p class="modal-card-title">Update the parent for "{{ item.title }}"</p>
24
24
  </header>
25
25
  <section class="modal-card-body" style="overflow: visible;">
26
26
  <b-field>
@@ -28,11 +28,16 @@
28
28
  v-model.lazy="movePageSearchQuery"
29
29
  :disabled="isLoading"
30
30
  open-on-focus
31
- :data="pagesList"
32
- :custom-formatter="data => data.title + ' - ' + data.slug"
31
+ :data="sortedPagesList"
33
32
  placeholder="Search for pages..."
34
33
  clearable
35
34
  @select="setParentPage">
35
+ <template #default="props">
36
+ <span :style="getOptionStyle(props.option)">
37
+ {{ props.option.title }} - {{ props.option.slug }}
38
+ <span v-if="isCurrentParent(props.option)" style="opacity: 0.6;"> (current parent)</span>
39
+ </span>
40
+ </template>
36
41
  <template #empty>No results found</template>
37
42
  </b-autocomplete>
38
43
  </b-field>
@@ -71,6 +76,16 @@ export default {
71
76
  pagesList: []
72
77
  }
73
78
  },
79
+ computed: {
80
+ sortedPagesList() {
81
+ // Sort with Home page (slug '/') at the top, then alphabetically by title
82
+ return [...this.pagesList].sort((a, b) => {
83
+ if (a.slug === '/') return -1;
84
+ if (b.slug === '/') return 1;
85
+ return a.title.localeCompare(b.title);
86
+ });
87
+ }
88
+ },
74
89
  watch: {
75
90
  'movePageSearchQuery': 'fetchData'
76
91
  },
@@ -78,9 +93,6 @@ export default {
78
93
  async fetchData() {
79
94
  let data = await this.pagesApi.list({ inTrash: false, page: 1, q: this.movePageSearchQuery });
80
95
  this.pagesList = data.items;
81
- this.pagesList.sort((a, b) => {
82
- return a.title.localeCompare(b.title);
83
- });
84
96
  this.isLoading = false;
85
97
  },
86
98
  async publish() {
@@ -88,12 +100,32 @@ export default {
88
100
  this.$emit('update', item);
89
101
  },
90
102
  async setParentPage(parentPage) {
103
+ // Don't allow moving to current parent
104
+ if (this.isCurrentParent(parentPage)) {
105
+ return;
106
+ }
91
107
  let data = await this.pagesApi.update({id: this.item.id, parent: parentPage.id, autoConvertToDraft: 'no', version: false});
92
108
  this.$buefy.toast.open(morphToNotification(data));
93
109
  this.$emit('reload');
94
110
  },
95
111
  close() {
96
112
  this.$refs.moveDropdown.toggle();
113
+ },
114
+ isCurrentParent(page) {
115
+ if (!this.item.parent) return false;
116
+ // Handle both cases: parent as object or parent as ID
117
+ const parentId = typeof this.item.parent === 'object' ? this.item.parent.id : this.item.parent;
118
+ return page.id === parentId;
119
+ },
120
+ getOptionStyle(option) {
121
+ let style = '';
122
+ if (option.slug === '/') {
123
+ style += 'font-weight: bold;';
124
+ }
125
+ if (this.isCurrentParent(option)) {
126
+ style += ' opacity: 0.5; cursor: not-allowed;';
127
+ }
128
+ return style;
97
129
  }
98
130
  }
99
131
  }
@@ -27,7 +27,7 @@
27
27
  @details-close="item => setExpanded(item, false)"
28
28
  @sort="onSort">
29
29
  <b-table-column v-slot="props" label="Title" :sortable="!!onSort" field="title">{{ props.row.title }} <PageStatusIcon :item="props.row"></PageStatusIcon></b-table-column>
30
- <b-table-column v-slot="props" label="URL" :sortable="!!onSort" field="slugPart"><a :href="PagesApi.slugToUrl(props.row.slug)" class="is-size-7" target="_blank" v-if="props.row.stage === PagesApi.STAGE_PUBLISHED">{{ PagesApi.slugToUrl(props.row.slug) }} <b-icon icon="external-link-alt"></b-icon></a><em v-else class="is-size-7">(unpublished)</em></b-table-column>
30
+ <b-table-column v-slot="props" label="URL" :sortable="!!onSort" field="slugPart"><a :href="PagesApi.slugToUrl(props.row.slug)" class="is-size-7" target="_blank" v-if="props.row.stage === PagesApi.STAGE_PUBLISHED">{{ PagesApi.slugToUrl(props.row.slug) }} <b-icon icon="external-link-alt"></b-icon></a><em v-else class="is-size-7">(unpublished)</em></b-table-column>
31
31
  <b-table-column v-slot="props" label="Description" width="30%" :sortable="!!onSort" field="description"><div class="is-size-7">{{ props.row.description }}</div></b-table-column>
32
32
  <b-table-column v-slot="props" label="Last Updated" field="updatedAt" :sortable="!!onSort">
33
33
  <div v-if="props.row.updatedAt" class="is-size-7"><Updated :model="props.row"></Updated></div>
@@ -9,12 +9,18 @@
9
9
  <div class="is-flex-grow-1">
10
10
  </div>
11
11
  <b-input v-model.lazy="searchQuery" rounded :placeholder="'Search ' + displayName" icon="search" icon-pack="fas" class="mr-3"></b-input>
12
- <b-button v-if="!inTrash" tag="router-link" :to="'/' + routePrefix + '/create'" type="is-success" icon-left="pencil-alt" class="mr-3">Create
13
- {{ singularDisplayName }}</b-button>
12
+
13
+ <component v-if="!inTrash"
14
+ :is="createDropdownComponent"
15
+ class="mr-3"
16
+ @created="fetchData" />
17
+
14
18
  <b-button v-if="!inTrash" tag="router-link" :to="'/' + routePrefix + '/trash'" type="is-danger" outlined icon-left="trash">Deleted
15
19
  {{ displayName }}</b-button>
16
20
  </div>
17
21
 
22
+ <slot name="before-table" :search-query="searchQuery"></slot>
23
+
18
24
  <div class="full-height full-height-container box">
19
25
  <component :is="tableComponent" :paginated-items="paginatedItems" :on-page-change="page => paginatedItems.currentPage = page" :detailed="!searchQuery" :on-sort="onSort">
20
26
  <template #actions="slotProps">
@@ -58,7 +64,11 @@ export default {
58
64
  defaultSortOrder: String,
59
65
  tableComponent: Object,
60
66
  actionsComponent: Object,
61
- resourceApi: Object
67
+ resourceApi: Object,
68
+ createDropdownComponent: {
69
+ type: Object,
70
+ required: true
71
+ }
62
72
  },
63
73
  data() {
64
74
  return {
@@ -4,15 +4,23 @@
4
4
  <p class="subtitle">Photos, videos, audio, PDFs.</p>
5
5
  <div class="buttons">
6
6
  <b-button tag="router-link" to="/media/list" icon-left="list">Manage Photos & Files</b-button>
7
- <b-button tag="router-link" type="is-success" to="/media/list?upload=true" icon-left="upload">Upload</b-button>
7
+ <MediaUploadDropdown :minimal="false" :current-directory="null" @uploaded="handleUploaded" />
8
8
  <b-button tag="router-link" to="/media/responsive-images" type="is-light" icon-left="mail-bulk">Generate Responsive Images</b-button>
9
9
  </div>
10
10
  </article>
11
11
  </template>
12
12
 
13
13
  <script>
14
+ import MediaUploadDropdown from "../media/MediaUploadDropdown.vue";
15
+
14
16
  export default {
15
- name: "MediaPanel"
17
+ name: "MediaPanel",
18
+ components: { MediaUploadDropdown },
19
+ methods: {
20
+ handleUploaded() {
21
+ this.$router.push('/media/list');
22
+ }
23
+ }
16
24
  }
17
25
  </script>
18
26
 
@@ -35,7 +35,7 @@
35
35
  </b-field>
36
36
 
37
37
  <b-button v-if="!inTrash && !searchQuery" icon-left="folder-plus" class="action-bar-pad" @click="isCreateDirectoryModalActive = true">New Directory</b-button>
38
- <b-button v-if="!inTrash && !searchQuery" icon-left="file-upload" type="is-success" class="action-bar-pad" @click="$router.push({ query: { upload: true }})">Upload Files</b-button>
38
+ <MediaUploadDropdown v-if="!inTrash && !searchQuery" class="action-bar-pad" :current-directory="paginatedItems.currentDirectory" @uploaded="fetchData" />
39
39
  <b-input v-if="!inTrash" rounded placeholder="Search photos and files..." icon="search"
40
40
  icon-pack="fas" :value="searchQuery" class="action-bar-pad" @input="value => navigateTo({searchQuery: value})"></b-input>
41
41
  <b-button v-if="!inTrash" icon-left="trash" type="is-danger" outlined class="action-bar-pad" @click="navigateTo({inTrash: true})">Deleted Items</b-button>
@@ -101,10 +101,6 @@
101
101
  </div>
102
102
  </b-modal>
103
103
 
104
- <b-modal :active.sync="isUploadModalActive" trap-focus has-modal-card aria-role="dialog" aria-modal auto-focus>
105
- <MediaUpload :current-directory="paginatedItems.currentDirectory" @close="$router.push({ query: { }})" @uploaded="fetchData"></MediaUpload>
106
- </b-modal>
107
-
108
104
  </div>
109
105
 
110
106
  </template>
@@ -115,11 +111,11 @@ import MediaDirectory from "./MediaDirectory.vue";
115
111
  import MediaItem from "./MediaItem.vue";
116
112
  import MediaDirectoryApi, {getDirectoryBreadcrumbItems, getDirectoryPathString} from "../../MediaDirectoryApi";
117
113
  import {morphToNotification} from "../../api";
118
- import MediaUpload from "./MediaUpload.vue";
114
+ import MediaUploadDropdown from "./MediaUploadDropdown.vue";
119
115
 
120
116
  export default {
121
117
  name: "MediaList",
122
- components: { MediaDirectory, MediaItem, MediaUpload },
118
+ components: { MediaDirectory, MediaItem, MediaUploadDropdown },
123
119
  props: {
124
120
  currentPath: {
125
121
  type: String,
@@ -145,9 +141,6 @@ export default {
145
141
  }
146
142
  },
147
143
  computed: {
148
- isUploadModalActive() {
149
- return this.$route.query.upload === 'true';
150
- },
151
144
  displayPath() {
152
145
  return getDirectoryPathString(this.paginatedItems.currentDirectory);
153
146
  },
@@ -0,0 +1,182 @@
1
+ <template>
2
+ <b-dropdown
3
+ ref="dropdown"
4
+ :position="minimal ? 'is-bottom-right' : 'is-bottom-left'"
5
+ aria-role="menu"
6
+ trap-focus
7
+ append-to-body
8
+ class="media-upload-dropdown">
9
+ <template #trigger="{ active }">
10
+ <MinimalDropdownButton v-if="minimal" icon="upload"/>
11
+ <b-button v-else
12
+ :type="active ? '' : 'is-success'"
13
+ icon-left="file-upload"
14
+ :disabled="active">
15
+ Upload Files
16
+ </b-button>
17
+ </template>
18
+
19
+ <b-dropdown-item aria-role="menu-item" custom paddingless>
20
+ <div class="modal-card" style="width: auto; overflow: visible">
21
+ <header class="modal-card-head has-background-success-light">
22
+ <p v-if="!isUploading" class="modal-card-title">
23
+ <b-icon icon="upload" size="is-normal" class="push-right"></b-icon>
24
+ Upload files to '{{ currentPath }}'
25
+ </p>
26
+ <p v-else class="modal-card-title">
27
+ <b-icon icon="upload" size="is-normal" class="push-right"></b-icon>
28
+ Uploading {{ filesToUpload.length }} item(s)
29
+ </p>
30
+ </header>
31
+ <section v-if="!isUploading" class="modal-card-body" style="overflow: visible;">
32
+ <b-upload v-model="filesToUpload"
33
+ multiple
34
+ drag-drop expanded>
35
+ <section class="section">
36
+ <div class="content has-text-centered">
37
+ <p>
38
+ <b-icon
39
+ icon="upload"
40
+ size="is-large">
41
+ </b-icon>
42
+ </p>
43
+ <p>Drop your files here or click to upload</p>
44
+ </div>
45
+ </section>
46
+ </b-upload>
47
+
48
+ <div class="tags mt-4">
49
+ <span v-for="(file, index) in filesToUpload" :key="index" class="tag is-primary image-preview">
50
+ <img :src="imagePreviews[index]" alt="file preview" />
51
+ <p class="image-preview-label">
52
+ {{ file.name }}
53
+ <button class="delete is-small" type="button" @click="deleteFileToUpload(index)"></button>
54
+ </p>
55
+ </span>
56
+ </div>
57
+ </section>
58
+ <section v-else class="modal-card-body" style="overflow: visible;">
59
+ <b-progress size="is-medium" type="is-info"></b-progress>
60
+ </section>
61
+ <footer class="modal-card-foot is-flex">
62
+ <div class="is-flex-grow-1"></div>
63
+ <b-button @click="close">Close</b-button>
64
+ <b-button type="is-success" :disabled="isUploading" @click="doUpload">Upload</b-button>
65
+ </footer>
66
+ </div>
67
+ </b-dropdown-item>
68
+ </b-dropdown>
69
+ </template>
70
+
71
+ <script>
72
+ import {morphToNotification} from "../../api";
73
+ import MediaApi from "../../MediaApi";
74
+ import {getDirectoryPathString} from "../../MediaDirectoryApi";
75
+ import MinimalDropdownButton from "../MinimalDropdownButton.vue";
76
+
77
+ export default {
78
+ name: "MediaUploadDropdown",
79
+ components: { MinimalDropdownButton },
80
+ props: {
81
+ currentDirectory: { type: Object, default: null },
82
+ minimal: {
83
+ type: Boolean,
84
+ default: false
85
+ }
86
+ },
87
+ data() {
88
+ return {
89
+ filesToUpload: [],
90
+ isUploading: false,
91
+ mediaApi: new MediaApi()
92
+ }
93
+ },
94
+ computed: {
95
+ currentPath() {
96
+ return getDirectoryPathString(this.currentDirectory);
97
+ },
98
+ },
99
+ asyncComputed: {
100
+ async imagePreviews() {
101
+ let promises = this.filesToUpload.map(file => {
102
+ return new Promise((resolve) => {
103
+ let reader = new FileReader();
104
+ reader.onload = function (e) {
105
+ resolve(e.target.result);
106
+ };
107
+ reader.readAsDataURL(file);
108
+ })
109
+ })
110
+ return Promise.all(promises);
111
+ }
112
+ },
113
+ methods: {
114
+ deleteFileToUpload(index) {
115
+ this.filesToUpload.splice(index, 1);
116
+ },
117
+ async doUpload() {
118
+ if(this.filesToUpload.length === 0) {
119
+ return this.$buefy.toast.open({
120
+ message: 'No files selected for upload',
121
+ type: 'is-warning',
122
+ queue: false
123
+ });
124
+ }
125
+ this.isUploading = true;
126
+ try {
127
+ let data = await this.mediaApi.create({
128
+ files: this.filesToUpload,
129
+ currentDirectory: this.currentDirectory
130
+ });
131
+ this.$buefy.toast.open(morphToNotification(data));
132
+ this.$emit('uploaded');
133
+ this.close();
134
+ } catch(e) {
135
+ // upload failed
136
+ console.warn(e);
137
+ this.close();
138
+ }
139
+ },
140
+ close() {
141
+ this.$refs.dropdown.toggle();
142
+ this.isUploading = false;
143
+ this.filesToUpload = [];
144
+ }
145
+ }
146
+ }
147
+ </script>
148
+
149
+ <style>
150
+ .media-upload-dropdown .dropdown-content {
151
+ padding-top: 0;
152
+ padding-bottom: 0;
153
+ }
154
+
155
+ .media-upload-dropdown .dropdown-menu {
156
+ overflow: visible !important;
157
+ min-width: 30rem;
158
+ }
159
+
160
+ .media-upload-dropdown .modal-card-title {
161
+ flex-shrink: unset;
162
+ flex-grow: unset;
163
+ }
164
+ </style>
165
+
166
+ <style scoped>
167
+ .image-preview {
168
+ display: flex;
169
+ flex-direction: column;
170
+ height: auto;
171
+ }
172
+
173
+ .image-preview img {
174
+ margin: 0.5rem;
175
+ }
176
+
177
+ .image-preview-label {
178
+ margin: 0.5rem;
179
+ display: inline-flex;
180
+ align-items: center;
181
+ }
182
+ </style>
@@ -0,0 +1,209 @@
1
+ <template>
2
+ <b-dropdown
3
+ ref="dropdown"
4
+ :position="minimal ? 'is-bottom-right' : 'is-bottom-left'"
5
+ aria-role="menu"
6
+ trap-focus
7
+ append-to-body
8
+ class="create-page-dropdown">
9
+ <template #trigger="{ active }">
10
+ <MinimalDropdownButton v-if="minimal" icon="plus"/>
11
+ <b-button v-else
12
+ :type="active ? '' : 'is-success'"
13
+ icon-left="pencil-alt"
14
+ :disabled="active">
15
+ Create Page
16
+ </b-button>
17
+ </template>
18
+
19
+ <b-dropdown-item aria-role="menu-item" custom paddingless>
20
+ <div class="modal-card" style="width: auto; overflow: visible">
21
+ <header class="modal-card-head has-background-success-light">
22
+ <p class="modal-card-title">
23
+ <b-icon icon="file-alt" size="is-normal" class="push-right"></b-icon>
24
+ Create Page
25
+ </p>
26
+ </header>
27
+ <section class="modal-card-body" style="overflow: visible;">
28
+ <b-field label="Title">
29
+ <b-input
30
+ v-model="title"
31
+ placeholder="e.g.: About Us"
32
+ autofocus
33
+ @keyup.enter.native="submit">
34
+ </b-input>
35
+ </b-field>
36
+
37
+ <b-field label="URL Part" message="Leave blank to auto-generate from title">
38
+ <b-input
39
+ v-model="slugPart"
40
+ :placeholder="slugifyTitle(title) || 'e.g.: about-us'"
41
+ @keyup.enter.native="submit">
42
+ </b-input>
43
+ </b-field>
44
+
45
+ <b-field label="Parent Page">
46
+ <b-autocomplete
47
+ v-if="!selectedParent"
48
+ v-model="parentSearchQuery"
49
+ :disabled="isLoading"
50
+ open-on-focus
51
+ :data="sortedPagesList"
52
+ placeholder="Search for parent page..."
53
+ clearable
54
+ @select="onSelectParent"
55
+ @input="onParentSearchInput">
56
+ <template #default="props">
57
+ <span :style="props.option.slug === '/' ? 'font-weight: bold;' : ''">
58
+ {{ props.option.title }} - {{ props.option.slug }}
59
+ </span>
60
+ </template>
61
+ <template #empty>No results found</template>
62
+ </b-autocomplete>
63
+ <div v-else style="display: flex; align-items: center; padding: 0.5rem 0.75rem; border: 1px solid #dbdbdb; border-radius: 4px; background-color: #f5f5f5;">
64
+ <span style="flex: 1;">
65
+ <strong>{{ selectedParent.title }}</strong> - {{ selectedParent.slug }}
66
+ </span>
67
+ <a @click.stop="clearSelectedParent" style="color: #f14668; cursor: pointer; margin-left: 0.5rem;">
68
+ <b-icon icon="times" size="is-small"></b-icon>
69
+ </a>
70
+ </div>
71
+ </b-field>
72
+ </section>
73
+ <footer class="modal-card-foot is-flex">
74
+ <div class="is-flex-grow-1"></div>
75
+ <b-button @click="close">Cancel</b-button>
76
+ <b-button type="is-success" :loading="submitting" @click="submit">
77
+ Create Page
78
+ </b-button>
79
+ </footer>
80
+ </div>
81
+ </b-dropdown-item>
82
+ </b-dropdown>
83
+ </template>
84
+
85
+ <script>
86
+ import PagesApi from "../../PagesApi.js";
87
+ import { morphToNotification } from "../../api.js";
88
+ import { slugify } from "../../util.js";
89
+ import MinimalDropdownButton from "../MinimalDropdownButton.vue";
90
+
91
+ export default {
92
+ name: "CreatePageDropdown",
93
+ components: { MinimalDropdownButton },
94
+ props: {
95
+ minimal: {
96
+ type: Boolean,
97
+ default: false
98
+ }
99
+ },
100
+ created() {
101
+ this.fetchPages();
102
+ },
103
+ data() {
104
+ return {
105
+ title: '',
106
+ slugPart: '',
107
+ parentSearchQuery: '',
108
+ selectedParent: null,
109
+ submitting: false,
110
+ isLoading: false,
111
+ pagesList: [],
112
+ pagesApi: new PagesApi()
113
+ }
114
+ },
115
+ computed: {
116
+ sortedPagesList() {
117
+ // Sort with Home page (slug '/') at the top, then alphabetically by title
118
+ return [...this.pagesList].sort((a, b) => {
119
+ if (a.slug === '/') return -1;
120
+ if (b.slug === '/') return 1;
121
+ return a.title.localeCompare(b.title);
122
+ });
123
+ }
124
+ },
125
+ watch: {
126
+ 'parentSearchQuery': 'fetchPages'
127
+ },
128
+ methods: {
129
+ slugifyTitle(str) {
130
+ return slugify(str);
131
+ },
132
+ async fetchPages() {
133
+ this.isLoading = true;
134
+ let data = await this.pagesApi.list({ inTrash: false, page: 1, q: this.parentSearchQuery });
135
+ this.pagesList = data.items;
136
+ this.isLoading = false;
137
+ },
138
+ onSelectParent(option) {
139
+ this.selectedParent = option;
140
+ // Clear the search query after selection to reset the autocomplete
141
+ this.$nextTick(() => {
142
+ this.parentSearchQuery = '';
143
+ });
144
+ },
145
+ onParentSearchInput() {
146
+ // Clear selected parent if user starts typing
147
+ if (this.parentSearchQuery && this.selectedParent) {
148
+ this.selectedParent = null;
149
+ }
150
+ },
151
+ clearSelectedParent() {
152
+ this.selectedParent = null;
153
+ this.parentSearchQuery = '';
154
+ },
155
+ async submit() {
156
+ if (!this.title.trim()) {
157
+ this.$buefy.toast.open({
158
+ type: 'is-danger',
159
+ message: 'Please enter a title'
160
+ });
161
+ return;
162
+ }
163
+
164
+ this.submitting = true;
165
+ try {
166
+ let data = {
167
+ title: this.title,
168
+ slugPart: this.slugPart || this.slugifyTitle(this.title),
169
+ parent: this.selectedParent ? this.selectedParent.id : null
170
+ };
171
+
172
+ let response = await this.pagesApi.create(data);
173
+ this.$buefy.toast.open(morphToNotification(response));
174
+ this.close();
175
+ this.$emit('created', response.item);
176
+ this.$router.push('/pages/' + response.item.id + '/edit');
177
+ } catch(e) {
178
+ // Error handled by API layer
179
+ }
180
+ this.submitting = false;
181
+ },
182
+ close() {
183
+ this.$refs.dropdown.toggle();
184
+ // Reset form
185
+ this.title = '';
186
+ this.slugPart = '';
187
+ this.parentSearchQuery = '';
188
+ this.selectedParent = null;
189
+ }
190
+ }
191
+ }
192
+ </script>
193
+
194
+ <style>
195
+ .create-page-dropdown .dropdown-content {
196
+ padding-top: 0;
197
+ padding-bottom: 0;
198
+ }
199
+
200
+ .create-page-dropdown .dropdown-menu {
201
+ overflow: visible !important;
202
+ min-width: 25rem;
203
+ }
204
+
205
+ .create-page-dropdown .modal-card-title {
206
+ flex-shrink: unset;
207
+ flex-grow: unset;
208
+ }
209
+ </style>
@@ -0,0 +1,135 @@
1
+ <template>
2
+ <b-dropdown
3
+ ref="dropdown"
4
+ :position="minimal ? 'is-bottom-right' : 'is-bottom-left'"
5
+ aria-role="menu"
6
+ trap-focus
7
+ append-to-body
8
+ class="create-partial-dropdown">
9
+ <template #trigger="{ active }">
10
+ <MinimalDropdownButton v-if="minimal" icon="plus"/>
11
+ <b-button v-else
12
+ :type="active ? '' : 'is-success'"
13
+ icon-left="pencil-alt"
14
+ :disabled="active">
15
+ Create Partial
16
+ </b-button>
17
+ </template>
18
+
19
+ <b-dropdown-item aria-role="menu-item" custom paddingless>
20
+ <div class="modal-card" style="width: auto; overflow: visible">
21
+ <header class="modal-card-head has-background-success-light">
22
+ <p class="modal-card-title">
23
+ <b-icon icon="puzzle-piece" size="is-normal" class="push-right"></b-icon>
24
+ Create Partial
25
+ </p>
26
+ </header>
27
+ <section class="modal-card-body" style="overflow: visible;">
28
+ <b-field label="Title">
29
+ <b-input
30
+ v-model="title"
31
+ placeholder="e.g.: Footer Copyright Text"
32
+ autofocus
33
+ @keyup.enter.native="submit">
34
+ </b-input>
35
+ </b-field>
36
+
37
+ <b-field label="Key" message="Leave blank to auto-generate from title">
38
+ <b-input
39
+ v-model="key"
40
+ :placeholder="slugifyKey(title) || 'e.g.: footer.copyright'"
41
+ @keyup.enter.native="submit">
42
+ </b-input>
43
+ </b-field>
44
+ </section>
45
+ <footer class="modal-card-foot is-flex">
46
+ <div class="is-flex-grow-1"></div>
47
+ <b-button @click="close">Cancel</b-button>
48
+ <b-button type="is-success" :loading="submitting" @click="submit">
49
+ Create Partial
50
+ </b-button>
51
+ </footer>
52
+ </div>
53
+ </b-dropdown-item>
54
+ </b-dropdown>
55
+ </template>
56
+
57
+ <script>
58
+ import PartialsApi from "../../PartialsApi.js";
59
+ import { morphToNotification } from "../../api.js";
60
+ import { slugifyKey } from "../../util.js";
61
+ import MinimalDropdownButton from "../MinimalDropdownButton.vue";
62
+
63
+ export default {
64
+ name: "CreatePartialDropdown",
65
+ components: { MinimalDropdownButton },
66
+ props: {
67
+ minimal: {
68
+ type: Boolean,
69
+ default: false
70
+ }
71
+ },
72
+ data() {
73
+ return {
74
+ title: '',
75
+ key: '',
76
+ submitting: false,
77
+ partialsApi: new PartialsApi()
78
+ }
79
+ },
80
+ methods: {
81
+ slugifyKey(str) {
82
+ return slugifyKey(str);
83
+ },
84
+ async submit() {
85
+ if (!this.title.trim()) {
86
+ this.$buefy.toast.open({
87
+ type: 'is-danger',
88
+ message: 'Please enter a title'
89
+ });
90
+ return;
91
+ }
92
+
93
+ this.submitting = true;
94
+ try {
95
+ let data = {
96
+ title: this.title,
97
+ key: this.key || this.slugifyKey(this.title)
98
+ };
99
+
100
+ let response = await this.partialsApi.create(data);
101
+ this.$buefy.toast.open(morphToNotification(response));
102
+ this.close();
103
+ this.$emit('created', response.item);
104
+ this.$router.push('/partials/' + response.item.id + '/edit');
105
+ } catch(e) {
106
+ // Error handled by API layer
107
+ }
108
+ this.submitting = false;
109
+ },
110
+ close() {
111
+ this.$refs.dropdown.toggle();
112
+ // Reset form
113
+ this.title = '';
114
+ this.key = '';
115
+ }
116
+ }
117
+ }
118
+ </script>
119
+
120
+ <style>
121
+ .create-partial-dropdown .dropdown-content {
122
+ padding-top: 0;
123
+ padding-bottom: 0;
124
+ }
125
+
126
+ .create-partial-dropdown .dropdown-menu {
127
+ overflow: visible !important;
128
+ min-width: 25rem;
129
+ }
130
+
131
+ .create-partial-dropdown .modal-card-title {
132
+ flex-shrink: unset;
133
+ flex-grow: unset;
134
+ }
135
+ </style>
@@ -1,5 +1,6 @@
1
1
  import MediaPage from "../components/media/MediaPage.vue";
2
2
  import MediaResponsiveImages from "../components/media/MediaResponsiveImages.vue";
3
+ import MediaUploadDropdown from "../components/media/MediaUploadDropdown.vue";
3
4
  import {WEB_CONTENT} from "../main";
4
5
 
5
6
  export default function(ui) {
@@ -10,6 +11,7 @@ export default function(ui) {
10
11
  addAction: '/media/list/?upload=true',
11
12
  addIcon: 'upload',
12
13
  addPermission: 'media.postCreate',
14
+ addDropdownComponent: MediaUploadDropdown,
13
15
  listAction: '/media/list',
14
16
  listPermission: 'media.getList',
15
17
  items: {
@@ -8,6 +8,8 @@ import PartialsApi from "../PartialsApi.js";
8
8
  import PartialTable from "../components/PartialTable.vue";
9
9
  import PageActions from "../components/PageActions.vue";
10
10
  import PartialActions from "../components/PartialActions.vue";
11
+ import CreatePageDropdown from "../components/pages/CreatePageDropdown.vue";
12
+ import CreatePartialDropdown from "../components/partials/CreatePartialDropdown.vue";
11
13
 
12
14
  export default function(ui) {
13
15
  ui.addMainMenuGroup(WEB_CONTENT, {
@@ -18,6 +20,7 @@ export default function(ui) {
18
20
  addIcon: 'plus',
19
21
  addPermission: 'pages.postCreate',
20
22
  addAction: '/pages/create',
23
+ addDropdownComponent: CreatePageDropdown,
21
24
  items: {
22
25
  }
23
26
  });
@@ -29,25 +32,15 @@ export default function(ui) {
29
32
  addIcon: 'plus',
30
33
  addPermission: 'partials.postCreate',
31
34
  addAction: '/partials/create',
35
+ addDropdownComponent: CreatePartialDropdown,
32
36
  items: {
33
37
  }
34
38
  });
35
39
 
36
- const partialsProps = { displayName: 'Partials', routePrefix: 'partials', inTrash: false, tableComponent: PartialTable, actionsComponent: PartialActions, singularDisplayName: 'Partial', defaultSortField: 'title', defaultSortOrder: 'asc', resourceApi: new PartialsApi() }
37
- const pagesProps = { displayName: 'Pages', routePrefix: 'pages', inTrash: false, tableComponent: PageTable, actionsComponent: PageActions, singularDisplayName: 'Page', defaultSortField: 'title', defaultSortOrder: 'asc', resourceApi: new PagesApi() }
40
+ const partialsProps = { displayName: 'Partials', routePrefix: 'partials', inTrash: false, tableComponent: PartialTable, actionsComponent: PartialActions, singularDisplayName: 'Partial', defaultSortField: 'title', defaultSortOrder: 'asc', resourceApi: new PartialsApi(), createDropdownComponent: CreatePartialDropdown }
41
+ const pagesProps = { displayName: 'Pages', routePrefix: 'pages', inTrash: false, tableComponent: PageTable, actionsComponent: PageActions, singularDisplayName: 'Page', defaultSortField: 'title', defaultSortOrder: 'asc', resourceApi: new PagesApi(), createDropdownComponent: CreatePageDropdown }
38
42
 
39
43
  ui.addAuthenticatedRoutes([
40
- {
41
- path: '(pages|partials)/create',
42
- component: LegacyPage,
43
- props: (route) => {
44
- return {
45
- fullPath: route.fullPath,
46
- legacyPrefix: '/oxygen/view',
47
- adminPrefix: '/oxygen'
48
- }
49
- }
50
- },
51
44
  {
52
45
  path: '(pages|partials)/:subpath/edit',
53
46
  component: LegacyPage,
package/src/util.js CHANGED
@@ -62,4 +62,24 @@ const tryParseTelephone = (telephone) => {
62
62
  }
63
63
  };
64
64
 
65
- export { strEquals, convertStr, nestedGet, nestedSet, tryParseTelephone };
65
+ const slugify = (str) => {
66
+ if(!str) return '';
67
+ return str
68
+ .toLowerCase()
69
+ .trim()
70
+ .replace(/[^\w\s-]/g, '') // Remove non-word chars except spaces and hyphens
71
+ .replace(/[\s_-]+/g, '-') // Replace spaces, underscores, multiple hyphens with single hyphen
72
+ .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
73
+ };
74
+
75
+ const slugifyKey = (str) => {
76
+ if(!str) return '';
77
+ return str
78
+ .toLowerCase()
79
+ .trim()
80
+ .replace(/[^\w\s.-]/g, '') // Remove non-word chars except spaces, dots, and hyphens
81
+ .replace(/[\s_-]+/g, '.') // Replace spaces, underscores, hyphens with dots
82
+ .replace(/^\.+|\.+$/g, ''); // Remove leading/trailing dots
83
+ };
84
+
85
+ export { strEquals, convertStr, nestedGet, nestedSet, tryParseTelephone, slugify, slugifyKey };
@@ -1,37 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="ChangeListManager">
4
- <list default="true" id="4ad0dcde-54f6-459b-8ef0-38a17e946358" name="Changes" comment="" />
5
- <option name="SHOW_DIALOG" value="false" />
6
- <option name="HIGHLIGHT_CONFLICTS" value="true" />
7
- <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
8
- <option name="LAST_RESOLUTION" value="IGNORE" />
9
- </component>
10
- <component name="ComposerSettings">
11
- <execution />
12
- </component>
13
- <component name="ProjectId" id="27rS1jBVazc5EdthHlwaXfJm1hC" />
14
- <component name="ProjectViewState">
15
- <option name="hideEmptyMiddlePackages" value="true" />
16
- <option name="showLibraryContents" value="true" />
17
- </component>
18
- <component name="PropertiesComponent"><![CDATA[{
19
- "keyToString": {
20
- "RunOnceActivity.OpenProjectViewOnStart": "true",
21
- "RunOnceActivity.ShowReadmeOnStart": "true",
22
- "WebServerToolWindowFactoryState": "false",
23
- "last_opened_file_path": "/home/chris/code/oxygen/Components/ui"
24
- }
25
- }]]></component>
26
- <component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
27
- <component name="TaskManager">
28
- <task active="true" id="Default" summary="Default task">
29
- <changelist id="4ad0dcde-54f6-459b-8ef0-38a17e946358" name="Changes" comment="" />
30
- <created>1650076498224</created>
31
- <option name="number" value="Default" />
32
- <option name="presentableId" value="Default" />
33
- <updated>1650076498224</updated>
34
- </task>
35
- <servers />
36
- </component>
37
- </project>