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

21
monisuo-admin/src/App.vue Normal file
View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import Loading from '@/components/loading.vue'
import { Toaster } from '@/components/ui/sonner'
import { useSystemTheme } from '@/composables/use-system-theme'
useSystemTheme()
</script>
<template>
<Toaster />
<Suspense>
<router-view v-slot="{ Component, route }">
<component :is="Component" :key="route" />
</router-view>
<template #fallback>
<Loading />
</template>
</Suspense>
</template>

View File

@@ -0,0 +1,118 @@
/* theme red */
.theme-red {
--chart-1: oklch(0.808 0.114 19.571);
--chart-2: oklch(0.637 0.237 25.331);
--chart-3: oklch(0.577 0.245 27.325);
--chart-4: oklch(0.505 0.213 27.518);
--chart-5: oklch(0.444 0.177 26.899);
}
.theme-red.dark {
--chart-1: oklch(0.808 0.114 19.571);
--chart-2: oklch(0.637 0.237 25.331);
--chart-3: oklch(0.577 0.245 27.325);
--chart-4: oklch(0.505 0.213 27.518);
--chart-5: oklch(0.444 0.177 26.899);
}
/* theme rose */
.theme-rose {
--chart-1: oklch(0.81 0.117 11.638);
--chart-2: oklch(0.645 0.246 16.439);
--chart-3: oklch(0.586 0.253 17.585);
--chart-4: oklch(0.514 0.222 16.935);
--chart-5: oklch(0.455 0.188 13.697);
}
.theme-rose.dark {
--chart-1: oklch(0.81 0.117 11.638);
--chart-2: oklch(0.645 0.246 16.439);
--chart-3: oklch(0.586 0.253 17.585);
--chart-4: oklch(0.514 0.222 16.935);
--chart-5: oklch(0.455 0.188 13.697);
}
/* theme orange */
.theme-orange {
--chart-1: oklch(0.837 0.128 66.29);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
}
.theme-orange.dark {
--chart-1: oklch(0.837 0.128 66.29);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
}
/* theme green */
.theme-green {
--chart-1: oklch(0.871 0.15 154.449);
--chart-2: oklch(0.723 0.219 149.579);
--chart-3: oklch(0.627 0.194 149.214);
--chart-4: oklch(0.527 0.154 150.069);
--chart-5: oklch(0.448 0.119 151.328);
}
.theme-green.dark {
--chart-1: oklch(0.871 0.15 154.449);
--chart-2: oklch(0.723 0.219 149.579);
--chart-3: oklch(0.627 0.194 149.214);
--chart-4: oklch(0.527 0.154 150.069);
--chart-5: oklch(0.448 0.119 151.328);
}
/* theme blue */
.theme-blue {
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
}
.theme-blue.dark {
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
}
/* theme yellow */
.theme-yellow {
--chart-1: oklch(0.905 0.182 98.111);
--chart-2: oklch(0.795 0.184 86.047);
--chart-3: oklch(0.681 0.162 75.834);
--chart-4: oklch(0.554 0.135 66.442);
--chart-5: oklch(0.476 0.114 61.907);
}
.theme-yellow.dark {
--chart-1: oklch(0.905 0.182 98.111);
--chart-2: oklch(0.795 0.184 86.047);
--chart-3: oklch(0.681 0.162 75.834);
--chart-4: oklch(0.554 0.135 66.442);
--chart-5: oklch(0.476 0.114 61.907);
}
/* theme violet */
.theme-violet {
--chart-1: oklch(0.811 0.111 293.571);
--chart-2: oklch(0.606 0.25 292.717);
--chart-3: oklch(0.541 0.281 293.009);
--chart-4: oklch(0.491 0.27 292.581);
--chart-5: oklch(0.432 0.232 292.759);
}
.theme-violet.dark {
--chart-1: oklch(0.811 0.111 293.571);
--chart-2: oklch(0.606 0.25 292.717);
--chart-3: oklch(0.541 0.281 293.009);
--chart-4: oklch(0.491 0.27 292.581);
--chart-5: oklch(0.432 0.232 292.759);
}

View File

