This commit is contained in:
2023-06-06 11:22:17 +02:00
parent 68497a3ea9
commit 60849155d7
500 changed files with 0 additions and 46775 deletions
-9
View File
@@ -1,9 +0,0 @@
using System.Reflection;
using System.Runtime.InteropServices;
[assembly: AssemblyProduct("zero")]
[assembly: AssemblyCompany("brothers Klika OG")]
[assembly: AssemblyCopyright("Copyright © 2020 brothers Klika OG")]
[assembly: AssemblyCulture("")]
[assembly: ComVisible(false)]
[assembly: AssemblyVersion("0.0.1.0")]
@@ -1,213 +0,0 @@
<template>
<div class="page page-error" :class="{'theme-dark': dark }">
<i class="page-error-icon fth-cloud-snow"></i>
<p class="page-error-text">
<strong class="page-error-headline" v-localize="{ key: errorDetails.headline, tokens: tokens }"></strong><br>
<span v-localize:html="{ key: errorDetails.text, tokens: tokens }"></span>
</p>
<ui-button v-if="errorDetails.code === 404" class="page-error-button" type="light onbg" :label="detailsText" @click="details = !details" />
<ui-button v-if="errorDetails.code !== 404 && errorDetails.category === 4" class="page-error-button" type="light onbg" label="@ui.back" @click="$router.go(-1)" />
<!--<ui-button v-if="errorDetails.code !== 404 && errorDetails.category === 5" class="page-error-button" type="light" label="@ui.back" @click="$router.go(-1)" />-->
<template v-if="errorDetails.code === 404 && details">
<br><br>
<div class="page-error-routes">
<span>#</span>
<span>Name</span>
<span>Path</span>
<template v-for="(route, index) in routes">
<span>{{index + 1}}.</span>
<span>{{route.name}}</span>
<span>{{route.path}}</span>
</template>
</div>
</template>
</div>
</template>
<script>
const KNOWN_ERRORS = [403, 404, 409, 504];
export default {
props: {
dark: {
type: Boolean,
default: false
},
error: {
type: Error,
default: null
}
},
data: () => ({
path: null,
routes: [],
details: false,
errorDetails: {
category: 0,
code: null,
headline: null,
text: null
}
}),
watch: {
error: function (val)
{
this.rebuild();
},
'$route': function (val)
{
this.rebuild();
}
},
computed: {
detailsText()
{
return this.details ? 'Hide' : 'Defined routes';
},
tokens()
{
return {
code: this.errorDetails.code,
path: this.path
};
}
},
mounted()
{
console.info(this.error.response);
this.rebuild();
},
methods: {
rebuild()
{
if (this.error && this.error.response)
{
let errorKey = null;
const errorCode = this.error.response.status;
const errorCategory = Math.round(errorCode / 100);
if (KNOWN_ERRORS.indexOf(errorCode) > -1)
{
errorKey = '@errors.http.' + errorCode;
}
else if (errorCategory === 4)
{
errorKey = '@errors.http.4xx';
}
else if (errorCategory === 5)
{
errorKey = '@errors.http.5xx';
}
this.errorDetails.category = errorCategory;
this.errorDetails.code = errorCode;
this.errorDetails.headline = errorKey;
this.errorDetails.text = errorKey + '_text';
}
this.path = this.$router.options.base + this.$route.path.substring(1);
this.routes = [];
this.$router.options.routes.forEach(route =>
{
this.routes.push({
path: route.path,
name: route.name
});
if (route.children)
{
route.children.forEach(child =>
{
this.routes.push({
path: route.path + '/' + child.path,
name: child.name
});
});
}
});
}
}
}
</script>
<style lang="scss">
.page-error
{
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--color-text);
text-align: center;
padding: var(--padding);
overflow-y: auto;
}
.page-error-icon
{
font-size: 82px;
color: var(--color-text);
margin-bottom: 20px;
}
.page-error-text
{
font-size: var(--font-size);
color: var(--color-text-dim);
line-height: 1.4em;
}
.page-error-headline
{
display: inline-block;
margin-bottom: 10px;
font-size: var(--font-size-l);
color: var(--color-text);
}
.page-error-button
{
margin-top: 10px;
}
.page-error-routes
{
display: grid;
grid-template-columns: auto auto 1fr;
width: 100%;
max-width: 100%;
text-align: left;
border-top: 1px solid var(--color-line);
border-left: 1px solid var(--color-line);
margin-top: 30px;
span
{
border: 1px solid var(--color-line);
border-left: none;
border-top: none;
padding: 8px 10px 6px;
font-family: Consolas;
font-size: 12px;
&:nth-child(3n+1)
{
color: var(--color-text-dim);
}
}
}
</style>
@@ -1,54 +0,0 @@
<template>
<div class="ui-password-hash">
<input value="******************" type="text" class="ui-input" readonly="readonly" disabled="disabled" />
<ui-button v-if="!disabled" label="change" type="light" @click="open" />
</div>
</template>
<script>
//import Overlay from 'zero/helpers/overlay.js';
//import PasswordChangeOverlay from 'zero/components/overlays/password-change.vue';
export default {
props: {
value: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
},
placeholder: {
type: [String, Function],
default: null
},
entity: Object
},
methods: {
open()
{
//return Overlay.open({
// component: PasswordChangeOverlay
//}).then(hash =>
//{
// this.$emit('input', hash);
//});
}
}
}
</script>
<style lang="scss">
.ui-password-hash
{
display: grid;
grid-template-columns: 1fr auto;
grid-gap: var(--padding-xxs);
}
</style>
@@ -1,31 +0,0 @@
<template>
<div class="setup-step setup-step-application">
<h2>your application</h2>
<p>With zero you can manage multiple applications with one installation. Start by adding your first application.</p>
<ui-property label="Application name" :vertical="true">
<input v-model="value.appName" type="text" class="ui-input" maxlength="40" placeholder="My super awesome app maybe?" />
</ui-property>
</div>
</template>
<script>
export default {
name: 'setupStepApplication',
props: {
value: Object
},
mounted ()
{
},
methods: {
}
}
</script>
@@ -1,35 +0,0 @@
<template>
<div class="setup-step setup-step-database">
<h2>database configuration</h2>
<p>Zero uses <b>RavenDB</b> as its database. If you want a more sophisticated setup (i.e. cluster) you can override the IDocumentStore service which is registered as a singleton.</p>
<ui-property label="Instance URL" :vertical="true">
<input v-model="value.database.url" type="text" class="ui-input" placeholder="Enter URL to the Raven instance" />
</ui-property>
<ui-property label="Database name" :vertical="true">
<input v-model="value.database.name" type="text" class="ui-input" placeholder="Name of the existing database" />
</ui-property>
</div>
</template>
<script>
export default {
name: 'setupStepDatabase',
props: {
value: Object
},
mounted ()
{
},
methods: {
}
}
</script>
@@ -1,43 +0,0 @@
<template>
<div class="setup-step setup-step-finish">
<div class="setup-step-finish-inner">
<img src="/Icons/bird.svg" class="bird" alt="bird" width="100" />
<h2>You are done!</h2>
</div>
<ui-button label="Continue to zero" type="big" />
</div>
</template>
<script>
export default {
name: 'setupStepFinish',
mounted ()
{
},
methods: {
}
}
</script>
<style lang="scss">
.setup-step-finish
{
display: grid;
grid-template-rows: 1fr auto;
align-items: center;
justify-content: center;
text-align: center;
.bird
{
margin-bottom: 30px;
}
}
</style>
@@ -1,179 +0,0 @@
<template>
<div class="setup-step setup-step-install">
<div v-if="!errorMessage" class="setup-step-install-inner">
<div class="setup-step-install-progress"><i :style="{ width: progress + '%' }"></i></div>
<h2>Installing</h2>
</div>
<div v-if="errorMessage" class="setup-step-install-error">
<div class="setup-step-install-error-main">
<h2>Oh no!</h2>
<p>The setup of <b>zero</b> failed with following error(s):</p>
<pre class="setup-step-install-error-code"><code v-html="errorMessage"></code></pre>
</div>
<div class="setup-buttons">
<ui-button type="outline" @click="onBack" label="Go back" caret="left" caret-position="left" />
<ui-button label="Retry" @click="install" />
</div>
</div>
</div>
</template>
<script>
import Axios from 'axios'
export default {
name: 'setupStepInstall',
props: {
value: Object,
onBack: {
type: Function,
required: true
},
onNext: {
type: Function,
required: true
}
},
data: () => ({
errorMessage: null,
progress: 0
}),
mounted()
{
this.install();
},
methods: {
install()
{
this.errorMessage = null;
let interval = setInterval(() =>
{
if (++this.progress >= 100)
{
this.onNext();
clearInterval(interval);
}
}, 50);
Axios.post(zero.path + 'api/setup/install', this.value)
.then(response =>
{
if (!response.success)
{
throw response;
}
})
.catch((error) =>
{
clearInterval(interval);
// The request was made and the server responded with a status code that falls out of the range of 2xx
if (error.response)
{
this.error(error.response.data);
}
// The request was made but no response was received
else if (error.request)
{
this.error(error.request);
}
else
{
this.error(error.message);
}
});
},
error(error)
{
this.errorMessage = error;
}
}
}
</script>
<style lang="scss">
.setup-step-install
{
display: grid;
grid-template-rows: 1fr auto;
align-items: center;
justify-content: stretch;
}
.setup-step-install-inner
{
text-align: center;
h2
{
margin: 30px 0 0;
}
}
.setup-step-install-progress
{
position: relative;
margin: 0 auto;
width: 80%;
height: 12px;
border-radius: 6px;
background: var(--color-bg-light);
overflow: hidden;
i
{
background: var(--color-primary);
transition: width 0.1s ease;
display: block;
height: 100%;
}
}
.setup-step-install-error
{
display: grid;
grid-template-rows: 1fr auto;
align-items: stretch;
width: 100%;
height: 100%;
h2
{
color: var(--color-negative);
}
div
{
width: 100%;
}
}
.setup-step-install-error-code
{
background: var(--color-bg-light);
padding: 20px;
font-size: 14px;
line-height: 20px;
width: 440px;
max-height: 50vh;
overflow: scroll;
font-family: 'Lucida Console', Consolas, sans-serif;
margin: 0;
}
.setup-step-install-error-main
{
display: grid;
grid-template-rows: auto auto 1fr;
margin-bottom: 30px;
}
</style>
@@ -1,39 +0,0 @@
<template>
<div class="setup-step setup-step-user">
<h2>welcome to zero</h2>
<p>First we need some basic credentials from you to create a login. This login is the solely <b>superadmin</b> which is required to change critical application data.</p>
<ui-property label="Name" :vertical="true">
<input v-model="value.user.name" type="text" class="ui-input" maxlength="40" placeholder="What's your name? It doesn't have to be your real name" />
</ui-property>
<ui-property label="Email" :vertical="true">
<input v-model="value.user.email" type="text" class="ui-input" placeholder="Your email will be used as your login" />
</ui-property>
<ui-property label="Password" :vertical="true">
<input v-model="value.user.password" type="password" class="ui-input" autocomplete="off" maxlength="1024" placeholder="From 8 characters to a galaxy far, far away" />
</ui-property>
</div>
</template>
<script>
export default {
name: 'setupStepUser',
props: {
value: Object
},
mounted ()
{
},
methods: {
}
}
</script>
@@ -1,9 +0,0 @@
import Vue from 'vue';
import Setup from './setup.vue';
//import 'filter/generic.js'
//import 'directive/filedrop.js'
import GlobalComponents from 'zero/components/globals.js';
import Directives from 'zero/directives/globals.js';
new Vue(Setup).$mount('#application');
@@ -1,6 +0,0 @@
// configuration
@import "Core/all";
// modules
@import "Modules/button";
@import "Setup/all";
@@ -1,133 +0,0 @@
<template>
<div class="app">
<h1 class="app-headline">zero</h1>
<div class="setup">
<component v-bind:is="steps[step]" v-model="model" :on-back="prev" :on-next="next" />
<div class="setup-buttons" v-if="step < 3">
<ui-button v-if="step > 0" type="outline" label="Go back" @click="prev" caret="left" caret-position="left" />
<ui-button :label="nextLabel" @click="next" />
</div>
</div>
</div>
</template>
<script>
import Sass from '../Sass/setup.scss'
import UiButton from 'zero/components/buttons/button.vue'
import StepUser from './Steps/step-user.vue'
import StepApplication from './Steps/step-application.vue'
import StepDatabase from './Steps/step-database.vue'
import StepInstall from './Steps/step-install.vue'
import StepFinish from './Steps/step-finish.vue'
export default {
name: 'setup',
components: { UiButton, StepUser, StepApplication, StepDatabase, StepInstall, StepFinish },
data: () => ({
step: 0,
steps: ['step-user', 'step-database', 'step-application', 'step-install', 'step-finish'],
model: { "appName": "Brothers", "user": { "name": "Tobias Klika", "email": "", "password": "" }, "database": { "url": "http://localhost:9800", "name": "zero" } }
//model: {
// appName: null,
// user: {
// name: null,
// email: null,
// password: null
// },
// database: {
// url: 'http://localhost:9800',
// name: null
// }
//}
}),
computed: {
nextLabel()
{
return this.step < 2 ? 'Next' : 'Install application';
}
},
mounted()
{
},
methods: {
next()
{
if (this.step + 1 >= this.steps.length)
{
return;
}
this.step += 1;
},
prev()
{
this.step -= 1;
},
save()
{
}
}
}
</script>
<style lang="scss">
.setup
{
display: grid;
grid-template-rows: 1fr auto;
align-items: stretch;
max-width: 100%;
width: 520px;
background: var(--color-bg-bright);
border-radius: 3px;
min-height: 600px;
position: relative;
z-index: 2;
padding: 40px;
}
.setup-step
{
width: 100%;
}
.setup-step h2
{
display: block;
text-align: center;
}
.setup-step p
{
color: var(--color-text-dim);
font-size: 0.9rem;
line-height: 1.6;
margin: 0 0 2rem;
}
.setup-step p b
{
color: var(--color-text);
font-weight: 600;
}
.setup-step .ui-property + .ui-property
{
padding-top: 0;
border-top: none;
}
</style>
@@ -1,175 +0,0 @@
<template>
<div class="ui-tableeditor" :class="{'is-disabled': disabled }">
<div class="-table">
<div v-for="(row, index) in rows" :key="index" class="-row">
<div v-for="(cell, cellIndex) in row" :key="cellIndex" class="-cell" :class="{'-head': index == 0 }">
<p class="-content" :data-placeholder="(index == 0 ? 'Title' : 'Content')" contenteditable="true" v-html="cell" @input="onCellChange($event, index, cellIndex)"></p>
</div>
</div>
</div>
<div>
<br><br>
<input type="number" v-model="idx" placeholder="Index" style="display: inline; width: 80px; margin-right: 10px;" />
<button type="button" class="ui-button" @click="addRow(idx)">addRow</button>
<button type="button" class="ui-button" @click="removeRow(idx)">removeRow</button>
<button type="button" class="ui-button" @click="addColumn(idx)">addColumn</button>
<button type="button" class="ui-button" @click="removeColumn(idx)">removeColumn</button>
</div>
</div>
</template>
<script>
export default {
name: 'uiTableEditor',
props: {
value: {
type: Array,
default: () => []
},
disabled: {
type: Boolean,
default: false
}
},
data: () => ({
idx: 0,
rows: []
}),
watch: {
value(value)
{
this.rebuild();
}
},
mounted()
{
this.rebuild();
},
methods: {
rebuild()
{
this.rows = JSON.parse(JSON.stringify(this.value));
},
update()
{
this.$emit('input', this.rows);
},
onCellChange(ev, rowIndex, cellIndex)
{
this.rows[rowIndex][cellIndex] = ev.target.innerText;
this.update();
},
addRow(index)
{
this.rows.splice(index, 0, this.rows[0].map(_ => "<br>"));
this.update();
},
removeRow(index)
{
this.rows.splice(index, 1);
this.update();
},
addColumn(index)
{
this.rows.forEach(row =>
{
row.splice(index, 0, "<br>");
});
this.update();
},
removeColumn(index)
{
this.rows.forEach(row =>
{
row.splice(index, 1);
});
this.update();
}
}
}
</script>
<style lang="scss">
.ui-tableeditor .-table
{
width: 100%;
border-collapse: collapse;
break-inside: auto;
//border: 1px solid var(--color-line-onbg);
border-radius: var(--radius);
position: relative;
display: table;
}
.ui-tableeditor .-row
{
display: table-row;
}
.ui-tableeditor .-cell
{
display: table-cell;
text-align: left;
width: auto;
position: relative;
align-self: start;
border-top: medium none;
align-items: start;
border-right: medium none;
border-bottom: medium none;
-moz-box-align: start;
}
.ui-tableeditor .-cell.-head .-content
{
font-weight: 700;
}
.ui-tableeditor .-content
{
margin: 0;
min-height: 1.2em;
padding: 12px 8px;
}
/*.ui-tableeditor .-content[data-placeholder]:before
{
content: attr(data-placeholder);
color: var(--color-text-dim-one);
position: absolute;
z-index: 0;
}*/
.ui-tableeditor .-content:focus-within
{
background: var(--color-bg-dim);
outline: none;
}
.ui-tableeditor .-row
{
border-bottom: 1px solid var(--color-line-onbg);
padding: 10px;
background: rgb(255, 255, 255) none repeat scroll 0% 0%;
break-after: auto;
break-inside: avoid;
}
.ui-tableeditor .-row:first-child
{
//border-bottom-width: 2px;
}
</style>
@@ -1,82 +0,0 @@
<template>
<ui-form v-if="!loading" ref="form" class="app-password-change" v-slot="form" @submit="onSubmit">
<h2 class="ui-headline" v-localize="'@changepasswordoverlay.title'"></h2>
<div class="app-password-change-fields">
<ui-error :catch-remaining="true" />
<ui-editor :config="editorConfig" v-model="model" />
</div>
<div class="app-confirm-buttons">
<ui-button type="accent" :submit="true" :state="form.state" label="@ui.confirm"></ui-button>
<ui-button type="light" label="@ui.close" :disabled="loading" @click="config.close"></ui-button>
</div>
</ui-form>
</template>
<script>
import UsersApi from 'zero/api/users.js'
import passwordRenderer from 'zero/renderers/editors/password.js';
export default {
props: {
config: Object
},
data: () => ({
loading: false,
model: {
currentPassword: null,
newPassword: null,
confirmNewPassword: null
},
editorConfig: passwordRenderer
}),
methods: {
onSubmit(form)
{
form.handle(UsersApi.hashPassword({ ...this.model, userId: this.$route.params.id })).then(res =>
{
this.config.confirm(res.model);
}, errors => {});
}
}
}
</script>
<style lang="scss">
.app-password-change
{
text-align: left;
.editor, .ui-tab, .ui-box
{
padding: 0;
}
}
.app-password-change-fields
{
margin-top: var(--padding);
.ui-property + .ui-property, .ui-split + .ui-property
{
margin-top: var(--padding);
}
}
/*.translation-items
{
margin-top: var(--padding);
.ui-property + .ui-property,
.ui-split + .ui-property
{
margin-top: 0;
}
}*/
</style>
-39
View File
@@ -1,39 +0,0 @@
declare module 'zero/account'
{
export interface AccountUser
{
id: string;
currentAppId: string;
isSuper: boolean;
avatarId: string;
email: string;
name: string;
isActive: boolean;
createdDate: string;
flavor?: string;
}
export interface AccountLoginResponse
{
user: AccountUser;
apiKey: string;
}
export interface AccountStoreState
{
user?: AccountUser;
}
export interface AccountLoginModel
{
email: string;
password: string;
isPersistent: boolean;
}
export interface AccountLoggedInResponse
{
loggedIn: boolean;
}
}
-13
View File
@@ -1,13 +0,0 @@
import { AccountLoggedInResponse, AccountLoginModel, AccountLoginResponse, AccountUser } from 'zero/account';
import { get, post, ApiRequestConfig, ApiResponse } from '../services/request';
export default {
getUser: (config?: ApiRequestConfig): Promise<ApiResponse<AccountUser>> => get('backoffice/account/user', { ...config }),
isAuthenticated: (config?: ApiRequestConfig): Promise<ApiResponse<AccountLoggedInResponse>> => get('backoffice/account/loggedin', { ...config }),
login: (model: AccountLoginModel, config?: ApiRequestConfig): Promise<ApiResponse<AccountLoginResponse>> => post('backoffice/account/login', model, { ...config }),
logout: (config?: ApiRequestConfig) => post('backoffice/account/logout', null, { ...config })
};
-168
View File
@@ -1,168 +0,0 @@
<template>
<div class="app-auth">
<span></span>
<ui-form class="app-auth-inner" v-slot="form" @submit="onSubmit">
<div>
<div class="app-auth-logo">
<span class="app-nav-logo-circle"></span>
<img src="/Assets/zero.svg" class="app-auth-image show-light" v-localize:alt="'@zero.name'" />
<img src="/Assets/zero-dark.svg" class="app-auth-image show-dark" v-localize:alt="'@zero.name'" />
</div>
<ui-error :catch-remaining="true" />
<ui-property field="email" label="@login.fields.email" :vertical="true">
<input v-model="model.email" type="text" class="ui-input" maxlength="120" v-localize:placeholder="'@login.fields.email_placeholder'" />
</ui-property>
<ui-property field="password" label="@login.fields.password" :vertical="true">
<input v-model="model.password" type="password" class="ui-input" maxlength="1024" v-localize:placeholder="'@login.fields.password_placeholder'" />
</ui-property>
</div>
<div class="app-auth-bottom">
<ui-button class="app-auth-confirm" type="accent big" :submit="true" label="@login.button" :state="form.state" />
<!--<ui-button type="blank" label="@login.button_forgot" />-->
</div>
</ui-form>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import api from './api';
import { useAccountStore } from './store';
import { useUiStore } from '../ui/store';
export default defineComponent({
name: 'app-login',
data: () => ({
rejectReason: null,
model: {
email: '',
password: '',
isPersistent: true
}
}),
created()
{
//this.rejectReason = api.rejectReason;
},
methods: {
async onSubmit(form)
{
const accountStore = useAccountStore();
this.rejectReason = null;
form.setState('loading');
const response = await api.login(this.model);
if (response.success)
{
const userResponse = await api.getUser();
if (userResponse.success)
{
this.zero.events.emit('zero.authenticate', userResponse.data);
form.handle(response);
return;
}
}
else
{
form.handle(response);
}
//this.state = 'error';
return;
}
}
})
</script>
<style lang="scss">
.app-auth
{
grid-column: span 2 / auto;
align-self: stretch;
justify-self: stretch;
display: grid;
grid-template-rows: 1fr auto 1fr;
align-items: center;
justify-content: center;
background: var(--color-page);
/*&:before
{
content: 'login';
font-family: var(--font-headline);
color: rgba(black, 0.008);
position: fixed;
left: -10vw;
bottom: -30vh;
font-size: 150vh;
line-height: 150vh;
font-weight: bold;
}*/
}
.app-auth-inner
{
display: grid;
grid-template-rows: 1fr auto;
align-items: stretch;
max-width: 100%;
width: 420px;
background: var(--color-box);
box-shadow: var(--shadow-short);
border-radius: var(--radius);
/*border: 1px solid var(--color-line);*/
position: relative;
z-index: 2;
padding: var(--padding);
color: var(--color-text);
/*box-shadow: 0 0 60px var(--color-shadow);*/
}
.app-auth-logo
{
display: flex;
align-items: center;
margin: 0 0 3rem 0;
}
.app-auth-image
{
height: 16px;
}
.app-auth .ui-property + .ui-property
{
padding-top: 0;
margin-top: var(--padding);
border-top: none;
}
.app-auth-bottom
{
margin: calc(-1 * var(--padding));
margin-top: 3rem;
padding: var(--padding);
background: var(--color-box-nested);
border-top: 1px solid var(--color-line-onbg);
border-bottom-left-radius: var(--radius);
border-bottom-right-radius: var(--radius);
}
.app-auth .ui-message
{
margin: -16px 0 var(--padding);
}
</style>
-13
View File
@@ -1,13 +0,0 @@
import { defineStore } from 'pinia';
import { AccountStoreState } from 'zero/account';
export const useAccountStore = defineStore('zero.account', {
state: () => ({
user: undefined
} as AccountStoreState),
getters: {
isAuthenticated: state => state.user != null
}
});
-86
View File
@@ -1,86 +0,0 @@
<template>
<div class="app" v-if="!loading" :class="{ 'is-preview': preview}">
<app-login v-if="!authenticated" />
<template v-if="authenticated && !preview">
<app-navigation />
<div class="app-main">
<router-view></router-view>
</div>
<app-overlays />
<app-notifications />
</template>
<template v-if="authenticated && preview">
<router-view></router-view>
</template>
</div>
<div class="app-loading" v-else>
<ui-loading />
</div>
</template>
<script lang="ts">
import './styles/styles';
import AppLogin from './account/login.vue';
import AppNavigation from './ui/app-navigation.vue';
import AppNotifications from './ui/app-notifications.vue';
import AppOverlays from './ui/app-overlays.vue';
import { defineComponent } from 'vue';
import startup from './startup';
import { AccountUser } from 'zero/account';
export default defineComponent({
name: 'app',
components: { AppLogin, AppNavigation, AppNotifications, AppOverlays },
data: () => ({
loading: true,
authenticated: false
}),
computed: {
preview()
{
return this.$route.name === 'preview';
}
},
mounted()
{
this.startup();
this.zero.events.on('zero.authenticate', this.onAuthentication);
this.zero.events.on('zero.rejectauth', () => this.authenticated = false);
},
methods: {
async startup()
{
const result = await startup(this.zero);
this.authenticated = result.authenticated;
this.loading = false;
},
async onAuthentication(user?: AccountUser)
{
await this.startup();
}
}
});
</script>
<style lang="scss">
.app-loading
{
display: flex;
height: 100vh;
width: 100vw;
align-items: center;
justify-content: center;
}
</style>
-93
View File
@@ -1,93 +0,0 @@
import { AxiosError } from 'axios';
import Axios from 'axios';
import eventHub from './services/eventhub';
import { open as openOverlay } from './services/overlay';
//import Auth from 'zero/helpers/auth.js';
import Qs from 'qs';
//if (!__zero || !__zero.apiPath)
//{
// throw Exception('window.zero and zero.apiPath (= base path to the backoffice API) have to be configured');
//}
//Axios.defaults.baseURL = '/zero/api/';
Axios.defaults.withCredentials = true;
Axios.defaults.paramsSerializer = (params) =>
{
return Qs.stringify(params, { allowDots: true });
};
Axios.interceptors.response.use(
response => response,
(error: AxiosError) =>
{
const isApi = error.response && error.response.headers['x-variant'] == 'api-response';
if (isApi && error.response && error.response.status >= 500)
{
const errorData = error.response.data && error.response.data.errors ? error.response.data.errors[0] : {
category: 'server',
code: 'server.error.axios',
message: 'Unknown server error',
propery: null
};
openOverlay({
alias: 'servererror',
model: errorData,
autoclose: true,
softdismiss: true,
component: () => import('./ui/overlays/servererror.vue')
});
}
if (isApi)
{
return Promise.resolve(error.response);
}
if (error.response && error.response.status === 401)
{
console.error('[zero.axios] Auth failed. Please login again.');
eventHub.emit('zero.rejectauth');
return Promise.reject(error.response);
//Auth.rejectUser("@login.rejectReasons.inactive");
//Notification.error('Authentication failed. Please login again.', 3);
}
return Promise.reject(error);
}
);
Axios.interceptors.request.use(config =>
{
if (!config.params)
{
config.params = {};
}
let locationQuery = Qs.parse(location.search.substring(1));
let appKey = 'system';
// set app key to system when required
if (config.params['zero.system'] === true || locationQuery['zero.shared'] === "true")
{
delete config.params['zero.system'];
appKey = 'system';
}
// rewrite URLs to replace {app} placeholder
if (config.url != null && config.url.indexOf('{app}') > -1)
{
config.url = config.url.replace('{app}', appKey);
}
if (config.baseURL != null && config.baseURL.indexOf('{app}') > -1)
{
config.baseURL = config.baseURL.replace('{app}', appKey);
}
return config;
}, error => Promise.reject(error));
export default Axios;
@@ -1,99 +0,0 @@
import uiIcon from './ui-icon.vue';
import uiLoading from './ui-loading.vue';
import uiTab from './ui-tab.vue';
import uiTabs from './ui-tabs.vue';
import uiInlineTabs from './ui-inline-tabs.vue';
import uiButton from './ui-button.vue';
import uiDotButton from './ui-dot-button.vue';
import uiIconButton from './ui-icon-button.vue';
import uiSelectButton from './ui-select-button.vue';
import uiStateButton from './ui-state-button.vue';
import uiRadioButton from './ui-radio-button.vue';
import uiAddButton from './ui-add-button.vue';
import uiMessage from './ui-message.vue';
import uiPagination from './ui-pagination.vue';
import uiError from './ui-error.vue';
import uiLocalize from './ui-localize.vue';
import uiThumbnail from './ui-thumbnail.vue';
import uiDropdownSeparator from './ui-dropdown-separator.vue';
import uiDropdownButton from './ui-dropdown-button.vue';
import uiDropdown from './ui-dropdown.vue';
import uiHeaderBar from './ui-header-bar.vue';
import uiCalendar from './ui-calendar.vue';
import uiTree from './ui-tree.vue';
import uiTreeItem from './ui-tree-item.vue';
import uiTable from './ui-table.vue';
import uiTableFilter from './ui-table-filter.vue';
import uiTrinity from './ui-trinity.vue';
import uiPick from './ui-pick.vue';
import uiDatagrid from './ui-datagrid.vue';
import uiProgress from './ui-progress.vue';
import uiDate from './ui-date.vue';
import uiRte from './ui-rte/ui-rte.vue';
import uiCheckList from './ui-check-list.vue';
import uiSearch from './ui-search.vue';
import uiToggle from './ui-toggle.vue';
import uiProperty from './ui-property.vue';
import uiColorpicker from './ui-colorpicker.vue';
import uiDatepicker from './ui-datepicker.vue';
import uiDaterangepicker from './ui-daterangepicker.vue';
import uiTimepicker from './ui-timepicker.vue';
import uiSelect from './ui-select.vue';
import uiForm from './ui-form.vue';
import uiFormHeader from './ui-form-header.vue';
import uiFormHeaderLinks from './ui-form-header-links.vue';
import uiLink from './ui-link.vue';
import uiInputList from './ui-input-list.vue';
import uiTags from './ui-tags.vue';
import uiIconpicker from './ui-iconpicker.vue';
export {
uiIcon,
uiLoading,
uiTab,
uiTabs,
uiInlineTabs,
uiButton,
uiDotButton,
uiIconButton,
uiSelectButton,
uiStateButton,
uiRadioButton,
uiAddButton,
uiMessage,
uiPagination,
uiError,
uiLocalize,
uiThumbnail,
uiDropdownSeparator,
uiDropdownButton,
uiDropdown,
uiHeaderBar,
uiCalendar,
uiTree,
uiTreeItem,
uiTable,
uiTableFilter,
uiTrinity,
uiPick,
uiDatagrid,
uiProgress,
uiDate,
uiRte,
uiCheckList,
uiSearch,
uiToggle,
uiProperty,
uiColorpicker,
uiDatepicker,
uiDaterangepicker,
uiTimepicker,
uiSelect,
uiForm,
uiFormHeader,
uiFormHeaderLinks,
uiLink,
uiInputList,
uiTags,
uiIconpicker
};
@@ -1,13 +0,0 @@
import { App } from 'vue';
import * as components from './index';
export default function (app: App)
{
for (var key in components)
{
let component = components[key];
app.component(key, component.default || component);
}
};
@@ -1,213 +0,0 @@
<template>
<ui-button v-if="!hasDropdown" :type="type" :label="label" @click="onConfirm(null, false)" :disabled="disabled" />
<ui-dropdown v-else ref="dropdown" align="right">
<template v-slot:button>
<slot>
<ui-button :label="label" :type="type" :disabled="disabled" />
</slot>
</template>
<template v-if="flavors.length > 1">
<ui-dropdown-button v-for="(flavor, index) in flavors" :key="index" :icon="flavor.icon" :value="flavor" :label="flavor.name" :prevent="true" @click="selectFlavor" :selected="selectedflavor == flavor.alias" />
<template v-if="allowBlueprint">
<ui-dropdown-separator />
<div class="ui-decision">
<ui-toggle v-model:on="asBlueprint" :on-content="'Shared'" :off-content="'Not shared'" />
</div>
</template>
<ui-dropdown-separator />
<div class="ui-decision-button">
<ui-button label="@addoverlay.gotoeditor" type="accent" icon="fth-arrow-right" :disabled="disabled" @click="onConfirm(selectedflavor, asBlueprint)" />
</div>
</template>
<div v-else class="ui-add-button-items">
<button type="button" class="ui-add-button-item" @click="onConfirm(null, true)" :disabled="disabled">
<ui-icon symbol="fth-cloud" :size="20" />
<span class="-text">Shared</span>
</button>
<span class="ui-add-button-items-line"></span>
<button type="button" class="ui-add-button-item" @click="onConfirm(null, false)" :disabled="disabled">
<ui-icon symbol="fth-arrow-right" :size="20" />
<span class="-text">Local</span>
</button>
</div>
</ui-dropdown>
</template>
<script lang="ts">
import { useUiStore } from '../ui/store';
import { defineComponent } from 'vue';
import { UiFlavorProvider } from 'zero/ui';
export default defineComponent({
props: {
label: {
type: String,
default: '@ui.add'
},
type: {
type: String,
default: 'accent'
},
route: {
type: [String, Object],
default: null
},
alias: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
}
},
data: () => ({
flavors: [],
loading: false,
asBlueprint: false,
allowBlueprint: false,
selectedflavor: null,
store: null
}),
computed: {
hasDropdown()
{
return this.flavors.length > 1 || this.allowBlueprint;
}
},
async created()
{
this.store = useUiStore();
this.buildFlavors();
},
methods: {
buildFlavors()
{
this.flavors = this.store.getFlavors(this.alias);
this.allowBlueprint = this.store.blueprints.indexOf(this.alias) > -1;
if (this.flavors.length > 0)
{
this.selectedflavor = this.flavors[0].alias;
}
},
selectFlavor(flavor, opts)
{
this.selectedflavor = flavor.alias;
},
onConfirm(flavor, shared)
{
if (!!this.route)
{
let routeObj = typeof this.route === 'object' ? this.route : { name: this.route };
routeObj.query = routeObj.query || {};
if (flavor)
{
routeObj.query['zero.flavor'] = flavor;
}
if (shared)
{
routeObj.query['zero.scope'] = 'system';
}
this.$router.push(routeObj);
}
this.$emit('click', { flavor, shared });
}
}
})
</script>
<style lang="scss">
.ui-decision
{
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 16px;
font-size: var(--font-size);
}
.ui-decision-button
{
padding: 16px 16px;
}
.ui-dropdown.theme-dark .ui-decision
{
.ui-toggle-switch:not(.is-active)
{
background: var(--color-bg);
}
input:focus + .ui-toggle-switch
{
border-color: transparent;
box-shadow: none;
}
}
.ui-add-button
{
display: flex;
}
.ui-add-button-items
{
display: grid;
grid-template-columns: 1fr 1px 1fr;
}
.ui-add-button-item
{
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 20px 10px;
font-size: var(--font-size);
border-radius: var(--radius);
}
.ui-add-button-item .ui-icon
{
margin-bottom: 12px;
}
.ui-add-button-item .is-primary
{
font-size: 24px;
color: var(--color-primary);
}
.ui-add-button-item:hover
{
background: var(--color-dropdown-selected);
}
.ui-add-button-items-line
{
display: block;
height: 100%;
background: var(--color-line);
}
</style>
@@ -1,166 +0,0 @@
<template>
<button :type="buttonType" class="ui-button has-state" :class="buttonClass" :disabled="disabled || state == 'loading' || !isDefaultState" @click="tryClick">
<span v-if="label" class="ui-button-text" v-localize:html="{ key: label, tokens: tokens }"></span>
<ui-icon v-if="caret" :symbol="caretSymbol" class="ui-button-caret" />
<ui-icon v-if="icon" :symbol="icon" class="ui-button-icon" :stroke="stroke" />
<span v-if="!isDefaultState" class="ui-button-state">
<i v-if="stateDisplay == 'loading'" class="ui-button-progress"></i>
<ui-icon v-if="stateDisplay == 'success'" symbol="fth-check" />
<ui-icon v-if="stateDisplay == 'error'" symbol="fth-x" />
</span>
</button>
</template>
<script>
const STATE_DEFAULT = 'default';
const STATE_LOADING = 'loading';
const STATE_SUCCESS = 'success';
const STATE_ERROR = 'error';
const STATES = [STATE_DEFAULT, STATE_LOADING, STATE_SUCCESS, STATE_ERROR];
export default {
name: 'uiButton',
props: {
label: {
type: [String, Object]
},
state: {
type: String,
default: STATE_DEFAULT
},
submit: {
type: Boolean,
default: false
},
type: {
type: String,
default: 'action'
},
icon: {
type: String
},
stroke: {
type: Number,
default: 2
},
caret: String,
caretPosition: {
type: String,
default: 'right'
},
disabled: Boolean,
stateDuration: {
type: Number,
default: 2000
},
ellipsis: {
type: Boolean,
default: false
},
attach: {
type: Boolean,
default: false
},
tokens: {
type: Object,
default: null
},
},
data: () => ({
stateDisplay: null,
stateTimeout: null
}),
created()
{
this.stateDisplay = this.state;
},
computed: {
buttonType()
{
return this.submit ? 'submit' : 'button';
},
buttonClass()
{
let classes = [];
classes.push('type-' + this.type.split(' ').join(' type-'));
classes.push('state-' + (this.isDefaultState ? STATE_DEFAULT : this.stateDisplay));
if (this.caret)
{
classes.push('caret-' + this.caretPosition);
}
if (this.icon)
{
classes.push('has-icon');
}
if (this.ellipsis)
{
classes.push('has-ellipsis');
}
if (!this.label)
{
classes.push('no-label');
}
if (this.attach)
{
classes.push('is-attached');
}
return classes;
},
caretSymbol()
{
return 'fth-chevron-' + this.caret;
},
isDefaultState()
{
return !this.stateDisplay || this.stateDisplay === STATE_DEFAULT || STATES.indexOf(this.stateDisplay) < 0;
}
},
watch: {
state(value)
{
this.stateDisplay = value;
clearTimeout(this.stateTimeout);
if (value && STATES.indexOf(value) < 0)
{
console.warn(`ui-button: Supported states are "${STATES.join('", "')}"`);
}
if (value === STATE_SUCCESS || value === STATE_ERROR)
{
this.stateTimeout = setTimeout(() =>
{
//const stateUpdate = 'update:state';
//// check if :state property has .sync modifier
//let listeners = this.$options._parentListeners;
//const hasSyncModifier = listeners && listeners[stateUpdate];
//if (!hasSyncModifier)
//{
// console.warn(`ui-button: Add the .sync modifier to the "state" property, as changing the state to "${STATE_SUCCESS}" or "${STATE_ERROR}" will automatically set it back to "${STATE_DEFAULT}" after the state duration timeout`);
//}
this.stateDisplay = STATE_DEFAULT;
}, this.stateDuration);
}
}
},
methods: {
tryClick(ev)
{
this.$emit('click', ev);
}
}
}
</script>
@@ -1,62 +0,0 @@
<template>
<div ref="calendar" class="ui-calendar"></div>
</template>
<script>
import flatpickr from 'flatpickr';
import { German } from "flatpickr/dist/l10n/de.js"
import { extendObject } from '../utils/objects';
import { useAccountStore } from '../account/store';
export default {
name: 'uiCalendar',
props: {
today: String,
options: {
type: Object,
default: () => { }
}
},
data: () => ({
date: null,
initialized: false
}),
mounted()
{
let vm = this;
if (this.initialized)
{
return;
}
const user = useAccountStore();
flatpickr(this.$refs.calendar, extendObject({
inline: true,
enableTime: true,
noCalendar: false,
time_24hr: true,
defaultDate: this.today,
minuteIncrement: 1,
locale: user.user && user.user.culture == 'de-DE' ? German : null,
onChange(dates)
{
vm.$emit('change', dates[0]);
},
}, this.options));
this.initialized = true;
}
}
</script>
<style lang="scss">
.ui-calendar
{
}
</style>
@@ -1,152 +0,0 @@
<template>
<div class="ui-check-list" :class="{'is-disabled': disabled, 'is-inline': inline }">
<label v-for="item in list" class="ui-native-check ui-check-list-item">
<input type="checkbox" :checked="isChecked(item)" @input="onChange(item)" :disabled="disabled" />
<span class="ui-native-check-toggle"></span>
<span v-localize="item[labelKey]"></span>
<span class="-desc" v-if="item[descriptionKey]" v-localize="item[descriptionKey]"></span>
</label>
</div>
</template>
<script>
export default {
name: 'uiCheckList',
props: {
value: {
type: Array,
default: () => []
},
items: {
type: [Array, Function, Promise],
required: true
},
disabled: {
type: Boolean,
default: false
},
inline: {
type: Boolean,
default: false
},
limit: {
type: Number,
default: 100
},
reverse: Boolean,
labelKey: {
type: String,
default: 'value'
},
descriptionKey: {
type: String,
default: 'description'
},
idKey: {
type: String,
default: 'key'
}
},
data: () => ({
list: []
}),
watch: {
items()
{
this.init();
}
},
mounted()
{
this.init();
},
methods: {
init()
{
if (typeof this.items === 'function')
{
this.items().then(res =>
{
this.list = res.data;
});
}
else
{
this.list = JSON.parse(JSON.stringify(this.items));
}
},
isChecked(item)
{
let index = this.value.indexOf(item[this.idKey]);
return (!this.reverse && index > -1) || (this.reverse && index < 0);
},
onChange(item)
{
let index = this.value.indexOf(item[this.idKey]);
let value = JSON.parse(JSON.stringify(this.value));
if (index < 0)
{
value.push(item[this.idKey]);
}
else
{
value.splice(index, 1);
}
this.$emit('input', value);
this.$emit('update:value', value);
}
}
}
</script>
<style lang="scss">
.ui-check-list-item
{
display: block;
}
.ui-check-list .ui-check-list-item + .ui-check-list-item
{
margin-top: 8px;
}
.ui-alias + .ui-check-list-item
{
margin-top: 14px;
}
.ui-check-list.is-inline .ui-check-list-item
{
display: inline-block;
}
.ui-check-list.is-inline .ui-check-list-item + .ui-check-list-item
{
margin-top: 0;
margin-left: 30px;
}
.ui-check-list.is-inline .ui-check-list-item .ui-native-check-toggle
{
margin-right: 6px;
}
.ui-check-list-item .-desc
{
display: inline-block;
color: var(--color-text-dim);
font-size: var(--font-size-s);
margin-left: 0.5em;
&:before { content: '('; }
&:after { content: ')'; }
}
</style>
@@ -1,123 +0,0 @@
<template>
<div class="ui-colorpicker" :class="{'is-disabled': disabled }">
<label class="ui-colorpicker-color" :for="id">
<span class="ui-colorpicker-color-preview" :style="{ 'background-color': value || 'var(--color-box)' }"></span>
<input :id="id" type="color" :value="value" :disabled="disabled" @input="onChange" />
</label>
<input type="text" maxlength="7" class="ui-colorpicker-input" :value="value" @input="onChange" :disabled="disabled" v-localize:placeholder="'@colorpicker.placeholder'" />
</div>
</template>
<script>
import { generateId } from '../utils/numbers';
export default {
name: 'uiColorpicker',
props: {
value: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
},
options: {
type: Object,
default: () =>
{
return {
};
}
}
},
data: () => ({
id: null
}),
created()
{
this.id = 'colorpicker-' + generateId();
},
methods: {
onChange(ev)
{
this.$emit('change', ev.target.value);
this.$emit('input', ev.target.value);
// TODO this does not trigger the forms dirty flag
}
//pick()
//{
// this.$el.
//}
}
}
</script>
<style lang="scss">
.ui-colorpicker
{
position: relative;
}
.ui-colorpicker-color
{
display: inline-block;
position: absolute;
overflow: hidden;
width: 32px;
height: 100%;
border-radius: 3px;
left: 0;
top: -1px;
padding: 0 !important;
cursor: pointer;
}
.ui-colorpicker-color-preview
{
position: absolute;
left: 12px;
top: 50%;
margin-top: -8px;
width: 16px;
height: 16px;
border-radius: 2px;
box-shadow: var(--shadow-short);
}
.ui-colorpicker-color input
{
opacity: 0 !important;
visibility: hidden !important;
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
}
input[type="text"].ui-colorpicker-input
{
padding-left: 40px;
max-width: 322px;
}
.ui-colorpicker-preview
{
display: inline-block;
margin-top: -11px;
border: 1px solid #eceaea;
cursor: pointer;
}
</style>
@@ -1,453 +0,0 @@
<template>
<div class="ui-datagrid-outer" :class="{'is-selecting': configuration.selectable && selected.length > 0}">
<div class="ui-datagrid">
<div class="ui-datagrid-items" v-if="!isLoading" :style="'grid-template-columns: repeat(auto-fill, minmax(' + configuration.width + 'px, 1fr))'" :class="{'is-block': configuration.block }">
<slot name="before"></slot>
<div class="ui-datagrid-item" v-for="(item, index) in items" :key="index" v-on:contextmenu="onRightClicked(item, $event)">
<button v-if="configuration.selectable && selected.length > 0" type="button" class="ui-datagrid-cell-select" @click.exact="select(item)" @click.shift="shiftSelect(item)"></button>
<slot :item="item" :selected="configuration.selectable && selected.indexOf(item) > -1"></slot>
</div>
<slot name="below"></slot>
</div>
<div class="ui-datagrid-empty" v-if="!isLoading && items.length < 1">
<ui-icon symbol="fth-list" :size="34" class="ui-datagrid-empty-icon" />
<span v-localize="'@ui.emptylist'"></span>
</div>
<div class="ui-datagrid-loading" v-if="isLoading">
<ui-loading />
</div>
</div>
<footer class="ui-datagrid-pagination" v-if="pages > 1">
<ui-pagination :pages="pages" :page="filter.page" @change="setPage" />
</footer>
<ui-dropdown ref="dropdown" align="top" theme="dark" class="ui-datagrid-dropdown">
<slot name="actions" v-bind="actionProps"></slot>
</ui-dropdown>
</div>
</template>
<script>
import UiPagination from './ui-pagination.vue';
import { debounce, extendObject } from '../utils';
import Qs from 'qs';
const defaultConfig = {
order: {
// allow sorting of columns (asc + desc)
enabled: true,
// default order by
by: 'createdDate',
// order is descending
isDescending: true
},
// desired width of an item
width: 280,
// scroll to top on page change
scrollToTop: true,
// promise which returns items based on the current filter and sorting
items: null,
// for block items
block: false,
// ability to select items
selectable: false,
// whether the query params should be set for page + search
setQuery: true,
// custom scroll container
scrollContainerSelector: null,
// filter
page: 1,
pageSize: 30
};
export default {
name: 'uiDatagrid',
props: {
value: {
type: Object,
required: true,
default: () =>
{
return defaultConfig;
}
}
},
components: { UiPagination },
watch: {
'$route': function (val)
{
this.load(true);
},
'value.search': function (val)
{
this.filter.search = val;
},
'filter.search': function (val)
{
//this.filter.page = 1;
this.debouncedUpdate();
}
},
data: () => ({
configuration: {},
items: [],
isLoading: true,
pages: 1,
count: 0,
loaded: false,
filter: {
orderBy: null,
orderIsDescending: true,
page: 1,
pageSize: 30,
search: null
},
debouncedUpdate: null,
actionProps: {
item: null,
selected: false
},
selected: []
}),
computed: {
actionsDefined()
{
return this.$slots.hasOwnProperty('actions');
}
},
mounted()
{
this.setup();
},
methods: {
async setup()
{
this.debouncedUpdate = debounce(this.update, 300);
this.configuration = extendObject(JSON.parse(JSON.stringify(defaultConfig)), this.value);
if (this.configuration.order.enabled)
{
this.filter.orderBy = this.configuration.order.by;
this.filter.orderIsDescending = this.configuration.order.isDescending;
}
await this.load(true);
},
// load items based on the current filter
async load(initial)
{
if (!this.loaded && !initial)
{
return;
}
if (initial)
{
this.filter.page = (!this.configuration.setQuery ? null : +this.$route.query.page) || this.configuration.page || 1;
this.filter.pageSize = this.configuration.pageSize || 30;
this.filter.search = (!this.configuration.setQuery ? null : this.$route.query.search) || this.configuration.search;
}
const result = await this.configuration.items(this.filter);
this.pages = result.paging.totalPages;
this.count = result.paging.totalItems;
this.$emit('count', this.count);
this.isLoading = false;
this.items = result.data;
if (!initial && this.configuration.scrollToTop)
{
let container = document.querySelector(this.configuration.scrollContainerSelector || '.app-main');
if (container)
{
this.$nextTick(() => container.scrollTo({ top: 0, behavior: 'smooth' }));
}
}
setTimeout(() =>
{
this.loaded = true;
}, 500);
},
// updates the list (debounced)
async update()
{
if (!this.isLoading)
{
await this.load();
}
},
// set the active page
setPage(index)
{
this.filter.page = index;
this.onChange();
},
// search
search(query)
{
this.filter.search = query;
this.onChange();
},
// sort by a column
sort(column)
{
if (this.filter.orderBy === column.field && this.filter.orderIsDescending)
{
this.filter.orderIsDescending = false;
}
else if (this.filter.orderBy === column.field)
{
this.filter.orderBy = null;
}
else
{
this.filter.orderBy = column.field;
this.filter.orderIsDescending = true;
}
this.onChange();
},
// right clicked on an item
onRightClicked(item, ev)
{
if (this.actionsDefined)
{
ev.preventDefault();
this.onActionsClicked(item, ev);
}
},
// a property has changed and therefore we update the URL and table content
onChange()
{
if (this.configuration.setQuery)
{
let params = {};
if (+this.filter.page > 1)
{
params.page = +this.filter.page;
}
if (this.filter.search)
{
params.search = this.filter.search;
}
const query = Qs.stringify(params);
const path = this.$route.href.split('?')[0] + (query ? '?' + query : '');
history.replaceState(null, null, path);
}
this.debouncedUpdate();
},
// actions button clicked on item
onActionsClicked(item, ev)
{
let dropdown = this.$refs.dropdown;
if (!this.actionsDefined || (typeof this.hasActions === 'function' && !this.hasActions(item)))
{
return;
}
this.actionProps.item = item;
this.actionProps.event = ev;
this.actionProps.selected = this.configuration.selectable && this.selected.indexOf(item) > -1;
dropdown.toggle();
if (!dropdown.open)
{
return;
}
this.$nextTick(() =>
{
let target = ev.target;
do
{
if (target.classList.contains('ui-datagrid-cell'))
{
break;
}
}
while (target = target.parentElement);
var width = 240;
var position = {
x: ev.pageX,
y: ev.pageY
};
let element = dropdown.$el.querySelector('.ui-dropdown');
element.style.top = position.y + 'px';
element.style.left = position.x + 'px';
element.style.width = width + 'px';
});
},
// toggle selection of an item
select(item)
{
if (!item)
{
if (this.selected.length >= this.items.length)
{
this.selected = [];
}
else
{
this.selected = [];
this.items.forEach(item =>
{
this.selected.push(item);
});
}
}
else
{
const index = this.selected.indexOf(item);
if (index > -1)
{
this.selected.splice(index, 1);
}
else
{
this.selected.push(item);
}
}
this.$emit('select', this.selected, this);
},
shiftSelect(item)
{
return;
// TODO implement shift selection (this is only a part of it)
// for selection area see Viselect https://simonwep.github.io/selection/
const last = this.selected[this.selected.length - 1];
if (last)
{
let startIndex = this.items.indexOf(last);
let endIndex = this.items.indexOf(item);
if (startIndex > endIndex)
{
const tempIndex = endIndex;
endIndex = startIndex;
startIndex = tempIndex;
}
for (let index = startIndex; index <= endIndex; index++)
{
this.selected.push(this.items[index]);
}
}
},
clearSelection()
{
this.selected = [];
this.$emit('select', this.selected, this);
}
}
}
</script>
<style lang="scss">
.ui-datagrid-items
{
display: grid;
gap: var(--padding-s);
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
align-items: stretch;
}
.ui-datagrid-items.is-block
{
display: block;
}
.ui-datagrid-item
{
position: relative;
}
.ui-datagrid-empty, .ui-datagrid-loading
{
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 250px;
text-align: center;
padding: 0 20px;
font-size: var(--font-size);
.ui-loading
{
left: 0;
}
}
.ui-datagrid-empty-icon
{
margin-bottom: 20px;
}
.ui-datagrid-dropdown .ui-dropdown
{
position: fixed;
min-width: 200px;
}
.ui-datagrid-cell-select
{
width: 100%;
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: transparent;
z-index: 2;
}
</style>
@@ -1,73 +0,0 @@
<template>
<span class="ui-date" v-html="output"></span>
</template>
<script>
import { formatDate } from '../utils/dates';
export default {
name: 'uiDate',
props: {
value: {
type: String,
default: null
},
format: {
type: String,
default: null
},
split: {
type: Boolean,
default: false
}
},
data: () => ({
output: null
}),
watch: {
value: function (value)
{
this.rebuild();
}
},
mounted()
{
this.rebuild();
},
methods: {
rebuild()
{
if (!this.value)
{
this.output = '';
return;
}
if (!this.split)
{
this.output = formatDate(this.value, this.format);
}
else
{
this.output = formatDate(this.value, 'short') + ' <span class="-minor">' + formatDate(this.value, 'time') + '</span>';
}
}
}
}
</script>
<style lang="scss">
.ui-date .-minor
{
color: var(--color-text-dim);
}
</style>
@@ -1,204 +0,0 @@
<template>
<div class="ui-datepicker" :class="{'is-disabled': disabled }">
<input type="text" class="ui-input ui-datepicker-input" v-localize:placeholder="placeholder" :value="output" @input="onChange" @focus="onFocus" @blur="onBlur" :disabled="disabled" />
<ui-icon v-if="!clear || !value" symbol="fth-calendar" class="ui-datepicker-icon" :size="17" />
<button v-if="clear && value" type="button" class="ui-datepicker-input-button" @click="clearInput"><ui-icon symbol="fth-x" :size="15" /></button>
<ui-dropdown ref="overlay" class="ui-datepicker-overlay" @opened="overlayOpened">
<ui-calendar :today="value" @change="onSelect" :options="pickerOptions" />
</ui-dropdown>
</div>
</template>
<script>
import { generateId } from '../utils/numbers';
import { formatDate, toIsoDate } from '../utils/dates';
const DATETIME_FORMAT = 'DD.MM.YY HH:mm';
const DATE_FORMAT = 'DD.MM.YY';
export default {
name: 'uiDatepicker',
props: {
value: {
type: String,
default: null
},
placeholder: {
type: String,
default: '@ui.date.select'
},
clear: {
type: Boolean,
default: true
},
disabled: {
type: Boolean,
default: false
},
format: {
type: String,
default: null
},
time: {
type: Boolean,
default: false,
},
maxDate: {
type: [String, Date],
default: null
},
minDate: {
type: [String, Date],
default: null
},
amPm: {
type: Boolean,
default: false
}
},
data: () => ({
id: null,
output: null,
pickerOptions: {}
}),
watch: {
value()
{
this.updateOutput();
},
format()
{
this.updateOutput();
}
},
mounted()
{
this.id = 'datepicker-' + generateId();
this.updateOptions();
this.updateOutput();
},
methods: {
updateOutput()
{
this.output = formatDate(this.value, this.format || (this.time ? DATETIME_FORMAT : DATE_FORMAT));
},
updateOptions()
{
this.pickerOptions = {
enableTime: this.time,
maxDate: this.maxDate,
minDate: this.minDate,
time_24hr: !this.amPm
};
},
onSelect(date)
{
let dateStr = toIsoDate(date);
//console.info(dateStr, date);
this.setValue(dateStr);
this.$refs.overlay.hide();
document.activeElement.blur();
},
onChange(ev)
{
this.setValue(ev.target.value);
},
setValue(value)
{
this.$emit('change', value);
this.$emit('input', value);
},
onFocus()
{
this.$refs.overlay.show();
},
onBlur()
{
if (!this.$refs.overlay.open)
{
this.$refs.overlay.hide();
}
},
clearInput()
{
this.setValue(null);
},
overlayOpened()
{
}
//pick()
//{
// this.$el.
//}
}
}
</script>
<style lang="scss">
.ui-datepicker
{
position: relative;
max-width: 260px;
}
input[type="text"].ui-datepicker-input
{
padding-right: 36px;
}
.ui-datepicker-icon
{
position: absolute;
right: 13px;
top: 50%;
margin-top: -8px;
}
.ui-datepicker-overlay .ui-dropdown
{
padding: 0;
}
.ui-datepicker-input-button
{
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 40px;
text-align: center;
font-size: var(--font-size);
padding-top: 1px;
}
</style>
@@ -1,66 +0,0 @@
<template>
<ui-form ref="form" class="ui-daterangepicker" v-slot="form">
<div class="ui-daterangepicker-items">
<div :class="{ 'ui-split': options.rangeEnd }">
<div class="ui-daterangepicker-group">
<ui-property :label="options.fromText" :vertical="true">
<ui-datepicker v-model="fromDate" v-bind="options" />
</ui-property>
</div>
<div class="ui-daterangepicker-group" v-if="options.rangeEnd">
<ui-property :label="options.toText" :vertical="true">
<ui-datepicker v-model="toDate" v-bind="options" />
</ui-property>
</div>
</div>
</div>
<div class="app-confirm-buttons">
<ui-button type="primary" label="@ui.confirm" @click="confirm"></ui-button>
<ui-button type="light" label="@ui.close" @click="config.close"></ui-button>
</div>
</ui-form>
</template>
<script>
export default {
props: {
model: Object,
config: Object,
},
data: () => ({
options: {},
disabled: false,
fromDate: null,
toDate: null
}),
mounted()
{
this.options = this.model.options;
this.fromDate = this.model.from;
this.toDate = this.model.to;
},
methods: {
confirm()
{
this.config.confirm({
from: this.fromDate,
to: this.toDate
}, this.config);
}
}
}
</script>
<style lang="scss">
.ui-daterangepicker
{
text-align: left;
}
h3.ui-daterangepicker-group-header
{
font-weight: 400;
font-size: var(--font-size);
}
</style>
@@ -1,157 +0,0 @@
<template>
<div class="ui-daterangepicker" :class="{'is-disabled': disabled }">
<button v-if="!inline" type="button" class="ui-link" @click="schedule" v-localize="scheduleLocalize" :disabled="disabled"></button>
<div v-if="inline && value" class="ui-daterangepicker-inline">
<div class="ui-daterangepicker-group">
<ui-property :vertical="true">
<ui-datepicker v-model="value.from" :time="time" />
</ui-property>
</div>
<div class="ui-daterangepicker-group" v-if="rangeEnd">
<ui-property :vertical="true">
<ui-datepicker v-model="value.to" :time="time" />
</ui-property>
</div>
</div>
</div>
</template>
<script lang="ts">
import { generateId } from '../utils/numbers';
import { formatDate, toIsoDate } from '../utils/dates';
import * as overlays from '../services/overlay';
const DATETIME_FORMAT = 'DD.MM.YY HH:mm';
const DATE_FORMAT = 'DD.MM.YY';
export default {
name: 'uiDaterangepicker',
props: {
value: {
type: Object,
default: {
from: null,
to: null
}
},
disabled: {
type: Boolean,
default: false
},
format: {
type: String,
default: null
},
time: {
type: Boolean,
default: false,
},
maxDate: {
type: [String, Date],
default: null
},
minDate: {
type: [String, Date],
default: null
},
fromLabel: {
type: String,
default: '@ui.date.range_from'
},
toLabel: {
type: String,
default: '@ui.date.range_to'
},
amPm: {
type: Boolean,
default: false
},
inline: {
type: Boolean,
default: false
},
rangeEnd: {
type: Boolean,
default: true
},
options: {
type: Object,
default: () => { }
}
},
data: () => ({
id: null,
output: null,
pickerOptions: {}
}),
computed: {
scheduleLocalize()
{
return {
key: !this.value || (!this.value.from && !this.value.to) ? '@ui.date.set' :
(this.value.from && !this.value.to ? '@ui.date.x' :
(!this.value.from && this.value.to ? '@ui.date.y' : '@ui.date.xtoy')),
tokens: {
x: this.value ? formatDate(this.value.from, this.format || (this.time ? DATETIME_FORMAT : DATE_FORMAT)) : null,
y: this.value ? formatDate(this.value.to, this.format || (this.time ? DATETIME_FORMAT : DATE_FORMAT)) : null
}
};
}
},
created()
{
this.id = 'daterangepicker-' + generateId();
},
methods: {
async schedule()
{
const result = await overlays.open({
component: DaterangepickerOverlay,
model: {
from: this.value.from,
to: this.value.to,
options: {
format: this.format,
time: this.time,
max: this.maxDate,
min: this.minDate,
fromText: this.fromLabel,
toText: this.toLabel,
amPm: this.amPm,
rangeEnd: this.rangeEnd
},
},
});
if (result.eventType == 'confirm')
{
this.$emit('change', result.value);
this.$emit('input', result.value);
}
}
}
}
</script>
<style lang="scss">
.ui-daterangepicker.is-primary .ui-link
{
color: var(--color-primary);
font-weight: 700;
text-decoration-color: var(--color-primary) !important;
}
.ui-daterangepicker-inline
{
display: flex;
gap: var(--padding-s);
}
</style>
@@ -1,38 +0,0 @@
<template>
<button type="button" class="ui-dot-button" :disabled="disabled" @click="tryClick" v-localize:title="title">
<span class="sr-only" v-localize="title"></span>
<ui-icon class="ui-button-icon" symbol="fth-more-horizontal" />
</button>
</template>
<script>
export default {
name: 'uiDotButton',
props: {
state: {
type: String,
default: 'default'
},
title: {
type: String,
default: '@ui.actions'
},
disabled: Boolean
},
mounted ()
{
},
methods: {
tryClick(ev)
{
this.$emit('click', ev);
}
}
}
</script>
@@ -1,289 +0,0 @@
<template>
<button v-if="!confirming" :disabled="disabled" type="button" @click.stop="onClick" class="ui-dropdown-button" :class="{ 'has-icon': icon, 'is-active': selected, 'is-multiline': multiline }">
<ui-icon v-if="icon" :symbol="icon" class="ui-dropdown-button-icon" />
<span class="-name"><ui-localize :value="label" /><span v-if="confirm && !confirming"> &hellip;</span></span>
<ui-icon v-if="selected" symbol="fth-check" class="ui-dropdown-button-selected" />
<i v-if="loading" class="ui-dropdown-button-progress"></i>
</button>
<div v-if="confirming" class="ui-dropdown-button ui-dropdown-button-confirmation">
<ui-icon v-if="icon" :symbol="icon" class="ui-dropdown-button-icon" />
<span class="-name"><ui-localize :value="label" /><span>?</span></span>
<ui-button type="small light" icon="fth-x" title="@ui.cancel" @click="confirming=false" />
<ui-button :type="negative ? 'small danger' : 'small primary'" icon="fth-check" title="@ui.ok" @click="onClick($event, true)" />
</div>
</template>
<script>
export default {
name: 'uiDropdownButton',
props: {
value: {
default: null
},
multiline: {
type: Boolean,
default: false
},
label: {
type: String,
required: true
},
icon: {
type: String
},
selected: {
type: Boolean,
default: false
},
confirm: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
negative: {
type: Boolean,
default: false
},
prevent: {
type: Boolean,
default: false
}
},
data: () => ({
dropdown: null,
loading: false,
confirming: false
}),
mounted ()
{
// find parent dropdown so we can pass it on item click
let current = this;
do
{
if (current.$options.name === 'uiDropdown')
{
this.dropdown = current;
break;
}
}
while (current = current.$parent);
if (!this.dropdown)
{
console.warn('ui-dropdown-button: Could not find parent <ui-dropdown />');
}
},
methods: {
onClick(e, confirmed)
{
var instance = this;
if (!this.loading && !this.disabled)
{
if (this.confirm && !confirmed)
{
this.confirming = true;
//var confirmed = window.confirm('confirm?');
return;
}
else
{
this.confirming = false;
}
if (!this.prevent && this.dropdown)
{
this.dropdown.hide();
}
let self = this;
this.$emit('click', this.value, {
dropdown: this.dropdown,
hide()
{
if (self.dropdown)
{
self.dropdown.hide();
}
instance.$emit('hide');
},
loading(isLoading)
{
instance.loading = isLoading;
}
});
}
}
}
}
</script>
<style lang="scss">
button.ui-dropdown-button, a.ui-dropdown-button
{
display: grid;
width: 100%;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
font-size: var(--font-size);
font-weight: 500;
padding: 0 16px;
height: 48px;
color: var(--color-text);
border-radius: var(--radius);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 100%;
.-minor
{
display: block;
font-size: var(--font-size-xs);
color: var(--color-text-dim-one);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.has-icon
{
grid-template-columns: 30px minmax(0, 1fr) auto;
&:not([disabled]):hover .ui-dropdown-button-icon
{
color: var(--color-text);
}
}
&.has-icon.is-negative:not([disabled]):hover .ui-dropdown-button-icon
{
color: var(--color-accent-error);
}
&.is-multiline
{
white-space: normal;
overflow: visible;
}
&:not([disabled]):hover, &.is-active, &:focus
{
background: var(--color-dropdown-selected);
color: var(--color-text);
//font-weight: 700;
.ui-dropdown-list-item-icon
{
color: var(--color-text);
}
}
&.is-active
{
font-weight: 700;
}
&.is-active .ui-dropdown-button-icon
{
color: var(--color-text);
}
&[disabled]
{
color: var(--color-text-dim);
cursor: default;
pointer-events: none;
.ui-dropdown-list-item-icon,
.ui-dropdown-button-icon
{
color: var(--color-text-dim);
}
}
& + .ui-dropdown-button
{
margin-top: 4px;
}
}
.ui-dropdown-button-icon
{
font-size: 18px;
line-height: 1;
font-weight: 400;
position: relative;
top: -1px;
color: var(--color-text);
}
.ui-dropdown-button-progress
{
width: 16px;
height: 16px;
z-index: 2;
border-radius: 40px;
border: 2px solid transparent;
border-left-color: var(--color-text);
opacity: 1;
will-change: transform;
animation: rotating .5s linear infinite;
transition: opacity .25s ease;
}
.ui-dropdown-button-confirmation
{
display: grid;
flex-grow: 0;
width: 100%;
grid-template-columns: 30px minmax(0, 1fr) auto auto;
gap: 6px;
align-items: center;
font-size: var(--font-size);
padding: 0 6px 0 16px;
height: 48px;
color: var(--color-text);
border-radius: var(--radius);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 300px;
.ui-button + .ui-button
{
margin-left: 2px;
}
.ui-button
{
width: 40px;
}
}
@keyframes rotating
{
from
{
-webkit-transform: rotate(0);
transform: rotate(0)
}
to
{
-webkit-transform: rotate(1turn);
transform: rotate(1turn)
}
}
</style>
@@ -1,25 +0,0 @@
<template>
<hr class="ui-dropdown-separator">
</template>
<script>
export default {
name: 'uiDropdownSeparator'
}
</script>
<style lang="scss">
.ui-dropdown-separator
{
border: none;
border-bottom: 1px solid var(--color-dropdown-line);
margin: 5px 0;
}
.ui-dropdown-separator + .ui-dropdown-separator
{
display: none;
}
</style>
@@ -1,199 +0,0 @@
<template>
<div class="ui-dropdown-container">
<slot name="before"></slot>
<div v-if="hasButton" ref="trigger" class="ui-dropdown-toggle" @click.prevent.stop="toggle">
<slot name="button"></slot>
</div>
<div class="ui-dropdown" ref="overlay" role="dialog" v-if="open" v-click-outside="hide" :class="dropdownClasses">
<slot></slot>
</div>
</div>
</template>
<script>
//import Overlay from 'zero/helpers/overlay.js';
import { defineComponent } from 'vue';
const varKey = 'zero.ui-dropdown.current';
export default defineComponent({
name: 'uiDropdown',
props: {
align: {
type: String,
default: 'left'
},
theme: {
type: String,
default: 'dark'
},
locked: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
},
computed: {
hasButton()
{
return this.$slots.hasOwnProperty('button');
},
dropdownClasses()
{
let classes = 'align-' + this.align.split(' ').join(' align-');
if (!!this.theme)
{
classes += ' theme-' + this.theme;
}
return classes;
}
},
data: () => ({
open: false
}),
methods: {
toggle()
{
if (this.open)
{
this.hide();
}
else if (!this.disabled)
{
this.show();
}
},
show()
{
if (this.disabled)
{
return;
}
if (this.zero.runtimeVariables[varKey])
{
this.zero.runtimeVariables[varKey].hide();
}
this.zero.runtimeVariables[varKey] = this;
//Overlay.setDropdown(this);
this.open = true;
//this.position();
this.$emit('opened');
},
hide()
{
if (this.locked)
{
return;
}
this.open = false;
this.$emit('closed');
this.zero.runtimeVariables[varKey] = null;
},
position()
{
this.$nextTick(() =>
{
// the trigger which is the relative to the overlay
const reference = this.$refs.trigger;
// get bounding boxes both for the trigger and the overlay
const triggerBoundingBox = reference.getBoundingClientRect();
const overlayBoundingBox = this.$refs.overlay.getBoundingClientRect();
const windowBox = { width: window.innerWidth, height: window.innerHeight };
const windowOffset = 32;
// calculate available space for the dropdown in all 4 directions areound the trigger element
let availableSpace = {
left: triggerBoundingBox.left + triggerBoundingBox.width - windowOffset,
right: windowBox.width - triggerBoundingBox.left - windowOffset,
top: triggerBoundingBox.top + triggerBoundingBox.height - windowOffset,
bottom: windowBox.height - triggerBoundingBox.top - windowOffset
};
console.table(availableSpace);
//console.info(triggerBoundingBox, overlayBoundingBox, windowBox);
//const resize_ob = new ResizeObserver(function (entries)
//{
// // since we are observing only a single element, so we access the first element in entries array
// let rect = entries[0].contentRect;
// // current width & height
// let width = rect.width;
// let height = rect.height;
// console.log('Current Width : ' + width);
// console.log('Current Height : ' + height);
//});
//// start observing for resize
//resize_ob.observe(document.querySelector("#demo-textarea"));
//resize_ob.unobserve(document.querySelector("#demo-textarea"));
});
}
}
});
</script>
<style lang="scss">
.ui-dropdown-container
{
position: relative;
}
.ui-dropdown
{
position: absolute;
min-width: 300px;
min-height: 20px;
background: var(--color-dropdown);
border-radius: var(--radius);
border: 1px solid var(--color-dropdown-border);
box-shadow: var(--shadow-dropdown);
z-index: 8;
top: calc(100% + 5px);
padding: 5px;
color: var(--color-text);
&.align-right
{
right: 0;
}
&.align-top
{
top: calc(100% + 5px);
bottom: auto;
}
&.align-bottom
{
bottom: calc(100% + 5px);
top: auto;
}
}
</style>
@@ -1,93 +0,0 @@
<template>
<div class="ui-error" v-if="visible">
<ui-message v-for="error in errors" :key="error.id" type="error" :text="error.message" :title="error.field" />
</div>
</template>
<script>
import { generateId } from '../utils/numbers';
export default {
name: 'uiError',
props: {
field: {
type: String,
default: ''
},
catchRemaining: {
type: Boolean,
default: false
},
catchAll: {
type: Boolean,
default: false
}
},
data: () => ({
errors: []
}),
mounted ()
{
},
computed: {
visible()
{
return this.errors && this.errors.length;
}
},
methods: {
// set and display errors
setErrors(errors, append)
{
if (!errors)
{
return this.clearErrors();
}
if (!Array.isArray(errors))
{
errors = [errors];
}
errors.forEach(error =>
{
error.id = generateId();
});
if (append)
{
errors.forEach(error =>
{
this.errors.push(error);
});
}
else
{
this.errors = errors;
}
},
// clear errors and hide
clearErrors()
{
this.errors = [];
}
}
}
</script>
<style lang="scss">
.ui-error + .editor
{
margin-top: var(--padding-m);
}
</style>
@@ -1,94 +0,0 @@
<template>
<ui-trinity class="ui-form-header-links-list">
<template v-slot:header>
<ui-header-bar title="@ui.links" :back-button="false" :close-button="true" @close="config.close(true)" />
</template>
<template v-slot:footer>
<ui-button type="light onbg" label="@ui.close" @click="config.close"></ui-button>
</template>
<div class="ui-form-header-links-list-items">
<a :href="urlPrefix + url" target="_blank" v-for="(url, index) in urls" class="ui-form-header-links-list-item">
<span class="-text">{{url}}</span>
<ui-icon symbol="fth-external-link" class="ui-form-header-links-list-item-icon" :size="16"></ui-icon>
</a>
</div>
</ui-trinity>
</template>
<script>
export default {
props: {
config: Object
},
data: () => ({
urls: [],
urlPrefix: ''
}),
mounted()
{
this.urlPrefix = this.config.model.urlPrefix || '';
this.urls = this.config.model.urls;
}
}
</script>
<style lang="scss">
.ui-form-header-links-list .ui-box
{
margin: 0;
}
.ui-form-header-links-list content
{
padding-top: 0;
}
.ui-form-header-links-list-item
{
display: grid;
width: 100%;
grid-template-columns: 1fr auto;
gap: var(--padding-xs);
align-items: center;
font-size: var(--font-size);
height: 60px;
color: var(--color-text);
position: relative;
padding: 0 var(--padding-s);
background: var(--color-box);
border-radius: var(--radius);
border: 2px solid transparent;
.-text
{
display: block;
padding: 12px 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
& + .ui-form-header-links-list-item
{
margin-top: 8px;
}
}
.ui-form-header-links-list-text
{
margin: 0 0 20px;
}
.ui-form-header-links-list-item-icon
{
position: relative;
top: -1px;
color: var(--color-text);
}
</style>
@@ -1,153 +0,0 @@
<template>
<!--<ui-dropdown-button v-if="hasPreview" label="@page.preview.title" icon="fth-eye" :disabled="disabled || !urlLink" @click="openPreview" />
<ui-dropdown-button v-if="hasLink" label="@ui.open.title" icon="fth-external-link" :disabled="disabled || !urlLink" @click="openLink" />-->
<a v-if="hasPreview" :href="previewLink" target="preview" :disabled="disabled || !urlLink" type="button" class="ui-dropdown-button has-icon" @click.prevent="openPreview">
<ui-icon symbol="fth-eye" class="ui-dropdown-button-icon" />
<span class="-name"><ui-localize value="@page.preview.title" /></span>
</a>
<a v-if="hasLink" :href="urlLinkAbsolute" target="_blank" :disabled="disabled || !urlLink" type="button" class="ui-dropdown-button has-icon" @click="dropdown.hide()">
<ui-icon symbol="fth-external-link" class="ui-dropdown-button-icon" />
<span class="-name"><ui-localize value="@ui.open.title" /></span>
<span class="-minor -link" v-if="urlLink && urlList.length > 1" @click.prevent.stop="showLinkList">{{urlList.length}} URLs</span>
</a>
<ui-dropdown-separator v-if="hasPreview || hasLink" />
</template>
<script>
import * as overlays from '../services/overlay';
export default {
name: 'uiFormHeaderLinks',
props: {
disabled: {
type: Boolean,
default: false
},
url: {
type: Function,
default: null
},
previewEnabled: {
type: Boolean,
default: false
},
value: {
type: Object,
default: null
}
},
data: () => ({
dropdown: null,
urlList: [],
urlDomain: null,
urlPreview: null,
failed: false
}),
watch: {
'value.lastModifiedDate'()
{
this.loadUrls();
}
},
mounted()
{
this.loadUrls();
let current = this;
do
{
if (current.$options.name === 'uiDropdown')
{
this.dropdown = current;
break;
}
}
while (current = current.$parent);
if (!this.dropdown)
{
console.warn('ui-dropdown-button: Could not find parent <ui-dropdown />');
}
},
computed: {
hasPreview()
{
return !this.failed && this.value && typeof this.url === 'function' && this.previewEnabled;
},
hasLink()
{
return !this.failed && this.value && typeof this.url === 'function';
},
urlLink()
{
return this.urlDomain && this.urlList.length ? this.urlList.reduce((a, b) => a.length <= b.length ? a : b) : null;
},
urlLinkAbsolute()
{
return this.urlDomain && this.urlList.length ? this.urlDomain + this.urlList.reduce((a, b) => a.length <= b.length ? a : b) : null;
},
previewLink()
{
return this.urlLink ? this.$router.resolve({ name: 'preview', query: { path: this.urlLink } }).href : null;
}
},
methods: {
async loadUrls()
{
if (typeof this.url !== 'function')
{
this.failed = true;
return;
}
const result = await this.url(this.value);
this.urlList = result.data.urls ? result.data.urls : (result.data.url ? [result.data.url] : []);
this.urlDomain = result.data.domain;
this.failed = this.urlList.length < 1 || !result.data.domain;
},
openPreview()
{
window.open(this.previewLink, 'preview');
this.dropdown.hide();
},
async showLinkList()
{
this.dropdown.hide();
await overlays.open({
component: () => import('./ui-form-header-links-overlay.vue'),
display: 'editor',
width: 620,
softdismiss: true,
model: {
urls: this.urlList,
urlPrefix: this.urlDomain
}
});
}
}
}
</script>
<style lang="scss">
.ui-dropdown-button .-link.-minor
{
display: inline-block;
padding: 10px 0;
&:hover
{
color: var(--color-text);
text-decoration: underline dotted var(--color-text);
}
}
</style>
@@ -1,266 +0,0 @@
<template>
<ui-header-bar class="ui-form-header" :back-button="true" :sticky="sticky">
<template v-slot:title>
<h2 class="ui-header-bar-title" :class="{'is-empty': title && !value.name && !titleDisabled}">
<template v-for="prefix in prefixes">
<span class="-minor -prefix" v-localize:html="prefix"></span>
<ui-icon class="-chevron" symbol="fth-chevron-right" :size="14" />
</template>
<ui-error field="name" />
<input v-if="!titleDisabled" class="ui-form-header-title-input" type="text" v-model="value.name" v-localize:placeholder="title" :readonly="titleDisabled || disabled" />
<!--<ui-alias class="ui-form-header-title-alias" v-if="hasAlias" v-model="value.alias" :name="value.name" :disabled="disabled" />-->
<span v-if="titleDisabled" v-localize="forceTitle ? title : (value.name || title)"></span>
</h2>
</template>
<div class="ui-form-header-aside">
<slot></slot>
<div v-if="!activeDisabled && typeof value.isActive !== 'undefined'" class="ui-form-header-toggle">
<ui-toggle v-model:on="value.isActive" class="is-accent" off-content="@ui.inactive" :off-warning="true" on-content="@ui.active" :content-left="true" :disabled="disabled" />
</div>
<slot name="buttons"></slot>
<ui-dropdown v-if="actionsDefined" align="right" theme="default">
<template v-slot:button>
<ui-button type="light onbg" icon="fth-more-horizontal" v-localize:title="'@ui.actions'" />
</template>
<slot name="actions"></slot>
<ui-dropdown-button v-if="canDelete" label="@ui.delete" icon="fth-trash" @click="onDelete" :disabled="disabled" />
<ui-dropdown-separator v-if="!isCreate" />
<div class="ui-form-info" v-if="!isCreate">
<div class="ui-form-info-item" v-if="value.createdDate"><span class="-key" v-localize="'@ui.createdDate'"></span><span class="-value"><ui-date v-model="value.createdDate" :split="true" format="long" /></span></div>
<div class="ui-form-info-item" v-if="value.lastModifiedDate"><span class="-key" v-localize="'@ui.modifiedDate'"></span><span class="-value"><ui-date v-model="value.lastModifiedDate" :split="true" format="long" /></span></div>
<!--<div class="ui-form-info-item" v-if="value.alias"><span class="-key" v-localize="'@ui.entityfields.alias'"></span><span class="-value">{{value.alias}}</span></div>-->
</div>
</ui-dropdown>
<ui-button :submit="true" type="accent" :label="isCreate ? '@ui.create' :'@ui.update'" :state="state" v-if="!disabled" class="ui-form-header-primary-button" />
</div>
</ui-header-bar>
</template>
<script>
export default {
name: 'uiFormHeader',
props: {
title: {
type: String,
default: null
},
forceTitle: {
type: Boolean,
default: false
},
prefix: {
type: [String, Array]
},
value: {
type: Object,
default: {}
},
canDelete: {
type: Boolean,
default: true
},
disabled: {
type: Boolean,
default: false
},
titleDisabled: {
type: Boolean,
default: false
},
activeDisabled: {
type: Boolean,
default: false
},
state: {
type: String
},
isCreate: {
type: Boolean,
default: false
},
hasAlias: {
type: Boolean,
default: true
},
sticky: {
type: Boolean,
default: false
}
},
computed: {
actionsDefined()
{
return !this.isCreate && (this.canDelete || this.$slots.hasOwnProperty('actions'));
},
prefixes()
{
let items = Array.isArray(this.prefix) ? this.prefix : [this.prefix];
return items.filter(x => !!x);
}
},
methods: {
onDelete(item, opts)
{
this.$emit('delete', item, opts);
}
}
}
</script>
<style lang="scss">
.ui-form-header
{
/* width: 100%;
max-width: 1320px;
margin: 0 auto;*/
}
.ui-form-header-aside
{
display: flex;
align-items: center;
justify-content: flex-end;
> * + *
{
margin-left: var(--padding-s);
}
}
.ui-form-header-toggle
{
display: inline-flex;
justify-content: center;
align-items: center;
position: relative;
top: -1px;
margin-left: var(--padding-s);
//margin-right: var(--padding-s);
.ui-toggle-off-warning
{
display: none;
color: var(--color-accent-red);
}
.ui-toggle-switch
{
background: var(--color-button-light-onbg);
box-shadow: var(--shadow-short) !important;
}
.ui-toggle-switch.is-active
{
background: var(--color-toggled);
box-shadow: none !important;
}
input:focus + .ui-toggle-switch
{
border-color: transparent;
}
& + .ui-button
{
margin-left: var(--padding);
}
}
.ui-header-bar-title
{
position: relative;
.ui-error
{
display: none;
}
.ui-error + input[type="text"].ui-form-header-title-input:not(:focus)
{
border-color: var(--color-accent-error);
}
}
input[type="text"].ui-form-header-title-input
{
font-family: var(--font);
color: var(--color-text);
font-size: var(--font-size-l);
font-weight: 700;
background: none;
border: 1px dashed var(--color-line-dashed-onbg);
/*&:hover, &:focus, .ui-header-bar-title.is-empty &
{
border: 1px dashed var(--color-text-dim-one);
}*/
.-prefix + &
{
margin-left: 5px;
}
&[readonly]:focus-visible
{
outline: none;
}
&[readonly]
{
cursor: default;
border-color: transparent;
padding-left: 0;
}
}
.ui-form-header-title-alias
{
position: absolute;
right: 10px;
top: 11px;
z-index: 2;
.ui-alias-lock
{
background: none;
}
}
.ui-form-header-info-button
{
padding: 0;
justify-content: center;
width: 48px;
text-align: center;
}
.ui-form-header-info-button .ui-button-icon
{
margin: 0 !important;
font-size: 18px;
}
.ui-form-info
{
padding: 16px;
display: flex;
flex-direction: column;
gap: var(--padding-xs);
font-size: var(--font-size-s);
}
.ui-form-info-item
{
display: flex;
justify-content: space-between;
.-key
{
color: var(--color-text-dim-one);
}
}
</style>
@@ -1,481 +0,0 @@
<template>
<form class="ui-form" @keydown="onKeydown" @submit.prevent="onSubmit" @change="onChange">
<slot v-if="loadingState === 'default'" v-bind="slotProps" />
<div v-if="loadingState == 'loading'" class="ui-form-loading">
<i class="ui-form-loading-progress"></i>
</div>
<div v-if="loadingState === 'error'">
error [not implemented]
</div>
<!--<form-error-view v-if="loadingState === 'error'" :error="loadingError" />-->
</form>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
//import FormErrorView from './form-error-view.vue';
import * as overlays from '../services/overlay';
import * as notifications from '../services/notification';
import { arrayGroupBy, selectorToArray } from '../utils';
export default defineComponent({
name: 'uiForm',
//components: { FormErrorView },
props: {
errorComponents: {
type: Array,
default: () => ['uiError']
},
inputComponents: {
type: Array,
default: () => ['uiProperty']
},
route: {
type: [String, Object],
default: null
}
},
data: () => ({
dirty: false,
loadingState: 'default',
loadingError: null,
state: 'default',
errors: [],
canEdit: true,
slotProps: {
state: null
},
submitBlocked: false
}),
watch: {
'$route': {
deep: true,
handler: function (val)
{
//console.info('route', val);
//this.$nextTick(() =>
//{
// this.$emit('load', this);
//});
}
},
state(val)
{
this.slotProps.state = val;
},
canEdit(val)
{
this.$nextTick(() =>
{
this.setCanEdit(val);
});
}
},
created()
{
this.slotProps.state = this.state;
this.$emit('load', this);
},
methods: {
// shows a confirmation dialog for dirty forms when the route tries to change
// it only works when this method is attached to the route component
beforeRouteLeave(to, from, next)
{
if (this.dirty)
{
Overlay.confirm({
title: '@unsavedchanges.title',
text: '@unsavedchanges.text',
confirmLabel: '@unsavedchanges.confirm',
closeLabel: '@unsavedchanges.close'
}).then(
() => next(false),
() =>
{
this.dirty = false;
next();
}
);
}
else
{
next()
}
},
// loads data on creation of the form
async load(promise)
{
this.loadingState = 'loading';
const response = await promise();
if (!response.success)
{
this.loadingState = 'error';
if (response.errors)
{
this.loadingError = response.errors[0].message;
}
return null;
}
this.canEdit = true;
this.loadingState = 'default';
this.$nextTick(() =>
{
this.$emit('loaded', this);
});
return response.data;
},
// handles a promise as result of the form submission
async handle(response, isCreate)
{
this.setState('loading');
this.clearErrors();
if (!response.success)
{
this.setState('error');
this.setErrors(response.errors);
return null;
}
this.setState('success');
this.setDirty(false);
if (response.data && this.route && response.status === 201)
{
let routeObj = typeof this.route === 'object' ? this.route : { name: this.route };
routeObj.params = routeObj.params || {};
routeObj.query = this.$route.query || {};
routeObj.params.id = response.data.id;
this.$router.replace(routeObj);
}
},
// submits the form
onSubmit(e)
{
if (!this.submitBlocked)
{
this.setState('loading');
this.$emit('submit', this, e);
}
},
// set the form to dirty when one of the fields changes
onChange(e)
{
this.dirty = true;
},
// handle delete event
async onDelete(promise, dontNavigate)
{
const overlay = await overlays.confirmDelete();
if (overlay.eventType == 'close')
{
return;
}
const opts = overlay.value;
opts.state('loading');
const response = await promise();
if (response.success)
{
opts.state('success');
opts.close();
if (!dontNavigate)
{
this.$router.go(-1);
}
notifications.success('@deleteoverlay.success', '@deleteoverlay.success_text');
return true;
}
else
{
opts.state('error');
opts.errors(response.errors);
return false;
}
},
// prevent submission of form on enter key press
onKeydown(e)
{
if (e.keyCode === 13)
{
this.submitBlocked = true;
clearTimeout(this.submitBlockedTimeout);
this.submitBlockedTimeout = setTimeout(() => this.submitBlocked = false, 300);
}
},
// set dirty status
setDirty(dirty)
{
this.dirty = dirty;
},
// set state for this component and all child components which attached it's state from the slot props
setState(state)
{
this.state = state;
},
// clears all errors from the form
clearErrors()
{
this.getErrorComponents().forEach(component =>
{
component.clearErrors();
if (component.tab)
{
component.tab.clearErrors();
}
});
},
// tries to find matching fields for the given errors and displays them
setErrors(errors)
{
if (typeof errors === 'undefined' || !errors)
{
this.errors = [];
}
else
{
this.errors = !Array.isArray(errors) ? [errors] : errors;
}
// get all components + grouped errors
let errorComponents = this.getErrorComponents();
let errorGroups = arrayGroupBy(this.errors, 'property');
let handledGroups = [];
//console.info({ errorComponents, errorGroups });
// set errors
errorComponents.forEach(component =>
{
let field = component.field;
if (field)
{
this.errors.forEach(error =>
{
let errorField = error.property;
let errorFieldSelector = selectorToArray(errorField);
// exact error field match
if (errorField == field)
{
handledGroups.push(field);
component.setErrors(error, true);
if (component.tab)
{
component.tab.setErrors(true);
}
}
// nested error
else if (errorFieldSelector[0] == field)
{
handledGroups.push(field);
component.setErrors(error, true);
if (component.tab)
{
component.tab.setErrors(true);
}
}
});
}
//if (field && errorGroups[field])
//{
// handledGroups.push(field);
// component.setErrors(errorGroups[field]);
// if (component.tab)
// {
// component.tab.setErrors(true);
// }
//}
});
for (var field in errorGroups)
{
let errorGroup = errorGroups[field];
if (handledGroups.indexOf(field) < 0)
{
errorComponents.forEach(component =>
{
if (component.catchRemaining || component.catchAll)
{
component.setErrors(errorGroup, true);
if (component.tab)
{
component.tab.setErrors(true);
}
}
});
}
}
},
// find all error components in form
getErrorComponents()
{
let errorComponents = [];
// find components which can output errors
let traverseChildren = (parent, tab) =>
{
parent.$children.forEach(component =>
{
if (this.errorComponents.indexOf(component.$options.name) > -1)
{
errorComponents.push({
name: component.$options.name,
field: component.field,
catchAll: component.catchAll,
catchRemaining: component.catchRemaining,
component: component,
setErrors: component.setErrors,
clearErrors: component.clearErrors,
tab: tab
});
}
else
{
traverseChildren(component, tab || (component.$options.name === 'uiTab' ? component : null));
}
});
};
traverseChildren(this);
return errorComponents;
},
// sets the form editing ability
setCanEdit(canEdit)
{
// find components which can output errors
//let traverseChildren = parent =>
//{
// parent.$children.forEach(component =>
// {
// const isValidComponent = this.inputComponents.indexOf(component.$options.name) > -1;
// //const field = component.field;
// if (typeof component._props.disabled === 'boolean')
// {
// component.$emit('update:disabled', canEdit);
// }
// else
// {
// traverseChildren(component);
// }
// //if (isErrorComponent)
// //{
// // errorComponents.push(component);
// //}
// //if (isErrorComponent && field)
// //{
// // let errorGroup = errorGroups[field];
// // if (errorGroup)
// // {
// // handledGroups.push(field);
// // component.set(errorGroup);
// // }
// //}
// //else
// //{
// // traverseChildren(component);
// //}
// });
//};
//traverseChildren(this);
//console.info('done');
}
}
});
</script>
<style lang="scss">
.ui-form
{
min-height: 100%;
font-size: var(--font-size);
}
.ui-form-loading
{
display: flex;
width: 100%;
height: 100vh;
align-items: center;
justify-content: center;
}
.ui-form-loading-progress
{
width: 32px;
height: 32px;
z-index: 2;
border-radius: 40px;
border: 2px solid var(--color-bg-shade-3);
border-left-color: var(--color-text);
opacity: 1;
will-change: transform;
animation: rotating .5s linear infinite;
transition: opacity .25s ease;
}
@keyframes rotating
{
from
{
-webkit-transform: rotate(0);
transform: rotate(0)
}
to
{
-webkit-transform: rotate(1turn);
transform: rotate(1turn)
}
}
</style>
@@ -1,228 +0,0 @@
<template>
<div class="ui-header-bar" :class="{ 'is-sticky': sticky }">
<div class="ui-header-bar-inner">
<div class="ui-header-bar-main">
<ui-icon-button v-if="backButton" type="light onbg" @click="onBack" :size="15" />
<div class="ui-header-bar-main-title">
<slot name="title">
<h2 class="ui-header-bar-title" :class="{'is-empty': !title && titleEmpty}">
<template v-for="prefix in prefixes">
<span class="-minor -prefix" v-localize:html="prefix"></span>
<ui-icon class="-chevron" symbol="fth-chevron-right" :size="14" />
</template>
<span v-localize="title || titleEmpty"></span>
<span v-if="suffix" class="-minor -suffix" v-localize:html="suffix"></span>
<span v-if="count > 0" class="-minor -count">{{count}}</span>
</h2>
</slot>
<p v-if="description" class="ui-header-bar-description" v-localize="description"></p>
</div>
</div>
<div class="ui-header-bar-aside">
<slot></slot>
<ui-icon-button class="ui-header-bar-close" v-if="closeButton" @click="$emit('close')" icon="fth-x" title="@ui.close" />
</div>
</div>
</div>
</template>
<script>
export default {
name: 'uiHeaderBar',
emits: ['close'],
props: {
title: {
type: String
},
titleEmpty: {
type: String
},
prefix: {
type: [String, Array]
},
suffix: {
type: String
},
count: {
type: Number,
default: 0
},
description: {
type: String
},
backButton: {
type: Boolean,
default: false
},
closeButton: {
type: Boolean,
default: false
},
sticky: {
type: Boolean,
default: false
}
},
computed: {
prefixes()
{
let items = Array.isArray(this.prefix) ? this.prefix : [this.prefix];
return items.filter(x => !!x);
}
},
methods: {
onBack()
{
this.$router.go(-1);
}
}
}
</script>
<style lang="scss">
.ui-header-bar
{
width: 100%;
height: 90px;
padding: 0 var(--padding) 0; //10px;
& + .ui-blank-box, & + .ui-box, & + .ui-view-box
{
margin-top: 0;
}
& + .ui-view-box
{
padding-top: 0;
}
.app-tree &
{
margin-bottom: 8px;
}
&.is-sticky
{
position: sticky;
top: 0;
z-index: 2;
background: var(--color-bg);
//width: auto;
//border-radius: var(--radius);
//box-shadow: 0 0 10px rgba(0,0,0,0.1);
//margin-bottom: var(--padding);
//background: linear-gradient(0deg, transparent, var(--color-bg) 30%);
}
}
.ui-header-bar-inner
{
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
height: 100%;
}
.ui-header-bar-main
{
display: flex;
align-items: center;
height: 100%;
flex: 1 0 auto;
overflow: hidden;
.ui-icon-button
{
margin-right: var(--padding-s);
flex-shrink: 0;
//margin-top: -2px;
}
}
.ui-header-bar-main-title
{
flex: 1 0 auto;
}
.ui-header-bar-aside
{
display: flex;
align-items: center;
height: 100%;
flex-shrink: 0;
padding-left: var(--padding-s);
> * + *
{
margin-left: var(--padding-s);
}
}
.ui-header-bar-title
{
font-family: var(--font);
color: var(--color-text);
margin: 0;
font-size: var(--font-size-xl);
font-weight: 700;
display: flex;
align-items: center;
&.is-empty, .-minor
{
color: var(--color-text-dim);
font-weight: 400;
}
.-prefix
{
flex-shrink: 0;
}
.-chevron
{
color: var(--color-text-dim);
position: relative;
top: -1px;
margin: 0 var(--padding-xxs);
}
.-count
{
display: inline-block;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
background: var(--color-box);
//box-shadow: var(--shadow-short);
color: var(--color-text);
height: 22px;
line-height: 22px;
padding: 0 10px;
border-radius: 16px;
letter-spacing: .5px;
font-style: normal;
margin-left: 12px;
margin-top: 2px;
position: relative;
top: -1px;
}
}
.ui-header-bar-description
{
font-size: var(--font-size-s);
color: var(--color-text-dim);
margin: 2px 0 0;
}
.ui-header-bar-close
{
background: transparent !important;
}
</style>
@@ -1,43 +0,0 @@
<template>
<button type="button" class="ui-icon-button" :disabled="disabled" :class="'type-' + type.split(' ').join(' type-')" @click="$emit('click', $event)" v-localize:title="title">
<span class="sr-only" v-localize="title"></span>
<ui-icon class="ui-button-icon" :symbol="icon" :size="size" :stroke="stroke" />
</button>
</template>
<script>
export default {
name: 'uiIconButton',
emits: ['click'],
props: {
state: {
type: String,
default: 'default'
},
icon: {
type: String,
default: 'fth-arrow-left'
},
stroke: {
type: Number,
default: 2
},
type: {
type: String,
default: 'action'
},
title: {
type: String,
default: ''
},
size: {
type: Number,
default: 14
},
disabled: Boolean
}
}
</script>
@@ -1,68 +0,0 @@
<template>
<svg class="ui-icon" :width="size" :height="size" :stroke-width="stroke" :data-symbol="symbolName" :class="classes">
<use v-show="!isFlag" v-bind="{ 'xlink:href': href }" />
</svg>
</template>
<script lang="ts">
export default {
props: {
symbol: {
type: String,
default: null,
required: true
},
file: {
type: String,
default: null
},
size: {
type: Number,
default: 17
},
stroke: {
type: Number,
default: 2
}
},
computed: {
symbolName()
{
return this.symbol && this.symbol.split(' ')[0].trim();
},
classes()
{
return this.symbol ? this.symbol.split(' ').slice(1) : [];
},
isFlag()
{
return this.symbol && this.symbol.indexOf('flag') === 0;
},
href()
{
return (this.file || '') + '#' + this.symbolName;
}
}
}
</script>
<style lang="scss">
.ui-icon
{
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
//fill: var(--color-bg-shade-3);
fill: none;
}
.ui-icon[data-symbol="fth-waffle"]
{
stroke: none;
fill: currentColor;
}
</style>
@@ -1,221 +0,0 @@
<template>
<ui-trinity class="ui-iconpicker-overlay">
<template v-slot:header>
<ui-header-bar title="@iconpicker.title" :back-button="false" :close-button="true" @close="config.close(true)" />
</template>
<template v-slot:footer>
<ui-button type="light onbg" label="@ui.close" @click="config.close"></ui-button>
<ui-button type="light onbg" label="@ui.remove" @click="remove()"></ui-button>
</template>
<ui-search class="ui-iconpicker-overlay-search onbg" v-model="query" />
<div class="ui-iconpicker-overlay-colors" v-if="config.model.colors">
<i v-for="col in colors" :class="{ 'is-active': ('color-' + col) === color || (col === 'default' && !color), ['bg-color-' + col]: true }" @click="selectColor(col)" :title="col"></i>
</div>
<!--<div class="ui-iconpicker-overlay-size">
<input type="range" min="14" max="48" step="2" v-model.number="size" />
</div>-->
<!--<hr class="ui-iconpicker-overlay-line">-->
<div class="ui-iconpicker-overlay-items" :style="{ 'grid-template-columns': 'repeat(' + columns + ', 1fr)' }">
<button v-for="item in items" type="button" class="ui-iconpicker-overlay-item" :class="{ 'is-active': item === icon, [color]: item === icon }" :title="item" @click="select(item)">
<ui-icon :symbol="item" :size="size" />
<!--<i :class="item"></i>-->
</button>
</div>
</ui-trinity>
</template>
<script>
import { debounce } from '../utils';
export default {
props: {
config: Object
},
data: () => ({
value: null,
file: null,
colors: ['default', 'gray', 'blue-gray', 'blue', 'teal', 'green', 'lime', 'yellow', 'orange', 'red', 'purple', 'brown'],
icon: null,
color: null,
query: '',
set: null,
items: [],
size: 20
}),
watch: {
value()
{
this.init();
},
query()
{
this.debouncedSearch();
}
},
computed: {
columns()
{
return ~~(580 / (this.size + 45));
}
},
created()
{
this.debouncedSearch = debounce(this.search, 100);
this.set = this.config.model.set;
this.value = this.config.model.value;
this.items = this.set.icons;
this.init();
},
mounted()
{
let $selected = this.$el.querySelector('.ui-iconpicker-overlay-item.is-active');
if ($selected)
{
let $scrollable = this.$el.querySelector('content');
const offset = $selected.offsetTop - $scrollable.clientHeight * 0.5 - 30;
$scrollable.scrollTop = offset < 0 ? 0 : offset;
}
},
methods: {
confirm()
{
const result = (this.icon || '') + ' ' + (this.color || '').trim();
this.config.confirm(result);
},
select(item)
{
this.icon = item;
this.confirm();
},
selectColor(color)
{
this.color = color === 'default' ? null : 'color-' + color;
},
init()
{
if (!this.value)
{
this.icon = null;
this.color = null;
}
else
{
const parts = this.value.split(' ');
this.icon = parts[0];
this.color = parts.length > 1 ? parts[1] : null;
}
},
search()
{
const query = this.query;
if (!query)
{
this.items = this.set.icons;
}
else
{
this.items = this.set.icons.filter(item => item.toLowerCase().indexOf(query) > -1);
}
},
remove()
{
this.config.confirm(null);
}
}
}
</script>
<style lang="scss">
.ui-iconpicker-overlay content
{
padding-top: 0;
}
.ui-iconpicker-overlay-items
{
display: grid;
grid-template-columns: repeat(auto-fill, 61px);
grid-gap: 8px;
align-items: stretch;
}
.ui-iconpicker-overlay-item
{
display: flex;
align-items: center;
justify-content: center;
padding: 22px 0;
border-radius: var(--radius);
&:hover
{
background: var(--color-box);
box-shadow: var(--shadow-short);
}
&.is-active
{
background: var(--color-primary);
color: var(--color-primary-text);
box-shadow: var(--shadow-short);
}
&.is-active
{
//color: var(--color-primary);
}
}
.ui-iconpicker-overlay-search
{
margin-bottom: 20px;
}
.ui-iconpicker-overlay-colors
{
text-align: center;
display: flex;
justify-content: space-between;
align-items: center;
margin: 0 20px;
i
{
display: inline-block;
width: 16px;
height: 16px;
border-radius: 20px;
cursor: pointer;
transition: transform 0.2s ease;
&.is-active
{
transform: scale(1.4);
}
}
}
.ui-iconpicker-overlay-line
{
}
</style>
@@ -1,127 +0,0 @@
<template>
<div v-if="render" class="ui-iconpicker" :class="{'is-disabled': disabled }">
<input ref="input" type="hidden" :value="value" />
<ui-select-button :icon="previewIcon" label="@ui.icon" :description="buttonDescription" @click="pick" :disabled="disabled" />
</div>
</template>
<script>
import { open as openOverlay } from '../services/overlay';
import { extendObject } from '../utils';
import { useUiStore } from '../ui/store';
import * as notifications from '../services/notification';
import { localize } from '../services/localization';
export default {
name: 'uiIconpicker',
props: {
value: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
},
colors: {
type: Boolean,
default: false
},
render: {
type: Boolean,
default: true
},
set: {
type: String,
default: 'feather'
},
options: {
type: Object,
default: () =>
{
return {
};
}
}
},
data: () => ({
iconSet: null,
store: null
}),
watch: {
set()
{
this.loadSet();
}
},
created()
{
this.store = useUiStore();
this.loadSet();
},
computed: {
buttonDescription()
{
return this.value ? this.value.split(' ')[0] : '@ui.icon_select';
},
previewIcon()
{
return this.value || 'fth-plus';
}
},
methods: {
onChange(value)
{
this.$emit('change', value);
this.$emit('input', value);
this.$emit('update:value', value);
// TODO this does not trigger the forms dirty flag
},
loadSet()
{
const alias = this.set || 'feather';
this.iconSet = this.store.iconSets.find(x => x.alias === alias);
},
async pick()
{
if (this.disabled)
{
return;
}
if (!this.iconSet)
{
notifications.error("@iconpicker.notfound.title", localize("@iconpicker.notfound.text", { tokens: { name: this.set || '' } }));
return;
}
const result = await openOverlay(extendObject({
model: {
set: this.iconSet,
value: this.value,
colors: this.colors
},
component: () => import('./ui-iconpicker-overlay.vue'),
display: 'editor',
width: 660
}, typeof this.options === 'object' ? this.options : {}));
if (result.eventType === 'confirm')
{
this.onChange(result.value);
}
}
}
}
</script>
@@ -1,158 +0,0 @@
<template>
<div class="ui-inline-tabs">
<nav role="tablist" class="ui-inline-tabs-list">
<button type="button" v-for="(tab, index) in tabs" class="ui-inline-tabs-list-item" :key="index" :class="{ 'is-active': tab.active, 'has-errors': tab.error }" :disabled="tab.disabled"
:aria-selected="tab.active" role="tab" @click="select(index)">
<i v-if="tab.error" class="ui-inline-tabs-list-item-error fth-alert-circle"></i>
<span v-localize="tab.label"></span>
<i v-if="forceCount || tab.count > 0" class="ui-inline-tabs-list-item-count">{{tab.count}}</i>
</button>
</nav>
<div class="ui-inline-tabs-items">
<slot />
</div>
</div>
</template>
<script>
export default {
name: 'uiInlineTabs',
props: {
cache: {
type: String
},
active: {
type: Number,
default: 0
},
forceCount: {
type: Boolean,
default: false
}
},
data: () => ({
storageKey: null,
tabs: []
}),
created()
{
this.cacheKey = this.cache ? `zero.ui-inline-tabs.cache.${this.cache}` : null;
this.tabs = this.$children;
},
mounted()
{
if (this.cache)
{
const cachedActiveTab = localStorage.getItem(this.cacheKey);
if (cachedActiveTab !== null)
{
this.select(+cachedActiveTab);
return;
}
}
this.select(this.active);
},
methods: {
select(index, ev)
{
if (ev)
{
ev.preventDefault();
}
const currentTab = this.tabs[index];
if (currentTab.disabled)
{
return;
}
this.tabs.forEach((tab, tabIndex) =>
{
tab.active = index === tabIndex;
});
if (this.cache)
{
localStorage.setItem(this.cacheKey, index);
}
//this.$emit('changed', { tab: selectedTab });
//this.activeTabHash = selectedTab.hash;
//this.activeTabIndex = this.getTabIndex(selectedTabHash);
//this.lastActiveTabHash = this.activeTabHash = selectedTab.hash;
//expiringStorage.set(this.storageKey, selectedTab.hash, this.cacheLifetime);
}
}
}
</script>
<style lang="scss">
.ui-inline-tabs-list
{
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: var(--padding);
margin-left: -2px;
}
.ui-inline-tabs-list-item
{
display: inline-flex;
align-items: center;
padding: 6px 12px;
background: transparent;
border-radius: 30px;
font-size: var(--font-size);
.ui-inline-tabs-list-item-count
{
display: inline-flex;
justify-content: center;
align-items: center;
width: 20px;
height: 20px;
border-radius: 10px;
background: var(--color-button-light);
margin-left: 12px;
margin-right: -6px;
font-size: 11px;
line-height: 1;
color: var(--color-text-dim);
font-style: normal;
}
&.is-active
{
background: var(--color-button-light);
font-weight: 600;
.ui-inline-tabs-list-item-count
{
background: var(--color-box);
color: var(--color-text);
}
}
}
.ui-inline-tabs-list-item-error
{
display: inline-block;
float: left;
font-size: 16px;
margin-right: 6px;
margin-left: -4px;
position: relative;
margin-top: -4px;
top: 1px;
}
</style>
@@ -1,149 +0,0 @@
<template>
<div class="ui-input-list" :class="{'is-disabled': disabled }">
<div class="ui-input-list-items" v-sortable="{ handle: '.is-handle', onUpdate: onSortingUpdated, enabled: !disabled }">
<div v-for="item in items" :key="item.id" class="ui-input-list-item">
<button type="button" class="ui-input-list-sort is-handle" tabindex="-1">
<ui-icon symbol="fth-grip-vertical" />
</button>
<input v-model="item.value" type="text" class="ui-input" :maxlength="maxLength" :readonly="disabled" @change="onChange(items)" size="5" />
<ui-icon-button type="light" icon="fth-x" @click="removeItem(item)" v-if="!disabled" tabindex="-1" />
</div>
</div>
<ui-button v-if="!disabled && !plusButton" type="light" :label="addLabel" @click="addItem" />
<ui-select-button v-if="!disabled && plusButton" icon="fth-plus" :label="items.length > 0 ? null : addLabel" @click="addItem" />
</div>
</template>
<script>
import { arrayMove, generateId } from '../utils';
export default {
name: 'uiInputList',
props: {
addLabel: {
type: String,
default: '@ui.add'
},
value: {
type: Array,
default: () => []
},
disabled: {
type: Boolean,
default: false
},
maxItems: {
type: Number,
default: 100
},
maxLength: {
type: Number,
default: 200
},
plusButton: {
type: Boolean,
default: false
},
},
data: () => ({
items: []
}),
watch: {
value(value)
{
this.$nextTick(() =>
{
this.items = (value || []).map(item =>
{
return { id: generateId(), value: item };
});
});
}
},
mounted()
{
this.items = (this.value || []).map(item =>
{
return { id: generateId(), value: item };
});
},
methods: {
onChange(items)
{
let value = items.map(item => item.value);
this.$emit('input', value);
this.$emit('update:value', value);
},
addItem()
{
this.items.push({
id: generateId(),
value: ''
});
this.onChange(this.items);
this.$nextTick(() =>
{
this.$el.querySelector('.ui-input-list-item:last-of-type input').focus();
});
},
removeItem(item)
{
const index = this.items.indexOf(item);
this.items.splice(index, 1);
this.onChange(this.items);
},
onSortingUpdated(ev)
{
let items = arrayMove(this.items, ev.oldIndex, ev.newIndex);
this.onChange(items);
}
}
}
</script>
<style lang="scss">
.ui-input-list
{
}
.ui-input-list-item
{
display: grid;
grid-template-columns: auto 1fr auto;
background: var(--color-input);
border-radius: var(--radius);
margin-bottom: 6px;
.ui-input-list.is-disabled &
{
background: transparent;
}
.ui-icon-button
{
height: 48px;
width: 48px;
border-left: none;
background: transparent !important;
box-shadow: none;
}
.ui-icon-button + .ui-icon-button
{
margin-left: 0;
}
}
button.ui-input-list-sort
{
height: 100%;
width: 30px;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
cursor: grab;
}
</style>
@@ -1,76 +0,0 @@
<template>
<a v-if="isExternalLink"
v-bind="attrs"
class="ui-link"
:class="classes"
target="_blank"
rel="noopener noreferrer"
:href="to"
:tabindex="disabled ? -1 : undefined"
:aria-disabled="disabled">
<slot />
</a>
<a v-else
v-bind="attrs"
class="ui-link"
:class="classes"
:href="href"
:tabindex="disabled ? -1 : undefined"
:aria-disabled="disabled"
@click="navigate">
<slot />
</a>
</template>
<script lang="ts">
import { RouterLinkProps, RouteLocationRaw, START_LOCATION, useLink, useRoute } from 'vue-router';
import { computed, defineComponent, toRefs, PropType } from 'vue';
export default defineComponent({
name: 'ui-link',
props: {
to: {
type: [String, Object] as PropType<RouteLocationRaw>,
required: true,
},
replace: Boolean,
custom: Boolean,
ariaCurrentValue: {
type: String as PropType<RouterLinkProps['ariaCurrentValue']>,
default: 'page',
},
disabled: Boolean
},
setup(props, { attrs })
{
const { replace, to, disabled } = toRefs(props);
const isExternalLink = computed(
() => typeof to.value === 'string' && to.value.startsWith('http')
);
const currentRoute = useRoute();
const { route, href, isActive, isExactActive, navigate } = useLink({
to: computed(() => (isExternalLink.value ? START_LOCATION : to.value)),
replace,
});
const classes = computed(() => ({
'is-active': isActive.value || currentRoute.path.startsWith(route.value.path),
'is-active-exact': isExactActive.value || currentRoute.path === route.value.path
}));
return { attrs, isExternalLink, href, navigate, classes, disabled };
},
})
</script>
<style lang="scss">
.ui-link[aria-disabled]
{
pointer-events: none;
}
</style>
@@ -1,64 +0,0 @@
<template>
<i class="ui-loading" :class="{'is-big': isBig || big, 'is-small': small }"></i>
</template>
<script lang="ts">
export default {
props: {
isBig: {
type: Boolean,
default: false
},
big: {
type: Boolean,
default: false
},
small: {
type: Boolean,
default: false
}
}
}
</script>
<style lang="scss">
.ui-loading
{
display: inline-block;
width: 28px;
height: 28px;
border-radius: 40px;
//border: 2px solid var(--color-box);
//border-left-color: var(--color-text);
border: 2px solid transparent;
border-left-color: var(--color-text);
will-change: transform;
animation: loadingRotation 0.8s linear infinite;
&.is-big
{
width: 36px;
height: 36px;
border-width: 2px;
}
&.is-small
{
width: 18px;
height: 18px;
}
}
@keyframes loadingRotation
{
0%
{
transform: rotate(0)
}
100%
{
transform: rotate(1turn)
}
}
</style>
@@ -1,38 +0,0 @@
<template>
<span>{{output}}</span>
</template>
<script>
import { localize } from '../services/localization';
export default {
name: 'uiLocalize',
props: {
value: {
type: String,
default: null
},
force: {
type: Boolean,
default: false
},
tokens: {
type: Object,
default: null
},
hideEmpty: {
type: Boolean,
default: false
}
},
computed: {
output()
{
return localize(this.value, { tokens: this.tokens, force: this.force, hideEmpty: this.hideEmpty });
}
}
}
</script>
@@ -1,128 +0,0 @@
<template>
<p class="ui-message" :class="messageClasses">
<ui-icon v-if="iconClass" class="ui-message-icon" :symbol="iconClass" />
<span class="ui-message-text" v-localize:html="text"></span>
</p>
</template>
<script>
const TYPE_ICONS = {
neutral: 'fth-info',
info: 'fth-info',
primary: 'fth-info',
warn: 'fth-alert-circle',
error: 'fth-alert-circle',
success: 'fth-check-circle'
};
export default {
name: 'uiMessage',
props: {
type: {
type: String,
default: 'info'
},
text: {
type: String,
required: true
},
icon: {
type: String,
default: '-1'
},
block: {
type: Boolean,
default: true
}
},
computed: {
iconClass()
{
return this.icon !== '-1' ? this.icon : TYPE_ICONS[this.type];
},
messageClasses()
{
return [
'type-' + this.type,
(this.block ? 'block' : null)
];
}
},
mounted ()
{
},
methods: {
}
}
</script>
<style lang="scss">
.ui-message
{
font-size: var(--font-size-s);
background: var(--color-accent-info-bg);
color: var(--color-accent-info);
display: inline-grid;
padding: 12px 12px 11px 12px;
grid-template-columns: auto 1fr;
gap: 12px;
border-radius: var(--radius-inner);
position: relative;
line-height: 20px;
text-align: left;
&.type-warn
{
background: var(--color-accent-warn-bg);
color: var(--color-accent-warn);
}
&.type-error
{
background: var(--color-accent-error-bg);
color: var(--color-accent-error);
}
&.type-success
{
background: var(--color-accent-success-bg);
color: var(--color-accent-success);
}
&.type-neutral
{
background: var(--color-box-nested);
color: var(--color-text);
}
&.type-primary
{
background: var(--color-primary-low);
color: var(--color-primary);
}
&.block
{
display: grid;
}
}
.ui-message-icon
{
font-size: 1.3em;
position: relative;
top: 1px;
}
.ui-message-text
{
}
</style>
@@ -1,101 +0,0 @@
<template>
<div class="ui-pagination" v-if="pages > 1" :class="{'is-inline': inline }">
<ui-icon-button class="ui-pagination-prev ui-pagination-button" :type="buttonType" title="@ui.pagination.previous" icon="fth-chevron-left" :disabled="page < 2" @click="set(page - 1)" />
<div class="ui-pagination-select">
<select :value="page" @change="selectChanged">
<option v-for="value in values" v-bind:value="value">{{value}}</option>
</select>
<button type="button" class="ui-button type-blank caret-down">
<span class="ui-button-text" v-localize="{ key: '@ui.pagination.xofy', tokens: { x: page, y: pages }}"></span>
<ui-icon class="ui-button-caret" symbol="fth-chevron-down" />
</button>
</div>
<ui-icon-button class="ui-pagination-next ui-pagination-button" :type="buttonType" title="@ui.pagination.next" icon="fth-chevron-right" :disabled="page >= pages" @click="set(page + 1)" />
</div>
</template>
<script>
export default {
name: 'uiPagination',
props: {
pages: {
type: Number,
default: 1
},
page: {
type: Number,
default: 1
},
inline: {
type: Boolean,
default: false
}
},
computed: {
values: function ()
{
return Array.apply(null, Array(this.pages)).map(function (_, i) { return i + 1; });
},
buttonType()
{
return this.inline ? 'light' : 'light onbg';
}
},
methods: {
set(page)
{
this.$emit('update:page', page);
this.$emit('change', page);
},
selectChanged(ev, a)
{
this.set(+ev.target.value);
}
}
}
</script>
<style lang="scss">
.ui-pagination
{
display: flex;
justify-content: center;
margin-top: var(--padding);
align-items: center;
}
.ui-pagination-select
{
margin: 0 30px;
position: relative;
.ui-button
{
padding: 0 2px;
}
select
{
width: 100%;
position: absolute;
z-index: 1;
left: 0;
right: 0;
top: 0;
bottom: 0;
opacity: 0;
}
}
.ui-pagination-button[disabled] .ui-button-icon
{
color: var(--color-text-dim);
}
</style>
@@ -1,90 +0,0 @@
<template>
<div>
<div class="ui-password-hash" :class="{ 'is-reloading': reloading }">
<input value="******************" v-if="!changing" type="text" class="ui-input" readonly="readonly" disabled="disabled" />
<input v-model="password" v-else type="text" readonly="readonly" class="ui-input" @click="$event.target.select()" />
<ui-button v-if="!disabled" :disabled="reloading" icon="fth-rotate-ccw" v-localize:title="'@changepasswordoverlay.regenerate'" type="light" @click="getRandomPassword" />
</div>
<p v-if="changing" class="ui-property-help" v-localize="'@changepasswordoverlay.regenerate_help'"></p>
</div>
</template>
<script>
import api from '../modules/users/api';
export default {
props: {
value: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
},
placeholder: {
type: [String, Function],
default: null
},
entity: Object
},
data: () => ({
reloading: false,
password: null,
changing: false
}),
methods: {
async getRandomPassword()
{
this.reloading = true;
const result = await api.getRandomPassword(32);
this.password = result.data.password;
let passedValue = 'raw:' + this.password;
this.$emit('input', passedValue);
this.$emit('update:value', passedValue);
this.changing = true;
this.reloading = false;
}
}
}
</script>
<style lang="scss">
.ui-password-hash
{
display: grid;
grid-template-columns: 1fr auto;
grid-gap: var(--padding-xxs);
&.is-reloading .ui-icon
{
animation: rotating 1s linear infinite reverse;
}
@keyframes rotating
{
from
{
-webkit-transform: rotate(0);
transform: rotate(0)
}
to
{
-webkit-transform: rotate(1turn);
transform: rotate(1turn)
}
}
& + .ui-property-help
{
margin: 10px 0 0;
}
}
</style>
@@ -1,818 +0,0 @@
<template>
<div class="ui-pick" :class="{'is-disabled': disabled, 'is-combined': configuration.preview.combined }">
<!-- previews -->
<div class="ui-pick-previews" v-if="configuration.preview.enabled && previews.length > 0 && !configuration.preview.combined" v-sortable="{ onUpdate: onSortingUpdated, enabled: configuration.sortable }">
<div v-for="preview in previews" :key="preview[configuration.keys.id]" class="ui-pick-preview">
<ui-select-button :icon="getPreviewIcon(preview)" :icon-as-image="configuration.preview.iconAsImage" :label="preview[configuration.keys.name]" :description="getPreviewDescription(preview)" :disabled="disabled" @click="pick(preview[configuration.keys.id])" :tokens="preview" />
<ui-icon-button v-if="!disabled && configuration.preview.delete" @click="remove(preview[configuration.keys.id])" icon="fth-x" title="@ui.remove" :size="14" />
</div>
</div>
<!-- combined preview -->
<div class="ui-pick-previews" v-if="configuration.preview.enabled && configuration.preview.combined">
<div class="ui-pick-preview">
<ui-select-button icon="fth-check-circle" :label="combinedTitle" :disabled="disabled" @click="pick()" />
</div>
</div>
<!-- add button -->
<div class="ui-pick-add" v-if="canAdd && configuration.addButton.enabled && !configuration.preview.combined">
<slot name="add">
<ui-select-button icon="fth-plus" :label="configuration.addButton.label" @click="pick()" :disabled="disabled" />
</slot>
</div>
<!-- overlay -->
<ui-dropdown ref="overlay" class="ui-pick-overlay" @opened="overlayOpened" :theme="configuration.theme">
<!-- headline -->
<div class="ui-pick-overlay-head">
<div class="ui-pick-overlay-head-title">
<span class="-name" v-localize="title"></span>
<span class="-max" v-if="configuration.limit > 1" v-localize="{ key: '@ui.pick.max', tokens: { count: configuration.limit }}"></span>
</div>
<ui-icon-button @click="hide" icon="fth-x" title="@ui.close" />
</div>
<!-- search -->
<ui-search v-if="configuration.search.enabled" ref="search" class="ui-pick-overlay-search" :value="searchValue" @input="onSearch" @submit="onSearchSubmit">
<template v-slot:button="search" v-if="configuration.autocomplete">
<button type="button" class="ui-searchinput-button" v-localize:title="'@ui.search.button'" @click="search.onSubmit"><ui-icon symbol="fth-check"></ui-icon></button>
</template>
</ui-search>
<!-- items -->
<div class="ui-pick-overlay-items">
<button v-for="item in items" :key="item[configuration.keys.id]" type="button" class="ui-pick-overlay-item" @click="select(item)" :class="{'is-selected': isSelected(item) }" :disabled="item.disabled">
<ui-icon v-if="item[configuration.keys.icon] && !configuration.list.iconAsImage" class="-icon" :symbol="item[configuration.keys.icon]" />
<span v-if="item[configuration.keys.icon] && configuration.list.iconAsImage" class="-image">
<ui-thumbnail :media="item[configuration.keys.icon]" :alt="item[configuration.keys.name]" />
</span>
<div class="ui-pick-overlay-item-title">
<span class="-name" v-localize="item[configuration.keys.name]"></span>
<span v-if="item[configuration.keys.description] && configuration.list.description" class="-text" v-localize="item[configuration.keys.description]"></span>
</div>
</button>
</div>
<!-- loading + empty states -->
<div class="ui-pick-overlay-center" v-if="isLoading || (!configuration.autocomplete && !items.length)">
<div v-if="!isLoading && !configuration.autocomplete && !items.length" class="ui-pick-overlay-message">
<ui-icon class="ui-pick-overlay-message-icon" symbol="fth-list" :size="18" />
<span v-localize="'@ui.emptylist'"></span>
</div>
<ui-loading v-if="isLoading" />
</div>
</ui-dropdown>
</div>
</template>
<script lang="ts">
import { arrayMove, debounce, extendObject } from '../utils';
const defaultConfig = {
// picker items, can either be a static list or a promise
items: [],
// preview items, can either be a static list or a promise. If null, take the items list
previews: [],
// exclude Ids from the picker selection items
excludedIds: [],
// limit results to the given IDs
filteredIds: [],
// autocomplete allows entering custom texts
autocomplete: false,
// maximum selection count
limit: 10,
// multiple selection
multiple: true,
// whether the previews/results are sortable or not
sortable: true,
// close picker when an item is clicked
closeOnClick: true,
// title in dropdown
title: null,
// automatically open picker
autoOpen: false,
// picker has pagination
paging: false,
// theme for the dropdown
theme: 'dark',
keys: {
// id key
id: 'id',
// name key
name: 'name',
// description key
description: 'text',
// icon key
icon: 'icon'
},
addButton: {
// hide the add button
enabled: true,
// text key for the add button
label: '@ui.select'
},
list: {
// output description text if available (second line)
description: false,
// displays an image instead of an icon in the list
iconAsImage: false
},
preview: {
// output previews
enabled: true,
// output all selected items in one line (comma-separated)
combined: false,
// prefixed title when combine=true
combinedTitle: null,
// default icon used for previews
defaultIcon: 'fth-square',
// hides the icon in the preview
icon: true,
// hides the delete icon in preview
delete: true,
// displays an image instead of an icon in the preview
iconAsImage: false,
// output description text if available (second line)
description: true
},
search: {
// hides the search input
enabled: true,
// can force a local search for remote items (via promise)
local: false,
// sets the current model as the search input when opened
setDefaultValue: false,
// focus search input on open
focus: false,
// placeholder key in search input
placeholder: null, //vm.options.autocomplete ? 'ui_search_autocomplete' : 'ui_search',
// function for local search filtering
filterFunc: null
}
};
export default {
name: 'uiPick',
props: {
value: {
type: [String, Array],
default: null
},
disabled: {
type: Boolean,
default: false
},
config: {
type: Object,
default: () =>
{
return defaultConfig;
}
}
},
watch: {
config: {
deep: true,
handler(newval, oldval)
{
if (JSON.stringify(newval) !== JSON.stringify(oldval))
{
this.buildConfig();
this.loaded = false;
}
}
},
value(val)
{
this.onValueChanged(val);
}
},
computed: {
multiple()
{
return this.configuration.multiple;
},
canAdd()
{
return (!this.value && !this.multiple) || (!this.value || this.value.length < this.configuration.limit);
},
isRemote()
{
return typeof this.configuration.items === 'function';
},
title()
{
if (!!this.configuration.title) return this.configuration.title;
if (this.configuration.autocomplete) return '@ui.pick.title_autocomplete';
if (this.multiple) return '@ui.pick.title_multiple';
return '@ui.pick.title';
},
combinedTitle()
{
let html = this.configuration.preview.combinedTitle ? this.configuration.preview.combinedTitle : '';
if (this.previews.length > 0)
{
html += (html ? ': ' : '') + '<b>' + this.previews.map(p => p[this.configuration.keys.name]).join(', ') + '</b>';
}
return html;
}
},
data: () => ({
configuration: {},
previews: [],
allItems: [],
items: [],
selected: [],
loaded: false,
isLoading: false,
debouncedUpdate: null,
searchValue: ''
}),
created()
{
this.buildConfig();
this.debouncedUpdate = debounce(this.loadSuggestions, 300);
this.onValueChanged(this.value);
},
mounted()
{
if (this.configuration.autoOpen)
{
this.pick();
}
},
methods: {
refresh()
{
this.buildConfig();
this.loaded = false;
},
onValueChanged(val)
{
if (this.multiple)
{
this.selected = Array.isArray(val) && val.length ? JSON.parse(JSON.stringify(val)) : [];
}
else
{
this.selected = val ? [val] : [];
}
this.loadPreviews();
},
buildConfig()
{
var config = JSON.parse(JSON.stringify(defaultConfig));
this.configuration = extendObject(JSON.parse(JSON.stringify(config)), this.config);
this.configuration.search = extendObject(config.search, this.config.search || {});
this.configuration.addButton = extendObject(config.addButton, this.config.addButton || {});
this.configuration.preview = extendObject(config.preview, this.config.preview || {});
this.configuration.list = extendObject(config.list, this.config.list || {});
this.configuration.keys = extendObject(config.keys, this.config.keys || {});
},
overlayOpened()
{
if (!this.loaded)
{
this.load();
}
if (this.configuration.search.enabled && this.configuration.search.focus)
{
this.$nextTick(() => this.$refs.search.focus());
}
},
pick()
{
this.$refs.overlay.toggle();
if (!this.loaded)
{
this.load();
}
},
hide()
{
this.$refs.overlay.hide();
},
remove(id)
{
let index = this.selected.indexOf(id);
this.selected.splice(index, 1);
this.onChange(this.multiple ? this.selected : null);
},
clear()
{
this.selected = [];
this.onChange(this.multiple ? this.selected : null);
},
// loads remote items or items from the given configuration
// this will store both all items and the reduced search results
load()
{
if (this.isLoading)
{
return;
}
this.isLoading = true;
this.allItems = [];
let onLoaded = (items) =>
{
if (this.configuration.paging && typeof items === 'object' && !Array.isArray(items))
{
// TODO we need to store metadata to allow pagination
items = items.items;
}
this.allItems = items;
this.loadSuggestions();
this.loaded = true;
this.isLoading = false;
};
if (this.isRemote)
{
this.configuration.items().then(onLoaded);
}
else
{
onLoaded(this.configuration.items);
}
},
loadSuggestions()
{
let items = [];
let search = this.searchValue;
let handleResult = (res) =>
{
if (this.configuration.paging && typeof res === 'object' && !Array.isArray(res))
{
// TODO we need to store metadata to allow pagination
res = res.items;
}
if (this.configuration.excludedIds && this.configuration.excludedIds.length)
{
res = res.filter(item => this.configuration.excludedIds.indexOf(item[this.configuration.keys.id]) < 0);
}
if (this.configuration.filteredIds && this.configuration.filteredIds.length)
{
console.info('filtered-ids:', this.configuration.filteredIds, res);
res = res.filter(item => this.configuration.filteredIds.indexOf(item[this.configuration.keys.id]) > -1);
}
this.items = res;
};
if (!search)
{
items = this.allItems;
}
else
{
if (!this.isRemote || this.configuration.search.local)
{
let query = search.toLowerCase();
let filterFunc = this.configuration.search.filterFunc;
if (!filterFunc)
{
filterFunc = (item, q, cfg) => item[cfg.keys.name].toLowerCase().indexOf(q) > -1;
}
items = this.allItems.filter(item => filterFunc(item, query, this.configuration));
}
else if (this.isRemote)
{
this.configuration.items(search).then(res => handleResult(res));
return;
}
}
handleResult(items);
},
loadPreviews()
{
let onLoaded = (items, needsFilter) =>
{
if (this.configuration.paging && typeof items === 'object' && !Array.isArray(items))
{
// TODO we need to store metadata to allow pagination
items = items.data;
}
if (needsFilter)
{
this.previews = [];
this.selected.forEach(id =>
{
let res = items.find(item => item[this.configuration.keys.id] === id);
if (!res)
{
// TODO push error output
}
else
{
this.previews.push(JSON.parse(JSON.stringify(res)));
}
});
}
else
{
this.previews = items;
}
//console.info(JSON.parse(JSON.stringify(this.previews)));
};
if (this.configuration.autocomplete)
{
onLoaded(this.selected.map(s =>
{
let item = { };
item[this.configuration.keys.id] = s;
item[this.configuration.keys.name] = s;
return item;
}), false);
}
else
{
let promise = (Array.isArray(this.configuration.previews) && this.configuration.previews.length) || typeof this.configuration.previews === 'function' ? this.configuration.previews : this.configuration.items;
let isFunc = typeof promise === 'function';
if (isFunc && this.selected.length > 0)
{
promise(this.selected).then(onLoaded);
}
else if (isFunc)
{
onLoaded([]);
}
else
{
onLoaded(promise, true);
}
}
},
onSearch(value)
{
this.searchValue = value;
if (!this.isRemote || this.configuration.search.local)
{
this.loadSuggestions();
}
else
{
this.debouncedUpdate();
}
},
onSearchSubmit()
{
let value = this.searchValue.trim();
let item = {};
item[this.configuration.keys.id] = value;
item[this.configuration.keys.name] = value;
this.select(item);
},
select(item)
{
let value = this.configuration.autocomplete ? item[this.configuration.keys.name] : item[this.configuration.keys.id];
if (this.multiple)
{
if (!this.canAdd)
{
if (this.limit > 1)
{
return;
}
this.selected = [];
}
var index = this.selected.indexOf(value);
if (index > -1)
{
this.selected.splice(index, 1);
this.onDeselected(value, index);
}
else
{
this.selected.push(value);
this.onSelected(value, item);
}
this.onChange(this.selected);
}
else
{
this.selected = [value];
this.onChange(value);
this.onSelected(value, item);
}
},
onSelected(value, item)
{
this.$emit('select', value, item);
},
onDeselected(value, index)
{
this.$emit('deselect', value, index);
},
onChange(value)
{
this.$emit('input', value);
this.$emit('change', value);
this.$emit('update:value', value);
if (this.configuration.closeOnClick)
{
this.$refs.overlay.hide();
}
},
isSelected(item)
{
let value = this.configuration.autocomplete ? item[this.configuration.keys.name] : item[this.configuration.keys.id];
return this.selected.indexOf(value) > -1;
},
getPreviewIcon(preview)
{
return this.configuration.preview.icon ? (preview[this.configuration.keys.icon] || this.configuration.preview.defaultIcon) : null;
},
getPreviewDescription(preview)
{
return this.configuration.preview.description ? preview[this.configuration.keys.description] : null;
},
onSortingUpdated(ev)
{
this.selected = arrayMove(this.selected, ev.oldIndex, ev.newIndex);
this.onChange(this.multiple ? this.selected : this.selected[0]);
}
}
}
</script>
<style lang="scss">
.ui-pick-overlay-search
{
margin: 15px 15px 5px;
.ui-input
{
min-width: 0;
}
}
.theme-dark .ui-pick-overlay-search .ui-input
{
background: var(--color-bg-shade-1);
box-shadow: none !important;
}
.ui-pick-overlay
{
.ui-dropdown
{
min-width: 0;
width: 100%;
max-width: 380px;
}
}
.ui-pick-overlay-center
{
display: flex;
justify-content: center;
margin: 30px 0;
}
.ui-pick-overlay-head
{
padding: 15px 15px 5px;
display: flex;
justify-content: space-between;
align-items: center;
.ui-icon-button
{
background: none;
}
}
.ui-pick-overlay-head-title
{
font-size: var(--font-size-l);
font-weight: 600;
.-max
{
font-size: var(--font-size-s);
font-weight: 400;
color: var(--color-text-dim);
margin-left: .6em;
}
}
.ui-pick-overlay-message
{
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 0 15px;
}
.ui-pick-overlay-message-icon
{
font-size: 18px;
margin-bottom: 10px;
}
.ui-pick-overlay-items
{
margin: 15px 0;
max-height: 290px;
overflow-y: auto;
}
.ui-pick-overlay-item
{
display: grid;
grid-template-columns: auto 1fr;
padding: 10px 18px;
min-height: 48px;
width: 100%;
border-radius: var(--radius);
align-items: center;
transition: background .2s, transform .2s, opacity .2s;
position: relative;
&[disabled]
{
opacity: .6;
}
&:hover
{
background: var(--color-dropdown-selected);
}
&.is-selected
{
padding-right: 40px;
.-name
{
font-weight: 600;
}
}
&.is-selected:after
{
font-family: "Feather";
content: "\e83e";
font-size: 16px;
color: var(--color-primary);
position: absolute;
right: 20px;
}
.-icon
{
margin-right: var(--padding-s);
}
.-image
{
display: inline-flex;
justify-content: center;
width: 24px;
height: 24px;
margin-right: var(--padding-xs);
img
{
max-width: 100%;
max-height: 100%;
}
}
}
.ui-pick-overlay-item-title
{
.-name
{
display: block;
}
.-text
{
display: block;
color: var(--color-text-dim);
font-size: var(--font-size-s);
margin-top: 3px;
}
}
.ui-pick-preview
{
display: flex;
justify-content: space-between;
align-items: center;
}
.ui-pick-preview + .ui-pick-preview,
.ui-pick-previews + .ui-select-button,
.ui-pick-previews + .ui-pick-add
{
margin-top: 10px;
}
.ui-pick.is-combined .ui-select-button-label
{
font-weight: 400;
}
.ui-pick-previews
{
.ui-icon-button
{
height: 24px;
width: 24px;
flex-shrink: 0;
.ui-button-icon
{
font-size: 13px;
}
}
}
</style>
@@ -1,85 +0,0 @@
<template>
<div class="ui-progress" :class="{'is-animated': animated }">
<span class="-value" :style="{ transform: 'translateX(' + ((100 - value) * -1) + '%)' }"></span>
</div>
</template>
<script>
export default {
name: 'uiProgress',
props: {
value: {
type: Number,
default: 0
},
animated: {
type: Boolean,
default: true
}
},
computed: {
progress()
{
return this.value > 100 ? 100 : (this.value < 0 ? 0 : this.value);
}
}
}
</script>
<style lang="scss">
.ui-progress
{
display: block;
width: 100%;
height: 12px;
border-radius: 6px;
background: var(--color-bg-shade-3);
position: relative;
overflow: hidden;
}
.ui-progress .-value
{
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
transform: translateX(-100%);
border-radius: 6px;
overflow: hidden;
background: var(--color-accent);
transition: transform 0.3s linear;
}
.ui-progress.is-animated .-value:before
{
--color-progress-overlay: #00000022;
content: '';
position: absolute;
left: -20px;
right: -20px;
top: 0;
bottom: 0;
background-image: linear-gradient(-60deg, transparent 25%, var(--color-progress-overlay) 25%, var(--color-progress-overlay) 50%, transparent 50%, transparent 75%, var(--color-progress-overlay) 75%, var(--color-progress-overlay));
background-size: 20px 30px;
animation: uiProgressAnimation 1s linear infinite;
}
@keyframes uiProgressAnimation
{
from
{
transform: translateX(-20px);
}
to
{
transform: translateX(0);
}
}
</style>
@@ -1,311 +0,0 @@
<template>
<div class="ui-property" :class="{'is-vertical': vertical, 'is-text': isText, 'hide-label': hideLabel, 'is-disabled': disabled, 'is-locked': locked, 'can-unlock': canUnlock }">
<label v-if="label && !hideLabel" class="ui-property-label" :for="field" v-localize:title="description" :class="{ 'has-description': !!description }">
<button type="button" v-if="canUnlock" class="ui-property-label-small is-lock" :class="{'is-unlocked': !locked}" :title="locked ? 'Unlock property...' : 'Lock property'" @click.prevent="$emit(locked ? 'unlock' : 'lock')">
<ui-icon :size="13" :symbol="locked ? 'fth-lock' : 'fth-unlock'"></ui-icon>
</button>
<span v-localize="label"></span>
<strong class="ui-property-required" v-if="required">*</strong>
<slot name="label-after"></slot>
<small v-if="description" v-localize="description"></small>
<!--<small class="ui-property-label-small" v-if="description" :size="14" v-localize:title="description">?</small>-->
</label>
<div class="ui-property-content">
<slot></slot>
</div>
<ui-error v-if="field" :field="field" />
<slot name="after"></slot>
</div>
</template>
<script>
export default {
name: 'uiProperty',
props: {
field: String,
label: String,
hideLabel: Boolean,
description: String,
required: Boolean,
vertical: {
type: Boolean,
default: true
},
locked: Boolean,
canUnlock: Boolean,
isText: Boolean,
disabled: {
type: Boolean,
default: false
}
}
}
</script>
<style lang="scss">
.ui-property
{
position: relative;
display: grid;
grid-gap: 12px 40px;
grid-template-columns: minmax(auto, 1fr) auto;
&.is-disabled .ui-property-content,
&.is-locked .ui-property-content
{
pointer-events: none;
}
&.is-disabled, &.is-locked
{
cursor: not-allowed;
}
&:not(.is-vertical) > .ui-error
{
grid-column: span 2 / auto;
}
}
.ui-property + .ui-split,
.ui-split + .ui-property,
.ui-property + .ui-property
{
padding-top: 30px;
margin-top: 30px;
border-top: 1px dashed var(--color-line-dashed);
.is-narrow &
{
margin-top: 30px;
padding-top: 0;
border-top: none;
}
}
.ui-property.is-vertical
{
grid-template-columns: minmax(auto, 1fr);
grid-gap: 12px;
flex-direction: column;
//border-top: none;
> .ui-property-label
{
width: 100%;
padding-right: 0;
}
> .ui-property-label + .ui-property-content
{
margin-top: 0;
}
> .ui-property-content
{
display: block;
}
}
.ui-property.is-text
{
grid-gap: 2px;
}
.ui-property.full-width > .ui-property-content,
.ui-property.hide-label > .ui-property-content,
.ui-property.is-static > .ui-property-content
{
max-width: 100%;
}
.ui-property.hide-label > .ui-property-label,
.ui-property.is-static > .ui-property-label
{
display: none;
}
.ui-property.is-static,
.ui-property.is-static + .ui-property
{
margin-top: 0 !important;
}
.ui-property-label
{
display: block;
color: var(--color-text);
//flex-basis: 30%;
font-size: var(--font-size);
line-height: 1.5;
font-weight: 700;
flex-basis: 100%;
&.has-description:hover
{
cursor: help;
}
/*.ui-property:focus-within &
{
color: var(--color-accent-info);
}*/
}
.ui-property-label small
{
display: block;
padding-top: 2px;
font-size: var(--font-size-xs);
font-weight: 400;
line-height: 1.3;
text-decoration: none;
color: var(--color-text-dim-one);
&:empty
{
display: none;
}
}
.ui-property-label-small
{
display: inline-block;
font-size: 11px;
font-weight: 600;
position: relative;
top: -1px;
left: 10px;
color: var(--color-text-dim);
cursor: help;
width: 16px;
height: 16px;
text-align: center;
line-height: 16px;
border-radius: 16px;
background: var(--color-bg-shade-4);
&:hover
{
color: var(--color-text);
}
&.is-lock
{
left: 0;
margin-right: 6px;
background: none;
top: 2px;
cursor: pointer;
&[disabled]
{
cursor: not-allowed;
}
&.is-unlocked
{
color: var(--color-text);
}
.is-locked &
{
color: var(--color-accent-error);
}
}
}
.ui-property-required
{
color: var(--color-required-marker);
margin-left: 0.2em;
font-weight: 400;
pointer-events: none;
user-select: none;
}
.ui-property-content
{
flex: 1;
max-width: 932px;
font-size: var(--font-size);
display: flex;
align-items: center;
}
.ui-property-help
{
max-width: 932px;
padding-left: 26px;
font-size: var(--font-size-xs);
color: var(--color-text-dim);
margin: 5px 0 0;
line-height: 1.4;
position: relative;
//letter-spacing: 0.3px;
&:before
{
content: "\e87f";
font-family: var(--font-icon);
color: var(--color-primary);
font-size: var(--font-size-l);
position: absolute;
top: -3px;
left: 0;
}
}
.ui-properties-floating
{
.ui-property
{
display: inline-flex;
min-height: 52px;
}
.ui-property + .ui-property
{
padding-top: 0;
margin-top: 0;
border-left: 1px solid var(--color-line);
padding-left: 50px;
margin-left: 50px;
}
}
.ui-property.ui-property-parent
{
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
grid-gap: 0; //var(--padding) var(--padding-m);
> .ui-property
{
grid-column-start: 1;
grid-column-end: 13;
margin-left: 0;
margin-right: 0;
padding-left: 0;
padding-right: 0;
}
&:empty:first-child + .ui-property,
> .ui-property[data-cols] + .ui-property[data-cols]
{
margin-top: 0;
border-top: none;
padding-top: 0;
}
}
.ui-property.ui-property-parent:empty
{
display: none;
}
</style>
@@ -1,187 +0,0 @@
<template>
<div v-if="items" class="ui-radio-button" :class="{'is-disabled': disabled, 'is-horizontal': horizontal }" :style="{ 'grid-template-columns': 'repeat(' + items.length + ', minmax(0, 1fr))' }">
<label v-for="item in items" class="ui-radio-button-item" :class="{ 'is-active': value === item.value }">
<input type="radio" :value="item.value" :checked="item.value === value" @input="onChange(item.value)" :disabled="disabled || item.disabled" />
<div class="-output">
<span class="-check"><ui-icon symbol="fth-check" :size="12" :stroke="3"></ui-icon></span>
<div>
<span class="-title">
<ui-icon class="-icon" v-if="item.icon" :symbol="item.icon" :size="14"></ui-icon>
<span v-localize="item.label"></span>
</span>
<span class="-description" v-if="item.description" v-localize="item.description"></span>
</div>
</div>
</label>
</div>
</template>
<script>
export default {
props: {
disabled: {
type: Boolean,
default: false
},
items: {
type: Array,
default: []
},
value: {
type: [ String, Number ],
default: null
},
horizontal: {
type: Boolean,
default: true
}
},
methods: {
onChange(value)
{
if (!this.disabled)
{
this.$emit('update:value', value);
this.$emit('input', value);
}
}
}
}
</script>
<style lang="scss">
.ui-radio-button
{
//display: flex;
&.is-horizontal
{
display: grid;
}
}
.ui-radio-button-item
{
display: flex;
align-items: center;
width: 100%;
position: relative;
padding: var(--padding-s);
border: 1px solid var(--color-line);
background: var(--color-box);
cursor: pointer;
&.is-active
{
//border-color: var(--color-accent);
//box-shadow: inset 0 0 0 1px var(--color-accent);
//border-radius: var(--radius);
background: var(--color-box-nested);
z-index: 2;
cursor: default;
}
.-output
{
display: grid;
align-items: center;
grid-template-columns: auto minmax(0, 1fr);
grid-gap: var(--padding-s);
line-height: 1.5;
}
.-title
{
font-weight: 700;
display: block;
}
.-description
{
display: block;
font-size: var(--font-size-s);
color: var(--color-text-dim);
}
.-icon
{
display: none;
}
.-check
{
display: inline-flex;
justify-content: center;
align-items: center;
width: 22px;
height: 22px;
border-radius: 20px;
background: var(--color-bg-shade-4);
.ui-icon
{
display: none;
}
}
&.is-active .-check
{
background: var(--color-accent);
color: var(--color-accent-fg);
.ui-icon
{
display: inline;
}
}
&:first-child
{
border-top-left-radius: var(--radius);
border-top-right-radius: var(--radius);
}
&:last-child
{
border-bottom-left-radius: var(--radius);
border-bottom-right-radius: var(--radius);
}
.is-horizontal &
{
& + .ui-radio-button-item
{
margin-top: 0;
margin-left: -1px;
}
&:first-child
{
border-top-right-radius: 0;
border-bottom-left-radius: var(--radius);
}
&:last-child
{
border-bottom-left-radius: 0;
border-top-right-radius: var(--radius);
}
}
& + .ui-radio-button-item
{
margin-top: -1px;
}
input
{
position: absolute;
opacity: 0;
}
}
</style>
@@ -1,253 +0,0 @@
import { Mark, markPasteRule, mergeAttributes } from '@tiptap/core';
import { find } from 'linkifyjs';
import { autolink } from '@tiptap/extension-link/src/helpers/autolink';
import { clickHandler } from '@tiptap/extension-link/src/helpers/clickHandler';
import { pasteHandler } from '@tiptap/extension-link/src/helpers/pasteHandler';
import * as overlays from '../../../../services/overlay';
export interface ZeroLinkOptions
{
/**
* If enabled, it adds links as you type.
*/
autolink: boolean,
/**
* If enabled, links will be opened on click.
*/
openOnClick: boolean,
/**
* Adds a link to the current selection if the pasted content only contains an url.
*/
linkOnPaste: boolean,
/**
* A list of HTML attributes to be rendered.
*/
HTMLAttributes: Record<string, any>,
}
declare module '@tiptap/core' {
interface Commands<ReturnType>
{
zerolink: {
/**
* Pick a link
*/
pickLink: () => ReturnType,
/**
* Set a link mark from data
*/
setLinkData: (data: object) => ReturnType,
/**
* Set a link mark
*/
setLink: (attributes: { href: string, target?: string }) => ReturnType,
/**
* Toggle a link mark
*/
toggleLink: (attributes: { href: string, target?: string }) => ReturnType,
/**
* Unset a link mark
*/
unsetLink: () => ReturnType,
}
}
}
export async function pickLink(editor)
{
const result = await overlays.open({
component: () => import('../../../../modules/links/picker/ui-linkpicker-overlay.vue'),
display: 'editor',
model: {
value: null,
areas: [],
allowTitle: false,
allowTarget: true,
allowSuffix: true
}
});
if (result.eventType == 'confirm')
{
return editor.chain().focus().setLinkData(result.value).run();
}
}
export const ZeroLink = Mark.create<ZeroLinkOptions>({
name: 'zerolink',
priority: 1000,
keepOnSplit: false,
inclusive()
{
return this.options.autolink
},
addOptions()
{
return {
openOnClick: true,
linkOnPaste: true,
autolink: true,
HTMLAttributes: {
target: '_self',
rel: 'noopener noreferrer nofollow',
},
}
},
addAttributes()
{
return {
href: {
default: null,
},
'zero-link': {
default: null
},
target: {
default: this.options.HTMLAttributes.target,
},
title: {
default: null
}
}
},
parseHTML()
{
return [
{ tag: 'a[href]' },
{ tag: 'a[zero-link]' }
]
},
renderHTML({ HTMLAttributes })
{
return [
'a',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
]
},
addCommands()
{
return {
setLinkData: data => ({ chain }) =>
{
let attributes = {};
if (data.target !== 'default')
{
attributes.target = data.target;
}
if (data.title)
{
attributes.title = data.title;
}
let link = {
area: data.area
};
if (data.values)
{
link.values = data.values;
}
if (data.urlSuffix)
{
link.suffix = data.urlSuffix;
}
attributes['zero-link'] = JSON.stringify(link);
return chain()
.setMark(this.name, attributes)
.setMeta('preventAutolink', true)
.run()
},
setLink: attributes => ({ chain }) =>
{
return chain()
.setMark(this.name, attributes)
.setMeta('preventAutolink', true)
.run()
},
toggleLink: attributes => ({ chain }) =>
{
return chain()
.toggleMark(this.name, attributes, { extendEmptyMarkRange: true })
.setMeta('preventAutolink', true)
.run()
},
unsetLink: () => ({ chain }) =>
{
return chain()
.unsetMark(this.name, { extendEmptyMarkRange: true })
.setMeta('preventAutolink', true)
.run()
},
}
},
addPasteRules()
{
return [
markPasteRule({
find: text => find(text)
.filter(link => link.isLink)
.map(link => ({
text: link.value,
index: link.start,
data: link,
})),
type: this.type,
getAttributes: match => ({
href: match.data?.href,
}),
}),
]
},
addProseMirrorPlugins()
{
const plugins = []
if (this.options.autolink)
{
plugins.push(autolink({
type: this.type,
}))
}
if (this.options.openOnClick)
{
plugins.push(clickHandler({
type: this.type,
}))
}
if (this.options.linkOnPaste)
{
plugins.push(pasteHandler({
editor: this.editor,
type: this.type,
}))
}
return plugins
},
})
@@ -1,182 +0,0 @@
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import Bold from '@tiptap/extension-bold';
import Code from '@tiptap/extension-code';
import Italic from '@tiptap/extension-italic';
import Strike from '@tiptap/extension-strike';
//import Underline from '@tiptap/ex';
import History from '@tiptap/extension-history';
//import Placeholder from '@tiptap/extension-placeholder';
import HorizontalRule from '@tiptap/extension-horizontal-rule';
import ListItem from '@tiptap/extension-list-item';
import BulletList from '@tiptap/extension-bullet-list';
import OrderedList from '@tiptap/extension-ordered-list';
import Heading from '@tiptap/extension-heading';
import HardBreak from '@tiptap/extension-hard-break';
import Image from '@tiptap/extension-image';
import { ZeroLink, pickLink } from './extensions/link/link';
export default function ()
{
return {
removeExtension(name)
{
var ext = this.extensions.find(x => x.name === name);
ext && this.extensions.splice(this.extensions.indexOf(ext), 1);
},
removeCommand(alias)
{
var cmd = this.commands.find(x => x.alias === alias);
cmd && this.commands.splice(this.commands.indexOf(cmd), 1);
},
extensions: [
Document,
Paragraph,
Text,
History,
HardBreak,
ZeroLink,
Bold,
Code,
Italic,
Strike,
//Underline,
ListItem,
BulletList,
OrderedList,
HorizontalRule,
Heading.configure({ // TODO
levels: [2, 3, 4],
})
],
commands: [
//{
// alias: 'undo',
// title: '@rte.undo',
// symbol: 'fth-chevron-left',
// disabled: cmd => cmd.undoDepth() > 1,
// onClick: (ev, cmd) => cmd.undo(ev),
//},
//{
// alias: 'redo',
// title: '@rte.redo',
// symbol: 'fth-chevron-right',
// disabled: cmd => cmd.redoDepth() > 1,
// onClick: (ev, cmd) => cmd.redo(ev)
//},
{
alias: 'bold',
title: '@rte.bold',
symbol: 'fth-bold',
symbolSize: 14,
isActive: editor => editor.isActive('bold'),
onClick: editor => editor.chain().focus().toggleBold().run(),
bubble: true
},
{
alias: 'italic',
title: '@rte.italic',
symbol: 'fth-italic',
symbolSize: 14,
isActive: editor => editor.isActive('italic'),
onClick: editor => editor.chain().focus().toggleItalic().run(),
bubble: true
},
{
alias: 'underline',
title: '@rte.underline',
symbol: 'fth-underline',
symbolSize: 14,
isActive: editor => editor.isActive('underline'),
onClick: editor => editor.chain().focus().toggleUnderline().run(),
bubble: true
},
{
alias: 'strikethrough',
title: '@rte.strikethrough',
symbol: 'fth-strikethrough',
symbolSize: 14,
isActive: editor => editor.isActive('strike'),
onClick: editor => editor.chain().focus().toggleStrike().run(),
bubble: true
},
{
alias: 'code',
title: '@rte.code',
symbol: 'fth-code',
symbolSize: 14,
isActive: editor => editor.isActive('code'),
onClick: editor => editor.chain().focus().toggleCode().run(),
bubble: true
},
{
alias: 'link',
title: '@rte.link',
symbol: 'fth-link-2',
symbolSize: 14,
isActive: editor => editor.isActive('link'),
onClick: editor => pickLink(editor)
},
{
alias: 'line',
title: '@rte.line',
symbol: 'fth-minus',
symbolSize: 15,
onClick: editor => editor.chain().focus().setHorizontalRule().run()
},
{
alias: 'list',
title: '@rte.list',
symbol: 'fth-list',
symbolSize: 14,
children: [
{
alias: 'ulist',
title: '@rte.ulist',
isActive: editor => editor.isActive('bullet_list'),
onClick: editor => editor.chain().focus().toggleBulletList().run()
},
{
alias: 'olist',
title: '@rte.olist',
isActive: editor => editor.isActive('ordered_list'),
onClick: editor => editor.chain().focus().toggleOrderedList().run()
}
]
},
{
alias: 'heading',
title: '@rte.heading',
symbol: 'fth-type',
symbolSize: 14,
children: [
{
alias: 'h2',
title: '@rte.heading2',
isActive: editor => editor.isActive('heading', { level: 2}),
onClick: editor => editor.chain().focus().toggleHeading({ level: 2 }).run()
},
{
alias: 'h3',
title: '@rte.heading3',
isActive: editor => editor.isActive('heading', { level: 3 }),
onClick: editor => editor.chain().focus().toggleHeading({ level: 3 }).run()
},
{
alias: 'h4',
title: '@rte.heading4',
isActive: editor => editor.isActive('heading', { level: 4 }),
onClick: editor => editor.chain().focus().toggleHeading({ level: 4 }).run()
}
]
}
]
};
};
@@ -1,73 +0,0 @@
//import { Node, Extension, Plugin } from '@tiptap/vue-3';
//import { chainCommands, exitCode } from 'prosemirror-commands';
//export class HardBreak extends Node
//{
// get name()
// {
// return 'br'
// }
// get schema()
// {
// return {
// inline: true,
// group: 'inline',
// selectable: false,
// parseDOM: [
// { tag: 'br' },
// ],
// toDOM: () => ['br'],
// }
// }
// keys({ type })
// {
// const command = chainCommands(exitCode, (state, dispatch) =>
// {
// dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView())
// return true
// })
// return {
// 'Enter': command
// }
// }
//}
//export class MaxSize extends Extension
//{
// get name()
// {
// return 'maxSize'
// }
// get defaultOptions()
// {
// return {
// maxSize: null
// }
// }
// get plugins()
// {
// return [
// new Plugin({
// appendTransaction: (transactions, oldState, newState) =>
// {
// const max = this.options.maxSize;
// const oldLength = oldState.doc.content.size;
// const newLength = newState.doc.content.size;
// if (max && newLength > max && newLength > oldLength)
// {
// let newTr = newState.tr;
// newTr.insertText('', max + 1, newLength);
// return newTr;
// }
// }
// })
// ]
// }
//}
@@ -1,113 +0,0 @@
import { Plugin, PluginKey } from 'prosemirror-state'
class Menu
{
constructor({ options })
{
this.options = options
this.preventHide = false
// the mousedown event is fired before blur so we can prevent it
this.mousedownHandler = this.handleClick.bind(this)
this.options.element.addEventListener('mousedown', this.mousedownHandler, { capture: true })
this.blurHandler = () =>
{
if (this.preventHide)
{
this.preventHide = false
return
}
this.options.editor.emit('menubar:focusUpdate', false)
}
this.options.editor.on('blur', this.blurHandler)
}
handleClick()
{
this.preventHide = true
}
destroy()
{
this.options.element.removeEventListener('mousedown', this.mousedownHandler)
this.options.editor.off('blur', this.blurHandler)
}
}
const MenuBar = function (options)
{
return new Plugin({
key: new PluginKey('menu_bar'),
view(editorView)
{
return new Menu({ editorView, options })
},
})
};
export default {
props: {
editor: {
default: null,
type: Object,
},
},
data() {
return {
focused: false,
}
},
watch: {
editor: {
immediate: true,
handler(editor) {
if (editor) {
this.$nextTick(() =>
{
var menubar = MenuBar({
editor,
element: this.$el,
});
editor.registerPlugin(menubar);
this.focused = editor.focused
editor.on('focus', () => {
this.focused = true
})
editor.on('menubar:focusUpdate', focused => {
this.focused = focused
})
})
}
},
},
},
render() {
if (!this.editor) {
return null
}
return this.$slots.default({
focused: this.focused,
focus: this.editor.focus,
commands: this.editor.commands,
isActive: this.editor.isActive,
getMarkAttrs: this.editor.getMarkAttrs.bind(this.editor),
getNodeAttrs: this.editor.getNodeAttrs.bind(this.editor),
})
},
}
@@ -1,434 +0,0 @@
<template>
<div class="ui-rte" :id="id" :disabled="disabled">
<!--<bubble-menu :editor="editor" :tippy-options="{ duration: 100 }" v-if="editor && hasBubble">
<div class="ui-rte-overlay-controls theme-dark is-active">
<template v-for="cmd in cmds" :key="cmd.id">
<div class="ui-rte-overlay-control-outer" v-if="cmd && cmd.bubble && !cmd.isParent">
<button type="button" :data-alias="cmd.alias" v-localize:title="cmd.title" class="ui-rte-overlay-control" :class="{ 'is-active': cmd.isActive(editor) }" @click="cmd.onClick(editor)">
<ui-icon :symbol="cmd.symbol" :size="cmd.symbolSize" />
</button>
</div>
</template>
</div>
</bubble-menu>-->
<div class="ui-rte-controls" v-if="editor">
<div class="ui-rte-control-outer" v-for="cmd in cmds" :key="cmd.id">
<button type="button" v-if="!cmd.isParent" :data-alias="cmd.alias" v-localize:title="cmd.title" class="ui-rte-control" :class="{ 'is-active': cmd.isActive(editor) }" @click="cmd.onClick(editor)">
<ui-icon :symbol="cmd.symbol" :size="cmd.symbolSize" />
</button>
<ui-dropdown v-if="cmd.isParent" align="right">
<template v-slot:button>
<button :data-alias="cmd.alias" type="button" v-localize:title="cmd.title" class="ui-rte-control" :class="{ 'is-active': cmd.isActive(editor) }">
<ui-icon :symbol="cmd.symbol" :size="cmd.symbolSize" />
</button>
</template>
<ui-dropdown-button v-for="child in cmd.children" :key="child.id" :label="child.title" @click="child.onClick(editor)" />
</ui-dropdown>
</div>
</div>
<editor-content class="ui-rte-input" :editor="editor" />
</div>
</template>
<script lang="ts">
import { generateId } from '../../utils/numbers';
import { debounce } from '../../utils/timing';
import { Editor, EditorContent, BubbleMenu } from '@tiptap/vue-3';
// import { Placeholder } from '@tiptap/vue-3';
import EditorMenuBar from './rte.menubar';
//import { MaxSize } from './rte.extensions';
import createConfig from './rte.config';
export default {
name: 'uiRte',
components: { EditorContent, BubbleMenu, EditorMenuBar },
props: {
value: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
},
maxLength: {
type: Number,
default: null
},
placeholder: {
type: String,
default: null
},
setup: {
type: Function,
default: () => { }
}
},
data: () => ({
id: 'rte-' + generateId(),
blocked: false,
onDebouncedChange: null,
editor: null,
extensions: [],
cmds: []
}),
watch: {
value()
{
if (!this.blocked)
{
this.init();
}
},
disabled(value)
{
this.editor.setOptions({
editable: !value
})
},
},
computed: {
hasBubble()
{
return this.cmds.find(x => x.bubble) != null;
}
},
created()
{
this.onDebouncedChange = debounce(this.onChange, 200);
},
mounted()
{
var config = createConfig();
if (typeof this.setup === 'function')
{
this.setup(config);
}
this.extensions = config.extensions;
this.cmds = config.commands.map(cmd =>
{
let params = this.mapCommand(cmd);
if (cmd.children && cmd.children.length)
{
params.isParent = true;
params.children = cmd.children.map(child => this.mapCommand(child));
}
return params;
});
//if (this.placeholder)
//{
// this.extensions.push(new Placeholder({
// emptyEditorClass: 'is-editor-empty',
// emptyNodeClass: 'is-empty',
// emptyNodeText: this.placeholder,
// showOnlyWhenEditable: true,
// showOnlyCurrent: true
// }));
//}
//if (this.maxLength > 0)
//{
// this.extensions.push(new MaxSize({ maxSize: this.maxLength }));
//}
this.editor = new Editor({
editable: !this.disabled,
extensions: this.extensions,
onUpdate: ({ editor }) =>
{
this.onDebouncedChange(editor.getHTML());
}
});
this.editor.cmds = this.cmds;
this.init();
},
methods: {
init()
{
this.editor.commands.setContent(this.value, false);
},
onChange(content)
{
this.blocked = true;
this.$emit('input', content);
this.$emit('update:value', content);
this.$nextTick(() => this.blocked = false);
},
mapCommand(cmd)
{
return {
id: generateId(),
alias: cmd.alias,
title: cmd.title,
symbol: cmd.symbol,
symbolSize: cmd.symbolSize || 17,
isActive: typeof cmd.isActive === 'function' ? cmd.isActive : () => false,
disabled: typeof cmd.disabled === 'function' ? cmd.disabled : () => false,
onClick: typeof cmd.onClick === 'function' ? cmd.onClick : () => { },
bubble: cmd.bubble || false
};
}
}
}
</script>
<style lang="scss">
.ui-rte
{
background: var(--color-input);
border-radius: var(--radius);
border: var(--color-input-border);
}
.ui-rte:focus-within
{
background-color: var(--color-input-focus-bg);
border: var(--color-input-focus-border);
box-shadow: var(--color-input-focus-shadow);
outline: none;
}
.ui-rte-input
{
height: auto;
min-height: 48px;
padding-top: 9px;
padding-bottom: 14px;
max-height: 420px;
overflow-y: auto;
border: none !important;
.ProseMirror:focus
{
outline: none;
}
p
{
margin: 0;
}
p + p
{
margin-top: 1em;
}
p.is-editor-empty:first-child:before
{
content: attr(data-empty-text);
float: left;
color: var(--color-input-placeholder);
opacity: .7;
pointer-events: none;
height: 0;
}
a
{
color: var(--color-primary);
text-decoration: underline;
cursor: pointer;
}
}
.ui-rte-overlay-controls
{
position: absolute;
display: flex;
z-index: 20;
background: var(--color-bg);
border-radius: var(--radius);
padding: 5px;
margin-bottom: 0;
transform: translateX(-50%) translateY(-100%);
visibility: hidden;
opacity: 0;
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none;
&:after
{
content: '';
position: absolute;
left: 50%;
top: 100%;
width: 0;
height: 0;
border: 8px solid transparent;
margin-left: -7px;
border-top-color: var(--color-bg);
}
}
.ui-rte-overlay-controls.is-active
{
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.ui-rte-overlay-control
{
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 0;
color: var(--color-text);
height: 32px;
width: 32px;
border-radius: var(--radius);
cursor: pointer;
}
.ui-rte-overlay-control + .ui-rte-overlay-control
{
margin-left: 4px;
}
.ui-rte-overlay-control:hover
{
background-color: var(--color-bg-shade-1);
}
.ui-rte-overlay-control.is-active
{
background-color: var(--color-bg-shade-1);
color: var(--color-text-dim);
}
.ui-rte-controls
{
background: none;
padding: 5px 5px 0;
display: flex;
align-items: center;
justify-content: flex-start;
border-bottom: var(--color-input-border);
}
.ui-rte-controls-label
{
color: var(--color-text-dim);
margin: 0 14px 0 8px;
font-size: 10px;
text-transform: uppercase;
pointer-events: none;
user-select: none;
}
.ui-rte-control
{
background-color: transparent;
cursor: pointer;
height: 30px;
width: 30px;
text-align: center;
border-radius: 5px;
font-size: 14px;
vertical-align: bottom;
}
.ui-rte-control[disabled]
{
opacity: .5;
}
.ui-rte-control-outer + .ui-rte-control-outer
{
margin-left: 5px;
}
.ui-rte-control:hover, .ui-rte-control.is-active
{
background: var(--color-box-nested);
color: var(--color-text);
}
.ui-rte-input
{
background: none !important;
border-radius: 0;
}
.ui-rte-input .ProseMirror
{
> *
{
margin: 1rem 0;
}
> :first-child
{
margin-top: 0;
}
> :last-child
{
margin-bottom: 0;
}
h1, h2, h3, h4, h5, h6
{
margin-bottom: .5rem;
font-weight: bold;
}
h1
{
font-size: 1.4em;
}
h2
{
font-size: 1.2em;
}
h3
{
font-size: 1.1em;
}
h4, h5, h6
{
font-size: 1em;
}
hr
{
display: block;
border-bottom-style: dashed;
border-bottom-color: var(--color-line-dashed);
}
li p
{
display: block;
line-height: 1.5;
}
}
</style>
@@ -1,99 +0,0 @@
<template>
<div class="ui-searchinput">
<input ref="input" type="search" :value="value" @input="onChange" @keyup.enter="onSubmit" class="ui-input" v-localize:placeholder="placeholder" />
<slot name="button" v-bind="{ onSubmit: onSubmit }">
<button type="button" class="ui-searchinput-button" v-localize:title="'@ui.search.button'" @click="onSubmit">
<ui-icon symbol="fth-search" />
</button>
</slot>
</div>
</template>
<script>
export default {
name: 'uiSearch',
props: {
value: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '@ui.search.placeholder'
}
},
computed: {
},
methods: {
onChange(ev)
{
//this.$emit('change', ev.target.value);
this.$emit('input', ev.target.value);
//this.$emit('update:value', ev.target.value);
//this.$emit('update:modelValue', ev.target.value);
},
onSubmit(ev)
{
this.$emit('submit', this.$refs.input.value);
},
focus()
{
this.$refs.input.focus();
}
}
}
</script>
<style lang="scss">
.ui-searchinput
{
position: relative;
.ui-input
{
display: block;
min-width: 320px;
padding-right: 40px;
border: var(--color-input-border);
background: var(--color-input);
}
&.onbg .ui-input:not(:focus)
{
/*box-shadow: var(--shadow-short);
background: var(--color-button-light-onbg);*/
background: transparent;
border: 1px dashed var(--color-line-dashed-onbg);
}
.ui-searchinput-button
{
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 45px;
text-align: center;
font-size: var(--font-size);
padding-top: 1px;
border: 1px solid transparent;
border-radius: var(--radius);
}
.ui-input:focus
{
background-color: var(--color-input-focus-bg);
border: var(--color-input-focus-border);
box-shadow: var(--color-input-focus-shadow);
outline: none;
}
}
</style>
@@ -1,65 +0,0 @@
<template>
<button type="button" class="ui-select-button type-light" :disabled="disabled" @click="tryClick">
<span class="ui-select-button-icon" v-if="!isImage">
<ui-icon :symbol="icon" />
</span>
<span class="ui-select-button-icon is-image" v-if="isImage">
<ui-thumbnail :media="icon" :alt="icon" />
</span>
<div class="ui-select-button-content" v-if="label || description">
<strong class="ui-select-button-label" v-localize:html="{ key: label, tokens: tokens }"></strong>
<span class="ui-select-button-description" v-if="description" v-localize:html="{ key: description, tokens: tokens }"></span>
</div>
<slot></slot>
</button>
</template>
<script>
//import MediaApi from 'zero/api/media.js'; // TODO vue
export default {
name: 'uiSelectButton',
props: {
icon: {
type: String,
default: 'fth-box'
},
iconAsImage: {
type: Boolean,
default: false
},
label: {
type: String,
default: null
},
description: {
type: String,
default: null
},
tokens: {
type: Object,
default: () => {}
},
disabled: Boolean
},
computed: {
isImage()
{
return this.iconAsImage && (!this.icon || this.icon.indexOf('fth-') !== 0);
}
},
methods: {
tryClick(ev)
{
this.$emit('click', ev);
}
}
}
</script>
@@ -1,78 +0,0 @@
<template>
<div class="ui-native-select" :disabled="disabled">
<select :value="value" @input="onChange" :disabled="disabled">
<option v-if="emptyOption"></option>
<option v-for="option in options" :value="option.value" v-localize="option.label"></option>
</select>
</div>
</template>
<script>
export default {
name: 'uiSelect',
props: {
value: [String, Number, Object],
items: [Array, Function],
entity: Object,
disabled: Boolean,
emptyOption: {
type: Boolean,
default: false
}
},
data: () => ({
options: []
}),
created()
{
this.rebuild();
},
watch: {
items: {
deep: true,
handler()
{
this.rebuild();
this.onChange({ target: { value: this.value } });
}
},
entity: {
deep: true,
handler()
{
this.rebuild();
this.onChange({ target: { value: this.value } });
}
}
},
methods: {
rebuild()
{
if (this.entity && typeof this.items === 'function')
{
this.options = this.items(this.entity);
}
else if (typeof this.items !== 'function' && this.items)
{
this.options = [...this.items];
}
else
{
this.options = [];
}
},
onChange(ev)
{
this.$emit('update:value', ev.target.value ? ev.target.value : null);
this.$emit('input', ev.target.value ? ev.target.value : null);
}
}
}
</script>
@@ -1,83 +0,0 @@
<template>
<div class="ui-state-button" :class="{'is-disabled': disabled }">
<input ref="input" type="hidden" :value="value" />
<button v-for="item in items" @click="select(item.value)" v-localize="item.label"
type="button" class="ui-state-button-item" :disabled="disabled || item.disabled" :class="{ 'is-active': value === item.value }"></button>
</div>
</template>
<script>
export default {
props: {
disabled: {
type: Boolean,
default: false
},
items: {
type: Array,
default: []
},
value: {
type: [ String, Number ],
default: null
}
},
methods: {
select(value)
{
if (!this.disabled)
{
this.$emit('input', value);
}
}
}
}
</script>
<style>
.ui-state-button
{
display: inline-flex;
gap: 3px;
}
.ui-state-button.is-disabled button
{
cursor: default;
}
button.ui-state-button-item
{
background: var(--color-input);
padding: 10px 16px;
color: var(--color-text-dim);
}
button.ui-state-button-item + .ui-state-button-item
{
border-left: none;
}
button.ui-state-button-item:first-of-type
{
border-top-left-radius: var(--radius);
border-bottom-left-radius: var(--radius);
}
button.ui-state-button-item:last-child
{
border-top-right-radius: var(--radius);
border-bottom-right-radius: var(--radius);
}
button.ui-state-button-item.is-active
{
color: var(--color-text);
font-weight: bold;
}
</style>
@@ -1,85 +0,0 @@
<template>
<section v-show="active && !hide" :class="{ 'is-hidden': hide }" class="ui-tab" :aria-hidden="!active ? true : null" role="tabpanel">
<slot />
</section>
</template>
<script>
import { generateId } from '../utils';
export default {
name: 'uiTab',
props: {
label: {
type: String
},
count: {
type: Number,
default: 0
},
disabled: {
type: Boolean,
default: false
},
hide: {
type: Boolean,
default: false
}
},
data: () => ({
id: null,
loaded: false,
active: false,
hasErrors: false,
countOutput: 0
}),
watch: {
active(val)
{
this.loaded = true;
},
count(val)
{
this.countOutput = val;
}
},
created()
{
this.id = generateId();
this.loaded = this.active;
this.countOutput = this.count;
this.$parent.register(this);
},
unmounted()
{
this.$parent.unregister(this);
},
methods: {
setCount(count)
{
this.countOutput = count;
},
// set and display errors
setErrors(errors, append)
{
this.hasErrors = !!errors;
},
// clear errors and hide
clearErrors()
{
this.hasErrors = false;
}
}
}
</script>
@@ -1,202 +0,0 @@
<template>
<ui-form class="ui-table-filter-overlay" ref="form" v-slot="form" @submit="onSubmit" @load="onLoad">
<ui-trinity class="ui-module-overlay">
<template v-slot:header>
<ui-header-bar title="@listfilter.headline" :back-button="false" :close-button="true" @close="config.close(true)" />
</template>
<template v-slot:footer>
<ui-button type="light onbg" label="@ui.close" @click="config.close"></ui-button>
<ui-button v-if="!model.isCreate" type="light onbg" label="@ui.remove" @click="onRemove"></ui-button>
<ui-button type="accent" :submit="true" label="@ui.confirm" :state="form.state" :disabled="loading || disabled || !filterName"></ui-button>
</template>
<ui-loading v-if="loading" :is-big="true" />
<!--<div v-if="!loading" class="ui-box">
<ui-property label="Name" :required="true">
<input v-model="model.name" type="text" class="ui-input" maxlength="300" />
</ui-property>
</div>-->
<div v-if="!loading" class="ui-box">
<div v-for="(field, index) in fields" class="ui-table-filter-overlay-group" :class="{ 'is-open': activeFilter === index, 'has-value': field.preview.selected(value[field.path]) }">
<div class="ui-table-filter-overlay-group-head">
<ui-select-button :label="field.label" :icon="field.preview.icon" :description="field.preview.value(value[field.path])" @click="toggleFilter(field, index)" />
<ui-button class="ui-table-filter-overlay-group-clear" type="blank" label="@listfilter.clearitem" @click="clearFilter(field, index)" />
</div>
<div v-show="activeFilter === index" class="ui-table-filter-overlay-group-content">
<ui-editor-component :field="field" :value="value" @input="onFieldChange" :disabled="disabled" />
</div>
</div>
</div>
<div v-if="!loading" class="ui-box ui-table-filter-overlay-filtername">
<ui-property label="@listfilter.saveas" description="@listfilter.saveas_text" :vertical="true">
<input v-model="filterName" type="text" class="ui-input" maxlength="40" />
</ui-property>
</div>
</ui-trinity>
</ui-form>
</template>
<script>
import { compileEditor } from '../editor/compile';
export default {
props: {
model: Object,
config: Object
},
provide: function ()
{
return {
meta: {},
disabled: false
};
},
data: () => ({
value: {},
loading: true,
disabled: false,
template: null,
editor: null,
fields: [],
filterName: null,
activeFilter: null
}),
methods: {
toggleFilter(filter, index)
{
this.activeFilter = this.activeFilter === index ? null : index;
},
onFieldChange(value)
{
//this.$emit('input', this.value);
},
clearFilter(filter, index)
{
this.value[filter.path] = JSON.parse(JSON.stringify(this.template[filter.path]));
},
onLoad()
{
this.loading = true;
this.template = JSON.parse(JSON.stringify(this.model.template || {}));
this.value = JSON.parse(JSON.stringify(this.model.value.filter || this.template));
this.editor = compileEditor(this.zero, this.model.editor);
this.filterName = this.model.value.name;
this.fields = [];
this.editor.tabs.forEach(tab =>
{
tab.fieldsets.forEach(set =>
{
set.fields.forEach(field =>
{
if (!field.preview)
{
console.warn('[zero] All fields in a filter need filled-out preview options');
}
else
{
this.fields.push(field);
}
});
});
});
this.loading = false;
},
onSubmit()
{
this.config.confirm({
model: this.value,
filterName: this.filterName
});
},
onRemove()
{
this.config.confirm({
remove: true
});
}
}
}
</script>
<style lang="scss">
.ui-table-filter-overlay
{
.ui-box + .ui-box
{
margin-top: var(--padding-s);
}
}
.ui-table-filter-overlay-group
{
&:not(.is-open) + .ui-table-filter-overlay-group
{
margin-top: var(--padding-s);
border-top: 1px solid var(--color-line);
padding-top: var(--padding-s);
}
&:not(.has-value) .ui-table-filter-overlay-group-clear
{
display: none;
}
}
.ui-table-filter-overlay-group-head
{
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-gap: 20px;
}
.ui-table-filter-overlay-group-clear .ui-button-text
{
font-weight: 400;
color: var(--color-text-dim);
}
.ui-table-filter-overlay-group-content
{
background: var(--color-box);
margin: var(--padding-s) 0;
padding: var(--padding-m) 0;
border-top: 1px solid var(--color-line);
border-bottom: 1px solid var(--color-line);
> .ui-property > .ui-property-label
{
display: none;
}
}
.ui-table-filter-overlay-filtername .ui-property-content
{
margin-top: 12px !important;
}
.ui-table-filter-overlay .ui-select-button-description
{
color: var(--color-primary);
}
</style>
@@ -1,254 +0,0 @@
<template>
<div v-if="loaded" class="ui-table-filter">
<ui-search v-if="!hideSearch" v-model="attach.query.search" class="onbg" />
<ui-dropdown v-if="hasFilter && storedFilters.length" align="right">
<template v-slot:button>
<ui-button type="light onbg" :label="filterLabel" :tokens="{ name: currentFilter ? currentFilter.name : null }" caret="down" />
</template>
<ui-dropdown-button v-if="storedFilters.length" v-for="(filter, index) in storedFilters" :key="index" :value="filter" :label="filter.name" @click="setFilter" />
<ui-dropdown-separator v-if="storedFilters.length" />
<ui-dropdown-button label="@ui.add" icon="fth-plus" @click="addOrEditFilter()" />
<ui-dropdown-button v-if="currentFilter" label="@listfilter.button_edit" icon="fth-edit-2" @click="addOrEditFilter(currentFilter.id)" />
<ui-dropdown-button v-if="currentFilter" label="@listfilter.button_clear" icon="fth-x" @click="setFilter(null)" />
</ui-dropdown>
<ui-button v-if="hasFilter && !storedFilters.length" type="light onbg" label="@listfilter.button" @click="addOrEditFilter()" />
<ui-dropdown v-if="!hideSelection && selection.length > 0" align="right">
<template v-slot:button>
<ui-button type="light" :label="selectedText" caret="down" />
</template>
<slot name="actions"></slot>
</ui-dropdown>
<ui-dropdown v-if="actions && actions.length > 0" align="right">
<template v-slot:button>
<ui-button type="light onbg" icon="fth-more-horizontal" />
</template>
<ui-dropdown-button v-for="(action, index) in actions" :key="index" :value="action" :prevent="action.autoclose === false" :label="action.label" :icon="action.icon" @click="onActionClicked" />
</ui-dropdown>
</div>
</template>
<script>
//import Overlay from 'zero/helpers/overlay.js';
import { generateId } from '../utils/numbers';
import * as overlays from '../services/overlay';
//import FilterOverlay from './table-filter-overlay.vue';
const KEY_PREFIX = 'zero.ui-table-filter.';
export default {
name: 'uiTableFilter',
props: {
attach: [Object, undefined],
hideSearch: {
type: Boolean,
default: false
}
},
watch: {
attach: function (value)
{
this.setup();
}
},
data: () => ({
loaded: false,
hasFilter: false,
filterOptions: null,
//hideFilter: true,
hideSelection: false,
selection: [],
storedFilters: [],
currentFilter: null,
actions: []
}),
created()
{
//this.reload();
},
mounted()
{
this.setup();
},
computed: {
filterLabel()
{
return this.currentFilter ? '@listfilter.button_selected' : '@listfilter.button';
}
},
methods: {
// attach table data
setup()
{
if (!this.attach)
{
return;
}
this.filterOptions = { ...this.attach.filter };
this.hasFilter = this.filterOptions && this.filterOptions.editor;
this.storedFilters = this.getStoredFilters();
this.actions = this.attach.listConfig.actions;
let filterId = this.$route.query['filter'];
if (filterId)
{
let filter = this.storedFilters.find(x => x.id === filterId);
if (filter)
{
this.setFilter(filter);
}
}
this.loaded = true;
},
// called when an action has been clicked
onActionClicked(action, opts)
{
action.call(opts, this);
},
// load stored filters from local storage
getStoredFilters()
{
if (!this.hasFilter)
{
return [];
}
return JSON.parse(localStorage.getItem(KEY_PREFIX + this.filterOptions.editor.alias) || "[]");
},
// set the current filter
setFilter(value)
{
if (!value)
{
this.currentFilter = null;
this.$emit('filter', null);
this.attach.setFilter(null);
}
else
{
this.currentFilter = JSON.parse(JSON.stringify(value));
this.currentFilter.filter.id = this.currentFilter.id;
this.$emit('filter', this.currentFilter.filter);
this.attach.setFilter(this.currentFilter.filter);
}
},
// removes a filter from the storage
removeFilter(id)
{
let savedFilter = this.storedFilters.find(x => x.id === id);
this.storedFilters.splice(this.storedFilters.indexOf(savedFilter), 1);
localStorage.setItem(KEY_PREFIX + this.filterOptions.editor.alias, JSON.stringify(this.storedFilters));
if (this.currentFilter && this.currentFilter.id === id)
{
this.setFilter(null);
}
},
// persist a filter in local storage
saveFilter(name, filter, id)
{
id = id || generateId(4);
let savedFilter = this.storedFilters.find(x => x.id === id);
let index = this.storedFilters.indexOf(savedFilter);
let model = { id, name, filter };
if (index > -1)
{
this.storedFilters.splice(index, 1, model);
}
else
{
this.storedFilters.push(model);
}
localStorage.setItem(KEY_PREFIX + this.filterOptions.editor.alias, JSON.stringify(this.storedFilters));
return id;
},
async addOrEditFilter(id)
{
let model = { name: null, filter: this.filterOptions.template };
if (id)
{
model = this.storedFilters.find(x => x.id === id);
}
const result = await overlays.open({
component: () => import('./ui-table-filter-overlay.vue'),
display: 'editor',
model: {
editor: this.filterOptions.editor,
template: this.filterOptions.template,
value: model,
isCreate: !id
}
});
if (result.eventType == 'confirm')
{
const value = result.value;
if (value.remove && id)
{
this.removeFilter(id);
return;
}
if (value.filterName)
{
id = this.saveFilter(value.filterName, value.model, id);
}
this.setFilter({
id,
name: value.filterName,
filter: value.model
});
}
}
}
}
</script>
<style lang="scss">
.ui-table-filter
{
display: flex;
align-items: center;
height: 100%;
> * + *
{
margin-left: 15px;
}
}
</style>
@@ -1,324 +0,0 @@
.ui-table
{
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
flex-direction: column;
position: relative;
background: var(--color-table);
flex-wrap: nowrap;
-webkit-box-pack: justify;
justify-content: space-between;
min-width: auto;
border-radius: var(--radius);
table-layout: fixed;
word-wrap: break-word;
font-size: var(--font-size);
width: 100%;
box-shadow: var(--shadow-short);
&.is-inline
{
box-shadow: none;
border: 1px solid var(--color-line);
}
}
.ui-table-row
{
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
flex-flow: row nowrap;
-webkit-box-align: center;
align-items: stretch;
width: 100%;
border-bottom: 1px solid var(--color-table-line-horizontal);
position: relative;
transition: outline 0.1s ease, box-shadow 0.1s ease;
color: var(--color-text);
&:last-child
{
border-bottom: none;
}
.ui-table.is-inline &
{
border-bottom-color: var(--color-line);
}
}
a.ui-table-row:hover, button.ui-table-row:hover
{
//box-shadow: var(--shadow-mid);
background: var(--color-table-hover);
z-index: 2;
}
.ui-table-head
{
font-weight: 700;
border-radius: 5px 5px 0 0;
color: var(--color-text);
position: sticky;
top: 0;
//border-bottom: 1px solid var(--color-line);
z-index: 3;
background: var(--color-table-head);
/*border-bottom: 2px solid var(--color-bg);*/
.ui-table-cell
{
justify-content: space-between;
font-size: var(--font-size-s);
}
.ui-table.is-inline &
{
background: var(--color-box);
}
}
.ui-table-cell
{
display: inline-flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
flex: 1 1 5%;
position: relative;
text-align: left;
padding: 15px 20px 14px 20px;
border-left: 1px solid var(--color-table-line-vertical);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-height: 58px;
min-width: 20px;
line-height: 20px;
&:first-child
{
border-left: none;
}
&.is-multiline
{
white-space: normal;
overflow: auto;
text-overflow: initial;
}
&.is-bold
{
font-weight: bold;
}
&.is-selectable, &.is-options
{
font-size: var(--font-size-l);
flex: 0 1 40px;
text-align: center;
padding: 0;
justify-content: flex-end;
.ui-native-check-toggle
{
margin-right: 0;
}
}
&.is-options
{
overflow: visible;
flex: 0 1 60px;
}
}
.ui-table-row:not(.ui-table-head) .ui-table-cell
{
color: var(--color-text-dim);
&.is-primary, &:first-child
{
color: var(--color-text);
}
&.is-vertical
{
flex-direction: column;
align-items: flex-start;
justify-content: center;
}
&.is-name .ui-icon
{
margin-left: .6em;
color: var(--color-synchronized);
}
}
.ui-table-row:not(.is-selected) .ui-table-cell:not(.is-head).is-selectable i
{
color: var(--color-text);
margin-right: 1px;
&:before
{
content: "\e8cb";
}
}
.ui-table-sort
{
height: 20px;
width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: -10px;
border-radius: 15px;
transition: background 0.2s ease;
&[disabled]
{
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.arrow
{
border-top-color: var(--color-text);
transition: opacity 0.2s ease, transform 0.3s ease;
opacity: 0.2;
}
/*&:hover
{
background: var(--color-bg);
}*/
&:hover .arrow
{
opacity: 0.5;
}
&.sort-desc .arrow, &.sort-asc .arrow,
{
opacity: 1;
}
&.sort-asc .arrow
{
transform: scaleY(-1) translateY(5px);
}
}
.ui-table-empty, .ui-table-loading
{
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 250px;
text-align: center;
padding: 0 20px;
}
.ui-table.is-inline .ui-table-empty,
.ui-table.is-inline .ui-table-loading
{
height: auto;
padding: var(--padding) 20px;
}
.ui-table-empty-icon
{
margin-bottom: 20px;
}
/* special styling for display types */
.ui-table-field-bool
{
color: var(--color-text-dim-one);
}
.ui-table-field-bool[data-symbol="fth-check"]
{
color: var(--color-primary);
}
.ui-table-cell[field-type="datetime"]
{
display: inline;
.-minor
{
color: var(--color-text-dim);
}
}
.ui-table-cell .-minor
{
color: var(--color-text-dim);
font-weight: 400;
font-size: var(--font-size-xs);
}
.ui-table-field-image
{
border-radius: 3px;
&.is-error
{
opacity: 0;
}
}
.ui-table-cell .state
{
display: inline-flex;
align-self: center;
align-items: center;
font-size: var(--font-size-xs);
color: var(--color-text);
font-weight: 600;
background: var(--color-table-highlight);
padding: 4px 12px;
border-radius: 20px;
&[data-state="completed"], &[data-state="cancelled"],
&[data-state="none"], &[data-state="captured"], &[data-state="authorized"], &[data-state="cancelled"], &[data-state="refunded"]
{
font-weight: 400;
color: var(--color-text-dim);
}
&[data-state="error"]
{
color: var(--color-accent-error);
background: var(--color-accent-error-bg);
}
&[data-payment-state]
{
font-weight: 400;
color: var(--color-text-dim);
}
&[data-payment-state="error"]
{
color: var(--color-accent-error);
background: var(--color-accent-error-bg);
}
}
.ui-table-row:hover .ui-table-cell .state
{
background: var(--color-bg-shade-4);
}
@@ -1,390 +0,0 @@
<template>
<div class="ui-table-outer">
<div v-if="!itemComponentConfig.active" class="ui-table" :class="{'is-inline': inline }">
<slot name="top"></slot>
<header class="ui-table-row ui-table-head">
<button type="button" v-if="listConfig.selectable" table-field="table_selectable" class="ui-table-cell is-head is-selectable" @click="select()">
<span class="ui-native-check">
<input type="checkbox" :checked="selected.length === items.length && selected.length > 0" />
<span class="ui-native-check-toggle"></span>
</span>
</button>
<div v-for="column in columns" :key="column.path" class="ui-table-cell" :table-field="column.path" :style="column.flex" :class="column.options.class">
<span v-localize:html="column.label"></span>
<button :disabled="!column.options.canSort" @click="sort(column)" type="button" class="ui-table-sort" :class="query.orderBy == column.path ? 'sort-' + (query.orderIsDescending ? 'desc' : 'asc') : null">
<i class="arrow arrow-down"></i>
</button>
</div>
<div v-if="listConfig.hasOptions" table-field="table_options" class="ui-table-cell is-head is-options"></div>
</header>
<slot name="topRow"></slot>
<component :is="component" v-for="(item, index) in items" :key="index" :to="getLink(item)" type="button" class="ui-table-row" :class="{ 'is-selected': selected.indexOf(item) > -1 }" @click="onRowClick(item)">
<button type="button" v-if="listConfig.selectable" table-field="table_selectable" class="ui-table-cell is-selectable" @click.prevent.stop="select(item)">
<span class="ui-native-check">
<input type="checkbox" :checked="selected.indexOf(item) > -1" />
<span class="ui-native-check-toggle"></span>
</span>
</button>
<div v-for="column in columns" :key="column.path" class="ui-table-cell" :class="column.class" :style="column.flex"
@input="onFieldValueChange(column, $event)"
:table-field="column.path" :field-type="column.type" v-table-value="{ column, item }"></div>
<div v-if="listConfig.hasOptions" table-field="table_options" class="ui-table-cell is-options">
<slot name="options" v-bind="{ item, index }"></slot>
</div>
</component>
<div class="ui-table-empty" v-if="!isLoading && items.length < 1">
<ui-icon class="ui-table-empty-icon" symbol="fth-list" :size="38" />
<span v-localize="'@ui.emptylist'"></span>
</div>
<div class="ui-table-loading" v-if="isLoading">
<ui-loading />
</div>
</div>
<div v-if="itemComponentConfig.active">
<div class="ui-datagrid-outer">
<div class="ui-datagrid">
<div class="ui-datagrid-items" :style="'grid-template-columns: repeat(auto-fill, minmax(' + itemComponentConfig.width + 'px, 1fr))'" :class="{'is-block': itemComponentConfig.block }">
<component :is="component" class="ui-datagrid-item" v-for="(item, index) in items" :key="index" :to="getLink(item)" type="button" @click="onRowClick(item)">
<component :is="itemComponentConfig.component" :value="item" class="ui-datagrid-cell" :class="{ 'is-selected': selected.indexOf(item) > -1 }"></component>
</component>
</div>
<div class="ui-datagrid-empty" v-if="!isLoading && items.length < 1">
<i class="ui-datagrid-empty-icon fth-list"></i>
<span v-localize="'@ui.emptylist'"></span>
</div>
<div class="ui-datagrid-loading" v-if="isLoading">
<ui-loading />
</div>
</div>
</div>
</div>
<footer class="ui-table-pagination" v-if="pages > 1">
<ui-pagination :pages="pages" :page="query.page" @change="setPage" :inline="inline" />
</footer>
</div>
</template>
<script lang="ts">
import './ui-table.scss';
import { debounce } from '../utils/timing';
import { defineComponent } from 'vue';
import Qs from 'qs';
import { compileList } from '../editor/compile';
const tableValue = function (el, binding)
{
const item = binding.value.item;
const column = binding.value.column.column;
const value = item[column.path];
if (column.isHtml)
{
el.innerHTML = column.render(value, item);
}
else
{
el.innerText = column.render(value, item);
}
}
const FILTER_KEY_PREFIX = 'zero.ui-table-filter.';
export default defineComponent({
name: 'uiTable',
props: {
config: {
type: [String, Object],
required: true
},
inline: {
type: Boolean,
default: false
}
},
directives: {
'table-value': tableValue
},
data: () => ({
loaded: false,
listConfig: {},
columns: [],
query: {},
alias: null,
filter: null,
items: [],
component: 'div',
isLoading: true,
pages: 1,
count: 0,
debouncedUpdate: null,
selected: [],
itemComponentConfig: { active: false }
}),
mounted()
{
this.setup();
},
watch: {
'query.search': function (val)
{
if (this.loaded)
{
this.query.page = 1;
this.onChange();
}
},
$route(to, from)
{
//console.info('$route changed', to);
this.setup();
}
},
methods: {
async setup()
{
this.debouncedUpdate = debounce(this.update, 300);
const schema = typeof this.config === 'string' ? await this.zero.getSchema(this.config) : this.config;
this.alias = schema.alias;
this.listConfig = compileList(this.zero, schema);
this.itemComponentConfig = this.listConfig.componentConfig || { active: false };
//this.listConfig.selectable = true;
this.columns = this.listConfig.columns.map(column =>
{
let col = {
...column,
class: column.options.class || '',
column: column,
label: column.options.hideLabel ? null : (column.options.label || this.listConfig.templateLabel(column.path)),
flex: column.options.width ? { 'flex': '0 1 ' + column.options.width + 'px' } : {}
};
if (column.options.primary)
{
col.class += ' is-primary';
}
return col;
});
this.query = { ...this.listConfig.query, ...this.listConfig.queryToParams(this.$route.query) };
this.component = !!this.listConfig.link ? 'router-link' : (!!this.listConfig.onClick ? 'button' : 'div');
this.filter = { ...this.listConfig.filterOptions };
if (this.query.filter && this.query.filter.id)
{
let filters = this.getStoredFilters();
let filter = filters.find(x => x.id === this.query.filter.id);
if (filter)
{
this.query.filter = { ...filter.filter, id: filter.id };
}
}
this.$nextTick(() =>
{
this.loaded = true;
this.load(true);
});
},
// load items based on the current query
async load(initial)
{
this.isLoading = true;
const fetchResult = await this.listConfig.fetch(this.query);
if (!fetchResult.success)
{
this.isLoading = false;
return;
}
this.$emit('loaded', fetchResult);
this.pages = fetchResult.paging.totalPages;
this.count = fetchResult.paging.totalItems;
this.$emit('count', this.count);
this.isLoading = false;
this.items = fetchResult.data;
this.selected = [];
if (!this.listConfig.preventScroll) //&& this.configuration.scrollToTop)
{
let container = document.querySelector(this.listConfig.scrollContainerSelector || '.app-main');
if (container)
{
this.$nextTick(() => container.scrollTo({ top: 0, behavior: 'smooth' }));
}
}
},
// load stored filters from local storage
getStoredFilters()
{
if (!this.filter.editor)
{
return [];
}
return JSON.parse(localStorage.getItem(FILTER_KEY_PREFIX + this.filter.editor.alias) || "[]");
},
// updates the list (debounced)
async update()
{
if (!this.isLoading)
{
await this.load();
}
},
// a property has changed and therefore we update the URL and table content
onChange()
{
if (!this.listConfig.noQueryString)
{
const query = this.listConfig.paramsToQuery(this.query, true);
this.$router.replace({ path: this.$route.path, params: this.$route.params, query })
}
else
{
this.debouncedUpdate();
}
},
getLink(item)
{
if (!this.listConfig.link)
{
return null;
}
else if (typeof this.listConfig.link === 'function')
{
return this.listConfig.link(item);
}
return {
name: this.listConfig.link,
params: {
id: item.id
}
};
},
onRowClick(item)
{
if (typeof this.listConfig.onClick === 'function')
{
this.listConfig.onClick(item);
}
},
// set the active page
setPage(index)
{
this.query.page = index;
this.onChange();
},
// set a new filter
setFilter(filter)
{
this.query.filter = filter;
this.onChange();
},
// sort by a column
sort(column)
{
if (this.query.orderBy === column.path && this.query.orderIsDescending)
{
this.query.orderIsDescending = false;
}
else if (this.query.orderBy === column.path)
{
this.query.orderBy = null;
}
else
{
this.query.orderBy = column.path;
this.query.orderIsDescending = true;
}
// reset the page on sorting change
this.query.page = 1;
this.onChange();
},
// toggle selection of an item
select(item)
{
if (!item)
{
if (this.selected.length >= this.items.length)
{
this.selected = [];
}
else
{
this.selected = [];
this.items.forEach(item =>
{
this.selected.push(item);
});
}
}
else
{
const index = this.selected.indexOf(item);
if (index > -1)
{
this.selected.splice(index, 1);
}
else
{
this.selected.push(item);
}
}
this.$emit('select', this.selected, this);
},
onFieldValueChange(column, ev)
{
console.info('change', column, ev);
}
}
})
</script>
@@ -1,240 +0,0 @@
<template>
<div class="ui-tabs">
<div role="tablist" class="ui-tabs-list">
<button type="button" v-for="(tab, index) in tabs" class="ui-tabs-list-item" :key="index"
:class="{ 'is-active': tab.active, 'has-errors': tab.hasErrors, 'is-hidden': tab.hide }"
:disabled="tab.disabled"
:aria-selected="tab.active ? true : null"
role="tab"
@click="select(index)">
<ui-icon v-if="tab.hasErrors" class="ui-tabs-list-item-error" :size="16" symbol="fth-alert-circle"></ui-icon>
<span v-localize="tab.label"></span>
<i v-if="tab.countOutput > 0" class="ui-tabs-list-item-count">{{tab.countOutput}}</i>
</button>
</div>
<div class="ui-tabs-items">
<slot />
</div>
</div>
</template>
<script>
export default {
name: 'uiTabs',
props: {
cache: {
type: String
},
active: {
type: Number,
default: 0
}
},
data: () => ({
storageKey: null,
tabs: []
}),
mounted()
{
this.cacheKey = this.cache ? `zero.ui-tabs.cache.${this.cache}` : null;
if (this.cache)
{
const cachedActiveTab = localStorage.getItem(this.cacheKey);
if (cachedActiveTab !== null)
{
this.select(+cachedActiveTab);
return;
}
}
this.select(this.active);
},
methods: {
register(tab)
{
this.tabs.push(tab);
},
unregister(tab)
{
this.tabs = this.tabs.splice(this.tabs.indexOf(tab), 1);
},
select(index, ev)
{
if (ev)
{
ev.preventDefault();
}
const currentTab = this.tabs[index];
if (!currentTab || currentTab.disabled)
{
return;
}
this.tabs.forEach((tab, tabIndex) =>
{
tab.active = index === tabIndex;
});
if (this.cache)
{
localStorage.setItem(this.cacheKey, index);
}
//this.$emit('changed', { tab: selectedTab });
//this.activeTabHash = selectedTab.hash;
//this.activeTabIndex = this.getTabIndex(selectedTabHash);
//this.lastActiveTabHash = this.activeTabHash = selectedTab.hash;
//expiringStorage.set(this.storageKey, selectedTab.hash, this.cacheLifetime);
}
}
}
</script>
<style lang="scss">
.ui-tabs
{
}
.ui-header-bar + .ui-tabs > .ui-tabs-list
{
padding-top: 0;
}
.ui-tabs-list
{
padding: var(--padding) var(--padding) 0;
margin-bottom: calc(var(--padding) * -1);
//height: 50px;
display: flex;
}
/*.ui-tabs-items > .ui-tab:first-child.ui-box:not(.is-blank)
{
border-top-left-radius: 0;
}*/
.ui-tabs-items
{
position: relative;
z-index: 1;
}
.ui-tabs-list-item
{
display: inline-flex;
align-items: center;
height: 50px;
padding: 0 var(--padding-m);
font-size: var(--font-size);
color: var(--color-text);
position: relative;
transition: color 0.2s ease;
border-radius: var(--radius-inner) var(--radius-inner) 0 0;
background: var(--color-box-light);
& + .ui-tabs-list-item
{
margin-left: 4px;
}
&:hover
{
color: var(--color-text);
}
&[disabled]
{
cursor: default;
color: var(--color-text-dim);
}
&.is-hidden
{
display: none;
}
&.is-active
{
font-weight: 700;
color: var(--color-text);
background: var(--color-box);
box-shadow: var(--shadow-short);
.ui-tabs-list-item-count
{
background: var(--color-box-light);
}
}
&.has-errors
{
color: var(--color-accent-error);
&.is-active
{
color: var(--color-accent-error);
&:before
{
background: var(--color-accent-error);
}
}
}
&:first-child:after
{
content: '';
position: absolute;
left: 0;
width: 30px;
bottom: -30px;
height: 30px;
background: var(--color-box-light);
z-index: 0;
}
}
.ui-tabs-list-item-count
{
font-style: normal;
font-size: 12px;
overflow: hidden;
float: right;
padding: 2px 6px;
background: var(--color-box);
border-radius: 10px;
margin-left: 8px;
margin-right: -4px;
margin-top: -1px;
font-weight: bold;
color: var(--color-text);
}
.ui-tabs-list-item-error
{
display: inline-block;
float: left;
margin-right: 6px;
margin-left: -4px;
position: relative;
margin-top: -4px;
top: 1px;
}
.ui-tab.ui-box:first-child
{
border-top-left-radius: 0;
}
</style>
@@ -1,67 +0,0 @@
<template>
<div class="ui-tags" :class="{'is-disabled': disabled }">
<ui-input-list :value="value" @input="$emit('input', $event)" :add-label="addLabel" :disabled="disabled" :max-items="maxItems" :max-length="maxLength" :plus-button="true" />
</div>
</template>
<script>
export default {
name: 'uiTags',
props: {
addLabel: {
type: String,
default: '@ui.add'
},
value: {
type: Array,
default: () => []
},
disabled: {
type: Boolean,
default: false
},
maxItems: {
type: Number,
default: 100
},
maxLength: {
type: Number,
default: 200
}
}
}
</script>
<style lang="scss">
.ui-tags
{
.ui-input-list
{
display: flex;
align-items: flex-start;
flex-wrap: wrap;
}
.ui-input-list-item
{
display: inline-grid;
min-width: 0;
.ui-input
{
min-width: 120px;
width: auto;
}
& + .ui-input-list-item, & + .ui-button, & + .ui-select-button
{
margin-top: 0;
margin-left: 6px;
margin-bottom: 8px;
}
}
}
</style>
@@ -1,48 +0,0 @@
<template>
<img v-if="media && !hasError" :src="src" @error="onError" />
<span v-else><slot></slot></span>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { paths } from '../options';
import { useAppStore } from '../modules/applications/store';
export default defineComponent({
name: 'uiThumbnail',
props: {
media: {
type: String,
required: true
},
size: {
type: String,
default: 'thumb'
}
},
data: () => ({
hasError: false,
appStore: null,
}),
created()
{
this.appStore = useAppStore();
},
computed: {
src()
{
return this.media ? (paths.api.replace('{app}', this.appStore.appId) + '/backoffice/ui/thumbnail/' + this.media + '-' + this.size + '.tmp') : null;
},
onError(ev)
{
this.hasError = true;
}
}
});
</script>
@@ -1,191 +0,0 @@
<template>
<div class="ui-timepicker" :class="{'is-disabled': disabled }">
<input type="text" class="ui-input ui-timepicker-input" v-localize:placeholder="placeholder" :value="output" @input="onChange" @focus="onFocus" @blur="onBlur" :disabled="disabled" />
<ui-icon v-if="!clear || !value" symbol="fth-calendar" class="ui-timepicker-icon" :size="17" />
<button v-if="clear && value" type="button" class="ui-timepicker-input-button" @click="clearInput"><ui-icon symbol="fth-x" :size="15" /></button>
<ui-dropdown ref="overlay" class="ui-timepicker-overlay" @opened="overlayOpened">
<ui-calendar :today="value" @change="onSelect" :options="pickerOptions" />
</ui-dropdown>
</div>
</template>
<script>
import { generateId } from '../utils/numbers';
import { formatDate, toIsoDate } from '../utils/dates';
const TIME_FORMAT = 'HH:mm';
export default {
name: 'uiTimepicker',
props: {
value: {
type: String,
default: null
},
placeholder: {
type: String,
default: '@ui.time.select'
},
clear: {
type: Boolean,
default: true
},
disabled: {
type: Boolean,
default: false
},
format: {
type: String,
default: null
},
amPm: {
type: Boolean,
default: false
}
},
data: () => ({
id: null,
output: null,
pickerOptions: {}
}),
watch: {
value()
{
this.updateOutput();
},
format()
{
this.updateOutput();
}
},
mounted()
{
this.id = 'timepicker-' + generateId();
this.updateOptions();
this.updateOutput();
},
methods: {
updateOutput()
{
this.output = formatDate(this.value, TIME_FORMAT);
},
updateOptions()
{
this.pickerOptions = {
enableTime: true,
noCalendar: true,
maxDate: null,
minDate: null,
time_24hr: !this.amPm
};
},
onSelect(date)
{
let dateStr = toIsoDate(date);
this.setValue(dateStr);
this.$refs.overlay.hide();
document.activeElement.blur();
},
onChange(ev)
{
this.setValue(ev.target.value);
},
setValue(value)
{
this.$emit('change', value);
this.$emit('input', value);
},
onFocus()
{
this.$refs.overlay.show();
},
onBlur()
{
if (!this.$refs.overlay.open)
{
this.$refs.overlay.hide();
}
},
clearInput()
{
this.setValue(null);
},
overlayOpened()
{
}
//pick()
//{
// this.$el.
//}
}
}
</script>
<style lang="scss">
.ui-timepicker
{
position: relative;
max-width: 260px;
}
input[type="text"].ui-timepicker-input
{
padding-right: 36px;
}
.ui-timepicker-icon
{
position: absolute;
right: 13px;
top: 50%;
margin-top: -8px;
}
.ui-timepicker-overlay .ui-dropdown
{
padding: 0;
}
.ui-timepicker-input-button
{
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 40px;
text-align: center;
font-size: var(--font-size);
padding-top: 1px;
}
</style>
@@ -1,193 +0,0 @@
<template>
<div class="ui-toggle" :class="{'is-disabled': disabled, 'is-negative': negative, 'is-active': on, 'is-content-left': contentLeft }">
<input type="checkbox" :value="on" @input="onChange" :disabled="disabled" />
<span class="ui-toggle-switch" :class="{ 'is-active': on }"><i></i></span>
<i class="fth-minus-circle ui-toggle-off-warning" v-if="offContent && !on && offWarning"></i>
<span class="ui-toggle-text" v-if="onContent && on" v-localize="onContent"></span>
<span class="ui-toggle-text" v-if="offContent && !on" v-localize="offContent"></span>
</div>
</template>
<script>
export default {
name: 'uiToggle',
props: {
on: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
negative: {
type: Boolean,
default: false
},
onContent: {
type: String,
default: null
},
offContent: {
type: String,
default: null
},
offWarning: {
type: Boolean,
default: false
},
contentLeft: {
type: Boolean,
default: false
}
},
methods: {
onChange(ev)
{
this.$emit('update:on', !this.on);
this.$emit('input', !this.on);
}
}
}
</script>
<style lang="scss">
.ui-toggle
{
display: inline-flex;
align-items: center;
position: relative;
height: 22px;
input
{
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
margin: 0;
z-index: 2;
opacity: 0;
cursor: pointer;
}
&.is-disabled input
{
cursor: default;
}
&.is-content-left
{
flex-direction: row-reverse;
.ui-toggle-text
{
margin-left: 0;
margin-right: 12px;
}
}
}
.ui-toggle-text
{
margin-top: 1px;
margin-left: 12px;
.ui-toggle.is-active &
{
font-weight: 600;
}
}
.ui-toggle-switch
{
display: inline-block;
height: 22px;
width: 36px;
background: var(--color-toggle);
border-radius: 20px;
border: 1px solid transparent;
transition: all 0.2s ease;
z-index: 1;
pointer-events: none;
i
{
display: inline-block;
height: 14px;
width: 14px;
border-radius: 20px;
margin: 3px;
background: var(--color-toggle-fg);
transition: all 0.2s ease;
}
&.is-active
{
background: var(--color-toggled);
}
&.is-active i
{
background: var(--color-toggled-fg);
transform: translateX(14px);
}
input:focus + &
{
border: var(--color-input-focus-border);
box-shadow: var(--color-input-focus-shadow);
outline: none;
&.is-active
{
background-color: var(--color-toggled);
}
}
.ui-toggle.is-disabled &
{
opacity: 0.7;
pointer-events: none;
}
}
.ui-toggle.onbg .ui-toggle-switch:not(.is-active)
{
background: var(--color-bg);
}
.ui-toggle.is-negative .ui-toggle-switch.is-active
{
background: var(--color-negative);
border-color: transparent !important;
i
{
background: white;
}
}
.ui-toggle.is-accent .ui-toggle-switch.is-active
{
background: var(--color-accent);
border-color: transparent !important;
i
{
background: var(--color-accent-fg);
}
}
.ui-toggle-off-warning
{
margin: 0 10px 0 -5px;
}
</style>
@@ -1,379 +0,0 @@
<template>
<div class="ui-tree-item" :class="getClasses(value)" v-on:contextmenu="onRightClicked(value, $event)">
<button :disabled="value.disabled" v-if="value.hasChildren" @click="toggle(value)" type="button" class="ui-tree-item-toggle" :style="{ 'width': ((depth * 15) + 32) + 'px' }">
<ui-icon class="ui-tree-item-arrow" :symbol="'fth-chevron-' + (value.isOpen ? 'up' : 'down')" :size="14" />
</button>
<span v-if="!value.hasChildren" class="ui-tree-item-toggle" :style="{ 'width': ((depth * 15) + 32) + 'px' }"></span>
<component :disabled="value.disabled" :is="tag" type="button" :to="value.url" class="ui-tree-item-link" @click="onClick(value, $event)">
<ui-icon class="ui-tree-item-icon" v-if="value.icon" :class="{'is-dashed': value.isDashed }" :symbol="value.icon" :size="18" />
<ui-icon v-if="value.modifier" :title="value.modifier.name" class="ui-tree-item-modifier" :symbol="modifier" :class="modifierClass" :size="10" :stroke="2.5" />
<span class="ui-tree-item-text">
<ui-localize :value="value.name" />
<span class="ui-tree-item-description" v-if="value.description">
<br />
<ui-localize :value="value.description" />
</span>
</span>
</component>
<ui-dot-button :disabled="value.disabled" class="ui-tree-item-actions" v-if="value.hasActions" @click="onActionsClicked(value, $event)" />
<span class="ui-tree-item-count" v-if="value.countOutput != null">{{value.countOutput}}</span>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'uiTreeItem',
props: {
value: {
type: Object,
required: true
},
activeId: {
type: String,
default: null
},
selected: {
type: Boolean,
default: false
},
depth: {
type: Number,
default: 0
},
},
data: () => ({
_isActive: false
}),
computed: {
isLink()
{
return this.value && this.value.url;
},
tag()
{
return this.isLink ? 'router-link' : 'button';
},
modifier()
{
return this.value && this.value.modifier ? this.value.modifier.icon.split(' ')[0] : null;
},
modifierClass()
{
return this.value && this.value.modifier ? this.value.modifier.icon.split(' ')[1] : null;
},
isActive()
{
return this.value && this.isLink && (
(!!this.value.id && this.value.id === this.activeId)
|| this.value.id == this.$route.params.id
|| (this.value.url && !this.value.url.params && this.value.url.name === this.$route.name && !this.$route.params.id)
);
}
},
watch: {
isActive(val)
{
if (val)
{
this.$emit('setactive', {
$el: this.$el,
model: this.value
});
}
}
},
mounted()
{
if (this.isActive)
{
this.$emit('setactive', {
$el: this.$el,
model: this.value
});
}
},
methods: {
onClick(item, ev)
{
if (this.isLink)
{
return;
}
else
{
this.$emit('click', item, ev);
}
},
// get all classes for a tree item
getClasses(item)
{
return {
'has-icon': !!item.icon,
'has-children': item.hasChildren,
'is-inactive': item.isInactive,
'is-open': item.isOpen,
'is-selected': this.selected || item.isSelected,
'is-disabled': item.disabled,
'is-active': this.isActive
};
},
// toggles children of an item
toggle(item)
{
this.$emit('open', item);
},
// right clicked on an item
onRightClicked(item, ev)
{
if (!item.disabled)
{
this.$emit('rightclick', item, ev);
}
},
// actions button clicked on item
onActionsClicked(item, ev)
{
if (!item.disabled)
{
this.$emit('actions', item, ev);
}
}
}
})
</script>
<style lang="scss">
.ui-tree-item
{
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
font-size: var(--font-size);
padding: 0 var(--padding) 0 0;
//height: 54px;
height: 58px;
border-top: 1px dashed var(--color-line-dashed);
color: var(--color-text);
position: relative;
transition: color 0.2s ease;
position: relative;
.theme-dark &
{
border-top: 1px dashed var(--color-line);
}
&:hover > .ui-tree-item-actions
{
opacity: 1;
}
&.is-disabled
{
cursor: not-allowed;
opacity: .5;
}
&.is-active:before, &.is-selected:before, &:hover:before
{
content: ' ';
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: var(--color-tree-selected);
}
&.is-selected:after
{
font-family: "Feather";
content: "\e83e";
font-size: 16px;
color: var(--color-primary);
z-index: 2;
}
&.is-selected .ui-tree-item-text
{
font-weight: bold;
}
}
.ui-tree-header + .ui-tree-item
{
margin-top: -8px;
}
.ui-tree-item-link
{
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 6px;
height: 100%;
align-items: center;
position: relative;
color: var(--color-text);
&:hover
{
color: var(--color-text);
}
&.is-active
{
color: var(--color-text);
font-weight: bold;
//color: var(--color-primary);
}
}
.ui-tree-item-text
{
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ui-tree-item-description
{
color: var(--color-text-dim);
font-size: var(--font-size-xs);
}
.ui-tree-item-toggle
{
color: var(--color-text-dim);
height: 100%;
text-align: right;
padding-right: 4px;
transition: color 0.2s ease;
z-index: 1;
&:hover
{
color: var(--color-text);
}
}
.ui-tree-item-icon
{
position: relative;
top: -2px;
color: var(--color-text-dim);
transition: color 0.2s ease;
}
.ui-tree-item-icon.is-dashed
{
stroke-dasharray: 3px;
}
.ui-tree-item:hover .ui-tree-item-icon
{
color: var(--color-text);
}
.ui-tree-item.is-active .ui-tree-item-icon
{
color: var(--color-text);
}
.ui-tree-item-loading
{
display: block;
overflow: hidden;
position: absolute;
left: 0;
right: 0;
height: 2px;
i
{
background-color: var(--color-bg-shade-4);
transform: translateX(-100%) scaleX(1);
animation: treeitemloading 1s linear infinite;
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
}
}
@keyframes treeitemloading
{
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.ui-tree-item-modifier
{
position: absolute;
left: 10px;
bottom: 17px;
color: var(--color-text-dim);
background: var(--color-tree);
border-radius: 50%;
padding: 2px;
width: 14px;
height: 14px;
transition: color 0.2s ease;
.ui-tree-item.is-active &, .ui-tree-item:hover &
{
color: var(--color-text);
}
.ui-tree-item.is-active &
{
background: var(--color-tree-selected);
}
}
.ui-tree-item-actions
{
opacity: 0;
color: var(--color-text-dim);
&:focus
{
opacity: 1;
}
}
.ui-tree-item-count
{
display: inline-block;
font-size: 11px;
font-weight: 400;
text-transform: uppercase;
background: var(--color-box-nested);
color: var(--color-text);
height: 22px;
line-height: 22px;
padding: 0 10px;
border-radius: 16px;
font-style: normal;
grid-column: 3;
margin-right: -4px;
margin-left: 8px;
}
</style>
@@ -1,257 +0,0 @@
<template>
<div class="ui-tree">
<ui-header-bar class="ui-tree-header" :title="header" :back-button="false" v-if="header">
<ui-dot-button @click="onActionsClicked(null, $event)" />
</ui-header-bar>
<slot></slot>
<span v-if="status === 'loading'" class="ui-tree-item-loading"><i></i></span>
<template v-for="item in items">
<ui-tree-item :value="item" :active-id="active" :depth="depth" :selected="selection.indexOf(item.id) > -1"
@rightclick="onRightClicked" @click="onSelect(item, $event)" @actions="onActionsClicked" @open="toggle" @setactive="onActiveSet"
/>
<ui-tree v-if="item.hasChildren && item.isOpen && status != 'loading'" v-bind="{ get, parent: item.id, depth: depth + 1, active, mode, selection, selectionLimit }" @select="onChildSelect" @setactive="onActiveSet">
<template v-slot:actions="props">
<slot name="actions" v-bind="props"></slot>
</template>
</ui-tree>
</template>
<slot name="bottom"></slot>
<ui-dropdown ref="dropdown" align="top" theme="dark" class="ui-tree-dropdown">
<slot name="actions" v-bind="actionProps"></slot>
</ui-dropdown>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import UiTreeItem from './ui-tree-item.vue';
export default defineComponent({
name: 'uiTree',
components: { UiTreeItem },
props: {
depth: {
type: Number,
default: 0
},
active: {
type: String,
default: null
},
parent: {
type: String,
default: null
},
header: {
type: String,
default: null
},
get: {
type: Function,
required: true
},
hasActions: {
type: Function,
default: null
},
mode: {
type: String,
default: 'link'
},
selection: {
type: Array,
default: () => []
},
selectionLimit: {
type: Number,
default: 1
}
},
data: () => ({
items: [],
status: 'none',
actionProps: {
item: null
}
}),
computed: {
actionsDefined()
{
return this.$slots.hasOwnProperty('actions');
}
},
mounted()
{
this.refresh();
},
methods: {
onActiveSet(val)
{
this.$emit('setactive', val);
},
// refreshes the whole tree
refresh()
{
this.load(this.parent);
},
// loads children of the given parent id or on root if empty
load(parent)
{
this.setStatus('loading', this.items);
let promise = this.get(parent, this.active);
promise.then(response =>
{
this.items = response;
this.setStatus('loaded', this.items);
})
.catch(error =>
{
console.error(error);
this.setStatus('error', this.items, error);
// TODO handle errors
});
},
// updates the status for the current tree
setStatus(status)
{
this.status = status;
this.$emit('onStatusChange', status);
},
// toggles children of an item
toggle(item)
{
item.isOpen = !item.isOpen;
},
// selected an item
onSelect(item, ev)
{
if (this.mode === 'select')
{
let index = this.selection.indexOf(item.id);
if (index > -1)
{
this.selection.splice(index, 1);
}
else if (this.selectionLimit === 1)
{
this.selection.splice(0, this.selection.length);
this.selection.push(item.id);
}
else if (this.selection.length < this.selectionLimit)
{
this.selection.push(item.id);
}
this.$emit('select', this.selectionLimit > 1 ? this.selection : (this.selection.length > 0 ? this.selection[0] : null), ev);
}
else
{
this.$emit('select', item, ev);
}
},
onChildSelect(item, ev)
{
this.$emit('select', item, ev);
},
// right clicked on an item
onRightClicked(item, ev)
{
if (this.actionsDefined && (!item || item.hasActions))
{
ev.preventDefault();
this.onActionsClicked(item, ev);
}
},
// actions button clicked on item
onActionsClicked(item, ev)
{
let dropdown = this.$refs.dropdown;
if (!this.actionsDefined || (item && !item.hasActions) || (typeof this.hasActions === 'function' && !this.hasActions(item)))
{
return;
}
this.actionProps.item = item;
this.actionProps.event = ev;
dropdown.toggle();
if (!dropdown.open)
{
return;
}
this.$nextTick(() =>
{
let target = ev.target;
do
{
if (target.classList.contains('ui-tree-item') || target.classList.contains('ui-tree-header'))
{
break;
}
}
while (target = target.parentElement);
target = target.querySelector('.ui-dot-button');
var rect = target.getBoundingClientRect();
var width = 240;
var position = {
x: rect.left - width + rect.width,
y: rect.top + rect.height
};
let element = dropdown.$el.querySelector('.ui-dropdown');
element.style.top = position.y + 'px';
element.style.left = position.x + 'px';
element.style.width = width + 'px';
});
}
}
})
</script>
<style lang="scss">
.ui-tree
{
position: relative;
overflow-x: hidden;
}
.ui-tree-dropdown .ui-dropdown
{
position: fixed;
min-width: 200px;
}
</style>
@@ -1,56 +0,0 @@
<template>
<div class="ui-trinity">
<header>
<slot name="header"></slot>
</header>
<content>
<slot></slot>
</content>
<footer>
<slot name="footer"></slot>
</footer>
</div>
</template>
<script>
export default {
name: 'uiTrinity'
}
</script>
<style lang="scss">
.ui-trinity
{
display: grid;
grid-template-rows: auto 1fr auto;
height: 100vh;
> header
{
display: block;
}
> footer
{
display: flex;
justify-content: flex-end;
padding: 20px var(--padding);
align-items: center;
//background: var(--color-overlay-footer);
> * + *
{
margin-left: 15px;
}
}
> content
{
display: block;
padding: var(--padding);
height: 100%;
overflow-y: auto;
}
}
</style>
@@ -1,19 +0,0 @@
import { App } from 'vue';
import { ZeroRuntime, ZeroInstallOptions } from './index';
export function createZeroPlugin(options?: ZeroInstallOptions)
{
return {
install: (app: App) =>
{
const zero = new ZeroRuntime(app, options);
app.config.globalProperties.zero = zero;
zero.useZero();
zero.usePlugins();
zero.useRouter();
}
};
}
-7
View File
@@ -1,7 +0,0 @@
export * from './createZeroPlugin';
export * from './zeroRuntime';
export * from './types/zero';
export * from './types/zeroPlugin';
export * from './types/zeroInstallOptions';
export * from './types/zeroPluginOptions';
@@ -1,18 +0,0 @@
import { RouteLocationNormalized, NavigationGuardNext, nav } from 'vue-router';
export default function (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext)
{
if (from.matched.length && from.matched[0].instances)
{
let instance = from.matched[0].instances.default;
if (instance.$refs['form'] && typeof instance.$refs.form.beforeRouteLeave === 'function')
{
isGuarded = true;
instance.$refs.form.beforeRouteLeave(to, from, res =>
{
next(res !== false);
});
}
}
}
@@ -1,34 +0,0 @@
import { Zero } from '../types/zero';
import { createWebHistory, Router, RouterOptions } from 'vue-router';
import titleGuard from './titleGuard';
import formDirtyGuard from './formDirtyGuard';
export function getRouterConfig(basePath: string, zero: Zero): RouterOptions
{
let options = {
history: createWebHistory(basePath),
linkActiveClass: 'is-active',
linkExactActiveClass: 'is-active-exact',
scrollBehavior(to, from, savedPosition)
{
return savedPosition ? savedPosition : { x: 0, y: 0 };
},
routes: []
} as RouterOptions;
options.routes.push({ name: 'dashboard', path: '/', redirect: '/pages' }); // component: () => import('../../dashboard.vue') });
options.routes.push({ name: '404', path: '/:pathMatch(.*)', component: () => import('../../notfound.vue') });
return options;
}
export function appendRouterGuards(router: Router): Router
{
//router.beforeEach(formDirtyGuard);
//router.beforeEach(titleGuard);
return router;
}
@@ -1,43 +0,0 @@
import { localize } from '../../services/localization';
import { RouteLocationNormalized, NavigationGuardNext } from 'vue-router';
export default function (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext)
{
let title = localize('@zero.name');
let name = to.meta.name;
if (!name && to.matched.length > 1)
{
to.matched.forEach(route =>
{
if (!name && route.meta.name)
{
name = route.meta.name;
}
});
}
if (!name || to.meta.alias === 'dashboard') //__zero.alias.sections.dashboard)
{
document.title = title;
next();
return;
}
let nameParts = Array.isArray(name) ? name : [name];
let translations = [];
nameParts.forEach(part =>
{
if (part)
{
translations.push(localize(part));
}
});
title += ': ' + translations.join(' - ');
document.title = title;
next();
}
-62
View File
@@ -1,62 +0,0 @@
import { Emitter, EventType } from "mitt";
import { Component } from "vue";
import { ZeroSchema, ZeroSchemaExtension } from "zero/schemas";
export interface Zero
{
version: string;
/**
* access the event hub
**/
events: Emitter<Record<EventType, unknown>>;
options: ZeroOptions;
useZero: () => void;
useRouter: () => void;
usePlugins: () => void;
runPlugins: () => void;
runtimeVariables: Record<string, any>;
/**
* get a defined field type component
**/
getFieldTypeComponent(alias: string): Component | undefined;
/**
* get a defined list or editor schema
**/
getSchema(alias: string): Promise<ZeroSchema | null>;
/**
* get all defined extensions for a schema
**/
getSchemaExtensions(alias: string): ZeroSchemaExtension[];
/**
* get all defined link areas
**/
linkAreas: ZeroLinkArea[];
/**
* get a defined link area by alias
**/
getLinkArea(alias: string): ZeroLinkArea | undefined;
}
export interface ZeroOptions
{
paths: ZeroPathOptions;
}
export interface ZeroPathOptions
{
root: string;
api: string;
}
export interface ZeroLinkArea
{
alias: string;
name: string;
component: Component;
}
@@ -1,5 +0,0 @@
export interface ZeroInstallOptions
{
}
@@ -1,30 +0,0 @@
import { App } from 'vue';
import { Zero } from './zero';
import { ZeroPluginOptions } from './zeroPluginOptions';
export interface ZeroPlugin
{
name: string;
install: (zero: ZeroPluginOptions) => void;
run?: (zero: Zero) => void;
}
//export class Zero
//{
// _app: App;
// _options: ZeroInstallOptions;
// get version(): string
// {
// return "0.0.1";
// }
// constructor(app: App, options?: ZeroInstallOptions)
// {
// this._app = app;
// this._options = options || {};
// console.info('[zero installed]');
// }
//}
@@ -1,39 +0,0 @@
import { RouteRecordRaw } from 'vue-router';
import { App, Component } from 'vue';
import { ZeroSchemaProp } from '../zero';
import { ZeroLinkArea } from './zero';
import { ZeroSchema, ZeroSchemaExtension } from 'zero/schemas';
export interface ZeroPluginOptions
{
vue: App;
routes: RouteRecordRaw[];
route: (route: RouteRecordRaw) => void;
schemas: Record<string, ZeroSchemaProp>;
schema: (alias: string, schema: ZeroSchemaProp) => void;
schemaExtensions: ZeroSchemaExtension[];
extendSchema: (alias: string, extension: ((schema: ZeroSchema) => void)) => void;
fieldTypes: Record<string, Component>;
fieldType: (alias: string, component: Component) => void;
linkAreas: Record<string, ZeroLinkArea>;
linkArea: (alias: string, name: string, component: Component) => void;
}
//export class Zero
//{
// _app: App;
// _options: ZeroInstallOptions;
// get version(): string
// {
// return "0.0.1";
// }
// constructor(app: App, options?: ZeroInstallOptions)
// {
// this._app = app;
// this._options = options || {};
// console.info('[zero installed]');
// }
//}
-16
View File
@@ -1,16 +0,0 @@
import { ZeroSchema } from "zero/schemas";
import { Zero } from "./types/zero";
declare module '@vue/runtime-core'
{
export interface ComponentCustomProperties
{
/**
* zero instance
*/
zero: Zero
}
}
declare type Lazy<T> = () => Promise<T>;
declare type ZeroSchemaProp = ZeroSchema | Lazy<ZeroSchema>;
-226
View File
@@ -1,226 +0,0 @@
import { App, Component } from 'vue';
import { ZeroInstallOptions } from './types/zeroInstallOptions';
import { ZeroPluginOptions } from './types/zeroPluginOptions';
import { Zero, ZeroLinkArea, ZeroOptions } from './types/zero';
import { createRouter, RouteRecordRaw, RouterOptions } from 'vue-router';
import registerDirectives from '../directives/register';
import registerComponents from '../components/register';
import registerEditorComponents from '../editor/register';
import { getRouterConfig, appendRouterGuards } from './router/routerConfig';
import
{
previewPlugin,
countryPlugin,
applicationPlugin,
settingsPlugin,
languagePlugin,
pagePlugin,
mediaPlugin,
spacePlugin,
mailTemplatePlugin,
translationPlugin,
integrationPlugin,
userPlugin,
linksPlugin,
pageModulePlugin,
searchPlugin
} from '../modules';
import editorPlugin from '../editor/plugin';
import { ZeroSchema, ZeroSchemaExtension } from 'zero/schemas';
import { ZeroSchemaProp } from './zero';
import * as zeroOptions from '../options';
import plugins from '../plugins.generated';
import eventHub from '../services/eventhub';
import { Emitter, EventType } from 'mitt';
plugins.push(
editorPlugin, previewPlugin, countryPlugin, applicationPlugin, settingsPlugin, languagePlugin,
pagePlugin, linksPlugin, mediaPlugin, spacePlugin, mailTemplatePlugin,
translationPlugin, integrationPlugin, userPlugin, pageModulePlugin, searchPlugin
);
export class ZeroRuntime implements Zero
{
_app: App;
_installOptions: ZeroInstallOptions;
_routerConfig: RouterOptions;
_schemas: Record<string, ZeroSchemaProp> = {};
_schemaExtensions: ZeroSchemaExtension[] = [];
_fieldTypes: Record<string, Component> = {};
_linkAreas: Record<string, ZeroLinkArea> = {};
/**
* version of zero backoffice
**/
get version(): string
{
return "0.0.1";
}
/**
* access the event hub
**/
get events(): Emitter<Record<EventType, unknown>>
{
return eventHub;
}
/**
* options
**/
options: ZeroOptions;
/**
* runtime variables
**/
runtimeVariables: Record<string, any> = {};
constructor(app: App, options?: ZeroInstallOptions)
{
this._app = app;
this._installOptions = options || {};
this._routerConfig = getRouterConfig('/zero', this);
this.options = zeroOptions;
}
/**
* register core components
**/
useZero()
{
let app = this._app;
registerDirectives(app);
registerComponents(app);
registerEditorComponents(app);
}
/**
* create plugin options which are passed to install() for all plugins
**/
usePlugins()
{
const pluginOptions = {
vue: this._app,
routes: this._routerConfig.routes,
schemas: this._schemas,
schemaExtensions: this._schemaExtensions,
fieldTypes: this._fieldTypes,
linkAreas: this._linkAreas,
route(route: RouteRecordRaw)
{
this.routes.push(route);
},
schema(alias: string, schema: ZeroSchemaProp)
{
this.schemas[alias] = schema;
},
extendSchema(alias: string, extension: ((schema: ZeroSchema) => void))
{
this.schemaExtensions.push({ alias, extension });
},
fieldType(alias: string, component: Component) // TODO v3 allow default field options
{
this.fieldTypes[alias] = component;
},
linkArea(alias: string, name: string, component: Component)
{
this.linkAreas[alias] = { alias, name, component };
}
} as ZeroPluginOptions;
// install all plugins
plugins.forEach(plugin =>
{
plugin.install(pluginOptions);
});
}
runPlugins()
{
plugins.forEach(plugin =>
{
if (typeof plugin.run === 'function')
{
plugin.run(this);
}
});
}
/**
* after all plugins were installed we can create the router
* with the designated routes
**/
useRouter()
{
const router = createRouter(this._routerConfig);
appendRouterGuards(router);
this._app.use(router);
}
/**
* get a defined field type component
**/
getFieldTypeComponent(alias: string): Component | undefined
{
return this._fieldTypes[alias];
}
/**
* get a defined list or editor schema
**/
async getSchema(alias: string): Promise<ZeroSchema | null>
{
let schema: ZeroSchemaProp = this._schemas[alias];
if (!schema)
{
return Promise.resolve(null);
}
if (typeof schema === 'function')
{
const res = await schema();
schema = res.default as ZeroSchema;
}
schema.alias = alias;
return Promise.resolve(schema);
}
/**
* get all defined extensions for a schema
**/
getSchemaExtensions(alias: string): ZeroSchemaExtension[]
{
return this._schemaExtensions.filter(x => x.alias == alias);
}
/**
* get all defined link areas
**/
get linkAreas(): ZeroLinkArea[]
{
return Object.values(this._linkAreas);
}
/**
* get a defined link area by alias
**/
getLinkArea(alias: string): ZeroLinkArea | undefined
{
return this._linkAreas[alias];
}
}
-27
View File
@@ -1,27 +0,0 @@
<template>
<div class="dashboard">hi zero</div>
</template>
<script lang="ts">
export default {
}
</script>
<style lang="scss">
.dashboard
{
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 48px;
font-weight: 100;
color: var(--color-text);
opacity: 0.2;
pointer-events: none;
user-select: none;
}
</style>
@@ -1,35 +0,0 @@
import { App } from 'vue';
import vClickOutside from './v-click-outside';
import vCurrency from './v-currency';
import vDate from './v-date';
import vFilesize from './v-filesize';
import vLocalize from './v-localize';
import vMaxLines from './v-max-lines';
import vPlaceholder from './v-placeholder';
import vResizeable from './v-resizeable';
import vSortable from './v-sortable';
import vEncode from './v-encode'
import vMultiline from './v-multiline';
const directives = [
{ key: 'click-outside', definition: vClickOutside },
{ key: 'currency', definition: vCurrency },
{ key: 'date', definition: vDate },
{ key: 'filesize', definition: vFilesize },
{ key: 'localize', definition: vLocalize },
{ key: 'max-lines', definition: vMaxLines },
{ key: 'placeholder', definition: vPlaceholder },
{ key: 'resizeable', definition: vResizeable },
{ key: 'sortable', definition: vSortable },
{ key: 'encode', definition: vEncode },
{ key: 'multiline', definition: vMultiline }
];
export default function (app: App)
{
directives.forEach(directive =>
{
app.directive(directive.key, directive.definition);
});
};
@@ -1,94 +0,0 @@
const isPopup = (popupItem, elements) =>
{
if (!popupItem || !elements)
{
return false;
}
for (var i = 0, len = elements.length; i < len; i++)
{
try
{
if (popupItem.contains(elements[i]))
{
return true;
}
if (elements[i].contains(popupItem))
{
return false;
}
}
catch (e)
{
return false;
}
}
return false;
};
const validate = (binding) =>
{
if (typeof binding.value !== 'function')
{
console.warn('v-click-outside: provided expression ' + binding.expression + ' is not a function.');
return false;
}
return true;
};
/**
* resize an element
*/
export default {
bind(el, binding, vNode)
{
if (!validate(binding)) return;
// Define Handler and cache it on the element
function handler(e)
{
if (!vNode.context) return;
// some components may have related popup item, on which we shall prevent the click outside event handler.
var elements = e.path || (e.composedPath && e.composedPath());
elements && elements.length > 0 && elements.unshift(e.target);
if (el.contains(e.target) || isPopup(vNode.context.popupItem, elements) || !el.__vueClickOutside__)
{
return;
}
el.__vueClickOutside__.callback(e);
}
// add Event Listeners
el.__vueClickOutside__ = {
handler: handler,
callback: binding.value
};
setTimeout(() =>
{
document.addEventListener('click', handler);
}, 200);
},
update(el, binding)
{
if (validate(binding))
{
el.__vueClickOutside__.callback = binding.value;
}
},
unbind(el, binding, vNode)
{
document.removeEventListener('click', el.__vueClickOutside__.handler);
delete el.__vueClickOutside__;
}
};
@@ -1,13 +0,0 @@
import { toCurrency } from '../utils/numbers';
/**
* Outputs a currency
*/
export default (el, binding) =>
{
if (binding.value !== binding.oldValue)
{
el.innerHTML = toCurrency(binding.value);
}
};
@@ -1,19 +0,0 @@
import { formatDate } from '../utils/dates';
/**
* Outputs a formatted date
*/
export default (el, binding) =>
{
if (binding.value !== binding.oldValue)
{
if (!binding.value)
{
el.innerHTML = '-';
return;
}
el.innerHTML = formatDate(binding.value, binding.arg);
}
};
@@ -1,11 +0,0 @@
import { convertHtmlToText } from '../utils';
export default (el, binding) =>
{
if (binding.value !== binding.oldValue)
{
el.innerHTML = convertHtmlToText(binding.value, binding.arg === 'nl');
}
};
@@ -1,13 +0,0 @@
import { getFilesize } from '../utils/filesize';
/**
* Outputs a filesize
*/
export default (el, binding) =>
{
if (binding.value !== binding.oldValue)
{
el.innerText = getFilesize(binding.value, typeof binding.arg !== 'undefined' ? +binding.arg : 1);
}
};
@@ -1,40 +0,0 @@
import { localize } from '../services/localization';
/**
* Localizes the given property and sets the inner-text of the node to its result
*/
export default (el, binding) =>
{
if (binding.value !== binding.oldValue || !el.innerText)
{
const hasValue = !!binding.value;
const isObject = typeof binding.value === 'object';
let key = hasValue ? (isObject ? binding.value.key : binding.value) : null;
let options = hasValue && isObject ? binding.value : null;
let html = isObject && binding.value ? (binding.value.html || binding.arg === 'html') : binding.arg === 'html';
const result = hasValue ? localize(key, options) : '';
// set content as html
if (html)
{
el.innerHTML = result;
}
// set title
else if (binding.arg === 'title')
{
el.title = result;
}
// set attribute
else if (binding.arg)
{
el.setAttribute(binding.arg, result);
}
// set content as plain text
else
{
el.innerText = result;
}
}
};
@@ -1,21 +0,0 @@
/**
* Limits an element to a maximum number of text lines (can be toggled with click)
*/
export default (el, binding) =>
{
if (binding.value !== binding.oldValue)
{
if (!el.__zero_maxlines)
{
el.__zero_maxlines = true;
el.addEventListener('click', e =>
{
el.classList.toggle('is-expanded');
});
}
el.classList.add('ui-maxlines');
el.style.webkitLineClamp = +binding.value > 0 ? +binding.value : null;
}
};
@@ -1,11 +0,0 @@
/**
* Converts new line chars to <br>
*/
export default (el, binding) =>
{
if (binding.value !== binding.oldValue)
{
el.innerHTML = binding.value.split('\n').join('<br>');
}
};

Some files were not shown because too many files have changed in this diff Show More