copy from web folder and remove docs, register and onboarding
This commit is contained in:
commit
5c916bb87a
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
NUXT_PUBLIC_API_BASE=http://localhost:8000/v1
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
.agents
|
||||
.mcp.json
|
||||
opencode.json
|
||||
skills-lock.json
|
||||
47
AGENTS.md
Normal file
47
AGENTS.md
Normal file
@ -0,0 +1,47 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Aturan Utama
|
||||
|
||||
### Dokumentasi Perubahan
|
||||
|
||||
**WAJIB** catat setiap perubahan yang dilakukan ke dalam folder `docs/history/` setelah selesai mengerjakan suatu fitur/bugfix/refactor.
|
||||
|
||||
#### Format Penulisan
|
||||
|
||||
Buat file markdown dengan nama format: `YYYY-MM-DD-singkatan-perubahan.md`
|
||||
|
||||
Contoh: `2026-07-24-sidebar-icon-mode.md`
|
||||
|
||||
#### Struktur Dokumen
|
||||
|
||||
```markdown
|
||||
# Judul Perubahan
|
||||
|
||||
**Tanggal:** YYYY-MM-DD
|
||||
**Status:** Selesai
|
||||
|
||||
## Tujuan
|
||||
Deskripsi singkat tujuan perubahan.
|
||||
|
||||
## Yang Dikerjakan
|
||||
|
||||
### 1. Judul Sub Perubahan
|
||||
- Detail perubahan yang dilakukan
|
||||
- Sebelum → Sesudah (jika ada perubahan value)
|
||||
|
||||
## File yang Diubah
|
||||
|
||||
| File | Aksi | Detail |
|
||||
|------|------|--------|
|
||||
| `path/to/file` | Diubah/Dibuat/Dihapus | Penjelasan singkat |
|
||||
|
||||
## Notes
|
||||
Catatan tambahan jika perlu.
|
||||
```
|
||||
|
||||
#### Aturan Tambahan
|
||||
|
||||
- Selalu cantumkan file apa saja yang diubah, dibuat, atau dihapus
|
||||
- Gunakan tabel untuk perubahan value (sebelum → sesudah)
|
||||
- Cantumkan behavior/efek perubahan jika relevan
|
||||
- Dokumen harus dalam Bahasa Indonesia
|
||||
75
README.md
Normal file
75
README.md
Normal file
@ -0,0 +1,75 @@
|
||||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
13
app/app.vue
Normal file
13
app/app.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtPage />
|
||||
<ClientOnly>
|
||||
<Toaster />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Toaster from '@/components/ui/sonner/Sonner.vue'
|
||||
</script>
|
||||
152
app/assets/css/tailwind.css
Normal file
152
app/assets/css/tailwind.css
Normal file
@ -0,0 +1,152 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
@import "vue-sonner/style.css";
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--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);
|
||||
--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(0.97 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);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #c4c4c4 transparent;
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: #444 transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: #c4c4c4;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
background-color: #444;
|
||||
}
|
||||
77
app/components/AppSidebar.vue
Normal file
77
app/components/AppSidebar.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CreditCard,
|
||||
Database,
|
||||
Grid,
|
||||
HelpCircle,
|
||||
Settings
|
||||
} from "@lucide/vue"
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
name: "shadcn",
|
||||
email: "m@example.com",
|
||||
avatar: "/avatars/shadcn.jpg",
|
||||
},
|
||||
navMain: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/admin/dashboard",
|
||||
icon: Grid,
|
||||
},
|
||||
{
|
||||
title: "Jenis Bisnis",
|
||||
url: "/admin/business-types",
|
||||
icon: Grid,
|
||||
},
|
||||
{
|
||||
title: "Plans",
|
||||
url: "/admin/plans",
|
||||
icon: CreditCard,
|
||||
},
|
||||
],
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton as-child class="data-[slot=sidebar-menu-button]:p-1.5!">
|
||||
<a href="#">
|
||||
<Database class="size-6!" />
|
||||
<span class="text-lg font-semibold">Profitra</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroupContent class="flex flex-col gap-2">
|
||||
<SidebarGroupLabel>Master</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in data.navMain" :key="item.title">
|
||||
<SidebarMenuButton as-child :tooltip="item.title">
|
||||
<NuxtLink :to="item.url">
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
</NuxtLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</template>
|
||||
275
app/components/ChartAreaInteractive.vue
Normal file
275
app/components/ChartAreaInteractive.vue
Normal file
@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartConfig } from '@/components/ui/chart'
|
||||
|
||||
// import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
|
||||
import { VisArea, VisAxis, VisLine, VisXYContainer } from "@unovis/vue"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
|
||||
ChartContainer,
|
||||
ChartCrosshair,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
componentToString,
|
||||
} from '@/components/ui/chart'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
const description = "An interactive area chart"
|
||||
|
||||
const chartData = [
|
||||
{ date: new Date("2024-04-01"), desktop: 222, mobile: 150 },
|
||||
{ date: new Date("2024-04-02"), desktop: 97, mobile: 180 },
|
||||
{ date: new Date("2024-04-03"), desktop: 167, mobile: 120 },
|
||||
{ date: new Date("2024-04-04"), desktop: 242, mobile: 260 },
|
||||
{ date: new Date("2024-04-05"), desktop: 373, mobile: 290 },
|
||||
{ date: new Date("2024-04-06"), desktop: 301, mobile: 340 },
|
||||
{ date: new Date("2024-04-07"), desktop: 245, mobile: 180 },
|
||||
{ date: new Date("2024-04-08"), desktop: 409, mobile: 320 },
|
||||
{ date: new Date("2024-04-09"), desktop: 59, mobile: 110 },
|
||||
{ date: new Date("2024-04-10"), desktop: 261, mobile: 190 },
|
||||
{ date: new Date("2024-04-11"), desktop: 327, mobile: 350 },
|
||||
{ date: new Date("2024-04-12"), desktop: 292, mobile: 210 },
|
||||
{ date: new Date("2024-04-13"), desktop: 342, mobile: 380 },
|
||||
{ date: new Date("2024-04-14"), desktop: 137, mobile: 220 },
|
||||
{ date: new Date("2024-04-15"), desktop: 120, mobile: 170 },
|
||||
{ date: new Date("2024-04-16"), desktop: 138, mobile: 190 },
|
||||
{ date: new Date("2024-04-17"), desktop: 446, mobile: 360 },
|
||||
{ date: new Date("2024-04-18"), desktop: 364, mobile: 410 },
|
||||
{ date: new Date("2024-04-19"), desktop: 243, mobile: 180 },
|
||||
{ date: new Date("2024-04-20"), desktop: 89, mobile: 150 },
|
||||
{ date: new Date("2024-04-21"), desktop: 137, mobile: 200 },
|
||||
{ date: new Date("2024-04-22"), desktop: 224, mobile: 170 },
|
||||
{ date: new Date("2024-04-23"), desktop: 138, mobile: 230 },
|
||||
{ date: new Date("2024-04-24"), desktop: 387, mobile: 290 },
|
||||
{ date: new Date("2024-04-25"), desktop: 215, mobile: 250 },
|
||||
{ date: new Date("2024-04-26"), desktop: 75, mobile: 130 },
|
||||
{ date: new Date("2024-04-27"), desktop: 383, mobile: 420 },
|
||||
{ date: new Date("2024-04-28"), desktop: 122, mobile: 180 },
|
||||
{ date: new Date("2024-04-29"), desktop: 315, mobile: 240 },
|
||||
{ date: new Date("2024-04-30"), desktop: 454, mobile: 380 },
|
||||
{ date: new Date("2024-05-01"), desktop: 165, mobile: 220 },
|
||||
{ date: new Date("2024-05-02"), desktop: 293, mobile: 310 },
|
||||
{ date: new Date("2024-05-03"), desktop: 247, mobile: 190 },
|
||||
{ date: new Date("2024-05-04"), desktop: 385, mobile: 420 },
|
||||
{ date: new Date("2024-05-05"), desktop: 481, mobile: 390 },
|
||||
{ date: new Date("2024-05-06"), desktop: 498, mobile: 520 },
|
||||
{ date: new Date("2024-05-07"), desktop: 388, mobile: 300 },
|
||||
{ date: new Date("2024-05-08"), desktop: 149, mobile: 210 },
|
||||
{ date: new Date("2024-05-09"), desktop: 227, mobile: 180 },
|
||||
{ date: new Date("2024-05-10"), desktop: 293, mobile: 330 },
|
||||
{ date: new Date("2024-05-11"), desktop: 335, mobile: 270 },
|
||||
{ date: new Date("2024-05-12"), desktop: 197, mobile: 240 },
|
||||
{ date: new Date("2024-05-13"), desktop: 197, mobile: 160 },
|
||||
{ date: new Date("2024-05-14"), desktop: 448, mobile: 490 },
|
||||
{ date: new Date("2024-05-15"), desktop: 473, mobile: 380 },
|
||||
{ date: new Date("2024-05-16"), desktop: 338, mobile: 400 },
|
||||
{ date: new Date("2024-05-17"), desktop: 499, mobile: 420 },
|
||||
{ date: new Date("2024-05-18"), desktop: 315, mobile: 350 },
|
||||
{ date: new Date("2024-05-19"), desktop: 235, mobile: 180 },
|
||||
{ date: new Date("2024-05-20"), desktop: 177, mobile: 230 },
|
||||
{ date: new Date("2024-05-21"), desktop: 82, mobile: 140 },
|
||||
{ date: new Date("2024-05-22"), desktop: 81, mobile: 120 },
|
||||
{ date: new Date("2024-05-23"), desktop: 252, mobile: 290 },
|
||||
{ date: new Date("2024-05-24"), desktop: 294, mobile: 220 },
|
||||
{ date: new Date("2024-05-25"), desktop: 201, mobile: 250 },
|
||||
{ date: new Date("2024-05-26"), desktop: 213, mobile: 170 },
|
||||
{ date: new Date("2024-05-27"), desktop: 420, mobile: 460 },
|
||||
{ date: new Date("2024-05-28"), desktop: 233, mobile: 190 },
|
||||
{ date: new Date("2024-05-29"), desktop: 78, mobile: 130 },
|
||||
{ date: new Date("2024-05-30"), desktop: 340, mobile: 280 },
|
||||
{ date: new Date("2024-05-31"), desktop: 178, mobile: 230 },
|
||||
{ date: new Date("2024-06-01"), desktop: 178, mobile: 200 },
|
||||
{ date: new Date("2024-06-02"), desktop: 470, mobile: 410 },
|
||||
{ date: new Date("2024-06-03"), desktop: 103, mobile: 160 },
|
||||
{ date: new Date("2024-06-04"), desktop: 439, mobile: 380 },
|
||||
{ date: new Date("2024-06-05"), desktop: 88, mobile: 140 },
|
||||
{ date: new Date("2024-06-06"), desktop: 294, mobile: 250 },
|
||||
{ date: new Date("2024-06-07"), desktop: 323, mobile: 370 },
|
||||
{ date: new Date("2024-06-08"), desktop: 385, mobile: 320 },
|
||||
{ date: new Date("2024-06-09"), desktop: 438, mobile: 480 },
|
||||
{ date: new Date("2024-06-10"), desktop: 155, mobile: 200 },
|
||||
{ date: new Date("2024-06-11"), desktop: 92, mobile: 150 },
|
||||
{ date: new Date("2024-06-12"), desktop: 492, mobile: 420 },
|
||||
{ date: new Date("2024-06-13"), desktop: 81, mobile: 130 },
|
||||
{ date: new Date("2024-06-14"), desktop: 426, mobile: 380 },
|
||||
{ date: new Date("2024-06-15"), desktop: 307, mobile: 350 },
|
||||
{ date: new Date("2024-06-16"), desktop: 371, mobile: 310 },
|
||||
{ date: new Date("2024-06-17"), desktop: 475, mobile: 520 },
|
||||
{ date: new Date("2024-06-18"), desktop: 107, mobile: 170 },
|
||||
{ date: new Date("2024-06-19"), desktop: 341, mobile: 290 },
|
||||
{ date: new Date("2024-06-20"), desktop: 408, mobile: 450 },
|
||||
{ date: new Date("2024-06-21"), desktop: 169, mobile: 210 },
|
||||
{ date: new Date("2024-06-22"), desktop: 317, mobile: 270 },
|
||||
{ date: new Date("2024-06-23"), desktop: 480, mobile: 530 },
|
||||
{ date: new Date("2024-06-24"), desktop: 132, mobile: 180 },
|
||||
{ date: new Date("2024-06-25"), desktop: 141, mobile: 190 },
|
||||
{ date: new Date("2024-06-26"), desktop: 434, mobile: 380 },
|
||||
{ date: new Date("2024-06-27"), desktop: 448, mobile: 490 },
|
||||
{ date: new Date("2024-06-28"), desktop: 149, mobile: 200 },
|
||||
{ date: new Date("2024-06-29"), desktop: 103, mobile: 160 },
|
||||
{ date: new Date("2024-06-30"), desktop: 446, mobile: 400 },
|
||||
]
|
||||
type Data = typeof chartData[number]
|
||||
|
||||
const chartConfig = {
|
||||
// visitors: {
|
||||
// label: 'Visitors',
|
||||
// },
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
const svgDefs = `
|
||||
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stop-color="var(--color-desktop)"
|
||||
stop-opacity="0.8"
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stop-color="var(--color-desktop)"
|
||||
stop-opacity="0.1"
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stop-color="var(--color-mobile)"
|
||||
stop-opacity="0.8"
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stop-color="var(--color-mobile)"
|
||||
stop-opacity="0.1"
|
||||
/>
|
||||
</linearGradient>
|
||||
`
|
||||
|
||||
const timeRange = ref("90d")
|
||||
const filterRange = computed(() => {
|
||||
return chartData.filter((item) => {
|
||||
const date = new Date(item.date)
|
||||
const referenceDate = new Date("2024-06-30")
|
||||
let daysToSubtract = 90
|
||||
if (timeRange.value === "30d") {
|
||||
daysToSubtract = 30
|
||||
}
|
||||
else if (timeRange.value === "7d") {
|
||||
daysToSubtract = 7
|
||||
}
|
||||
const startDate = new Date(referenceDate)
|
||||
startDate.setDate(startDate.getDate() - daysToSubtract)
|
||||
return date >= startDate
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="pt-0">
|
||||
<CardHeader class="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
|
||||
<div class="grid flex-1 gap-1">
|
||||
<CardTitle>Area Chart - Interactive</CardTitle>
|
||||
<CardDescription>
|
||||
Showing total visitors for the last 3 months
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Select v-model="timeRange">
|
||||
<SelectTrigger
|
||||
class="hidden w-[160px] rounded-lg sm:ml-auto sm:flex"
|
||||
aria-label="Select a value"
|
||||
>
|
||||
<SelectValue placeholder="Last 3 months" />
|
||||
</SelectTrigger>
|
||||
<SelectContent class="rounded-xl">
|
||||
<SelectItem value="90d" class="rounded-lg">
|
||||
Last 3 months
|
||||
</SelectItem>
|
||||
<SelectItem value="30d" class="rounded-lg">
|
||||
Last 30 days
|
||||
</SelectItem>
|
||||
<SelectItem value="7d" class="rounded-lg">
|
||||
Last 7 days
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent class="px-2 pt-4 sm:px-6 sm:pt-6 pb-4">
|
||||
<ChartContainer :config="chartConfig" class="aspect-auto h-[250px] w-full" :cursor="false">
|
||||
<VisXYContainer
|
||||
:data="filterRange"
|
||||
:svg-defs="svgDefs"
|
||||
:margin="{ left: -40 }"
|
||||
:y-domain="[0, 1200]"
|
||||
>
|
||||
<VisArea
|
||||
:x="(d: Data) => d.date"
|
||||
:y="[(d: Data) => d.mobile, (d: Data) => d.desktop]"
|
||||
:color="(d: Data, i: number) => ['url(#fillMobile)', 'url(#fillDesktop)'][i]"
|
||||
:opacity="0.6"
|
||||
/>
|
||||
<VisLine
|
||||
:x="(d: Data) => d.date"
|
||||
:y="[(d: Data) => d.mobile, (d: Data) => d.mobile + d.desktop]"
|
||||
:color="(d: Data, i: number) => [chartConfig.mobile.color, chartConfig.desktop.color][i]"
|
||||
:line-width="1"
|
||||
/>
|
||||
<VisAxis
|
||||
type="x"
|
||||
:x="(d: Data) => d.date"
|
||||
:tick-line="false"
|
||||
:domain-line="false"
|
||||
:grid-line="false"
|
||||
:num-ticks="6"
|
||||
:tick-format="(d: number, index: number) => {
|
||||
const date = new Date(d)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}"
|
||||
/>
|
||||
<VisAxis
|
||||
type="y"
|
||||
:num-ticks="3"
|
||||
:tick-line="false"
|
||||
:domain-line="false"
|
||||
/>
|
||||
<ChartTooltip />
|
||||
<ChartCrosshair
|
||||
:template="componentToString(chartConfig, ChartTooltipContent, {
|
||||
labelFormatter: (d) => {
|
||||
return new Date(d).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
},
|
||||
})"
|
||||
:color="(d: Data, i: number) => [chartConfig.mobile.color, chartConfig.desktop.color][i % 2]"
|
||||
/>
|
||||
</VisXYContainer>
|
||||
|
||||
<ChartLegendContent />
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
477
app/components/DataTable.vue
Normal file
477
app/components/DataTable.vue
Normal file
@ -0,0 +1,477 @@
|
||||
<script lang="ts">
|
||||
import { z } from "zod"
|
||||
import DraggableRow from "./DraggableRow.vue"
|
||||
import DragHandle from "./DragHandle.vue"
|
||||
|
||||
export const schema = z.object({
|
||||
id: z.number(),
|
||||
header: z.string(),
|
||||
type: z.string(),
|
||||
status: z.string(),
|
||||
target: z.string(),
|
||||
limit: z.string(),
|
||||
reviewer: z.string(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
} from "@tanstack/vue-table"
|
||||
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconCircleCheckFilled,
|
||||
IconDotsVertical,
|
||||
IconLayoutColumns,
|
||||
IconLoader,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-vue"
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
} from "@tanstack/vue-table"
|
||||
import { DragDropProvider } from "dnd-kit-vue"
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui/tabs'
|
||||
|
||||
const props = defineProps<{
|
||||
data: TableData[]
|
||||
}>()
|
||||
|
||||
interface TableData {
|
||||
id: number
|
||||
header: string
|
||||
type: string
|
||||
status: string
|
||||
target: string
|
||||
limit: string
|
||||
reviewer: string
|
||||
}
|
||||
|
||||
const sorting = ref<SortingState>([])
|
||||
const columnFilters = ref<ColumnFiltersState>([])
|
||||
const columnVisibility = ref<VisibilityState>({})
|
||||
const rowSelection = ref({})
|
||||
|
||||
const columns: ColumnDef<TableData>[] = [
|
||||
{
|
||||
id: "drag",
|
||||
header: () => null,
|
||||
cell: ({ row }) => h(DragHandle),
|
||||
},
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => h(Checkbox, {
|
||||
"modelValue": table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate"),
|
||||
"onUpdate:modelValue": value => table.toggleAllPageRowsSelected(!!value),
|
||||
"aria-label": "Select all",
|
||||
}),
|
||||
cell: ({ row }) => h(Checkbox, {
|
||||
"modelValue": row.getIsSelected(),
|
||||
"onUpdate:modelValue": value => row.toggleSelected(!!value),
|
||||
"aria-label": "Select row",
|
||||
}),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "header",
|
||||
header: "Header",
|
||||
cell: ({ row }) => h("div", String(row.getValue("header"))),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: "Section Type",
|
||||
cell: ({ row }) => h(Badge, {
|
||||
variant: "outline",
|
||||
}, () => String(row.getValue("type"))),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") as string
|
||||
return h("div", { class: "flex items-center gap-2" }, [
|
||||
status === "Done"
|
||||
? h(IconCircleCheckFilled, { class: "h-4 w-4 text-emerald-500" })
|
||||
: h(IconLoader, { class: "h-4 w-4 animate-spin text-muted-foreground" }),
|
||||
h("span", {}, status),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "target",
|
||||
header: () => h("div", { class: "flex items-center gap-1" }, [
|
||||
"Target",
|
||||
]),
|
||||
cell: ({ row }) => h(Button, {
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
class: "h-auto p-1 text-xs font-mono",
|
||||
}, () => [
|
||||
h("span", { class: "ml-1 font-semibold" }, String(row.getValue("target"))),
|
||||
]),
|
||||
},
|
||||
{
|
||||
accessorKey: "limit",
|
||||
header: () => h("div", { class: "flex items-center gap-1" }, [
|
||||
"Limit",
|
||||
]),
|
||||
cell: ({ row }) => h(Button, {
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
class: "h-auto p-1 text-xs font-mono",
|
||||
}, () => [
|
||||
h("span", { class: "ml-1 font-semibold" }, String(row.getValue("limit"))),
|
||||
]),
|
||||
},
|
||||
{
|
||||
accessorKey: "reviewer",
|
||||
header: "Reviewer",
|
||||
cell: ({ row }) => {
|
||||
const reviewer = row.getValue("reviewer") as string
|
||||
const isAssigned = reviewer !== "Assign reviewer"
|
||||
|
||||
if (isAssigned) {
|
||||
return h("span", {}, reviewer)
|
||||
}
|
||||
|
||||
return h(Select, {}, {
|
||||
default: () => [
|
||||
h(SelectTrigger, { class: "w-full" }, {
|
||||
default: () => h(SelectValue, { placeholder: "Assign reviewer" }),
|
||||
}),
|
||||
h(SelectContent, {}, {
|
||||
default: () => [
|
||||
h(SelectItem, { value: "eddie" }, () => "Eddie Lake"),
|
||||
h(SelectItem, { value: "jamik" }, () => "Jamik Tashpulatov"),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: () => h(DropdownMenu, {}, {
|
||||
default: () => [
|
||||
h(DropdownMenuTrigger, { asChild: true }, {
|
||||
default: () => h(Button, {
|
||||
variant: "ghost",
|
||||
class: "h-8 w-8 p-0",
|
||||
}, {
|
||||
default: () => [
|
||||
h("span", { class: "sr-only" }, "Open menu"),
|
||||
h(IconDotsVertical, { class: "h-4 w-4" }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
h(DropdownMenuContent, { align: "end" }, {
|
||||
default: () => [
|
||||
h(DropdownMenuItem, {}, () => "Edit"),
|
||||
h(DropdownMenuItem, {}, () => "Make a copy"),
|
||||
h(DropdownMenuItem, {}, () => "Favorite"),
|
||||
h(DropdownMenuSeparator, {}),
|
||||
h(DropdownMenuItem, {}, () => "Delete"),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
return props.data
|
||||
},
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onSortingChange: (updaterOrValue) => {
|
||||
sorting.value = typeof updaterOrValue === "function"
|
||||
? updaterOrValue(sorting.value)
|
||||
: updaterOrValue
|
||||
},
|
||||
onColumnFiltersChange: (updaterOrValue) => {
|
||||
columnFilters.value = typeof updaterOrValue === "function"
|
||||
? updaterOrValue(columnFilters.value)
|
||||
: updaterOrValue
|
||||
},
|
||||
onColumnVisibilityChange: (updaterOrValue) => {
|
||||
columnVisibility.value = typeof updaterOrValue === "function"
|
||||
? updaterOrValue(columnVisibility.value)
|
||||
: updaterOrValue
|
||||
},
|
||||
onRowSelectionChange: (updaterOrValue) => {
|
||||
rowSelection.value = typeof updaterOrValue === "function"
|
||||
? updaterOrValue(rowSelection.value)
|
||||
: updaterOrValue
|
||||
},
|
||||
state: {
|
||||
get sorting() { return sorting.value },
|
||||
get columnFilters() { return columnFilters.value },
|
||||
get columnVisibility() { return columnVisibility.value },
|
||||
get rowSelection() { return rowSelection.value },
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabs
|
||||
default-value="outline"
|
||||
class="w-full flex-col justify-start gap-6"
|
||||
>
|
||||
<div class="flex items-center justify-between px-4 lg:px-6">
|
||||
<Label for="view-selector" class="sr-only">
|
||||
View
|
||||
</Label>
|
||||
<Select default-value="outline">
|
||||
<SelectTrigger
|
||||
id="view-selector"
|
||||
class="flex w-fit @4xl/main:hidden"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue placeholder="Select a view" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="outline">
|
||||
Outline
|
||||
</SelectItem>
|
||||
<SelectItem value="past-performance">
|
||||
Past Performance
|
||||
</SelectItem>
|
||||
<SelectItem value="key-personnel">
|
||||
Key Personnel
|
||||
</SelectItem>
|
||||
<SelectItem value="focus-documents">
|
||||
Focus Documents
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TabsList class="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
|
||||
<TabsTrigger value="outline">
|
||||
Outline
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="past-performance">
|
||||
Past Performance <Badge variant="secondary">
|
||||
3
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="key-personnel">
|
||||
Key Personnel <Badge variant="secondary">
|
||||
2
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="focus-documents">
|
||||
Focus Documents
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div class="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span class="hidden lg:inline">Customize Columns</span>
|
||||
<span class="lg:hidden">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-56">
|
||||
<template v-for="column in table.getAllColumns().filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide())" :key="column.id">
|
||||
<DropdownMenuCheckboxItem
|
||||
class="capitalize"
|
||||
:model-value="column.getIsVisible()"
|
||||
@update:model-value="(value) => {
|
||||
|
||||
column.toggleVisibility(!!value)
|
||||
}"
|
||||
>
|
||||
{{ column.id }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconPlus />
|
||||
<span class="hidden lg:inline">Add Section</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent
|
||||
value="outline"
|
||||
class="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
|
||||
>
|
||||
<div class="overflow-hidden rounded-lg border">
|
||||
<DragDropProvider :modifiers="[RestrictToVerticalAxis]">
|
||||
<Table>
|
||||
<TableHeader class="bg-muted sticky top-0 z-10">
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead v-for="header in headerGroup.headers" :key="header.id" :colspan="header.colSpan">
|
||||
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="**:data-[slot=table-cell]:first:w-8">
|
||||
<template v-if="table.getRowModel().rows.length">
|
||||
<DraggableRow v-for="row in table.getRowModel().rows" :key="row.id" :row="row" :index="row.index" />
|
||||
</template>
|
||||
<TableRow v-else>
|
||||
<TableCell
|
||||
:colspan="columns.length"
|
||||
class="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DragDropProvider>
|
||||
<!-- <DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}
|
||||
id={sortableId}
|
||||
> -->
|
||||
<!-- </DndContext> -->
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-4">
|
||||
<div class="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{{ table.getFilteredSelectedRowModel().rows.length }} of
|
||||
{{ table.getFilteredRowModel().rows.length }} row(s) selected.
|
||||
</div>
|
||||
<div class="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<Label for="rows-per-page" class="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="table.getState().pagination.pageSize"
|
||||
@update:model-value="(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}"
|
||||
>
|
||||
<SelectTrigger id="rows-per-page" size="sm" class="w-20">
|
||||
<SelectValue :placeholder="`${table.getState().pagination.pageSize}`" />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
<SelectItem v-for="pageSize in [10, 20, 30, 40, 50]" :key="pageSize" :value="`${pageSize}`">
|
||||
{{ pageSize }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {{ table.getState().pagination.pageIndex + 1 }} of
|
||||
{{ table.getPageCount() }}
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="hidden h-8 w-8 p-0 lg:flex"
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.setPageIndex(0)"
|
||||
>
|
||||
<span class="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="size-8"
|
||||
size="icon"
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.previousPage()"
|
||||
>
|
||||
<span class="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="size-8"
|
||||
size="icon"
|
||||
:disabled="!table.getCanNextPage()"
|
||||
@click="table.nextPage()"
|
||||
>
|
||||
<span class="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
:disabled="!table.getCanNextPage()"
|
||||
@click="table.setPageIndex(table.getPageCount() - 1)"
|
||||
>
|
||||
<span class="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="past-performance"
|
||||
class="flex flex-col px-4 lg:px-6"
|
||||
>
|
||||
<div class="aspect-video w-full flex-1 rounded-lg border border-dashed" />
|
||||
</TabsContent>
|
||||
<TabsContent value="key-personnel" class="flex flex-col px-4 lg:px-6">
|
||||
<div class="aspect-video w-full flex-1 rounded-lg border border-dashed" />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="focus-documents"
|
||||
class="flex flex-col px-4 lg:px-6"
|
||||
>
|
||||
<div class="aspect-video w-full flex-1 rounded-lg border border-dashed" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</template>
|
||||
59
app/components/DeleteDialog.vue
Normal file
59
app/components/DeleteDialog.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { Loader2 } from '@lucide/vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
itemName?: string
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const dialogTitle = computed(() => props.title ?? 'Hapus Item')
|
||||
const dialogDescription = computed(() => {
|
||||
if (props.description) return props.description
|
||||
if (props.itemName) {
|
||||
return `Apakah Anda yakin ingin menghapus "${props.itemName}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
return 'Apakah Anda yakin ingin menghapus item ini? Tindakan ini tidak dapat dibatalkan.'
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ dialogTitle }}</DialogTitle>
|
||||
<DialogDescription>{{ dialogDescription }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose as-child>
|
||||
<Button variant="outline" :disabled="loading">Batal</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" :disabled="loading" @click="emit('confirm')">
|
||||
<Loader2 v-if="loading" class="h-4 w-4 mr-1 animate-spin" />
|
||||
Hapus
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
19
app/components/DragHandle.vue
Normal file
19
app/components/DragHandle.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { IconGripVertical } from "@tabler/icons-vue"
|
||||
import { useSortableContext } from "dnd-kit-vue"
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const { handleRef, sortable } = useSortableContext()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
:ref="handleRef"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-muted-foreground size-7 hover:bg-transparent"
|
||||
>
|
||||
<IconGripVertical class="text-muted-foreground size-3" />
|
||||
<span class="sr-only">Drag to reorder</span>
|
||||
</Button>
|
||||
</template>
|
||||
31
app/components/DraggableRow.vue
Normal file
31
app/components/DraggableRow.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { Row } from "@tanstack/vue-table"
|
||||
import type { z } from "zod"
|
||||
import type { schema } from "./DataTable.vue"
|
||||
import { FlexRender } from "@tanstack/vue-table"
|
||||
import { useSortable } from "dnd-kit-vue"
|
||||
import {
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
|
||||
const props = defineProps<{ row: Row<z.infer<typeof schema>>, index: number }>()
|
||||
|
||||
const { elementRef, isDragging } = useSortable({
|
||||
id: props.row.original.id,
|
||||
index: props.index,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableRow
|
||||
:ref="elementRef"
|
||||
:data-state="row.getIsSelected() && 'selected'"
|
||||
:data-dragging="isDragging"
|
||||
class="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
|
||||
>
|
||||
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
170
app/components/LoginForm.vue
Normal file
170
app/components/LoginForm.vue
Normal file
@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { toast } from 'vue-sonner'
|
||||
import * as z from 'zod'
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Masuk Akun',
|
||||
ogTitle: 'Masuk Akun',
|
||||
description: 'Masuk untuk mengakses akun Anda',
|
||||
ogDescription: 'Masuk untuk mengakses akun Anda',
|
||||
})
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const { signInWithGoogle, loading: googleLoading } = useGoogleAuth()
|
||||
|
||||
const login = ref('')
|
||||
const password = ref('')
|
||||
const errors = ref<Record<string, string>>({})
|
||||
|
||||
const schema = z.object({
|
||||
login: z.string().min(1, 'Username atau email wajib diisi'),
|
||||
password: z.string().min(8, 'Kata sandi minimal 8 karakter'),
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
errors.value = {}
|
||||
|
||||
const result = schema.safeParse({ login: login.value, password: password.value })
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
const field = issue.path[0] as string
|
||||
errors.value[field] = issue.message
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await $fetch(`${config.public.apiBase}/auth/login`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
login: login.value,
|
||||
password: password.value,
|
||||
}
|
||||
})
|
||||
toast.success('Selamat datang kembali!', {
|
||||
description: `Masuk sebagai ${login.value}`,
|
||||
})
|
||||
router.push('/')
|
||||
} catch (error: any) {
|
||||
const detail = error?.data?.detail
|
||||
if (detail && Array.isArray(detail)) {
|
||||
const fieldErrors = detail
|
||||
.filter((e: any) => e.loc?.length >= 2)
|
||||
.map((e: any) => ({
|
||||
path: e.loc[e.loc.length - 1],
|
||||
message: e.msg
|
||||
}))
|
||||
for (const fe of fieldErrors) {
|
||||
errors.value[fe.path] = fe.message
|
||||
}
|
||||
} else {
|
||||
toast.error('Gagal masuk', {
|
||||
description: error?.data?.message || 'Ups, terjadi kesalahan. Silakan coba lagi beberapa saat.',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleLogin() {
|
||||
await signInWithGoogle(
|
||||
(result) => {
|
||||
toast.success('Selamat datang kembali!', {
|
||||
description: `Masuk sebagai ${result.user.email}`,
|
||||
})
|
||||
router.push('/')
|
||||
},
|
||||
(errorMsg) => {
|
||||
toast.error('Gagal masuk', {
|
||||
description: errorMsg,
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-xl">
|
||||
Selamat datang kembali!
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Masukkan kredensial Anda untuk mengakses akun.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<FieldGroup>
|
||||
<Field :class="{ 'text-destructive': errors.login }">
|
||||
<FieldLabel for="email">
|
||||
Username atau Email <span class="text-destructive">*</span>
|
||||
</FieldLabel>
|
||||
<Input id="email" v-model="login" type="text" placeholder="Masukkan username atau email"
|
||||
:class="{ 'border-destructive': errors.login }" required />
|
||||
<p v-if="errors.login" class="text-sm text-destructive mt-1">
|
||||
{{ errors.login }}
|
||||
</p>
|
||||
</Field>
|
||||
<Field :class="{ 'text-destructive': errors.password }">
|
||||
<div class="flex items-center">
|
||||
<FieldLabel for="password">
|
||||
Kata Sandi <span class="text-destructive">*</span>
|
||||
</FieldLabel>
|
||||
<NuxtLink to="/auth/forgot-password" class="ml-auto text-sm underline-offset-4 hover:underline">
|
||||
Lupa kata sandi?
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<Input id="password" v-model="password" type="password" placeholder="Masukkan kata sandi"
|
||||
:class="{ 'border-destructive': errors.password }" required />
|
||||
<p v-if="errors.password" class="text-sm text-destructive mt-1">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit" :disabled="loading">
|
||||
<span v-if="loading" class="i-lucide-loader-2 h-4 w-4 animate-spin mr-2" />
|
||||
Masuk
|
||||
</Button>
|
||||
<FieldDescription class="text-center">
|
||||
Tidak punya akun?
|
||||
<NuxtLink to="/auth/register" class="text-primary hover:underline">
|
||||
Daftar
|
||||
</NuxtLink>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<FieldSeparator class="*:data-[slot=field-separator-content]:bg-card">
|
||||
Atau lanjutkan dengan
|
||||
</FieldSeparator>
|
||||
<Field>
|
||||
<Button variant="outline" type="button" :disabled="googleLoading" @click="handleGoogleLogin">
|
||||
<span v-if="googleLoading" class="i-lucide-loader-2 h-4 w-4 animate-spin mr-2" />
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-4 w-4 mr-2">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
Masuk dengan Google
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FieldDescription class="px-6 text-center">
|
||||
Dengan masuk, Anda menyetujui
|
||||
<NuxtLink to="/terms" class="text-primary hover:underline">
|
||||
Ketentuan Layanan
|
||||
</NuxtLink>
|
||||
dan
|
||||
<NuxtLink to="/privacy" class="text-primary hover:underline">
|
||||
Kebijakan Privasi
|
||||
</NuxtLink>.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</template>
|
||||
108
app/components/NavUser.vue
Normal file
108
app/components/NavUser.vue
Normal file
@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Bell,
|
||||
CreditCard,
|
||||
EllipsisVertical,
|
||||
LogOut,
|
||||
UserCircle,
|
||||
} from "@lucide/vue"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
interface User {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
user: User
|
||||
}>()
|
||||
|
||||
const { isMobile } = useSidebar()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
||||
<Avatar class="h-8 w-8 rounded-lg grayscale">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
CN
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{{ user.name }}</span>
|
||||
<span class="text-muted-foreground truncate text-xs">
|
||||
{{ user.email }}
|
||||
</span>
|
||||
</div>
|
||||
<EllipsisVertical class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-(--reka-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
:side="isMobile ? 'bottom' : 'right'" :side-offset="4" align="end">
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
CN
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{{ user.name }}</span>
|
||||
<span class="text-muted-foreground truncate text-xs">
|
||||
{{ user.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<UserCircle />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCard />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Bell />
|
||||
Bells
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
66
app/components/PriceDisplay.vue
Normal file
66
app/components/PriceDisplay.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from '@/components/ui/input-group'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
value?: number | string
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
readonly?: boolean
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
placeholder: '0',
|
||||
readonly: false,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
function formatIdr(n: number | string): string {
|
||||
const num = typeof n === 'string' ? parseInt(n.replace(/\D/g, ''), 10) : n
|
||||
if (isNaN(num)) return ''
|
||||
return num.toLocaleString('id-ID')
|
||||
}
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (props.modelValue !== undefined) return formatIdr(props.modelValue)
|
||||
if (props.value == null) return ''
|
||||
return formatIdr(props.value)
|
||||
})
|
||||
|
||||
function onInput(e: Event) {
|
||||
const raw = (e.target as HTMLInputElement).value.replace(/\D/g, '')
|
||||
emit('update:modelValue', raw)
|
||||
}
|
||||
|
||||
function onFocus(e: FocusEvent) {
|
||||
const el = e.target as HTMLInputElement
|
||||
const pos = el.value.length
|
||||
el.setSelectionRange(pos, pos)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<InputGroupText>Rp</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
:model-value="displayValue"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="font-medium"
|
||||
@input="onInput"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
</InputGroup>
|
||||
</template>
|
||||
215
app/components/RegisterForm.vue
Normal file
215
app/components/RegisterForm.vue
Normal file
@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import { toast } from 'vue-sonner'
|
||||
import * as z from 'zod'
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Daftar Akun',
|
||||
ogTitle: 'Daftar Akun',
|
||||
description: 'Ayo daftar dan nikmati layanan kami!',
|
||||
ogDescription: 'Ayo daftar dan nikmati layanan kami!',
|
||||
})
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const { signInWithGoogle, loading: googleLoading } = useGoogleAuth()
|
||||
|
||||
const fullName = ref('')
|
||||
const email = ref('')
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const passwordConfirmation = ref('')
|
||||
const errors = ref<Record<string, string>>({})
|
||||
|
||||
const schema = z.object({
|
||||
full_name: z.string().min(1, 'Nama lengkap wajib diisi').max(200, 'Nama lengkap maksimal 200 karakter'),
|
||||
email: z.string().min(1, 'Email wajib diisi').email('Format email tidak valid').max(100, 'Email maksimal 100 karakter'),
|
||||
username: z.string().min(1, 'Username wajib diisi').max(20, 'Username maksimal 20 karakter'),
|
||||
password: z.string().min(8, 'Kata sandi minimal 8 karakter'),
|
||||
password_confirmation: z.string().min(1, 'Konfirmasi kata sandi wajib diisi'),
|
||||
}).refine(data => data.password === data.password_confirmation, {
|
||||
message: 'Kata sandi tidak cocok',
|
||||
path: ['password_confirmation'],
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
errors.value = {}
|
||||
|
||||
const result = schema.safeParse({
|
||||
full_name: fullName.value,
|
||||
email: email.value,
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
password_confirmation: passwordConfirmation.value,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
const field = issue.path[0] as string
|
||||
errors.value[field] = issue.message
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await $fetch(`${config.public.apiBase}/auth/register`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
full_name: fullName.value,
|
||||
email: email.value,
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
password_confirmation: passwordConfirmation.value,
|
||||
}
|
||||
})
|
||||
toast.success('Akun berhasil dibuat!', {
|
||||
description: `Selamat datang, ${fullName.value}!`,
|
||||
})
|
||||
router.push('/')
|
||||
} catch (error: any) {
|
||||
const detail = error?.data?.detail
|
||||
if (detail && Array.isArray(detail)) {
|
||||
const fieldErrors = detail
|
||||
.filter((e: any) => e.loc?.length >= 2)
|
||||
.map((e: any) => ({
|
||||
path: e.loc[e.loc.length - 1],
|
||||
message: e.msg
|
||||
}))
|
||||
for (const fe of fieldErrors) {
|
||||
errors.value[fe.path] = fe.message
|
||||
}
|
||||
} else {
|
||||
toast.error('Gagal mendaftar', {
|
||||
description: error?.data?.message || 'Ups, terjadi kesalahan. Silakan coba lagi beberapa saat.',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleRegister() {
|
||||
await signInWithGoogle(
|
||||
(result) => {
|
||||
toast.success('Berhasil mendaftar!', {
|
||||
description: `Selamat datang, ${result.user.email}!`,
|
||||
})
|
||||
router.push('/')
|
||||
},
|
||||
(errorMsg) => {
|
||||
toast.error('Gagal mendaftar', {
|
||||
description: errorMsg,
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-xl">
|
||||
Buat akun baru
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Kelola bisnis Anda dengan mudah dan efisien bersama platform kami.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<FieldGroup>
|
||||
<Field :class="{ 'text-destructive': errors.full_name }">
|
||||
<FieldLabel for="full_name">
|
||||
Nama Lengkap <span class="text-destructive">*</span>
|
||||
</FieldLabel>
|
||||
<Input id="full_name" v-model="fullName" type="text" placeholder="Masukkan nama lengkap"
|
||||
:class="{ 'border-destructive': errors.full_name }" required />
|
||||
<p v-if="errors.full_name" class="text-sm text-destructive mt-1">
|
||||
{{ errors.full_name }}
|
||||
</p>
|
||||
</Field>
|
||||
<Field :class="{ 'text-destructive': errors.email }">
|
||||
<FieldLabel for="email">
|
||||
Email <span class="text-destructive">*</span>
|
||||
</FieldLabel>
|
||||
<Input id="email" v-model="email" type="email" placeholder="Masukkan email"
|
||||
:class="{ 'border-destructive': errors.email }" required />
|
||||
<p v-if="errors.email" class="text-sm text-destructive mt-1">
|
||||
{{ errors.email }}
|
||||
</p>
|
||||
</Field>
|
||||
<Field :class="{ 'text-destructive': errors.username }">
|
||||
<FieldLabel for="username">
|
||||
Username <span class="text-destructive">*</span>
|
||||
</FieldLabel>
|
||||
<Input id="username" v-model="username" type="text" placeholder="Masukkan username"
|
||||
:class="{ 'border-destructive': errors.username }" required />
|
||||
<p v-if="errors.username" class="text-sm text-destructive mt-1">
|
||||
{{ errors.username }}
|
||||
</p>
|
||||
</Field>
|
||||
<Field :class="{ 'text-destructive': errors.password }">
|
||||
<FieldLabel for="password">
|
||||
Kata Sandi <span class="text-destructive">*</span>
|
||||
</FieldLabel>
|
||||
<Input id="password" v-model="password" type="password" placeholder="Buat kata sandi"
|
||||
:class="{ 'border-destructive': errors.password }" required />
|
||||
<p v-if="errors.password" class="text-sm text-destructive mt-1">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
</Field>
|
||||
<Field :class="{ 'text-destructive': errors.password_confirmation }">
|
||||
<FieldLabel for="password_confirmation">
|
||||
Konfirmasi Kata Sandi <span class="text-destructive">*</span>
|
||||
</FieldLabel>
|
||||
<Input id="password_confirmation" v-model="passwordConfirmation" type="password"
|
||||
placeholder="Konfirmasi kata sandi"
|
||||
:class="{ 'border-destructive': errors.password_confirmation }" required />
|
||||
<p v-if="errors.password_confirmation" class="text-sm text-destructive mt-1">
|
||||
{{ errors.password_confirmation }}
|
||||
</p>
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit" :disabled="loading">
|
||||
<span v-if="loading" class="i-lucide-loader-2 h-4 w-4 animate-spin mr-2" />
|
||||
Daftar
|
||||
</Button>
|
||||
<FieldDescription class="text-center">
|
||||
Sudah punya akun?
|
||||
<NuxtLink to="/auth/login" class="text-primary hover:underline">
|
||||
Masuk
|
||||
</NuxtLink>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<FieldSeparator class="*:data-[slot=field-separator-content]:bg-card">
|
||||
Atau daftar dengan
|
||||
</FieldSeparator>
|
||||
<Field>
|
||||
<Button variant="outline" type="button" :disabled="googleLoading" @click="handleGoogleRegister">
|
||||
<span v-if="googleLoading" class="i-lucide-loader-2 h-4 w-4 animate-spin mr-2" />
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-4 w-4 mr-2">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
Daftar dengan Google
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FieldDescription class="px-6 text-center">
|
||||
Dengan mendaftar, Anda menyetujui
|
||||
<NuxtLink to="/terms" class="text-primary hover:underline">
|
||||
Ketentuan Layanan
|
||||
</NuxtLink>
|
||||
dan
|
||||
<NuxtLink to="/privacy" class="text-primary hover:underline">
|
||||
Kebijakan Privasi
|
||||
</NuxtLink>.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</template>
|
||||
106
app/components/SectionCards.vue
Normal file
106
app/components/SectionCards.vue
Normal file
@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-vue"
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
<Card class="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Total Revenue</CardDescription>
|
||||
<CardTitle class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
$1,250.00
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<IconTrendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||
Trending up this month <IconTrendingUp class="size-4" />
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
Visitors for the last 6 months
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card class="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>New Customers</CardDescription>
|
||||
<CardTitle class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
1,234
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<IconTrendingDown />
|
||||
-20%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||
Down 20% this period <IconTrendingDown class="size-4" />
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
Acquisition needs attention
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card class="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Active Accounts</CardDescription>
|
||||
<CardTitle class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
45,678
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<IconTrendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||
Strong user retention <IconTrendingUp class="size-4" />
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
Engagement exceed targets
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card class="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Growth Rate</CardDescription>
|
||||
<CardTitle class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
4.5%
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<IconTrendingUp />
|
||||
+4.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||
Steady performance increase <IconTrendingUp class="size-4" />
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
Meets growth projections
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
101
app/components/SiteHeader.vue
Normal file
101
app/components/SiteHeader.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import {
|
||||
Bell,
|
||||
CreditCard,
|
||||
Home,
|
||||
LogOut,
|
||||
UserCircle,
|
||||
} from "@lucide/vue"
|
||||
|
||||
const user = {
|
||||
name: "shadcn",
|
||||
email: "m@example.com",
|
||||
avatar: "/avatars/shadcn.jpg",
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex h-(--header-height) shrink-0 items-center gap-2 border-b bg-background transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
||||
<div class="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||
<SidebarTrigger class="-ml-1" />
|
||||
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" class="size-8" as-child>
|
||||
<a href="/">
|
||||
<Home class="size-4" />
|
||||
<span class="sr-only">Homepage</span>
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="size-8">
|
||||
<Bell class="size-4" />
|
||||
<span class="sr-only">Notifications</span>
|
||||
</Button>
|
||||
<Separator orientation="vertical" class="mx-1 data-[orientation=vertical]:h-4" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-8 rounded-full">
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
CN
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-56 rounded-lg" side="bottom" align="end">
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
CN
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{{ user.name }}</span>
|
||||
<span class="text-muted-foreground truncate text-xs">
|
||||
{{ user.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<UserCircle />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCard />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
136
app/components/data-table/DataTable.vue
Normal file
136
app/components/data-table/DataTable.vue
Normal file
@ -0,0 +1,136 @@
|
||||
<script setup lang="ts" generic="TData, TValue">
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
ExpandedState,
|
||||
SortingState,
|
||||
} from '@tanstack/vue-table'
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
} from '@tanstack/vue-table'
|
||||
import { ArrowUpDown } from '@lucide/vue'
|
||||
import { computed, h, ref } from 'vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { valueUpdater } from '@/lib/utils'
|
||||
import DataTablePagination from './DataTablePagination.vue'
|
||||
import {
|
||||
Empty, EmptyDescription,
|
||||
EmptyHeader, EmptyTitle
|
||||
} from '@/components/ui/empty'
|
||||
|
||||
const props = defineProps<{
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
}>()
|
||||
|
||||
const sorting = ref<SortingState>([])
|
||||
const columnFilters = ref<ColumnFiltersState>([])
|
||||
const expanded = ref<ExpandedState>([])
|
||||
|
||||
const numberColumn: ColumnDef<TData> = {
|
||||
id: 'number',
|
||||
accessorFn: (_, index) => index,
|
||||
header: ({ column }) => {
|
||||
return h(Button, {
|
||||
variant: 'ghost',
|
||||
class: 'justify-center',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
}, () => ['#', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })])
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'text-center' }, `${row.index + 1}`)
|
||||
},
|
||||
enableHiding: false,
|
||||
}
|
||||
|
||||
const allColumns = computed<ColumnDef<TData>[]>(() => [
|
||||
numberColumn,
|
||||
...props.columns,
|
||||
])
|
||||
|
||||
const table = useVueTable({
|
||||
get data() { return props.data },
|
||||
get columns() { return allColumns.value },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting),
|
||||
onColumnFiltersChange: updaterOrValue => valueUpdater(updaterOrValue, columnFilters),
|
||||
onExpandedChange: updaterOrValue => valueUpdater(updaterOrValue, expanded),
|
||||
state: {
|
||||
get sorting() { return sorting.value },
|
||||
get columnFilters() { return columnFilters.value },
|
||||
get expanded() { return expanded.value },
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ table })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<slot name="filters" :table="table" />
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead v-for="header in headerGroup.headers" :key="header.id"
|
||||
:class="['actions', 'number'].includes(header.column.id) ? 'w-0' : ''">
|
||||
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header"
|
||||
:props="header.getContext()" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows?.length">
|
||||
<template v-for="row in table.getRowModel().rows" :key="row.id">
|
||||
<TableRow>
|
||||
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id"
|
||||
:class="['actions', 'number'].includes(cell.column.id) ? 'w-0' : ''">
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="row.getIsExpanded()">
|
||||
<TableCell :colspan="row.getAllCells().length">
|
||||
{{ JSON.stringify(row.original) }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<TableRow v-else>
|
||||
<TableCell :colspan="allColumns.length" class="h-24 text-center">
|
||||
<Empty class="from-muted/50 to-background h-full bg-linear-to-b from-30%">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Tidak ada data ditemukan</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Sesuaikan filter atau kata kunci pencarian, lalu coba lagi.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DataTablePagination :table="table" />
|
||||
</div>
|
||||
</template>
|
||||
64
app/components/data-table/DataTablePagination.vue
Normal file
64
app/components/data-table/DataTablePagination.vue
Normal file
@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from '@lucide/vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
const props = defineProps<{
|
||||
table: Table<any>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between px-2 py-4">
|
||||
<div />
|
||||
<div class="flex items-center gap-6 lg:gap-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium">
|
||||
Baris per halaman
|
||||
</p>
|
||||
<Select :model-value="`${table.getState().pagination.pageSize}`" @update:model-value="table.setPageSize">
|
||||
<SelectTrigger class="h-8 w-17.5">
|
||||
<SelectValue :placeholder="`${table.getState().pagination.pageSize}`" />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
<SelectItem v-for="pageSize in [10, 20, 30, 40, 50]" :key="pageSize" :value="`${pageSize}`">
|
||||
{{ pageSize }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex items-center justify-center text-sm font-medium">
|
||||
Halaman {{ table.getState().pagination.pageIndex + 1 }} dari
|
||||
{{ table.getPageCount() }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" class="hidden h-8 w-8 p-0 lg:flex" :disabled="!table.getCanPreviousPage()"
|
||||
@click="table.setPageIndex(0)">
|
||||
<span class="sr-only">Halaman pertama</span>
|
||||
<ChevronsLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" class="h-8 w-8 p-0" :disabled="!table.getCanPreviousPage()"
|
||||
@click="table.previousPage()">
|
||||
<span class="sr-only">Halaman sebelumnya</span>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" class="h-8 w-8 p-0" :disabled="!table.getCanNextPage()" @click="table.nextPage()">
|
||||
<span class="sr-only">Halaman berikutnya</span>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" class="hidden h-8 w-8 p-0 lg:flex" :disabled="!table.getCanNextPage()"
|
||||
@click="table.setPageIndex(table.getPageCount() - 1)">
|
||||
<span class="sr-only">Halaman terakhir</span>
|
||||
<ChevronsRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
18
app/components/ui/avatar/Avatar.vue
Normal file
18
app/components/ui/avatar/Avatar.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { AvatarRoot } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarRoot
|
||||
data-slot="avatar"
|
||||
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AvatarRoot>
|
||||
</template>
|
||||
21
app/components/ui/avatar/AvatarFallback.vue
Normal file
21
app/components/ui/avatar/AvatarFallback.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { AvatarFallbackProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AvatarFallback } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarFallback
|
||||
data-slot="avatar-fallback"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AvatarFallback>
|
||||
</template>
|
||||
16
app/components/ui/avatar/AvatarImage.vue
Normal file
16
app/components/ui/avatar/AvatarImage.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { AvatarImageProps } from "reka-ui"
|
||||
import { AvatarImage } from "reka-ui"
|
||||
|
||||
const props = defineProps<AvatarImageProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarImage
|
||||
data-slot="avatar-image"
|
||||
v-bind="props"
|
||||
class="aspect-square size-full"
|
||||
>
|
||||
<slot />
|
||||
</AvatarImage>
|
||||
</template>
|
||||
3
app/components/ui/avatar/index.ts
Normal file
3
app/components/ui/avatar/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as Avatar } from "./Avatar.vue"
|
||||
export { default as AvatarFallback } from "./AvatarFallback.vue"
|
||||
export { default as AvatarImage } from "./AvatarImage.vue"
|
||||
26
app/components/ui/badge/Badge.vue
Normal file
26
app/components/ui/badge/Badge.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { BadgeVariants } from "."
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { badgeVariants } from "."
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
variant?: BadgeVariants["variant"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:class="cn(badgeVariants({ variant }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
26
app/components/ui/badge/index.ts
Normal file
26
app/components/ui/badge/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Badge } from "./Badge.vue"
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
31
app/components/ui/button/Button.vue
Normal file
31
app/components/ui/button/Button.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "."
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
40
app/components/ui/button/index.ts
Normal file
40
app/components/ui/button/index.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Button } from "./Button.vue"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
"xs": "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
"icon": "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
22
app/components/ui/card/Card.vue
Normal file
22
app/components/ui/card/Card.vue
Normal 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="card"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
app/components/ui/card/CardAction.vue
Normal file
17
app/components/ui/card/CardAction.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-action"
|
||||
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
app/components/ui/card/CardContent.vue
Normal file
17
app/components/ui/card/CardContent.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-content"
|
||||
:class="cn('px-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
app/components/ui/card/CardDescription.vue
Normal file
17
app/components/ui/card/CardDescription.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="card-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
17
app/components/ui/card/CardFooter.vue
Normal file
17
app/components/ui/card/CardFooter.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
app/components/ui/card/CardHeader.vue
Normal file
17
app/components/ui/card/CardHeader.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-header"
|
||||
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
app/components/ui/card/CardTitle.vue
Normal file
17
app/components/ui/card/CardTitle.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3
|
||||
data-slot="card-title"
|
||||
:class="cn('leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
7
app/components/ui/card/index.ts
Normal file
7
app/components/ui/card/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { default as Card } from "./Card.vue"
|
||||
export { default as CardAction } from "./CardAction.vue"
|
||||
export { default as CardContent } from "./CardContent.vue"
|
||||
export { default as CardDescription } from "./CardDescription.vue"
|
||||
export { default as CardFooter } from "./CardFooter.vue"
|
||||
export { default as CardHeader } from "./CardHeader.vue"
|
||||
export { default as CardTitle } from "./CardTitle.vue"
|
||||
59
app/components/ui/chart/ChartContainer.vue
Normal file
59
app/components/ui/chart/ChartContainer.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ChartConfig } from "."
|
||||
import { useId } from "reka-ui"
|
||||
import { computed, toRefs } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { provideChartContext } from "."
|
||||
import ChartStyle from "./ChartStyle.vue"
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
id?: HTMLAttributes["id"]
|
||||
class?: HTMLAttributes["class"]
|
||||
config: ChartConfig
|
||||
cursor?: boolean
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
default: {
|
||||
id: string
|
||||
config: ChartConfig
|
||||
}
|
||||
}>()
|
||||
|
||||
const { config } = toRefs(props)
|
||||
const uniqueId = useId()
|
||||
const chartId = computed(() => `chart-${props.id || uniqueId.replace(/:/g, "")}`)
|
||||
|
||||
provideChartContext({
|
||||
id: uniqueId,
|
||||
config,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="chart"
|
||||
:data-chart="chartId"
|
||||
:class="cn(
|
||||
`[&_.tick_text]:!fill-muted-foreground [&_.tick_line]:!stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex flex-col aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden [&_[data-vis-xy-container]]:h-full [&_[data-vis-single-container]]:h-full h-full [&_[data-vis-xy-container]]:w-full [&_[data-vis-single-container]]:w-full w-full `,
|
||||
props.class,
|
||||
)"
|
||||
:style="{
|
||||
'--vis-tooltip-padding': '0px',
|
||||
'--vis-tooltip-background-color': 'transparent',
|
||||
'--vis-tooltip-border-color': 'transparent',
|
||||
'--vis-tooltip-text-color': 'none',
|
||||
'--vis-tooltip-shadow-color': 'none',
|
||||
'--vis-tooltip-backdrop-filter': 'none',
|
||||
'--vis-crosshair-circle-stroke-color': '#0000',
|
||||
'--vis-crosshair-line-stroke-width': cursor ? '1px' : '0px',
|
||||
'--vis-font-family': 'var(--font-sans)',
|
||||
}"
|
||||
>
|
||||
<slot :id="uniqueId" :config="config" />
|
||||
<ChartStyle :id="chartId" />
|
||||
</div>
|
||||
</template>
|
||||
60
app/components/ui/chart/ChartLegendContent.vue
Normal file
60
app/components/ui/chart/ChartLegendContent.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { computed, onMounted, ref } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useChart } from "."
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
verticalAlign?: "bottom" | "top"
|
||||
// payload?: any[]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>(), {
|
||||
verticalAlign: "bottom",
|
||||
})
|
||||
|
||||
const { id, config } = useChart()
|
||||
|
||||
const payload = computed(() => Object.entries(config.value).map(([key, value]) => {
|
||||
return {
|
||||
key: props.nameKey || key,
|
||||
itemConfig: value,
|
||||
}
|
||||
}))
|
||||
|
||||
const containerSelector = ref("")
|
||||
onMounted(() => {
|
||||
containerSelector.value = `[data-chart="chart-${id}"]>[data-vis-xy-container]`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="containerSelector"
|
||||
:class="cn(
|
||||
'flex items-center justify-center gap-4',
|
||||
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<div
|
||||
v-for="{ key, itemConfig } in payload"
|
||||
:key="key"
|
||||
:class="cn(
|
||||
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3',
|
||||
)"
|
||||
>
|
||||
<component :is="itemConfig.icon" v-if="itemConfig.icon" />
|
||||
<div
|
||||
v-else
|
||||
class="h-2 w-2 shrink-0 rounded-xs"
|
||||
:style="{
|
||||
backgroundColor: itemConfig?.color,
|
||||
}"
|
||||
/>
|
||||
|
||||
{{ itemConfig.label }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
42
app/components/ui/chart/ChartStyle.vue
Normal file
42
app/components/ui/chart/ChartStyle.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { Primitive } from "reka-ui"
|
||||
import { computed } from "vue"
|
||||
import { THEMES, useChart } from "."
|
||||
|
||||
defineProps<{
|
||||
id?: HTMLAttributes["id"]
|
||||
}>()
|
||||
|
||||
const { config } = useChart()
|
||||
|
||||
const colorConfig = computed(() => {
|
||||
return Object.entries(config.value).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
v-if="colorConfig.length"
|
||||
as="style"
|
||||
>
|
||||
{{ Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color
|
||||
= itemConfig.theme?.[theme as keyof typeof itemConfig.theme]
|
||||
|| itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n") }}
|
||||
</Primitive>
|
||||
</template>
|
||||
105
app/components/ui/chart/ChartTooltipContent.vue
Normal file
105
app/components/ui/chart/ChartTooltipContent.vue
Normal file
@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ChartConfig } from "."
|
||||
import { computed } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
labelFormatter?: (d: number | Date) => string
|
||||
payload?: Record<string, any>
|
||||
config?: ChartConfig
|
||||
class?: HTMLAttributes["class"]
|
||||
color?: string
|
||||
x?: number | Date
|
||||
}>(), {
|
||||
payload: () => ({}),
|
||||
config: () => ({}),
|
||||
indicator: "dot",
|
||||
})
|
||||
|
||||
// TODO: currently we use `createElement` and `render` to render the
|
||||
// const chartContext = useChart(null)
|
||||
|
||||
const payload = computed(() => {
|
||||
return Object.entries(props.payload).map(([key, value]) => {
|
||||
// const key = `${props.nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = props.config[key]
|
||||
const indicatorColor = props.config[key]?.color ?? props.payload.fill
|
||||
|
||||
return { key, value, itemConfig, indicatorColor }
|
||||
}).filter(i => i.itemConfig)
|
||||
})
|
||||
|
||||
const nestLabel = computed(() => Object.keys(props.payload).length === 1 && props.indicator !== "dot")
|
||||
const tooltipLabel = computed(() => {
|
||||
if (props.hideLabel)
|
||||
return null
|
||||
if (props.labelFormatter && props.x !== undefined) {
|
||||
return props.labelFormatter(props.x)
|
||||
}
|
||||
return props.labelKey ? props.config[props.labelKey]?.label || props.payload[props.labelKey] : props.x
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn(
|
||||
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot>
|
||||
<div v-if="!nestLabel && tooltipLabel" class="font-medium">
|
||||
{{ tooltipLabel }}
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<div
|
||||
v-for="{ value, itemConfig, indicatorColor, key } in payload"
|
||||
:key="key"
|
||||
:class="
|
||||
cn('[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
|
||||
indicator === 'dot' && 'items-center')"
|
||||
>
|
||||
<component :is="itemConfig.icon" v-if="itemConfig?.icon" />
|
||||
<template v-else-if="!hideIndicator">
|
||||
<div
|
||||
:class="cn(
|
||||
'shrink-0 rounded-xs border-(--color-border) bg-(--color-bg)',
|
||||
{
|
||||
'h-2.5 w-2.5': indicator === 'dot',
|
||||
'w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||
indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed',
|
||||
},
|
||||
)"
|
||||
:style="{
|
||||
'--color-bg': indicatorColor,
|
||||
'--color-border': indicatorColor,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div :class="cn('flex flex-1 justify-between leading-none', nestLabel ? 'items-end' : 'items-center')">
|
||||
<div class="grid gap-1.5">
|
||||
<div v-if="nestLabel" class="font-medium">
|
||||
{{ tooltipLabel }}
|
||||
</div>
|
||||
<span class="text-muted-foreground">
|
||||
{{ itemConfig?.label || value }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="value" class="text-foreground font-mono font-medium tabular-nums">
|
||||
{{ value.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
29
app/components/ui/chart/index.ts
Normal file
29
app/components/ui/chart/index.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { Component, Ref } from "vue"
|
||||
import { createContext } from "reka-ui"
|
||||
|
||||
export { default as ChartContainer } from "./ChartContainer.vue"
|
||||
export { default as ChartLegendContent } from "./ChartLegendContent.vue"
|
||||
export { default as ChartTooltipContent } from "./ChartTooltipContent.vue"
|
||||
export { componentToString } from "./utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
export const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: string | Component
|
||||
icon?: string | Component
|
||||
} & (
|
||||
| { color?: string, theme?: never }
|
||||
| { color?: never, theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
interface ChartContextProps {
|
||||
id: string
|
||||
config: Ref<ChartConfig>
|
||||
}
|
||||
|
||||
export const [useChart, provideChartContext] = createContext<ChartContextProps>("Chart")
|
||||
|
||||
export { VisCrosshair as ChartCrosshair, VisTooltip as ChartTooltip } from "@unovis/vue"
|
||||
44
app/components/ui/chart/utils.ts
Normal file
44
app/components/ui/chart/utils.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import type { ChartConfig } from "."
|
||||
import { isClient } from "@vueuse/core"
|
||||
import { useId } from "reka-ui"
|
||||
import { h, render } from "vue"
|
||||
|
||||
// Simple cache using a Map to store serialized object keys
|
||||
const cache = new Map<string, string>()
|
||||
|
||||
// Convert object to a consistent string key
|
||||
function serializeKey(key: Record<string, any>): string {
|
||||
return JSON.stringify(key, Object.keys(key).sort())
|
||||
}
|
||||
|
||||
interface Constructor<P = any> {
|
||||
__isFragment?: never
|
||||
__isTeleport?: never
|
||||
__isSuspense?: never
|
||||
new (...args: any[]): {
|
||||
$props: P
|
||||
}
|
||||
}
|
||||
|
||||
export function componentToString<P>(config: ChartConfig, component: Constructor<P>, props?: P) {
|
||||
if (!isClient)
|
||||
return
|
||||
|
||||
// This function will be called once during mount lifecycle
|
||||
const id = useId()
|
||||
|
||||
// https://unovis.dev/docs/auxiliary/Crosshair#component-props
|
||||
return (_data: any, x: number | Date) => {
|
||||
const data = "data" in _data ? _data.data : _data
|
||||
const serializedKey = `${id}-${serializeKey(data)}`
|
||||
const cachedContent = cache.get(serializedKey)
|
||||
if (cachedContent)
|
||||
return cachedContent
|
||||
|
||||
const vnode = h<unknown>(component, { ...props, payload: data, config, x })
|
||||
const div = document.createElement("div")
|
||||
render(vnode, div)
|
||||
cache.set(serializedKey, div.innerHTML)
|
||||
return div.innerHTML
|
||||
}
|
||||
}
|
||||
35
app/components/ui/checkbox/Checkbox.vue
Normal file
35
app/components/ui/checkbox/Checkbox.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { Check } from "@lucide/vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="checkbox"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class)"
|
||||
>
|
||||
<CheckboxIndicator
|
||||
data-slot="checkbox-indicator"
|
||||
class="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<slot v-bind="slotProps">
|
||||
<Check class="size-3.5" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
1
app/components/ui/checkbox/index.ts
Normal file
1
app/components/ui/checkbox/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Checkbox } from "./Checkbox.vue"
|
||||
19
app/components/ui/dialog/Dialog.vue
Normal file
19
app/components/ui/dialog/Dialog.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dialog"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
15
app/components/ui/dialog/DialogClose.vue
Normal file
15
app/components/ui/dialog/DialogClose.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from "reka-ui"
|
||||
import { DialogClose } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose
|
||||
data-slot="dialog-close"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
53
app/components/ui/dialog/DialogContent.vue
Normal file
53
app/components/ui/dialog/DialogContent.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { X } from "@lucide/vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import DialogOverlay from "./DialogOverlay.vue"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="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 />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
app/components/ui/dialog/DialogDescription.vue
Normal file
23
app/components/ui/dialog/DialogDescription.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogDescription, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="dialog-description"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
27
app/components/ui/dialog/DialogFooter.vue
Normal file
27
app/components/ui/dialog/DialogFooter.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { DialogClose } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
showCloseButton?: boolean
|
||||
}>(), {
|
||||
showCloseButton: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
<DialogClose v-if="showCloseButton" as-child>
|
||||
<Button variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</template>
|
||||
17
app/components/ui/dialog/DialogHeader.vue
Normal file
17
app/components/ui/dialog/DialogHeader.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
app/components/ui/dialog/DialogOverlay.vue
Normal file
21
app/components/ui/dialog/DialogOverlay.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogOverlay } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('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', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
59
app/components/ui/dialog/DialogScrollContent.vue
Normal file
59
app/components/ui/dialog/DialogScrollContent.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { X } from "@lucide/vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
app/components/ui/dialog/DialogTitle.vue
Normal file
23
app/components/ui/dialog/DialogTitle.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogTitle, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="dialog-title"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-lg leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
15
app/components/ui/dialog/DialogTrigger.vue
Normal file
15
app/components/ui/dialog/DialogTrigger.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from "reka-ui"
|
||||
import { DialogTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger
|
||||
data-slot="dialog-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
10
app/components/ui/dialog/index.ts
Normal file
10
app/components/ui/dialog/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export { default as Dialog } from "./Dialog.vue"
|
||||
export { default as DialogClose } from "./DialogClose.vue"
|
||||
export { default as DialogContent } from "./DialogContent.vue"
|
||||
export { default as DialogDescription } from "./DialogDescription.vue"
|
||||
export { default as DialogFooter } from "./DialogFooter.vue"
|
||||
export { default as DialogHeader } from "./DialogHeader.vue"
|
||||
export { default as DialogOverlay } from "./DialogOverlay.vue"
|
||||
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
|
||||
export { default as DialogTitle } from "./DialogTitle.vue"
|
||||
export { default as DialogTrigger } from "./DialogTrigger.vue"
|
||||
19
app/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
19
app/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from "reka-ui"
|
||||
import { DropdownMenuRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuRootProps>()
|
||||
const emits = defineEmits<DropdownMenuRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dropdown-menu"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
39
app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
Normal file
39
app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { Check } from "@lucide/vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuCheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
:class=" cn(
|
||||
`focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Check class="size-4" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
39
app/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal file
39
app/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</template>
|
||||
15
app/components/ui/dropdown-menu/DropdownMenuGroup.vue
Normal file
15
app/components/ui/dropdown-menu/DropdownMenuGroup.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuGroupProps } from "reka-ui"
|
||||
import { DropdownMenuGroup } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuGroupProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuGroup
|
||||
data-slot="dropdown-menu-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuGroup>
|
||||
</template>
|
||||
31
app/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal file
31
app/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DropdownMenuItem, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(defineProps<DropdownMenuItemProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}>(), {
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "inset", "variant", "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuItem
|
||||
data-slot="dropdown-menu-item"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(`relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!`, props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
23
app/components/ui/dropdown-menu/DropdownMenuLabel.vue
Normal file
23
app/components/ui/dropdown-menu/DropdownMenuLabel.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuLabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DropdownMenuLabel, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuLabel>
|
||||
</template>
|
||||
21
app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
Normal file
21
app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from "reka-ui"
|
||||
import {
|
||||
DropdownMenuRadioGroup,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuRadioGroupProps>()
|
||||
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuRadioGroup>
|
||||
</template>
|
||||
40
app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
Normal file
40
app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { Circle } from "@lucide/vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const emits = defineEmits<DropdownMenuRadioItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
`focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Circle class="size-2 fill-current" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuRadioItem>
|
||||
</template>
|
||||
23
app/components/ui/dropdown-menu/DropdownMenuSeparator.vue
Normal file
23
app/components/ui/dropdown-menu/DropdownMenuSeparator.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSeparatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuSeparator,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSeparatorProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSeparator
|
||||
data-slot="dropdown-menu-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
|
||||
/>
|
||||
</template>
|
||||
17
app/components/ui/dropdown-menu/DropdownMenuShortcut.vue
Normal file
17
app/components/ui/dropdown-menu/DropdownMenuShortcut.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
18
app/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
18
app/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from "reka-ui"
|
||||
import {
|
||||
DropdownMenuSub,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuSubProps>()
|
||||
const emits = defineEmits<DropdownMenuSubEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</DropdownMenuSub>
|
||||
</template>
|
||||
27
app/components/ui/dropdown-menu/DropdownMenuSubContent.vue
Normal file
27
app/components/ui/dropdown-menu/DropdownMenuSubContent.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuSubContent,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
v-bind="forwarded"
|
||||
:class="cn('bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] max-w-(--reka-dropdown-menu-content-available-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuSubContent>
|
||||
</template>
|
||||
31
app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
Normal file
31
app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { ChevronRight } from "@lucide/vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuSubTrigger,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:class="cn(
|
||||
`relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!`,
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRight class="ml-auto size-4" />
|
||||
</DropdownMenuSubTrigger>
|
||||
</template>
|
||||
17
app/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal file
17
app/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuTriggerProps } from "reka-ui"
|
||||
import { DropdownMenuTrigger, useForwardProps } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuTriggerProps>()
|
||||
|
||||
const forwardedProps = useForwardProps(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuTrigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
</template>
|
||||
16
app/components/ui/dropdown-menu/index.ts
Normal file
16
app/components/ui/dropdown-menu/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export { default as DropdownMenu } from "./DropdownMenu.vue"
|
||||
|
||||
export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue"
|
||||
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"
|
||||
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"
|
||||
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue"
|
||||
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue"
|
||||
export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue"
|
||||
export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue"
|
||||
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue"
|
||||
export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue"
|
||||
export { default as DropdownMenuSub } from "./DropdownMenuSub.vue"
|
||||
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue"
|
||||
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue"
|
||||
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue"
|
||||
export { DropdownMenuPortal } from "reka-ui"
|
||||
20
app/components/ui/empty/Empty.vue
Normal file
20
app/components/ui/empty/Empty.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<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="empty"
|
||||
:class="cn(
|
||||
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
20
app/components/ui/empty/EmptyContent.vue
Normal file
20
app/components/ui/empty/EmptyContent.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<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="empty-content"
|
||||
:class="cn(
|
||||
'flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
20
app/components/ui/empty/EmptyDescription.vue
Normal file
20
app/components/ui/empty/EmptyDescription.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="empty-description"
|
||||
:class="cn(
|
||||
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
|
||||
$attrs.class ?? '',
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
20
app/components/ui/empty/EmptyHeader.vue
Normal file
20
app/components/ui/empty/EmptyHeader.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<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="empty-header"
|
||||
:class="cn(
|
||||
'flex max-w-sm flex-col items-center gap-2 text-center',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
app/components/ui/empty/EmptyMedia.vue
Normal file
21
app/components/ui/empty/EmptyMedia.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { EmptyMediaVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { emptyMediaVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: EmptyMediaVariants["variant"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
:data-variant="variant"
|
||||
:class="cn(emptyMediaVariants({ variant }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
app/components/ui/empty/EmptyTitle.vue
Normal file
17
app/components/ui/empty/EmptyTitle.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
:class="cn('text-lg font-medium tracking-tight', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
26
app/components/ui/empty/index.ts
Normal file
26
app/components/ui/empty/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Empty } from "./Empty.vue"
|
||||
export { default as EmptyContent } from "./EmptyContent.vue"
|
||||
export { default as EmptyDescription } from "./EmptyDescription.vue"
|
||||
export { default as EmptyHeader } from "./EmptyHeader.vue"
|
||||
export { default as EmptyMedia } from "./EmptyMedia.vue"
|
||||
export { default as EmptyTitle } from "./EmptyTitle.vue"
|
||||
|
||||
export const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type EmptyMediaVariants = VariantProps<typeof emptyMediaVariants>
|
||||
25
app/components/ui/field/Field.vue
Normal file
25
app/components/ui/field/Field.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { FieldVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { fieldVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
orientation?: FieldVariants["orientation"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
:data-orientation="orientation"
|
||||
:class="cn(
|
||||
fieldVariants({ orientation }),
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
20
app/components/ui/field/FieldContent.vue
Normal file
20
app/components/ui/field/FieldContent.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<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="field-content"
|
||||
:class="cn(
|
||||
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
22
app/components/ui/field/FieldDescription.vue
Normal file
22
app/components/ui/field/FieldDescription.vue
Normal 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>
|
||||
<p
|
||||
data-slot="field-description"
|
||||
:class="cn(
|
||||
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
|
||||
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
53
app/components/ui/field/FieldError.vue
Normal file
53
app/components/ui/field/FieldError.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { computed } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
errors?: Array<string | { message: string | undefined } | undefined>
|
||||
}>()
|
||||
|
||||
const content = computed(() => {
|
||||
if (!props.errors || props.errors.length === 0)
|
||||
return null
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(
|
||||
props.errors
|
||||
.filter(Boolean)
|
||||
.map((error) => {
|
||||
const message = typeof error === "string" ? error : error?.message
|
||||
return [message, error]
|
||||
}),
|
||||
).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors.length === 1 && uniqueErrors[0]) {
|
||||
return typeof uniqueErrors[0] === "string" ? uniqueErrors[0] : uniqueErrors[0].message
|
||||
}
|
||||
|
||||
return uniqueErrors.map(error => typeof error === "string" ? error : error?.message)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="$slots.default || content"
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
:class="cn('text-destructive text-sm font-normal', props.class)"
|
||||
>
|
||||
<slot v-if="$slots.default" />
|
||||
|
||||
<template v-else-if="typeof content === 'string'">
|
||||
{{ content }}
|
||||
</template>
|
||||
|
||||
<ul v-else-if="Array.isArray(content)" class="ml-4 flex list-disc flex-col gap-1">
|
||||
<li v-for="(error, index) in content" :key="index">
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
20
app/components/ui/field/FieldGroup.vue
Normal file
20
app/components/ui/field/FieldGroup.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<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="field-group"
|
||||
:class="cn(
|
||||
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
23
app/components/ui/field/FieldLabel.vue
Normal file
23
app/components/ui/field/FieldLabel.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
:class="cn(
|
||||
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
|
||||
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
24
app/components/ui/field/FieldLegend.vue
Normal file
24
app/components/ui/field/FieldLegend.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: "legend" | "label"
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
:data-variant="variant"
|
||||
:class="cn(
|
||||
'mb-3 font-medium',
|
||||
'data-[variant=legend]:text-base',
|
||||
'data-[variant=label]:text-sm',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</legend>
|
||||
</template>
|
||||
29
app/components/ui/field/FieldSeparator.vue
Normal file
29
app/components/ui/field/FieldSeparator.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
:data-content="!!$slots.default"
|
||||
:class="cn(
|
||||
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<Separator class="absolute inset-0 top-1/2" />
|
||||
<span
|
||||
v-if="$slots.default"
|
||||
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
21
app/components/ui/field/FieldSet.vue
Normal file
21
app/components/ui/field/FieldSet.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
:class="cn(
|
||||
'flex flex-col gap-6',
|
||||
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</fieldset>
|
||||
</template>
|
||||
20
app/components/ui/field/FieldTitle.vue
Normal file
20
app/components/ui/field/FieldTitle.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<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="field-label"
|
||||
:class="cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
39
app/components/ui/field/index.ts
Normal file
39
app/components/ui/field/index.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type FieldVariants = VariantProps<typeof fieldVariants>
|
||||
|
||||
export { default as Field } from "./Field.vue"
|
||||
export { default as FieldContent } from "./FieldContent.vue"
|
||||
export { default as FieldDescription } from "./FieldDescription.vue"
|
||||
export { default as FieldError } from "./FieldError.vue"
|
||||
export { default as FieldGroup } from "./FieldGroup.vue"
|
||||
export { default as FieldLabel } from "./FieldLabel.vue"
|
||||
export { default as FieldLegend } from "./FieldLegend.vue"
|
||||
export { default as FieldSeparator } from "./FieldSeparator.vue"
|
||||
export { default as FieldSet } from "./FieldSet.vue"
|
||||
export { default as FieldTitle } from "./FieldTitle.vue"
|
||||
35
app/components/ui/input-group/InputGroup.vue
Normal file
35
app/components/ui/input-group/InputGroup.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<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="input-group"
|
||||
role="group"
|
||||
:class="cn(
|
||||
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
|
||||
'h-9 min-w-0 has-[>textarea]:h-auto',
|
||||
|
||||
// Variants based on alignment.
|
||||
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
|
||||
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
|
||||
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
|
||||
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
|
||||
|
||||
// Focus state.
|
||||
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-3',
|
||||
|
||||
// Error state.
|
||||
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
|
||||
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
36
app/components/ui/input-group/InputGroupAddon.vue
Normal file
36
app/components/ui/input-group/InputGroupAddon.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { InputGroupVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { inputGroupAddonVariants } from "."
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
align?: InputGroupVariants["align"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>(), {
|
||||
align: "inline-start",
|
||||
})
|
||||
|
||||
function handleInputGroupAddonClick(e: MouseEvent) {
|
||||
const currentTarget = e.currentTarget as HTMLElement | null
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target && target.closest("button")) {
|
||||
return
|
||||
}
|
||||
if (currentTarget && currentTarget?.parentElement) {
|
||||
currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
:data-align="props.align"
|
||||
:class="cn(inputGroupAddonVariants({ align: props.align }), props.class)"
|
||||
@click="handleInputGroupAddonClick"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
29
app/components/ui/input-group/InputGroupButton.vue
Normal file
29
app/components/ui/input-group/InputGroupButton.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { InputGroupButtonVariants } from "."
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { inputGroupButtonVariants } from "."
|
||||
|
||||
interface InputGroupButtonProps {
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: InputGroupButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<InputGroupButtonProps>(), {
|
||||
size: "xs",
|
||||
variant: "ghost",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
:data-size="props.size"
|
||||
:variant="props.variant"
|
||||
:class="cn(inputGroupButtonVariants({ size: props.size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Button>
|
||||
</template>
|
||||
19
app/components/ui/input-group/InputGroupInput.vue
Normal file
19
app/components/ui/input-group/InputGroupInput.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
:class="cn(
|
||||
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
props.class,
|
||||
)"
|
||||
/>
|
||||
</template>
|
||||
19
app/components/ui/input-group/InputGroupText.vue
Normal file
19
app/components/ui/input-group/InputGroupText.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="cn(
|
||||
`text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4`,
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
19
app/components/ui/input-group/InputGroupTextarea.vue
Normal file
19
app/components/ui/input-group/InputGroupTextarea.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
:class="cn(
|
||||
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
props.class,
|
||||
)"
|
||||
/>
|
||||
</template>
|
||||
51
app/components/ui/input-group/index.ts
Normal file
51
app/components/ui/input-group/index.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as InputGroup } from "./InputGroup.vue"
|
||||
export { default as InputGroupAddon } from "./InputGroupAddon.vue"
|
||||
export { default as InputGroupButton } from "./InputGroupButton.vue"
|
||||
export { default as InputGroupInput } from "./InputGroupInput.vue"
|
||||
export { default as InputGroupText } from "./InputGroupText.vue"
|
||||
export { default as InputGroupTextarea } from "./InputGroupTextarea.vue"
|
||||
|
||||
export const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type InputGroupVariants = VariantProps<typeof inputGroupAddonVariants>
|
||||
|
||||
export const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
"xs": "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
"sm": "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs": "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type InputGroupButtonVariants = VariantProps<typeof inputGroupButtonVariants>
|
||||
33
app/components/ui/input/Input.vue
Normal file
33
app/components/ui/input/Input.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: "update:modelValue", payload: string | number): void
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="modelValue"
|
||||
data-slot="input"
|
||||
:class="cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
</template>
|
||||
1
app/components/ui/input/index.ts
Normal file
1
app/components/ui/input/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Input } from "./Input.vue"
|
||||
26
app/components/ui/label/Label.vue
Normal file
26
app/components/ui/label/Label.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { LabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Label } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="label"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
1
app/components/ui/label/index.ts
Normal file
1
app/components/ui/label/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Label } from "./Label.vue"
|
||||
19
app/components/ui/select/Select.vue
Normal file
19
app/components/ui/select/Select.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
|
||||
import { SelectRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<SelectRootProps>()
|
||||
const emits = defineEmits<SelectRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="select"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</SelectRoot>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user