优化
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
AudioWaveform,
|
||||
Command,
|
||||
GalleryVerticalEnd,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { useSidebar } from '@/composables/use-sidebar'
|
||||
|
||||
import type { SidebarData, Team, User } from '../types'
|
||||
|
||||
const user: User = {
|
||||
name: 'shadcn',
|
||||
email: 'm@example.com',
|
||||
avatar: '/avatars/shadcn.jpg',
|
||||
}
|
||||
|
||||
const teams: Team[] = [
|
||||
{
|
||||
name: 'Acme Inc',
|
||||
logo: GalleryVerticalEnd,
|
||||
plan: 'Enterprise',
|
||||
},
|
||||
{
|
||||
name: 'Acme Corp.',
|
||||
logo: AudioWaveform,
|
||||
plan: 'Startup',
|
||||
},
|
||||
{
|
||||
name: 'Evil Corp.',
|
||||
logo: Command,
|
||||
plan: 'Free',
|
||||
},
|
||||
]
|
||||
|
||||
const { navData } = useSidebar()
|
||||
|
||||
export const sidebarData: SidebarData = {
|
||||
user,
|
||||
teams,
|
||||
navMain: navData.value!,
|
||||
}
|
||||
24
monisuo-admin/src/components/app-sidebar/index.vue
Normal file
24
monisuo-admin/src/components/app-sidebar/index.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
import { sidebarData } from './data/sidebar-data'
|
||||
import NavFooter from './nav-footer.vue'
|
||||
import NavTeam from './nav-team.vue'
|
||||
import TeamSwitcher from './team-switcher.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSidebar collapsible="icon" class="z-50">
|
||||
<UiSidebarHeader>
|
||||
<TeamSwitcher :teams="sidebarData.teams" />
|
||||
</UiSidebarHeader>
|
||||
|
||||
<UiSidebarContent>
|
||||
<NavTeam :nav-main="sidebarData.navMain" />
|
||||
</UiSidebarContent>
|
||||
|
||||
<UiSidebarFooter>
|
||||
<NavFooter :user="sidebarData.user" />
|
||||
</UiSidebarFooter>
|
||||
|
||||
<UiSidebarRail />
|
||||
</UiSidebar>
|
||||
</template>
|
||||
108
monisuo-admin/src/components/app-sidebar/nav-footer.vue
Normal file
108
monisuo-admin/src/components/app-sidebar/nav-footer.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
ChevronsUpDown,
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
UserRoundCog,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
|
||||
import type { User } from './types'
|
||||
|
||||
const { user } = defineProps<
|
||||
{ user: User }
|
||||
>()
|
||||
|
||||
const { logout } = useAuth()
|
||||
const { isMobile, open } = useSidebar()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSidebarMenu>
|
||||
<UiSidebarMenuItem>
|
||||
<UiDropdownMenu>
|
||||
<UiDropdownMenuTrigger as-child>
|
||||
<UiSidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<UiAvatar class="size-8 rounded-lg">
|
||||
<UiAvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<UiAvatarFallback class="rounded-lg">
|
||||
CN
|
||||
</UiAvatarFallback>
|
||||
</UiAvatar>
|
||||
<div class="grid flex-1 text-sm leading-tight text-left">
|
||||
<span class="font-semibold truncate">{{ user.name }}</span>
|
||||
<span class="text-xs truncate">{{ user.email }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</UiSidebarMenuButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
<UiDropdownMenuContent
|
||||
class="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
:side="(isMobile || open) ? 'bottom' : 'right'"
|
||||
align="start"
|
||||
:side-offset="4"
|
||||
>
|
||||
<UiDropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<UiAvatar class="size-8 rounded-lg">
|
||||
<UiAvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<UiAvatarFallback class="rounded-lg">
|
||||
CN
|
||||
</UiAvatarFallback>
|
||||
</UiAvatar>
|
||||
<div class="grid flex-1 text-sm leading-tight text-left">
|
||||
<span class="font-semibold truncate">{{ user.name }}</span>
|
||||
<span class="text-xs truncate">{{ user.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiDropdownMenuLabel>
|
||||
|
||||
<UiDropdownMenuSeparator />
|
||||
<UiDropdownMenuGroup>
|
||||
<UiDropdownMenuItem @click="$router.push('/billing/')">
|
||||
<Sparkles />
|
||||
Upgrade to Pro
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuGroup>
|
||||
|
||||
<UiDropdownMenuSeparator />
|
||||
<UiDropdownMenuGroup>
|
||||
<UiDropdownMenuItem @click="$router.push('/billing?type=billing')">
|
||||
<CreditCard />
|
||||
Billing
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuGroup>
|
||||
|
||||
<UiDropdownMenuSeparator />
|
||||
<UiDropdownMenuGroup>
|
||||
<UiDropdownMenuItem @click="$router.push('/settings/')">
|
||||
<UserRoundCog />
|
||||
Profile
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuItem @click="$router.push('/settings/account')">
|
||||
<BadgeCheck />
|
||||
Account
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuItem @click="$router.push('/settings/notifications')">
|
||||
<Bell />
|
||||
Notifications
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuGroup>
|
||||
|
||||
<UiDropdownMenuSeparator />
|
||||
<UiDropdownMenuItem @click="logout">
|
||||
<LogOut />
|
||||
{{ $t('logout') }}
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
</UiSidebarMenuItem>
|
||||
</UiSidebarMenu>
|
||||
</template>
|
||||
91
monisuo-admin/src/components/app-sidebar/nav-team-add.vue
Normal file
91
monisuo-admin/src/components/app-sidebar/nav-team-add.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
|
||||
import { teamAddValidator } from './validators/team.validator'
|
||||
|
||||
const emits = defineEmits(['close'])
|
||||
|
||||
const teamAddFormSchema = toTypedSchema(teamAddValidator)
|
||||
|
||||
const { handleSubmit } = useForm({
|
||||
validationSchema: teamAddFormSchema,
|
||||
initialValues: {},
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
toast('You submitted the following values:', {
|
||||
position: 'top-center',
|
||||
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(values, null, 2))),
|
||||
})
|
||||
|
||||
emits('close')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UiDialogHeader>
|
||||
<UiDialogTitle>
|
||||
Add New Team
|
||||
</UiDialogTitle>
|
||||
<UiDialogDescription>
|
||||
Add a new team by your self.
|
||||
</UiDialogDescription>
|
||||
</UiDialogHeader>
|
||||
|
||||
<form class="space-y-4" @submit="onSubmit">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel class="text-base">
|
||||
Name
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<UiInput v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Set the name for the team.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="slug">
|
||||
<FormItem>
|
||||
<FormLabel class="text-base">
|
||||
Slug
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<UiInput v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Set the slug for the team.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="logo">
|
||||
<FormItem>
|
||||
<FormLabel class="text-base">
|
||||
Logo
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<UiInput v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Set the logo of the team.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<div class="flex justify-start mt-4">
|
||||
<UiButton type="submit">
|
||||
Add team
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
108
monisuo-admin/src/components/app-sidebar/nav-team.vue
Normal file
108
monisuo-admin/src/components/app-sidebar/nav-team.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ChevronRight,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
|
||||
import type { NavGroup, NavItem } from './types'
|
||||
|
||||
const { navMain } = defineProps<{
|
||||
navMain: NavGroup[]
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const { state, isMobile } = useSidebar()
|
||||
|
||||
function isCollapsed(menu: NavItem): boolean {
|
||||
const pathname = route.path
|
||||
navMain.forEach((group) => {
|
||||
group.items.forEach((item) => {
|
||||
if (item.url === pathname) {
|
||||
return true
|
||||
}
|
||||
})
|
||||
})
|
||||
return !!menu.items?.some(item => item.url === pathname)
|
||||
}
|
||||
|
||||
function isActive(menu: NavItem): boolean {
|
||||
const pathname = route.path
|
||||
if (menu.url) {
|
||||
return pathname === menu.url
|
||||
}
|
||||
return !!menu.items?.some(item => item.url === pathname)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSidebarGroup v-for="group in navMain" :key="group.title">
|
||||
<UiSidebarGroupLabel>{{ group.title }}</UiSidebarGroupLabel>
|
||||
<UiSidebarMenu>
|
||||
<template v-for="menu in group.items" :key="menu.title">
|
||||
<UiSidebarMenuItem v-if="!menu.items">
|
||||
<UiSidebarMenuButton as-child :is-active="isActive(menu)" :tooltip="menu.title">
|
||||
<router-link :to="menu.url">
|
||||
<component :is="menu.icon" />
|
||||
<span>{{ menu.title }}</span>
|
||||
</router-link>
|
||||
</UiSidebarMenuButton>
|
||||
</UiSidebarMenuItem>
|
||||
|
||||
<UiSidebarMenuItem v-else>
|
||||
<!-- sidebar expanded -->
|
||||
<UiCollapsible
|
||||
v-if="state !== 'collapsed' || isMobile"
|
||||
as-child :default-open="isCollapsed(menu)"
|
||||
class="group/collapsible"
|
||||
>
|
||||
<UiSidebarMenuItem>
|
||||
<UiCollapsibleTrigger as-child>
|
||||
<UiSidebarMenuButton :tooltip="menu.title">
|
||||
<component :is="menu.icon" v-if="menu.icon" />
|
||||
<span>{{ menu.title }}</span>
|
||||
<ChevronRight
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
</UiSidebarMenuButton>
|
||||
</UiCollapsibleTrigger>
|
||||
</UiSidebarMenuItem>
|
||||
<UiCollapsibleContent>
|
||||
<UiSidebarMenuSub>
|
||||
<UiSidebarMenuSubItem v-for="subItem in menu.items" :key="subItem.title">
|
||||
<UiSidebarMenuSubButton as-child :is-active="isActive(subItem as NavItem)">
|
||||
<router-link :to="subItem?.url || '/'">
|
||||
<component :is="subItem.icon" v-if="subItem.icon" />
|
||||
<span>{{ subItem.title }}</span>
|
||||
</router-link>
|
||||
</UiSidebarMenuSubButton>
|
||||
</UiSidebarMenuSubItem>
|
||||
</UiSidebarMenuSub>
|
||||
</UiCollapsibleContent>
|
||||
</UiCollapsible>
|
||||
|
||||
<!-- sidebar collapsed -->
|
||||
<UiDropdownMenu v-else>
|
||||
<UiDropdownMenuTrigger as-child>
|
||||
<UiSidebarMenuButton :tooltip="menu.title">
|
||||
<component :is="menu.icon" v-if="menu.icon" />
|
||||
<span>{{ menu.title }}</span>
|
||||
</UiSidebarMenuButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
<UiDropdownMenuContent align="start" side="right">
|
||||
<UiDropdownMenuLabel>{{ menu.title }}</UiDropdownMenuLabel>
|
||||
<UiDropdownMenuSeparator />
|
||||
<UiDropdownMenuItem v-for="subItem in menu.items" :key="subItem.title" as-child>
|
||||
<router-link :to="subItem?.url || '/'">
|
||||
<component :is="subItem.icon" v-if="subItem.icon" />
|
||||
<span>{{ subItem.title }}</span>
|
||||
</router-link>
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
</UiSidebarMenuItem>
|
||||
</template>
|
||||
</UiSidebarMenu>
|
||||
</UiSidebarGroup>
|
||||
</template>
|
||||
100
monisuo-admin/src/components/app-sidebar/team-switcher.vue
Normal file
100
monisuo-admin/src/components/app-sidebar/team-switcher.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ChevronsUpDown,
|
||||
Plus,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
|
||||
import type { Team } from './types'
|
||||
|
||||
const { teams } = defineProps<{
|
||||
teams: Team[]
|
||||
}>()
|
||||
|
||||
const { isMobile, open } = useSidebar()
|
||||
|
||||
const activeTeam = ref<Team>(teams[0])
|
||||
function setActiveTeam(team: Team) {
|
||||
activeTeam.value = team
|
||||
}
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const showComponent = shallowRef<Component | null>(null)
|
||||
type TComponent = 'team-add'
|
||||
|
||||
function handleSelect(command: TComponent) {
|
||||
switch (command) {
|
||||
case 'team-add':
|
||||
showComponent.value = defineAsyncComponent(() => import('./nav-team-add.vue'))
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSidebarMenu>
|
||||
<UiSidebarMenuItem>
|
||||
<UiDialog v-model:open="isOpen">
|
||||
<UiDropdownMenu>
|
||||
<UiDropdownMenuTrigger as-child>
|
||||
<UiSidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-lg aspect-square size-8 bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
<component :is="activeTeam.logo" class="size-4" />
|
||||
</div>
|
||||
<div class="grid flex-1 text-sm leading-tight text-left">
|
||||
<span class="font-semibold truncate">{{ activeTeam.name }}</span>
|
||||
<span class="text-xs truncate">{{ activeTeam.plan }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto" />
|
||||
</UiSidebarMenuButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
<UiDropdownMenuContent
|
||||
class="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
align="start"
|
||||
:side="(isMobile || open) ? 'bottom' : 'right'"
|
||||
:side-offset="4"
|
||||
>
|
||||
<UiDropdownMenuLabel class="text-xs text-muted-foreground">
|
||||
Teams
|
||||
</UiDropdownMenuLabel>
|
||||
<UiDropdownMenuItem
|
||||
v-for="(team, index) in teams"
|
||||
:key="team.name"
|
||||
class="gap-2 p-2"
|
||||
@click="setActiveTeam(team)"
|
||||
>
|
||||
<div class="flex items-center justify-center border rounded-sm size-6">
|
||||
<component :is="team.logo" class="size-4 shrink-0" />
|
||||
</div>
|
||||
{{ team.name }}
|
||||
<UiDropdownMenuShortcut>⌘{{ index + 1 }}</UiDropdownMenuShortcut>
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuSeparator />
|
||||
|
||||
<UiDialogTrigger as-child>
|
||||
<UiDropdownMenuItem class="gap-2 p-2" @click.stop="handleSelect('team-add')">
|
||||
<div class="flex items-center justify-center border rounded-md size-6 bg-background">
|
||||
<Plus class="size-4" />
|
||||
</div>
|
||||
<div class="font-medium text-muted-foreground">
|
||||
Add team
|
||||
</div>
|
||||
</UiDropdownMenuItem>
|
||||
</UiDialogTrigger>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
|
||||
<UiDialogContent>
|
||||
<component :is="showComponent" @close="isOpen = false" />
|
||||
</UiDialogContent>
|
||||
</UiDialog>
|
||||
</UiSidebarMenuItem>
|
||||
</UiSidebarMenu>
|
||||
</template>
|
||||
42
monisuo-admin/src/components/app-sidebar/types.ts
Normal file
42
monisuo-admin/src/components/app-sidebar/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { LucideProps } from 'lucide-vue-next'
|
||||
import type { FunctionalComponent } from 'vue'
|
||||
|
||||
type NavIcon = FunctionalComponent<LucideProps, Record<any, any>, any, Record<any, any>>
|
||||
|
||||
interface BaseNavItem {
|
||||
title: string
|
||||
icon?: NavIcon
|
||||
}
|
||||
|
||||
export type NavItem
|
||||
= | BaseNavItem & {
|
||||
items: (BaseNavItem & { url?: string })[]
|
||||
url?: never
|
||||
isActive?: boolean
|
||||
} | BaseNavItem & {
|
||||
url: string
|
||||
items?: never
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
title: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
export interface User {
|
||||
name: string
|
||||
avatar: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
name: string
|
||||
logo: NavIcon
|
||||
plan: string
|
||||
}
|
||||
|
||||
export interface SidebarData {
|
||||
user: User
|
||||
teams: Team[]
|
||||
navMain: NavGroup[]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const teamAddValidator = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { error: 'Group name is required' })
|
||||
.max(50, { error: 'Group name must be less than 50 characters' }),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, { error: 'Group name is required' })
|
||||
.max(50, { error: 'Group name must be less than 50 characters' }),
|
||||
logo: z
|
||||
.string()
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export type TeamAddValidator = z.infer<typeof teamAddValidator>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { useColorMode } from '@vueuse/core'
|
||||
import { Moon, Sun, SunMoon } from 'lucide-vue-next'
|
||||
|
||||
import CommandItemHasIcon from './command-item-has-icon.vue'
|
||||
|
||||
defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
const mode = useColorMode()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiCommandGroup heading="Theme">
|
||||
<UiCommandItem value="light" @click="mode = 'light', $emit('click')">
|
||||
<CommandItemHasIcon name="Light" :icon="Sun" />
|
||||
</UiCommandItem>
|
||||
<UiCommandItem value="dark" @click="mode = 'dark', $emit('click')">
|
||||
<CommandItemHasIcon name="Dark" :icon="Moon" />
|
||||
</UiCommandItem>
|
||||
<UiCommandItem value="system" @click="mode = 'auto', $emit('click')">
|
||||
<CommandItemHasIcon name="System" :icon="SunMoon" />
|
||||
</UiCommandItem>
|
||||
</UiCommandGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import { Milestone } from 'lucide-vue-next'
|
||||
|
||||
const { icon } = defineProps<{
|
||||
name: string
|
||||
icon?: Component
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="icon" v-if="icon" class="size-4" />
|
||||
<Milestone v-else class="size-4" />
|
||||
{{ name }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { useSidebar } from '@/composables/use-sidebar'
|
||||
|
||||
import type { NavGroup, NavItem } from '../app-sidebar/types'
|
||||
|
||||
import CommandItemHasIcon from './command-item-has-icon.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
const { navData, otherPages } = useSidebar()
|
||||
|
||||
function getFlatNavItems(navData: NavGroup[]): NavItem[] {
|
||||
const flatItems: NavItem[] = []
|
||||
navData.forEach((group) => {
|
||||
group.items.forEach((item) => {
|
||||
if (item.items) {
|
||||
flatItems.push(...getFlatNavItems([item as unknown as NavGroup]))
|
||||
}
|
||||
else {
|
||||
flatItems.push(item)
|
||||
}
|
||||
})
|
||||
})
|
||||
return flatItems
|
||||
}
|
||||
|
||||
const commands = getFlatNavItems([...navData.value!, ...otherPages.value!])
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
function commandItemClick(url: string) {
|
||||
emit('click')
|
||||
if (route.fullPath !== url) {
|
||||
router.push(url)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiCommandGroup heading="Pages">
|
||||
<UiCommandItem
|
||||
v-for="command in commands"
|
||||
:key="command.title"
|
||||
:value="command.title"
|
||||
@click="commandItemClick(command.url!)"
|
||||
>
|
||||
<CommandItemHasIcon :name="command.title" :icon="command.icon" />
|
||||
</UiCommandItem>
|
||||
</UiCommandGroup>
|
||||
</template>
|
||||
66
monisuo-admin/src/components/command-menu-panel/index.vue
Normal file
66
monisuo-admin/src/components/command-menu-panel/index.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { MenuIcon, SearchIcon } from 'lucide-vue-next'
|
||||
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
||||
|
||||
import CommandChangeTheme from './command-change-theme.vue'
|
||||
import CommandToPage from './command-to-page.vue'
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
useEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.key === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
handleOpenChange()
|
||||
}
|
||||
})
|
||||
|
||||
function handleOpenChange() {
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
const firstKey = computed(() => navigator?.userAgent.includes('Mac OS') ? '⌘' : 'Ctrl')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="text-sm items-center justify-between text-muted-foreground border border-border bg-muted/5 px-4 py-2 rounded-md md:min-w-[220px] cursor-pointer hidden md:flex"
|
||||
@click="handleOpenChange"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SearchIcon class="size-4" />
|
||||
<span class="text-xs font-semibold text-muted-foreground">{{ $t('homePage.searchKeyWords') }}</span>
|
||||
</div>
|
||||
<UiKbd>{{ firstKey }} + k</UiKbd>
|
||||
</div>
|
||||
|
||||
<UiButton variant="outline" size="icon" class="md:hidden" @click="handleOpenChange">
|
||||
<SearchIcon />
|
||||
</UiButton>
|
||||
|
||||
<UiCommandDialog v-model:open="open">
|
||||
<UiCommandInput placeholder="Type a command or search..." />
|
||||
<UiCommandList>
|
||||
<UiCommandEmpty>
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<MenuIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No menu found.</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Try searching for a command or check the spelling.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</UiCommandEmpty>
|
||||
|
||||
<CommandToPage @click="handleOpenChange" />
|
||||
<UiCommandSeparator />
|
||||
<CommandChangeTheme @click="handleOpenChange" />
|
||||
</UiCommandList>
|
||||
</UiCommandDialog>
|
||||
</div>
|
||||
</template>
|
||||
71
monisuo-admin/src/components/confirm-dialog.vue
Normal file
71
monisuo-admin/src/components/confirm-dialog.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script lang='ts' setup>
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isLoading?: boolean
|
||||
disabled?: boolean
|
||||
cancelButtonText?: string
|
||||
confirmButtonText?: string
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
destructive = false,
|
||||
cancelButtonText = 'Cancel',
|
||||
confirmButtonText = 'Continue',
|
||||
} = defineProps<ConfirmDialogProps>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'confirm'): void
|
||||
}>()
|
||||
|
||||
const openModel = defineModel<boolean>('open', {
|
||||
default: false,
|
||||
})
|
||||
|
||||
function handleConfirm() {
|
||||
emits('confirm')
|
||||
openModel.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialog :open="openModel">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader class="text-start">
|
||||
<AlertDialogTitle>
|
||||
<slot name="title" />
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription as-child>
|
||||
<slot name="description" />
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<slot />
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel :disabled="isLoading" @click="openModel = false">
|
||||
{{ cancelButtonText }}
|
||||
</AlertDialogCancel>
|
||||
|
||||
<UiButton
|
||||
:variant="destructive ? 'destructive' : 'default'"
|
||||
:disabled="disabled || isLoading"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmButtonText }}
|
||||
</UiButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
34
monisuo-admin/src/components/custom-error.vue
Normal file
34
monisuo-admin/src/components/custom-error.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
code: number
|
||||
subtitle: string
|
||||
error: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto text-center">
|
||||
<h1 class="font-bold text-8xl">
|
||||
{{ code }}
|
||||
</h1>
|
||||
<h2 class="mt-4 text-2xl font-bold">
|
||||
{{ subtitle }}
|
||||
</h2>
|
||||
<p class="text-stone-400">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<footer class="mt-8">
|
||||
<slot>
|
||||
<div class="flex justify-center gap-2">
|
||||
<UiButton variant="outline" @click="$router.back()">
|
||||
Go Back
|
||||
</UiButton>
|
||||
<UiButton @click="$router.push('/')">
|
||||
Back to Home
|
||||
</UiButton>
|
||||
</div>
|
||||
</slot>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
30
monisuo-admin/src/components/custom-theme/content-layout.vue
Normal file
30
monisuo-admin/src/components/custom-theme/content-layout.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { CONTENT_LAYOUTS } from '@/constants/themes'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { setContentLayout } = themeStore
|
||||
const { contentLayout } = storeToRefs(themeStore)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5 pt-6">
|
||||
<UiLabel for="radius" class="text-xs">
|
||||
Content Layout
|
||||
</UiLabel>
|
||||
<div class="grid grid-cols-2 gap-2 py-1.5">
|
||||
<UiButton
|
||||
v-for="layout in CONTENT_LAYOUTS" :key="layout.label"
|
||||
variant="outline"
|
||||
class="justify-center h-8 px-3"
|
||||
:class="contentLayout === layout.value ? 'border-foreground border-2' : ''"
|
||||
@click="setContentLayout(layout.value)"
|
||||
>
|
||||
<component :is="layout.icon" />
|
||||
{{ layout.label }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
40
monisuo-admin/src/components/custom-theme/custom-color.vue
Normal file
40
monisuo-admin/src/components/custom-theme/custom-color.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { THEME_PRIMARY_COLORS, THEMES } from '@/constants/themes'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { setTheme } = themeStore
|
||||
const { theme: t } = storeToRefs(themeStore)
|
||||
|
||||
watchEffect(() => {
|
||||
document.documentElement.classList.remove(...THEMES.map(theme => `theme-${theme}`))
|
||||
document.documentElement.classList.add(`theme-${t.value}`)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5 pt-6">
|
||||
<UiLabel for="radius" class="text-xs">
|
||||
Color
|
||||
</UiLabel>
|
||||
<div class="grid grid-cols-2 gap-2 py-1.5">
|
||||
<UiButton
|
||||
v-for="theme in THEME_PRIMARY_COLORS" :key="theme.theme"
|
||||
variant="outline"
|
||||
class="justify-center h-8 px-3"
|
||||
:class="t === theme.theme ? 'border-foreground border-2' : ''"
|
||||
@click="setTheme(theme.theme)"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
'--theme-primary': theme.primaryColor,
|
||||
}"
|
||||
class="size-2 rounded-full bg-(--theme-primary)"
|
||||
/>
|
||||
<span class="text-xs">{{ theme.theme[0].toUpperCase() }}{{ theme.theme.slice(1) }}</span>
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
33
monisuo-admin/src/components/custom-theme/custom-radius.vue
Normal file
33
monisuo-admin/src/components/custom-theme/custom-radius.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { RADIUS } from '@/constants/themes'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { setRadius } = themeStore
|
||||
const { radius } = storeToRefs(themeStore)
|
||||
|
||||
watchEffect(() => {
|
||||
document.documentElement.style.setProperty('--radius', `${radius.value}rem`)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5 pt-6">
|
||||
<UiLabel for="radius" class="text-xs">
|
||||
Radius
|
||||
</UiLabel>
|
||||
<div class="grid grid-cols-5 gap-2 py-1.5">
|
||||
<UiButton
|
||||
v-for="rayon in RADIUS" :key="rayon"
|
||||
variant="outline"
|
||||
class="justify-center h-8 px-3"
|
||||
:class="rayon === radius ? 'border-foreground border-2' : ''"
|
||||
@click="setRadius(rayon)"
|
||||
>
|
||||
<span class="text-xs">{{ rayon }}</span>
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid space-y-1">
|
||||
<h1 class="font-semibold text-md text-foreground">
|
||||
Customize
|
||||
</h1>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Pick a style and color for your components.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
33
monisuo-admin/src/components/custom-theme/theme-popover.vue
Normal file
33
monisuo-admin/src/components/custom-theme/theme-popover.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
import { Paintbrush } from 'lucide-vue-next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
import ContentLayout from './content-layout.vue'
|
||||
import CustomColor from './custom-color.vue'
|
||||
import CustomRadius from './custom-radius.vue'
|
||||
import CustomThemeTitle from './custom-theme-title.vue'
|
||||
import ToggleColorMode from './toggle-color-mode.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button variant="outline" size="icon">
|
||||
<Paintbrush />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end">
|
||||
<CustomThemeTitle />
|
||||
<CustomColor />
|
||||
<CustomRadius />
|
||||
<ToggleColorMode />
|
||||
<ContentLayout />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import type { BasicColorSchema } from '@vueuse/core'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import { useColorMode } from '@vueuse/core'
|
||||
import { Moon, Sun, SunMoon } from 'lucide-vue-next'
|
||||
|
||||
const mode = useColorMode()
|
||||
|
||||
const colorModes: {
|
||||
colorMode: BasicColorSchema
|
||||
icon: Component
|
||||
}[] = [
|
||||
{ colorMode: 'light', icon: Sun },
|
||||
{ colorMode: 'dark', icon: Moon },
|
||||
{ colorMode: 'auto', icon: SunMoon },
|
||||
]
|
||||
|
||||
function setColorMode(colorMode: BasicColorSchema) {
|
||||
mode.value = colorMode
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5 pt-6">
|
||||
<UiLabel for="radius" class="text-xs">
|
||||
Color Mode
|
||||
</UiLabel>
|
||||
<div class="grid grid-cols-3 gap-2 py-1.5">
|
||||
<UiButton
|
||||
v-for="item in colorModes" :key="item.colorMode"
|
||||
variant="outline"
|
||||
class="justify-center items-center h-8 px-3"
|
||||
:class="item.colorMode === mode ? 'border-foreground border-2' : ''"
|
||||
@click="setColorMode(item.colorMode)"
|
||||
>
|
||||
<component :is="item.icon" />
|
||||
<span class="text-xs">{{ item.colorMode }}</span>
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
86
monisuo-admin/src/components/data-table/bulk-actions.vue
Normal file
86
monisuo-admin/src/components/data-table/bulk-actions.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang='ts' setup generic="T">
|
||||
import type { Table as VueTable } from '@tanstack/vue-table'
|
||||
|
||||
import { XIcon } from 'lucide-vue-next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface BulkActionsProps<T> {
|
||||
table: VueTable<T>
|
||||
entityName: string
|
||||
}
|
||||
|
||||
const { table, entityName } = defineProps<BulkActionsProps<T>>()
|
||||
|
||||
const selectedRows = computed(() => table.getSelectedRowModel().rows)
|
||||
const selectedCount = computed(() => selectedRows.value.length || 0)
|
||||
|
||||
function handleClearSelection() {
|
||||
table.resetRowSelection()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn(
|
||||
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
|
||||
'transition-all delay-100 duration-300 ease-out hover:scale-105',
|
||||
'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none',
|
||||
)"
|
||||
>
|
||||
<section
|
||||
v-if="selectedCount" :class="cn(
|
||||
'p-2 shadow-xl',
|
||||
'rounded-xl border',
|
||||
'bg-background/95 supports-backdrop-filter:bg-background/60 backdrop-blur-lg',
|
||||
'flex items-center gap-x-2',
|
||||
)"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="size-6 rounded-full"
|
||||
aria-label="Clear selection"
|
||||
title="Clear selection (Escape)"
|
||||
@click="handleClearSelection"
|
||||
>
|
||||
<XIcon />
|
||||
<span class="sr-only">Clear selection</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Clear selection (Escape)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator
|
||||
class="h-5"
|
||||
orientation="vertical"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<section id="bulk-actions-description" class="flex items-center gap-x-1 text-sm">
|
||||
<UiBadge
|
||||
class="min-w-8 rounded-lg"
|
||||
:aria-label="`${selectedCount} selected`"
|
||||
>
|
||||
{{ selectedCount }}
|
||||
</UiBadge>
|
||||
{{ entityName }} selected
|
||||
</section>
|
||||
|
||||
<Separator
|
||||
class="h-5"
|
||||
orientation="vertical"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
93
monisuo-admin/src/components/data-table/column-header.vue
Normal file
93
monisuo-admin/src/components/data-table/column-header.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import type { Column } from '@tanstack/vue-table'
|
||||
|
||||
import { ArrowDownIcon, ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, ChevronsUpDownIcon, EyeOffIcon, PinIcon, PinOffIcon } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DataTableColumnHeaderProps {
|
||||
column: Column<T, any>
|
||||
title: string
|
||||
}
|
||||
|
||||
const props = defineProps<DataTableColumnHeaderProps>()
|
||||
|
||||
const canPinned = computed(() => props.column.getCanPin())
|
||||
const canSorted = computed(() => props.column.getCanSort())
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="canSorted || canPinned" :class="cn('flex items-center space-x-2', $attrs.class ?? '')">
|
||||
<UiDropdownMenu>
|
||||
<UiDropdownMenuTrigger as-child>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="-ml-3 h-8 data-[state=open]:bg-accent"
|
||||
>
|
||||
<template v-if="canPinned">
|
||||
<PinIcon v-if="props.column.getIsPinned()" class="ml-2 size-4 text-primary" />
|
||||
</template>
|
||||
|
||||
<span>{{ title }}</span>
|
||||
|
||||
<template v-if="canSorted">
|
||||
<ArrowDownIcon v-if="props.column.getIsSorted() === 'desc'" class="ml-2 size-4" />
|
||||
<ArrowUpIcon v-else-if="props.column.getIsSorted() === 'asc'" class="ml-2 size-4" />
|
||||
<ChevronsUpDownIcon v-else class="ml-2 size-4" />
|
||||
</template>
|
||||
</UiButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
|
||||
<UiDropdownMenuContent align="start">
|
||||
<template v-if="canSorted">
|
||||
<UiDropdownMenuItem @click="props.column.toggleSorting(false)">
|
||||
<ArrowUpIcon class="mr-2 size-4 text-muted-foreground/70" />
|
||||
Asc
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuItem @click="props.column.toggleSorting(true)">
|
||||
<ArrowDownIcon class="mr-2 size-4 text-muted-foreground/70" />
|
||||
Desc
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuItem @click="props.column.clearSorting()">
|
||||
<ChevronsUpDownIcon class="mr-2 size-4 text-muted-foreground/70" />
|
||||
Clear Sorting
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuSeparator />
|
||||
</template>
|
||||
|
||||
<UiDropdownMenuItem @click="props.column.toggleVisibility(false)">
|
||||
<EyeOffIcon class="mr-2 size-4 text-muted-foreground/70" />
|
||||
Hide
|
||||
</UiDropdownMenuItem>
|
||||
|
||||
<template v-if="canPinned">
|
||||
<UiDropdownMenuSeparator />
|
||||
<UiDropdownMenuItem @click="props.column.pin('left')">
|
||||
<ArrowLeftIcon class="mr-2 size-4 text-muted-foreground/70" />
|
||||
Pin Left
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuItem @click="props.column.pin('right')">
|
||||
<ArrowRightIcon class="mr-2 size-4 text-muted-foreground/70" />
|
||||
Pin Right
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuItem @click="props.column.pin(false)">
|
||||
<PinOffIcon class="mr-2 size-4 text-muted-foreground/70" />
|
||||
Unpin
|
||||
</UiDropdownMenuItem>
|
||||
</template>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
</div>
|
||||
|
||||
<div v-else :class="$attrs?.class ?? ''">
|
||||
{{ title }}
|
||||
</div>
|
||||
</template>
|
||||
82
monisuo-admin/src/components/data-table/data-table.vue
Normal file
82
monisuo-admin/src/components/data-table/data-table.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import type { Column, Table as VueTable } from '@tanstack/vue-table'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
import { FlexRender } from '@tanstack/vue-table'
|
||||
|
||||
import NoResultFound from '@/components/no-result-found.vue'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
|
||||
import type { DataTableProps } from './types'
|
||||
|
||||
import DataTableLoading from './table-loading.vue'
|
||||
import DataTablePagination from './table-pagination.vue'
|
||||
|
||||
defineProps<DataTableProps<T> & {
|
||||
table: VueTable<T>
|
||||
}>()
|
||||
|
||||
function getCommonPinningStyles(column: Column<T>): CSSProperties {
|
||||
const isPinned = column.getIsPinned()
|
||||
return {
|
||||
left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
|
||||
right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
|
||||
position: isPinned ? 'sticky' : 'relative',
|
||||
width: `${column.getSize()}px`,
|
||||
zIndex: isPinned ? 1 : 0,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<slot name="toolbar" />
|
||||
|
||||
<div class="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:style="getCommonPinningStyles(header.column)"
|
||||
:class="{ 'bg-background': header.column.getIsPinned() }"
|
||||
>
|
||||
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody v-if="!loading">
|
||||
<template v-if="table.getRowModel().rows?.length">
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() && 'selected'"
|
||||
>
|
||||
<TableCell
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:style="getCommonPinningStyles(cell.column)"
|
||||
:class="{ 'bg-background': cell.column.getIsPinned() }"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<TableRow v-else>
|
||||
<TableCell
|
||||
:colspan="columns.length"
|
||||
class="h-24 text-center"
|
||||
>
|
||||
<NoResultFound />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<DataTableLoading v-if="loading" />
|
||||
</div>
|
||||
|
||||
<DataTablePagination v-if="!loading" :table="table" :server-pagination="serverPagination" />
|
||||
</div>
|
||||
</template>
|
||||
121
monisuo-admin/src/components/data-table/faceted-filter.vue
Normal file
121
monisuo-admin/src/components/data-table/faceted-filter.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import type { Column } from '@tanstack/vue-table'
|
||||
|
||||
import { Check, CirclePlus } from 'lucide-vue-next'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FacetedFilterOption } from './types'
|
||||
|
||||
interface DataTableFacetedFilter {
|
||||
column?: Column<T, any>
|
||||
title?: string
|
||||
options: FacetedFilterOption[]
|
||||
}
|
||||
|
||||
const props = defineProps<DataTableFacetedFilter>()
|
||||
|
||||
const facets = computed(() => props.column?.getFacetedUniqueValues())
|
||||
const selectedValues = computed(() => new Set(props.column?.getFilterValue() as string[]))
|
||||
const filterFunction = (list: DataTableFacetedFilter['options'], term: string) => list.filter(i => i.label.toLowerCase()?.includes(term))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiPopover>
|
||||
<UiPopoverTrigger as-child>
|
||||
<UiButton variant="outline" size="sm" class="h-8 border-dashed">
|
||||
<CirclePlus class="size-4 mr-2" />
|
||||
{{ title }}
|
||||
<template v-if="selectedValues.size > 0">
|
||||
<UiSeparator orientation="vertical" class="h-4 mx-2" />
|
||||
<UiBadge
|
||||
variant="secondary"
|
||||
class="px-1 font-normal rounded-sm lg:hidden"
|
||||
>
|
||||
{{ selectedValues.size }}
|
||||
</UiBadge>
|
||||
<div class="hidden space-x-1 lg:flex">
|
||||
<UiBadge
|
||||
v-if="selectedValues.size > 2"
|
||||
variant="secondary"
|
||||
class="px-1 font-normal rounded-sm"
|
||||
>
|
||||
{{ selectedValues.size }} selected
|
||||
</UiBadge>
|
||||
|
||||
<template v-else>
|
||||
<UiBadge
|
||||
v-for="option in options
|
||||
.filter((option) => selectedValues.has(option.value))"
|
||||
:key="option.value"
|
||||
variant="secondary"
|
||||
class="px-1 font-normal rounded-sm"
|
||||
>
|
||||
{{ option.label }}
|
||||
</UiBadge>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</UiButton>
|
||||
</UiPopoverTrigger>
|
||||
<UiPopoverContent class="w-[200px] p-0" align="start">
|
||||
<UiCommand
|
||||
:filter-function="filterFunction as unknown as any"
|
||||
>
|
||||
<UiCommandInput :placeholder="title" />
|
||||
<UiCommandList>
|
||||
<UiCommandEmpty>No results found.</UiCommandEmpty>
|
||||
<UiCommandGroup>
|
||||
<UiCommandItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
@select="(_e) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
}
|
||||
else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined,
|
||||
)
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:class="cn(
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
selectedValues.has(option.value)
|
||||
? 'bg-primary'
|
||||
: 'opacity-50 [&_svg]:invisible',
|
||||
)"
|
||||
>
|
||||
<Check :class="cn('h-4 w-4', selectedValues.has(option.value) ? 'text-primary-foreground' : '')" />
|
||||
</div>
|
||||
<component :is="option.icon" v-if="option.icon" class="size-4 mr-2 text-muted-foreground" />
|
||||
<span>{{ option.label }}</span>
|
||||
<span v-if="facets?.get(option.value)" class="flex items-center justify-center size-4 ml-auto font-mono text-xs">
|
||||
{{ facets.get(option.value) }}
|
||||
</span>
|
||||
</UiCommandItem>
|
||||
</UiCommandGroup>
|
||||
|
||||
<template v-if="selectedValues.size > 0">
|
||||
<UiCommandSeparator />
|
||||
<UiCommandGroup>
|
||||
<UiCommandItem
|
||||
:value="{ label: 'Clear filters' }"
|
||||
class="justify-center text-center"
|
||||
@select="column?.setFilterValue(undefined)"
|
||||
>
|
||||
Clear filters
|
||||
</UiCommandItem>
|
||||
</UiCommandGroup>
|
||||
</template>
|
||||
</UiCommandList>
|
||||
</UiCommand>
|
||||
</UiPopoverContent>
|
||||
</UiPopover>
|
||||
</template>
|
||||
10
monisuo-admin/src/components/data-table/index.ts
Normal file
10
monisuo-admin/src/components/data-table/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { default as DataTableBulkActions } from './bulk-actions.vue'
|
||||
export { default as DataTableColumnHeader } from './column-header.vue'
|
||||
export { default as DataTable } from './data-table.vue'
|
||||
export { default as DataTableFacetedFilter } from './faceted-filter.vue'
|
||||
export { RadioSelectColumn, SelectColumn } from './table-columns'
|
||||
export { default as DataTableLoading } from './table-loading.vue'
|
||||
export { default as DataTablePagination } from './table-pagination.vue'
|
||||
export type * from './types'
|
||||
export { useGenerateVueTable } from './use-generate-vue-table'
|
||||
export { default as DataTableViewOptions } from './view-options.vue'
|
||||
35
monisuo-admin/src/components/data-table/radio-cell.vue
Normal file
35
monisuo-admin/src/components/data-table/radio-cell.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleIcon } from 'lucide-vue-next'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineProps<{
|
||||
checked: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="checked"
|
||||
:class="
|
||||
cn(
|
||||
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'hover:border-ring cursor-pointer',
|
||||
)
|
||||
"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<span
|
||||
v-if="checked"
|
||||
class="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon class="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
49
monisuo-admin/src/components/data-table/table-columns.ts
Normal file
49
monisuo-admin/src/components/data-table/table-columns.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { h } from 'vue'
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
|
||||
import RadioCell from './radio-cell.vue'
|
||||
|
||||
const FIXED_WIDTH_COLUMN = {
|
||||
size: 32,
|
||||
minSize: 32,
|
||||
maxSize: 32,
|
||||
enableResizing: false,
|
||||
} as const
|
||||
|
||||
export const SelectColumn: ColumnDef<any> = {
|
||||
id: 'select',
|
||||
...FIXED_WIDTH_COLUMN,
|
||||
header: ({ table }) => h(Checkbox, {
|
||||
'modelValue': table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate'),
|
||||
'onUpdate:modelValue': value => table.toggleAllPageRowsSelected(!!value),
|
||||
'ariaLabel': 'Select all',
|
||||
}),
|
||||
cell: ({ row }) => h(Checkbox, {
|
||||
'modelValue': row.getIsSelected(),
|
||||
'onUpdate:modelValue': value => row.toggleSelected(!!value),
|
||||
'ariaLabel': 'Select row',
|
||||
}),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
}
|
||||
|
||||
export const RadioSelectColumn: ColumnDef<any> = {
|
||||
id: 'radio-select',
|
||||
...FIXED_WIDTH_COLUMN,
|
||||
header: () => null,
|
||||
cell: ({ row, table }) => h(RadioCell, {
|
||||
checked: row.getIsSelected(),
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
// cancel selection of all rows
|
||||
table.toggleAllRowsSelected(false)
|
||||
// select the current row
|
||||
row.toggleSelected(true)
|
||||
},
|
||||
}),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="h-120 w-full flex items-center justify-center">
|
||||
<UiSpinner class="size-10" />
|
||||
</div>
|
||||
</template>
|
||||
167
monisuo-admin/src/components/data-table/table-pagination.vue
Normal file
167
monisuo-admin/src/components/data-table/table-pagination.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { ChevronLeftIcon, ChevronRightIcon, ChevronsLeft, ChevronsRight } from 'lucide-vue-next'
|
||||
|
||||
import { PAGE_SIZES } from '@/constants/pagination'
|
||||
|
||||
import type { ServerPagination } from './types'
|
||||
|
||||
interface DataTablePaginationProps {
|
||||
table: Table<T>
|
||||
serverPagination?: ServerPagination
|
||||
}
|
||||
const props = defineProps<DataTablePaginationProps>()
|
||||
|
||||
const isServerPagination = computed(() => !!props.serverPagination)
|
||||
|
||||
const currentPage = computed(() => {
|
||||
if (isServerPagination.value && props.serverPagination) {
|
||||
return props.serverPagination.page
|
||||
}
|
||||
return props.table.getState().pagination.pageIndex + 1
|
||||
})
|
||||
|
||||
const currentPageSize = computed(() => {
|
||||
if (isServerPagination.value && props.serverPagination) {
|
||||
return props.serverPagination.pageSize
|
||||
}
|
||||
return props.table.getState().pagination.pageSize
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (isServerPagination.value && props.serverPagination) {
|
||||
return Math.ceil(props.serverPagination.total / props.serverPagination.pageSize)
|
||||
}
|
||||
return props.table.getPageCount()
|
||||
})
|
||||
|
||||
const canPreviousPage = computed(() => {
|
||||
if (isServerPagination.value) {
|
||||
return currentPage.value > 1
|
||||
}
|
||||
return props.table.getCanPreviousPage()
|
||||
})
|
||||
|
||||
const canNextPage = computed(() => {
|
||||
if (isServerPagination.value) {
|
||||
return currentPage.value < totalPages.value
|
||||
}
|
||||
return props.table.getCanNextPage()
|
||||
})
|
||||
|
||||
function handlePageSizeChange(value: any) {
|
||||
if (!value)
|
||||
return
|
||||
const newPageSize = Number(value)
|
||||
if (isServerPagination.value && props.serverPagination?.onPageSizeChange) {
|
||||
props.serverPagination.onPageSizeChange(newPageSize)
|
||||
}
|
||||
else {
|
||||
props.table.setPageSize(newPageSize)
|
||||
}
|
||||
}
|
||||
|
||||
function goToFirstPage() {
|
||||
if (isServerPagination.value && props.serverPagination?.onPageChange) {
|
||||
props.serverPagination.onPageChange(1)
|
||||
}
|
||||
else {
|
||||
props.table.setPageIndex(0)
|
||||
}
|
||||
}
|
||||
|
||||
function goToPreviousPage() {
|
||||
if (isServerPagination.value && props.serverPagination?.onPageChange) {
|
||||
props.serverPagination.onPageChange(currentPage.value - 1)
|
||||
}
|
||||
else {
|
||||
props.table.previousPage()
|
||||
}
|
||||
}
|
||||
|
||||
function goToNextPage() {
|
||||
if (isServerPagination.value && props.serverPagination?.onPageChange) {
|
||||
props.serverPagination.onPageChange(currentPage.value + 1)
|
||||
}
|
||||
else {
|
||||
props.table.nextPage()
|
||||
}
|
||||
}
|
||||
|
||||
function goToLastPage() {
|
||||
if (isServerPagination.value && props.serverPagination?.onPageChange) {
|
||||
props.serverPagination.onPageChange(totalPages.value)
|
||||
}
|
||||
else {
|
||||
props.table.setPageIndex(props.table.getPageCount() - 1)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between px-2 py-2 bg-background">
|
||||
<div class="flex-1" />
|
||||
<div class="flex items-center space-x-6 lg:space-x-8">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="hidden text-sm font-medium line-clamp-1 md:block">
|
||||
Rows per page
|
||||
</p>
|
||||
<UiSelect
|
||||
:model-value="`${currentPageSize}`"
|
||||
@update:model-value="handlePageSizeChange"
|
||||
>
|
||||
<UiSelectTrigger class="h-8 w-[70px]">
|
||||
<UiSelectValue :placeholder="`${currentPageSize}`" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent side="top">
|
||||
<UiSelectItem v-for="pageSize in PAGE_SIZES" :key="pageSize" :value="`${pageSize}`">
|
||||
{{ pageSize }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div class="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
Page {{ currentPage }} of {{ totalPages }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="hidden size-8 p-0 lg:flex"
|
||||
:disabled="!canPreviousPage"
|
||||
@click="goToFirstPage"
|
||||
>
|
||||
<span class="sr-only">Go to first page</span>
|
||||
<ChevronsLeft class="size-4" />
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="size-8 p-0"
|
||||
:disabled="!canPreviousPage"
|
||||
@click="goToPreviousPage"
|
||||
>
|
||||
<span class="sr-only">Go to previous page</span>
|
||||
<ChevronLeftIcon class="size-4" />
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="size-8 p-0"
|
||||
:disabled="!canNextPage"
|
||||
@click="goToNextPage"
|
||||
>
|
||||
<span class="sr-only">Go to next page</span>
|
||||
<ChevronRightIcon class="size-4" />
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="hidden size-8 p-0 lg:flex"
|
||||
:disabled="!canNextPage"
|
||||
@click="goToLastPage"
|
||||
>
|
||||
<span class="sr-only">Go to last page</span>
|
||||
<ChevronsRight class="size-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
22
monisuo-admin/src/components/data-table/types.ts
Normal file
22
monisuo-admin/src/components/data-table/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
export interface FacetedFilterOption {
|
||||
label: string
|
||||
value: string
|
||||
icon?: Component
|
||||
}
|
||||
|
||||
export interface ServerPagination {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange: (pageSize: number) => void
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
loading?: boolean
|
||||
columns: ColumnDef<T, any>[]
|
||||
data: T[]
|
||||
serverPagination?: ServerPagination
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { ColumnFiltersState, ColumnPinningState, PaginationState, SortingState, TableOptionsWithReactiveData, VisibilityState } from '@tanstack/vue-table'
|
||||
|
||||
import { getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
|
||||
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants/pagination'
|
||||
import { valueUpdater } from '@/lib/utils'
|
||||
|
||||
import type { DataTableProps } from './types'
|
||||
|
||||
export function useGenerateVueTable<T>(props: DataTableProps<T>) {
|
||||
const sorting = ref<SortingState>([])
|
||||
const columnFilters = ref<ColumnFiltersState>([])
|
||||
const columnVisibility = ref<VisibilityState>({})
|
||||
const columnPinning = ref<ColumnPinningState>({ left: [], right: [] })
|
||||
const rowSelection = ref({})
|
||||
const pagination = ref<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
})
|
||||
|
||||
const useServerPagination = !!props.serverPagination
|
||||
|
||||
const pageIndex = computed(() => {
|
||||
if (useServerPagination && props.serverPagination) {
|
||||
return props.serverPagination.page - 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
const pageSize = computed(() => {
|
||||
if (useServerPagination && props.serverPagination) {
|
||||
return props.serverPagination.pageSize
|
||||
}
|
||||
return DEFAULT_PAGE_SIZE
|
||||
})
|
||||
|
||||
const pageCount = computed(() => {
|
||||
if (useServerPagination && props.serverPagination) {
|
||||
return Math.ceil(props.serverPagination.total / props.serverPagination.pageSize)
|
||||
}
|
||||
return -1
|
||||
})
|
||||
|
||||
const tableConfig: TableOptionsWithReactiveData<T> = {
|
||||
get data() { return props.data },
|
||||
get columns() { return props.columns },
|
||||
state: {
|
||||
get sorting() { return sorting.value },
|
||||
get columnFilters() { return columnFilters.value },
|
||||
get columnVisibility() { return columnVisibility.value },
|
||||
get columnPinning() { return columnPinning.value },
|
||||
get rowSelection() { return rowSelection.value },
|
||||
get pagination() {
|
||||
if (useServerPagination) {
|
||||
return {
|
||||
pageIndex: pageIndex.value,
|
||||
pageSize: pageSize.value,
|
||||
}
|
||||
}
|
||||
return pagination.value
|
||||
},
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting),
|
||||
onColumnFiltersChange: updaterOrValue => valueUpdater(updaterOrValue, columnFilters),
|
||||
onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility),
|
||||
onColumnPinningChange: updaterOrValue => valueUpdater(updaterOrValue, columnPinning),
|
||||
onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection),
|
||||
onPaginationChange: updaterOrValue => valueUpdater(updaterOrValue, pagination),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
}
|
||||
|
||||
if (useServerPagination) {
|
||||
tableConfig.pageCount = pageCount.value
|
||||
tableConfig.manualPagination = true
|
||||
}
|
||||
else {
|
||||
tableConfig.getPaginationRowModel = getPaginationRowModel()
|
||||
}
|
||||
|
||||
const table = useVueTable<T>(tableConfig)
|
||||
|
||||
return table
|
||||
}
|
||||
59
monisuo-admin/src/components/data-table/view-options.vue
Normal file
59
monisuo-admin/src/components/data-table/view-options.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { RefreshCcw, Settings2 } from 'lucide-vue-next'
|
||||
|
||||
interface DataTableViewOptionsProps {
|
||||
table: Table<T>
|
||||
}
|
||||
|
||||
const props = defineProps<DataTableViewOptionsProps>()
|
||||
|
||||
const columns = computed(() => props.table.getAllColumns()
|
||||
.filter(
|
||||
column =>
|
||||
typeof column.accessorFn !== 'undefined' && column.getCanHide(),
|
||||
))
|
||||
|
||||
function resetColumnVisible() {
|
||||
columns.value.forEach(column => column.toggleVisibility(true))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiDropdownMenu>
|
||||
<UiDropdownMenuTrigger as-child>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="hidden h-8 ml-auto lg:flex"
|
||||
>
|
||||
<Settings2 class="size-4 mr-2" />
|
||||
Columns View
|
||||
</UiButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
<UiDropdownMenuContent align="end" class="w-[150px]">
|
||||
<UiDropdownMenuLabel>Toggle columns</UiDropdownMenuLabel>
|
||||
<UiDropdownMenuSeparator />
|
||||
|
||||
<UiDropdownMenuCheckboxItem
|
||||
v-for="column in columns"
|
||||
:key="column.id"
|
||||
class="capitalize"
|
||||
:model-value="column.getIsVisible()"
|
||||
@update:model-value="(value:boolean) => column.toggleVisibility(!!value)"
|
||||
>
|
||||
{{ column.id }}
|
||||
</UiDropdownMenuCheckboxItem>
|
||||
|
||||
<UiDropdownMenuSeparator />
|
||||
<UiDropdownMenuItem
|
||||
class="capitalize"
|
||||
@click="resetColumnVisible"
|
||||
>
|
||||
<RefreshCcw />
|
||||
Reset
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
</template>
|
||||
29
monisuo-admin/src/components/global-layout/basic-header.vue
Normal file
29
monisuo-admin/src/components/global-layout/basic-header.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { LayoutHeaderProps } from './types'
|
||||
|
||||
defineProps<LayoutHeaderProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
:class="cn(
|
||||
'flex flex-col md:flex-row gap-2 justify-between py-2',
|
||||
sticky ? 'sticky top-0 z-40 bg-background' : '',
|
||||
)"
|
||||
>
|
||||
<main>
|
||||
<h1 class="text-2xl font-bold">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="description" class="text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<aside class="flex items-center gap-2 flex-wrap">
|
||||
<slot name="actions" />
|
||||
</aside>
|
||||
</header>
|
||||
</template>
|
||||
25
monisuo-admin/src/components/global-layout/basic-page.vue
Normal file
25
monisuo-admin/src/components/global-layout/basic-page.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
import type { LayoutHeaderProps } from './types'
|
||||
|
||||
import BasicHeader from './basic-header.vue'
|
||||
|
||||
defineProps<LayoutHeaderProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<BasicHeader
|
||||
:title="title"
|
||||
:description="description"
|
||||
:sticky="sticky"
|
||||
>
|
||||
<template #actions>
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
</BasicHeader>
|
||||
|
||||
<main class="py-4">
|
||||
<slot />
|
||||
</main>
|
||||
</main>
|
||||
</template>
|
||||
6
monisuo-admin/src/components/global-layout/index.ts
Normal file
6
monisuo-admin/src/components/global-layout/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as BasicHeader } from './basic-header.vue'
|
||||
export { default as BasicPage } from './basic-page.vue'
|
||||
export { default as TwoColAside } from './two-col-aside.vue'
|
||||
export { default as TwoColLayout } from './two-col.vue'
|
||||
|
||||
export type * from './types'
|
||||
48
monisuo-admin/src/components/global-layout/two-col-aside.vue
Normal file
48
monisuo-admin/src/components/global-layout/two-col-aside.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronsUpDownIcon } from 'lucide-vue-next'
|
||||
|
||||
import type { TwoColAsideNavItem } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
nav: TwoColAsideNavItem[]
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const currentPath = computed(() => route.path)
|
||||
const activeClass = 'text-primary font-semibold bg-primary/5'
|
||||
|
||||
const currentLink = computed(() => props.nav.find(link => link.url === currentPath.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="flex flex-col gap-2">
|
||||
<router-link
|
||||
v-for="link in props.nav" :key="link.url"
|
||||
:to="link.url"
|
||||
class="items-center hidden px-2 py-1 rounded-md lg:flex hover:bg-primary/5"
|
||||
:class="link.url === currentPath ? activeClass : ''"
|
||||
>
|
||||
<component :is="link.icon" class="size-4 mr-1" />
|
||||
<span>{{ link.title }}</span>
|
||||
</router-link>
|
||||
|
||||
<UiDropdownMenu class="lg:hidden">
|
||||
<UiDropdownMenuTrigger as-child>
|
||||
<UiButton variant="outline" class="w-48 lg:hidden">
|
||||
<component :is="currentLink?.icon" class="size-4 mr-1" />
|
||||
<span>{{ currentLink?.title }}</span>
|
||||
<ChevronsUpDownIcon class="size-4 ml-auto" />
|
||||
</UiButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
<UiDropdownMenuContent class="w-48" align="start">
|
||||
<UiDropdownMenuItem
|
||||
v-for="link in props.nav" :key="link.url"
|
||||
@click="$router.push(link.url)"
|
||||
>
|
||||
<component :is="link.icon" class="size-4 mr-1" />
|
||||
{{ link.title }}
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
</nav>
|
||||
</template>
|
||||
19
monisuo-admin/src/components/global-layout/two-col.vue
Normal file
19
monisuo-admin/src/components/global-layout/two-col.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang='ts' setup>
|
||||
import { cn } from '@/lib/utils'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn(
|
||||
`grid grid-cols-1 lg:grid-cols-[200px_1fr] gap-4 w-full`,
|
||||
)"
|
||||
>
|
||||
<aside>
|
||||
<slot name="aside" />
|
||||
</aside>
|
||||
|
||||
<section>
|
||||
<slot name="default" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
13
monisuo-admin/src/components/global-layout/types.ts
Normal file
13
monisuo-admin/src/components/global-layout/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export interface LayoutHeaderProps {
|
||||
title: string
|
||||
description: string
|
||||
sticky?: boolean
|
||||
}
|
||||
|
||||
export interface TwoColAsideNavItem {
|
||||
title: string
|
||||
url: string
|
||||
icon?: Component
|
||||
}
|
||||
185
monisuo-admin/src/components/inspira-ui/flickering-grid.vue
Normal file
185
monisuo-admin/src/components/inspira-ui/flickering-grid.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FlickeringGridProps {
|
||||
squareSize?: number
|
||||
gridGap?: number
|
||||
flickerChance?: number
|
||||
color?: string
|
||||
width?: number
|
||||
height?: number
|
||||
class?: string
|
||||
maxOpacity?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<FlickeringGridProps>(), {
|
||||
squareSize: 4,
|
||||
gridGap: 6,
|
||||
flickerChance: 0.3,
|
||||
color: 'rgb(0, 0, 0)',
|
||||
maxOpacity: 0.3,
|
||||
})
|
||||
|
||||
const { squareSize, gridGap, flickerChance, color, maxOpacity, width, height } = toRefs(props)
|
||||
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
|
||||
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
|
||||
const context = ref<CanvasRenderingContext2D>()
|
||||
|
||||
const isInView = ref(false)
|
||||
const canvasSize = ref({ width: 0, height: 0 })
|
||||
|
||||
const hexColorRegex = /^#/
|
||||
const computedColor = computed(() => {
|
||||
if (!context.value)
|
||||
return 'rgba(255, 0, 0,'
|
||||
|
||||
const hex = color.value.replace(hexColorRegex, '')
|
||||
const bigint = Number.parseInt(hex, 16)
|
||||
const r = (bigint >> 16) & 255
|
||||
const g = (bigint >> 8) & 255
|
||||
const b = bigint & 255
|
||||
return `rgba(${r}, ${g}, ${b},`
|
||||
})
|
||||
|
||||
function setupCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
width: number,
|
||||
height: number,
|
||||
): {
|
||||
cols: number
|
||||
rows: number
|
||||
squares: Float32Array
|
||||
dpr: number
|
||||
} {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
|
||||
const cols = Math.floor(width / (squareSize.value + gridGap.value))
|
||||
const rows = Math.floor(height / (squareSize.value + gridGap.value))
|
||||
|
||||
const squares = new Float32Array(cols * rows)
|
||||
for (let i = 0; i < squares.length; i++) {
|
||||
squares[i] = Math.random() * maxOpacity.value
|
||||
}
|
||||
return { cols, rows, squares, dpr }
|
||||
}
|
||||
|
||||
function updateSquares(squares: Float32Array, deltaTime: number) {
|
||||
for (let i = 0; i < squares.length; i++) {
|
||||
if (Math.random() < flickerChance.value * deltaTime) {
|
||||
squares[i] = Math.random() * maxOpacity.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawGrid(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
cols: number,
|
||||
rows: number,
|
||||
squares: Float32Array,
|
||||
dpr: number,
|
||||
) {
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
ctx.fillStyle = 'transparent'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
for (let i = 0; i < cols; i++) {
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const opacity = squares[i * rows + j]
|
||||
ctx.fillStyle = `${computedColor.value}${opacity})`
|
||||
ctx.fillRect(
|
||||
i * (squareSize.value + gridGap.value) * dpr,
|
||||
j * (squareSize.value + gridGap.value) * dpr,
|
||||
squareSize.value * dpr,
|
||||
squareSize.value * dpr,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gridParams = ref<ReturnType<typeof setupCanvas>>()
|
||||
|
||||
function updateCanvasSize() {
|
||||
const newWidth = width.value || containerRef.value!.clientWidth
|
||||
const newHeight = height.value || containerRef.value!.clientHeight
|
||||
|
||||
canvasSize.value = { width: newWidth, height: newHeight }
|
||||
gridParams.value = setupCanvas(canvasRef.value!, newWidth, newHeight)
|
||||
}
|
||||
|
||||
let animationFrameId: number | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let intersectionObserver: IntersectionObserver | undefined
|
||||
let lastTime = 0
|
||||
|
||||
function animate(time: number) {
|
||||
if (!isInView.value)
|
||||
return
|
||||
|
||||
const deltaTime = (time - lastTime) / 1000
|
||||
lastTime = time
|
||||
|
||||
updateSquares(gridParams.value!.squares, deltaTime)
|
||||
drawGrid(
|
||||
context.value!,
|
||||
canvasRef.value!.width,
|
||||
canvasRef.value!.height,
|
||||
gridParams.value!.cols,
|
||||
gridParams.value!.rows,
|
||||
gridParams.value!.squares,
|
||||
gridParams.value!.dpr,
|
||||
)
|
||||
animationFrameId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!canvasRef.value || !containerRef.value)
|
||||
return
|
||||
context.value = canvasRef.value.getContext('2d')!
|
||||
if (!context.value)
|
||||
return
|
||||
|
||||
updateCanvasSize()
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updateCanvasSize()
|
||||
})
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isInView.value = entry.isIntersecting
|
||||
animationFrameId = requestAnimationFrame(animate)
|
||||
},
|
||||
{ threshold: 0 },
|
||||
)
|
||||
|
||||
resizeObserver.observe(containerRef.value)
|
||||
intersectionObserver.observe(canvasRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
resizeObserver?.disconnect()
|
||||
intersectionObserver?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
:class="cn('w-full h-full', props.class)"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="pointer-events-none"
|
||||
:width="canvasSize.width"
|
||||
:height="canvasSize.height"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
197
monisuo-admin/src/components/inspira-ui/glowing-effect.vue
Normal file
197
monisuo-admin/src/components/inspira-ui/glowing-effect.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { animate } from 'motion-v'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
blur?: number
|
||||
inactiveZone?: number
|
||||
proximity?: number
|
||||
spread?: number
|
||||
variant?: 'default' | 'white'
|
||||
glow?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
movementDuration?: number
|
||||
borderWidth?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
blur: 0,
|
||||
inactiveZone: 0.7,
|
||||
proximity: 0,
|
||||
spread: 20,
|
||||
variant: 'default',
|
||||
glow: false,
|
||||
movementDuration: 2,
|
||||
borderWidth: 1,
|
||||
disabled: true,
|
||||
})
|
||||
|
||||
const containerRef = useTemplateRef('containerRef')
|
||||
const lastPosition = ref({
|
||||
x: 0,
|
||||
y: 0,
|
||||
})
|
||||
const animationFrame = ref(0)
|
||||
|
||||
const containerStyles = computed(() => {
|
||||
return {
|
||||
'--blur': `${props.blur}px`,
|
||||
'--spread': props.spread,
|
||||
'--start': '0',
|
||||
'--active': '0',
|
||||
'--glowingeffect-border-width': `${props.borderWidth}px`,
|
||||
'--repeating-conic-gradient-times': '5',
|
||||
'--gradient':
|
||||
props.variant === 'white'
|
||||
? `repeating-conic-gradient(
|
||||
from 236.84deg at 50% 50%,
|
||||
var(--black),
|
||||
var(--black) calc(25% / var(--repeating-conic-gradient-times))
|
||||
)`
|
||||
: `radial-gradient(circle, #dd7bbb 10%, #dd7bbb00 20%),
|
||||
radial-gradient(circle at 40% 40%, #d79f1e 5%, #d79f1e00 15%),
|
||||
radial-gradient(circle at 60% 60%, #5a922c 10%, #5a922c00 20%),
|
||||
radial-gradient(circle at 40% 60%, #4c7894 10%, #4c789400 20%),
|
||||
repeating-conic-gradient(
|
||||
from 236.84deg at 50% 50%,
|
||||
#dd7bbb 0%,
|
||||
#d79f1e calc(25% / var(--repeating-conic-gradient-times)),
|
||||
#5a922c calc(50% / var(--repeating-conic-gradient-times)),
|
||||
#4c7894 calc(75% / var(--repeating-conic-gradient-times)),
|
||||
#dd7bbb calc(100% / var(--repeating-conic-gradient-times))
|
||||
)`,
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.disabled)
|
||||
return
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
document.body.addEventListener('pointermove', handlePointerMove, {
|
||||
passive: true,
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationFrame.value) {
|
||||
cancelAnimationFrame(animationFrame.value)
|
||||
}
|
||||
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
document.body.removeEventListener('pointermove', handlePointerMove)
|
||||
})
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
handleMove(e)
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
handleMove()
|
||||
}
|
||||
|
||||
function handleMove(e?: MouseEvent | PointerEvent | { x: number, y: number }) {
|
||||
if (!containerRef.value)
|
||||
return
|
||||
|
||||
if (animationFrame.value) {
|
||||
cancelAnimationFrame(animationFrame.value)
|
||||
}
|
||||
|
||||
animationFrame.value = requestAnimationFrame(() => {
|
||||
const element = containerRef.value
|
||||
|
||||
if (!element)
|
||||
return
|
||||
|
||||
const { left, top, width, height } = element.getBoundingClientRect()
|
||||
|
||||
const mouseX = e?.x ?? lastPosition.value.x
|
||||
const mouseY = e?.y ?? lastPosition.value.y
|
||||
|
||||
if (e) {
|
||||
lastPosition.value = { x: mouseX, y: mouseY }
|
||||
}
|
||||
|
||||
const center = [left + width * 0.5, top + height * 0.5]
|
||||
const distanceFromCenter = Math.hypot(mouseX - center[0], mouseY - center[1])
|
||||
const inactiveRadius = 0.5 * Math.min(width, height) * props.inactiveZone
|
||||
|
||||
if (distanceFromCenter < inactiveRadius) {
|
||||
element.style.setProperty('--active', '0')
|
||||
return
|
||||
}
|
||||
|
||||
const isActive
|
||||
= mouseX > left - props.proximity
|
||||
&& mouseX < left + width + props.proximity
|
||||
&& mouseY > top - props.proximity
|
||||
&& mouseY < top + height + props.proximity
|
||||
|
||||
element.style.setProperty('--active', isActive ? '1' : '0')
|
||||
|
||||
if (!isActive)
|
||||
return
|
||||
|
||||
const currentAngle = Number.parseFloat(element.style.getPropertyValue('--start')) || 0
|
||||
const targetAngle = (180 * Math.atan2(mouseY - center[1], mouseX - center[0])) / Math.PI + 90
|
||||
|
||||
const angleDiff = ((targetAngle - currentAngle + 180) % 360) - 180
|
||||
const newAngle = currentAngle + angleDiff
|
||||
|
||||
animate(currentAngle, newAngle, {
|
||||
duration: props.movementDuration,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
onUpdate: (value) => {
|
||||
element.style.setProperty('--start', String(value))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute -inset-px hidden rounded-[inherit] border opacity-0 transition-opacity',
|
||||
glow && 'opacity-100',
|
||||
variant === 'white' && 'border-white',
|
||||
disabled && 'block!',
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
ref="containerRef"
|
||||
:style="containerStyles"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute inset-0 rounded-[inherit] opacity-100 transition-opacity',
|
||||
glow && 'opacity-100',
|
||||
blur > 0 && 'blur-(--blur)',
|
||||
props.class,
|
||||
disabled && 'hidden!',
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'glow',
|
||||
'rounded-[inherit]',
|
||||
`after:content-[''] after:rounded-[inherit] after:absolute after:inset-[calc(-1*var(--glowingeffect-border-width))]`,
|
||||
'after:[border:var(--glowingeffect-border-width)_solid_transparent]',
|
||||
'after:[background:var(--gradient)] after:bg-fixed',
|
||||
'after:opacity-(--active) after:transition-opacity after:duration-300',
|
||||
'after:[mask-clip:padding-box,border-box]',
|
||||
'after:mask-intersect',
|
||||
'after:mask-[linear-gradient(#0000,#0000),conic-gradient(from_calc((var(--start)-var(--spread))*1deg),#00000000_0deg,#fff,#00000000_calc(var(--spread)*2deg))]',
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
76
monisuo-admin/src/components/inspira-ui/marquee/index.vue
Normal file
76
monisuo-admin/src/components/inspira-ui/marquee/index.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
class?: string
|
||||
reverse?: boolean
|
||||
pauseOnHover?: boolean
|
||||
vertical?: boolean
|
||||
repeat?: number
|
||||
}>(),
|
||||
{
|
||||
pauseOnHover: false,
|
||||
vertical: false,
|
||||
repeat: 4,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group flex overflow-hidden p-2 [--duration:40s] [--gap:1rem] gap-(--gap)',
|
||||
vertical ? 'flex-col' : 'flex-row',
|
||||
$props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-for="index in repeat"
|
||||
:key="index"
|
||||
:class="
|
||||
cn(
|
||||
'flex shrink-0 justify-around gap-(--gap)',
|
||||
vertical ? 'animate-marquee-vertical flex-col' : 'animate-marquee flex-row',
|
||||
pauseOnHover ? 'group-hover:paused' : '',
|
||||
)
|
||||
"
|
||||
:style="{
|
||||
animationDirection: reverse ? 'reverse' : 'normal',
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.animate-marquee {
|
||||
animation: marquee var(--duration) linear infinite;
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
.animate-marquee-vertical {
|
||||
animation: marquee-vertical var(--duration) linear infinite;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-vertical {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
interface Props {
|
||||
img: string
|
||||
name: string
|
||||
username: string
|
||||
body: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure
|
||||
class="relative w-64 cursor-pointer overflow-hidden rounded-xl border border-gray-950/10 bg-gray-950/1 p-4 hover:bg-gray-950/5 dark:border-gray-50/10 dark:bg-gray-50/10 dark:hover:bg-gray-50/15"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<img :src="img" class="rounded-full" width="32" height="32" alt="">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium dark:text-white">
|
||||
{{ name }}
|
||||
</span>
|
||||
<p class="text-xs font-medium dark:text-white/40">
|
||||
{{ username }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<blockquote class="mt-2 text-sm">
|
||||
{{ body }}
|
||||
</blockquote>
|
||||
</figure>
|
||||
</template>
|
||||
46
monisuo-admin/src/components/inspira-ui/ripple/circle.vue
Normal file
46
monisuo-admin/src/components/inspira-ui/ripple/circle.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
size?: number
|
||||
class?: string
|
||||
opacity?: number
|
||||
animationDelay?: number
|
||||
borderStyle?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 210,
|
||||
opacity: 0.24,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('absolute shadow-xl', 'animate-ripple-circle', props.class)" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.animate-ripple-circle {
|
||||
animation: ripple-effect var(--duration, 2s) ease-in-out calc(var(--i, 0) * 0.2s) infinite;
|
||||
border-width: 1px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: v-bind('`${props.size}px`');
|
||||
height: v-bind('`${props.size}px`');
|
||||
animation-delay: v-bind('`${props.animationDelay}ms`');
|
||||
opacity: v-bind('props.opacity');
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
border-style: v-bind('props.borderStyle');
|
||||
}
|
||||
|
||||
@keyframes ripple-effect {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
36
monisuo-admin/src/components/inspira-ui/ripple/container.vue
Normal file
36
monisuo-admin/src/components/inspira-ui/ripple/container.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import RippleCircle from './circle.vue'
|
||||
|
||||
interface Props {
|
||||
baseCircleSize?: number
|
||||
baseCircleOpacity?: number
|
||||
spaceBetweenCircle?: number
|
||||
circleOpacityDowngradeRatio?: number
|
||||
circleClass?: string
|
||||
waveSpeed?: number
|
||||
numberOfCircles?: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
baseCircleSize: 210,
|
||||
baseCircleOpacity: 0.24,
|
||||
circleOpacityDowngradeRatio: 0.03,
|
||||
waveSpeed: 80,
|
||||
spaceBetweenCircle: 70,
|
||||
numberOfCircles: 7,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute inset-0">
|
||||
<RippleCircle
|
||||
v-for="index in numberOfCircles"
|
||||
:key="index"
|
||||
:opacity="baseCircleOpacity - index * circleOpacityDowngradeRatio"
|
||||
:size="baseCircleSize + index * spaceBetweenCircle"
|
||||
:animation-delay="index * waveSpeed"
|
||||
:border-style="index === numberOfCircles - 1 ? 'dashed' : 'solid'"
|
||||
:class="circleClass"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
36
monisuo-admin/src/components/inspira-ui/ripple/index.vue
Normal file
36
monisuo-admin/src/components/inspira-ui/ripple/index.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import RippleCircle from './circle.vue'
|
||||
|
||||
interface Props {
|
||||
baseCircleSize?: number
|
||||
baseCircleOpacity?: number
|
||||
spaceBetweenCircle?: number
|
||||
circleOpacityDowngradeRatio?: number
|
||||
circleClass?: string
|
||||
waveSpeed?: number
|
||||
numberOfCircles?: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
baseCircleSize: 210,
|
||||
baseCircleOpacity: 0.24,
|
||||
circleOpacityDowngradeRatio: 0.03,
|
||||
waveSpeed: 80,
|
||||
spaceBetweenCircle: 70,
|
||||
numberOfCircles: 7,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute inset-0">
|
||||
<RippleCircle
|
||||
v-for="index in numberOfCircles"
|
||||
:key="index"
|
||||
:opacity="baseCircleOpacity - index * circleOpacityDowngradeRatio"
|
||||
:size="baseCircleSize + index * spaceBetweenCircle"
|
||||
:animation-delay="index * waveSpeed"
|
||||
:border-style="index === numberOfCircles - 1 ? 'dashed' : 'solid'"
|
||||
:class="circleClass"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
55
monisuo-admin/src/components/language-change.vue
Normal file
55
monisuo-admin/src/components/language-change.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { AcceptableValue } from 'reka-ui'
|
||||
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { Language } from '@/plugins/i18n'
|
||||
|
||||
import { appLocale, DEFAULT_LOCALE, SUPPORTED_LOCALES } from '@/plugins/i18n'
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
function setDefaultLanguage() {
|
||||
locale.value = DEFAULT_LOCALE
|
||||
appLocale.value = DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
function handleLocaleChange(val: AcceptableValue) {
|
||||
if (typeof val !== 'string' || !SUPPORTED_LOCALES.has(val as Language)) {
|
||||
setDefaultLanguage()
|
||||
return
|
||||
}
|
||||
|
||||
locale.value = val as Language
|
||||
appLocale.value = val as Language
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiDropdownMenu>
|
||||
<UiDropdownMenuTrigger as-child>
|
||||
<UiButton variant="outline">
|
||||
<Icon icon="mdi:translate" class="mr-2" />
|
||||
{{ $t('language') }}
|
||||
</UiButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
<UiDropdownMenuContent>
|
||||
<UiDropdownMenuLabel>{{ $t('changeLanguage') }}</UiDropdownMenuLabel>
|
||||
<UiDropdownMenuSeparator />
|
||||
<UiDropdownMenuRadioGroup
|
||||
v-model="locale"
|
||||
@update:model-value="handleLocaleChange"
|
||||
>
|
||||
<UiDropdownMenuRadioItem value="en">
|
||||
<Icon icon="flag:us-4x3" />
|
||||
<span class="ml-2">English</span>
|
||||
</UiDropdownMenuRadioItem>
|
||||
<UiDropdownMenuRadioItem value="zh">
|
||||
<Icon icon="flag:cn-4x3" />
|
||||
<span class="ml-2">中文</span>
|
||||
</UiDropdownMenuRadioItem>
|
||||
</UiDropdownMenuRadioGroup>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
</template>
|
||||
3
monisuo-admin/src/components/loading.vue
Normal file
3
monisuo-admin/src/components/loading.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<UiSpinner class="w-24 h-24 animate-spin" />
|
||||
</template>
|
||||
48
monisuo-admin/src/components/marketing-layout/the-footer.vue
Normal file
48
monisuo-admin/src/components/marketing-layout/the-footer.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useColorMode } from '@vueuse/core'
|
||||
|
||||
const mode = useColorMode()
|
||||
|
||||
const links = [
|
||||
{
|
||||
name: 'bluesky',
|
||||
icon: 'simple-icons:bluesky',
|
||||
url: 'https://bsky.app/profile/bitmc.bsky.social',
|
||||
},
|
||||
{
|
||||
name: 'github',
|
||||
icon: 'simple-icons:github',
|
||||
url: 'https://www.github.com/whbbit1999/shadcn-vue-admin',
|
||||
},
|
||||
{
|
||||
name: 'bilibili',
|
||||
icon: 'simple-icons:bilibili',
|
||||
url: 'https://space.bilibili.com/104376935',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="min-h-18 flex items-center justify-between">
|
||||
<UiAvatar>
|
||||
<UiAvatarImage :src="`${mode === 'dark' ? '/logo.svg' : '/logo-black.svg'}`" alt="Logo" />
|
||||
</UiAvatar>
|
||||
|
||||
<div>© 2025 Whbbit1999</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<UiButton
|
||||
v-for="link in links"
|
||||
:key="link.name"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
as="a"
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
>
|
||||
<Icon :icon="link.icon" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
40
monisuo-admin/src/components/marketing-layout/the-header.vue
Normal file
40
monisuo-admin/src/components/marketing-layout/the-header.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import { useColorMode } from '@vueuse/core'
|
||||
|
||||
import LanguageChange from '@/components/language-change.vue'
|
||||
import SignInButton from '@/components/sign-in-button.vue'
|
||||
import SignUpButton from '@/components/sign-up-button.vue'
|
||||
import ToggleTheme from '@/components/toggle-theme.vue'
|
||||
|
||||
const mode = useColorMode()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="h-14 flex items-center marketing-header sticky top-0 z-99">
|
||||
<router-link to="/" class="flex items-center gap-2">
|
||||
<UiAvatar>
|
||||
<UiAvatarImage :src="`${mode === 'dark' ? '/logo.svg' : '/logo-black.svg'}`" alt="Logo" />
|
||||
</UiAvatar>
|
||||
<span class="text-base font-bold">Shadcn Vue Admin</span>
|
||||
</router-link>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<div class="mr-2 hidden lg:flex lg:gap-2">
|
||||
<SignInButton />
|
||||
<SignUpButton />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<LanguageChange />
|
||||
<ToggleTheme />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.marketing-header {
|
||||
backdrop-filter: saturate(50%) blur(4px);
|
||||
background-size: 4px 4px;
|
||||
}
|
||||
</style>
|
||||
91
monisuo-admin/src/components/marketing/evaluation.vue
Normal file
91
monisuo-admin/src/components/marketing/evaluation.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script lang="ts" setup>
|
||||
import Marquee from '@/components/inspira-ui/marquee/index.vue'
|
||||
import MarqueeReviewCard from '@/components/inspira-ui/marquee/review-card.vue'
|
||||
|
||||
const reviews = [
|
||||
{
|
||||
name: 'Jack',
|
||||
username: '@jack',
|
||||
body: 'I\'ve never seen anything like this before. It\'s amazing. I love it.',
|
||||
img: 'https://avatar.vercel.sh/jack',
|
||||
},
|
||||
{
|
||||
name: 'Jill',
|
||||
username: '@jill',
|
||||
body: 'I don\'t know what to say. I\'m speechless. This is amazing.',
|
||||
img: 'https://avatar.vercel.sh/jill',
|
||||
},
|
||||
{
|
||||
name: 'John',
|
||||
username: '@john',
|
||||
body: 'I\'m at a loss for words. This is amazing. I love it.',
|
||||
img: 'https://avatar.vercel.sh/john',
|
||||
},
|
||||
{
|
||||
name: 'Jane',
|
||||
username: '@jane',
|
||||
body: 'I\'m at a loss for words. This is amazing. I love it.',
|
||||
img: 'https://avatar.vercel.sh/jane',
|
||||
},
|
||||
{
|
||||
name: 'Jenny',
|
||||
username: '@jenny',
|
||||
body: 'I\'m at a loss for words. This is amazing. I love it.',
|
||||
img: 'https://avatar.vercel.sh/jenny',
|
||||
},
|
||||
{
|
||||
name: 'James',
|
||||
username: '@james',
|
||||
body: 'I\'m at a loss for words. This is amazing. I love it.',
|
||||
img: 'https://avatar.vercel.sh/james',
|
||||
},
|
||||
]
|
||||
|
||||
// Split reviews into two rows
|
||||
const firstRow = ref(reviews.slice(0, reviews.length / 2))
|
||||
const secondRow = ref(reviews.slice(reviews.length / 2))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 class="text-4xl font-black my-4 text-center">
|
||||
{{ $t('marketing.evaluation.title') }}
|
||||
</h2>
|
||||
<h4 class="text-center mb-4">
|
||||
{{ $t('marketing.evaluation.subtitle') }}
|
||||
</h4>
|
||||
<div
|
||||
class="relative flex w-full flex-col items-center justify-center overflow-hidden"
|
||||
>
|
||||
<Marquee pause-on-hover class="[--duration:50s]">
|
||||
<MarqueeReviewCard
|
||||
v-for="review in firstRow"
|
||||
:key="review.username"
|
||||
:img="review.img"
|
||||
:name="review.name"
|
||||
:username="review.username"
|
||||
:body="review.body"
|
||||
/>
|
||||
</Marquee>
|
||||
|
||||
<Marquee reverse pause-on-hover class="[--duration:50s]">
|
||||
<MarqueeReviewCard
|
||||
v-for="review in secondRow"
|
||||
:key="review.username"
|
||||
:img="review.img"
|
||||
:name="review.name"
|
||||
:username="review.username"
|
||||
:body="review.body"
|
||||
/>
|
||||
</Marquee>
|
||||
|
||||
<!-- Left Gradient -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 w-1/3 bg-linear-to-r from-(--ui-bg) dark:from-(--ui-bg)"
|
||||
/>
|
||||
|
||||
<!-- Right Gradient -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 w-1/3 bg-linear-to-l from-(--ui-bg) dark:from-(--ui-bg)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
84
monisuo-admin/src/components/marketing/features.vue
Normal file
84
monisuo-admin/src/components/marketing/features.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import GlowingEffect from '@/components/inspira-ui/glowing-effect.vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const gridItems = computed(() => [
|
||||
{
|
||||
icon: 'lucide:box',
|
||||
title: t('marketing.features.feature1.title'),
|
||||
description: t('marketing.features.feature1.description'),
|
||||
},
|
||||
{
|
||||
icon: 'lucide:settings',
|
||||
title: t('marketing.features.feature2.title'),
|
||||
description: t('marketing.features.feature2.description'),
|
||||
},
|
||||
{
|
||||
icon: 'lucide:sparkles',
|
||||
title: t('marketing.features.feature3.title'),
|
||||
description: t('marketing.features.feature3.description'),
|
||||
},
|
||||
{
|
||||
icon: 'lucide:search',
|
||||
title: t('marketing.features.feature4.title'),
|
||||
description: t('marketing.features.feature4.description'),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-4xl font-bold text-center mb-8">
|
||||
{{ $t('marketing.features.title') }}
|
||||
</h2>
|
||||
|
||||
<ul
|
||||
class="grid grid-cols-1 grid-rows-none gap-4 overflow-auto xl:max-h-[56rem] xl:grid-rows-2 lg:gap-4 md:grid-cols-2 md:grid-rows-3"
|
||||
>
|
||||
<li
|
||||
v-for="item in gridItems"
|
||||
:key="item.title"
|
||||
:class="cn('min-h-[14rem] list-none')"
|
||||
>
|
||||
<div class="rounded-2.5xl relative h-full border p-2 md:rounded-3xl md:p-3">
|
||||
<GlowingEffect
|
||||
:spread="40"
|
||||
:glow="true"
|
||||
:disabled="false"
|
||||
:proximity="64"
|
||||
:inactive-zone="0.01"
|
||||
/>
|
||||
<div
|
||||
class="border-0.75 relative flex h-full flex-col justify-between gap-6 overflow-hidden rounded-xl p-6 md:p-6 dark:shadow-[0px_0px_27px_0px_#2D2D2D]"
|
||||
>
|
||||
<div class="relative flex flex-1 flex-col justify-between gap-3">
|
||||
<div class="w-fit rounded-lg border border-gray-600 p-2">
|
||||
<Icon
|
||||
class="size-4 text-black dark:text-neutral-500"
|
||||
:icon="item.icon"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<h3
|
||||
class="-tracking-4 text-balance pt-0.5 font-sans text-xl/[1.375rem] font-semibold text-black md:text-2xl/[1.875rem] dark:text-white"
|
||||
>
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<h2
|
||||
class="font-sans text-sm/[1.125rem] text-black md:text-base/[1.375rem] dark:text-neutral-400 [&_b]:md:font-semibold [&_strong]:md:font-semibold"
|
||||
>
|
||||
{{ item.description }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
81
monisuo-admin/src/components/marketing/hero.vue
Normal file
81
monisuo-admin/src/components/marketing/hero.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts" setup>
|
||||
import Autoplay from 'embla-carousel-autoplay'
|
||||
|
||||
const images = [
|
||||
'https://picsum.photos/640/640?random=1',
|
||||
'https://picsum.photos/640/640?random=2',
|
||||
'https://picsum.photos/640/640?random=3',
|
||||
'https://picsum.photos/640/640?random=4',
|
||||
'https://picsum.photos/640/640?random=5',
|
||||
'https://picsum.photos/640/640?random=6',
|
||||
]
|
||||
|
||||
const users: { avatar: string, name: string, id: number }[] = [
|
||||
{ avatar: 'https://github.com/benjamincanac.png', name: 'Benjamin Canac', id: 1 },
|
||||
{ avatar: 'https://github.com/romhml.png', name: 'Benjamin Canac', id: 2 },
|
||||
{ avatar: 'https://github.com/noook.png', name: 'Benjamin Canac', id: 3 },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex gap-8 justify-between flex-col lg:flex-row">
|
||||
<aside class="w-full lg:w-1/3">
|
||||
<p class="text-4xl font-black relative">
|
||||
{{ $t('marketing.hero.title') }}
|
||||
</p>
|
||||
|
||||
<div class="font-bold mt-2 relative">
|
||||
{{ $t('marketing.hero.subtitle') }}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 my-12 relative">
|
||||
<UiButton>
|
||||
{{ $t('marketing.hero.getMore') }}
|
||||
</UiButton>
|
||||
|
||||
<img
|
||||
src="@/assets/icons/arrow-dark.svg"
|
||||
alt=""
|
||||
class="dark:hidden block w-12 h-12 absolute top-[110%] left-8 -rotate-90"
|
||||
>
|
||||
<img
|
||||
src="@/assets/icons/arrow-light.svg"
|
||||
alt=""
|
||||
class="dark:block hidden w-12 h-12 absolute top-[110%] left-8 -rotate-90"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex gap-2">
|
||||
<UiAvatar v-for="user in users" :key="user.id">
|
||||
<UiAvatarImage :src="user.avatar" />
|
||||
</UiAvatar>
|
||||
</div>
|
||||
|
||||
<span class="font-black">
|
||||
{{ $t('marketing.hero.learnPeople') }}
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="w-full lg:w-2/3 lg:px-2">
|
||||
<UiCarousel
|
||||
:opts="{
|
||||
align: 'start',
|
||||
loop: true,
|
||||
}"
|
||||
:plugins="[Autoplay({
|
||||
delay: 2000,
|
||||
})]"
|
||||
>
|
||||
<UiCarouselContent>
|
||||
<UiCarouselItem v-for="image in images" :key="image" class="basis-1/3">
|
||||
<img :src="image" width="320" height="320" class="rounded-lg">
|
||||
</UiCarouselItem>
|
||||
</UiCarouselContent>
|
||||
<UiCarouselPrevious class="hidden lg:flex" />
|
||||
<UiCarouselNext class="hidden lg:flex" />
|
||||
</UiCarousel>
|
||||
</aside>
|
||||
</main>
|
||||
</template>
|
||||
42
monisuo-admin/src/components/marketing/logos.vue
Normal file
42
monisuo-admin/src/components/marketing/logos.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
import Marquee from '@/components/inspira-ui/marquee/index.vue'
|
||||
|
||||
const types = [
|
||||
{ name: 'Nuxt', icon: 'simple-icons:nuxt' },
|
||||
{ name: 'Vue', icon: 'simple-icons:vitess' },
|
||||
{ name: 'Vite', icon: 'simple-icons:vite' },
|
||||
{ name: 'vitest', icon: 'simple-icons:vitest' },
|
||||
{ name: 'vscode', icon: 'simple-icons:visualstudiocode' },
|
||||
{ name: 'mysql', icon: 'simple-icons:mysql' },
|
||||
{ name: 'prisma', icon: 'simple-icons:prisma' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex w-full flex-col items-center justify-center overflow-hidden -rotate-3"
|
||||
>
|
||||
<Marquee pause-on-hover reverse class="[--duration:50s]">
|
||||
<div
|
||||
v-for="type in types"
|
||||
:key="type.name"
|
||||
class="flex items-center gap-2 mx-4"
|
||||
>
|
||||
<Icon :icon="type.icon" class="w-12 h-12" />
|
||||
<span class="font-black text-4xl">{{ type.name }}</span>
|
||||
</div>
|
||||
</Marquee>
|
||||
|
||||
<!-- Left Gradient -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 w-1/3 bg-linear-to-r from-(--ui-bg) dark:from-(--ui-bg)"
|
||||
/>
|
||||
|
||||
<!-- Right Gradient -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 w-1/3 bg-linear-to-l from-(--ui-bg) dark:from-(--ui-bg)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
141
monisuo-admin/src/components/marketing/pricing-plans/index.vue
Normal file
141
monisuo-admin/src/components/marketing/pricing-plans/index.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Plan {
|
||||
id: string | number
|
||||
title: string
|
||||
description: string
|
||||
badge?: string
|
||||
price: string
|
||||
unit: string
|
||||
discount: string
|
||||
recommendation?: boolean
|
||||
billing?: {
|
||||
cycle: string
|
||||
period: string
|
||||
}
|
||||
features: string[]
|
||||
}
|
||||
|
||||
const plans = computed<Plan[]>(() => [
|
||||
{
|
||||
id: 1,
|
||||
title: t('marketing.pricingPlans.hobby.title'),
|
||||
description: t('marketing.pricingPlans.hobby.description'),
|
||||
price: t('marketing.pricingPlans.hobby.price'),
|
||||
discount: t('marketing.pricingPlans.hobby.discount'),
|
||||
unit: t('marketing.pricingPlans.hobby.unit'),
|
||||
billing: {
|
||||
cycle: t('marketing.pricingPlans.hobby.billing.cycle'),
|
||||
period: t('marketing.pricingPlans.hobby.billing.period'),
|
||||
},
|
||||
features: [
|
||||
t('marketing.pricingPlans.hobby.features.feature1'),
|
||||
t('marketing.pricingPlans.hobby.features.feature2'),
|
||||
t('marketing.pricingPlans.hobby.features.feature3'),
|
||||
t('marketing.pricingPlans.hobby.features.feature4'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
recommendation: true,
|
||||
title: t('marketing.pricingPlans.starter.title'),
|
||||
description: t('marketing.pricingPlans.starter.description'),
|
||||
price: t('marketing.pricingPlans.starter.price'),
|
||||
discount: t('marketing.pricingPlans.starter.discount'),
|
||||
unit: t('marketing.pricingPlans.starter.unit'),
|
||||
billing: {
|
||||
cycle: t('marketing.pricingPlans.starter.billing.cycle'),
|
||||
period: t('marketing.pricingPlans.starter.billing.period'),
|
||||
},
|
||||
features: [
|
||||
t('marketing.pricingPlans.starter.features.feature1'),
|
||||
t('marketing.pricingPlans.starter.features.feature2'),
|
||||
t('marketing.pricingPlans.starter.features.feature3'),
|
||||
t('marketing.pricingPlans.starter.features.feature4'),
|
||||
t('marketing.pricingPlans.starter.features.feature5'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: t('marketing.pricingPlans.business.title'),
|
||||
description: t('marketing.pricingPlans.business.description'),
|
||||
price: t('marketing.pricingPlans.business.price'),
|
||||
discount: t('marketing.pricingPlans.business.discount'),
|
||||
unit: t('marketing.pricingPlans.business.unit'),
|
||||
billing: {
|
||||
cycle: t('marketing.pricingPlans.business.billing.cycle'),
|
||||
period: t('marketing.pricingPlans.business.billing.period'),
|
||||
},
|
||||
features: [
|
||||
t('marketing.pricingPlans.business.features.feature1'),
|
||||
t('marketing.pricingPlans.business.features.feature2'),
|
||||
t('marketing.pricingPlans.business.features.feature3'),
|
||||
t('marketing.pricingPlans.business.features.feature4'),
|
||||
t('marketing.pricingPlans.business.features.feature5'),
|
||||
t('marketing.pricingPlans.business.features.feature6'),
|
||||
],
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="pricing-plans">
|
||||
<h2 class="text-center font-black my-4 text-4xl">
|
||||
{{ $t('marketing.pricingPlans.title') }}
|
||||
</h2>
|
||||
<h4 class="text-center text-xl">
|
||||
{{ $t('marketing.pricingPlans.subtitle') }}
|
||||
</h4>
|
||||
<div
|
||||
class="flex flex-col lg:flex-row lg:items-start items-center justify-center gap-4 mt-8"
|
||||
>
|
||||
<UiCard
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
class="w-full lg:w-1/5"
|
||||
:class="{
|
||||
'border-2 border-primary bg-primary/10':
|
||||
plan.recommendation,
|
||||
}"
|
||||
>
|
||||
<h3 class="text-xl font-black text-center">
|
||||
{{ plan.title }}
|
||||
</h3>
|
||||
<div class="text-sm text-center text-neutral-400">
|
||||
{{ plan.description }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-top my-2 justify-center">
|
||||
<div class="text-2xl font-black">
|
||||
{{ plan.unit }}
|
||||
<span class="text-4xl">{{ plan.price }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="plan.discount"
|
||||
class="text-sm font-bold line-through text-neutral-400"
|
||||
>
|
||||
{{ plan.unit }}{{ plan.discount }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm mb-4 text-center">
|
||||
<ul>
|
||||
<li v-for="feature in plan.features" :key="feature" class="mb-1">
|
||||
<Icon icon="carbon:checkmark" class="inline-block" />
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex justify-center mx-8">
|
||||
<UiButton block>
|
||||
{{ $t('marketing.pricingPlans.buy') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
34
monisuo-admin/src/components/marketing/setup.vue
Normal file
34
monisuo-admin/src/components/marketing/setup.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import Ripple from '@/components/inspira-ui/ripple/index.vue'
|
||||
import SignInButton from '@/components/sign-in-button.vue'
|
||||
import SignUpButton from '@/components/sign-up-button.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex h-[450px] w-full flex-col items-center justify-center overflow-hidden rounded-lg lg:w-full md:w-full"
|
||||
>
|
||||
<p class="z-10 whitespace-pre-wrap text-center text-5xl font-medium tracking-tighter text-black dark:text-white">
|
||||
{{ $t('marketing.setup.title') }}
|
||||
</p>
|
||||
<small class="mt-2">
|
||||
{{ $t('marketing.setup.subtitle') }}
|
||||
</small>
|
||||
|
||||
<div class="flex items-center gap-3 my-2 z-100">
|
||||
<SignInButton />
|
||||
<SignUpButton />
|
||||
</div>
|
||||
|
||||
<Ripple
|
||||
class="bg-white/5 mask-[linear-gradient(to_bottom,white,transparent)]"
|
||||
circle-class="border-[hsl(var(--primary))] bg-primary/25 blobed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.blobed) {
|
||||
border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%;
|
||||
}
|
||||
</style>
|
||||
19
monisuo-admin/src/components/no-result-found.vue
Normal file
19
monisuo-admin/src/components/no-result-found.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang='ts' setup>
|
||||
import { FolderOpenIcon } from 'lucide-vue-next'
|
||||
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<FolderOpenIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No result found.</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Please try a different search term or check the spelling.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</template>
|
||||
72
monisuo-admin/src/components/prop-ui/copy/Copy.vue
Normal file
72
monisuo-admin/src/components/prop-ui/copy/Copy.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { Copy, CopyCheck } from 'lucide-vue-next'
|
||||
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { copyVariants } from '.'
|
||||
|
||||
interface Props {
|
||||
content: string
|
||||
size?: 'sm' | 'default'
|
||||
variant?: ButtonVariants['variant']
|
||||
class?: HTMLAttributes['class']
|
||||
copyTooltipText?: string
|
||||
copiedTooltipText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'default',
|
||||
variant: 'outline',
|
||||
copyTooltipText: 'Copy',
|
||||
copiedTooltipText: 'Copied',
|
||||
})
|
||||
|
||||
const iconSize = computed(() => {
|
||||
return props.size === 'sm' ? 'sm' : 'default'
|
||||
})
|
||||
|
||||
const size = computed(() => {
|
||||
return props.size === 'sm' ? 'sm' : 'icon'
|
||||
})
|
||||
|
||||
const source = computed(() => props.content)
|
||||
|
||||
const { copy, copied, isSupported } = useClipboard({ source })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="isSupported">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:variant="props.variant"
|
||||
:size="size"
|
||||
:class="cn(props.class)"
|
||||
@click="copy(source)"
|
||||
>
|
||||
<Copy v-if="!copied" :class="cn(copyVariants({ iconSize }))" />
|
||||
<CopyCheck v-else :class="cn(copyVariants({ iconSize }))" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p v-if="!copied">{{ props.copyTooltipText }}: {{ props.content }}</p>
|
||||
<p v-else>{{ props.copiedTooltipText }}: {{ props.content }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
<span v-else>Your browser does not support Clipboard API</span>
|
||||
</template>
|
||||
22
monisuo-admin/src/components/prop-ui/copy/index.ts
Normal file
22
monisuo-admin/src/components/prop-ui/copy/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Copy } from './Copy.vue'
|
||||
|
||||
export const copyVariants = cva(
|
||||
'',
|
||||
{
|
||||
variants: {
|
||||
iconSize: {
|
||||
default: 'size-4',
|
||||
sm: 'size-3',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
iconSize: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type CopyVariants = VariantProps<typeof copyVariants>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { InlineTipVariants } from '.'
|
||||
|
||||
import { inlineTipVariants } from '.'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
variant?: InlineTipVariants['variant']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'info',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn(
|
||||
'bg-secondary text-secondary-foreground text-sm inline-grid grid-cols-[4px_1fr] items-start gap-3 rounded-md border p-3',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<div
|
||||
:class="cn(
|
||||
'h-full w-1 rounded-full',
|
||||
inlineTipVariants({ variant: props.variant }))"
|
||||
/>
|
||||
|
||||
<div class="text-muted-foreground">
|
||||
<strong class="text-sm font-semibold text-foreground mr-2">{{ props.label }}:</strong>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
24
monisuo-admin/src/components/prop-ui/inline-tip/index.ts
Normal file
24
monisuo-admin/src/components/prop-ui/inline-tip/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as InlineTip } from './InlineTip.vue'
|
||||
|
||||
export const inlineTipVariants = cva(
|
||||
'',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
info: 'bg-stone-400 dark:bg-stone-600',
|
||||
warning: 'bg-yellow-400 dark:bg-yellow-600',
|
||||
success: 'bg-green-400 dark:bg-green-600',
|
||||
error: 'bg-rose-400 dark:bg-rose-600',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'info',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type InlineTipVariants = VariantProps<typeof inlineTipVariants>
|
||||
28
monisuo-admin/src/components/prop-ui/modal/Modal.vue
Normal file
28
monisuo-admin/src/components/prop-ui/modal/Modal.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DialogRootProps } from 'reka-ui'
|
||||
import type { DrawerRootProps } from 'vaul-vue'
|
||||
|
||||
import { useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
import { useModal } from './use-modal'
|
||||
|
||||
type Props = DrawerRootProps | DialogRootProps
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emits = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
}>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
|
||||
const { Modal } = useModal()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="Modal.Root"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
21
monisuo-admin/src/components/prop-ui/modal/ModalClose.vue
Normal file
21
monisuo-admin/src/components/prop-ui/modal/ModalClose.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DialogCloseProps } from 'reka-ui'
|
||||
import type { DrawerCloseProps } from 'vaul-vue'
|
||||
|
||||
import { useModal } from './use-modal'
|
||||
|
||||
type Props = DrawerCloseProps | DialogCloseProps
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { Modal } = useModal()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="Modal.Close"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
29
monisuo-admin/src/components/prop-ui/modal/ModalContent.vue
Normal file
29
monisuo-admin/src/components/prop-ui/modal/ModalContent.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { useModal } from './use-modal'
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const { Modal, contentClass } = useModal()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
|
||||
const mergedClass = computed(() => cn(contentClass.value, props.class))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="Modal.Content"
|
||||
v-bind="forwarded"
|
||||
:class="mergedClass"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DialogDescriptionProps } from 'reka-ui'
|
||||
import type { DrawerDescriptionProps } from 'vaul-vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { useForwardProps } from 'reka-ui'
|
||||
|
||||
import { useModal } from './use-modal'
|
||||
|
||||
type Props = (DrawerDescriptionProps | DialogDescriptionProps) & { class?: HTMLAttributes['class'] }
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
|
||||
const { Modal } = useModal()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="Modal.Description"
|
||||
v-bind="forwardedProps"
|
||||
:class="props.class"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
22
monisuo-admin/src/components/prop-ui/modal/ModalFooter.vue
Normal file
22
monisuo-admin/src/components/prop-ui/modal/ModalFooter.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { useModal } from './use-modal'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { Modal } = useModal()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="Modal.Footer"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
22
monisuo-admin/src/components/prop-ui/modal/ModalHeader.vue
Normal file
22
monisuo-admin/src/components/prop-ui/modal/ModalHeader.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { useModal } from './use-modal'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { Modal } = useModal()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="Modal.Header"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
30
monisuo-admin/src/components/prop-ui/modal/ModalTitle.vue
Normal file
30
monisuo-admin/src/components/prop-ui/modal/ModalTitle.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DialogTitleProps } from 'reka-ui'
|
||||
import type { DrawerTitleProps } from 'vaul-vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { useForwardProps } from 'reka-ui'
|
||||
|
||||
import { useModal } from './use-modal'
|
||||
|
||||
type Props = (DialogTitleProps | DrawerTitleProps) & { class?: HTMLAttributes['class'] }
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
|
||||
const { Modal } = useModal()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="Modal.Title"
|
||||
v-bind="forwardedProps"
|
||||
:class="props.class"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
21
monisuo-admin/src/components/prop-ui/modal/ModalTrigger.vue
Normal file
21
monisuo-admin/src/components/prop-ui/modal/ModalTrigger.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DialogTriggerProps } from 'reka-ui'
|
||||
import type { DrawerTriggerProps } from 'vaul-vue'
|
||||
|
||||
import { useModal } from './use-modal'
|
||||
|
||||
type Props = DialogTriggerProps | DrawerTriggerProps
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { Modal } = useModal()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="Modal.Trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
10
monisuo-admin/src/components/prop-ui/modal/index.ts
Normal file
10
monisuo-admin/src/components/prop-ui/modal/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { default as Modal } from './Modal.vue'
|
||||
export { default as ModalClose } from './ModalClose.vue'
|
||||
export { default as ModalContent } from './ModalContent.vue'
|
||||
export { default as ModalDescription } from './ModalDescription.vue'
|
||||
export { default as ModalFooter } from './ModalFooter.vue'
|
||||
export { default as ModalHeader } from './ModalHeader.vue'
|
||||
export { default as ModalTitle } from './ModalTitle.vue'
|
||||
export { default as ModalTrigger } from './ModalTrigger.vue'
|
||||
|
||||
export * from './use-modal'
|
||||
31
monisuo-admin/src/components/prop-ui/modal/use-modal.ts
Normal file
31
monisuo-admin/src/components/prop-ui/modal/use-modal.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createSharedComposable, useMediaQuery } from '@vueuse/core'
|
||||
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'
|
||||
|
||||
const useSharedModal = createSharedComposable(() => {
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)')
|
||||
|
||||
const Modal = computed(() => ({
|
||||
Root: isDesktop.value ? Dialog : Drawer,
|
||||
Trigger: isDesktop.value ? DialogTrigger : DrawerTrigger,
|
||||
Content: isDesktop.value ? DialogContent : DrawerContent,
|
||||
Header: isDesktop.value ? DialogHeader : DrawerHeader,
|
||||
Title: isDesktop.value ? DialogTitle : DrawerTitle,
|
||||
Description: isDesktop.value ? DialogDescription : DrawerDescription,
|
||||
Footer: isDesktop.value ? DialogFooter : DrawerFooter,
|
||||
Close: isDesktop.value ? DialogClose : DrawerClose,
|
||||
}))
|
||||
|
||||
const contentClass = computed(() => (isDesktop.value ? '' : 'px-2 pb-8 *:px-4'))
|
||||
|
||||
return {
|
||||
isDesktop,
|
||||
Modal,
|
||||
contentClass,
|
||||
}
|
||||
})
|
||||
|
||||
export function useModal() {
|
||||
return useSharedModal()
|
||||
}
|
||||
33
monisuo-admin/src/components/prop-ui/status-badge/Status.vue
Normal file
33
monisuo-admin/src/components/prop-ui/status-badge/Status.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { StatusVariants } from '.'
|
||||
|
||||
import { statusVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
color?: StatusVariants['color']
|
||||
rounded?: StatusVariants['rounded']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="cn(
|
||||
'mr-1',
|
||||
statusVariants({ color, rounded }),
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span
|
||||
:class="cn(statusVariants({ color, rounded }), 'absolute inline-flex h-full w-full animate-ping opacity-75')"
|
||||
/>
|
||||
<span :class="cn(statusVariants({ color, rounded }), props.class, 'relative inline-flex')" />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { BadgeVariants } from '@/components/ui/badge'
|
||||
|
||||
import { badgeVariants } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { StatusVariants } from '.'
|
||||
|
||||
import Status from './Status.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
variant?: BadgeVariants['variant']
|
||||
rounded?: StatusVariants['rounded']
|
||||
class?: HTMLAttributes['class']
|
||||
color?: StatusVariants['color']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(badgeVariants({ variant }), props.class)">
|
||||
<Status :color="props.color" :rounded="props.rounded" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
35
monisuo-admin/src/components/prop-ui/status-badge/index.ts
Normal file
35
monisuo-admin/src/components/prop-ui/status-badge/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Status } from './Status.vue'
|
||||
export { default as StatusBadge } from './StatusBadge.vue'
|
||||
|
||||
export const statusVariants = cva(
|
||||
'relative flex size-2',
|
||||
{
|
||||
variants: {
|
||||
rounded: {
|
||||
default: 'rounded-full',
|
||||
xs: 'rounded-xs',
|
||||
},
|
||||
color: {
|
||||
green: 'bg-green-500',
|
||||
red: 'bg-rose-500',
|
||||
blue: 'bg-blue-500',
|
||||
orange: 'bg-orange-500',
|
||||
purple: 'bg-purple-500',
|
||||
gray: 'bg-gray-300',
|
||||
},
|
||||
size: {
|
||||
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
color: 'green',
|
||||
rounded: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type StatusVariants = VariantProps<typeof statusVariants>
|
||||
9
monisuo-admin/src/components/sign-in-button.vue
Normal file
9
monisuo-admin/src/components/sign-in-button.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiButton as="a" href="/auth/sign-in">
|
||||
{{ $t('login') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
9
monisuo-admin/src/components/sign-up-button.vue
Normal file
9
monisuo-admin/src/components/sign-up-button.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiButton as="a" href="/auth/sign-up" variant="outline">
|
||||
{{ $t('register') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
2
monisuo-admin/src/components/sort-select/index.ts
Normal file
2
monisuo-admin/src/components/sort-select/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as SortSelect } from './sort-select.vue'
|
||||
export type * from './types'
|
||||
38
monisuo-admin/src/components/sort-select/sort-select.vue
Normal file
38
monisuo-admin/src/components/sort-select/sort-select.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowDownAZ, ArrowDownZA, SlidersHorizontal } from 'lucide-vue-next'
|
||||
|
||||
import type { TSort } from './types'
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:sort', payload: TSort): void
|
||||
}>()
|
||||
|
||||
const sort = defineModel<TSort>({ default: 'asc' })
|
||||
watch(sort, (newValue) => {
|
||||
emits('update:sort', newValue!)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSelect v-model:model-value="sort">
|
||||
<UiSelectTrigger class="w-16">
|
||||
<UiSelectValue>
|
||||
<SlidersHorizontal :size="16" />
|
||||
</UiSelectValue>
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent align="end">
|
||||
<UiSelectItem value="asc">
|
||||
<div class="flex items-center gap-4">
|
||||
<ArrowDownAZ :size="16" />
|
||||
<span>Ascending</span>
|
||||
</div>
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="desc">
|
||||
<div class="flex items-center gap-4">
|
||||
<ArrowDownZA :size="16" />
|
||||
<span>Descending</span>
|
||||
</div>
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</template>
|
||||
1
monisuo-admin/src/components/sort-select/types.ts
Normal file
1
monisuo-admin/src/components/sort-select/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type TSort = 'asc' | 'desc'
|
||||
32
monisuo-admin/src/components/toggle-theme.vue
Normal file
32
monisuo-admin/src/components/toggle-theme.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { useColorMode } from '@vueuse/core'
|
||||
import { Moon, Sun, SunMoon } from 'lucide-vue-next'
|
||||
|
||||
const mode = useColorMode()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiDropdownMenu>
|
||||
<UiDropdownMenuTrigger as-child>
|
||||
<UiButton variant="outline" size="icon">
|
||||
<Moon class=" rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Sun class="absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</UiButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
<UiDropdownMenuContent align="end">
|
||||
<UiDropdownMenuItem @click="mode = 'light'">
|
||||
<Sun />
|
||||
Light
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuItem @click="mode = 'dark'">
|
||||
<Moon />
|
||||
Dark
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuItem @click="mode = 'auto'">
|
||||
<SunMoon />
|
||||
System
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
</template>
|
||||
18
monisuo-admin/src/components/ui/accordion/Accordion.vue
Normal file
18
monisuo-admin/src/components/ui/accordion/Accordion.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionRootEmits, AccordionRootProps } from "reka-ui"
|
||||
import {
|
||||
AccordionRoot,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
const props = defineProps<AccordionRootProps>()
|
||||
const emits = defineEmits<AccordionRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionRoot v-slot="slotProps" data-slot="accordion" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</AccordionRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AccordionContent } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionContent
|
||||
data-slot="accordion-content"
|
||||
v-bind="delegatedProps"
|
||||
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
>
|
||||
<div :class="cn('pt-0 pb-4', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</template>
|
||||
24
monisuo-admin/src/components/ui/accordion/AccordionItem.vue
Normal file
24
monisuo-admin/src/components/ui/accordion/AccordionItem.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AccordionItem, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionItem
|
||||
v-slot="slotProps"
|
||||
data-slot="accordion-item"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('border-b last:border-b-0', props.class)"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</AccordionItem>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronDown } from "lucide-vue-next"
|
||||
import {
|
||||
AccordionHeader,
|
||||
AccordionTrigger,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionHeader class="flex">
|
||||
<AccordionTrigger
|
||||
data-slot="accordion-trigger"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<slot name="icon">
|
||||
<ChevronDown
|
||||
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
|
||||
/>
|
||||
</slot>
|
||||
</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
</template>
|
||||
4
monisuo-admin/src/components/ui/accordion/index.ts
Normal file
4
monisuo-admin/src/components/ui/accordion/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Accordion } from "./Accordion.vue"
|
||||
export { default as AccordionContent } from "./AccordionContent.vue"
|
||||
export { default as AccordionItem } from "./AccordionItem.vue"
|
||||
export { default as AccordionTrigger } from "./AccordionTrigger.vue"
|
||||
15
monisuo-admin/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
15
monisuo-admin/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
|
||||
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogProps>()
|
||||
const emits = defineEmits<AlertDialogEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogActionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogAction } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogCancelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogCancel } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<AlertDialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
|
||||
/>
|
||||
<AlertDialogContent
|
||||
data-slot="alert-dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogDescription,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogDescription
|
||||
data-slot="alert-dialog-description"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogDescription>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogTitle } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTitle
|
||||
data-slot="alert-dialog-title"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-lg font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogTitle>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogTriggerProps } from "reka-ui"
|
||||
import { AlertDialogTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
|
||||
<slot />
|
||||
</AlertDialogTrigger>
|
||||
</template>
|
||||
9
monisuo-admin/src/components/ui/alert-dialog/index.ts
Normal file
9
monisuo-admin/src/components/ui/alert-dialog/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { default as AlertDialog } from "./AlertDialog.vue"
|
||||
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
|
||||
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
|
||||
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
|
||||
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
|
||||
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
|
||||
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
|
||||
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
|
||||
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
|
||||
21
monisuo-admin/src/components/ui/alert/Alert.vue
Normal file
21
monisuo-admin/src/components/ui/alert/Alert.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { AlertVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { alertVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: AlertVariants["variant"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert"
|
||||
:class="cn(alertVariants({ variant }), props.class)"
|
||||
role="alert"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
monisuo-admin/src/components/ui/alert/AlertDescription.vue
Normal file
17
monisuo-admin/src/components/ui/alert/AlertDescription.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
:class="cn('text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
monisuo-admin/src/components/ui/alert/AlertTitle.vue
Normal file
17
monisuo-admin/src/components/ui/alert/AlertTitle.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
:class="cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
24
monisuo-admin/src/components/ui/alert/index.ts
Normal file
24
monisuo-admin/src/components/ui/alert/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Alert } from "./Alert.vue"
|
||||
export { default as AlertDescription } from "./AlertDescription.vue"
|
||||
export { default as AlertTitle } from "./AlertTitle.vue"
|
||||
|
||||
export const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||
16
monisuo-admin/src/components/ui/aspect-ratio/AspectRatio.vue
Normal file
16
monisuo-admin/src/components/ui/aspect-ratio/AspectRatio.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { AspectRatioProps } from "reka-ui"
|
||||
import { AspectRatio } from "reka-ui"
|
||||
|
||||
const props = defineProps<AspectRatioProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AspectRatio
|
||||
v-slot="slotProps"
|
||||
data-slot="aspect-ratio"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</AspectRatio>
|
||||
</template>
|
||||
1
monisuo-admin/src/components/ui/aspect-ratio/index.ts
Normal file
1
monisuo-admin/src/components/ui/aspect-ratio/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AspectRatio } from "./AspectRatio.vue"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user