@@ -0,0 +1,4 @@
<svg width="144" height="141" viewBox="0 0 144 141" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M129.189 0.0490494C128.744 0.119441 126.422 0.377545 124.03 0.635648C114.719 1.6446 109.23 2.4893 108.058 3.09936C107.119 3.56864 106.674 4.34295 106.674 5.44576C106.674 6.71281 107.424 7.51058 109.043 7.97986C110.403 8.37875 110.825 8.42567 118.87 9.52847C121.778 9.92736 124.288 10.3028 124.475 10.3732C124.663 10.4436 122.951 11.1006 120.676 11.8749C110.028 15.4414 100.412 20.7677 91.7339 27.9242C88.38 30.7164 81.6957 37.4271 79.2096 40.5009C73.8387 47.2116 69.6874 54.8139 66.5681 63.7302C65.9348 65.4665 65.3484 66.8978 65.2546 66.8978C65.1374 66.8978 63.7771 66.7336 62.2291 66.5693C52.9649 65.5134 43.1847 68.1649 34.1316 74.2186C24.7735 80.46 18.5349 87.7338 10.5371 101.742C2.53943 115.726 -1.0959 127.482 0.287874 135.014C0.89767 138.463 2.0469 140.035 3.97011 140.082C5.28352 140.105 5.37733 139.659 4.20465 139.049C3.05541 138.463 2.6567 137.9 2.32835 136.281C0.616228 128.021 6.24512 113.028 17.4325 96.1104C23.2725 87.241 28.362 81.9147 35.5622 77.1046C43.8649 71.5437 52.7069 69.033 61.1737 69.8308C64.9967 70.1828 64.6917 69.9247 64.1992 72.4822C62.2525 82.5013 63.8005 92.6378 67.9753 97.354C73.1116 103.079 81.9771 102 85.0027 95.2657C86.3395 92.2858 86.3864 87.7103 85.1434 83.9796C83.1498 78.0901 80.007 73.8197 75.4335 70.8163C73.8152 69.7604 70.4848 68.1883 69.875 68.1883C69.359 68.1883 69.4294 67.6487 70.2268 65.3257C72.3377 59.2486 75.457 52.7021 78.4122 48.244C83.2436 40.9232 91.4524 32.5701 99.1687 27.103C105.806 22.4102 113.241 18.5386 120.512 16.0045C123.772 14.8548 129.87 13.1889 130.081 13.3766C130.128 13.447 129.541 14.362 128.791 15.4414C124.78 21.0258 122.716 26.0706 122.388 30.998C122.224 33.7198 122.341 34.588 122.88 34.2595C122.998 34.1891 123.678 32.969 124.405 31.5611C126.281 27.8069 131.722 20.6738 139.579 11.6402C141.127 9.85697 142.652 7.86254 143.027 7.08823C144.552 4.03792 143.52 1.48035 140.377 0.471397C139.439 0.166366 138.102 0.0490408 134.584 0.0255769C132.074 -0.021351 129.635 0.00212153 129.189 0.0490494ZM137.117 4.92955C137.187 5.0234 136.718 5.63346 136.061 6.29045L134.865 7.48712L131.042 6.73627C128.931 6.33739 126.727 5.9385 126.14 5.8681C124.827 5.68039 124.123 5.32843 124.968 5.28151C125.296 5.28151 126.868 5.11725 128.486 4.953C131.3 4.64797 136.812 4.62451 137.117 4.92955ZM71.5168 72.5292C76.2075 74.899 79.4441 78.8175 81.3204 84.355C83.6189 91.1361 81.2266 96.8378 76.0433 96.8847C73.3227 96.9082 70.9773 95.2188 69.5936 92.2389C68.2802 89.4232 67.6938 86.5606 67.5765 82.1259C67.4593 78.3248 67.6 76.4242 68.2333 72.7403L68.4912 71.2856L69.359 71.5906C69.8515 71.7548 70.8132 72.1772 71.5168 72.5292Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,5 @@
<svg width="144" height="141" viewBox="0 0 144 141" fill="white" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M129.189 0.0490494C128.744 0.119441 126.422 0.377545 124.03 0.635648C114.719 1.6446 109.23 2.4893 108.058 3.09936C107.119 3.56864 106.674 4.34295 106.674 5.44576C106.674 6.71281 107.424 7.51058 109.043 7.97986C110.403 8.37875 110.825 8.42567 118.87 9.52847C121.778 9.92736 124.288 10.3028 124.475 10.3732C124.663 10.4436 122.951 11.1006 120.676 11.8749C110.028 15.4414 100.412 20.7677 91.7339 27.9242C88.38 30.7164 81.6957 37.4271 79.2096 40.5009C73.8387 47.2116 69.6874 54.8139 66.5681 63.7302C65.9348 65.4665 65.3484 66.8978 65.2546 66.8978C65.1374 66.8978 63.7771 66.7336 62.2291 66.5693C52.9649 65.5134 43.1847 68.1649 34.1316 74.2186C24.7735 80.46 18.5349 87.7338 10.5371 101.742C2.53943 115.726 -1.0959 127.482 0.287874 135.014C0.89767 138.463 2.0469 140.035 3.97011 140.082C5.28352 140.105 5.37733 139.659 4.20465 139.049C3.05541 138.463 2.6567 137.9 2.32835 136.281C0.616228 128.021 6.24512 113.028 17.4325 96.1104C23.2725 87.241 28.362 81.9147 35.5622 77.1046C43.8649 71.5437 52.7069 69.033 61.1737 69.8308C64.9967 70.1828 64.6917 69.9247 64.1992 72.4822C62.2525 82.5013 63.8005 92.6378 67.9753 97.354C73.1116 103.079 81.9771 102 85.0027 95.2657C86.3395 92.2858 86.3864 87.7103 85.1434 83.9796C83.1498 78.0901 80.007 73.8197 75.4335 70.8163C73.8152 69.7604 70.4848 68.1883 69.875 68.1883C69.359 68.1883 69.4294 67.6487 70.2268 65.3257C72.3377 59.2486 75.457 52.7021 78.4122 48.244C83.2436 40.9232 91.4524 32.5701 99.1687 27.103C105.806 22.4102 113.241 18.5386 120.512 16.0045C123.772 14.8548 129.87 13.1889 130.081 13.3766C130.128 13.447 129.541 14.362 128.791 15.4414C124.78 21.0258 122.716 26.0706 122.388 30.998C122.224 33.7198 122.341 34.588 122.88 34.2595C122.998 34.1891 123.678 32.969 124.405 31.5611C126.281 27.8069 131.722 20.6738 139.579 11.6402C141.127 9.85697 142.652 7.86254 143.027 7.08823C144.552 4.03792 143.52 1.48035 140.377 0.471397C139.439 0.166366 138.102 0.0490408 134.584 0.0255769C132.074 -0.021351 129.635 0.00212153 129.189 0.0490494ZM137.117 4.92955C137.187 5.0234 136.718 5.63346 136.061 6.29045L134.865 7.48712L131.042 6.73627C128.931 6.33739 126.727 5.9385 126.14 5.8681C124.827 5.68039 124.123 5.32843 124.968 5.28151C125.296 5.28151 126.868 5.11725 128.486 4.953C131.3 4.64797 136.812 4.62451 137.117 4.92955ZM71.5168 72.5292C76.2075 74.899 79.4441 78.8175 81.3204 84.355C83.6189 91.1361 81.2266 96.8378 76.0433 96.8847C73.3227 96.9082 70.9773 95.2188 69.5936 92.2389C68.2802 89.4232 67.6938 86.5606 67.5765 82.1259C67.4593 78.3248 67.6 76.4242 68.2333 72.7403L68.4912 71.2856L69.359 71.5906C69.8515 71.7548 70.8132 72.1772 71.5168 72.5292Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,143 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(1 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--reka-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--reka-accordion-content-height);
}
to {
height: 0;
}
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,61 @@
@reference './index.css';
/* Make clicks pass-through */
#nprogress {
@apply pointer-events-none;
}
#nprogress .bar {
@apply bg-primary fixed left-0 top-0 z-[1031] h-[2px] w-full;
}
/* Fancy blur effect */
#nprogress .peg {
@apply absolute right-0 block h-full w-[100px];
box-shadow:
0 0 10px hsl(var(--primary)),
0 0 5px hsl(var(--primary));
opacity: 1;
transform: rotate(3deg) translate(0, -4px);
}
/* Remove these to get rid of the spinner */
#nprogress .spinner {
@apply fixed right-4 top-4 z-[1031] block;
}
#nprogress .spinner-icon {
@apply border-t-primary border-l-primary size-4 rounded-full border-[2px] border-solid border-transparent;
animation: nprogress-spinner 400ms linear infinite;
}
.nprogress-custom-parent {
@apply relative overflow-hidden;
}
.nprogress-custom-parent #nprogress .spinner,
.nprogress-custom-parent #nprogress .bar {
@apply absolute;
}
@keyframes nprogress-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes nprogress-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,25 @@
* {
scrollbar-color: #8885 var(--c-border);
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar:horizontal {
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--c-border);
border-radius: 1px;
}
::-webkit-scrollbar-thumb {
background: #8885;
border-radius: 1px;
}
::-webkit-scrollbar-thumb:hover {
background: #8886;
}

