Work in progress
This commit is contained in:
89
client/components/global/Breadcrumb.vue
Normal file
89
client/components/global/Breadcrumb.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<section class="sticky flex items-center inset-x-0 top-0 z-20 py-3 bg-white border-b border-gray-200">
|
||||
<div class="hidden md:flex flex-grow">
|
||||
<slot name="left" />
|
||||
</div>
|
||||
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div class="flex items-center justify-center space-x-4">
|
||||
<div v-if="displayHome" class="flex items-center">
|
||||
<router-link class="text-gray-400 hover:text-gray-500" :to="{ name: (authenticated) ? 'home' : 'index' }">
|
||||
<svg class="flex-shrink-0 w-5 h-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Home</span>
|
||||
</router-link>
|
||||
<svg class="flex-shrink-0 w-5 h-5 text-gray-400 ml-4" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path fill-rule="evenodd"
|
||||
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-for="(item,index) in path" :key="index" class="flex items-center">
|
||||
<router-link v-if="item.route" class="text-sm font-semibold text-gray-500 hover:text-gray-700 truncate"
|
||||
:to="item.route"
|
||||
>
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
<div v-else class="text-sm font-semibold sm:w-full w-36 text-blue-500 truncate">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div v-if="index!==path.length-1">
|
||||
<svg class="flex-shrink-0 w-5 h-5 text-gray-400 ml-4" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path fill-rule="evenodd"
|
||||
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden md:flex flex-grow justify-end">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
|
||||
export default {
|
||||
name: 'Breadcrumb',
|
||||
props: {
|
||||
/**
|
||||
* route: Route object
|
||||
* label: Label
|
||||
*/
|
||||
path: { type: Array }
|
||||
},
|
||||
|
||||
setup () {
|
||||
const authStore = useAuthStore()
|
||||
return {
|
||||
authenticated : computed(() => authStore.check)
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
displayHome: true
|
||||
}
|
||||
},
|
||||
|
||||
computed: {},
|
||||
|
||||
mounted () {},
|
||||
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
27
client/components/global/Card.vue
Normal file
27
client/components/global/Card.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-full bg-white rounded-lg shadow"
|
||||
:class="{'px-4 py-8 sm:px-6 md:px-8 lg:px-10':padding}"
|
||||
>
|
||||
<div v-if="title" class="self-center mb-6 text-xl font-light text-gray-900 sm:text-3xl font-bold dark:text-white">
|
||||
{{ title }}
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Card',
|
||||
|
||||
props: {
|
||||
padding: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
41
client/components/global/Collapse.vue
Normal file
41
client/components/global/Collapse.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="w-full relative">
|
||||
<div class="cursor-pointer" @click="trigger">
|
||||
<slot name="title" />
|
||||
</div>
|
||||
<div class="text-gray-400 hover:text-gray-600 absolute -right-2 -top-1 cursor-pointer p-2" @click="trigger">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 transition transform duration-500"
|
||||
:class="{'rotate-180':showContent}" viewBox="0 0 20 20" fill="currentColor"
|
||||
>
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<VTransition>
|
||||
<div v-if="showContent" class="w-full">
|
||||
<slot />
|
||||
</div>
|
||||
</VTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import VTransition from './transitions/VTransition.vue'
|
||||
import { ref, defineProps, defineEmits } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: null }
|
||||
})
|
||||
|
||||
const showContent = ref(props.modelValue)
|
||||
const emit = defineEmits()
|
||||
|
||||
const trigger = () => {
|
||||
showContent.value = !showContent.value
|
||||
emit('update:modelValue', showContent.value)
|
||||
}
|
||||
</script>
|
||||
54
client/components/global/Dropdown.vue
Normal file
54
client/components/global/Dropdown.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<slot name="trigger"
|
||||
:toggle="toggle"
|
||||
:open="open"
|
||||
:close="close"
|
||||
/>
|
||||
|
||||
<collapsible v-model="isOpen" :class="dropdownClass">
|
||||
<div class="py-1 " role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
|
||||
<slot />
|
||||
</div>
|
||||
</collapsible>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import Collapsible from './transitions/Collapsible.vue'
|
||||
|
||||
export default {
|
||||
name: 'Dropdown',
|
||||
components: { Collapsible },
|
||||
directives: {},
|
||||
props: {
|
||||
dropdownClass: {
|
||||
type: String,
|
||||
default: 'origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-20'
|
||||
}
|
||||
},
|
||||
setup () {
|
||||
const isOpen = ref(false)
|
||||
|
||||
const open = () => {
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
toggle
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
72
client/components/global/EditableDiv.vue
Normal file
72
client/components/global/EditableDiv.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div ref="parentRef"
|
||||
tabindex="0"
|
||||
:class="{
|
||||
'hover:bg-gray-100 dark:hover:bg-gray-800 rounded px-2 cursor-pointer': !editing
|
||||
}"
|
||||
class="relative"
|
||||
:style="{ height: editing ? divHeight + 'px' : 'auto' }"
|
||||
@focus="startEditing"
|
||||
>
|
||||
<slot v-if="!editing" :content="content">
|
||||
<label class="cursor-pointer truncate w-full">
|
||||
{{ content }}
|
||||
</label>
|
||||
</slot>
|
||||
<div v-if="editing" class="absolute inset-0 border-2 transition-colors"
|
||||
:class="{ 'border-transparent': !editing, 'border-blue-500': editing }"
|
||||
>
|
||||
<input ref="editInputRef" v-model="content"
|
||||
class="absolute inset-0 focus:outline-none bg-white transition-colors"
|
||||
:class="[{'bg-blue-50': editing}, contentClass]" @blur="editing = false" @keyup.enter="editing = false"
|
||||
@input="handleInput"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, nextTick, defineProps, defineEmits } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, required: true },
|
||||
textAlign: { type: String, default: 'left' },
|
||||
contentClass: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits()
|
||||
const content = ref(props.modelValue)
|
||||
const editing = ref(false)
|
||||
const divHeight = ref(0)
|
||||
|
||||
const parentRef = ref(null) // Ref for parent element
|
||||
const editInputRef = ref(null) // Ref for edit input element
|
||||
|
||||
const startEditing = () => {
|
||||
if (parentRef.value) {
|
||||
divHeight.value = parentRef.value.offsetHeight
|
||||
editing.value = true
|
||||
nextTick(() => {
|
||||
if (editInputRef.value) {
|
||||
editInputRef.value.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = () => {
|
||||
emit('update:modelValue', content.value)
|
||||
}
|
||||
|
||||
// Watch for changes in props.modelValue and update the local content
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
content.value = newValue
|
||||
})
|
||||
|
||||
// Wait until the component is mounted to set the initial divHeight
|
||||
onMounted(() => {
|
||||
if (parentRef.value) {
|
||||
divHeight.value = parentRef.value.offsetHeight
|
||||
}
|
||||
})
|
||||
</script>
|
||||
13
client/components/global/Loader.vue
Normal file
13
client/components/global/Loader.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Loader',
|
||||
props: {}
|
||||
}
|
||||
</script>
|
||||
180
client/components/global/Modal.vue
Normal file
180
client/components/global/Modal.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<portal to="modals" :order="portalOrder">
|
||||
<transition @leave="(el,done) => motions.backdrop.leave(done)">
|
||||
<div v-if="show" v-motion="'backdrop'" :variants="motionFadeIn"
|
||||
class="fixed z-30 top-0 inset-0 px-4 sm:px-0 flex items-top justify-center bg-gray-700/75 w-full h-screen overflow-y-scroll"
|
||||
:class="{'backdrop-blur-sm':backdropBlur}"
|
||||
@click.self="close"
|
||||
>
|
||||
<div ref="content" v-motion="'body'" :variants="motionSlideBottom"
|
||||
class="self-start bg-white dark:bg-notion-dark w-full relative p-4 md:p-6 my-6 rounded-xl shadow-xl"
|
||||
:class="maxWidthClass"
|
||||
>
|
||||
<div v-if="closeable" class="absolute top-4 right-4">
|
||||
<button class="text-gray-500 hover:text-gray-900 cursor-pointer" @click.prevent="close">
|
||||
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sm:flex sm:flex-col sm:items-start">
|
||||
<div v-if="$scopedSlots.hasOwnProperty('icon')" class="flex w-full justify-center mb-4">
|
||||
<div class="w-14 h-14 rounded-full flex justify-center items-center"
|
||||
:class="'bg-'+iconColor+'-100 text-'+iconColor+'-600'"
|
||||
>
|
||||
<slot name="icon"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 w-full">
|
||||
<h2 v-if="$scopedSlots.hasOwnProperty('title')"
|
||||
class="text-2xl font-semibold text-center text-gray-900"
|
||||
>
|
||||
<slot name="title"/>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<slot/>
|
||||
</div>
|
||||
|
||||
<div v-if="$scopedSlots.hasOwnProperty('footer')" class="px-6 py-4 bg-gray-100 text-right">
|
||||
<slot name="footer"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</portal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {useMotions} from '@vueuse/motion'
|
||||
|
||||
export default {
|
||||
name: 'Modal',
|
||||
|
||||
props: {
|
||||
show: {
|
||||
default: false
|
||||
},
|
||||
backdropBlur: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
iconColor: {
|
||||
default: 'blue'
|
||||
},
|
||||
maxWidth: {
|
||||
default: '2xl'
|
||||
},
|
||||
closeable: {
|
||||
default: true
|
||||
},
|
||||
portalOrder: {
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
useHead({
|
||||
bodyAttrs: {
|
||||
class: {
|
||||
'overflow-hidden': props.show
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const closeOnEscape = (e) => {
|
||||
if (e.key === 'Escape' && this.show) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (process.server) return
|
||||
document.addEventListener('keydown', closeOnEscape)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (process.server) return
|
||||
document.removeEventListener('keydown', closeOnEscape)
|
||||
})
|
||||
|
||||
return {
|
||||
motions: useMotions(),
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
maxWidthClass() {
|
||||
return {
|
||||
sm: 'sm:max-w-sm',
|
||||
md: 'sm:max-w-md',
|
||||
lg: 'sm:max-w-lg',
|
||||
xl: 'sm:max-w-xl',
|
||||
'2xl': 'sm:max-w-2xl'
|
||||
}[this.maxWidth]
|
||||
},
|
||||
motionFadeIn() {
|
||||
return {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
delay: 100,
|
||||
duration: 200,
|
||||
ease: 'easeIn'
|
||||
}
|
||||
},
|
||||
enter: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 200
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
motionSlideBottom() {
|
||||
return {
|
||||
initial: {
|
||||
y: 150,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
ease: 'easeIn',
|
||||
duration: 200
|
||||
}
|
||||
},
|
||||
enter: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 250,
|
||||
ease: 'easeOut',
|
||||
delay: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
show(newVal, oldVal) {
|
||||
if (newVal !== oldVal) {
|
||||
if (!newVal) {
|
||||
this.motions.body.apply('initial')
|
||||
this.motions.backdrop.apply('initial')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
if (this.closeable) {
|
||||
this.$emit('close')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
216
client/components/global/Navbar.vue
Normal file
216
client/components/global/Navbar.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<nav v-if="hasNavbar" class="bg-white dark:bg-notion-dark border-b">
|
||||
<div class="max-w-7xl mx-auto px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<router-link :to="{ name: user ? 'home' : 'index' }" class="flex-shrink-0 font-semibold hover:no-underline flex items-center">
|
||||
<img src="/img/logo.svg" alt="notion tools logo" class="w-8 h-8">
|
||||
|
||||
<span
|
||||
class="ml-2 text-md hidden sm:inline text-black dark:text-white"
|
||||
>
|
||||
OpnForm</span>
|
||||
</router-link>
|
||||
<workspace-dropdown class="ml-6" />
|
||||
</div>
|
||||
<div v-if="showAuth" class="hidden md:block ml-auto relative">
|
||||
<router-link v-if="$route.name !== 'templates'" :to="{name:'templates'}"
|
||||
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8"
|
||||
>
|
||||
Templates
|
||||
</router-link>
|
||||
<router-link v-if="$route.name !== 'ai-form-builder'" :to="{name:'ai-form-builder'}"
|
||||
class="text-sm text-gray-600 dark:text-white hidden lg:inline hover:text-gray-800 cursor-pointer mt-1 mr-8"
|
||||
>
|
||||
AI Form Builder
|
||||
</router-link>
|
||||
<router-link v-if="paidPlansEnabled && (user===null || (user && workspace && !workspace.is_pro)) && $route.name !== 'pricing'" :to="{name:'pricing'}"
|
||||
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8"
|
||||
>
|
||||
<span v-if="user">Upgrade</span>
|
||||
<span v-else>Pricing</span>
|
||||
</router-link>
|
||||
<a v-if="hasCrisp" href="#"
|
||||
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1" @click.prevent="openCrisp"
|
||||
>
|
||||
Help
|
||||
</a>
|
||||
<a v-else :href="helpUrl"
|
||||
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1" target="_blank"
|
||||
>
|
||||
Help
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="showAuth" class="hidden md:block pl-5 border-gray-300 border-r h-5" />
|
||||
<div v-if="showAuth" class="block">
|
||||
<div class="flex items-center">
|
||||
<div class="ml-3 mr-4 relative">
|
||||
<div class="relative inline-block text-left">
|
||||
<dropdown v-if="user" dusk="nav-dropdown">
|
||||
<template #trigger="{toggle}">
|
||||
<button id="dropdown-menu-button" type="button"
|
||||
class="flex items-center justify-center w-full rounded-md px-4 py-2 text-sm text-gray-700 dark:text-gray-50 hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-gray-500"
|
||||
dusk="nav-dropdown-button" @click.stop="toggle()"
|
||||
>
|
||||
<img :src="user.photo_url" class="rounded-full w-6 h-6">
|
||||
<p class="ml-2 hidden sm:inline">
|
||||
{{ user.name }}
|
||||
</p>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<router-link v-if="userOnboarded" :to="{ name: 'home' }"
|
||||
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
My Forms
|
||||
</router-link>
|
||||
|
||||
<router-link v-if="userOnboarded" :to="{ name: 'my_templates' }"
|
||||
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
My Templates
|
||||
</router-link>
|
||||
|
||||
<router-link :to="{ name: 'settings.profile' }"
|
||||
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Settings
|
||||
</router-link>
|
||||
|
||||
<a href="#"
|
||||
class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
@click.prevent="logout"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
Logout
|
||||
</a>
|
||||
</dropdown>
|
||||
<div v-else class="flex gap-2">
|
||||
<router-link v-if="$route.name !== 'login'" :to="{ name: 'login' }"
|
||||
class="text-gray-600 dark:text-white hover:text-gray-800 dark:hover:text-white px-0 sm:px-3 py-2 rounded-md text-sm"
|
||||
active-class="text-gray-800 dark:text-white"
|
||||
>
|
||||
Login
|
||||
</router-link>
|
||||
|
||||
<v-button v-track.nav_create_form_click size="small" :to="{ name: 'forms.create.guest' }" color="outline-blue" :arrow="true">
|
||||
Create a form
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
import Dropdown from '~/components/global/Dropdown.vue'
|
||||
import WorkspaceDropdown from './WorkspaceDropdown.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WorkspaceDropdown,
|
||||
Dropdown
|
||||
},
|
||||
|
||||
setup () {
|
||||
const authStore = useAuthStore()
|
||||
const formsStore = useFormsStore()
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
const {openCrisp} = useCrisp()
|
||||
return {
|
||||
authStore,
|
||||
formsStore,
|
||||
workspacesStore,
|
||||
openCrisp,
|
||||
config: useConfig(),
|
||||
user: computed(() => authStore.user),
|
||||
isIframe: useIsIframe(),
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
helpUrl: () => this.config.links.help_url,
|
||||
form () {
|
||||
if (this.$route.name && this.$route.name.startsWith('forms.show_public')) {
|
||||
return this.formsStore.getBySlug(this.$route.params.slug)
|
||||
}
|
||||
return null
|
||||
},
|
||||
workspace () {
|
||||
return this.workspacesStore.getCurrent()
|
||||
},
|
||||
paidPlansEnabled () {
|
||||
return this.config.paid_plans_enabled
|
||||
},
|
||||
showAuth () {
|
||||
return this.$route.name && !this.$route.name.startsWith('forms.show_public')
|
||||
},
|
||||
hasNavbar () {
|
||||
if (this.isIframe) return false
|
||||
|
||||
if (this.$route.name && this.$route.name.startsWith('forms.show_public')) {
|
||||
if (this.form) {
|
||||
// If there is a cover, or if branding is hidden remove nav
|
||||
if (this.form.cover_picture || this.form.no_branding) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return !this.$root.navbarHidden
|
||||
},
|
||||
userOnboarded () {
|
||||
return this.user && this.user.workspaces_count > 0
|
||||
},
|
||||
hasCrisp () {
|
||||
return this.config.crisp_website_id
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async logout () {
|
||||
// Log out the user.
|
||||
await this.authStore.logout()
|
||||
|
||||
// Reset store
|
||||
this.workspacesStore.resetState()
|
||||
this.formsStore.resetState()
|
||||
|
||||
// Redirect to login.
|
||||
this.$router.push({ name: 'login' })
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
139
client/components/global/Notifications.vue
Normal file
139
client/components/global/Notifications.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="fixed top-0 bottom-24 right-0 flex px-4 items-start justify-end z-50 pointer-events-none">
|
||||
<notification v-slot="{ notifications, close }">
|
||||
<div class="relative pointer-events-auto" v-for="notification in notifications" :key="notification.id">
|
||||
<div
|
||||
v-if="notification.type==='success'"
|
||||
class="flex max-w-sm w-full mx-auto bg-white shadow-md rounded-lg overflow-hidden mt-4"
|
||||
>
|
||||
<div class="flex justify-center items-center w-12 bg-green-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 fill-current text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="-mx-3 py-2 px-4">
|
||||
<div class="mx-3">
|
||||
<span class="text-green-500 font-semibold pr-6">{{notification.title}}</span>
|
||||
<p class="text-gray-600 text-sm">{{notification.text}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="notification.type==='info'"
|
||||
class="flex max-w-sm w-full mx-auto bg-white shadow-md rounded-lg overflow-hidden mt-4"
|
||||
>
|
||||
<div class="flex justify-center items-center w-12 bg-blue-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 fill-current text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="-mx-3 py-2 px-4">
|
||||
<div class="mx-3">
|
||||
<span class="text-blue-500 font-semibold pr-6">{{notification.title}}</span>
|
||||
<p class="text-gray-600 text-sm">T{{notification.text}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="notification.type==='error'"
|
||||
class="flex max-w-sm w-full mx-auto bg-white shadow-md rounded-lg overflow-hidden mt-4"
|
||||
>
|
||||
<div class="flex justify-center items-center w-12 bg-red-500">
|
||||
<svg
|
||||
class="h-6 w-6 fill-current text-white"
|
||||
viewBox="0 0 40 40"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M20 3.33331C10.8 3.33331 3.33337 10.8 3.33337 20C3.33337 29.2 10.8 36.6666 20 36.6666C29.2 36.6666 36.6667 29.2 36.6667 20C36.6667 10.8 29.2 3.33331 20 3.33331ZM21.6667 28.3333H18.3334V25H21.6667V28.3333ZM21.6667 21.6666H18.3334V11.6666H21.6667V21.6666Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="-mx-3 py-2 px-4">
|
||||
<div class="mx-3">
|
||||
<span class="text-red-500 font-semibold pr-6">{{notification.title}}</span>
|
||||
<p class="text-gray-600 text-sm">{{notification.text}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex max-w-sm w-full mx-auto bg-white shadow-md rounded-lg overflow-hidden mt-4"
|
||||
v-if="notification.type==='warning'"
|
||||
>
|
||||
<div class="flex justify-center items-center w-12 bg-yellow-500">
|
||||
<svg
|
||||
class="h-6 w-6 fill-current text-white"
|
||||
viewBox="0 0 40 40"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M20 3.33331C10.8 3.33331 3.33337 10.8 3.33337 20C3.33337 29.2 10.8 36.6666 20 36.6666C29.2 36.6666 36.6667 29.2 36.6667 20C36.6667 10.8 29.2 3.33331 20 3.33331ZM21.6667 28.3333H18.3334V25H21.6667V28.3333ZM21.6667 21.6666H18.3334V11.6666H21.6667V21.6666Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="-mx-3 py-2 px-4">
|
||||
<div class="mx-3">
|
||||
<span class="text-yellow-500 font-semibold pr-6">{{notification.title}}</span>
|
||||
<p class="text-gray-600 text-sm">{{notification.text}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex max-w-sm w-full mx-auto bg-white shadow-md rounded-lg overflow-hidden mt-4"
|
||||
v-if="notification.type==='confirm'"
|
||||
>
|
||||
<div class="flex justify-center items-center w-12 bg-blue-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6 text-white">
|
||||
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm11.378-3.917c-.89-.777-2.366-.777-3.255 0a.75.75 0 01-.988-1.129c1.454-1.272 3.776-1.272 5.23 0 1.513 1.324 1.513 3.518 0 4.842a3.75 3.75 0 01-.837.552c-.676.328-1.028.774-1.028 1.152v.75a.75.75 0 01-1.5 0v-.75c0-1.279 1.06-2.107 1.875-2.502.182-.088.351-.199.503-.331.83-.727.83-1.857 0-2.584zM12 18a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="-mx-3 py-2 px-4">
|
||||
<div class="mx-3">
|
||||
<span class="text-blue-500 font-semibold pr-6">{{notification.title}}</span>
|
||||
<p class="text-gray-600 text-sm">{{notification.text}}</p>
|
||||
<div class="w-full flex gap-2 mt-1">
|
||||
<v-button color="blue" size="small" @click.prevent="notification.success();close(notification.id)">Yes</v-button>
|
||||
<v-button color="gray" shade="light" size="small" @click.prevent="notification.failure();close(notification.id)">No</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="close(notification.id)" class="absolute top-0 right-0 px-2 py-2 cursor-pointer">
|
||||
<svg
|
||||
class="fill-current h-6 w-6 text-gray-300 hover:text-gray-500"
|
||||
role="button"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<title>Close</title>
|
||||
<path
|
||||
d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651 3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1.2 1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1 1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1.698z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</notification>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Notifications',
|
||||
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {},
|
||||
|
||||
mounted() {
|
||||
},
|
||||
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
36
client/components/global/NotionPage.vue
Normal file
36
client/components/global/NotionPage.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<notion-renderer :block-map="blockMap"/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {NotionRenderer} from 'vue-notion'
|
||||
|
||||
export default {
|
||||
name: 'NotionPage',
|
||||
components: {NotionRenderer},
|
||||
props: {
|
||||
pageId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
async setup(props) {
|
||||
const apiUrl = useConfig().notion.worker
|
||||
const {data} = await useFetch(`${apiUrl}/page/${props.pageId}`)
|
||||
|
||||
return {
|
||||
apiUrl: useConfig().notion.worker,
|
||||
blockMap: data,
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "styles.css";
|
||||
|
||||
.notion-blue {
|
||||
@apply text-nt-blue;
|
||||
}
|
||||
</style>
|
||||
85
client/components/global/ProTag.vue
Normal file
85
client/components/global/ProTag.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="inline" v-if="shouldDisplayProTag">
|
||||
<div class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold cursor-pointer"
|
||||
@click.prevent="showPremiumModal=true"
|
||||
>
|
||||
PRO
|
||||
</div>
|
||||
<modal :show="showPremiumModal" @close="showPremiumModal=false">
|
||||
<h2 class="text-nt-blue">
|
||||
OpnForm PRO
|
||||
</h2>
|
||||
<h4 v-if="user && user.is_subscribed" class="text-center mt-5">
|
||||
We're happy to have you as a Pro customer. If you're having any issue with OpnForm, or if you have a
|
||||
feature request, please <a href="mailto:contact@opnform.com">contact us</a>.
|
||||
</h4>
|
||||
<div v-if="!user || !user.is_subscribed" class="mt-4">
|
||||
<p>
|
||||
All the features with a<span
|
||||
class="bg-nt-blue text-white px-2 text-xs uppercase inline rounded-full font-semibold mx-1"
|
||||
>
|
||||
PRO
|
||||
</span> tag are available in the Pro plan of OpnForm. <b>You can play around and try all Pro features
|
||||
within
|
||||
the form editor, but you can't use them in your real forms</b>. You can subscribe now to gain unlimited access
|
||||
to
|
||||
all our pro features!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="my-4 text-center">
|
||||
<v-button color="white" @click="showPremiumModal=false">
|
||||
Close
|
||||
</v-button>
|
||||
</div>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
import Modal from './Modal.vue'
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useWorkspacesStore } from '../../stores/workspaces';
|
||||
import PricingTable from "../pages/pricing/PricingTable.vue";
|
||||
|
||||
export default {
|
||||
name: 'ProTag',
|
||||
components: {PricingTable, Modal},
|
||||
props: {},
|
||||
|
||||
setup () {
|
||||
const authStore = useAuthStore()
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
return {
|
||||
user : computed(() => authStore.user),
|
||||
currentWorkSpace : computed(() => workspacesStore.getCurrent())
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
showPremiumModal: false,
|
||||
checkoutLoading: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
shouldDisplayProTag() {
|
||||
if (!this.$config.paid_plans_enabled) return false
|
||||
if (!this.user || !this.currentWorkSpace) return true
|
||||
return !(this.currentWorkSpace.is_pro)
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
},
|
||||
|
||||
methods: {
|
||||
openChat() {
|
||||
window.$crisp.push(['do', 'chat:show'])
|
||||
window.$crisp.push(['do', 'chat:open'])
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
185
client/components/global/ScrollShadow.vue
Normal file
185
client/components/global/ScrollShadow.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="scroll-shadow max-w-full" :class="[$style.wrap,{'w-max':!shadow.left && !shadow.right}]">
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
:class="[$style['scroll-container'],{'no-scrollbar':hideScrollbar}]"
|
||||
:style="{ width: width?width:'auto', height }"
|
||||
@scroll.passive="toggleShadow"
|
||||
>
|
||||
<slot />
|
||||
<span :class="[$style['shadow-top'], shadow.top && $style['is-active']]" :style="{
|
||||
top: shadowTopOffset+'px',
|
||||
}"
|
||||
/>
|
||||
<span :class="[$style['shadow-right'], shadow.right && $style['is-active']]" />
|
||||
<span :class="[$style['shadow-bottom'], shadow.bottom && $style['is-active']]" />
|
||||
<span :class="[$style['shadow-left'], shadow.left && $style['is-active']]" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
function newResizeObserver (callback) {
|
||||
// Skip this feature for browsers which
|
||||
// do not support ResizeObserver.
|
||||
// https://caniuse.com/#search=resizeobserver
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
|
||||
return new ResizeObserver(e => e.map(callback))
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'ScrollShadow',
|
||||
props: {
|
||||
hideScrollbar: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
shadowTopOffset: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
shadow: {
|
||||
top: false,
|
||||
right: false,
|
||||
bottom: false,
|
||||
left: false
|
||||
},
|
||||
debounceTimeout: null,
|
||||
scrollContainerObserver: null,
|
||||
wrapObserver: null
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
window.addEventListener('resize', this.calcDimensions)
|
||||
|
||||
// Check if shadows are necessary after the element is resized.
|
||||
const scrollContainerObserver = newResizeObserver(this.toggleShadow)
|
||||
if (scrollContainerObserver) {
|
||||
scrollContainerObserver.observe(this.$refs.scrollContainer)
|
||||
}
|
||||
|
||||
// Recalculate the container dimensions when the wrapper is resized.
|
||||
this.wrapObserver = newResizeObserver(this.calcDimensions)
|
||||
if (this.wrapObserver) {
|
||||
this.wrapObserver.observe(this.$el)
|
||||
}
|
||||
},
|
||||
unmounted () {
|
||||
window.removeEventListener('resize', this.calcDimensions)
|
||||
// Cleanup when the component is unmounted.
|
||||
this.wrapObserver.disconnect()
|
||||
this.scrollContainerObserver.disconnect()
|
||||
},
|
||||
methods: {
|
||||
async calcDimensions () {
|
||||
// Reset dimensions for correctly recalculating parent dimensions.
|
||||
this.width = undefined
|
||||
this.height = undefined
|
||||
await this.$nextTick()
|
||||
|
||||
this.width = `${this.$el.clientWidth}px`
|
||||
this.height = `${this.$el.clientHeight}px`
|
||||
},
|
||||
// Check if shadows are needed.
|
||||
toggleShadow () {
|
||||
const hasHorizontalScrollbar =
|
||||
this.$refs.scrollContainer.clientWidth <
|
||||
this.$refs.scrollContainer.scrollWidth
|
||||
const hasVerticalScrollbar =
|
||||
this.$refs.scrollContainer.clientHeight <
|
||||
this.$refs.scrollContainer.scrollHeight
|
||||
|
||||
const scrolledFromLeft =
|
||||
this.$refs.scrollContainer.offsetWidth +
|
||||
this.$refs.scrollContainer.scrollLeft
|
||||
const scrolledFromTop =
|
||||
this.$refs.scrollContainer.offsetHeight +
|
||||
this.$refs.scrollContainer.scrollTop
|
||||
|
||||
const scrolledToTop = this.$refs.scrollContainer.scrollTop === 0
|
||||
const scrolledToRight =
|
||||
scrolledFromLeft >= this.$refs.scrollContainer.scrollWidth
|
||||
const scrolledToBottom =
|
||||
scrolledFromTop >= this.$refs.scrollContainer.scrollHeight
|
||||
const scrolledToLeft = this.$refs.scrollContainer.scrollLeft === 0
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.shadow.top = hasVerticalScrollbar && !scrolledToTop
|
||||
this.shadow.right = hasHorizontalScrollbar && !scrolledToRight
|
||||
this.shadow.bottom = hasVerticalScrollbar && !scrolledToBottom
|
||||
this.shadow.left = hasHorizontalScrollbar && !scrolledToLeft
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrap {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.shadow-top,
|
||||
.shadow-right,
|
||||
.shadow-bottom,
|
||||
.shadow-left {
|
||||
position: absolute;
|
||||
border-radius: 6em;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shadow-top,
|
||||
.shadow-bottom {
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 1em;
|
||||
border-top-right-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
background-image: linear-gradient(rgba(#555, 0.1) 0%, rgba(#FFF, 0) 100%);
|
||||
}
|
||||
|
||||
.shadow-top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.shadow-bottom {
|
||||
bottom: 0;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.shadow-right,
|
||||
.shadow-left {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1em;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
background-image: linear-gradient(90deg, rgba(#555, 0.1) 0%, rgba(#FFF, 0) 100%);
|
||||
}
|
||||
|
||||
.shadow-right {
|
||||
right: 0;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.shadow-left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.is-active {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
44
client/components/global/Steps.vue
Normal file
44
client/components/global/Steps.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="py-4" :class="{'border-b-2':borderBottom}">
|
||||
<div class="uppercase tracking-wide text-xs font-bold dark:text-gray-400 text-gray-500 mb-1 leading-tight">
|
||||
Step: {{ Math.min(current + 1, steps.length) }} of {{ steps.length }}
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="text-lg font-bold dark:text-gray-300 text-gray-700 leading-tight">
|
||||
{{ steps[current] ? steps[current] : 'Complete!' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center md:w-64">
|
||||
<div class="w-full bg-gray-100 dark:bg-gray-700 rounded-full mr-2">
|
||||
<div class="rounded-full bg-nt-blue text-xs leading-none h-2 text-center text-white transition-all"
|
||||
:style="{'width': parseInt(current / steps.length * 100) +'%', 'min-width': '8px'}"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs w-10 text-gray-600 dark:text-gray-400" v-text="parseInt(current / steps.length * 100) +'%'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Steps',
|
||||
|
||||
props: {
|
||||
steps: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
borderBottom: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
current: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
185
client/components/global/VButton.vue
Normal file
185
client/components/global/VButton.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<a v-if="href" :class="btnClasses" :href="href" :target="target">
|
||||
<slot />
|
||||
</a>
|
||||
<button v-else-if="!to" :type="nativeType" :disabled="loading?true:null" :class="btnClasses"
|
||||
@click="onClick($event)"
|
||||
>
|
||||
<template v-if="!loading">
|
||||
<span class="no-underline mx-auto">
|
||||
<slot />
|
||||
</span>
|
||||
<svg v-if="arrow" class="ml-2 w-3 h-3 inline" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 11L11 1M11 1H1M11 1V11" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<loader v-else class="h-6 w-6 mx-auto" :class="`text-${colorShades['text']}`" />
|
||||
</button>
|
||||
<router-link v-else :class="btnClasses" :to="to" :target="target">
|
||||
<span class="no-underline mx-auto">
|
||||
<slot />
|
||||
</span>
|
||||
<svg v-if="arrow" class="ml-2 w-3 h-3 inline" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 11L11 1M11 1H1M11 1V11" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'VButton',
|
||||
|
||||
props: {
|
||||
color: {
|
||||
type: String,
|
||||
default: 'blue'
|
||||
},
|
||||
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium'
|
||||
},
|
||||
|
||||
nativeType: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
arrow: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
to: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
|
||||
href: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
|
||||
target: {
|
||||
type: String,
|
||||
default: '_self'
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
btnClasses () {
|
||||
const sizes = this.sizes
|
||||
const colorShades = this.colorShades
|
||||
return `v-btn ${sizes['p-y']} ${sizes['p-x']}
|
||||
${colorShades?.main} ${colorShades?.hover} ${colorShades?.ring} ${colorShades['ring-offset']}
|
||||
${colorShades?.text} transition ease-in duration-200 text-center text-${sizes?.font} font-medium focus:outline-none focus:ring-2
|
||||
focus:ring-offset-2 rounded-lg flex items-center hover:no-underline`
|
||||
},
|
||||
colorShades () {
|
||||
if (this.color === 'blue') {
|
||||
return {
|
||||
main: 'bg-blue-600',
|
||||
hover: 'hover:bg-blue-700',
|
||||
ring: 'focus:ring-blue-500',
|
||||
'ring-offset': 'focus:ring-offset-blue-200',
|
||||
text: 'text-white'
|
||||
}
|
||||
} else if (this.color === 'outline-blue') {
|
||||
return {
|
||||
main: 'bg-transparent border border-blue-600',
|
||||
hover: 'hover:bg-blue-600',
|
||||
ring: 'focus:ring-blue-500',
|
||||
'ring-offset': 'focus:ring-offset-blue-200',
|
||||
text: 'text-blue-600 hover:text-white'
|
||||
}
|
||||
} else if (this.color === 'outline-gray') {
|
||||
return {
|
||||
main: 'bg-transparent border border-gray-300',
|
||||
hover: 'hover:bg-gray-500',
|
||||
ring: 'focus:ring-gray-500',
|
||||
'ring-offset': 'focus:ring-offset-gray-200',
|
||||
text: 'text-gray-500 hover:text-white'
|
||||
}
|
||||
} else if (this.color === 'red') {
|
||||
return {
|
||||
main: 'bg-red-600',
|
||||
hover: 'hover:bg-red-700',
|
||||
ring: 'focus:ring-red-500',
|
||||
'ring-offset': 'focus:ring-offset-red-200',
|
||||
text: 'text-white'
|
||||
}
|
||||
} else if (this.color === 'gray') {
|
||||
return {
|
||||
main: 'bg-gray-600',
|
||||
hover: 'hover:bg-gray-700',
|
||||
ring: 'focus:ring-gray-500',
|
||||
'ring-offset': 'focus:ring-offset-gray-200',
|
||||
text: 'text-white'
|
||||
}
|
||||
} else if (this.color === 'light-gray') {
|
||||
return {
|
||||
main: 'bg-gray-50 border border-gray-300',
|
||||
hover: 'hover:bg-gray-100',
|
||||
ring: 'focus:ring-gray-500',
|
||||
'ring-offset': 'focus:ring-offset-gray-300',
|
||||
text: 'text-gray-700'
|
||||
}
|
||||
} else if (this.color === 'green') {
|
||||
return {
|
||||
main: 'bg-green-600',
|
||||
hover: 'hover:bg-green-700',
|
||||
ring: 'focus:ring-green-500',
|
||||
'ring-offset': 'focus:ring-offset-green-200',
|
||||
text: 'text-white'
|
||||
}
|
||||
} else if (this.color === 'yellow') {
|
||||
return {
|
||||
main: 'bg-yellow-600',
|
||||
hover: 'hover:bg-yellow-700',
|
||||
ring: 'focus:ring-yellow-500',
|
||||
'ring-offset': 'focus:ring-offset-yellow-200',
|
||||
text: 'text-white'
|
||||
}
|
||||
} else if (this.color === 'white') {
|
||||
return {
|
||||
main: 'bg-transparent border border-gray-300',
|
||||
hover: 'hover:bg-gray-200',
|
||||
ring: 'focus:ring-white-500',
|
||||
'ring-offset': 'focus:ring-offset-white-200',
|
||||
text: 'text-gray-700'
|
||||
}
|
||||
}
|
||||
console.error('Unknown color')
|
||||
},
|
||||
sizes () {
|
||||
if (this.size === 'small') {
|
||||
return {
|
||||
font: 'sm',
|
||||
'p-y': 'py-1',
|
||||
'p-x': 'px-2'
|
||||
}
|
||||
}
|
||||
return {
|
||||
font: 'base',
|
||||
'p-y': 'py-2',
|
||||
'p-x': 'px-4'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onClick (event) {
|
||||
this.$emit('click', event)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
104
client/components/global/WorkspaceDropdown.vue
Normal file
104
client/components/global/WorkspaceDropdown.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<dropdown v-if="user && workspaces && workspaces.length > 1" ref="dropdown"
|
||||
dropdown-class="origin-top-left absolute left-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||||
dusk="workspace-dropdown"
|
||||
>
|
||||
<template v-if="workspace" #trigger="{toggle}">
|
||||
<div class="flex items-center cursor group" role="button" @click.stop="toggle()">
|
||||
<div class="rounded-full h-8 8">
|
||||
<img v-if="isUrl(workspace.icon)"
|
||||
:src="workspace.icon"
|
||||
:alt="workspace.name + ' icon'" class="flex-shrink-0 h-8 w-8 rounded-full shadow"
|
||||
>
|
||||
<div v-else class="rounded-full pt-2 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow"
|
||||
v-text="workspace.icon"
|
||||
/>
|
||||
</div>
|
||||
<p class="hidden group-hover:underline lg:block max-w-10 truncate ml-2 text-gray-800 dark:text-gray-200">
|
||||
{{ workspace.name }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-for="worksp in workspaces" :key="worksp.id">
|
||||
<a href="#"
|
||||
class="px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
:class="{'bg-blue-100 dark:bg-blue-900':workspace.id === worksp.id}" @click.prevent="switchWorkspace(worksp)"
|
||||
>
|
||||
<div class="rounded-full h-8 w-8 flex-shrink-0" role="button">
|
||||
<img v-if="isUrl(worksp.icon)"
|
||||
:src="worksp.icon"
|
||||
:alt="worksp.name + ' icon'" class="flex-shrink-0 h-8 w-8 rounded-full shadow"
|
||||
>
|
||||
<div v-else class="rounded-full flex-shrink-0 pt-1 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow"
|
||||
v-text="worksp.icon"
|
||||
/>
|
||||
</div>
|
||||
<p class="ml-4 truncate">{{ worksp.name }}</p>
|
||||
</a>
|
||||
</template>
|
||||
</dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '../../stores/auth.js'
|
||||
import { useFormsStore } from '../../stores/forms.js'
|
||||
import { useWorkspacesStore } from '../../stores/workspaces.js'
|
||||
import Dropdown from '~/components/global/Dropdown.vue'
|
||||
|
||||
export default {
|
||||
|
||||
name: 'WorkspaceDropdown',
|
||||
components: {
|
||||
Dropdown
|
||||
},
|
||||
|
||||
setup () {
|
||||
const authStore = useAuthStore()
|
||||
const formsStore = useFormsStore()
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
return {
|
||||
formsStore,
|
||||
workspacesStore,
|
||||
user: computed(() => authStore.user),
|
||||
workspaces: computed(() => workspacesStore.content),
|
||||
loading: computed(() => workspacesStore.loading)
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
workspace () {
|
||||
return this.workspacesStore.getCurrent()
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
},
|
||||
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {
|
||||
switchWorkspace (workspace) {
|
||||
this.workspacesStore.setCurrentId(workspace.id)
|
||||
this.$refs.dropdown.close()
|
||||
if (this.$route.name !== 'home') {
|
||||
this.$router.push({ name: 'home' })
|
||||
}
|
||||
this.formsStore.load(workspace.id)
|
||||
},
|
||||
isUrl (str) {
|
||||
try {
|
||||
new URL(str)
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
59
client/components/global/transitions/Collapsible.vue
Normal file
59
client/components/global/transitions/Collapsible.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<transition @leave="(el,done) => motions.collapsible.leave(done)">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
key="dropdown"
|
||||
v-motion="'collapsible'"
|
||||
v-on-click-outside.bubble="close"
|
||||
:variants="motionCollapse"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { vOnClickOutside } from '@vueuse/components'
|
||||
import { useMotions } from '@vueuse/motion'
|
||||
|
||||
export default {
|
||||
name: 'Collapsible',
|
||||
directives: {
|
||||
onClickOutside: vOnClickOutside
|
||||
},
|
||||
props: {
|
||||
modelValue: { type: Boolean },
|
||||
closeOnClickAway: { type: Boolean, default: true }
|
||||
},
|
||||
setup () {
|
||||
return {
|
||||
motions: useMotions()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
motionCollapse () {
|
||||
return {
|
||||
enter: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
height: 'auto',
|
||||
transition: { duration: 150, ease: 'easeOut' }
|
||||
},
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: -10,
|
||||
height: 0,
|
||||
transition: { duration: 75, ease: 'easeIn' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
close () {
|
||||
if (this.closeOnClickAway) {
|
||||
this.$emit('update:modelValue', false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
19
client/components/global/transitions/VTransition.vue
Normal file
19
client/components/global/transitions/VTransition.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<transition v-if="name=='slideInUp'"
|
||||
enter-active-class="linear duration-300 overflow-hidden"
|
||||
enter-from-class="max-h-0"
|
||||
enter-to-class="max-h-screen"
|
||||
leave-active-class="linear duration-300 overflow-hidden"
|
||||
leave-from-class="max-h-screen"
|
||||
leave-to-class="max-h-0"
|
||||
>
|
||||
<slot />
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'VTransition',
|
||||
props: { name: { default: 'slideInUp' } }
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user