forked from geolba/tethys.backend
- added NcModal.vue, NcActions.vue, NcButton.vue, FirstrunWizard.vue, Card.vue, Page0.vue, Page1.vue, Page2.vue, Page3.vue and some icons
- added lime color inside tailwind.config.js - added some utilities scripts needed for components - npm updates - changed postcss.config.js for nesting css styles - added about function to NavBar.vue
This commit is contained in:
parent
cefd9081ae
commit
87e9314b00
683
package-lock.json
generated
683
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
@ -85,6 +85,7 @@
|
|||
"clamscan": "^2.1.2",
|
||||
"crypto": "^1.0.1",
|
||||
"dayjs": "^1.11.7",
|
||||
"focus-trap": "^7.5.4",
|
||||
"http-status-codes": "^2.2.0",
|
||||
"leaflet": "^1.9.3",
|
||||
"luxon": "^3.2.1",
|
||||
|
@ -92,7 +93,7 @@
|
|||
"pg": "^8.9.0",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"redis": "^4.6.10",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"saxon-js": "^2.5.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"vuedraggable": "^4.1.0",
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
// 'postcss-import': {},
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
|
|
|
@ -14,6 +14,101 @@
|
|||
@import '@fontsource/inter/index.css';
|
||||
@import '@fontsource/archivo-black/index.css';
|
||||
|
||||
:root {
|
||||
--color-main-background: #ffffff;
|
||||
--color-main-background-rgb: 255,255,255;
|
||||
--color-main-background-translucent: rgba(var(--color-main-background-rgb), .97);
|
||||
--color-main-background-blur: rgba(var(--color-main-background-rgb), .8);
|
||||
--filter-background-blur: blur(25px);
|
||||
--gradient-main-background: var(--color-main-background) 0%, var(--color-main-background-translucent) 85%, transparent 100%;
|
||||
--color-background-hover: #f5f5f5;
|
||||
/** Can be used e.g. to colorize selected table rows */
|
||||
--color-background-dark: #ededed;
|
||||
/** This should only be used for elements, not as a text background! Otherwise it will not work for accessibility. */
|
||||
--color-background-darker: #dbdbdb;
|
||||
--color-placeholder-light: #e6e6e6;
|
||||
--color-placeholder-dark: #cccccc;
|
||||
--color-main-text: #222222;
|
||||
--color-text-maxcontrast: #6b6b6b;
|
||||
--color-text-maxcontrast-default: #6b6b6b;
|
||||
--color-text-maxcontrast-background-blur: #595959;
|
||||
/** @deprecated use ` --color-main-text` instead */
|
||||
--color-text-light: var(--color-main-text);
|
||||
/** @deprecated use `--color-text-maxcontrast` instead */
|
||||
--color-text-lighter: var(--color-text-maxcontrast);
|
||||
--color-scrollbar: rgba(34,34,34, .15);
|
||||
--color-error: #d91812;
|
||||
--color-error-rgb: 217,24,18;
|
||||
--color-error-hover: #dd342f;
|
||||
--color-error-text: #c61610;
|
||||
--color-warning: #b88100;
|
||||
--color-warning-rgb: 184,129,0;
|
||||
--color-warning-hover: #c69a32;
|
||||
--color-warning-text: #855d00;
|
||||
--color-success: #2d7b41;
|
||||
--color-success-rgb: 45,123,65;
|
||||
--color-success-hover: #448955;
|
||||
--color-success-text: #286c39;
|
||||
--color-info: #0071ad;
|
||||
--color-info-rgb: 0,113,173;
|
||||
--color-info-hover: #197fb5;
|
||||
--color-info-text: #006499;
|
||||
--color-loading-light: #cccccc;
|
||||
--color-loading-dark: #444444;
|
||||
--color-box-shadow-rgb: 77,77,77;
|
||||
--color-box-shadow: rgba(var(--color-box-shadow-rgb), 0.5);
|
||||
--color-border: #ededed;
|
||||
--color-border-dark: #dbdbdb;
|
||||
--color-border-maxcontrast: #949494;
|
||||
--font-face: system-ui, -apple-system, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--default-font-size: 15px;
|
||||
--animation-quick: 100ms;
|
||||
--animation-slow: 300ms;
|
||||
--border-radius: 3px;
|
||||
--border-radius-large: 10px;
|
||||
--border-radius-rounded: 28px;
|
||||
--border-radius-pill: 100px;
|
||||
--default-clickable-area: 44px;
|
||||
--clickable-area: 44;
|
||||
--default-line-height: 24px;
|
||||
--default-grid-baseline: 4px;
|
||||
--header-height: 50px;
|
||||
--navigation-width: 300px;
|
||||
--sidebar-min-width: 300px;
|
||||
--sidebar-max-width: 500px;
|
||||
--list-min-width: 200px;
|
||||
--list-max-width: 300px;
|
||||
--header-menu-item-height: 44px;
|
||||
--header-menu-profile-item-height: 66px;
|
||||
--breakpoint-mobile: 1024px;
|
||||
--background-invert-if-dark: no;
|
||||
--background-invert-if-bright: invert(100%);
|
||||
--background-image-invert-if-bright: no;
|
||||
--primary-invert-if-bright: no;
|
||||
--primary-invert-if-dark: invert(100%);
|
||||
--color-primary: #00679e;
|
||||
--color-primary-default: #0082c9;
|
||||
--color-primary-text: #ffffff;
|
||||
--color-primary-hover: #3285b1;
|
||||
--color-primary-light: #e5eff5;
|
||||
--color-primary-light-text: #00293f;
|
||||
--color-primary-light-hover: #dbe4ea;
|
||||
/* --color-primary-element: #00679e; */
|
||||
--color-primary-element: #bfce40;
|
||||
/* --color-primary-element-hover: #052E37; */
|
||||
--color-primary-element-hover: rgba(5,46,55,0.7);
|
||||
--color-primary-element-text: #ffffff;
|
||||
--color-primary-element-text-dark: #f5f5f5;
|
||||
--color-primary-element-light: #e5eff5;
|
||||
--color-primary-element-light-hover: #dbe4ea;
|
||||
--color-primary-element-light-text: #00293f;
|
||||
--gradient-primary-background: linear-gradient(40deg, var(--color-primary) 0%, var(--color-primary-hover) 100%);
|
||||
/* --image-background-default: url('./img/background/kamil-porembinski-clouds.jpg'); */
|
||||
--color-background-plain: #0082c9;
|
||||
--radius: 15;
|
||||
--pi: 3.14159265358979;
|
||||
}
|
||||
|
||||
/* @layer base {
|
||||
html,
|
||||
body {
|
||||
|
|
312
resources/js/Components/FirstrunWizard/FirstrunWizard.vue
Normal file
312
resources/js/Components/FirstrunWizard/FirstrunWizard.vue
Normal file
|
@ -0,0 +1,312 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2023 Arno Kaimbacher <arno.kaimbacher@outlook.at>
|
||||
-
|
||||
- @author Arno Kaimbacher <arno.kaimbacher@outlook.at>
|
||||
-
|
||||
- @license GNU AGPL version 3 or any later version
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as
|
||||
- published by the Free Software Foundation, either version 3 of the
|
||||
- License, or (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<NcModal v-if="showModal" id="firstrunwizard" class="first-run-wizard" size="normal" :has-next="hasNext"
|
||||
:has-previous="hasPrevious" @close="close" @next="goToNextPage" @previous="goToPreviousPage">
|
||||
<!-- page 0 for entry -->
|
||||
<Page0 v-if="page === 0" @next="goToNextPage" />
|
||||
|
||||
<div v-else class="first-run-wizard__wrapper">
|
||||
<Transition :name="circleSlideDirection">
|
||||
<div v-if="page === 1" class="first-run-wizard__background-circle" />
|
||||
</Transition>
|
||||
|
||||
<div class="first-run-wizard__background-bar rounded-t-xl" />
|
||||
|
||||
<!-- previous header button -->
|
||||
<NcButton v-if="page > 1" type="tertiary" class="first-run-wizard__back-button"
|
||||
aria-label="'Go to previous page'" @click="goToPreviousPage" :icon="mdiArrowLeft" :size="20"
|
||||
:rounded-full="true">
|
||||
</NcButton>
|
||||
<!-- close header button -->
|
||||
<NcButton :type="page === 1 ? 'primary' : 'tertiary'" class="first-run-wizard__close-button"
|
||||
aria-label="'Close dialog'" @click="close" :icon="mdiClose" :size="20" :rounded-full="true">
|
||||
</NcButton>
|
||||
|
||||
<!-- show logo on first page -->
|
||||
<div v-if="page === 1" class="first-run-wizard__logo"> <!-- :style="logoStyle" -->
|
||||
<JustboilLogo class="w-auto h-16" />
|
||||
</div>
|
||||
<div v-if="page === 1" class="first-run-wizard__logo" />
|
||||
<Transition :name="pageSlideDirection" mode="out-in">
|
||||
<Page1 v-if="page === 1" />
|
||||
<Page2 v-else-if="page === 2" />
|
||||
<Page3 v-else-if="page === 3" />
|
||||
</Transition>
|
||||
|
||||
<!-- last wider button at footer for every page -->
|
||||
<NcButton type="primary" alignment="center-reverse" :wide="true" @click="handleButtonCLick" :label="buttonText"
|
||||
:icon="mdiArrowRight" :size="20">
|
||||
</NcButton>
|
||||
</div>
|
||||
</NcModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// import { NcModal, BaseButton } from '@nextcloud/vue';
|
||||
import NcModal from '@/Components/NcModal.vue';
|
||||
import BaseButton from '@/Components/BaseButton.vue';
|
||||
import NcButton from '@/Components/NcButton.vue';
|
||||
// import { imagePath, generateUrl } from '@nextcloud/router';
|
||||
// import axios from '@nextcloud/axios';
|
||||
import JustboilLogo from '@/Components/JustboilLogo.vue';
|
||||
|
||||
import Page0 from './components/Page0.vue';
|
||||
import Page1 from './components/Page1.vue';
|
||||
import Page2 from './components/Page2.vue';
|
||||
import Page3 from './components/Page3.vue';
|
||||
|
||||
import { mdiClose, mdiArrowRight, mdiArrowLeft } from '@mdi/js';
|
||||
|
||||
export default {
|
||||
name: 'FirstrunWizard',
|
||||
components: {
|
||||
NcModal,
|
||||
Page0,
|
||||
Page1,
|
||||
Page2,
|
||||
BaseButton,
|
||||
NcButton,
|
||||
Page3,
|
||||
JustboilLogo
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
showModal: false,
|
||||
page: 1,
|
||||
// logoURL: '/logo.svg',// imagePath('firstrunwizard', 'nextcloudLogo.svg'),
|
||||
pageSlideDirection: undefined,
|
||||
circleSlideDirection: undefined,
|
||||
mdiClose: mdiClose,
|
||||
mdiArrowRight: mdiArrowRight,
|
||||
mdiArrowLeft,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
// logoStyle() {
|
||||
// // return { backgroundImage: 'url(' + this.logoURL + ')' };
|
||||
// return { backgroundImage: 'url(' + this.logoURL + ')' };
|
||||
// },
|
||||
|
||||
hasPrevious() {
|
||||
if (window.innerWidth <= 512) {
|
||||
return false;
|
||||
} else {
|
||||
return this.page > 1;
|
||||
}
|
||||
},
|
||||
|
||||
hasNext() {
|
||||
if (window.innerWidth <= 512) {
|
||||
return false;
|
||||
} else {
|
||||
return this.page < 3;
|
||||
}
|
||||
},
|
||||
|
||||
buttonText() {
|
||||
if (this.page === 1) {
|
||||
return 'TethysCloud on all your devices';
|
||||
} else if (this.page === 2) {
|
||||
return 'More about TethysCloud';
|
||||
} else if (this.page === 3) {
|
||||
return 'Get started!';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
open() {
|
||||
this.page = 1;
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
close() {
|
||||
this.page = 1;
|
||||
this.showModal = false;
|
||||
// axios.delete(generateUrl('/apps/firstrunwizard/wizard'));
|
||||
},
|
||||
|
||||
goToNextPage() {
|
||||
this.pageSlideDirection = 'slide-left';
|
||||
if (this.page === 1) {
|
||||
this.circleSlideDirection = 'slide-up';
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.page++;
|
||||
});
|
||||
},
|
||||
|
||||
goToPreviousPage() {
|
||||
this.pageSlideDirection = 'slide-right';
|
||||
if (this.page === 2) {
|
||||
this.circleSlideDirection = 'slide-down';
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.page--;
|
||||
});
|
||||
},
|
||||
|
||||
handleButtonCLick() {
|
||||
if (this.page < 3) {
|
||||
this.goToNextPage();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.first-run-wizard {
|
||||
&__wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: calc(var(--default-grid-baseline) * 5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__background-circle {
|
||||
height: 6000px;
|
||||
width: 6000px;
|
||||
border-radius: 3000px;
|
||||
background-color: var(--color-primary-element);
|
||||
position: absolute;
|
||||
top: -5900px;
|
||||
left: calc(-3000px + 50%);
|
||||
}
|
||||
|
||||
&__background-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
background-color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
&__back-button {
|
||||
position: absolute;
|
||||
top: var(--default-grid-baseline);
|
||||
left: var(--default-grid-baseline);
|
||||
}
|
||||
|
||||
&__close-button {
|
||||
position: absolute;
|
||||
top: var(--default-grid-baseline);
|
||||
right: var(--default-grid-baseline);
|
||||
}
|
||||
|
||||
&__logo {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
/* top: 50%; */
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--color-primary-element-text);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-wrapper .modal-container {
|
||||
overflow: hidden;
|
||||
/* max-width: 90%;
|
||||
width: 600px;
|
||||
max-height: min(90%, 100% - 100px); */
|
||||
}
|
||||
|
||||
.modal-wrapper .modal-container__content {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 512px) {
|
||||
:deep(.modal-wrapper .modal-container) {
|
||||
height: 100dvh;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
:deep(.modal-header) {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* hide close button predefined by NCModal.vue */
|
||||
:deep(.modal-container__close) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active,
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active,
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active,
|
||||
.slide-down-enter-active,
|
||||
.slide-down-leave-active {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.slide-left-enter {
|
||||
opacity: 0;
|
||||
transform: translateX(30%);
|
||||
}
|
||||
|
||||
.slide-left-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-30%);
|
||||
}
|
||||
|
||||
.slide-right-enter {
|
||||
opacity: 0;
|
||||
transform: translateX(-30%);
|
||||
}
|
||||
|
||||
.slide-right-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30%);
|
||||
}
|
||||
|
||||
.slide-up-enter {
|
||||
top: calc(-5900px);
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
top: calc(-5900px - 80px);
|
||||
}
|
||||
|
||||
.slide-down-enter {
|
||||
top: calc(-5900px - 80px);
|
||||
}
|
||||
|
||||
.slide-down-leave-to {
|
||||
top: calc(-5900px);
|
||||
}
|
||||
</style>
|
|
@ -1,8 +1,7 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2023 Marco Ambrosini <marcoambrosini@proton.me>
|
||||
- @copyright Copyright (c) 2023 Arno Kaimbacher <arno.kaimbacher@outlook.at>
|
||||
-
|
||||
- @author Simon Lindner <szaimen@e.mail.de>
|
||||
- @author Marco Ambrosini <marcoambrosini@proton.me>
|
||||
- @author Arno Kaimbacher <arno.kaimbacher@outlook.at>
|
||||
-
|
||||
- @license GNU AGPL version 3 or any later version
|
||||
-
|
||||
|
@ -21,18 +20,18 @@
|
|||
-
|
||||
-->
|
||||
<template>
|
||||
<element :is="isLink ? 'a' : 'div'" :href="href || undefined" class="card" :class="{ 'card--link': isLink }"
|
||||
<component :is="isLink ? 'a' : 'div'" :href="href || undefined" class="card" :class="{ 'card--link': isLink }"
|
||||
:target="!isLink ? undefined : '_blank'" :rel="!isLink ? undefined : 'noreferrer'">
|
||||
<div v-if="!isLink" class="card__icon">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="card__text">
|
||||
<h3 class="card__heading">
|
||||
<h3 class="text-base text-ellipsis card__heading">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p>{{ subtitle }}</p>
|
||||
</div>
|
||||
</element>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
|
85
resources/js/Components/FirstrunWizard/components/Page0.vue
Normal file
85
resources/js/Components/FirstrunWizard/components/Page0.vue
Normal file
|
@ -0,0 +1,85 @@
|
|||
<template>
|
||||
<!--<div class="video-wrapper">
|
||||
<video ref="video" playsinline autoplay muted @ended="handleEnded">
|
||||
<source :src="videoWebm" type="video/webm" />
|
||||
<source :src="videoMp4" type="video/mp4" />
|
||||
{{ videoFallbackText }}
|
||||
</video>
|
||||
</div>-->
|
||||
<div id="page__wrapper" class="flex flex-col justify-between min-h-520">
|
||||
<div class="page__scroller">
|
||||
<h2 id="page__heading" class="text-xl font-bold leading-tight dark:text-slate-400 text-center">
|
||||
{{ 'Seamless integration with your devices.' }}
|
||||
</h2>
|
||||
<!-- <p id="page__subtitle" class="max-w-md m-auto text-center">
|
||||
{{ subtitleText }}
|
||||
</p> -->
|
||||
<div class="page__content">
|
||||
Discover the power of Tethys, the cutting-edge web backend solution that revolutionizes the way you handle
|
||||
research
|
||||
data. At the heart of Tethys lies our meticulously developed research data repository, which leverages
|
||||
state-of-the-art
|
||||
CI/CD techniques to deliver a seamless and efficient experience.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// import { generateFilePath } from '@nextcloud/router';
|
||||
import CardBox from '@/Components/CardBox.vue';
|
||||
|
||||
export default {
|
||||
name: 'Page0',
|
||||
components: {
|
||||
CardBox
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// videoMp4: generateFilePath('firstrunwizard', 'img', 'Nextcloud.mp4'),
|
||||
// videoWebm: generateFilePath('firstrunwizard', 'img', 'Nextcloud.webm'),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// videoFallbackText() {
|
||||
// return 'Welcome to {cloudName}!', { cloudName: "test" };
|
||||
// },
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleEnded() {
|
||||
this.$emit('next');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="css">
|
||||
/* video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
} */
|
||||
|
||||
.video-wrapper {
|
||||
background-color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
.page {
|
||||
|
||||
&__scroller {
|
||||
/* overflow-y: scroll; */
|
||||
margin-top: calc(var(--default-grid-baseline) * 8);
|
||||
}
|
||||
|
||||
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: calc(var(--default-grid-baseline) * 6);
|
||||
justify-content: center;
|
||||
margin: calc(var(--default-grid-baseline) * 10) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,24 +1,38 @@
|
|||
<template>
|
||||
<div class="page__wrapper">
|
||||
<div id="page__wrapper" class="flex flex-col justify-between min-h-520">
|
||||
<div class="page__scroller first-page">
|
||||
<h2 class="page__heading">
|
||||
{{ t('firstrunwizard', 'A collaboration platform that puts you in control') }}
|
||||
<h2 id="page__heading" class="text-xl font-bold leading-tight dark:text-slate-400 text-center">
|
||||
{{ 'A researchdata platform that puts you in control' }}
|
||||
</h2>
|
||||
<div class="page__content">
|
||||
<Card :title="t('firstrunwizard', 'Privacy')"
|
||||
:subtitle="t('firstrunwizard', 'Host your data and files where you decide.')">
|
||||
<!-- <Card :title="'Privacy'"
|
||||
:subtitle="'Host your data and files where you decide.'">
|
||||
<Lock :size="20" />
|
||||
</Card>
|
||||
<Card :title="t('firstrunwizard', 'Productivity')"
|
||||
:subtitle="t('firstrunwizard', 'Collaborate and communicate across any platform.')">
|
||||
<Card :title="'Productivity'"
|
||||
:subtitle="'Collaborate and communicate across any platform.'">
|
||||
<BriefcaseCheck :size="20" />
|
||||
</Card>
|
||||
<Card :title="t('firstrunwizard', 'Interoperability')"
|
||||
:subtitle="t('firstrunwizard', 'Import and export anything you want with open standards.')">
|
||||
</Card> -->
|
||||
<div class="max-w-60 h-fit box-border flex">
|
||||
|
||||
<!-- <div class="card__icon">
|
||||
<BriefcaseCheck :size="20" />
|
||||
</div> -->
|
||||
|
||||
<div>
|
||||
Discover the power of TethysCloud, the cutting-edge web backend solution that revolutionizes the way you
|
||||
handle
|
||||
research
|
||||
data. At the heart of Tethys lies our meticulously developed research data repository, which
|
||||
leverages
|
||||
state-of-the-art
|
||||
CI/CD techniques to deliver a seamless and efficient experience.
|
||||
</div>
|
||||
</div>
|
||||
<Card :title="'Interoperability'" :subtitle="'Import and export anything you want with open standards.'">
|
||||
<SwapHorizontal :size="20" />
|
||||
</Card>
|
||||
<Card :title="t('firstrunwizard', 'Community')"
|
||||
:subtitle="t('firstrunwizard', 'Enjoy constant improvements from a thriving open-source community.')">
|
||||
<Card :title="'Community'" :subtitle="'Enjoy constant improvements from a thriving open-source community.'">
|
||||
<AccountGroup :size="20" />
|
||||
</Card>
|
||||
</div>
|
||||
|
@ -26,21 +40,18 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import Card from './Card.vue'
|
||||
|
||||
import Lock from 'vue-material-design-icons/Lock.vue'
|
||||
import BriefcaseCheck from 'vue-material-design-icons/BriefcaseCheck.vue'
|
||||
import SwapHorizontal from 'vue-material-design-icons/SwapHorizontal.vue'
|
||||
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
|
||||
import SwapHorizontal from '@/Components/Icons/SwapHorizontal.vue';
|
||||
import AccountGroup from '@/Components/Icons/AccountGroup.vue'
|
||||
|
||||
|
||||
export default {
|
||||
name: 'Page1',
|
||||
|
||||
components: {
|
||||
Card,
|
||||
Lock,
|
||||
BriefcaseCheck,
|
||||
SwapHorizontal,
|
||||
AccountGroup,
|
||||
},
|
||||
|
@ -48,7 +59,25 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
/* @import "pageStyles"; */
|
||||
.page {
|
||||
&__scroller {
|
||||
/* overflow-y: scroll; */
|
||||
margin-top: calc(var(--default-grid-baseline) * 8);
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: calc(var(--default-grid-baseline) * 6);
|
||||
justify-content: center;
|
||||
margin: calc(var(--default-grid-baseline) * 10) 0;
|
||||
}
|
||||
}
|
||||
.card__icon {
|
||||
display: flex;
|
||||
flex: 0 0 44px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.first-page {
|
||||
margin-top: 100px;
|
||||
|
|
88
resources/js/Components/FirstrunWizard/components/Page2.vue
Normal file
88
resources/js/Components/FirstrunWizard/components/Page2.vue
Normal file
|
@ -0,0 +1,88 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2023 Arno Kaimbacher <arno.kaimbacher@outlook.at>
|
||||
-
|
||||
- @author Marco Ambrosini <arno.kaimbacher@outlook.at>
|
||||
-
|
||||
- @license GNU AGPL version 3 or any later version
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as
|
||||
- published by the Free Software Foundation, either version 3 of the
|
||||
- License, or (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div id="page__wrapper" class="flex flex-col justify-between min-h-520">
|
||||
<div class="page__scroller">
|
||||
<h2 id="page__heading" class="text-xl font-bold leading-tight dark:text-slate-400 text-center">
|
||||
{{ 'Seamless integration with your devices.' }}
|
||||
</h2>
|
||||
<p id="page__subtitle" class="max-w-md m-auto text-center">
|
||||
{{ subtitleText }}
|
||||
</p>
|
||||
<div class="page__content">
|
||||
<!-- <AppStoreBadge type="android" />
|
||||
<AppStoreBadge type="ios" /> -->
|
||||
<Card :href="desktop" :title="'Desktop app ↗'" :subtitle="'Download For Windows, Mac OS and Linux.'" />
|
||||
<!-- <Card :href="syncClientsUrl" :title="'Calendar and contacts ↗'"
|
||||
:subtitle="'Connect your calendar and contacts with your devices.'" /> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Card from './Card.vue';
|
||||
// import AppStoreBadge from './AppStoreBadge.vue';
|
||||
// import { generateUrl } from '@nextcloud/router';
|
||||
import { loadState } from '@/utils/initialState';
|
||||
|
||||
const desktop = loadState('firstrunwizard', 'desktop');
|
||||
|
||||
export default {
|
||||
name: 'Page2',
|
||||
|
||||
components: {
|
||||
Card,
|
||||
// AppStoreBadge,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
subtitleText: 'Sync your files across your devices with the desktop and mobile apps, and connect your calendar and contacts.',
|
||||
// syncClientsUrl: generateUrl('settings/user/sync-clients'),
|
||||
desktop,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
/* @import 'pageStyles'; */
|
||||
.page {
|
||||
|
||||
&__scroller {
|
||||
/* overflow-y: scroll; */
|
||||
margin-top: calc(var(--default-grid-baseline) * 8);
|
||||
}
|
||||
|
||||
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: calc(var(--default-grid-baseline) * 6);
|
||||
justify-content: center;
|
||||
margin: calc(var(--default-grid-baseline) * 10) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
68
resources/js/Components/FirstrunWizard/components/Page3.vue
Normal file
68
resources/js/Components/FirstrunWizard/components/Page3.vue
Normal file
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<div id="page__wrapper" class="flex flex-col justify-between min-h-520">
|
||||
<div class="page__scroller">
|
||||
<h2 id="page__heading" class="text-xl font-bold leading-tight dark:text-slate-400 text-center">
|
||||
{{ 'More about TethysCloud' }}
|
||||
</h2>
|
||||
<div id="page__content" class="flex flex-wrap justify-center page__content">
|
||||
<Card href="https://gitea.geologie.ac.at/geolba" :title="'Explore more apps ↗'"
|
||||
:subtitle="'Extend the functionality of TethysCloud.'" />
|
||||
<Card href="https://gitea.geologie.ac.at/geolba" :title="'Get involved! ↗'"
|
||||
:subtitle="'Be a part of the community that helps build, design, translate and promote TethysCloud!'" />
|
||||
<Card href="https://gitea.geologie.ac.at/geolba" :title="'Need help? ↗'"
|
||||
:subtitle="'Find out more about your TethysCloud setup with the admin, user or developer documentation.'" />
|
||||
<Card href="https://gitea.geologie.ac.at/geolba" :title="'For large organisations ↗'"
|
||||
:subtitle="'Get TethysCloud Enterprise for mission critical environments where advanced security and compliance are important.'" />
|
||||
</div>
|
||||
<p class="version-number">
|
||||
{{ versionNumber }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Card from './Card.vue'
|
||||
|
||||
export default {
|
||||
name: 'Page3',
|
||||
|
||||
components: {
|
||||
Card,
|
||||
},
|
||||
|
||||
computed: {
|
||||
versionNumber() {
|
||||
return 'This TethysCloud is on version 2.0.0'; // + OC.config.versionstring
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.page {
|
||||
|
||||
&__scroller {
|
||||
/* overflow-y: scroll; */
|
||||
margin-top: calc(var(--default-grid-baseline) * 8);
|
||||
}
|
||||
|
||||
/* &__content {
|
||||
|
||||
gap: calc(var(--default-grid-baseline) * 6);
|
||||
margin: calc(var(--default-grid-baseline) * 10) 0;
|
||||
} */
|
||||
}
|
||||
|
||||
|
||||
.page__content {
|
||||
gap: calc(var(--default-grid-baseline) * 6);
|
||||
margin: calc(var(--default-grid-baseline) * 6) 0 calc(var(--default-grid-baseline) * 4) 0;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
margin: 0px 0 calc(var(--default-grid-baseline) * 4) 0;
|
||||
color: var(--color-text-maxcontrast);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
31
resources/js/Components/Icons/DotsHorizontal.vue
Normal file
31
resources/js/Components/Icons/DotsHorizontal.vue
Normal file
|
@ -0,0 +1,31 @@
|
|||
<template>
|
||||
<span v-bind="$attrs" :aria-hidden="!title" :aria-label="title" class="material-design-icon dots-horizontal-icon"
|
||||
role="img" @click="$emit('click', $event)">
|
||||
<svg :fill="fillColor" class="material-design-icon__svg" :width="size" :height="size" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z">
|
||||
<title v-if="title">{{ title }}</title>
|
||||
</path>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "DotsHorizontalIcon",
|
||||
emits: ['click'],
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
},
|
||||
fillColor: {
|
||||
type: String,
|
||||
default: "currentColor"
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 24
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
30
resources/js/Components/Icons/Pause.vue
Normal file
30
resources/js/Components/Icons/Pause.vue
Normal file
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<span v-bind="$attrs" :aria-hidden="!title" :aria-label="title" class="material-design-icon pause-icon" role="img"
|
||||
@click="$emit('click', $event)">
|
||||
<svg :fill="fillColor" class="material-design-icon__svg" :width="size" :height="size" viewBox="0 0 24 24">
|
||||
<path d="M14,19H18V5H14M6,19H10V5H6V19Z">
|
||||
<title v-if="title">{{ title }}</title>
|
||||
</path>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "PauseIcon",
|
||||
emits: ['click'],
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
},
|
||||
fillColor: {
|
||||
type: String,
|
||||
default: "currentColor"
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 24
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
30
resources/js/Components/Icons/Play.vue
Normal file
30
resources/js/Components/Icons/Play.vue
Normal file
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<span v-bind="$attrs" :aria-hidden="!title" :aria-label="title" class="material-design-icon play-icon" role="img"
|
||||
@click="$emit('click', $event)">
|
||||
<svg :fill="fillColor" class="material-design-icon__svg" :width="size" :height="size" viewBox="0 0 24 24">
|
||||
<path d="M8,5.14V19.14L19,12.14L8,5.14Z">
|
||||
<title v-if="title">{{ title }}</title>
|
||||
</path>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "PlayIcon",
|
||||
emits: ['click'],
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
},
|
||||
fillColor: {
|
||||
type: String,
|
||||
default: "currentColor"
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 24
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -27,7 +27,8 @@ import {
|
|||
mdiGithub,
|
||||
mdiThemeLightDark,
|
||||
mdiViewDashboard,
|
||||
mdiMapSearch
|
||||
mdiMapSearch,
|
||||
mdiInformationVariant,
|
||||
} from '@mdi/js';
|
||||
import NavBarItem from '@/Components/NavBarItem.vue';
|
||||
import NavBarItemLabel from '@/Components/NavBarItemLabel.vue';
|
||||
|
@ -38,8 +39,8 @@ import BaseIcon from '@/Components/BaseIcon.vue';
|
|||
// import NavBarSearch from '@/Components/NavBarSearch.vue';
|
||||
import { stardust } from '@eidellev/adonis-stardust/client';
|
||||
import type { User } from '@/Dataset';
|
||||
// import FirstrunWizard from '@/Components/FirstrunWizard/FirstrunWizard.vue'
|
||||
import Lock from 'vue-material-design-icons/Lock.vue'
|
||||
import FirstrunWizard from '@/Components/FirstrunWizard/FirstrunWizard.vue'
|
||||
// import Lock from 'vue-material-design-icons/Lock.vue'
|
||||
// import BriefcaseCheck from 'vue-material-design-icons/BriefcaseCheck.vue'
|
||||
// import SwapHorizontal from 'vue-material-design-icons/SwapHorizontal.vue'
|
||||
// import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
|
||||
|
@ -89,11 +90,20 @@ const logout = async () => {
|
|||
await router.post(stardust.route('logout'));
|
||||
};
|
||||
|
||||
const about = ref();
|
||||
const showAbout = async () => {
|
||||
// router.post(route('logout'));
|
||||
about.value.open();
|
||||
};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<nav class="text-base top-0 left-0 right-0 fixed bg-gray-50 h-14 z-40 w-screen transition-position lg:w-auto dark:bg-slate-800"
|
||||
:class="{ 'xl:pl-60': props.showBurger == true }">
|
||||
<FirstrunWizard ref="about"></FirstrunWizard>
|
||||
<div class="flex lg:items-stretch" :class="containerMaxW">
|
||||
<div class="flex-1 items-stretch flex h-14">
|
||||
<NavBarItem type="flex lg:hidden" @click.prevent="layoutStore.asideMobileToggle()" v-if="props.showBurger">
|
||||
|
@ -165,6 +175,9 @@ const logout = async () => {
|
|||
<!-- <NavBarItem>
|
||||
<NavBarItemLabel :icon="mdiEmail" label="Messages" />
|
||||
</NavBarItem> -->
|
||||
<NavBarItem @click="showAbout">
|
||||
<NavBarItemLabel :icon="mdiInformationVariant" label="About" />
|
||||
</NavBarItem>
|
||||
<BaseDivider nav-bar />
|
||||
<NavBarItem @click="logout">
|
||||
<NavBarItemLabel :icon="mdiLogout" label="Log Out" />
|
||||
|
@ -178,6 +191,9 @@ const logout = async () => {
|
|||
<NavBarItem href="https://gitea.geologie.ac.at/geolba/tethys" target="_blank" is-desktop-icon-only>
|
||||
<NavBarItemLabel v-bind:icon="mdiGithub" label="GitHub" is-desktop-icon-only />
|
||||
</NavBarItem>
|
||||
<NavBarItem is-desktop-icon-only @click="showAbout">
|
||||
<NavBarItemLabel v-bind:icon="mdiInformationVariant" label="About" is-desktop-icon-only />
|
||||
</NavBarItem>
|
||||
<NavBarItem is-desktop-icon-only @click="logout">
|
||||
<NavBarItemLabel v-bind:icon="mdiLogout" label="Log out" is-desktop-icon-only />
|
||||
</NavBarItem>
|
||||
|
|
774
resources/js/Components/NcActions.vue
Normal file
774
resources/js/Components/NcActions.vue
Normal file
|
@ -0,0 +1,774 @@
|
|||
<script lang="ts">
|
||||
// import NcButton from '../NcButton/index.js'
|
||||
import BaseButton from '@/Components/BaseButton.vue';
|
||||
// import NcPopover from '../NcPopover/index.js'
|
||||
import GenRandomId from '../utils/GenRandomId';
|
||||
// import { t } from '../../l10n.js'
|
||||
|
||||
import { computed } from 'vue';
|
||||
import DotsHorizontal from '@/Components/Icons/DotsHorizontal.vue';
|
||||
|
||||
const focusableSelector = '.focusable';
|
||||
|
||||
/**
|
||||
* The Actions component can be used to display one ore more actions.
|
||||
* If only a single action is provided, it will be rendered as an inline icon.
|
||||
* For more, a menu indicator will be shown and a popovermenu containing the
|
||||
* actions will be opened on click.
|
||||
*
|
||||
* @since 0.10.0
|
||||
*/
|
||||
export default {
|
||||
name: 'NcActions',
|
||||
|
||||
components: {
|
||||
// NcButton,
|
||||
BaseButton,
|
||||
DotsHorizontal,
|
||||
// NcPopover,
|
||||
},
|
||||
|
||||
provide() {
|
||||
return {
|
||||
/**
|
||||
* NcActions can be used as:
|
||||
* - Application menu (has menu role)
|
||||
* - Navigation (has no specific role, should be used an element with navigation role)
|
||||
* - Popover with plain text or text inputs (has no specific role)
|
||||
* Depending on the usage (used items), the menu and its items should have different roles for a11y.
|
||||
* Provide the role for NcAction* components in the NcActions content.
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
'NcActions:isSemanticMenu': computed(() => this.isSemanticMenu),
|
||||
};
|
||||
},
|
||||
|
||||
props: {
|
||||
/**
|
||||
* Specify the open state of the popover menu
|
||||
*/
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* This disables the internal open management,
|
||||
* so the actions menu only respects the `open` prop.
|
||||
* This is e.g. necessary for the NcAvatar component
|
||||
* to only open the actions menu after loading it's entries has finished.
|
||||
*/
|
||||
manualOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* Force the actions to display in a three dot menu
|
||||
*/
|
||||
forceMenu: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* Force the name to show for single actions
|
||||
*/
|
||||
forceName: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* Specify the menu name
|
||||
*/
|
||||
menuName: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply primary styling for this menu
|
||||
*/
|
||||
primary: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* Specifies the button type used for trigger and single actions buttons
|
||||
* Accepted values: primary, secondary, tertiary, tertiary-no-background, tertiary-on-primary, error, warning, success. If left empty,
|
||||
* the default button style will be applied.
|
||||
*/
|
||||
type: {
|
||||
type: String,
|
||||
validator(value: string) {
|
||||
return (
|
||||
[
|
||||
'primary',
|
||||
'secondary',
|
||||
'tertiary',
|
||||
'tertiary-no-background',
|
||||
'tertiary-on-primary',
|
||||
'error',
|
||||
'warning',
|
||||
'success',
|
||||
].indexOf(value) !== -1
|
||||
);
|
||||
},
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
* Icon to show for the toggle menu button
|
||||
* when more than one action is inside the actions component.
|
||||
* Only replace the default three-dot icon if really necessary.
|
||||
*/
|
||||
defaultIcon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
* Aria label for the actions menu.
|
||||
*
|
||||
* If `menuName` is defined this will not be used to prevent
|
||||
* any accessible name conflicts. This ensures that the
|
||||
* element can be activated via voice input.
|
||||
*/
|
||||
ariaLabel: {
|
||||
type: String,
|
||||
default: 'Actions',
|
||||
},
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
|
||||
* @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
|
||||
* otherwise root element will inherit incorrect aria-hidden.
|
||||
*/
|
||||
ariaHidden: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
* Wanted direction of the menu
|
||||
*/
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'bottom',
|
||||
},
|
||||
|
||||
/**
|
||||
* DOM element for the actions' popover boundaries
|
||||
*/
|
||||
boundariesElement: {
|
||||
type: Element,
|
||||
default: () => document.querySelector('body'),
|
||||
},
|
||||
|
||||
/**
|
||||
* Selector for the actions' popover container
|
||||
*/
|
||||
container: {
|
||||
type: [String, Object, Element, Boolean],
|
||||
default: 'body',
|
||||
},
|
||||
|
||||
/**
|
||||
* Disabled state of the main button (single action or menu toggle)
|
||||
*/
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* Display x items inline out of the dropdown menu
|
||||
* Will be ignored if `forceMenu` is set
|
||||
*/
|
||||
inline: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['open', 'update:open', 'close', 'focus', 'blur'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
opened: this.open,
|
||||
focusIndex: 0,
|
||||
randomId: `menu-${GenRandomId()}`,
|
||||
isSemanticMenu: false,
|
||||
isSemanticNavigation: false,
|
||||
isSemanticPopoverLike: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
triggerBtnType() {
|
||||
// If requested, we use a primary button
|
||||
return (
|
||||
this.type ||
|
||||
(this.primary
|
||||
? 'primary'
|
||||
: // If it has a name, we use a secondary button
|
||||
this.menuName
|
||||
? 'secondary'
|
||||
: 'tertiary')
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
// Watch parent prop
|
||||
open(state) {
|
||||
if (state === this.opened) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.opened = state;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Do we have exactly one Action and
|
||||
* is it allowed as a standalone element?
|
||||
*
|
||||
* @param {Array} action The action to check
|
||||
* @return {boolean}
|
||||
*/
|
||||
isValidSingleAction(action) {
|
||||
const componentName = action?.componentOptions?.Ctor?.extendOptions?.name ?? action?.componentOptions?.tag;
|
||||
return ['NcActionButton', 'NcActionLink', 'NcActionRouter'].includes(componentName);
|
||||
},
|
||||
|
||||
// MENU STATE MANAGEMENT
|
||||
openMenu() {
|
||||
if (this.opened) {
|
||||
return;
|
||||
}
|
||||
this.opened = true;
|
||||
|
||||
/**
|
||||
* Event emitted when the popover menu open state is changed
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.$emit('update:open', true);
|
||||
|
||||
/**
|
||||
* Event emitted when the popover menu is opened
|
||||
*/
|
||||
this.$emit('open');
|
||||
},
|
||||
closeMenu(returnFocus = true) {
|
||||
if (!this.opened) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.opened = false;
|
||||
|
||||
this.$refs.popover.clearFocusTrap({ returnFocus });
|
||||
|
||||
/**
|
||||
* Event emitted when the popover menu open state is changed
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.$emit('update:open', false);
|
||||
|
||||
/**
|
||||
* Event emitted when the popover menu is closed
|
||||
*/
|
||||
this.$emit('close');
|
||||
|
||||
// close everything
|
||||
this.focusIndex = 0;
|
||||
|
||||
// focus back the menu button
|
||||
this.$refs.menuButton.$el.focus();
|
||||
},
|
||||
|
||||
onOpen(event) {
|
||||
this.$nextTick(() => {
|
||||
this.focusFirstAction(event);
|
||||
});
|
||||
},
|
||||
|
||||
// MENU KEYS & FOCUS MANAGEMENT
|
||||
// focus nearest focusable item on mouse move
|
||||
// DO NOT change the focus if the target is already focused
|
||||
// this will prevent issues with input being unfocused
|
||||
// on mouse move
|
||||
onMouseFocusAction(event) {
|
||||
if (document.activeElement === event.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuItem = event.target.closest('li');
|
||||
if (menuItem && this.$refs.menu.contains(menuItem)) {
|
||||
const focusableItem = menuItem.querySelector(focusableSelector);
|
||||
if (focusableItem) {
|
||||
const focusList = this.$refs.menu.querySelectorAll(focusableSelector);
|
||||
const focusIndex = [...focusList].indexOf(focusableItem);
|
||||
if (focusIndex > -1) {
|
||||
this.focusIndex = focusIndex;
|
||||
this.focusAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Dispatches the keydown listener to different handlers
|
||||
*
|
||||
* @param {object} event The keydown event
|
||||
*/
|
||||
onKeydown(event) {
|
||||
if (event.key === 'Tab' && !this.isSemanticPopoverLike) {
|
||||
this.closeMenu(false);
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
this.focusPreviousAction(event);
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
this.focusNextAction(event);
|
||||
}
|
||||
|
||||
if (event.key === 'PageUp') {
|
||||
this.focusFirstAction(event);
|
||||
}
|
||||
|
||||
if (event.key === 'PageDown') {
|
||||
this.focusLastAction(event);
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
this.closeMenu();
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
removeCurrentActive() {
|
||||
const currentActiveElement = this.$refs.menu.querySelector('li.active');
|
||||
if (currentActiveElement) {
|
||||
currentActiveElement.classList.remove('active');
|
||||
}
|
||||
},
|
||||
focusAction() {
|
||||
// TODO: have a global disabled state for non input elements
|
||||
const focusElement = this.$refs.menu.querySelectorAll(focusableSelector)[this.focusIndex];
|
||||
if (focusElement) {
|
||||
this.removeCurrentActive();
|
||||
const liMenuParent = focusElement.closest('li.action');
|
||||
focusElement.focus();
|
||||
if (liMenuParent) {
|
||||
liMenuParent.classList.add('active');
|
||||
}
|
||||
}
|
||||
},
|
||||
focusPreviousAction(event) {
|
||||
if (this.opened) {
|
||||
if (this.focusIndex === 0) {
|
||||
this.focusLastAction(event);
|
||||
} else {
|
||||
this.preventIfEvent(event);
|
||||
this.focusIndex = this.focusIndex - 1;
|
||||
}
|
||||
this.focusAction();
|
||||
}
|
||||
},
|
||||
focusNextAction(event) {
|
||||
if (this.opened) {
|
||||
const indexLength = this.$refs.menu.querySelectorAll(focusableSelector).length - 1;
|
||||
if (this.focusIndex === indexLength) {
|
||||
this.focusFirstAction(event);
|
||||
} else {
|
||||
this.preventIfEvent(event);
|
||||
this.focusIndex = this.focusIndex + 1;
|
||||
}
|
||||
this.focusAction();
|
||||
}
|
||||
},
|
||||
focusFirstAction(event) {
|
||||
if (this.opened) {
|
||||
this.preventIfEvent(event);
|
||||
// In case a button is considered aria-selected we will use this one as a initial focus
|
||||
const firstSelectedIndex = [...this.$refs.menu.querySelectorAll(focusableSelector)].findIndex((button) => {
|
||||
return button.parentElement.getAttribute('aria-selected');
|
||||
});
|
||||
this.focusIndex = firstSelectedIndex > -1 ? firstSelectedIndex : 0;
|
||||
this.focusAction();
|
||||
}
|
||||
},
|
||||
focusLastAction(event) {
|
||||
if (this.opened) {
|
||||
this.preventIfEvent(event);
|
||||
this.focusIndex = this.$refs.menu.querySelectorAll(focusableSelector).length - 1;
|
||||
this.focusAction();
|
||||
}
|
||||
},
|
||||
preventIfEvent(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
onFocus(event) {
|
||||
this.$emit('focus', event);
|
||||
},
|
||||
onBlur(event) {
|
||||
this.$emit('blur', event);
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* The render function to display the component
|
||||
*
|
||||
* @param {Function} h The function to create VNodes
|
||||
* @return {object|undefined} The created VNode
|
||||
*/
|
||||
render(h) {
|
||||
/**
|
||||
* Filter the Actions, so that we only get allowed components.
|
||||
* This also ensure that we don't get 'text' elements, which would
|
||||
* become problematic later on.
|
||||
*/
|
||||
const actions = ([this.$slots.default] || []).filter(
|
||||
(action) => action?.componentOptions?.tag || action?.componentOptions?.Ctor?.extendOptions?.name,
|
||||
);
|
||||
|
||||
const getActionName = (action) => action?.componentOptions?.Ctor?.extendOptions?.name ?? action?.componentOptions?.tag;
|
||||
|
||||
const menuItemsActions = ['NcActionButton', 'NcActionButtonGroup', 'NcActionCheckbox', 'NcActionRadio'];
|
||||
const textInputActions = ['NcActionInput', 'NcActionTextEditable'];
|
||||
const linkActions = ['NcActionLink', 'NcActionRouter'];
|
||||
|
||||
const hasTextInputAction = actions.some((action) => textInputActions.includes(getActionName(action)));
|
||||
const hasMenuItemAction = actions.some((action) => menuItemsActions.includes(getActionName(action)));
|
||||
const hasLinkAction = actions.some((action) => linkActions.includes(getActionName(action)));
|
||||
|
||||
// We consider the NcActions to have role="menu" if it consists some button-like action and not text inputs
|
||||
this.isSemanticMenu = hasMenuItemAction && !hasTextInputAction;
|
||||
// We consider the NcActions to be navigation if it consists some link-like action
|
||||
this.isSemanticNavigation = hasLinkAction && !hasMenuItemAction && !hasTextInputAction;
|
||||
// If it is no a manu and not a navigation, it is a popover with items: a form or just a text
|
||||
this.isSemanticPopoverLike = !this.isSemanticMenu && !this.isSemanticNavigation;
|
||||
|
||||
/**
|
||||
* Filter and list actions that are allowed to be displayed inline
|
||||
*/
|
||||
let inlineActions = actions.filter(this.isValidSingleAction);
|
||||
if (this.forceMenu && inlineActions.length > 0 && this.inline > 0) {
|
||||
// Vue.util.warn('Specifying forceMenu will ignore any inline actions rendering.');
|
||||
inlineActions = [];
|
||||
}
|
||||
|
||||
// Check that we have at least one action
|
||||
if (actions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the provided action
|
||||
*
|
||||
* @param {import('vue').VNode} action the action to render
|
||||
* @return {Function} the vue render function
|
||||
*/
|
||||
const renderInlineAction = (action) => {
|
||||
const icon =
|
||||
action?.data?.scopedSlots?.icon()?.[0] || h('span', { class: ['icon', action?.componentOptions?.propsData?.icon] });
|
||||
const attrs = action?.data?.attrs || {};
|
||||
const clickListener = action?.componentOptions?.listeners?.click;
|
||||
|
||||
const text = action?.componentOptions?.children?.[0]?.text?.trim?.();
|
||||
const ariaLabel = action?.componentOptions?.propsData?.ariaLabel || text;
|
||||
const buttonText = this.forceName ? text : '';
|
||||
|
||||
let title = action?.componentOptions?.propsData?.title;
|
||||
// Show a default title for single actions if none is present
|
||||
if (!(this.forceName || title)) {
|
||||
title = text;
|
||||
}
|
||||
|
||||
return h(
|
||||
'BaseButton',
|
||||
{
|
||||
class: ['action-item action-item--single', action?.data?.staticClass, action?.data?.class],
|
||||
attrs: {
|
||||
...attrs,
|
||||
'aria-label': ariaLabel,
|
||||
title,
|
||||
},
|
||||
ref: action?.data?.ref,
|
||||
props: {
|
||||
// If it has a menuName, we use a secondary button
|
||||
type: this.type || (buttonText ? 'secondary' : 'tertiary'),
|
||||
disabled: this.disabled || action?.componentOptions?.propsData?.disabled,
|
||||
...action?.componentOptions?.propsData,
|
||||
},
|
||||
on: {
|
||||
focus: this.onFocus,
|
||||
blur: this.onBlur,
|
||||
// If we have a click listener,
|
||||
// we bind it to execute on click and forward the click event
|
||||
...(!!clickListener && {
|
||||
click: (event) => {
|
||||
if (clickListener) {
|
||||
clickListener(event);
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
[h('template', { slot: 'icon' }, [icon]), buttonText],
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the actions popover
|
||||
*
|
||||
* @param {Array} actions the actions to render within
|
||||
* @return {Function} the vue render function
|
||||
*/
|
||||
const renderActionsPopover = (actions) => {
|
||||
const triggerIcon =
|
||||
this.$slots.icon?.[0] ||
|
||||
(this.defaultIcon
|
||||
? h('span', { class: ['icon', this.defaultIcon] })
|
||||
: h('DotsHorizontal', {
|
||||
props: {
|
||||
size: 20,
|
||||
},
|
||||
}));
|
||||
return h(
|
||||
'NcPopover',
|
||||
{
|
||||
ref: 'popover',
|
||||
props: {
|
||||
delay: 0,
|
||||
handleResize: true,
|
||||
shown: this.opened,
|
||||
placement: this.placement,
|
||||
boundary: this.boundariesElement,
|
||||
container: this.container,
|
||||
popoverBaseClass: 'action-item__popper',
|
||||
// Menu and navigation should not have focus trap
|
||||
// Tab should close the menu and move focus to the next UI element
|
||||
setReturnFocus: !this.isSemanticPopoverLike ? null : this.$refs.menuButton?.$el,
|
||||
focusTrap: this.isSemanticPopoverLike,
|
||||
},
|
||||
// For some reason the popover component
|
||||
// does not react to props given under the 'props' key,
|
||||
// so we use both 'attrs' and 'props'
|
||||
attrs: {
|
||||
delay: 0,
|
||||
handleResize: true,
|
||||
shown: this.opened,
|
||||
placement: this.placement,
|
||||
boundary: this.boundariesElement,
|
||||
container: this.container,
|
||||
...(this.manualOpen && { triggers: [] }),
|
||||
},
|
||||
on: {
|
||||
'show': this.openMenu,
|
||||
'after-show': this.onOpen,
|
||||
'hide': this.closeMenu,
|
||||
},
|
||||
},
|
||||
[
|
||||
h(
|
||||
'BaseButton',
|
||||
{
|
||||
class: 'action-item__menutoggle',
|
||||
props: {
|
||||
type: this.triggerBtnType,
|
||||
disabled: this.disabled,
|
||||
},
|
||||
slot: 'trigger',
|
||||
ref: 'menuButton',
|
||||
attrs: {
|
||||
'aria-haspopup': this.isSemanticMenu ? 'menu' : null,
|
||||
'aria-label': this.menuName ? null : this.ariaLabel,
|
||||
'aria-controls': this.opened ? this.randomId : null,
|
||||
'aria-expanded': this.opened ? 'true' : 'false',
|
||||
},
|
||||
on: {
|
||||
focus: this.onFocus,
|
||||
blur: this.onBlur,
|
||||
},
|
||||
},
|
||||
[h('template', { slot: 'icon' }, [triggerIcon]), this.menuName],
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: {
|
||||
open: this.opened,
|
||||
},
|
||||
attrs: {
|
||||
tabindex: '-1',
|
||||
},
|
||||
on: {
|
||||
keydown: this.onKeydown,
|
||||
mousemove: this.onMouseFocusAction,
|
||||
},
|
||||
ref: 'menu',
|
||||
},
|
||||
[
|
||||
h(
|
||||
'ul',
|
||||
{
|
||||
attrs: {
|
||||
id: this.randomId,
|
||||
tabindex: '-1',
|
||||
role: this.isSemanticMenu ? 'menu' : undefined,
|
||||
},
|
||||
},
|
||||
[actions],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* If we have a single action only and didn't force a menu,
|
||||
* we render the action as a standalone button
|
||||
*/
|
||||
if (actions.length === 1 && inlineActions.length === 1 && !this.forceMenu) {
|
||||
return renderInlineAction(inlineActions[0]);
|
||||
}
|
||||
|
||||
// If we completely re-render the children
|
||||
// we need to focus the first action again
|
||||
// Mostly used when clicking a menu item
|
||||
this.$nextTick(() => {
|
||||
if (this.opened && this.$refs.menu) {
|
||||
const isAnyActive = this.$refs.menu.querySelector('li.active') || [];
|
||||
if (isAnyActive.length === 0) {
|
||||
this.focusFirstAction();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* If we some inline actions to render, render them, then the menu
|
||||
*/
|
||||
if (inlineActions.length > 0 && this.inline > 0) {
|
||||
const renderedInlineActions = inlineActions.slice(0, this.inline);
|
||||
// Filter already rendered actions
|
||||
const menuActions = actions.filter((action) => !renderedInlineActions.includes(action));
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: ['action-items', `action-item--${this.triggerBtnType}`],
|
||||
},
|
||||
[
|
||||
// Render inline actions
|
||||
...renderedInlineActions.map(renderInlineAction),
|
||||
// render the rest within the popover menu
|
||||
menuActions.length > 0
|
||||
? h(
|
||||
'div',
|
||||
{
|
||||
class: [
|
||||
'action-item',
|
||||
{
|
||||
'action-item--open': this.opened,
|
||||
},
|
||||
],
|
||||
},
|
||||
[renderActionsPopover(menuActions)],
|
||||
)
|
||||
: null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Otherwise, we render the actions in a popover
|
||||
*/
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: [
|
||||
'action-item action-item--default-popover',
|
||||
`action-item--${this.triggerBtnType}`,
|
||||
{
|
||||
'action-item--open': this.opened,
|
||||
},
|
||||
],
|
||||
},
|
||||
[renderActionsPopover(actions)],
|
||||
);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
/* Inline buttons */
|
||||
.action-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
/* Spacing between buttons */
|
||||
/* &>button {
|
||||
margin-right: math.div(28, 2);
|
||||
} */
|
||||
}
|
||||
|
||||
.action-item {
|
||||
/* --open-background-color: var(--color-background-hover, $action-background-hover); */
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
&.action-item--primary {
|
||||
--open-background-color: var(--color-primary-element-hover);
|
||||
}
|
||||
|
||||
&.action-item--secondary {
|
||||
--open-background-color: var(--color-primary-element-light-hover);
|
||||
}
|
||||
|
||||
&.action-item--error {
|
||||
--open-background-color: var(--color-error-hover);
|
||||
}
|
||||
|
||||
&.action-item--warning {
|
||||
--open-background-color: var(--color-warning-hover);
|
||||
}
|
||||
|
||||
&.action-item--success {
|
||||
--open-background-color: var(--color-success-hover);
|
||||
}
|
||||
|
||||
&.action-item--tertiary-no-background {
|
||||
--open-background-color: transparent;
|
||||
}
|
||||
|
||||
&.action-item--open .action-item__menutoggle {
|
||||
background-color: var(--open-background-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="css">
|
||||
/* // We overwrote the popover base class, so we can style
|
||||
// the popover__inner for actions only. */
|
||||
.v-popper--theme-dropdown.v-popper__popper.action-item__popper .v-popper__wrapper {
|
||||
border-radius: var(--border-radius-large);
|
||||
overflow: hidden;
|
||||
|
||||
.v-popper__inner {
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 4px;
|
||||
max-height: calc(50vh - 16px);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
544
resources/js/Components/NcButton.vue
Normal file
544
resources/js/Components/NcButton.vue
Normal file
|
@ -0,0 +1,544 @@
|
|||
<template>
|
||||
<component :is="is" :class="componentClass" :target="target" href="href" aria-label="ariaLabel" aria-pressed="pressed"
|
||||
:disabled="disabled" :type="type" v-on:click="click">
|
||||
|
||||
<span class="button-vue__wrapper">
|
||||
<!-- <span v-if="hasIcon" class="button-vue__icon" aria-hidden="true">
|
||||
{{ this.$slots.icon }}-->
|
||||
<BaseIcon v-if="icon" :path="icon" class="button-vue__icon" aria-hidden="true" :size="size" />
|
||||
|
||||
<!-- <span v-if="hasText" class="button-vue__text">{{ this.$slots.default }}</span> -->
|
||||
<span v-if="label" class="font-bold whitespace-nowrap text-ellipsis overflow-hidden" :class="labelClass">{{
|
||||
label }}</span>
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import BaseIcon from '@/Components/BaseIcon.vue';
|
||||
// import { h } from 'vue';
|
||||
export default {
|
||||
name: 'NcButton',
|
||||
|
||||
components: {
|
||||
BaseIcon
|
||||
},
|
||||
|
||||
props: {
|
||||
size: {
|
||||
type: [String, Number],
|
||||
default: 16,
|
||||
},
|
||||
small: Boolean,
|
||||
roundedFull: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
/**
|
||||
* Set the text and icon alignment
|
||||
*
|
||||
* @default 'center'
|
||||
*/
|
||||
alignment: {
|
||||
type: String,
|
||||
default: 'center',
|
||||
validator: (alignment: string) => ['start', 'start-reverse', 'center', 'center-reverse', 'end', 'end-reverse'].includes(alignment),
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggles the disabled state of the button on and off.
|
||||
*/
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* Specifies the button type
|
||||
* Accepted values: primary, secondary, tertiary, tertiary-no-background, tertiary-on-primary, error, warning, success. If left empty,
|
||||
* the default button style will be applied.
|
||||
*/
|
||||
type: {
|
||||
type: String,
|
||||
validator(value: string) {
|
||||
return (
|
||||
[
|
||||
'primary',
|
||||
'secondary',
|
||||
'tertiary',
|
||||
'tertiary-no-background',
|
||||
'tertiary-on-primary',
|
||||
'error',
|
||||
'warning',
|
||||
'success',
|
||||
].indexOf(value) !== -1
|
||||
);
|
||||
},
|
||||
default: 'secondary',
|
||||
},
|
||||
|
||||
/**
|
||||
* Specifies the button native type
|
||||
* Accepted values: submit, reset, button. If left empty,
|
||||
* the default "button" type will be used.
|
||||
*/
|
||||
nativeType: {
|
||||
type: String,
|
||||
validator(value: string) {
|
||||
return ['submit', 'reset', 'button'].indexOf(value) !== -1;
|
||||
},
|
||||
default: 'button',
|
||||
},
|
||||
|
||||
/**
|
||||
* Specifies whether the button should span all the available width.
|
||||
* By default, buttons span the whole width of the container.
|
||||
*/
|
||||
wide: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* Always try to provide an aria-label to your button. Make it more
|
||||
* specific than the button's name by provide some more context. E.g. if
|
||||
* the name of the button is "send" in the Mail app, the aria label could
|
||||
* be "Send email".
|
||||
*/
|
||||
ariaLabel: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
* Providing the href attribute turns the button component into an `a`
|
||||
* element.
|
||||
*/
|
||||
href: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
* Providing the download attribute with href downloads file when clicking.
|
||||
*/
|
||||
download: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
* Providing the to attribute turns the button component into a `router-link`
|
||||
* element. Takes precedence over the href attribute.
|
||||
*/
|
||||
to: {
|
||||
type: [String, Object],
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
* Pass in `true` if you want the matching behaviour of `router-link` to
|
||||
* be non-inclusive: https://router.vuejs.org/api/#exact
|
||||
*/
|
||||
exact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
|
||||
* @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
|
||||
* otherwise root element will inherit incorrect aria-hidden.
|
||||
*/
|
||||
ariaHidden: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
* The pressed state of the button if it has a checked state
|
||||
* This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
|
||||
*/
|
||||
pressed: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['update:pressed', 'click'],
|
||||
|
||||
computed: {
|
||||
labelClass() {
|
||||
return (this.small && this.icon) ? 'px-1' : 'px-2';
|
||||
},
|
||||
is() {
|
||||
if (this.href) {
|
||||
return 'a';
|
||||
}
|
||||
return 'button';
|
||||
},
|
||||
hasText() {
|
||||
// return !!this.$slots.default;
|
||||
return this.label != undefined;
|
||||
},
|
||||
hasIcon() {
|
||||
// return this.$slots?.icon;
|
||||
return this.icon != undefined;
|
||||
},
|
||||
// type() {
|
||||
// return this.href ? null : this.nativeType;
|
||||
// },
|
||||
|
||||
componentClass() {
|
||||
const base = [
|
||||
'button-vue',
|
||||
this.roundedFull ? 'rounded-full' : 'rounded',
|
||||
'inline-flex',
|
||||
'cursor-pointer',
|
||||
'justify-center',
|
||||
'items-center',
|
||||
'whitespace-nowrap',
|
||||
'focus:outline-none',
|
||||
'transition-colors',
|
||||
// 'focus:ring-2',
|
||||
{
|
||||
'button-vue--icon-only': this.hasIcon && !this.hasText,
|
||||
'button-vue--text-only': this.hasText && !this.hasIcon,
|
||||
'button-vue--icon-and-text': this.hasIcon && this.hasText,
|
||||
[`button-vue--vue-${this.realType}`]: this.realType,
|
||||
'button-vue--wide': this.wide,
|
||||
[`button-vue--${this.flexAlignment}`]: this.flexAlignment !== 'center',
|
||||
'button-vue--reverse': this.isReverseAligned,
|
||||
// Add other classes based on conditions as needed
|
||||
|
||||
|
||||
},
|
||||
];
|
||||
if (this.small) {
|
||||
base.push('text-sm', this.roundedFull ? 'px-3 py-1' : 'p-1');
|
||||
} else {
|
||||
base.push('py-2', this.roundedFull ? 'px-6' : 'px-3');
|
||||
}
|
||||
return base;
|
||||
},
|
||||
|
||||
target() {
|
||||
return (!this.to && this.href) ? '_self' : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
|
||||
* Otherwise the type property is used.
|
||||
*/
|
||||
realType() {
|
||||
// Force *primary* when pressed
|
||||
if (this.pressed) {
|
||||
return 'primary';
|
||||
}
|
||||
// If not pressed but button is configured as stateful button then the type must not be primary
|
||||
if (this.pressed === false && this.type === 'primary') {
|
||||
return 'secondary';
|
||||
}
|
||||
return this.type;
|
||||
},
|
||||
|
||||
/**
|
||||
* The flexbox alignment of the button content
|
||||
*/
|
||||
flexAlignment() {
|
||||
return this.alignment.split('-')[0];
|
||||
},
|
||||
|
||||
/**
|
||||
* If the button content should be reversed (icon on the end)
|
||||
*/
|
||||
isReverseAligned() {
|
||||
return this.alignment.includes('-');
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
click($event) {
|
||||
// Update pressed prop on click if it is set
|
||||
if (typeof this.pressed === 'boolean') {
|
||||
/**
|
||||
* Update the current pressed state of the button (if the `pressed` property was configured)
|
||||
*
|
||||
* @property {boolean} newValue The new `pressed`-state
|
||||
*/
|
||||
this.$emit('update:pressed', !this.pressed)
|
||||
}
|
||||
// We have to both navigate and emit the click event
|
||||
this.$emit('click', $event)
|
||||
// navigate?.($event)
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.button-vue {
|
||||
position: relative;
|
||||
|
||||
/* width: fit-content;
|
||||
overflow: hidden;
|
||||
border: 0; */
|
||||
padding: 0;
|
||||
/* font-size: var(--default-font-size);
|
||||
font-weight: bold; */
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* justify-content: center; */
|
||||
|
||||
/* Cursor pointer on element and all children */
|
||||
cursor: pointer;
|
||||
|
||||
& *,
|
||||
span {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* border-radius: math.div($clickable-area, 2); */
|
||||
/* transition-property: color, */
|
||||
/* border-color,
|
||||
background-color;
|
||||
transition-duration: 0.1s;
|
||||
transition-timing-function: linear; */
|
||||
|
||||
/* border-radius: math.div($clickable-area, 2); */
|
||||
/* border-radius: calc(50% - var(--clickable-area) / 2);
|
||||
transition-property: color, border-color, background-color;
|
||||
transition-duration: 0.1s;
|
||||
transition-timing-function: linear; */
|
||||
|
||||
/* No outline feedback for focus. Handled with a toggled class in js (see data) */
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
|
||||
& * {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
opacity: 0.5;
|
||||
/* // Gives a wash out effect */
|
||||
filter: saturate(0.7l);
|
||||
}
|
||||
|
||||
/* // Default button type */
|
||||
color: var(--color-primary-element-light-text);
|
||||
background-color: var(--color-primary-element-light);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-element-light-hover);
|
||||
}
|
||||
|
||||
|
||||
/* Back to the default color for this button when active */
|
||||
/* TODO: add ripple effect */
|
||||
&:active {
|
||||
background-color: var(--color-primary-element-light);
|
||||
}
|
||||
|
||||
&__wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--end &__wrapper {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
&--start &__wrapper {
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
&--reverse &__wrapper {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
&--reverse &--icon-and-text {
|
||||
padding-inline: calc(var(--default-grid-baseline) * 4) var(--default-grid-baseline);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* &__text {
|
||||
font-weight: bold;
|
||||
margin-bottom: 1px;
|
||||
padding: 2px 0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
} */
|
||||
|
||||
/* Icon-only button */
|
||||
&--icon-only {
|
||||
width: 44px !important;
|
||||
}
|
||||
|
||||
/* Text-only button */
|
||||
&--text-only {
|
||||
padding: 0 12px;
|
||||
|
||||
& .button-vue__text {
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Icon and text button */
|
||||
&--icon-and-text {
|
||||
padding-block: 0;
|
||||
padding-inline: var(--default-grid-baseline) calc(var(--default-grid-baseline) * 4);
|
||||
}
|
||||
|
||||
/* Wide button spans the whole width of the container */
|
||||
&--wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-main-text) !important;
|
||||
box-shadow: 0 0 0 4px var(--color-main-background) !important;
|
||||
|
||||
&.button-vue--vue-tertiary-on-primary {
|
||||
outline: 2px solid var(--color-primary-element-text);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button types */
|
||||
|
||||
/* // Primary */
|
||||
&--vue-primary {
|
||||
background-color: var(--color-primary-element);
|
||||
color: var(--color-primary-element-text);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-element-hover);
|
||||
}
|
||||
|
||||
/* Back to the default color for this button when active
|
||||
TODO: add ripple effect */
|
||||
&:active {
|
||||
background-color: var(--color-primary-element);
|
||||
}
|
||||
}
|
||||
|
||||
/* Secondary */
|
||||
&--vue-secondary {
|
||||
color: var(--color-primary-element-light-text);
|
||||
background-color: var(--color-primary-element-light);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--color-primary-element-light-text);
|
||||
background-color: var(--color-primary-element-light-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tertiary */
|
||||
&--vue-tertiary {
|
||||
color: var(--color-main-text);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tertiary, no background */
|
||||
&--vue-tertiary-no-background {
|
||||
color: var(--color-main-text);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tertiary on primary color (like the header) */
|
||||
&--vue-tertiary-on-primary {
|
||||
color: var(--color-primary-element-text);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Success */
|
||||
&--vue-success {
|
||||
background-color: var(--color-success);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-success-hover);
|
||||
}
|
||||
|
||||
/* Back to the default color for this button when active
|
||||
: add ripple effect */
|
||||
&:active {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
}
|
||||
|
||||
/* Warning */
|
||||
&--vue-warning {
|
||||
background-color: var(--color-warning);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-warning-hover);
|
||||
}
|
||||
|
||||
/* Back to the default color for this button when active
|
||||
TODO: add ripple effect */
|
||||
&:active {
|
||||
background-color: var(--color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
/* Error */
|
||||
&--vue-error {
|
||||
background-color: var(--color-error);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-error-hover);
|
||||
}
|
||||
|
||||
/* Back to the default color for this button when active
|
||||
TODO: add ripple effect */
|
||||
&:active {
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
901
resources/js/Components/NcModal.vue
Normal file
901
resources/js/Components/NcModal.vue
Normal file
|
@ -0,0 +1,901 @@
|
|||
<template>
|
||||
<transition name="fade" appear @after-enter="useFocusTrap" @before-leave="clearFocusTrap">
|
||||
<div v-show="showModal" ref="mask" class="modal-mask"
|
||||
:class="{ 'modal-mask--dark': dark || !closeButtonContained || hasPrevious || hasNext }" :style="cssVariables"
|
||||
role="dialog" aria-modal="true" :aria-labelledby="'modal-name-' + randId"
|
||||
:aria-describedby="'modal-description-' + randId" tabindex="-1">
|
||||
<!-- Header -->
|
||||
<transition name="fade-visibility" appear>
|
||||
<div class="modal-header">
|
||||
<h2 v-if="name.trim() !== ''" :id="'modal-name-' + randId" class="modal-name">
|
||||
{{ name }}
|
||||
</h2>
|
||||
<div class="icons-menu">
|
||||
<!-- Play-pause toggle -->
|
||||
<button v-if="hasNext && enableSlideshow" :class="{ 'play-pause-icons--paused': slideshowPaused }"
|
||||
class="play-pause-icons" type="button" @click="togglePlayPause">
|
||||
<!-- Play/pause icons -->
|
||||
<Play v-if="!playing" :size="iconSize" class="play-pause-icons__play" />
|
||||
<Pause v-else :size="iconSize" class="play-pause-icons__pause" />
|
||||
<span class="hidden-visually">
|
||||
{{ playPauseName }}
|
||||
</span>
|
||||
|
||||
<!-- Progress circle, css animated -->
|
||||
<svg v-if="playing" class="progress-ring" height="50" width="50">
|
||||
<circle class="progress-ring__circle" stroke="white" stroke-width="2" fill="transparent"
|
||||
r="15" cx="25" cy="25" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Actions menu -->
|
||||
<!-- @slot List of actions to show -->
|
||||
<NcActions class="header-actions" :inline="inlineActions">
|
||||
<slot name="actions" />
|
||||
</NcActions>
|
||||
|
||||
<!-- Close modal -->
|
||||
<NcButton v-if="canClose && !closeButtonContained" :aria-label="closeButtonAriaLabel"
|
||||
class="header-close" type="tertiary" @click="close" :icon="mdiClose">
|
||||
<!-- <template #icon>
|
||||
<Close :size="iconSize" />
|
||||
</template> -->
|
||||
</NcButton>
|
||||
<!-- <BaseButton v-if="canClose && !closeButtonContained" :aria-label="closeButtonAriaLabel" :color="button" outline @click="cancel" >
|
||||
</BaseButton> -->
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- modal-wrapper -->
|
||||
<transition :name="modalTransitionName" appear>
|
||||
<div v-show="showModal"
|
||||
:class="[`modal-wrapper--${size}`, { 'modal-wrapper--spread-navigation': spreadNavigation }]"
|
||||
class="modal-wrapper" @mousedown.self="handleClickModalWrapper">
|
||||
|
||||
<!-- Navigation button left -->
|
||||
<transition name="fade-visibility" appear>
|
||||
<NcButton v-show="hasPrevious" type="tertiary-no-background" class="prev"
|
||||
:aria-label="prevButtonAriaLabel" @click="previous" :icon="mdiChevronLeft" :size="40">
|
||||
</NcButton>
|
||||
</transition>
|
||||
|
||||
<!-- modal-container: Content with close button -->
|
||||
<div :id="'modal-description-' + randId" class="modal-container">
|
||||
<!-- Close modal -default hidden by FirstrunWizard.vue -->
|
||||
<NcButton v-if="canClose && closeButtonContained" type="tertiary" class="modal-container__close"
|
||||
:aria-label="'closeButtonAriaLabel'" @click="close" :icon="mdiClose" :size="20"
|
||||
:rounded-full="true">
|
||||
</NcButton>
|
||||
<div class="modal-container__content">
|
||||
<!-- @slot Modal content to render -->
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation button right -->
|
||||
<transition name="fade-visibility" appear>
|
||||
<NcButton v-show="hasNext" type="tertiary-no-background" class="next"
|
||||
:aria-label="nextButtonAriaLabel" @click="next" :icon="mdiChevronRight" :size="40">
|
||||
</NcButton>
|
||||
</transition>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { getTrapStack } from '../utils/focusTrap';
|
||||
// import { t } from '../../l10n.js';
|
||||
import GenRandomId from '../utils/GenRandomId';
|
||||
// import l10n from '../../mixins/l10n.js';
|
||||
import NcActions from '@/Components/NcActions.vue';
|
||||
import NcButton from '@/Components/NcButton.vue';
|
||||
import Timer from '../utils/Timer.js';
|
||||
// import Tooltip from '../../directives/Tooltip/index.js';
|
||||
|
||||
import Pause from '@/Components/Icons/Pause.vue';
|
||||
import Play from '@/Components/Icons/Play.vue';
|
||||
import { mdiClose, mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
|
||||
import { createFocusTrap } from 'focus-trap';
|
||||
// import { useSwipe } from '@vueuse/core';
|
||||
|
||||
export default {
|
||||
name: 'NcModal',
|
||||
|
||||
components: {
|
||||
NcActions,
|
||||
Pause,
|
||||
Play,
|
||||
NcButton,
|
||||
},
|
||||
|
||||
// directives: {
|
||||
// tooltip: Tooltip,
|
||||
// },
|
||||
|
||||
// mixins: [l10n],
|
||||
|
||||
props: {
|
||||
/**
|
||||
* Name to be shown with the modal
|
||||
*/
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
/**
|
||||
* Declare if a previous slide is available
|
||||
*/
|
||||
hasPrevious: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* Declare if a next slide is available
|
||||
*/
|
||||
hasNext: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* Declare if hiding the modal should be animated
|
||||
*/
|
||||
outTransition: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* Declare if the slideshow functionality should be enabled
|
||||
*/
|
||||
enableSlideshow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* Declare the slide interval
|
||||
*/
|
||||
slideshowDelay: {
|
||||
type: Number,
|
||||
default: 5000,
|
||||
},
|
||||
/**
|
||||
* Allow to pause an ongoing slideshow
|
||||
*/
|
||||
slideshowPaused: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* Enable swipe between slides
|
||||
*/
|
||||
enableSwipe: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
spreadNavigation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* Defines the modal size.
|
||||
* Default is 'normal'.
|
||||
* Available are 'small', 'normal', 'large' and 'full'.
|
||||
* All sizes except 'small' change automatically to full-screen on mobile.
|
||||
*/
|
||||
size: {
|
||||
type: String,
|
||||
default: 'normal',
|
||||
validator: (size: string) => {
|
||||
return ['small', 'normal', 'large', 'full'].includes(size);
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Declare if the modal can be closed
|
||||
*/
|
||||
canClose: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the modal if the user clicked outside of the modal
|
||||
* Only relevant if `canClose` is set to true.
|
||||
*/
|
||||
closeOnClickOutside: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* Makes the modal backdrop black if true
|
||||
* Will be overwritten if some buttons are shown outside
|
||||
*/
|
||||
dark: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* Selector for the modal container, pass `null` to prevent automatic container mounting
|
||||
*/
|
||||
// container: {
|
||||
// type: [String, null],
|
||||
// default: 'body',
|
||||
// },
|
||||
|
||||
/**
|
||||
* Pass in false if you want the modal 'close' button to be displayed
|
||||
* outside the modal boundaries, in the top right corner of the window
|
||||
*/
|
||||
closeButtonContained: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Additional elements to add to the focus trap
|
||||
*/
|
||||
additionalTrapElements: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
/**
|
||||
* Display x items inline
|
||||
*
|
||||
* @see Actions component usage
|
||||
*/
|
||||
inlineActions: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['previous', 'next', 'close', 'update:show'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mc: null,
|
||||
playing: false,
|
||||
slideshowTimeout: null,
|
||||
iconSize: 24,
|
||||
focusTrap: null,
|
||||
randId: GenRandomId(),
|
||||
internalShow: true,
|
||||
mdiClose,
|
||||
mdiChevronLeft,
|
||||
mdiChevronRight,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
showModal() {
|
||||
return this.show === undefined ? this.internalShow : this.show;
|
||||
},
|
||||
modalTransitionName() {
|
||||
return `modal-${this.outTransition ? 'out' : 'in'}`;
|
||||
},
|
||||
playPauseName() {
|
||||
return this.playing ? 'Pause slideshow' : 'Start slideshow';
|
||||
},
|
||||
cssVariables() {
|
||||
return {
|
||||
'--slideshow-duration': this.slideshowDelay + 'ms',
|
||||
'--icon-size': this.iconSize + 'px',
|
||||
};
|
||||
},
|
||||
|
||||
closeButtonAriaLabel() {
|
||||
return 'Close';
|
||||
},
|
||||
prevButtonAriaLabel() {
|
||||
return 'Previous';
|
||||
},
|
||||
nextButtonAriaLabel() {
|
||||
return 'Next';
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
/**
|
||||
* Handle play/pause of an ongoing slideshow
|
||||
*
|
||||
* @param {boolean} paused is the player paused
|
||||
*/
|
||||
slideshowPaused(paused) {
|
||||
if (this.slideshowTimeout) {
|
||||
if (paused) {
|
||||
this.slideshowTimeout.pause();
|
||||
} else {
|
||||
this.slideshowTimeout.start();
|
||||
}
|
||||
}
|
||||
},
|
||||
additionalTrapElements(elements) {
|
||||
if (this.focusTrap) {
|
||||
const contentContainer = this.$refs.mask;
|
||||
this.focusTrap.updateContainerElements([contentContainer, ...elements]);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
window.addEventListener('keydown', this.handleKeydown);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('keydown', this.handleKeydown);
|
||||
this.mc.stop();
|
||||
},
|
||||
mounted() {
|
||||
// init clear view
|
||||
this.useFocusTrap();
|
||||
// this.mc = useSwipe(this.$refs.mask, {
|
||||
// onSwipeEnd: this.handleSwipe,
|
||||
// });
|
||||
|
||||
if (this.container) {
|
||||
if (this.container === 'body') {
|
||||
// force mount the component to body
|
||||
document.body.insertBefore(this.$el, document.body.lastChild);
|
||||
} else {
|
||||
const container = document.querySelector(this.container);
|
||||
container.appendChild(this.$el);
|
||||
}
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
this.clearFocusTrap();
|
||||
this.$el.remove();
|
||||
},
|
||||
|
||||
methods: {
|
||||
// Events emitters
|
||||
previous(event) {
|
||||
// do not send the event if nothing is available
|
||||
if (this.hasPrevious) {
|
||||
// if data is set, then it's a user mouse event
|
||||
// and not the slideshow handler, therefore
|
||||
// we reset the timer
|
||||
if (event) {
|
||||
this.resetSlideshow();
|
||||
}
|
||||
this.$emit('previous', event);
|
||||
}
|
||||
},
|
||||
next(event) {
|
||||
// do not send the event if nothing is available
|
||||
if (this.hasNext) {
|
||||
// if data is set, then it's a mouse event
|
||||
// and not the slideshow handler, therefore
|
||||
// we reset the timer
|
||||
if (event) {
|
||||
this.resetSlideshow();
|
||||
}
|
||||
this.$emit('next', event);
|
||||
}
|
||||
},
|
||||
close(data) {
|
||||
// do not fire event if forbidden
|
||||
if (this.canClose) {
|
||||
// We set internalShow here, so the out transitions properly run before the component is destroyed
|
||||
this.internalShow = false;
|
||||
this.$emit('update:show', false);
|
||||
|
||||
// delay closing for animation
|
||||
setTimeout(() => {
|
||||
/**
|
||||
* Emitted when the closing animation is finished
|
||||
*/
|
||||
this.$emit('close', data);
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle click on modal wrapper
|
||||
* If `closeOnClickOutside` is set the modal will be closed
|
||||
*
|
||||
* @param {MouseEvent} event The click event
|
||||
*/
|
||||
handleClickModalWrapper(event) {
|
||||
if (this.closeOnClickOutside) {
|
||||
this.close(event);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event - keyboard event
|
||||
*/
|
||||
handleKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
const trapStack = getTrapStack();
|
||||
// Only close the most recent focus trap modal
|
||||
if (trapStack.length > 0 && trapStack[trapStack.length - 1] !== this.focusTrap) {
|
||||
return;
|
||||
}
|
||||
return this.close(event);
|
||||
}
|
||||
|
||||
const arrowHandlers = {
|
||||
ArrowLeft: this.previous,
|
||||
ArrowRight: this.next,
|
||||
};
|
||||
if (arrowHandlers[event.key]) {
|
||||
// Ignore arrow navigation, if there is a current focus outside the modal.
|
||||
// For example, when the focus is in Sidebar or NcActions's items,
|
||||
// arrow navigation should not be intercept by modal slider
|
||||
if (document.activeElement && !this.$el.contains(document.activeElement)) {
|
||||
return;
|
||||
}
|
||||
return arrowHandlers[event.key](event);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* handle the swipe event
|
||||
*
|
||||
* @param {TouchEvent} e The touch event
|
||||
* @param {import('@vueuse/core').SwipeDirection} direction Swipe direction
|
||||
*/
|
||||
handleSwipe(e, direction) {
|
||||
if (this.enableSwipe) {
|
||||
if (direction === 'left') {
|
||||
// swiping to left to go to the next item
|
||||
this.next(e);
|
||||
} else if (direction === 'right') {
|
||||
// swiping to right to go back to the previous item
|
||||
this.previous(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle the slideshow state
|
||||
*/
|
||||
togglePlayPause() {
|
||||
this.playing = !this.playing;
|
||||
if (this.playing) {
|
||||
this.handleSlideshow();
|
||||
} else {
|
||||
this.clearSlideshowTimeout();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset the slideshow timer and keep going if it was on
|
||||
*/
|
||||
resetSlideshow() {
|
||||
this.playing = !this.playing;
|
||||
this.clearSlideshowTimeout();
|
||||
this.$nextTick(function () {
|
||||
this.togglePlayPause();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle the slideshow timer and next event
|
||||
*/
|
||||
handleSlideshow() {
|
||||
this.playing = true;
|
||||
if (this.hasNext) {
|
||||
this.slideshowTimeout = new Timer(() => {
|
||||
this.next();
|
||||
this.handleSlideshow();
|
||||
}, this.slideshowDelay);
|
||||
} else {
|
||||
this.playing = false;
|
||||
this.clearSlideshowTimeout();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear slideshowTimeout if ongoing
|
||||
*/
|
||||
clearSlideshowTimeout() {
|
||||
if (this.slideshowTimeout) {
|
||||
this.slideshowTimeout.clear();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Add focus trap for accessibility.
|
||||
*/
|
||||
async useFocusTrap() {
|
||||
// Don't do anything if the modal is hidden,
|
||||
// or we have a focus trap already
|
||||
if (!this.showModal || this.focusTrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentContainer = this.$refs.mask;
|
||||
// wait until all children are mounted and available in the DOM before focusTrap can be added
|
||||
await this.$nextTick();
|
||||
|
||||
const options = {
|
||||
allowOutsideClick: true,
|
||||
fallbackFocus: contentContainer,
|
||||
trapStack: getTrapStack(),
|
||||
// Esc can be used without stop in content or additionalTrapElements where it should not deacxtivate modal's focus trap.
|
||||
// Focus trap is deactivated on modal close anyway.
|
||||
escapeDeactivates: false,
|
||||
};
|
||||
|
||||
// Init focus trap
|
||||
this.focusTrap = createFocusTrap([contentContainer, ...this.additionalTrapElements], options);
|
||||
this.focusTrap.activate();
|
||||
},
|
||||
clearFocusTrap() {
|
||||
if (!this.focusTrap) {
|
||||
return;
|
||||
}
|
||||
this.focusTrap?.deactivate();
|
||||
this.focusTrap = null;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
z-index: 9998;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
&--dark {
|
||||
background-color: rgba(0, 0, 0, 0.92);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
position: absolute;
|
||||
z-index: 10001;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
/* prevent vue show to use display:none and reseting */
|
||||
/* the circle animation loop */
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: var(--header-height);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
opacity 250ms,
|
||||
visibility 250ms;
|
||||
|
||||
.modal-name {
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
/* maximum actions is 3 */
|
||||
padding: 0 var(--clickable-area * 3) 0 12px;
|
||||
transition: padding ease 100ms;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: #fff;
|
||||
/* font-size: var(--icon-margin); */
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* On wider screens the name can be centered
|
||||
@media only screen and (min-width: $breakpoint-mobile) {
|
||||
.modal-name {
|
||||
padding-left: #{$clickable-area * 3}; // maximum actions is 3
|
||||
text-align: center;
|
||||
}
|
||||
} */
|
||||
|
||||
.icons-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
.header-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
/* margin: math.div($header-height - $clickable-area, 2); */
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.play-pause-icons {
|
||||
position: relative;
|
||||
width: var(--header-height);
|
||||
height: var(header-height);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
|
||||
.play-pause-icons__play,
|
||||
.play-pause-icons__pause {
|
||||
opacity: var(--opacity_full);
|
||||
/* border-radius: math.div($clickable-area, 2); */
|
||||
background-color: var(--icon-focus-bg);
|
||||
}
|
||||
}
|
||||
|
||||
&__play,
|
||||
&__pause {
|
||||
box-sizing: border-box;
|
||||
width: var(--default-clickable-area);
|
||||
height: var(--default-clickable-area);
|
||||
margin: var(--header-height) - var(--default-clickable-area);
|
||||
cursor: pointer;
|
||||
opacity: var(--opacity_normal);
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:deep() .action-item {
|
||||
/* margin: math.div($header-height - $clickable-area, 2); */
|
||||
|
||||
&--single {
|
||||
box-sizing: border-box;
|
||||
/* width: $clickable-area;
|
||||
height: $clickable-area; */
|
||||
cursor: pointer;
|
||||
background-position: center;
|
||||
background-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(button) {
|
||||
/* force white instead of default main text */
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Force the Actions menu icon to be the same size as other icons */
|
||||
&:deep(.action-item__menutoggle) {
|
||||
padding: 0;
|
||||
|
||||
span,
|
||||
svg {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
/* Navigation buttons */
|
||||
.prev,
|
||||
.next {
|
||||
z-index: 10000;
|
||||
height: 35vh;
|
||||
min-height: 300px;
|
||||
position: absolute;
|
||||
transition: opacity 250ms;
|
||||
/* // hover the mask */
|
||||
color: white;
|
||||
|
||||
&:focus-visible {
|
||||
/* Override NcButton focus styles */
|
||||
box-shadow: 0 0 0 2px var(--color-primary-element-text);
|
||||
background-color: var(--color-box-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
.prev {
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
.next {
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.modal-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
transition: transform 300ms ease;
|
||||
border-radius: var(--border-radius-large);
|
||||
background-color: var(--color-main-background);
|
||||
color: var(--color-main-text);
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&__close {
|
||||
/* // Ensure the close button is always ontop of the content */
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
width: 100%;
|
||||
/* At least the close button shall fit in */
|
||||
min-height: 52px;
|
||||
/* avoids unecessary hacks if the content should be bigger than the modal */
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* We allow 90% max-height, but we need to ensure the header does not overlap the modal
|
||||
as the modal is centered, we need the space on top and bottom */
|
||||
/* $max-modal-height: min(90%, calc(100% - 2 * $header-height)); */
|
||||
|
||||
/* // Sizing */
|
||||
&--small {
|
||||
.modal-container {
|
||||
width: 400px;
|
||||
max-width: 90%;
|
||||
/* max-height: $max-modal-height; */
|
||||
}
|
||||
}
|
||||
|
||||
&--normal {
|
||||
.modal-container {
|
||||
max-width: 90%;
|
||||
width: 600px;
|
||||
/* max-height: $max-modal-height; */
|
||||
}
|
||||
}
|
||||
|
||||
&--large {
|
||||
.modal-container {
|
||||
max-width: 90%;
|
||||
width: 900px;
|
||||
/* max-height: $max-modal-height; */
|
||||
}
|
||||
}
|
||||
|
||||
&--full {
|
||||
.modal-container {
|
||||
width: 100%;
|
||||
height: calc(100% - var(--header-height));
|
||||
position: absolute;
|
||||
/* top: $header-height; */
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* // Make modal full screen on mobile */
|
||||
@media only screen and (max-width: 640px) {
|
||||
.modal-container {
|
||||
max-width: initial;
|
||||
width: 100%;
|
||||
max-height: initial;
|
||||
height: calc(100% - var(--header-height));
|
||||
position: absolute;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* TRANSITIONS */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 250ms;
|
||||
}
|
||||
|
||||
.fade-enter,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-visibility-enter,
|
||||
.fade-visibility-leave-to {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-in-enter-active,
|
||||
.modal-in-leave-active,
|
||||
.modal-out-enter-active,
|
||||
.modal-out-leave-active {
|
||||
transition: opacity 250ms;
|
||||
}
|
||||
|
||||
.modal-in-enter,
|
||||
.modal-in-leave-to,
|
||||
.modal-out-enter,
|
||||
.modal-out-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-in-enter .modal-container,
|
||||
.modal-in-leave-to .modal-container {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.modal-out-enter .modal-container,
|
||||
.modal-out-leave-to .modal-container {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* // animated circle */
|
||||
/* $radius: 15;
|
||||
$pi: 3.14159265358979; */
|
||||
|
||||
.modal-mask .play-pause-icons {
|
||||
.progress-ring {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: rotate(-90deg);
|
||||
|
||||
.progress-ring__circle {
|
||||
transition: 100ms stroke-dashoffset;
|
||||
/* // axis compensation */
|
||||
transform-origin: 50% 50%;
|
||||
animation: progressring linear var(--slideshow-duration) infinite;
|
||||
|
||||
stroke-linecap: round;
|
||||
/* radius * 2 * PI */
|
||||
stroke-dashoffset: 15 * 2 * 3.14159265358979;
|
||||
/* radius * 2 * PI */
|
||||
stroke-dasharray: 15 * 2 * 3.14159265358979;
|
||||
}
|
||||
}
|
||||
|
||||
&--paused {
|
||||
.icon-pause {
|
||||
animation: breath 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
.progress-ring__circle {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* keyframes get scoped too and break the animation name, we need them unscoped */
|
||||
@keyframes progressring {
|
||||
from {
|
||||
/* radius * 2 * PI */
|
||||
stroke-dashoffset: 15 * 2 * 3.14159265358979;
|
||||
}
|
||||
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breath {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -146,7 +146,7 @@ const datasets = computed(() => mainService.datasets);
|
|||
<TableSampleClients />
|
||||
</CardBox>
|
||||
|
||||
<CardBox>
|
||||
<!-- <CardBox>
|
||||
<p class="mb-3 text-gray-500 dark:text-gray-400">
|
||||
Discover the power of Tethys, the cutting-edge web backend solution that revolutionizes the way you handle research
|
||||
data. At the heart of Tethys lies our meticulously developed research data repository, which leverages state-of-the-art
|
||||
|
@ -163,7 +163,7 @@ const datasets = computed(() => mainService.datasets);
|
|||
eliminates errors and minimizes downtime. Our CI/CD pipeline automatically verifies each code commit, runs comprehensive
|
||||
tests, and deploys the repository seamlessly, ensuring that your research data is always up-to-date and accessible.
|
||||
</p>
|
||||
</CardBox>
|
||||
</CardBox> -->
|
||||
</SectionMain>
|
||||
<!-- </section> -->
|
||||
</LayoutAuthenticated>
|
||||
|
|
|
@ -6,8 +6,10 @@ export const basic = {
|
|||
asideMenuItemActive: 'font-bold text-cyan-300',
|
||||
asideMenuDropdown: 'bg-gray-700/50',
|
||||
navBarItemLabel: 'text-black',
|
||||
navBarItemLabelHover: 'hover:text-blue-500',
|
||||
navBarItemLabelActiveColor: 'text-blue-600',
|
||||
// navBarItemLabelHover: 'hover:text-blue-500',
|
||||
navBarItemLabelHover: 'hover:text-lime-dark',
|
||||
// navBarItemLabelActiveColor: 'text-blue-600',
|
||||
navBarItemLabelActiveColor: 'text-lime-dark',
|
||||
overlay: 'from-gray-700 via-gray-900 to-gray-700',
|
||||
};
|
||||
|
||||
|
|
8
resources/js/utils/GenRandomId.js
Normal file
8
resources/js/utils/GenRandomId.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
const GenRandomId = (length) => {
|
||||
return Math.random()
|
||||
.toString(36)
|
||||
.replace(/[^a-z]+/g, '')
|
||||
.slice(0, length || 5)
|
||||
}
|
||||
|
||||
export default GenRandomId
|
43
resources/js/utils/Timer.js
Normal file
43
resources/js/utils/Timer.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* @param {Function} callback The function to call
|
||||
* @param {number} delay The time to wait
|
||||
*/
|
||||
export default function timer(callback, delay) {
|
||||
let id;
|
||||
let started;
|
||||
let remaining = delay;
|
||||
let running;
|
||||
|
||||
this.start = function () {
|
||||
running = true;
|
||||
started = new Date();
|
||||
id = setTimeout(callback, remaining);
|
||||
};
|
||||
|
||||
this.pause = function () {
|
||||
running = false;
|
||||
clearTimeout(id);
|
||||
remaining -= new Date() - started;
|
||||
};
|
||||
|
||||
this.clear = function () {
|
||||
running = false;
|
||||
clearTimeout(id);
|
||||
remaining = 0;
|
||||
};
|
||||
|
||||
this.getTimeLeft = function () {
|
||||
if (running) {
|
||||
this.pause();
|
||||
this.start();
|
||||
}
|
||||
|
||||
return remaining;
|
||||
};
|
||||
|
||||
this.getStateRunning = function () {
|
||||
return running;
|
||||
};
|
||||
|
||||
this.start();
|
||||
}
|
11
resources/js/utils/focusTrap.js
Normal file
11
resources/js/utils/focusTrap.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Return the default global focus trap stack
|
||||
*
|
||||
* @return {import('focus-trap').FocusTrap[]}
|
||||
*/
|
||||
export const getTrapStack = function() {
|
||||
// Create global stack if undefined
|
||||
Object.assign(window, { _nc_focus_trap: window._nc_focus_trap || [] })
|
||||
|
||||
return window._nc_focus_trap
|
||||
}
|
16
resources/js/utils/initialState.ts
Normal file
16
resources/js/utils/initialState.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
export function loadState<T>(app: string, key: string, fallback?: T): T {
|
||||
const elem = <HTMLInputElement>document.querySelector(`#initial-state-${app}-${key}`);
|
||||
if (elem === null) {
|
||||
if (fallback !== undefined) {
|
||||
return fallback;
|
||||
}
|
||||
throw new Error(`Could not find initial state ${key} of ${app}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const value = atob(elem.value);
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
throw new Error(`Could not parse initial state ${key} of ${app}`);
|
||||
}
|
||||
}
|
|
@ -1,11 +1,16 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{{-- <link rel="icon" type="image/png" href="/favicon.ico"> --}}
|
||||
{{--
|
||||
<link rel="icon" type="image/png" href="/favicon.ico"> --}}
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
{{-- <link rel="icon" href="/apps/theming/favicon/settings?v=ad28c447"> --}}
|
||||
{{--
|
||||
<link rel="icon" href="/apps/theming/favicon/settings?v=ad28c447"> --}}
|
||||
<input type="hidden" id="initial-state-firstrunwizard-desktop"
|
||||
value="Imh0dHBzOi8vZ2l0ZWEuZ2VvbG9naWUuYWMuYXQvZ2VvbGJhL3RldGh5cy5iYWNrZW5kIg==">
|
||||
|
||||
@routes
|
||||
@entryPointStyles('app')
|
||||
|
@ -14,7 +19,9 @@
|
|||
{{-- <title>myapp</title> --}}
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@inertia()
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,6 +1,7 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
const plugin = require('tailwindcss/plugin');
|
||||
const defaultTheme = require('tailwindcss/defaultTheme');
|
||||
const { registerables } = require('chart.js');
|
||||
|
||||
module.exports = {
|
||||
content: ['./resources/**/*.{edge,js,ts,jsx,tsx,vue}'],
|
||||
|
@ -14,6 +15,20 @@ module.exports = {
|
|||
colors: {
|
||||
'primary': '#22C55E',
|
||||
'primary-dark': '#DCFCE7',
|
||||
'lime': {
|
||||
DEFAULT: '#BFCE40',
|
||||
dark: 'rgba(5,46,55,0.7)',
|
||||
50: '#FBFCF7',
|
||||
100: '#F8FBE1',
|
||||
200: '#EEF69E',
|
||||
300: '#DCEC53',
|
||||
400: '#A8D619',
|
||||
500: '#65DC21',
|
||||
600: '#429E04',
|
||||
700: '#357C06',
|
||||
800: '#295B09',
|
||||
900: '#20450A',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', ...defaultTheme.fontFamily.sans],
|
||||
|
|
Loading…
Reference in New Issue
Block a user