View File

@@ -0,0 +1,559 @@
/* theme yellow */
.theme-yellow {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.795 0.184 86.047);
--primary-foreground: oklch(0.421 0.095 57.708);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.795 0.184 86.047);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.795 0.184 86.047);
--sidebar-primary-foreground: oklch(0.421 0.095 57.708);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.795 0.184 86.047);
}
.theme-yellow.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.795 0.184 86.047);
--primary-foreground: oklch(0.421 0.095 57.708);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.554 0.135 66.442);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.795 0.184 86.047);
--sidebar-primary-foreground: oklch(0.421 0.095 57.708);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.554 0.135 66.442);
}
/* theme red */
.theme-red {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.637 0.237 25.331);
--primary-foreground: oklch(0.971 0.013 17.38);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.637 0.237 25.331);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.637 0.237 25.331);
--sidebar-primary-foreground: oklch(0.971 0.013 17.38);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.637 0.237 25.331);
}
.theme-red.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.637 0.237 25.331);
--primary-foreground: oklch(0.971 0.013 17.38);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.637 0.237 25.331);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.637 0.237 25.331);
--sidebar-primary-foreground: oklch(0.971 0.013 17.38);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.637 0.237 25.331);
}
/* theme rose */
.theme-rose {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.645 0.246 16.439);
--primary-foreground: oklch(0.969 0.015 12.422);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.645 0.246 16.439);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.645 0.246 16.439);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.645 0.246 16.439);
}
.theme-rose.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.645 0.246 16.439);
--primary-foreground: oklch(0.969 0.015 12.422);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.645 0.246 16.439);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.645 0.246 16.439);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.645 0.246 16.439);
}
/* theme orange */
.theme-orange {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.705 0.213 47.604);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.705 0.213 47.604);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.213 47.604);
}
.theme-orange.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.646 0.222 41.116);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.646 0.222 41.116);
}
/* theme green */
.theme-green {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.723 0.219 149.579);
--primary-foreground: oklch(0.982 0.018 155.826);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.723 0.219 149.579);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.723 0.219 149.579);
--sidebar-primary-foreground: oklch(0.982 0.018 155.826);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.723 0.219 149.579);
}
.theme-green.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.696 0.17 162.48);
--primary-foreground: oklch(0.393 0.095 152.535);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.527 0.154 150.069);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.696 0.17 162.48);
--sidebar-primary-foreground: oklch(0.393 0.095 152.535);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.527 0.154 150.069);
}
/* theme blue */
.theme-blue {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.623 0.214 259.815);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.623 0.214 259.815);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.623 0.214 259.815);
}
.theme-blue.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.546 0.245 262.881);
--primary-foreground: oklch(0.379 0.146 265.522);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.379 0.146 265.522);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.488 0.243 264.376);
}
/* theme yellow */
.theme-yellow {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.795 0.184 86.047);
--primary-foreground: oklch(0.421 0.095 57.708);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.795 0.184 86.047);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.795 0.184 86.047);
--sidebar-primary-foreground: oklch(0.421 0.095 57.708);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.795 0.184 86.047);
}
.theme-yellow.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.795 0.184 86.047);
--primary-foreground: oklch(0.421 0.095 57.708);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.554 0.135 66.442);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.795 0.184 86.047);
--sidebar-primary-foreground: oklch(0.421 0.095 57.708);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.554 0.135 66.442);
}
/* theme violet */
.theme-violet {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.606 0.25 292.717);
--primary-foreground: oklch(0.969 0.016 293.756);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.606 0.25 292.717);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.606 0.25 292.717);
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.606 0.25 292.717);
}
.theme-violet.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.541 0.281 293.009);
--primary-foreground: oklch(0.969 0.016 293.756);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.541 0.281 293.009);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.541 0.281 293.009);
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.541 0.281 293.009);
}

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>

Some files were not shown because too many files have changed in this diff Show More