This commit is contained in:
2026-03-22 13:55:23 +08:00
parent c3f196ded4
commit 69099986e0
616 changed files with 38942 additions and 3 deletions

View File

@@ -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!,
}

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

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

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

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

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

View 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[]
}

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

View File

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
<template>
<div class="h-120 w-full flex items-center justify-center">
<UiSpinner class="size-10" />
</div>
</template>

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

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

View File

@@ -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
}

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
<template>
<UiSpinner class="w-24 h-24 animate-spin" />
</template>

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

View File

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

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

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

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

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

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

View 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()
}

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

View File

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

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

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
</script>
<template>
<UiButton as="a" href="/auth/sign-in">
{{ $t('login') }}
</UiButton>
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
</script>
<template>
<UiButton as="a" href="/auth/sign-up" variant="outline">
{{ $t('register') }}
</UiButton>
</template>

View File

@@ -0,0 +1,2 @@
export { default as SortSelect } from './sort-select.vue'
export type * from './types'

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

View File

@@ -0,0 +1 @@
export type TSort = 'asc' | 'desc'

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

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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-dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

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

View File

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

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

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

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

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

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

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

View 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