New page types: tabs, maps; misc. fixes (#198)

Upgrade to Framework7 5.4.5
Add leaflet/vue2-leaflet dependencies

Widgets & pages:
- Add OhTabsPage, OhMapPage, OhTab,
  OhMapMarker, OhMapCircleMarker
- Add support for tabbed pages in modals
- Add theme, themeOptions, device, JSON
  to expression evaluation context

Page designers:
- Add rudimentary designers for tabs, map pages
  (to refactor)
- Import designer components dynamically
  (split into individual webpack chunks)
- Fix masonry menus z-index issues in editor (hopefully)

Config parameters:
- Add map picker for location contexts

Code editor:
- Add indent guides

Signed-off-by: Yannick Schaus <github@schaus.net>
pull/199/head
Yannick Schaus 2020-03-05 18:59:42 +01:00 committed by GitHub
parent bcd161e774
commit b9f2f3ddd3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1710 additions and 152 deletions

View File

@ -7800,9 +7800,9 @@
}
},
"framework7": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/framework7/-/framework7-5.3.2.tgz",
"integrity": "sha512-pqsc0vVDwGOdZ7mFIPXX07tHzdtbAqEcfnw0rtURFdZuOxq9Aic65FGH1aIr2t/tTFebxkQ0XTVK0DKCVMmwBA==",
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/framework7/-/framework7-5.4.5.tgz",
"integrity": "sha512-PkrEJAjK6bX1R43en81hGmzUiaRGgrY+W+eM4VArcEDXTwUR+goSelGZjYA4mxTLSjJVT9WIosf0VxoWkZYf8w==",
"requires": {
"dom7": "2.1.3",
"path-to-regexp": "6.1.0",
@ -7816,9 +7816,9 @@
"integrity": "sha512-+mB8XhEAaIjjfRNlqW2tk7kYfPrQvDmDVpRFUjeUOiPDqqOybYmC9McvVPwwmqmob4nD3iZFWfs+rAMkVs5vPQ=="
},
"framework7-vue": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/framework7-vue/-/framework7-vue-5.3.2.tgz",
"integrity": "sha512-9znW9aChVIzKS0X7T607qMUT0D8OiQc8AVQXx4Qmx/MyrNqBsDISav37aT7dKQ+WyN14VTkq8bcSWoQWkA8DXA=="
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/framework7-vue/-/framework7-vue-5.4.5.tgz",
"integrity": "sha512-QzdSXO4srot2pCLUAdJewdq5KTUE/b7BI3zE8WLfPEroyNlFMitBc83KkJ/h35FwgmAzaNDm3eGXrwTx/OWoyQ=="
},
"fresh": {
"version": "0.5.2",
@ -10424,6 +10424,11 @@
"invert-kv": "2.0.0"
}
},
"leaflet": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.6.0.tgz",
"integrity": "sha512-CPkhyqWUKZKFJ6K8umN5/D2wrJ2+/8UIpXppY7QDnUZW5bZL5+SEI2J7GBpwh4LIupOKqbNSQXgqmrEJopHVNQ=="
},
"left-pad": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
@ -16645,6 +16650,11 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
"vue2-leaflet": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/vue2-leaflet/-/vue2-leaflet-2.5.2.tgz",
"integrity": "sha512-9eN0TxqCkyXbaI7waO3u+n0OAezkxjb811tstG6gRLAZy/ocXlNLC3JqTWE0FwBUlqBbMpyzsIk6LrEhs8oVBQ=="
},
"vuex": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/vuex/-/vuex-3.1.2.tgz",

View File

@ -60,10 +60,11 @@
"dom7": "^2.1.3",
"echarts": "^4.6.0",
"expression-eval": "^2.1.0",
"framework7": "^5.3.2",
"framework7": "^5.4.5",
"framework7-icons": "^3.0.0",
"framework7-vue": "^5.3.2",
"framework7-vue": "^5.4.5",
"later-again": "^0.1.1",
"leaflet": "^1.6.0",
"moo": "^0.5.1",
"nearley": "^2.19.1",
"template7": "^1.4.2",
@ -73,6 +74,7 @@
"vue-codemirror": "^4.0.6",
"vue-echarts": "^4.1.0",
"vue-magic-grid": "0.0.4",
"vue2-leaflet": "^2.5.2",
"vuex": "^3.1.2",
"yaml": "^1.7.2"
},

View File

