Work in progress

This commit is contained in:
Julien Nahum
2023-12-09 15:47:03 +01:00
parent f970557b76
commit 1f853e8178
315 changed files with 34058 additions and 25 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>