@christianriedl/media 1.0.128 → 1.0.129
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.
package/package.json
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
import { Helper } from '@christianriedl/utils';
|
|
4
4
|
import { IMediaFolder, MediaService, IPictureFile, getMediaSymbol } from '@christianriedl/media';
|
|
5
5
|
|
|
6
|
-
const props = defineProps<{ folder: IMediaFolder, photos
|
|
6
|
+
const props = defineProps<{ folder: IMediaFolder, photos?: IPictureFile[] }>();
|
|
7
7
|
const emits = defineEmits<{ (e: 'close'): void }>();
|
|
8
8
|
|
|
9
|
-
const title =
|
|
9
|
+
const title = getTitle();
|
|
10
10
|
const getMedia = inject(getMediaSymbol)!;
|
|
11
11
|
const mediaService = getMedia();
|
|
12
12
|
|
|
@@ -36,17 +36,27 @@
|
|
|
36
36
|
}
|
|
37
37
|
async function onDownload() {
|
|
38
38
|
const folder = await mediaService.getPhotos(props.folder);
|
|
39
|
+
const downloadUrl = mediaService.getPhotosDownloadUrl(folder, height.value, quality.value, getPhotoIds());
|
|
40
|
+
window.open(downloadUrl);
|
|
41
|
+
emits('close');
|
|
42
|
+
}
|
|
43
|
+
function getTitle(): string {
|
|
44
|
+
let count = getPhotoIds().length;
|
|
45
|
+
if (count == 0)
|
|
46
|
+
count = props.folder.ChildCount;
|
|
47
|
+
return `Download '${props.folder.Name}' (${count} Bilder)`
|
|
48
|
+
|
|
49
|
+
}
|
|
50
|
+
function getPhotoIds(): string[] {
|
|
39
51
|
const photoIds: string[] = [];
|
|
40
52
|
if (props.photos) {
|
|
41
|
-
for (var i = 0; i < props.photos.
|
|
42
|
-
const photo = props.photos
|
|
53
|
+
for (var i = 0; i < props.photos.length; i++) {
|
|
54
|
+
const photo = props.photos[i];
|
|
43
55
|
if (photo.selected)
|
|
44
56
|
photoIds.push(photo.DLNAID);
|
|
45
57
|
}
|
|
46
58
|
}
|
|
47
|
-
|
|
48
|
-
window.open(downloadUrl);
|
|
49
|
-
emits('close');
|
|
59
|
+
return photoIds;
|
|
50
60
|
}
|
|
51
61
|
</script>
|
|
52
62
|
|
|
@@ -1,50 +1,57 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { inject, ref, reactive, computed, onMounted, onUnmounted, nextTick, watch, StyleValue } from 'vue';
|
|
3
|
-
import { VDataTableVirtual } from 'vuetify/labs/VDataTable';
|
|
4
3
|
import type { ComponentPublicInstance } from 'vue';
|
|
5
4
|
import { useRouter } from 'vue-router';
|
|
6
5
|
import { IAppState, IAppConfig, EDevice, EBrowser, appStateSymbol, appConfigSymbol, Helper } from '@christianriedl/utils';
|
|
7
|
-
import { EItemType, EMediaType, IMediaFolder, IMediaItem, IPhotoSelection, MediaService, getMediaSymbol, IMediaAppConfig } from '@christianriedl/media';
|
|
6
|
+
import { EItemType, EMediaType, IMediaFolder, IMediaItem, IPhotoSelection, MediaService, IMediaService, getMediaSymbol, getMediaBinSymbol, IMediaAppConfig } from '@christianriedl/media';
|
|
8
7
|
import FileUpload from '../components/FileUpload.vue';
|
|
9
8
|
import PhotoDownload from '../components/PhotoDownload.vue';
|
|
10
9
|
|
|
11
10
|
const appState = inject(appStateSymbol)!;
|
|
12
11
|
const appConfig = inject(appConfigSymbol)!;
|
|
13
12
|
const mediaAppConfig = appConfig as unknown as IMediaAppConfig;
|
|
14
|
-
const getMediaService = inject(getMediaSymbol)!;
|
|
15
|
-
const mediaService = getMediaService();
|
|
16
|
-
const heightStyle = computed<StyleValue>(() => { return { height: appState.bodyHeight.value + 'px', overflowY: 'auto' } });
|
|
13
|
+
const getMediaService = inject(mediaAppConfig.useMediaBin ? getMediaBinSymbol : getMediaSymbol)!;
|
|
14
|
+
const mediaService = getMediaService() as unknown as IMediaService;
|
|
15
|
+
//const heightStyle = computed<StyleValue>(() => { return { height: appState.bodyHeight.value + 'px', overflowY: 'auto' } });
|
|
17
16
|
const isMobile = appState.isMobile && (appState.device != EDevice.iPad);
|
|
18
17
|
const router = useRouter();
|
|
19
|
-
|
|
18
|
+
|
|
19
|
+
const listHeight = ref(0);
|
|
20
|
+
const listhead = ref<ComponentPublicInstance|null>(null);
|
|
20
21
|
|
|
21
22
|
const items = ref<IMediaFolder[]>([]);
|
|
22
23
|
const selected = reactive<IPhotoSelection>(mediaService.photoSelection);
|
|
23
|
-
const listHeight = ref(appState.bodyHeight.value);
|
|
24
|
-
const listhead = ref<ComponentPublicInstance|null>(null);
|
|
25
|
-
const table = ref<InstanceType<typeof VDataTableVirtual> | null>(null);
|
|
26
24
|
const uploadVisible = ref(false);
|
|
27
25
|
|
|
28
26
|
const roots: string[] = ['Jahr', 'Orte', 'Ereignisse', 'Personen'];
|
|
29
27
|
const criterias: string[] = ['ye', 'lo', 'ev', 'pe'];
|
|
30
|
-
const groupBy = ref<any>([{key: 'info', order: 'asc'}]);
|
|
31
|
-
const sortBy = ref<any>([{key: 'title', order: 'asc'}]);
|
|
32
|
-
const headers= [
|
|
33
|
-
{ title: 'Title', key: 'title', align: 'start', sortable: false },
|
|
34
|
-
{ title: 'Actions', key: 'actions', sortable: false }
|
|
35
|
-
];
|
|
36
|
-
const grouped = ref(false);
|
|
37
28
|
const backVisible = ref(false);
|
|
29
|
+
const itemIndex = ref(0);
|
|
38
30
|
const showDownload = ref(false);
|
|
31
|
+
const showAlbums = ref(false);
|
|
39
32
|
const downloadFolder = ref<IMediaFolder | null>(null);
|
|
40
33
|
const selectedYear = ref<number | undefined>(undefined);
|
|
41
34
|
const selectedName = ref<string | undefined>(undefined);
|
|
35
|
+
const numGridCols = computed(() => computeColumnWidth(selected.selected, appState.pageWidth.value));
|
|
36
|
+
const numRootSelColumns = computed(() => { return 12 - (backVisible.value ? 2 : 0) - 2 - (isMobile ? 0 : 2) });
|
|
37
|
+
|
|
38
|
+
watch(appState.bodyHeight, () => computeListHeight());
|
|
42
39
|
|
|
43
40
|
window.addEventListener('popstate', onPopState);
|
|
41
|
+
|
|
42
|
+
function computeListHeight() {
|
|
43
|
+
nextTick(() => {
|
|
44
|
+
let height = appState.bodyHeight.value;
|
|
45
|
+
if (listhead.value) {
|
|
46
|
+
height = height - (listhead.value.$el as HTMLElement).clientHeight;
|
|
47
|
+
}
|
|
48
|
+
listHeight.value = height;
|
|
49
|
+
})
|
|
50
|
+
}
|
|
44
51
|
function onPopState(event: any) {
|
|
45
52
|
if (event.state && event.state.noBackExitsApp && backVisible.value) {
|
|
46
53
|
event.preventDefault();
|
|
47
|
-
|
|
54
|
+
goBack();
|
|
48
55
|
}
|
|
49
56
|
}
|
|
50
57
|
onUnmounted(() => {
|
|
@@ -54,67 +61,46 @@
|
|
|
54
61
|
});
|
|
55
62
|
onMounted(async () => {
|
|
56
63
|
mediaService.log.trace('Photos created');
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
let root: IMediaFolder;
|
|
65
|
+
if (mediaAppConfig.useMediaBin) {
|
|
66
|
+
const rc = await mediaService.initialize(EMediaType.Picture);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const rc = await (mediaService as MediaService).getLists(EMediaType.Picture);
|
|
70
|
+
}
|
|
59
71
|
if (!selected.selected.DLNAID) {
|
|
60
|
-
root = await mediaService.
|
|
72
|
+
root = await mediaService.getPhotosByCriteria(stars(), selected.criteria);
|
|
61
73
|
}
|
|
62
74
|
else {
|
|
63
75
|
root = mediaService.getFolder(selected.selected.DLNAID);
|
|
64
76
|
}
|
|
65
|
-
|
|
77
|
+
setSelected(root);
|
|
66
78
|
if (selected.selectedAlbum)
|
|
67
79
|
selectedYear.value = selected.selectedAlbum.Year;
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
const scrollTop = appState.scrollPosition.value;
|
|
71
|
-
if (scrollTop > 0 && table.value) {
|
|
72
|
-
nextTick(() => {
|
|
73
|
-
// Scoll to this position
|
|
74
|
-
const el = table.value?._?.vnode?.el;
|
|
75
|
-
el?.scrollTo(0, scrollTop);
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
mediaService.log.trace(`Photos start with ${selected.selected.DLNAID} scrollTop: ${scrollTop}`);
|
|
80
|
+
const grouped = selected.criteria == 'ye' || root.ItemType == EItemType.PictureCategory;
|
|
81
|
+
mediaService.log.trace(`Photos start with ${selected.selected.DLNAID}`);
|
|
79
82
|
})
|
|
80
|
-
watch(appState.bodyHeight, () => computeListHeight());
|
|
81
|
-
function computeListHeight() {
|
|
82
|
-
nextTick(() => {
|
|
83
|
-
let height = appState.bodyHeight.value;
|
|
84
|
-
if (listhead.value) {
|
|
85
|
-
height = height - (listhead.value.$el as HTMLElement).clientHeight;
|
|
86
|
-
}
|
|
87
|
-
listHeight.value = height;
|
|
88
|
-
})
|
|
89
|
-
}
|
|
90
83
|
function stars() {
|
|
91
84
|
return selected.rating ? (selected.rating == 1 ? '*' : '**') : 'all';
|
|
92
85
|
}
|
|
93
86
|
function showFolder(folder: IMediaFolder) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
selectedYear.value = folder.Year;
|
|
87
|
+
if (showAlbums.value)
|
|
88
|
+
showPictures(folder);
|
|
89
|
+
else {
|
|
90
|
+
window.history.pushState({ noBackExitsApp: true }, '')
|
|
91
|
+
setSelected(folder);
|
|
92
|
+
}
|
|
101
93
|
}
|
|
102
94
|
function showPictures(folder: IMediaFolder) {
|
|
103
95
|
selected.selectedAlbum = folder;
|
|
104
96
|
router.push({ path: 'photoalbum', query: { id: folder.DLNAID } });
|
|
105
97
|
}
|
|
106
|
-
function
|
|
107
|
-
const folder = row.item.raw as IMediaFolder;
|
|
108
|
-
if (folder.info)
|
|
109
|
-
showPictures(folder);
|
|
110
|
-
else
|
|
111
|
-
showFolder(folder);
|
|
112
|
-
}
|
|
113
|
-
function listBack() {
|
|
98
|
+
function goBack() {
|
|
114
99
|
const parent = mediaService.getFolder(selected.selected.DLNAParentID);
|
|
115
100
|
const index = parent.Folders.indexOf(selected.selected);
|
|
116
|
-
|
|
117
|
-
|
|
101
|
+
setSelected(parent);
|
|
102
|
+
if (index)
|
|
103
|
+
itemIndex.value = index;
|
|
118
104
|
}
|
|
119
105
|
function onLightbox(folder: IMediaFolder) {
|
|
120
106
|
selected.selectedAlbum = folder;
|
|
@@ -125,6 +111,10 @@
|
|
|
125
111
|
downloadFolder.value = folder;
|
|
126
112
|
showDownload.value = true;
|
|
127
113
|
}
|
|
114
|
+
function onCloseDownload() {
|
|
115
|
+
downloadFolder.value = null;
|
|
116
|
+
showDownload.value = false;
|
|
117
|
+
}
|
|
128
118
|
async function onShare(folder: IMediaFolder) {
|
|
129
119
|
const guid = Helper.generateUUID();
|
|
130
120
|
const url = `https://www.christian-riedl.com/photos?id=${guid}`;
|
|
@@ -140,10 +130,6 @@
|
|
|
140
130
|
window.alert(`Not Pasted : ${err} !`);
|
|
141
131
|
}
|
|
142
132
|
}
|
|
143
|
-
function onCloseDownload() {
|
|
144
|
-
downloadFolder.value = null;
|
|
145
|
-
showDownload.value = false;
|
|
146
|
-
}
|
|
147
133
|
function onRootChange(root: string) {
|
|
148
134
|
const idx = roots.indexOf(selected.root);
|
|
149
135
|
selected.criteria = criterias[idx];
|
|
@@ -155,77 +141,65 @@
|
|
|
155
141
|
initialize().then((v) => { });;
|
|
156
142
|
}
|
|
157
143
|
async function initialize(): Promise<boolean> {
|
|
158
|
-
let root = await mediaService.
|
|
159
|
-
let expanded = selected.criteria == 'ye';
|
|
144
|
+
let root = await mediaService.getPhotosByCriteria(stars(), selected.criteria);
|
|
160
145
|
if (selected.criteria != 'ye' && selectedName.value) {
|
|
161
146
|
for (let i = 0; i < root.Folders.length; i++) {
|
|
162
147
|
if (root.Folders[i].Name == selectedName.value) {
|
|
163
148
|
root = root.Folders[i];
|
|
164
|
-
expanded = true;
|
|
165
149
|
break;
|
|
166
150
|
}
|
|
167
151
|
}
|
|
168
152
|
}
|
|
169
|
-
|
|
170
|
-
setGrouped(folders, expanded, selectedYear.value);
|
|
153
|
+
setSelected(root);
|
|
171
154
|
return true;
|
|
172
155
|
}
|
|
173
|
-
function setSelected(folder: IMediaFolder)
|
|
156
|
+
function setSelected(folder: IMediaFolder) {
|
|
157
|
+
mediaService.prepare(folder); // To prepare children
|
|
174
158
|
selected.selected = folder;
|
|
175
159
|
if (folder.Year && folder.Year > 0)
|
|
176
160
|
selectedYear.value = folder.Year;
|
|
177
161
|
selectedName.value = folder.Name;
|
|
178
162
|
backVisible.value = selected.selected.ItemType != EItemType.PictureGenreType;
|
|
179
|
-
|
|
180
|
-
// calculate list height
|
|
163
|
+
computeColumnWidth(folder, appState.pageWidth.value); // computes showAlbums
|
|
181
164
|
computeListHeight();
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
function
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
else {
|
|
202
|
-
folder.info = undefined;
|
|
203
|
-
list.push(folder);
|
|
165
|
+
items.value = folder.Folders;
|
|
166
|
+
}
|
|
167
|
+
function computeColumnWidth(folder: IMediaFolder, width: number) {
|
|
168
|
+
let nameLength = 9;
|
|
169
|
+
if (folder) {
|
|
170
|
+
const dlnaid = folder.DLNAID;
|
|
171
|
+
if (dlnaid.endsWith('|'))
|
|
172
|
+
nameLength = 9; // Year
|
|
173
|
+
else if (dlnaid.endsWith(".ev"))
|
|
174
|
+
nameLength = 18; // Event
|
|
175
|
+
else if (dlnaid.endsWith(".lo"))
|
|
176
|
+
nameLength = 14; // Location
|
|
177
|
+
else if (dlnaid.endsWith(".pe"))
|
|
178
|
+
nameLength = 12; // Person
|
|
179
|
+
else {
|
|
180
|
+
//nameLength = 32; // Album
|
|
181
|
+
showAlbums.value = true;
|
|
182
|
+
return 12;
|
|
204
183
|
}
|
|
205
184
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return "";
|
|
214
|
-
}
|
|
185
|
+
let numColumns = appState.pageWidth.value / (nameLength * 10);
|
|
186
|
+
let colWidth = Math.ceil(12 / numColumns);
|
|
187
|
+
while (12 % colWidth != 0)
|
|
188
|
+
colWidth++;
|
|
189
|
+
showAlbums.value = false;
|
|
190
|
+
return colWidth;
|
|
191
|
+
}
|
|
215
192
|
</script>
|
|
216
193
|
<template>
|
|
217
|
-
<v-card ref="listhead" class="bg-media_head">
|
|
194
|
+
<v-card ref="listhead" class="bg-media_head" density="compact">
|
|
218
195
|
<v-card-title>{{selected.selected.Name}}</v-card-title>
|
|
219
196
|
<v-card-actions>
|
|
220
|
-
<v-btn v-if="backVisible" @click="
|
|
221
|
-
<v-icon
|
|
197
|
+
<v-btn v-if="backVisible" @click="goBack">
|
|
198
|
+
<v-icon icon="$back" />
|
|
222
199
|
</v-btn>
|
|
223
200
|
<v-rating clearable length="2" v-model="selected.rating" @update:modelValue="onRating" />
|
|
224
|
-
<v-select v-model="selected.root"
|
|
225
|
-
:
|
|
226
|
-
persistent-hint
|
|
227
|
-
@update:modelValue="onRootChange"
|
|
228
|
-
hide-details single-line>
|
|
201
|
+
<v-select v-model="selected.root" :items="roots" density="compact" persistent-hint
|
|
202
|
+
@update:modelValue="onRootChange" hide-details single-line>
|
|
229
203
|
</v-select>
|
|
230
204
|
<v-btn v-if="!isMobile" @click="uploadVisible = !uploadVisible">
|
|
231
205
|
UPLOAD
|
|
@@ -234,53 +208,24 @@
|
|
|
234
208
|
</v-card-actions>
|
|
235
209
|
</v-card>
|
|
236
210
|
<file-upload v-if="uploadVisible" accept="image/jpeg"></file-upload>
|
|
237
|
-
<v-
|
|
238
|
-
|
|
239
|
-
<template v-slot:top="{ toggleGroup, isGroupOpen }">
|
|
240
|
-
<p style="display:none">{{setGroupFunc(toggleGroup)}}</p>
|
|
241
|
-
</template>
|
|
242
|
-
<template v-slot:item.actions="{ item }">
|
|
243
|
-
<div v-if="item.raw.info">
|
|
244
|
-
<v-icon size="small" icon="$share" @click="onShare(item.raw)"></v-icon>
|
|
245
|
-
<v-icon size="small" icon="$download" @click="onDownload(item.raw)"></v-icon>
|
|
246
|
-
<v-icon size="small" icon="$grid" @click="onLightbox(item.raw)"></v-icon>
|
|
247
|
-
</div>
|
|
248
|
-
</template>
|
|
249
|
-
</v-data-table-virtual>
|
|
250
|
-
<!--
|
|
251
|
-
<v-card :max-height="listHeight" class="overflow-y-auto bg-media" ref="scrollElement" v-scroll.self="onScroll">
|
|
252
|
-
<v-data-table-virtual ref="table" :group-by="groupBy" :items="items" :headers="headers" class="elevation-1" item-value="title" :height="listHeight" @click:row="onClick">
|
|
253
|
-
<template v-slot:item.actions="{ item }">
|
|
254
|
-
<v-icon size="small" icon="$share" @click="onShare(item.raw)"></v-icon>
|
|
255
|
-
<v-icon size="small" icon="$download" @click="onDownload(item.raw)"></v-icon>
|
|
256
|
-
<v-icon size="small" icon="$grid" @click="onLightbox(item.raw)"></v-icon>
|
|
257
|
-
</template>
|
|
258
|
-
</v-data-table-virtual>
|
|
259
|
-
<!-
|
|
260
|
-
<v-list v-if="!grouped">
|
|
211
|
+
<v-card :height="listHeight" class="overflow-y-auto bg-media" >
|
|
212
|
+
<v-list v-if="showAlbums" class="bg-media">
|
|
261
213
|
<v-list-item v-for="item in items" :key="item.DLNAID" :title="item.info" density="compact" @click.stop="showFolder(item)">
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
<template v-slot:activator="{ props }">
|
|
267
|
-
<v-list-item v-bind="props" :title="item.info" :value="item.info" density="compact"></v-list-item>
|
|
214
|
+
<template v-slot:append>
|
|
215
|
+
<v-btn color="grey" variant="tonal" icon="$share" @click.stop.prevent="onShare(item)"></v-btn>
|
|
216
|
+
<v-btn color="grey" variant="tonal" icon="$download" @click.stop.prevent="onDownload(item)"></v-btn>
|
|
217
|
+
<v-btn color="grey" variant="tonal" icon="$grid" @click.stop.prevent="onLightbox(item)"></v-btn>
|
|
268
218
|
</template>
|
|
269
|
-
|
|
270
|
-
<template v-slot:append>
|
|
271
|
-
<v-btn color="grey" variant="tonal" icon="$share" @click.stop.prevent="onShare(subItem)"></v-btn>
|
|
272
|
-
<v-btn color="grey" variant="tonal" icon="$download" @click.stop.prevent="onDownload(subItem)"></v-btn>
|
|
273
|
-
<v-btn color="grey" variant="tonal" icon="$grid" @click.stop.prevent="onLightbox(subItem)"></v-btn>
|
|
274
|
-
</template>
|
|
275
|
-
</v-list-item>
|
|
276
|
-
</v-list-group>
|
|
219
|
+
</v-list-item>
|
|
277
220
|
</v-list>
|
|
278
|
-
|
|
221
|
+
<v-row v-else class="bg-media pa-1">
|
|
222
|
+
<v-col :cols="numGridCols" v-for="item in items" :key="item.DLNAID" density="compact" @click.stop="showFolder(item)">
|
|
223
|
+
{{item.info}}
|
|
224
|
+
</v-col>
|
|
225
|
+
</v-row>
|
|
279
226
|
</v-card>
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
<v-dialog v-model="showDownload">
|
|
283
|
-
<photo-download :folder="downloadFolder!" v-click-outside="onCloseDownload" @close="onCloseDownload"></photo-download>
|
|
227
|
+
<v-dialog v-model="showDownload" v-click-outside="onCloseDownload" >
|
|
228
|
+
<photo-download :folder="downloadFolder!" @close="onCloseDownload"></photo-download>
|
|
284
229
|
</v-dialog>
|
|
285
230
|
</template>
|
|
286
231
|
|
package/src/views/PhotosPage.vue
CHANGED
|
@@ -201,7 +201,7 @@
|
|
|
201
201
|
<v-card-title>{{selected.selected.Name}}</v-card-title>
|
|
202
202
|
<v-card-actions>
|
|
203
203
|
<v-btn v-if="backVisible" @click="listBack">
|
|
204
|
-
<v-icon
|
|
204
|
+
<v-icon icon="$back" />
|
|
205
205
|
</v-btn>
|
|
206
206
|
<v-rating clearable length="2" v-model="selected.rating" @update:modelValue="onRating" />
|
|
207
207
|
<v-select v-model="selected.root"
|