package list from viandwi24
This commit is contained in:
7
components/ActionSheet/Body.vue
Normal file
7
components/ActionSheet/Body.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-gray-100/[0.8] dark:bg-slate-800/[0.8] backdrop-blur supports-backdrop-blur:bg-white/60 p-4 rounded overflow-y-auto"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
components/ActionSheet/Header.vue
Normal file
14
components/ActionSheet/Header.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-xs font-bold text-center mb-2">
|
||||
<slot>{{ text }}</slot>
|
||||
</div>
|
||||
</template>
|
||||
3
components/ActionSheet/Overlay.vue
Normal file
3
components/ActionSheet/Overlay.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div class="fixed bg-black opacity-70 z-50 top-0 left-0 w-screen h-screen" />
|
||||
</template>
|
||||
64
components/ActionSheet/index.vue
Normal file
64
components/ActionSheet/index.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" setup>
|
||||
import { TransitionRoot, TransitionChild } from '@headlessui/vue'
|
||||
|
||||
// micro compiler
|
||||
const emit = defineEmits(['onClose'])
|
||||
|
||||
// state
|
||||
const show = ref(false)
|
||||
|
||||
// methods
|
||||
const close = () => {
|
||||
show.value = false
|
||||
setTimeout(() => emit('onClose'), 100)
|
||||
}
|
||||
|
||||
// lifecycle
|
||||
onMounted(() => {
|
||||
setTimeout(() => (show.value = true), 100)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<TransitionRoot :show="show" appear>
|
||||
<div>
|
||||
<ActionSheetOverlay @click="close" />
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="duration-300 ease-out"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="duration-300 ease-in"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div
|
||||
class="fixed bottom-0 w-screen z-50 flex"
|
||||
style="max-height: 66.666667%"
|
||||
>
|
||||
<div
|
||||
class="relative max-w-8xl px-4 pb-4 w-full mx-auto flex flex-col flex-1 space-y-1 overflow-y-auto justify-end"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</TransitionRoot>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.slide-fade-from-bottom-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
.slide-fade-from-bottom-leave-active {
|
||||
transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1);
|
||||
}
|
||||
.slide-fade-from-bottom-enter-from,
|
||||
.slide-fade-from-bottom-leave-to {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
107
components/Alert.vue
Normal file
107
components/Alert.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script lang="ts" setup>
|
||||
import { TransitionRoot, TransitionChild } from '@headlessui/vue'
|
||||
export type IStyles = 'primary' | 'success' | 'warning' | 'danger'
|
||||
|
||||
// props
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
},
|
||||
})
|
||||
|
||||
// styles
|
||||
const styles = reactive<{
|
||||
[key: string]: string
|
||||
}>({
|
||||
primary: '',
|
||||
success:
|
||||
'dark:from-green-500/50 via-gray-200 to-gray-200 dark:via-slate-800 dark:to-slate-800',
|
||||
warning:
|
||||
'dark:from-yellow-500/50 via-gray-200 to-gray-200 dark:via-slate-800 dark:to-slate-800',
|
||||
danger:
|
||||
'dark:from-red-500/50 via-gray-200 to-gray-200 dark:via-slate-800 dark:to-slate-800',
|
||||
})
|
||||
const textStyles = reactive<{
|
||||
[key: string]: string
|
||||
}>({
|
||||
primary: 'text-white',
|
||||
success: 'text-green-500',
|
||||
warning: 'text-orange-500',
|
||||
danger: 'text-red-500',
|
||||
})
|
||||
|
||||
// selected
|
||||
const isDestroyed = ref<Boolean>(false)
|
||||
const selectedType = computed<IStyles>((): IStyles => {
|
||||
if (['primary', 'success', 'warning', 'danger'].includes(props.type))
|
||||
return props.type as IStyles
|
||||
return 'primary'
|
||||
})
|
||||
const selectedStyle = computed(() => styles[selectedType.value])
|
||||
const selectedTextStyle = computed(() => textStyles[selectedType.value])
|
||||
|
||||
// actions
|
||||
const close = () => {
|
||||
isDestroyed.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TransitionRoot :show="!isDestroyed" appear>
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="duration-300 ease-out"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="duration-300 ease-in"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div
|
||||
:class="`bg-gray-200 dark:bg-slate-800 bg-gradient-to-r shadow-white/50 dark:shadow-slate-900/50 px-6 py-6 rounded-md shadow-lg flex space-x-6 ${selectedStyle}`"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<slot name="icon">
|
||||
<IconMdi:checkCircle
|
||||
v-if="selectedType === 'success'"
|
||||
:class="`text-2xl ${selectedTextStyle}`"
|
||||
/>
|
||||
<icon-clarity:times-circle-solid
|
||||
v-if="selectedType === 'danger'"
|
||||
:class="`text-2xl ${selectedTextStyle}`"
|
||||
/>
|
||||
<icon-bi:exclamation-circle-fill
|
||||
v-if="selectedType === 'warning'"
|
||||
:class="`text-2xl ${selectedTextStyle}`"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div :class="`font-bold text-lg mb-0.5 ${selectedTextStyle}`">
|
||||
<slot name="title">{{ props.title }}</slot>
|
||||
</div>
|
||||
<div class="text-gray-700 dark:text-gray-100">
|
||||
<slot name="title">{{ props.text }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="text-slate-600 hover:text-red-500 dark:text-gray-400 font-bold"
|
||||
@click="close"
|
||||
>
|
||||
<icon-clarity:times-line />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
39
components/Anchor.vue
Normal file
39
components/Anchor.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
// micro compiler
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
to: {
|
||||
type: [String, Object],
|
||||
default: undefined,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
// state
|
||||
const href = toRef(props, 'href')
|
||||
const to = toRef(props, 'to')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
v-if="to"
|
||||
tag="a"
|
||||
:to="to"
|
||||
:class="`transition-colors duration-300 dark:hover:text-white hover:text-gray-900 hover:underline`"
|
||||
>
|
||||
<slot>{{ text }}</slot>
|
||||
</NuxtLink>
|
||||
<a
|
||||
v-else
|
||||
:class="`transition-colors duration-300 dark:hover:text-white hover:text-gray-900 hover:underline`"
|
||||
:href="href"
|
||||
>
|
||||
<slot>{{ text }}</slot>
|
||||
</a>
|
||||
</template>
|
||||
171
components/Builder/Navbar.vue
Normal file
171
components/Builder/Navbar.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<script lang="ts" setup>
|
||||
import { AppConfigInput } from '@nuxt/schema'
|
||||
|
||||
// state
|
||||
const app = useAppConfig() as AppConfigInput
|
||||
const navbar = ref(null)
|
||||
const showDrawer = useState<boolean>('navbar.showDrawer', () => false)
|
||||
const showOptions = useState<boolean>('navbar.showOptions', () => false)
|
||||
|
||||
// lifecycle
|
||||
let timer: NodeJS.Timer
|
||||
onMounted(() => {
|
||||
if (!navbar.value) return
|
||||
|
||||
// scroll
|
||||
const { onScroll } = useSticky(navbar.value, 0)
|
||||
setTimeout(() => onScroll(), 50)
|
||||
|
||||
// on show on mobile
|
||||
setInterval(() => {
|
||||
// must in mobile
|
||||
const minW = 1024
|
||||
if (window.innerWidth < minW) {
|
||||
updateDrawerOptions()
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
// methods
|
||||
const updateDrawerOptions = () => {
|
||||
// drawer
|
||||
if (showDrawer.value || showOptions.value) {
|
||||
document.body.classList.add('overflow-hidden')
|
||||
} else {
|
||||
document.body.classList.remove('overflow-hidden')
|
||||
}
|
||||
}
|
||||
const toggleDrawer = () => (showDrawer.value = !showDrawer.value)
|
||||
const toggleOptions = (show?: boolean) => {
|
||||
if (show) {
|
||||
showOptions.value = show
|
||||
} else {
|
||||
showOptions.value = !showOptions.value
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="navbar"
|
||||
class="backdrop-filter backdrop-blur-md top-0 z-40 w-full flex-none transition-colors duration-300 lg:z-50 border-b border-gray-900/10 dark:border-gray-50/[0.2] bg-white/[0.5] dark:bg-slate-900/[0.5]"
|
||||
>
|
||||
<div id="navbar-banner" class="banner">
|
||||
<slot name="banner" />
|
||||
</div>
|
||||
<div class="max-w-8xl w-full mx-auto">
|
||||
<div class="py-3 lg:px-8 mx-4 lg:mx-0">
|
||||
<div class="relative flex items-center">
|
||||
<!-- drawer:toggle -->
|
||||
<div
|
||||
v-if="$slots['drawer']"
|
||||
class="lg:hidden flex items-center self-center justify-center mr-2"
|
||||
>
|
||||
<button
|
||||
class="flex items-center focus:outline-none"
|
||||
aria-label="Toggle Drawer Menu"
|
||||
@click="toggleDrawer()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center text-gray-600 dark:text-gray-300 text-lg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IconUil:bars v-if="!showDrawer" />
|
||||
<IconUil:times v-else />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- title -->
|
||||
<slot name="title">
|
||||
<NuxtLink
|
||||
tag="a"
|
||||
class="mr-3 flex-none overflow-hidden md:w-auto text-md font-bold text-gray-900 dark:text-gray-200"
|
||||
:to="{ name: 'index' }"
|
||||
>
|
||||
<span class="sr-only">home</span>
|
||||
<span class="flex items-center">
|
||||
<IconSimpleIcons:nuxtdotjs
|
||||
class="inline-block mr-2 text-lg text-primary-500"
|
||||
/>
|
||||
{{ app.name }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</slot>
|
||||
<!-- menu -->
|
||||
<slot name="menu" />
|
||||
<!-- options:toggle -->
|
||||
<div
|
||||
v-if="$slots['options']"
|
||||
class="flex-1 flex justify-end lg:hidden"
|
||||
>
|
||||
<button
|
||||
class="flex items-center focus:outline-none"
|
||||
aria-label="Toggle Options Menu"
|
||||
@click="toggleOptions()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center text-gray-600 dark:text-gray-300 text-sm"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<icon-fa-solid:ellipsis-v />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<Teleport to="#app-after">
|
||||
<!-- drawer -->
|
||||
<Transition name="slide-fade-from-up" mode="out-in">
|
||||
<div
|
||||
v-if="showDrawer && $slots['drawer']"
|
||||
class="fixed lg:hidden bg-gray-100 dark:bg-slate-800 pt-12 top-0 left-0 w-screen h-screen z-30 flex flex-col"
|
||||
>
|
||||
<div class="flex-1 flex flex-col relative overflow-y-auto">
|
||||
<slot name="drawer" :toggle-drawer="toggleDrawer" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- options -->
|
||||
<div v-if="showOptions && $slots['options']">
|
||||
<slot
|
||||
name="options"
|
||||
:toggle-options="toggleOptions"
|
||||
:show-options="showOptions"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.slide-fade-from-up-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
.slide-fade-from-up-leave-active {
|
||||
transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1);
|
||||
}
|
||||
.slide-fade-from-up-enter-from,
|
||||
.slide-fade-from-up-leave-to {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
a.router-link-active {
|
||||
font-weight: bold;
|
||||
}
|
||||
a.router-link-exact-active {
|
||||
color: theme('colors.slate.900');
|
||||
}
|
||||
html.dark {
|
||||
a.router-link-exact-active {
|
||||
color: theme('colors.white');
|
||||
}
|
||||
}
|
||||
</style>
|
||||
86
components/Button.vue
Normal file
86
components/Button.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
},
|
||||
to: {
|
||||
type: [String, Object],
|
||||
default: undefined,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
// state:styles
|
||||
const defaultStyle = `
|
||||
cursor-pointer
|
||||
border transition-color duration-300
|
||||
focus:outline-none focus:ring-1 focus:ring-offset-1 focus:dark:ring-offset-gray-50 focus:dark:ring-gray-400 focus:ring-gray-600/[0.6] focus:ring-offset-gray-800/[0.6]
|
||||
flex items-center justify-center font-semibold
|
||||
`
|
||||
const styles = reactive<{
|
||||
[key: string]: string
|
||||
}>({
|
||||
none: '',
|
||||
primary: 'text-white bg-primary-500 hover:bg-primary-400 border-primary-500',
|
||||
secondary:
|
||||
'text-slate-800 bg-gray-200 border-gray-200 hover:bg-gray-300 dark:text-white dark:border-slate-800 dark:bg-slate-800 dark:hover:bg-slate-700',
|
||||
opposite:
|
||||
'text-white bg-gray-800 hover:bg-white hover:text-gray-800 hover:border-gray-900 dark:text-gray-800 dark:bg-gray-100 dark:hover:bg-gray-800 dark:hover:text-gray-100 dark:border-white',
|
||||
})
|
||||
const sizes = reactive<{
|
||||
[key: string]: string
|
||||
}>({
|
||||
lg: 'h-13 px-8 text-lg rounded-lg',
|
||||
md: 'h-10 px-6 text-base rounded',
|
||||
sm: 'h-9 px-4 text-sm rounded',
|
||||
xs: 'h-6 px-3 text-xs rounded',
|
||||
})
|
||||
|
||||
// state
|
||||
const selectedStyle = computed(() =>
|
||||
props.type in styles ? styles[props.type] : styles.primary
|
||||
)
|
||||
const selectedSize = computed(() => sizes[props.size] || sizes.lg)
|
||||
|
||||
// methods
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const router = useRouter()
|
||||
if (props.to) {
|
||||
router.push(props.to)
|
||||
}
|
||||
if (!props.href) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
v-if="to"
|
||||
tag="a"
|
||||
:to="to"
|
||||
:class="`${defaultStyle} ${selectedStyle} ${selectedSize}`"
|
||||
>
|
||||
<slot>{{ text }}</slot>
|
||||
</NuxtLink>
|
||||
<a
|
||||
v-else
|
||||
:class="`${defaultStyle} ${selectedStyle} ${selectedSize}`"
|
||||
:href="href"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot>{{ text }}</slot>
|
||||
</a>
|
||||
</template>
|
||||
5
components/Card/Content.vue
Normal file
5
components/Card/Content.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="card-content px-6 py-6 relative">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
7
components/Card/Footer.vue
Normal file
7
components/Card/Footer.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="card-footer px-6 py-2 text-sm bg-white dark:bg-slate-800 border-t border-gray-900/10 dark:border-gray-50/[0.2]"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
components/Card/Title.vue
Normal file
14
components/Card/Title.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-xl font-semibold mb-2">
|
||||
<slot>{{ text }}</slot>
|
||||
</div>
|
||||
</template>
|
||||
20
components/Card/index.vue
Normal file
20
components/Card/index.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="card duration-300 transition-colors w-full relative rounded overflow-hidden bg-white dark:bg-slate-900 border border-gray-900/10 dark:border-gray-50/[0.2]"
|
||||
>
|
||||
<div
|
||||
v-if="disabled"
|
||||
class="absolute z-10 top-0 left-0 w-full h-full bg-slate-800/[0.75] cursor-not-allowed"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
65
components/Dashboard/Navbar.vue
Normal file
65
components/Dashboard/Navbar.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<BuilderNavbar>
|
||||
<template #menu>
|
||||
<div class="relative hidden lg:flex items-center ml-auto">
|
||||
<div class="flex items-center justify-center">
|
||||
<img
|
||||
class="w-6 h-6 rounded-full"
|
||||
src="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=634&q=80"
|
||||
alt="Avatar of Jonathan Reinink"
|
||||
/>
|
||||
<span class="ml-2 text-sm font-semibold">Alfian</span>
|
||||
<IconUil:angle-down />
|
||||
</div>
|
||||
<div
|
||||
class="flex space-x-4 border-l ml-6 pl-6 border-gray-900/10 dark:border-gray-50/[0.2]"
|
||||
>
|
||||
<LanguageSwitcher />
|
||||
<ThemeSwitcher />
|
||||
<Anchor
|
||||
class="hover:no-underline hover:text-slate-900 hover:dark:text-white text-lg flex self-center items-center"
|
||||
href="boilarplate"
|
||||
title="Github"
|
||||
>
|
||||
<IconMdi:github-face />
|
||||
</Anchor>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #options="{ toggleOptions }">
|
||||
<ActionSheet @on-close="toggleOptions(false)">
|
||||
<ActionSheetBody>
|
||||
<ActionSheetHeader text="Menu" />
|
||||
<div class="mt-6 text-sm font-bold capitalize">
|
||||
{{ $t('components.theme_switcher.change_theme') }}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<ThemeSwitcher type="select-box" />
|
||||
</div>
|
||||
<div class="mt-6 text-sm font-bold capitalize">
|
||||
{{ $t('components.language_switcher.change_language') }}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<LanguageSwitcher type="select-box" />
|
||||
</div>
|
||||
</ActionSheetBody>
|
||||
<Button
|
||||
type="secondary"
|
||||
title="Github"
|
||||
href="boilarplate"
|
||||
>
|
||||
<IconMdi:github-face />
|
||||
<span class="ml-1">Github</span>
|
||||
</Button>
|
||||
<Button
|
||||
text="Close"
|
||||
type="secondary"
|
||||
@click.prevent="toggleOptions(false)"
|
||||
/>
|
||||
</ActionSheet>
|
||||
</template>
|
||||
<template #drawer>
|
||||
<slot name="drawer" />
|
||||
</template>
|
||||
</BuilderNavbar>
|
||||
</template>
|
||||
64
components/Dashboard/Sidebar.vue
Normal file
64
components/Dashboard/Sidebar.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'normal',
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const sidebar = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
// const { onScroll } = useSticky(sidebar.value, -1000)
|
||||
// setTimeout(() => onScroll(), 50)
|
||||
})
|
||||
|
||||
return {
|
||||
sidebar,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="sidebar"
|
||||
:class="{
|
||||
'fixed top-0 hidden pt-12 lg:flex lg:w-60 xl:w-80 h-screen':
|
||||
mode === 'normal',
|
||||
'relative flex-1 flex flex-col w-full': mode === 'mobile',
|
||||
}"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto pl-4 lg:pl-0 pr-4 py-4">
|
||||
<ul>
|
||||
<li v-for="i in 29" :key="i">
|
||||
<Anchor
|
||||
:to="{ name: 'dashboard' }"
|
||||
class="group flex items-center mb-4 hover:no-underline"
|
||||
>
|
||||
<div
|
||||
class="flex items-center mr-4 px-2 py-2 rounded-md ring-1 ring-slate-900/5 shadow-sm group-hover:shadow group-hover:ring-slate-900/10 dark:ring-0 dark:shadow-none dark:group-hover:shadow-none dark:group-hover:highlight-white/10 group-hover:shadow-sky-200 dark:highlight-white/10"
|
||||
:class="{
|
||||
'text-white dark:text-white group-hover:bg-sky-500 bg-sky-500':
|
||||
i === 1,
|
||||
'text-slate-500 dark:text-gray-100 group-hover:bg-gray-200 bg-gray-100 dark:group-hover:bg-slate-600 dark:bg-slate-700':
|
||||
i !== 1,
|
||||
}"
|
||||
>
|
||||
<IconUil:apps class="text-xs" />
|
||||
</div>
|
||||
<span
|
||||
class="text-sm font-semibold capitalize"
|
||||
:class="{
|
||||
'font-extrabold text-sky-500 dark:text-sky-400': i === 1,
|
||||
}"
|
||||
>
|
||||
{{ $t('pages.dashboard.index.nav') }}
|
||||
</span>
|
||||
</Anchor>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
9
components/DocumentDrivenNotFound.vue
Normal file
9
components/DocumentDrivenNotFound.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<LayoutPage>
|
||||
<Error :code="404" wrap />
|
||||
</LayoutPage>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import LayoutPage from '~/layouts/page.vue'
|
||||
</script>
|
||||
46
components/Error.vue
Normal file
46
components/Error.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts" setup>
|
||||
// components
|
||||
const PageWrapper = resolveComponent('PageWrapper')
|
||||
|
||||
// compiler macro
|
||||
const props = defineProps({
|
||||
code: {
|
||||
type: Number,
|
||||
default: 400,
|
||||
},
|
||||
wrap: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// computed
|
||||
const errorsMap: {
|
||||
[key: string]: string
|
||||
} = {
|
||||
'400': 'Bad Request',
|
||||
'401': 'Unauthorized',
|
||||
'403': 'Forbidden',
|
||||
'404': 'Not Found',
|
||||
}
|
||||
const error = computed(() => {
|
||||
const { code } = props
|
||||
return {
|
||||
code,
|
||||
message: errorsMap[code.toString()] || 'Unknown Error',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="props.wrap ? PageWrapper : 'div'"
|
||||
:class="props.wrap ? 'flex flex-col items-center justify-center' : ''"
|
||||
>
|
||||
<h1 class="text-center mb-6 leading-3">
|
||||
<span class="font-bold text-8xl block">{{ error.code }}</span>
|
||||
<span class="block italic">{{ error.message }}</span>
|
||||
</h1>
|
||||
<Button text="Home" to="/" size="sm" />
|
||||
</component>
|
||||
</template>
|
||||
76
components/Form/Switch.vue
Normal file
76
components/Form/Switch.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts" setup>
|
||||
// compiler macro
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
on: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// random
|
||||
const randomId = () =>
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
|
||||
// state
|
||||
const id = ref(props.id || randomId())
|
||||
const input = ref<HTMLInputElement>()
|
||||
|
||||
// funcs
|
||||
const checked = useSyncProps<boolean>(props, 'modelValue', emit)
|
||||
const onInputChange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
checked.value = target.checked
|
||||
emit('update:modelValue', target.checked)
|
||||
}
|
||||
|
||||
// lifecycle
|
||||
onMounted(() => {
|
||||
if (props.on) {
|
||||
checked.value = true
|
||||
emit('update:modelValue', true)
|
||||
if (input.value) input.value.checked = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label :for="id" class="flex cursor-pointer">
|
||||
<label
|
||||
:for="id"
|
||||
class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in"
|
||||
>
|
||||
<input
|
||||
:id="id"
|
||||
ref="input"
|
||||
type="checkbox"
|
||||
class="switch-checkbox absolute block w-6 h-6 rounded-full bg-white dark:bg-slate-900 border-2 border-slate-300 dark:border-slate-600 appearance-none cursor-pointer"
|
||||
:checked="checked"
|
||||
@change="onInputChange"
|
||||
/>
|
||||
<label
|
||||
:for="id"
|
||||
class="switch-label block overflow-hidden h-6 rounded-full bg-gray-200 dark:bg-slate-700 cursor-pointer border border-slate-300 dark:border-slate-500"
|
||||
/>
|
||||
</label>
|
||||
<slot />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.switch-checkbox:checked {
|
||||
right: 0;
|
||||
}
|
||||
.switch-checkbox:checked + .switch-label {
|
||||
@apply bg-primary-500;
|
||||
}
|
||||
</style>
|
||||
97
components/Form/TextInput.vue
Normal file
97
components/Form/TextInput.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script lang="ts" setup>
|
||||
// compiler macro
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const slots = useSlots()
|
||||
|
||||
// list styles
|
||||
const paddingStyles = reactive<{
|
||||
[key: string]: string
|
||||
}>({
|
||||
xs: 'px-1 py-0.5',
|
||||
sm: 'px-2 py-1.5',
|
||||
md: 'px-4 py-2',
|
||||
lg: 'px-5 py-3',
|
||||
})
|
||||
const fontSizeStyles = reactive<{
|
||||
[key: string]: string
|
||||
}>({
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg',
|
||||
})
|
||||
|
||||
// states
|
||||
const modelValue = useSyncProps<string>(props, 'modelValue', emit)
|
||||
const havePreEl = computed(
|
||||
() =>
|
||||
typeof slots.prefix !== 'undefined' ||
|
||||
typeof slots['prefix-disabled'] !== 'undefined'
|
||||
)
|
||||
const haveSuEl = computed(() => typeof slots.suffix !== 'undefined')
|
||||
const selectedBorderStyle = computed(
|
||||
() => 'border-gray-900/10 dark:border-gray-50/[0.2]'
|
||||
)
|
||||
const selectedOnHoverBorderStyle = computed(
|
||||
() => 'dark:focus:border-white focus:border-gray-900'
|
||||
)
|
||||
const selectedPaddingStyle = computed(
|
||||
() => paddingStyles[props.size] || paddingStyles.md
|
||||
)
|
||||
const selectedFontSizeStyle = computed(
|
||||
() => fontSizeStyles[props.size] || fontSizeStyles.md
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="`text-input-container relative flex`">
|
||||
<div
|
||||
v-if="slots['prefix-disabled']"
|
||||
:class="`flex rounded-l bg-gray-100 dark:bg-slate-800 text-gray-500 border ${selectedBorderStyle}`"
|
||||
>
|
||||
<slot name="prefix-disabled" />
|
||||
</div>
|
||||
<div
|
||||
v-if="slots.prefix"
|
||||
:class="`flex rounded-l border ${selectedBorderStyle}`"
|
||||
>
|
||||
<slot name="prefix" />
|
||||
</div>
|
||||
<div class="text-input-wrapper relative flex flex-1">
|
||||
<input
|
||||
v-model="modelValue"
|
||||
:class="`text-input w-full flex-1 bg-transparent outline-none border ${
|
||||
havePreEl ? '' : 'rounded-l'
|
||||
} ${
|
||||
haveSuEl ? '' : 'rounded-r'
|
||||
} ${selectedBorderStyle} ${selectedOnHoverBorderStyle} ${selectedPaddingStyle} ${selectedFontSizeStyle}`"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="slots.suffix"
|
||||
:class="`flex rounded-r border ${selectedBorderStyle}`"
|
||||
>
|
||||
<slot name="suffix" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
143
components/Gem.vue
Normal file
143
components/Gem.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script>
|
||||
// taken from https://github.com/nuxt/framework/blob/main/docs/components/atoms/Gem.vue
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
ready: false,
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
const THREE = await import('three').then((m) => m.default || m)
|
||||
const { OrbitControls } = await import(
|
||||
'three/examples/jsm/controls/OrbitControls.js'
|
||||
).then((m) => m.default || m)
|
||||
const { GLTFLoader } = await import(
|
||||
'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
).then((m) => m.default || m)
|
||||
// Canvas
|
||||
let canvas = document.querySelector('canvas.webgl')
|
||||
|
||||
// wait
|
||||
while (!canvas) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
canvas = document.querySelector('canvas.webgl')
|
||||
}
|
||||
|
||||
// Scene
|
||||
const scene = new THREE.Scene()
|
||||
|
||||
// Models
|
||||
let gem
|
||||
let light
|
||||
|
||||
const gltfLoader = new GLTFLoader()
|
||||
gltfLoader.load('/assets/gem/gem.gltf', (gltf) => {
|
||||
// Gem
|
||||
gem = gltf.scene.children[6]
|
||||
|
||||
// Material setup
|
||||
// const textureLoader = new THREE.TextureLoader()
|
||||
// const roughnessTexture = textureLoader.load('/assets/gem/roughness.jpeg')
|
||||
// gem.material.roughnessMap = roughnessTexture
|
||||
gem.material.displacementScale = 0.15
|
||||
gem.material.emissiveIntensity = 0.4
|
||||
gem.material.refractionRatio = 1
|
||||
gem.rotation.z = 0
|
||||
// change color
|
||||
scene.add(gem)
|
||||
|
||||
light = gltf.scene.children[0]
|
||||
scene.add(light)
|
||||
this.ready = true
|
||||
})
|
||||
|
||||
// Lights
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||
scene.add(ambientLight)
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 3)
|
||||
directionalLight.position.set(1, 1, 1)
|
||||
scene.add(directionalLight)
|
||||
|
||||
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 3)
|
||||
directionalLight2.position.set(-1, -1, -1)
|
||||
scene.add(directionalLight2)
|
||||
|
||||
// Settings
|
||||
const sizes = {
|
||||
width: 500,
|
||||
height: 500,
|
||||
}
|
||||
|
||||
// Base camera
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
75,
|
||||
sizes.width / sizes.height,
|
||||
0.1,
|
||||
100
|
||||
)
|
||||
camera.position.set(2, 2, 6)
|
||||
scene.add(camera)
|
||||
|
||||
// Controls
|
||||
const controls = new OrbitControls(camera, canvas)
|
||||
controls.enableZoom = false
|
||||
controls.target.set(0, 0.75, 0)
|
||||
controls.enableDamping = true
|
||||
controls.enablePan = false
|
||||
// Lock Y Axis
|
||||
controls.minPolarAngle = Math.PI / 2
|
||||
controls.maxPolarAngle = Math.PI / 2
|
||||
|
||||
// Render
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialiasing: true,
|
||||
canvas,
|
||||
alpha: true,
|
||||
})
|
||||
renderer.setClearColor(0x000000, 0)
|
||||
renderer.shadowMap.enabled = true
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap
|
||||
renderer.setSize(sizes.width, sizes.height)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
|
||||
// Animations
|
||||
const clock = new THREE.Clock()
|
||||
let previousTime = 0
|
||||
|
||||
const tick = () => {
|
||||
const elapsedTime = clock.getElapsedTime()
|
||||
const _deltaTime = elapsedTime - previousTime
|
||||
previousTime = elapsedTime
|
||||
if (gem) {
|
||||
gem.rotation.y = 1.1 * elapsedTime
|
||||
}
|
||||
|
||||
// Update controls
|
||||
controls.update()
|
||||
|
||||
// Render
|
||||
renderer.render(scene, camera)
|
||||
|
||||
// Call tick again on the next frame
|
||||
window.requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
tick()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas class="webgl" :style="{ opacity: ready ? 1 : 0 }" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.webgl {
|
||||
outline: none;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
opacity: 0;
|
||||
transition: opacity 1s ease;
|
||||
}
|
||||
</style>
|
||||
82
components/LanguageSwitcher.vue
Normal file
82
components/LanguageSwitcher.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOptions,
|
||||
ListboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import { availableLocales } from '~/utils/lang'
|
||||
|
||||
// micro compiler
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'dropdown-right-top',
|
||||
},
|
||||
})
|
||||
|
||||
// state
|
||||
const currentStyle = toRef(props, 'type')
|
||||
const localeSetting = useState<string>('locale.setting')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<Listbox
|
||||
v-if="currentStyle === 'dropdown-right-top'"
|
||||
v-model="localeSetting"
|
||||
as="div"
|
||||
class="relative flex items-center"
|
||||
>
|
||||
<ListboxLabel class="sr-only">Theme</ListboxLabel>
|
||||
<ListboxButton
|
||||
type="button"
|
||||
title="Change Language"
|
||||
class="transition-colors duration-300"
|
||||
>
|
||||
<span class="justify-center items-center flex">
|
||||
<IconLa:language />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<ListboxOptions
|
||||
class="p-1 absolute z-50 top-full right-0 outline-none bg-white rounded-lg ring-1 ring-gray-900/10 shadow-lg overflow-hidden w-36 py-1 text-sm text-gray-700 font-semibold dark:bg-gray-800 dark:ring-0 dark:highlight-white/5 dark:text-gray-300"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="lang in availableLocales"
|
||||
:key="lang.iso"
|
||||
:value="lang.iso"
|
||||
:class="{
|
||||
'py-2 px-2 flex items-center cursor-pointer': true,
|
||||
'text-sky-500 bg-gray-100 dark:bg-gray-600/30':
|
||||
localeSetting === lang.iso,
|
||||
'hover:bg-gray-50 dark:hover:bg-gray-700/30':
|
||||
localeSetting !== lang.iso,
|
||||
}"
|
||||
>
|
||||
<span class="text-sm mr-2">
|
||||
{{ lang.flag }}
|
||||
</span>
|
||||
<span class="flex-1 truncate">
|
||||
{{ lang.name }}
|
||||
<span class="text-xs">({{ lang.iso }})</span>
|
||||
</span>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
<select
|
||||
v-if="currentStyle === 'select-box'"
|
||||
v-model="localeSetting"
|
||||
class="w-full px-2 pr-3 py-1 outline-none rounded border bg-transparent text-gray-700 dark:text-gray-300 border-gray-900/10 dark:border-gray-50/[0.2]"
|
||||
>
|
||||
<option
|
||||
v-for="lang in availableLocales"
|
||||
:key="lang.iso"
|
||||
:value="lang.iso"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
{{ lang.flag }} {{ lang.name }} ({{ lang.iso }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
5
components/Page/Body.vue
Normal file
5
components/Page/Body.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
30
components/Page/Content/Doc.vue
Normal file
30
components/Page/Content/Doc.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
emptyTip: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'This page is empty',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContentDoc>
|
||||
<template #default="{ doc }">
|
||||
<PageHeader>
|
||||
<PageTitle :text="doc.title" />
|
||||
</PageHeader>
|
||||
<PageBody>
|
||||
<PageSection>
|
||||
<ContentRenderer :value="doc" />
|
||||
</PageSection>
|
||||
</PageBody>
|
||||
</template>
|
||||
<template #empty>
|
||||
<h1>{{ emptyTip }}</h1>
|
||||
</template>
|
||||
<template #not-found>
|
||||
<Error :code="404" wrap />
|
||||
</template>
|
||||
</ContentDoc>
|
||||
</template>
|
||||
29
components/Page/Content/Renderer.vue
Normal file
29
components/Page/Content/Renderer.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
path: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
pageTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const { data } = await useAsyncData(props.path, () =>
|
||||
queryContent(props.path).findOne()
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageWrapper>
|
||||
<PageHeader>
|
||||
<PageTitle :text="pageTitle" class="capitalize" />
|
||||
</PageHeader>
|
||||
<PageBody>
|
||||
<PageSection>
|
||||
<ContentRenderer :value="data" />
|
||||
</PageSection>
|
||||
</PageBody>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
35
components/Page/Footer.vue
Normal file
35
components/Page/Footer.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
import { AppConfigInput } from '@nuxt/schema'
|
||||
import p from './../../package.json'
|
||||
const app = useAppConfig() as AppConfigInput
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="border-t lg:border-gray-900/10 dark:border-gray-50/[0.2]">
|
||||
<section
|
||||
class="max-w-8xl mx-auto px-4 lg:px-8 flex-1 flex w-full space-x-20"
|
||||
>
|
||||
<div class="w-full py-4 text-center md:text-left">
|
||||
<div class="mb-1">
|
||||
{{ app.name }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
Copyright © 2022 <a :href="app.author.link">{{ app.author.name }}</a
|
||||
>. All rights reserved. Made with <span class="text-red-500">❤</span>
|
||||
<div
|
||||
class="flex flex-col md:flex-row space-x-2 items-center md:float-right"
|
||||
>
|
||||
<span class="text-center md:text-right">
|
||||
design by <a href="#link">dwd</a>
|
||||
</span>
|
||||
<span
|
||||
class="block bg-blue-500 rounded px-1 py-0.5 text-white text-xs"
|
||||
>
|
||||
{{ p.devDependencies.nuxt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</footer>
|
||||
</template>
|
||||
11
components/Page/Header.vue
Normal file
11
components/Page/Header.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
layout: 'dashboard',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lg:px-8 px-4 mb-6">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
150
components/Page/Navbar.vue
Normal file
150
components/Page/Navbar.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script lang="ts" setup>
|
||||
import { AppConfigInput } from '@nuxt/schema'
|
||||
|
||||
export interface IMenuItem {
|
||||
type: 'link' | 'button'
|
||||
text: string
|
||||
href?: any
|
||||
route?: any
|
||||
}
|
||||
|
||||
const { t } = useLang()
|
||||
const app = useAppConfig() as AppConfigInput
|
||||
const menus = computed((): IMenuItem[] => [
|
||||
{
|
||||
type: 'link',
|
||||
text: t('pages.getting-started.nav'),
|
||||
route: { name: 'getting-started' },
|
||||
},
|
||||
{ type: 'link', text: t('pages.blank.nav'), route: { name: 'blank' } },
|
||||
{ type: 'link', text: t('pages.test.nav'), route: { name: 'test' } },
|
||||
{ type: 'link', text: t('pages.post.nav'), route: { name: 'post' } },
|
||||
{ type: 'link', text: t('pages.setting.nav'), route: { name: 'setting' } },
|
||||
{
|
||||
type: 'button',
|
||||
text: t('pages.dashboard.nav'),
|
||||
route: { name: 'dashboard' },
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BuilderNavbar>
|
||||
<template #banner>
|
||||
<div
|
||||
class="text-white text-xs text-center py-1 px-4 lg:px-8 bg-primary-500 capitalize"
|
||||
>
|
||||
<span class="mr-1">
|
||||
{{ $t('banners.welcome', { app_name: app.name }) }}
|
||||
<Anchor
|
||||
class="underline font-bold"
|
||||
:text="$t('others.learn_more')"
|
||||
href="boilarplate"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #menu>
|
||||
<div class="relative hidden lg:flex items-center ml-auto">
|
||||
<nav
|
||||
class="text-sm leading-6 font-semibold text-gray-600 dark:text-gray-300"
|
||||
role="navigation"
|
||||
>
|
||||
<ul class="flex items-center space-x-8">
|
||||
<li v-for="(item, i) in menus" :key="i">
|
||||
<Anchor
|
||||
v-if="item.type === 'link'"
|
||||
:to="item.route ? item.route : undefined"
|
||||
:href="item.href ? item.href : undefined"
|
||||
class="hover:no-underline hover:text-slate-900 hover:dark:text-white capitalize"
|
||||
>{{ item.text }}</Anchor
|
||||
>
|
||||
<Button
|
||||
v-else-if="item.type === 'button'"
|
||||
:text="item.text"
|
||||
size="xs"
|
||||
class="font-extrabold capitalize"
|
||||
:to="item.route ? item.route : undefined"
|
||||
:href="item.href ? item.href : undefined"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div
|
||||
class="flex space-x-4 border-l ml-6 pl-6 border-gray-900/10 dark:border-gray-50/[0.2]"
|
||||
>
|
||||
<LanguageSwitcher />
|
||||
<ThemeSwitcher />
|
||||
<Anchor
|
||||
class="hover:no-underline hover:text-slate-900 hover:dark:text-white text-lg flex self-center items-center"
|
||||
href="boilarplate"
|
||||
title="Github"
|
||||
>
|
||||
<IconMdi:github-face />
|
||||
</Anchor>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #options="{ toggleOptions }">
|
||||
<ActionSheet @on-close="toggleOptions(false)">
|
||||
<ActionSheetBody>
|
||||
<ActionSheetHeader text="Menu" />
|
||||
<nav class="leading-6 font-semibold text-gray-600 dark:text-gray-300">
|
||||
<ul class="flex flex-col">
|
||||
<li
|
||||
v-for="(item, i) in menus"
|
||||
:key="i"
|
||||
class="flex w-full"
|
||||
:class="{
|
||||
'pb-2 mb-2 border-b border-gray-900/10 dark:border-gray-50/[0.2]':
|
||||
item.type === 'link',
|
||||
}"
|
||||
>
|
||||
<Anchor
|
||||
v-if="item.type === 'link'"
|
||||
:to="item.route ? item.route : undefined"
|
||||
:href="item.href ? item.href : undefined"
|
||||
class="flex-1 hover:no-underline capitalize"
|
||||
>{{ item.text }}</Anchor
|
||||
>
|
||||
<Button
|
||||
v-else-if="item.type === 'button'"
|
||||
:text="item.text"
|
||||
size="xs"
|
||||
class="flex-1 font-extrabold capitalize"
|
||||
:to="item.route ? item.route : undefined"
|
||||
:href="item.href ? item.href : undefined"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="mt-6 text-sm font-bold capitalize">
|
||||
{{ $t('components.theme_switcher.change_theme') }}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<ThemeSwitcher type="select-box" />
|
||||
</div>
|
||||
<div class="mt-6 text-sm font-bold capitalize">
|
||||
{{ $t('components.language_switcher.change_language') }}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<LanguageSwitcher type="select-box" />
|
||||
</div>
|
||||
</ActionSheetBody>
|
||||
<Button
|
||||
type="secondary"
|
||||
title="Github"
|
||||
href="boilarplate"
|
||||
>
|
||||
<IconMdi:github-face />
|
||||
<span class="ml-1">Github</span>
|
||||
</Button>
|
||||
<Button
|
||||
text="Close"
|
||||
type="secondary"
|
||||
@click.prevent="toggleOptions(false)"
|
||||
/>
|
||||
</ActionSheet>
|
||||
</template>
|
||||
</BuilderNavbar>
|
||||
</template>
|
||||
14
components/Page/Section/Title.vue
Normal file
14
components/Page/Section/Title.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-2xl font-semibold mb-2">
|
||||
<slot>{{ text }}</slot>
|
||||
</div>
|
||||
</template>
|
||||
5
components/Page/Section/index.vue
Normal file
5
components/Page/Section/index.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<section class="lg:px-8 px-4 mb-6">
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
14
components/Page/Title.vue
Normal file
14
components/Page/Title.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-4xl font-bold">
|
||||
<slot>{{ text }}</slot>
|
||||
</div>
|
||||
</template>
|
||||
5
components/Page/Wrapper.vue
Normal file
5
components/Page/Wrapper.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="flex-1 relative py-8">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
86
components/ThemeSwitcher.vue
Normal file
86
components/ThemeSwitcher.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOptions,
|
||||
ListboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import { IThemeSettingOptions, availableThemes } from '~/utils/theme'
|
||||
|
||||
// micro compiler
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'dropdown-right-top',
|
||||
},
|
||||
})
|
||||
|
||||
// state
|
||||
const themeSetting = useState<IThemeSettingOptions>('theme.setting')
|
||||
const currentStyle = toRef(props, 'type')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<Listbox
|
||||
v-if="currentStyle === 'dropdown-right-top'"
|
||||
v-model="themeSetting"
|
||||
as="div"
|
||||
class="relative flex items-center"
|
||||
>
|
||||
<ListboxLabel class="sr-only">
|
||||
{{ $t('components.theme_switcher.theme') }}
|
||||
</ListboxLabel>
|
||||
<ListboxButton
|
||||
type="button"
|
||||
:title="$t('components.theme_switcher.change_theme')"
|
||||
class="transition-colors duration-300"
|
||||
>
|
||||
<span class="flex justify-center items-center dark:hidden">
|
||||
<IconUil:sun />
|
||||
</span>
|
||||
<span class="justify-center items-center hidden dark:flex">
|
||||
<IconUil:moon />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<ListboxOptions
|
||||
class="p-1 absolute z-50 top-full right-0 outline-none bg-white rounded-lg ring-1 ring-gray-900/10 shadow-lg overflow-hidden w-36 py-1 text-sm text-gray-700 font-semibold dark:bg-gray-800 dark:ring-0 dark:highlight-white/5 dark:text-gray-300"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="theme in availableThemes"
|
||||
:key="theme.key"
|
||||
:value="theme.key"
|
||||
:class="{
|
||||
'py-2 px-2 flex items-center cursor-pointer': true,
|
||||
'text-sky-500 bg-gray-100 dark:bg-gray-600/30':
|
||||
themeSetting === theme.key,
|
||||
'hover:bg-gray-50 dark:hover:bg-gray-700/30':
|
||||
themeSetting !== theme.key,
|
||||
}"
|
||||
>
|
||||
<span class="text-sm mr-2 flex items-center">
|
||||
<IconUil:sun v-if="theme.key === 'light'" />
|
||||
<IconUil:moon v-else-if="theme.key === 'dark'" />
|
||||
<IconUil:laptop v-else-if="theme.key === 'system'" />
|
||||
<IconUil:clock v-else-if="theme.key === 'realtime'" />
|
||||
</span>
|
||||
{{ theme.text }}
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
<select
|
||||
v-if="currentStyle === 'select-box'"
|
||||
v-model="themeSetting"
|
||||
class="w-full px-2 pr-3 py-1 outline-none rounded border bg-transparent text-gray-700 dark:text-gray-300 border-gray-900/10 dark:border-gray-50/[0.2]"
|
||||
>
|
||||
<option
|
||||
v-for="theme in availableThemes"
|
||||
:key="theme.key"
|
||||
:value="theme.key"
|
||||
>
|
||||
{{ theme.text }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
3
components/global/README.md
Normal file
3
components/global/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Global Components
|
||||
|
||||
This directory make your components available globally.
|
||||
19
components/global/Tab.vue
Normal file
19
components/global/Tab.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
const activeTab = inject<string>('activeTab')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="activeTab === name" class="relative overflow-auto px-6 py-2">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
109
components/global/Tabs.vue
Normal file
109
components/global/Tabs.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script lang="ts" setup>
|
||||
// types
|
||||
interface TabItem {
|
||||
name: string
|
||||
title: string
|
||||
}
|
||||
|
||||
// composables
|
||||
const slots = useSlots()
|
||||
defineEmits(['click'])
|
||||
|
||||
// vars
|
||||
const tabs = ref<HTMLDivElement>()
|
||||
const tabHeaderIndicator = ref<HTMLDivElement>()
|
||||
const tabItems = ref<TabItem[]>([])
|
||||
const activeTab = ref<string>()
|
||||
|
||||
// provides
|
||||
provide('activeTab', activeTab)
|
||||
|
||||
// methods
|
||||
const updateIndicator = () => {
|
||||
if (!tabs.value || !tabHeaderIndicator.value) return
|
||||
|
||||
// dom
|
||||
const dom = tabHeaderIndicator.value
|
||||
// get header tab item dom
|
||||
const currentActiveIndex = tabItems.value.findIndex(
|
||||
({ name }) => name === activeTab.value
|
||||
)
|
||||
const tabItem = tabs.value.querySelectorAll('.tabs-header-item')[
|
||||
currentActiveIndex
|
||||
] as HTMLDivElement
|
||||
if (!tabItem) return
|
||||
|
||||
// set dom position and size to tab item
|
||||
const padding = 24
|
||||
const diff = 30
|
||||
dom.style.width = `${tabItem.offsetWidth + diff}px`
|
||||
dom.style.left = `${tabItem.offsetLeft - padding - diff / 2}px`
|
||||
}
|
||||
|
||||
// watchs
|
||||
watch(tabItems, () => updateIndicator())
|
||||
watch(activeTab, () => updateIndicator())
|
||||
|
||||
// lifecycle
|
||||
onBeforeMount(() => {
|
||||
if (slots.default) {
|
||||
slots.default().forEach((element, i) => {
|
||||
const tab = element.props as TabItem
|
||||
tabItems.value.push(tab)
|
||||
if (i === 0) activeTab.value = tab.name
|
||||
})
|
||||
}
|
||||
})
|
||||
onMounted(() => {
|
||||
;(async () => {
|
||||
while (
|
||||
typeof tabHeaderIndicator.value === 'undefined' ||
|
||||
typeof tabs.value === 'undefined'
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
setTimeout(() => {
|
||||
updateIndicator()
|
||||
}, 350)
|
||||
})()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="tabs" class="tabs">
|
||||
<ClientOnly>
|
||||
<div
|
||||
class="tabs-header relative overflow-hidden flex space-x-6 text-sm font-bold text-gray-300 bg-primary-700/45 rounded-t-lg px-5 py-3"
|
||||
>
|
||||
<div
|
||||
v-for="item in tabItems"
|
||||
:key="item.name"
|
||||
:class="{
|
||||
'tabs-header-item': true,
|
||||
'text-white': activeTab === item.name,
|
||||
}"
|
||||
:style="{
|
||||
zIndex: 2,
|
||||
}"
|
||||
@click="activeTab = item.name"
|
||||
>
|
||||
<a href="#" @click.prevent="$emit('click')">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
</div>
|
||||
<span
|
||||
ref="tabHeaderIndicator"
|
||||
class="absolute flex h-full top-0 left-0 p-1 py-1.5 overflow-hidden transition-all duration-300"
|
||||
:style="{ zIndex: 1 }"
|
||||
>
|
||||
<span class="flex-1 bg-slate-500/40 rounded-lg" />
|
||||
</span>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
<div
|
||||
class="tabs-body relative text-slate-800 dark:text-white bg-gray-200 dark:bg-slate-800 shadow rounded-b-lg"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user