@ -22,7 +22,7 @@
:class="{ currentsection: currentUrl.indexOf('/page/' + page.uid) >= 0 }"
:link="'/page/' + page.uid"
:title="page.config.label" view=".view-main" panel-close>
<f7-icon slot="media" f7="tv"></f7-icon>
<f7-icon slot="media" :f7="pageIcon(page)"></f7-icon>
</f7-list-item>
</f7-list>
<f7-block-title>Administration</f7-block-title>
@ -285,6 +285,20 @@ export default {
})
})
},
pageIcon (page) {
switch (page.component) {
case 'Sitemap':
return 'menu'
case 'oh-layout-page':
return 'rectangle_grid_2x2'
case 'oh-tabs-page':
return 'squares_below_rectangle'
case 'oh-map-page':
return 'map'
default:
return 'tv'
}
},
login () {
localStorage.setItem('openhab.ui:serverUrl', this.serverUrl)
localStorage.setItem('openhab.ui:username', this.username)

View File

@ -10,29 +10,100 @@
@input="updateValue"
type="text">
<div class="padding-left" slot="content-end">
<f7-button slot="content-end" @click="openMap"><f7-icon f7="placemark" /> Map</f7-button>
<f7-button slot="content-end" @click="openMapPicker"><f7-icon f7="placemark" /> Map</f7-button>
</div>
</f7-list-input>
<f7-popup ref="mapPicker" class="mappicker-popup" close-on-escape :opened="mapPickerOpen" @popup:opened="mapPickerOpened" @popup:closed="mapPickerClosed">
<f7-page>
<f7-navbar>
<f7-nav-left>
<f7-link icon-ios="f7:arrow_left" icon-md="material:arrow_back" icon-aurora="f7:arrow_left" popup-close></f7-link>
</f7-nav-left>
<f7-nav-title>{{configDescription.label}}</f7-nav-title>
<f7-nav-right>
<f7-link @click="updateValue(marker)">Done</f7-link>
</f7-nav-right>
</f7-navbar>
<l-map
v-if="showMap"
:zoom="zoom"
:center="center"
:options="mapOptions"
@click="mapClicked"
class="oh-map-page-lmap">
<l-tile-layer
:url="url"
:attribution="attribution"
/>
<l-marker :lat-lng="marker" />
</l-map>
</f7-page>
</f7-popup>
</ul>
</template>
<style lang="stylus">
</style>
<script>
// TODO: map
import { latLng, Icon } from 'leaflet'
import { LMap, LTileLayer, LMarker } from 'vue2-leaflet'
import 'leaflet/dist/leaflet.css'
delete Icon.Default.prototype._getIconUrl
Icon.Default.mergeOptions({
iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
iconUrl: require('leaflet/dist/images/marker-icon.png'),
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
})
export default {
props: ['configDescription', 'value'],
components: {
LMap,
LTileLayer,
LMarker
},
data () {
return {
mapOpened: false
mapPickerOpen: false,
zoom: 13,
center: latLng(52.5200066, 13.4049540),
// url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
url: `https://a.basemaps.cartocdn.com/${this.$f7.data.themeOptions.dark}_all/{z}/{x}/{y}.png`,
attribution: '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/attribution/">CARTO</a>',
marker: latLng(52.5200066, 13.4049540),
showMap: false,
mapOptions: {
zoomSnap: 0.5
}
}
},
methods: {
updateValue (event) {
if (event.lat && event.lng) {
this.$emit('input', [event.lat, event.lng].join(','))
} else {
this.$emit('input', event.target.value)
}
this.mapPickerOpen = false
},
openMap () {
this.$f7.dialog.alert('Map location picker not implemented')
mapPickerClosed () {
this.showMap = false
this.mapPickerOpen = false
},
mapPickerOpened () {
this.$nextTick(() => { this.showMap = true })
},
mapClicked (evt) {
this.marker = latLng(evt.latlng)
},
openMapPicker () {
this.mapPickerOpen = true
}
}
}

View File

@ -17,6 +17,15 @@
height 100%
width 100%
.CodeMirror-line
line-height 1.3
.cm-lkcampbell-indent-guides
margin-top -5px
background-repeat repeat-y
background-image url("data:image/svg+xml;utf8,<?xml version='1.0' encoding='UTF-8'?><svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='1px' height='2px'><rect width='1' height='1' style='fill:%2377777777' /></svg>")
position relative
.CodeMirror-hints
z-index 999999
.CodeMirror-Tern-tooltip
@ -68,6 +77,42 @@ import tern from 'tern'
import EcmascriptDefs from 'tern/defs/ecmascript.json'
import OpenhabDefs from '@/assets/openhab-tern-defs.json'
// Adapted from https://github.com/lkcampbell/brackets-indent-guides (MIT)
var indentGuidesOverlay = {
token: function (stream, state) {
var char = '',
colNum = 0,
spaceUnits = 0,
isTabStart = false
char = stream.next()
colNum = stream.column()
if (colNum === 0) {
return null
}
if (char === '\t') {
return 'lkcampbell-indent-guides'
}
if (char !== ' ') {
stream.skipToEnd()
return null
}
spaceUnits = 2
isTabStart = !(colNum % spaceUnits)
if ((char === ' ') && (isTabStart)) {
return 'lkcampbell-indent-guides'
} else {
return null
}
},
flattenSpans: false
}
export default {
components: {
codemirror
@ -125,6 +170,8 @@ export default {
}
extraKeys['Shift-Tab'] = 'indentLess'
cm.setOption('extraKeys', extraKeys)
cm.addOverlay(indentGuidesOverlay)
cm.refresh()
},
onCmCodeChange (newCode) {
this.$emit('input', newCode)

View File

@ -12,10 +12,10 @@
<generic-widget-component class="magic-grid-item" :context="childContext(slotComponent)" v-for="(slotComponent, idx) in context.component.slots.default" :key="idx" v-on="$listeners" />
</template>
</magic-grid>
<div v-else class="oh-columns-grid">
<div v-for="(slotComponent, idx) in context.component.slots.default" :key="idx" class="oh-column-item">
<f7-menu v-if="context.editmode" class="configure-layout-menu" :style="{ 'z-index': 100 - context.component.slots.default.indexOf(slotComponent) }">
<f7-menu-item style="margin-left: auto" icon-f7="slider_horizontal_below_rectangle" dropdown>
<div v-else class="oh-masonry">
<div v-for="(slotComponent, idx) in context.component.slots.default" :key="idx" class="oh-masonry-item" :style="{ 'min-height': dropdownMenuOpened === idx ? 'calc(10 * var(--f7-menu-dropdown-item-height))' : undefined, 'z-index': 100 - context.component.slots.default.indexOf(slotComponent) }">
<f7-menu v-if="context.editmode" class="configure-layout-menu">
<f7-menu-item style="margin-left: auto" icon-f7="slider_horizontal_below_rectangle" dropdown @menu:opened="dropdownMenuOpened = idx" @menu:closed="dropdownMenuOpened = null">
<f7-menu-dropdown right>
<f7-menu-dropdown-item @click="context.editmode.configureWidget(slotComponent, context)" href="#" text="Configure Widget"></f7-menu-dropdown-item>
<f7-menu-dropdown-item @click="context.editmode.editWidgetCode(slotComponent, context)" href="#" text="Edit YAML"></f7-menu-dropdown-item>
@ -45,6 +45,11 @@ export default {
mixins: [mixin],
components: {
OhPlaceholderWidget
},
data () {
return {
dropdownMenuOpened: null
}
}
}
</script>
@ -58,16 +63,18 @@ export default {
.magic-grid-item
width calc(100% - 2 * var(--oh-grid-gap))
.oh-columns-grid
.oh-masonry
column-count 6
column-width 300px
column-gap 0
margin-left auto
margin-right auto
min-height 300px
.oh-column-item
.oh-masonry-item
width 100%
display inline-block
.list
z-index inherit
&.placeholder
width calc(100% - 16px)

View File

@ -8,6 +8,8 @@
<style lang="stylus">
.placeholder-widget
width 100%
display inline-block
opacity 0.5
height calc(2*3rem + 50px)
.button

View File

@ -0,0 +1,116 @@
<template>
<l-circle ref="marker" v-if="center && radius" :key="markerKey" :lat-lng="center" :radius="radius" v-bind="markerConfig" @update:latLng="$emit('update', $event)" @click="performAction">
<l-tooltip v-if="config.label">
{{config.label}}
</l-tooltip>
</l-circle>
</template>
<script>
import mixin from '../widget-mixin'
import { LCircle, LTooltip } from 'vue2-leaflet'
import { actionGroup, actionProps, actionsMixin } from '../widget-actions'
export default {
mixins: [mixin, actionsMixin],
components: {
LCircle,
LTooltip
},
widget: {
name: 'oh-map-circle-marker',
label: 'Circle Marker',
icon: 'map_pin_ellipse',
description: 'A circle on a map, to represent a radius',
props: {
parameterGroups: [
actionGroup(null, 'Action to perform when the circle is clicked')
],
parameters: [
...actionProps(),
{
name: 'label',
label: 'Label',
type: 'TEXT',
description: 'The label on the marker'
},
{
name: 'item',
label: 'Item',
type: 'TEXT',
context: 'item',
description: 'The Location item this circle will be centered on'
},
{
name: 'location',
label: 'Fixed location',
type: 'TEXT',
context: 'location',
description: 'The fixed center of the circle if no item is configured or its coordinates are invalid'
},
{
name: 'radiusItem',
label: 'Radius Item',
type: 'TEXT',
context: 'item',
description: 'The item whose state holds the radius of the circle, in meters'
},
{
name: 'radius',
label: 'Fixed radius',
type: 'DECIMAL',
description: 'The fixed radius of the circle in meters if no item is configured or its state is invalid'
},
{
name: 'color',
label: 'Circle color',
type: 'TEXT',
description: 'The color of the circle (e.g. "blue", "red", "yellow"...)'
}
]
}
},
data () {
return {
markerKey: this.$f7.utils.id()
}
},
computed: {
center () {
if (this.config.item) {
const itemState = this.context.store[this.config.item]
if (itemState && itemState.state.indexOf(',') > 0) {
return itemState.state.split(',')
}
}
if (this.config.location) {
return this.config.location.split(',')
}
return null
},
radius () {
if (this.config.radiusItem) {
const itemState = this.context.store[this.config.radiusItem]
if (itemState && !isNaN(parseFloat(itemState.state))) {
return parseFloat(itemState.state)
}
}
if (this.config.radius) {
return parseFloat(this.config.radius)
}
return null
},
markerConfig () {
if (!this.config) return {}
let ret = {}
Object.assign(ret, this.config)
delete ret.latLng
delete ret.radius
return ret
}
},
mounted () {
this.$emit('update', this.center, this.radius)
}
}
</script>

View File

@ -0,0 +1,104 @@
<template>
<l-marker ref="marker" v-if="coords" :key="markerKey" :lat-lng="coords" @update:latLng="$emit('update', $event)" @click="performAction">
<l-tooltip v-if="config.label">
{{config.label}}
</l-tooltip>
<l-icon v-if="icon"
:icon-size="[40,40]"
:icon-url="icon"
/>
</l-marker>
</template>
<script>
import mixin from '../widget-mixin'
import { LMarker, LTooltip, LIcon } from 'vue2-leaflet'
import { actionGroup, actionProps, actionsMixin } from '../widget-actions'
export default {
mixins: [mixin, actionsMixin],
components: {
LMarker,
LTooltip,
LIcon
},
widget: {
name: 'oh-map-marker',
label: 'Map Marker',
icon: 'map_pin',
description: 'A marker on a map',
props: {
parameterGroups: [
actionGroup(null, 'Action to perform when the marker is clicked')
],
parameters: [
...actionProps(),
{
name: 'label',
label: 'Label',
type: 'TEXT',
description: 'The label on the marker'
},
{
name: 'item',
label: 'Item',
type: 'TEXT',
context: 'item',
description: 'The Location item this marker will show'
},
{
name: 'location',
label: 'Fixed location',
type: 'TEXT',
context: 'location',
description: 'The fixed location to show if no item is configured or its coordinates are invalid'
},
{
name: 'icon',
label: 'Icon',
type: 'TEXT',
description: 'Use <code>oh:iconName</code> (<a class="external text-color-blue" target="_blank" href="https://www.openhab.org/docs/configuration/iconsets/classic/">openHAB icon</a>)'
}
]
}
},
data () {
return {
markerKey: this.$f7.utils.id()
}
},
computed: {
coords () {
if (this.config.item) {
const itemState = this.context.store[this.config.item]
if (itemState && itemState.state.indexOf(',') > 0) {
return itemState.state.split(',')
}
}
if (this.config.location) {
return this.config.location.split(',')
}
return null
},
hasIcon () {
return this.config.icon
}
},
asyncComputed: {
icon () {
if (this.config.icon && this.config.icon.indexOf('oh:') === 0) {
return this.$oh.media.getIcon(this.config.icon.substring(3)).then((icon) => {
// debugger
this.markerKey = this.$f7.utils.id()
this.$emit('update')
return icon
})
}
return null
}
},
mounted () {
this.$emit('update', this.coords)
}
}
</script>

View File

@ -0,0 +1,95 @@
<template>
<l-map
ref="map"
v-if="showMap"
:zoom="zoom"
:center="center"
:options="mapOptions"
class="oh-map-page-lmap"
:class="{ 'with-tabbar': context.tab }"
@update:center="centerUpdate"
@update:zoom="zoomUpdate">
<l-tile-layer
:url="url"
:attribution="attribution"
/>
<l-feature-group ref="featureGroup" v-if="context.component.slots">
<component v-for="(marker, idx) in context.component.slots.default" :key="idx"
:is="markerComponent(marker)" :context="childContext(marker)" @update="onMarkerUpdate" />
</l-feature-group>
</l-map>
</template>
<style lang="stylus">
.oh-map-page-lmap
position absolute
top calc(var(--f7-navbar-height))
height calc(100% - var(--f7-navbar-height)) !important
&.with-tabbar
height calc(100% - var(--f7-navbar-height) - var(--f7-tabbar-labels-height)) !important
</style>
<script>
import mixin from '../widget-mixin'
import { latLng, Icon } from 'leaflet'
import { LMap, LTileLayer, LFeatureGroup } from 'vue2-leaflet'
import 'leaflet/dist/leaflet.css'
import OhMapMarker from './oh-map-marker.vue'
import OhMapCircleMarker from './oh-map-circle-marker.vue'
delete Icon.Default.prototype._getIconUrl
Icon.Default.mergeOptions({
iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
iconUrl: require('leaflet/dist/images/marker-icon.png'),
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
})
export default {
mixins: [mixin],
components: {
LMap,
LTileLayer,
LFeatureGroup,
OhMapMarker,
OhMapCircleMarker
},
data () {
return {
zoom: 13,
currentZoom: 13,
currentCenter: null,
center: latLng(52.5200066, 13.4049540),
// url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
url: `https://a.basemaps.cartocdn.com/${this.$f7.data.themeOptions.dark}_all/{z}/{x}/{y}.png`,
attribution: '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/attribution/">CARTO</a>',
showMap: true,
mapOptions: {
zoomSnap: 0.5
}
}
},
methods: {
zoomUpdate (zoom) {
this.currentZoom = zoom
},
centerUpdate (center) {
this.currentCenter = center
},
markerComponent (marker) {
switch (marker.component) {
case 'oh-map-marker':
return OhMapMarker
case 'oh-map-circle-marker':
return OhMapCircleMarker
default:
return null
}
},
onMarkerUpdate () {
this.$refs.map.mapObject.fitBounds(this.$refs.featureGroup.mapObject.getBounds().pad(0.5))
this.$refs.map.mapObject.invalidateSize()
}
}
}
</script>

View File

@ -1,9 +1,6 @@
<template>
<f7-popover :target="el">
<oh-layout-page v-if="page && page.component === 'oh-layout-page'" :context="context"
class="layout-page"
:class="{notready: !ready}" />
<generic-widget-component v-else-if="widget" :context="context" :class="{notready: !ready}" />
<component :is="componentType" :context="context" :class="{notready: !ready}" />
</f7-popover>
</template>
@ -41,6 +38,21 @@ export default {
},
ready () {
return this.page || this.widget
},
componentType () {
if (this.page) {
switch (this.page.component) {
case 'oh-layout-page':
return OhLayoutPage
case 'oh-map-page':
return () => import('@/components/widgets/map/oh-map-page.vue')
default:
return null
}
} else if (this.widget) {
return 'generic-widget-component'
}
return null
}
}
}

View File

@ -4,10 +4,16 @@
<f7-navbar :title="(page) ? page.config.label : ''" back-link="Back">
</f7-navbar>
<oh-layout-page v-if="page && page.component === 'oh-layout-page'" :context="context"
class="layout-page"
:class="{notready: !ready}" />
<generic-widget-component v-else-if="widget" :context="context" :class="{notready: !ready}" />
<f7-toolbar tabbar labels bottom v-if="page && page.component === 'oh-tabs-page'">
<f7-link v-for="(tab, idx) in page.slots.default" :key="idx" tab-link @click="currentTab = idx" :tab-link-active="currentTab === idx" :icon-ios="tab.config.icon" :icon-md="tab.config.icon" :icon-aurora="tab.config.icon" :text="tab.config.title"></f7-link>
</f7-toolbar>
<f7-tabs v-if="page && page.component === 'oh-tabs-page'" :class="{notready: !ready}">
<f7-tab v-for="(tab, idx) in page.slots.default" :key="idx" :tab-active="currentTab === idx">
<component v-if="currentTab === idx" :is="tabComponent(tab)" :context="tabContext(tab)" />
</f7-tab>
</f7-tabs>
<component v-else :is="componentType" :context="context" :class="{notready: !ready}" />
</f7-page>
</f7-popup>
@ -23,11 +29,13 @@ import OhLayoutPage from '@/components/widgets/layout/oh-layout-page.vue'
export default {
components: {
OhLayoutPage
OhLayoutPage,
'oh-map-page': () => import('@/components/widgets/map/oh-map-page.vue')
},
props: ['uid', 'modalParams'],
data () {
return {
currentTab: 0
}
},
computed: {
@ -46,6 +54,47 @@ export default {
},
ready () {
return this.page || this.widget
},
componentType () {
if (this.page) {
switch (this.page.component) {
case 'oh-layout-page':
return OhLayoutPage
case 'oh-map-page':
return 'oh-map-page'
default:
return null
}
} else if (this.widget) {
return 'generic-widget-component'
}
return null
}
},
methods: {
tabContext (tab) {
const page = this.$store.getters.page(tab.config.page.replace('page:', ''))
return {
component: page,
tab: tab,
props: tab.config.pageConfig,
store: this.$store.getters.trackedItems
}
},
pageComponent (page) {
if (!page) return null
switch (page.component) {
case 'oh-layout-page':
return OhLayoutPage
case 'oh-map-page':
return 'oh-map-page'
default:
return null
}
},
tabComponent (tab) {
const page = this.$store.getters.page(tab.config.page.replace('page:', ''))
return this.pageComponent(page)
}
}
}

View File

@ -5,12 +5,7 @@
<div class="right"><f7-link sheet-close>Close</f7-link></div>
</f7-toolbar>
<f7-page v-if="page && page.component === 'oh-layout-page'">
<oh-layout-page :context="context"
class="layout-page"
:class="{notready: !ready}" />
</f7-page>
<generic-widget-component v-else-if="widget" :context="context" :class="{notready: !ready}" />
<component :is="componentType" :context="context" :class="{notready: !ready}" />
</f7-sheet>
</template>
@ -47,6 +42,21 @@ export default {
},
ready () {
return this.page || this.widget
},
componentType () {
if (this.page) {
switch (this.page.component) {
case 'oh-layout-page':
return OhLayoutPage
case 'oh-map-page':
return () => import('@/components/widgets/map/oh-map-page.vue')
default:
return null
}
} else if (this.widget) {
return 'generic-widget-component'
}
return null
}
}
}

View File

@ -36,7 +36,11 @@ export default {
evalConfig[key] = expr.eval(this.exprAst[key], {
items: this.context.store,
props: this.context.props,
Math: Math
Math: Math,
theme: this.$theme,
themeOptions: this.$f7.data.themeOptions,
device: this.$device,
JSON: JSON
})
} catch (e) {
evalConfig[key] = e

View File

@ -2,8 +2,8 @@ import HomePage from '../pages/home.vue'
import AboutPage from '../pages/about.vue'
import NotFoundPage from '../pages/not-found.vue'
import SitemapPage from '../pages/page/sitemap.vue'
import LayoutPage from '../pages/page/layout-page.vue'
import SitemapViewPage from '../pages/page/sitemap-view.vue'
import PageViewPage from '../pages/page/page-view.vue'
import SetupWizard from '../pages/wizards/setup-wizard.vue'
import SetupWizardPage from '../pages/wizards/setup-wizard-page.vue'
@ -33,8 +33,6 @@ import RuleEditPage from '../pages/settings/rules/rule-edit.vue'
import RuleConfigureModulePage from '../pages/settings/rules/rule-configure-module.vue'
import PagesListPage from '../pages/settings/pages/pages-list.vue'
import SitemapEditPage from '../pages/settings/pages/sitemap/sitemap-edit.vue'
import LayoutEditPage from '../pages/settings/pages/layout/layout-edit.vue'
// import SchedulePage from '../pages/settings/schedule/schedule.vue'
@ -62,11 +60,11 @@ export default [
},
{
path: '/page/:uid',
component: LayoutPage
component: PageViewPage
},
{
path: '/sitemap/:sitemapId/:pageId',
component: SitemapPage
component: SitemapViewPage
},
{
path: '/about/',
@ -121,30 +119,23 @@ export default [
component: PagesListPage,
routes: [
{
path: 'sitemap/add',
component: SitemapEditPage,
options: {
path: ':type/:uid',
async (routeTo, routeFrom, resolve, reject) {
// dynamic import component; returns promise
const editorComponent = () => import(`../pages/settings/pages/${routeTo.params.type}/${routeTo.params.type}-edit.vue`)
// resolve promise
editorComponent().then((vc) => {
// resolve with component
resolve({
component: vc.default
},
(routeTo.params.uid === 'add') ? {
props: {
createMode: true
}
} : {})
})
}
},
{
path: 'sitemap/:uid',
component: SitemapEditPage
},
{
path: 'layout/add',
component: LayoutEditPage,
options: {
props: {
createMode: true
}
}
},
{
path: 'layout/:uid',
component: LayoutEditPage
}
]
},

View File

@ -1,79 +0,0 @@
<template>
<f7-page @page:afterin="onPageAfterIn" @page:beforeout="onPageBeforeOut" hide-bars-on-scroll>
<f7-navbar :back-link="(deep) ? 'Back' : undefined">
<f7-nav-left v-if="!deep">
<f7-link icon-ios="f7:menu" icon-aurora="f7:menu" icon-md="material:menu" panel-open="left"></f7-link>
</f7-nav-left>
<f7-nav-title>{{(ready) ? page.config.label : ''}}</f7-nav-title>
<f7-nav-right>
<f7-link icon-md="material:edit" :href="'/settings/pages/' + pageType + '/' + uid">{{ $theme.md ? '' : 'Edit' }}</f7-link>
</f7-nav-right>
</f7-navbar>
<oh-layout-page v-if="page && pageType === 'layout'" :context="context"
class="layout-page"
:class="{notready: !ready}"
@command="onCommand" />
</f7-page>
</template>
<style lang="stylus">
.notready
visibility hidden
</style>
<script>
import OhLayoutPage from '@/components/widgets/layout/oh-layout-page.vue'
export default {
components: {
OhLayoutPage
},
props: ['uid', 'deep'],
data () {
return {
// ready: false,
loading: false
// page: {}
}
},
computed: {
context () {
return {
component: this.page,
store: this.$store.getters.trackedItems
}
},
page () {
return this.$store.getters.page(this.uid)
},
pageType () {
switch (this.page.component) {
case 'oh-layout-page':
return 'layout'
default:
console.warn('Unknown page type!')
return 'unknown'
}
},
ready () {
return this.page
}
},
methods: {
onPageAfterIn () {
this.$store.dispatch('startTrackingStates')
this.load()
},
onPageBeforeOut () {
this.$store.dispatch('stopTrackingStates')
},
onCommand (itemName, command) {
this.$store.dispatch('sendCommand', { itemName, command })
},
load () {
}
}
}
</script>

View File

@ -0,0 +1,117 @@
<template>
<f7-page @page:afterin="onPageAfterIn" @page:beforeout="onPageBeforeOut" hide-bars-on-scroll>
<f7-navbar :back-link="(deep) ? 'Back' : undefined">
<f7-nav-left v-if="!deep">
<f7-link icon-ios="f7:menu" icon-aurora="f7:menu" icon-md="material:menu" panel-open="left"></f7-link>
</f7-nav-left>
<f7-nav-title>{{(ready) ? page.config.label : ''}}</f7-nav-title>
<f7-nav-right>
<f7-link icon-md="material:edit" :href="'/settings/pages/' + pageType + '/' + uid">{{ $theme.md ? '' : 'Edit' }}</f7-link>
</f7-nav-right>
</f7-navbar>
<f7-toolbar tabbar labels bottom v-if="page && pageType === 'tabs'">
<f7-link v-for="(tab, idx) in page.slots.default" :key="idx" tab-link @click="currentTab = idx" :tab-link-active="currentTab === idx" :icon-ios="tab.config.icon" :icon-md="tab.config.icon" :icon-aurora="tab.config.icon" :text="tab.config.title"></f7-link>
</f7-toolbar>
<f7-tabs v-if="page && pageType === 'tabs'" :class="{notready: !ready}">
<f7-tab v-for="(tab, idx) in page.slots.default" :key="idx" :tab-active="currentTab === idx">
<component v-if="currentTab === idx" :is="tabComponent(tab)" :context="tabContext(tab)" @command="onCommand" />
</f7-tab>
</f7-tabs>
<component :is="pageComponent(page)" v-if="page" :context="context" :class="{notready: !ready}" @command="onCommand" />
</f7-page>
</template>
<style lang="stylus">
.notready
visibility hidden
</style>
<script>
import OhLayoutPage from '@/components/widgets/layout/oh-layout-page.vue'
export default {
components: {
OhLayoutPage,
'oh-map-page': () => import('@/components/widgets/map/oh-map-page.vue')
},
props: ['uid', 'deep'],
data () {
return {
currentTab: 0,
// ready: false,
loading: false
// page: {}
}
},
computed: {
context () {
return {
component: this.page,
store: this.$store.getters.trackedItems
}
},
page () {
return this.$store.getters.page(this.uid)
},
pageType () {
if (!this.page) return null
switch (this.page.component) {
case 'oh-layout-page':
return 'layout'
case 'oh-map-page':
return 'map'
case 'oh-tabs-page':
return 'tabs'
default:
console.warn('Unknown page type!')
return 'unknown'
}
},
ready () {
return this.page
}
},
methods: {
onPageAfterIn () {
this.$store.dispatch('startTrackingStates')
this.load()
},
onPageBeforeOut () {
this.$store.dispatch('stopTrackingStates')
},
onCommand (itemName, command) {
this.$store.dispatch('sendCommand', { itemName, command })
},
load () {
},
tabContext (tab) {
const page = this.$store.getters.page(tab.config.page.replace('page:', ''))
return {
component: page,
tab: tab,
props: tab.config.pageConfig,
store: this.$store.getters.trackedItems
}
},
pageComponent (page) {
if (!page) return null
switch (page.component) {
case 'oh-layout-page':
return OhLayoutPage
case 'oh-map-page':
return 'oh-map-page'
default:
return null
}
},
tabComponent (tab) {
const page = this.$store.getters.page(tab.config.page.replace('page:', ''))
return this.pageComponent(page)
}
}
}
</script>

View File

@ -1,5 +1,5 @@
<template>
<f7-page @page:afterin="onPageAfterIn" @page:beforeout="onPageBeforeOut">
<f7-page @page:afterin="onPageAfterIn" @page:beforeout="onPageBeforeOut" class="layout-editor">
<f7-navbar :title="(!ready) ? '' : (createMode) ? 'Create layout page' : page.config.label" back-link="Back" no-hairline>
<f7-nav-right>
<f7-link @click="save()" v-if="$theme.md" icon-md="material:save" icon-only></f7-link>
@ -117,12 +117,15 @@
position absolute
top 80%
white-space pre-wrap
.menu-dropdown-content
z-index 2000
.layout-editor-design-tab
.layout-page
.oh-columns-grid
.oh-masonry
z-index inherit
padding-bottom 5rem
.layout-editor
.page-content
padding-bottom 5rem
z-index inherit
</style>
<script>

View File

@ -0,0 +1,477 @@
<template>
<f7-page @page:afterin="onPageAfterIn" @page:beforeout="onPageBeforeOut" class="map-editor">
<f7-navbar :title="(!ready) ? '' : (createMode) ? 'Create map page' : page.config.label" back-link="Back" no-hairline>
<f7-nav-right>
<f7-link @click="save()" v-if="$theme.md" icon-md="material:save" icon-only></f7-link>
<f7-link @click="save()" v-if="!$theme.md">Save<span v-if="$device.desktop">&nbsp;(Ctrl-S)</span></f7-link>
</f7-nav-right>
</f7-navbar>
<f7-toolbar tabbar position="top">
<f7-link @click="currentTab = 'design'; fromYaml()" :tab-link-active="currentTab === 'design'" class="tab-link">Design</f7-link>
<!-- <f7-link @click="currentTab = 'preview'" :tab-link-active="currentTab === 'preview'" class="tab-link">Preview</f7-link> -->
<f7-link @click="currentTab = 'code'; toYaml()" :tab-link-active="currentTab === 'code'" class="tab-link">Code</f7-link>
</f7-toolbar>
<f7-toolbar bottom class="toolbar-details" v-show="currentTab === 'design'">
<div style="margin-left: auto">
<f7-toggle :checked="previewMode" @toggle:change="(value) => previewMode = value"></f7-toggle> Run mode<span v-if="$device.desktop">&nbsp;(Ctrl-R)</span>
</div>
</f7-toolbar>
<f7-tabs class="map-editor-tabs">
<f7-tab id="design" class="map-editor-design-tab" @tab:show="() => this.currentTab = 'design'" :tab-active="currentTab === 'design'">
<f7-block v-if="!ready" class="text-align-center">
<f7-preloader></f7-preloader>
<div>Loading...</div>
</f7-block>
<f7-block class="block-narrow" v-if="ready && !previewMode">
<f7-col>
<f7-list inline-labels>
<f7-list-input label="ID" type="text" placeholder="ID" :value="page.uid" @input="page.uid = $event.target.value"
required validate pattern="[A-Za-z0-9_]+" error-message="Required. Alphanumeric &amp; underscores only" :disabled="!createMode">
</f7-list-input>
<f7-list-input label="Label" type="text" placeholder="Label" :value="page.config.label" @input="page.config.label = $event.target.value" clear-button>
</f7-list-input>
<f7-list-item title="Show on sidebar">
<f7-toggle slot="after" :checked="page.config.sidebar" @toggle:change="page.config.sidebar = $event"></f7-toggle>
</f7-list-item>
<f7-list-input label="Sidebar order" type="number" placeholder="Assign order index to rearrange pages on sidebar" :value="page.config.order" @input="page.config.order = $event.target.value" clear-button>
</f7-list-input>
</f7-list>
</f7-col>
</f7-block>
<f7-block class="block-narrow" v-if="ready && !previewMode">
<f7-col>
<f7-block-title>Markers</f7-block-title>
<f7-menu v-if="clipboardType === 'oh-map-marker'">
<f7-menu-item style="margin-left: auto" icon-f7="map" dropdown>
<f7-menu-dropdown right>
<f7-menu-dropdown-item @click="pasteWidget(page, null)" href="#" text="Paste"></f7-menu-dropdown-item>
</f7-menu-dropdown>
</f7-menu-item>
</f7-menu>
<f7-list media-list>
<f7-list-item media-item v-for="(marker, idx) in page.slots.default" :key="idx"
:title="marker.config.label" :subtitle="marker.config.item || marker.config.location">
<oh-icon v-if="marker.config.icon && marker.config.icon.indexOf('oh:') === 0" slot="media" :icon="marker.config.icon.substring(3)" height="32" width="32" />
<f7-icon v-else slot="media" :f7="markerDefaultIcon(marker)" :size="32" />
<f7-menu slot="content-start">
<f7-menu-item icon-f7="list_bullet" dropdown>
<f7-menu-dropdown>
<f7-menu-dropdown-item @click="configureWidget(marker, { component: page })" href="#" text="Configure marker"></f7-menu-dropdown-item>
<f7-menu-dropdown-item @click="editWidgetCode(marker, { component: page })" href="#" text="Edit YAML"></f7-menu-dropdown-item>
<f7-menu-dropdown-item divider></f7-menu-dropdown-item>
<f7-menu-dropdown-item @click="cutWidget(marker, { component: page })" href="#" text="Cut"></f7-menu-dropdown-item>
<f7-menu-dropdown-item @click="copyWidget(marker, { component: page })" href="#" text="Copy"></f7-menu-dropdown-item>
<f7-menu-dropdown-item divider></f7-menu-dropdown-item>
<f7-menu-dropdown-item @click="moveWidgetUp(marker, { component: page })" href="#" text="Move Up"></f7-menu-dropdown-item>
<f7-menu-dropdown-item @click="moveWidgetDown(marker, { component: page })" href="#" text="Move Down"></f7-menu-dropdown-item>
<f7-menu-dropdown-item divider></f7-menu-dropdown-item>
<f7-menu-dropdown-item @click="removeWidget(marker, { component: page })" href="#" text="Remove marker"></f7-menu-dropdown-item>
</f7-menu-dropdown>
</f7-menu-item>
</f7-menu>
</f7-list-item>
<f7-list-button color="blue" title="Add marker" @click="addWidget(page, 'oh-map-marker')" />
<f7-list-button color="blue" title="Add circle marker" @click="addWidget(page, 'oh-map-circle-marker')" />
<!-- <f7-list-button color="blue" title="Add marker" @click="addWidget(page, 'oh-map-radius')" /> -->
</f7-list>
</f7-col>
</f7-block>
<oh-map-page class="map-page" v-else-if="ready && previewMode" :context="context" :key="pageKey" />
</f7-tab>
<!-- <f7-tab id="preview" class="map-editor-preview-tab" @tab:show="() => this.currentTab = 'preview'" :tab-active="currentTab === 'preview'">
</f7-tab> -->
<f7-tab id="code" @tab:show="() => { this.currentTab = 'code' }" :tab-active="currentTab === 'code'">
<editor v-if="currentTab === 'code'" class="page-code-editor" mode="text/x-yaml" :value="pageYaml" @input="(value) => pageYaml = value" />
<pre class="yaml-message padding-horizontal" :class="[yamlError === 'OK' ? 'text-color-green' : 'text-color-red']">{{yamlError}}</pre>
</f7-tab>
</f7-tabs>
<f7-popup ref="widgetConfig" class="widgetconfig-popup" close-on-escape :opened="widgetConfigOpened" @popup:closed="widgetConfigClosed">
<f7-page v-if="currentComponent && currentWidget">
<f7-navbar>
<f7-nav-left>
<f7-link icon-ios="f7:arrow_left" icon-md="material:arrow_back" icon-aurora="f7:arrow_left" popup-close></f7-link>
</f7-nav-left>
<f7-nav-title>Edit {{currentWidget.label || currentWidget.uid}}</f7-nav-title>
<f7-nav-right>
<f7-link @click="updateWidgetConfig">Done</f7-link>
</f7-nav-right>
</f7-navbar>
<f7-block v-if="currentWidget.props">
<f7-col>
<config-sheet
:parameterGroups="currentWidget.props.parameterGroups || []"
:parameters="currentWidget.props.parameters || []"
:configuration="currentComponentConfig"
@updated="dirty = true"
/>
</f7-col>
</f7-block>
</f7-page>
</f7-popup>
<f7-popup ref="widgetCode" class="widgetcode-popup" close-on-escape :opened="widgetCodeOpened" @popup:closed="widgetCodeClosed">
<f7-page v-if="currentComponent && widgetCodeOpened">
<f7-navbar>
<f7-nav-left>
<f7-link icon-ios="f7:arrow_left" icon-md="material:arrow_back" icon-aurora="f7:arrow_left" popup-close></f7-link>
</f7-nav-left>
<f7-nav-title>Edit Widget Code</f7-nav-title>
<f7-nav-right>
<f7-link @click="updateWidgetCode">Done</f7-link>
</f7-nav-right>
</f7-navbar>
<editor class="page-code-editor" mode="text/x-yaml" :value="widgetYaml" @input="(value) => widgetYaml = value" />
<pre class="yaml-message padding-horizontal" :class="[widgetYamlError === 'OK' ? 'text-color-green' : 'text-color-red']">{{widgetYamlError}}</pre>
</f7-page>
</f7-popup>
</f7-page>
</template>
<style lang="stylus">
.page-code-editor.vue-codemirror
display block
top calc(var(--f7-navbar-height) + var(--f7-tabbar-height))
height calc(80% - 2*var(--f7-navbar-height))
width 100%
.yaml-message
display block
position absolute
top 80%
white-space pre-wrap
.map-editor
.oh-map-page-lmap
top calc(var(--f7-navbar-height) + var(--f7-toolbar-height)) !important
height calc(100% - var(--f7-navbar-height) - 2 * var(--f7-toolbar-height)) !important
</style>
<script>
import YAML from 'yaml'
// import OhMapPage from '@/components/widgets/map/oh-map-page.vue'
import OhMapMarker from '@/components/widgets/map/oh-map-marker.vue'
import OhMapCircleMarker from '@/components/widgets/map/oh-map-circle-marker.vue'
// const ConfigurableWidgets = {
// 'oh-map-marker': () => import('@/components/widgets/map/oh-map-marker.vue'),
// 'oh-map-circle-marker': () => import('@/components/widgets/map/oh-map-circle-marker.vue')
// }
const ConfigurableWidgets = {
OhMapMarker,
OhMapCircleMarker
}
import ConfigSheet from '@/components/config/config-sheet.vue'
function uuidv4 () {
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
)
}
export default {
components: {
'editor': () => import('@/components/config/controls/script-editor.vue'),
'oh-map-page': () => import('@/components/widgets/map/oh-map-page.vue'),
ConfigSheet
},
props: ['createMode', 'uid'],
data () {
return {
pageReady: false,
loading: false,
page: {
uid: 'page_' + uuidv4().split('-')[0],
component: 'oh-map-page',
config: {},
slots: { default: [] }
},
pageKey: uuidv4(),
pageYaml: null,
previewMode: false,
currentTab: 'design',
clipboard: null,
clipboardType: null,
currentComponent: null,
currentComponentConfig: null,
currentWidget: null,
widgetConfigOpened: false,
widgetCodeOpened: false,
widgetYaml: null
}
},
computed: {
ready () {
return this.pageReady && this.$store.state.components.widgets != null
},
context () {
return {
component: this.page,
store: this.$store.getters.trackedItems,
// states: this.stateTracking.store,
editmode: (!this.previewMode) ? {
addWidget: this.addWidget,
configureWidget: this.configureWidget,
editWidgetCode: this.editWidgetCode,
cutWidget: this.cutWidget,
copyWidget: this.copyWidget,
pasteWidget: this.pasteWidget,
moveWidgetUp: this.moveWidgetUp,
moveWidgetDown: this.moveWidgetDown,
removeWidget: this.removeWidget
} : null,
clipboardtype: this.clipboardType
}
},
yamlError () {
if (this.currentTab !== 'code') return null
try {
YAML.parse(this.pageYaml, { prettyErrors: true })
return 'OK'
} catch (e) {
return e
}
},
widgetYamlError () {
if (!this.widgetCodeOpened) return null
try {
YAML.parse(this.widgetYaml, { prettyErrors: true })
return 'OK'
} catch (e) {
return e
}
}
},
methods: {
onPageAfterIn () {
if (window) {
window.addEventListener('keydown', this.keyDown)
}
this.$store.dispatch('startTrackingStates')
this.load()
},
onPageBeforeOut () {
if (window) {
window.removeEventListener('keydown', this.keyDown)
}
this.$store.dispatch('stopTrackingStates')
},
keyDown (ev) {
if (ev.ctrlKey || ev.metakKey) {
switch (ev.keyCode) {
case 82:
this.previewMode = !this.previewMode
ev.stopPropagation()
ev.preventDefault()
break
case 83:
this.save(!this.createMode)
ev.stopPropagation()
ev.preventDefault()
break
}
}
},
load () {
if (this.loading) return
this.loading = true
if (this.createMode) {
this.loading = false
this.pageReady = true
} else {
this.$oh.api.get('/rest/ui/components/ui:page/' + this.uid).then((data) => {
this.$set(this, 'page', data)
this.pageReady = true
this.loading = false
})
}
},
save (stay) {
if (!this.page.uid) {
this.$f7.dialog.alert('Please give an ID to the page')
return
}
if (!this.page.config.label) {
this.$f7.dialog.alert('Please give an label to the page')
return
}
if (!this.createMode && this.uid !== this.page.uid) {
this.$f7.dialog.alert('You cannot change the ID of an existing page. Duplicate it with the new ID then delete this one.')
return
}
const promise = (this.createMode)
? this.$oh.api.postPlain('/rest/ui/components/ui:page', JSON.stringify(this.page), 'text/plain', 'application/json')
: this.$oh.api.put('/rest/ui/components/ui:page/' + this.page.uid, this.page)
promise.then((data) => {
if (this.createMode) {
this.$f7.toast.create({
text: 'Page created',
destroyOnClose: true,
closeTimeout: 2000
}).open()
this.load()
} else {
this.$f7.toast.create({
text: 'Page updated',
destroyOnClose: true,
closeTimeout: 2000
}).open()
}
this.$f7.emit('sidebarRefresh', null)
if (!stay) this.$f7router.back()
}).catch((err) => {
this.$f7.toast.create({
text: 'Error while saving page: ' + err,
destroyOnClose: true,
closeTimeout: 2000
}).open()
})
},
markerDefaultIcon (marker) {
const widgetDefinition = Object.values(ConfigurableWidgets).find((c) => c.widget.name === marker.component)
if (widgetDefinition) {
return widgetDefinition.widget.icon
}
return null
},
addWidget (component, widgetType, parentContext, slot) {
if (!slot) slot = 'default'
if (!component.slots) component.slots = {}
if (!component.slots[slot]) component.slots[slot] = []
if (widgetType) {
component.slots[slot].push({
component: widgetType,
config: {},
slots: { default: [] }
})
this.forceUpdate()
}
},
widgetConfigClosed () {
this.currentComponent = null
this.currentWidget = null
this.widgetConfigOpened = false
},
updateWidgetConfig () {
this.$set(this.currentComponent, 'config', this.currentComponentConfig)
this.forceUpdate()
this.widgetConfigClosed()
},
widgetCodeClosed () {
this.currentComponent = null
this.currentWidget = null
this.widgetCodeOpened = false
},
updateWidgetCode () {
const updatedWidget = YAML.parse(this.widgetYaml)
this.$set(this.currentComponent, 'config', updatedWidget.config)
this.$set(this.currentComponent, 'slots', updatedWidget.slots)
this.forceUpdate()
this.widgetCodeClosed()
},
configureWidget (component, parentContext, forceComponentType) {
const componentType = forceComponentType || component.component
this.currentComponent = null
this.currentWidget = null
let widgetDefinition
if (componentType.indexOf('widget:') === 0) {
this.currentWidget = this.$store.getters.widget(componentType.substring(7))
} else {
widgetDefinition = Object.values(ConfigurableWidgets).find((w) => w.widget && w.widget.name === componentType)
if (!widgetDefinition) {
// widgetDefinition = Object.values(LayoutWidgets).find((w) => w.widget.name === component.component)
if (!widgetDefinition) {
console.warn('Widget not found: ' + componentType)
this.$f7.toast.create({
text: `This type of component cannot be configured: ${componentType}.`,
destroyOnClose: true,
closeTimeout: 3000,
closeButton: true,
closeButtonText: 'Edit YAML',
on: {
closeButtonClick: () => {
this.editWidgetCode(component, parentContext)
}
}
}).open()
return
}
}
this.currentWidget = widgetDefinition.widget
}
this.currentComponent = component
this.currentComponentConfig = JSON.parse(JSON.stringify(this.currentComponent.config))
this.widgetConfigOpened = true
},
editWidgetCode (component, parentContext, slot) {
if (slot && !component.slots) component.slots = {}
if (slot && !component.slots[slot]) component.slots[slot] = []
this.currentComponent = component
this.widgetYaml = YAML.stringify(component)
this.widgetCodeOpened = true
},
cutWidget (component, parentContext) {
this.copyWidget(component, parentContext)
this.removeWidget(component, parentContext)
},
copyWidget (component, parentContext) {
let newClipboard = JSON.stringify(component)
this.$set(this, 'clipboard', newClipboard)
this.clipboardType = component.component
},
pasteWidget (component, parentContext) {
if (!this.clipboard) return
component.slots.default.push(JSON.parse(this.clipboard))
this.forceUpdate()
},
moveWidgetUp (component, parentContext) {
let siblings = parentContext.component.slots.default
let pos = siblings.indexOf(component)
if (pos <= 0) return
siblings.splice(pos, 1)
siblings.splice(pos - 1, 0, component)
this.forceUpdate()
},
moveWidgetDown (component, parentContext) {
let siblings = parentContext.component.slots.default
let pos = siblings.indexOf(component)
if (pos >= siblings.length - 1) return
siblings.splice(pos, 1)
siblings.splice(pos + 1, 0, component)
this.forceUpdate()
},
removeWidget (component, parentContext) {
parentContext.component.slots.default.splice(parentContext.component.slots.default.indexOf(component), 1)
this.forceUpdate()
},
forceUpdate () {
this.pageKey = uuidv4()
},
toYaml () {
this.pageYaml = YAML.stringify({
markers: this.page.slots.default
})
},
fromYaml () {
try {
const updatedMarkers = YAML.parse(this.pageYaml)
this.$set(this.page.slots, 'default', updatedMarkers.markers)
this.forceUpdate()
return true
} catch (e) {
this.$f7.dialog.alert(e).open()
return false
}
}
}
}
</script>

View File

@ -71,6 +71,7 @@
:link="showCheckboxes ? null : getPageType(page).type + '/' + page.uid"
:title="page.config.label"
:subtitle="getPageType(page).label"
:footer="page.uid"
>
<div slot="subtitle">
<f7-chip v-for="tag in page.tags" :key="tag" :text="tag" media-bg-color="blue" style="margin-right: 6px">
@ -92,8 +93,9 @@
<f7-fab-buttons position="top">
<f7-fab-button fab-close label="Create sitemap" href="sitemap/add"><f7-icon f7="menu"></f7-icon></f7-fab-button>
<f7-fab-button fab-close label="Create layout" href="layout/add"><f7-icon f7="rectangle_grid_2x2"></f7-icon></f7-fab-button>
<!-- <f7-fab-button fab-close label="Create map view" href="add"><f7-icon f7="map"></f7-icon></f7-fab-button>
<f7-fab-button fab-close label="Create chart" href="add"><f7-icon f7="graph_square"></f7-icon></f7-fab-button>
<f7-fab-button fab-close label="Create tabbed page" href="tabs/add"><f7-icon f7="squares_below_rectangle"></f7-icon></f7-fab-button>
<f7-fab-button fab-close label="Create map view" href="map/add"><f7-icon f7="map"></f7-icon></f7-fab-button>
<!-- <f7-fab-button fab-close label="Create chart" href="add"><f7-icon f7="graph_square"></f7-icon></f7-fab-button>
<f7-fab-button fab-close label="Create floor plan" href="add"><f7-icon f7="layers"></f7-icon></f7-fab-button> -->
</f7-fab-buttons>
</f7-fab>
@ -112,7 +114,9 @@ export default {
showCheckboxes: false,
pageTypes: [
{ type: 'sitemap', label: 'Sitemap', componentType: 'Sitemap' },
{ type: 'layout', label: 'Layout', componentType: 'oh-layout-page' }
{ type: 'layout', label: 'Layout', componentType: 'oh-layout-page' },
{ type: 'tabs', label: 'Tabbed', componentType: 'oh-tabs-page' },
{ type: 'map', label: 'Map', componentType: 'oh-map-page' }
]
}
},
@ -130,6 +134,12 @@ export default {
switch (page.component) {
case 'Sitemap':
return 'menu'
case 'oh-layout-page':
return 'rectangle_grid_2x2'
case 'oh-tabs-page':
return 'squares_below_rectangle'
case 'oh-map-page':
return 'map'
default:
return 'tv'
}

View File

@ -0,0 +1,40 @@
// This component has no template, due to its special nature (has to add a tabbar to
// the host page) it'll simply be handled in the page renderers themselves
export default {
widget: {
name: 'oh-tab',
title: 'Tab',
description: 'Displays a page in a tab',
props: {
parameters: [
{
name: 'title',
label: 'Title',
type: 'TEXT',
description: 'The title of the tab'
},
{
name: 'icon',
label: 'Icon',
type: 'TEXT',
description: 'The icon on the tab: use <code>f7:iconName</code> (<a class="external text-color-blue" target="_blank" href="https://framework7.io/icons/">Framework7 icon</a>)'
},
{
name: 'page',
label: 'Page',
type: 'TEXT',
context: 'page',
description: 'The page to display'
},
{
name: 'pageConfig',
label: 'Page Configuration',
type: 'TEXT',
context: 'props',
description: 'The parameters (props) to configure the page, if any'
}
]
}
}
}

View File

@ -0,0 +1,456 @@
<template>
<f7-page @page:afterin="onPageAfterIn" @page:beforeout="onPageBeforeOut" class="tabs-editor">
<f7-navbar :title="(!ready) ? '' : (createMode) ? 'Create tabbed page' : page.config.label" back-link="Back" no-hairline>
<f7-nav-right>
<f7-link @click="save()" v-if="$theme.md" icon-md="material:save" icon-only></f7-link>
<f7-link @click="save()" v-if="!$theme.md">Save<span v-if="$device.desktop">&nbsp;(Ctrl-S)</span></f7-link>
</f7-nav-right>
</f7-navbar>
<f7-toolbar tabbar position="top">
<f7-link @click="currentTab = 'design'; fromYaml()" :tab-link-active="currentTab === 'design'" class="tab-link">Design</f7-link>
<!-- <f7-link @click="currentTab = 'preview'" :tab-link-active="currentTab === 'preview'" class="tab-link">Preview</f7-link> -->
<f7-link @click="currentTab = 'code'; toYaml()" :tab-link-active="currentTab === 'code'" class="tab-link">Code</f7-link>
</f7-toolbar>
<f7-toolbar bottom class="toolbar-details" v-show="currentTab === 'design'">
<!-- <div style="margin-left: auto">
<f7-toggle :checked="previewMode" @toggle:change="(value) => previewMode = value"></f7-toggle> Run mode<span v-if="$device.desktop">&nbsp;(Ctrl-R)</span>
</div> -->
</f7-toolbar>
<f7-tabs class="tabs-editor-tabs">
<f7-tab id="design" class="tabs-editor-design-tab" @tab:show="() => this.currentTab = 'design'" :tab-active="currentTab === 'design'">
<f7-block v-if="!ready" class="text-align-center">
<f7-preloader></f7-preloader>
<div>Loading...</div>
</f7-block>
<f7-block class="block-narrow" v-if="ready && !previewMode">
<f7-col>
<f7-list inline-labels>
<f7-list-input label="ID" type="text" placeholder="ID" :value="page.uid" @input="page.uid = $event.target.value"
required validate pattern="[A-Za-z0-9_]+" error-message="Required. Alphanumeric &amp; underscores only" :disabled="!createMode">
</f7-list-input>
<f7-list-input label="Label" type="text" placeholder="Label" :value="page.config.label" @input="page.config.label = $event.target.value" clear-button>
</f7-list-input>
<f7-list-item title="Show on sidebar">
<f7-toggle slot="after" :checked="page.config.sidebar" @toggle:change="page.config.sidebar = $event"></f7-toggle>
</f7-list-item>
<f7-list-input label="Sidebar order" type="number" placeholder="Assign order index to rearrange pages on sidebar" :value="page.config.order" @input="page.config.order = $event.target.value" clear-button>
</f7-list-input>
</f7-list>
</f7-col>
</f7-block>
<f7-block class="block-narrow" v-if="ready">
<f7-col>
<f7-block-title>Tabs</f7-block-title>
<f7-menu v-if="clipboardType === 'oh-tab'">
<f7-menu-item style="margin-left: auto" icon-f7="squares_below_rectangle" dropdown>
<f7-menu-dropdown right>
<f7-menu-dropdown-item @click="pasteWidget(page, null)" href="#" text="Paste"></f7-menu-dropdown-item>
</f7-menu-dropdown>
</f7-menu-item>
</f7-menu>
<f7-list media-list>
<f7-list-item media-item v-for="(tab, idx) in page.slots.default" :key="idx"
:title="tab.config.title" :subtitle="tab.config.page">
<f7-icon slot="media" :ios="tab.config.icon" :md="tab.config.icon" :aurora="tab.config.icon" color="gray" />
<f7-menu slot="content-start">
<f7-menu-item icon-f7="list_bullet" dropdown>
<f7-menu-dropdown>
<f7-menu-dropdown-item @click="configureWidget(tab, { component: page })" href="#" text="Configure Tab"></f7-menu-dropdown-item>
<f7-menu-dropdown-item @click="editWidgetCode(tab, { component: page })" href="#" text="Edit YAML"></f7-menu-dropdown-item>
<f7-menu-dropdown-item divider></f7-menu-dropdown-item>
<f7-menu-dropdown-item @click="cutWidget(tab, { component: page })" href="#" text="Cut"></f7-menu-dropdown-item>
<f7-menu-dropdown-item @click="copyWidget(tab, { component: page })" href="#" text="Copy"></f7-menu-dropdown-item>
<f7-menu-dropdown-item divider></f7-menu-dropdown-item>
<f7-menu-dropdown-item @click="moveWidgetUp(tab, { component: page })" href="#" text="Move Up"></f7-menu-dropdown-item>
<f7-menu-dropdown-item @click="moveWidgetDown(tab, { component: page })" href="#" text="Move Down"></f7-menu-dropdown-item>
<f7-menu-dropdown-item divider></f7-menu-dropdown-item>
<f7-menu-dropdown-item @click="removeWidget(tab, { component: page })" href="#" text="Remove Tab"></f7-menu-dropdown-item>
</f7-menu-dropdown>
</f7-menu-item>
</f7-menu>
</f7-list-item>
<f7-list-button color="blue" title="Add tab" @click="addWidget(page, 'oh-tab')" />
</f7-list>
</f7-col>
</f7-block>
</f7-tab>
<f7-tab id="preview" class="tabs-editor-preview-tab" @tab:show="() => this.currentTab = 'preview'" :tab-active="currentTab === 'preview'">
</f7-tab>
<f7-tab id="code" @tab:show="() => { this.currentTab = 'code' }" :tab-active="currentTab === 'code'">
<editor v-if="currentTab === 'code'" class="page-code-editor" mode="text/x-yaml" :value="pageYaml" @input="(value) => pageYaml = value" />
<pre class="yaml-message padding-horizontal" :class="[yamlError === 'OK' ? 'text-color-green' : 'text-color-red']">{{yamlError}}</pre>
</f7-tab>
</f7-tabs>
<f7-popup ref="widgetConfig" class="widgetconfig-popup" close-on-escape :opened="widgetConfigOpened" @popup:closed="widgetConfigClosed">
<f7-page v-if="currentComponent && currentWidget">
<f7-navbar>
<f7-nav-left>
<f7-link icon-ios="f7:arrow_left" icon-md="material:arrow_back" icon-aurora="f7:arrow_left" popup-close></f7-link>
</f7-nav-left>
<f7-nav-title>Edit {{currentWidget.label || currentWidget.uid}}</f7-nav-title>
<f7-nav-right>
<f7-link @click="updateWidgetConfig">Done</f7-link>
</f7-nav-right>
</f7-navbar>
<f7-block v-if="currentWidget.props">
<f7-col>
<config-sheet
:parameterGroups="currentWidget.props.parameterGroups || []"
:parameters="currentWidget.props.parameters || []"
:configuration="currentComponentConfig"
@updated="dirty = true"
/>
</f7-col>
</f7-block>
</f7-page>
</f7-popup>
<f7-popup ref="widgetCode" class="widgetcode-popup" close-on-escape :opened="widgetCodeOpened" @popup:closed="widgetCodeClosed">
<f7-page v-if="currentComponent && widgetCodeOpened">
<f7-navbar>
<f7-nav-left>
<f7-link icon-ios="f7:arrow_left" icon-md="material:arrow_back" icon-aurora="f7:arrow_left" popup-close></f7-link>
</f7-nav-left>
<f7-nav-title>Edit Widget Code</f7-nav-title>
<f7-nav-right>
<f7-link @click="updateWidgetCode">Done</f7-link>
</f7-nav-right>
</f7-navbar>
<editor class="page-code-editor" mode="text/x-yaml" :value="widgetYaml" @input="(value) => widgetYaml = value" />
<pre class="yaml-message padding-horizontal" :class="[widgetYamlError === 'OK' ? 'text-color-green' : 'text-color-red']">{{widgetYamlError}}</pre>
</f7-page>
</f7-popup>
</f7-page>
</template>
<style lang="stylus">
.sitemap-editor-tabs
--f7-grid-gap 0px
height calc(100% - var(--f7-toolbar-height))
.tab
height 100%
.page-code-editor.vue-codemirror
display block
top calc(var(--f7-navbar-height) + var(--f7-tabbar-height))
height calc(80% - 2*var(--f7-navbar-height))
width 100%
.yaml-message
display block
position absolute
top 80%
white-space pre-wrap
</style>
<script>
import YAML from 'yaml'
// import OhMapPage from '@/components/widgets/map/oh-map-page.vue'
import OhTab from './oh-tab'
import ConfigSheet from '@/components/config/config-sheet.vue'
const ConfigurableWidgets = { OhTab }
function uuidv4 () {
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
)
}
export default {
components: {
'editor': () => import('@/components/config/controls/script-editor.vue'),
ConfigSheet
},
props: ['createMode', 'uid'],
data () {
return {
pageReady: false,
loading: false,
page: {
uid: 'page_' + uuidv4().split('-')[0],
component: 'oh-tabs-page',
config: {},
slots: { default: [] }
},
pageKey: uuidv4(),
pageYaml: null,
previewMode: false,
currentTab: 'design',
clipboard: null,
clipboardType: null,
currentComponent: null,
currentComponentConfig: null,
currentWidget: null,
widgetConfigOpened: false,
widgetCodeOpened: false,
widgetYaml: null
}
},
computed: {
ready () {
return this.pageReady && this.$store.state.components.widgets != null
},
context () {
return {
component: this.page,
store: this.$store.getters.trackedItems,
// states: this.stateTracking.store,
editmode: (!this.previewMode) ? {
addWidget: this.addWidget,
configureWidget: this.configureWidget,
editWidgetCode: this.editWidgetCode,
cutWidget: this.cutWidget,
copyWidget: this.copyWidget,
pasteWidget: this.pasteWidget,
moveWidgetUp: this.moveWidgetUp,
moveWidgetDown: this.moveWidgetDown,
removeWidget: this.removeWidget
} : null,
clipboardtype: this.clipboardType
}
},
yamlError () {
if (this.currentTab !== 'code') return null
try {
YAML.parse(this.pageYaml, { prettyErrors: true })
return 'OK'
} catch (e) {
return e
}
},
widgetYamlError () {
if (!this.widgetCodeOpened) return null
try {
YAML.parse(this.widgetYaml, { prettyErrors: true })
return 'OK'
} catch (e) {
return e
}
}
},
methods: {
onPageAfterIn () {
if (window) {
window.addEventListener('keydown', this.keyDown)
}
this.$store.dispatch('startTrackingStates')
this.load()
},
onPageBeforeOut () {
if (window) {
window.removeEventListener('keydown', this.keyDown)
}
this.$store.dispatch('stopTrackingStates')
},
keyDown (ev) {
if (ev.ctrlKey || ev.metakKey) {
switch (ev.keyCode) {
// case 82:
// this.previewMode = !this.previewMode
// ev.stopPropagation()
// ev.preventDefault()
// break
case 83:
this.save(!this.createMode)
ev.stopPropagation()
ev.preventDefault()
break
}
}
},
load () {
if (this.loading) return
this.loading = true
if (this.createMode) {
this.loading = false
this.pageReady = true
} else {
this.$oh.api.get('/rest/ui/components/ui:page/' + this.uid).then((data) => {
this.$set(this, 'page', data)
this.pageReady = true
this.loading = false
})
}
},
save (stay) {
if (!this.page.uid) {
this.$f7.dialog.alert('Please give an ID to the page')
return
}
if (!this.page.config.label) {
this.$f7.dialog.alert('Please give an label to the page')
return
}
if (!this.createMode && this.uid !== this.page.uid) {
this.$f7.dialog.alert('You cannot change the ID of an existing page. Duplicate it with the new ID then delete this one.')
return
}
const promise = (this.createMode)
? this.$oh.api.postPlain('/rest/ui/components/ui:page', JSON.stringify(this.page), 'text/plain', 'application/json')
: this.$oh.api.put('/rest/ui/components/ui:page/' + this.page.uid, this.page)
promise.then((data) => {
if (this.createMode) {
this.$f7.toast.create({
text: 'Page created',
destroyOnClose: true,
closeTimeout: 2000
}).open()
this.load()
} else {
this.$f7.toast.create({
text: 'Page updated',
destroyOnClose: true,
closeTimeout: 2000
}).open()
}
this.$f7.emit('sidebarRefresh', null)
if (!stay) this.$f7router.back()
}).catch((err) => {
this.$f7.toast.create({
text: 'Error while saving page: ' + err,
destroyOnClose: true,
closeTimeout: 2000
}).open()
})
},
addWidget (component, widgetType, parentContext, slot) {
if (!slot) slot = 'default'
if (!component.slots) component.slots = {}
if (!component.slots[slot]) component.slots[slot] = []
if (widgetType) {
component.slots[slot].push({
component: widgetType,
config: {},
slots: { default: [] }
})
this.forceUpdate()
}
},
widgetConfigClosed () {
this.currentComponent = null
this.currentWidget = null
this.widgetConfigOpened = false
},
updateWidgetConfig () {
this.$set(this.currentComponent, 'config', this.currentComponentConfig)
this.forceUpdate()
this.widgetConfigClosed()
},
widgetCodeClosed () {
this.currentComponent = null
this.currentWidget = null
this.widgetCodeOpened = false
},
updateWidgetCode () {
const updatedWidget = YAML.parse(this.widgetYaml)
this.$set(this.currentComponent, 'config', updatedWidget.config)
this.$set(this.currentComponent, 'slots', updatedWidget.slots)
this.forceUpdate()
this.widgetCodeClosed()
},
configureWidget (component, parentContext, forceComponentType) {
const componentType = forceComponentType || component.component
this.currentComponent = null
this.currentWidget = null
let widgetDefinition
if (componentType.indexOf('widget:') === 0) {
this.currentWidget = this.$store.getters.widget(componentType.substring(7))
} else {
widgetDefinition = Object.values(ConfigurableWidgets).find((w) => w.widget && w.widget.name === componentType)
if (!widgetDefinition) {
// widgetDefinition = Object.values(LayoutWidgets).find((w) => w.widget.name === component.component)
if (!widgetDefinition) {
console.warn('Widget not found: ' + componentType)
this.$f7.toast.create({
text: `This type of component cannot be configured: ${componentType}.`,
destroyOnClose: true,
closeTimeout: 3000,
closeButton: true,
closeButtonText: 'Edit YAML',
on: {
closeButtonClick: () => {
this.editWidgetCode(component, parentContext)
}
}
}).open()
return
}
}
this.currentWidget = widgetDefinition.widget
}
this.currentComponent = component
this.currentComponentConfig = JSON.parse(JSON.stringify(this.currentComponent.config))
this.widgetConfigOpened = true
},
editWidgetCode (component, parentContext, slot) {
if (slot && !component.slots) component.slots = {}
if (slot && !component.slots[slot]) component.slots[slot] = []
this.currentComponent = component
this.widgetYaml = YAML.stringify(component)
this.widgetCodeOpened = true
},
cutWidget (component, parentContext) {
this.copyWidget(component, parentContext)
this.removeWidget(component, parentContext)
},
copyWidget (component, parentContext) {
let newClipboard = JSON.stringify(component)
this.$set(this, 'clipboard', newClipboard)
this.clipboardType = component.component
},
pasteWidget (component, parentContext) {
if (!this.clipboard) return
component.slots.default.push(JSON.parse(this.clipboard))
this.forceUpdate()
},
moveWidgetUp (component, parentContext) {
let siblings = parentContext.component.slots.default
let pos = siblings.indexOf(component)
if (pos <= 0) return
siblings.splice(pos, 1)
siblings.splice(pos - 1, 0, component)
this.forceUpdate()
},
moveWidgetDown (component, parentContext) {
let siblings = parentContext.component.slots.default
let pos = siblings.indexOf(component)
if (pos >= siblings.length - 1) return
siblings.splice(pos, 1)
siblings.splice(pos + 1, 0, component)
this.forceUpdate()
},
removeWidget (component, parentContext) {
parentContext.component.slots.default.splice(parentContext.component.slots.default.indexOf(component), 1)
this.forceUpdate()
},
forceUpdate () {
this.pageKey = uuidv4()
},
toYaml () {
this.pageYaml = YAML.stringify({
tabs: this.page.slots.default
})
},
fromYaml () {
try {
const updatedTabs = YAML.parse(this.pageYaml)
this.$set(this.page.slots, 'default', updatedTabs.tabs)
this.forceUpdate()
return true
} catch (e) {
this.$f7.dialog.alert(e).open()
return false
}
}
}
}
</script>