Initial codebase commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# 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
|
||||||
217
README.md
Normal file
217
README.md
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
I need to make mp3-playlist-composer which gives you mp3 folder + m3u (mp3 playlist) so i can use my mp3 player in daily basis without an hassle
|
||||||
|
|
||||||
|
so i need:
|
||||||
|
|
||||||
|
- auth (oauth2 with youtube to access playlists (ik how to setup it, so do not tell me bout it), and custom oidc (SSO) as zitadel)
|
||||||
|
- user creates an playlist compose which is multiple source mp3 files (with thumbnails, title, artist and etc.), available sources:
|
||||||
|
- youtube playlist link
|
||||||
|
- local files
|
||||||
|
- external mp3 link for 1 song or smth
|
||||||
|
|
||||||
|
- after user created an playlist compose he can:
|
||||||
|
- play music
|
||||||
|
- change order of individual pieces
|
||||||
|
- split one song into multiple ones
|
||||||
|
- combine multiple music pieces into subplaylist, for example if we have too much items
|
||||||
|
- draft playlist, which is same as playlist compose but draft playlist contains bunch of music which can be different, and user can "move" music from one playlist compose into another
|
||||||
|
- m3u file generation on the fly
|
||||||
|
- music can contain some characters which could break some mp3 players, so we need download modal with options such as:
|
||||||
|
title -> ascii, but our system is not very smart so user will be prompted ONCE to do that
|
||||||
|
|
||||||
|
Here's a realistic development plan for your MP3 playlist composer, assuming 3 hours per day:
|
||||||
|
|
||||||
|
## 📋 Complete Development Plan
|
||||||
|
|
||||||
|
### Phase 1: Foundation & Basic Playlist (Days 1-7 | ~21 hours)
|
||||||
|
|
||||||
|
**Day 1-2: Design & Frontend Foundation (6 hours)**
|
||||||
|
|
||||||
|
- **3 hours**: Find and customize a Figma UI kit (music player template)
|
||||||
|
- **3 hours**: Set up Vue 3 project with basic layout components (player, playlist view, sidebar)
|
||||||
|
|
||||||
|
**Day 3-4: Backend Foundation (6 hours)**
|
||||||
|
|
||||||
|
- **3 hours**: Database schema (Users, Playlists, Tracks, Drafts) + S3 bucket setup
|
||||||
|
- **3 hours**: Quarkus basic setup with authentication skeleton (no OAuth yet)
|
||||||
|
|
||||||
|
**Day 5-7: Core Playlist Features (9 hours)**
|
||||||
|
|
||||||
|
- **3 hours**: Basic playlist CRUD (create, read, update, delete)
|
||||||
|
- **3 hours**: Local file upload & processing
|
||||||
|
- **3 hours**: M3U file generation
|
||||||
|
|
||||||
|
### Phase 2: YouTube Integration & Basic Player (Days 8-14 | ~21 hours)
|
||||||
|
|
||||||
|
**Day 8-10: YouTube Integration (9 hours)**
|
||||||
|
|
||||||
|
- **3 hours**: YouTube API integration (fetch playlist metadata)
|
||||||
|
- **3 hours**: Audio downloading from YouTube
|
||||||
|
- **3 hours**: Metadata extraction (title, artist, thumbnail)
|
||||||
|
|
||||||
|
**Day 11-12: Authentication (6 hours)**
|
||||||
|
|
||||||
|
- **3 hours**: OAuth2 with YouTube setup
|
||||||
|
- **3 hours**: Session management & protected routes
|
||||||
|
|
||||||
|
**Day 13-14: Basic Music Player (6 hours)**
|
||||||
|
|
||||||
|
- **3 hours**: Audio player component with play/pause/seek
|
||||||
|
- **3 hours**: Playlist navigation and track switching
|
||||||
|
|
||||||
|
### Phase 3: Advanced Features (Days 15-25 | ~33 hours)
|
||||||
|
|
||||||
|
**Day 15-17: Draft System (9 hours)**
|
||||||
|
|
||||||
|
- **3 hours**: Draft playlist model and API
|
||||||
|
- **3 hours**: Move tracks between playlists/drafts
|
||||||
|
- **3 hours**: Draft management UI
|
||||||
|
|
||||||
|
**Day 18-20: Music Manipulation (12 hours)**
|
||||||
|
|
||||||
|
- **3 hours:** <https://www.sortbytune.com/ordering-the-perfect-playlist>, sort by tune, and etc.
|
||||||
|
- **3 hours**: Track splitting backend logic
|
||||||
|
- **3 hours**: Track splitting frontend interface
|
||||||
|
- **3 hours**: Combine tracks into subplaylists
|
||||||
|
|
||||||
|
**Day 21-22: External MP3 Support (6 hours)**
|
||||||
|
|
||||||
|
- **3 hours**: Single MP3 URL processing
|
||||||
|
- **3 hours**: URL validation and metadata fetching
|
||||||
|
|
||||||
|
**Day 23-25: Polish & Export (9 hours)**
|
||||||
|
|
||||||
|
- **3 hours**: Download modal with ASCII conversion
|
||||||
|
- **3 hours**: Character sanitization for MP3 players
|
||||||
|
- **3 hours**: Export optimization and error handling
|
||||||
|
|
||||||
|
### Phase 4: Future Features (Beyond MVP)
|
||||||
|
|
||||||
|
- Audio editing (volume, waveforms): ~15 hours
|
||||||
|
- Advanced formatting options: ~9 hours
|
||||||
|
- Real-time collaboration: ~12 hours
|
||||||
|
- Mobile app: ~30 hours
|
||||||
|
|
||||||
|
## 🎯 MVP Definition (What's achievable in ~75 hours)
|
||||||
|
|
||||||
|
**Core MVP Features:**
|
||||||
|
|
||||||
|
- ✅ User authentication (OAuth2 + local)
|
||||||
|
- ✅ Create playlists from YouTube links
|
||||||
|
- ✅ Local file upload
|
||||||
|
- ✅ Basic music player
|
||||||
|
- ✅ Track reordering
|
||||||
|
- ✅ M3U export with sanitization
|
||||||
|
- ✅ Draft system
|
||||||
|
- ✅ Single MP3 URL support
|
||||||
|
|
||||||
|
**Post-MVP:**
|
||||||
|
|
||||||
|
- Track splitting/combining
|
||||||
|
- Advanced audio manipulation
|
||||||
|
- Soundwave visualization
|
||||||
|
|
||||||
|
## ⏱️ Daily Time Allocation (3-hour sessions)
|
||||||
|
|
||||||
|
**Typical Session Structure:**
|
||||||
|
|
||||||
|
- **0:00-0:30**: Review previous work & plan today's tasks
|
||||||
|
- **0:30-2:00**: Core development (backend OR frontend focus)
|
||||||
|
- **2:00-2:30**: Integration & testing
|
||||||
|
- **2:30-3:00**: Documentation & next session planning
|
||||||
|
|
||||||
|
**Weekly Focus:**
|
||||||
|
|
||||||
|
- **Monday**: Backend heavy (APIs, database)
|
||||||
|
- **Tuesday**: Frontend (components, UI)
|
||||||
|
- **Wednesday**: Integration (connect frontend to backend)
|
||||||
|
- **Thursday**: Feature development
|
||||||
|
- **Friday**: Polish & bug fixes
|
||||||
|
- **Weekend**: Larger features or catch-up
|
||||||
|
|
||||||
|
## 🚀 Getting Started Tomorrow
|
||||||
|
|
||||||
|
**Day 1 Plan (3 hours):**
|
||||||
|
|
||||||
|
1. **0-1h**: Browse Figma Community for "music player" templates, pick one
|
||||||
|
2. **1-2h**: Set up Vue 3 + Vite project with basic routing
|
||||||
|
3. **2-3h**: Create basic layout components (replicate from chosen Figma design)
|
||||||
|
|
||||||
|
This plan respects the complexity of audio processing while giving you visible progress. You'll have a working music player within 2 weeks and full MVP in about a month at this pace.
|
||||||
|
|
||||||
|
Would you like me to elaborate on any specific phase or provide more detailed technical specifications for any part?
|
||||||
14
app/app.vue
Normal file
14
app/app.vue
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||||
|
import AppSidebar from '@/components/ui/sidebar/AppSidebar.vue';
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SidebarProvider>
|
||||||
|
<AppSidebar />
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</SidebarProvider>
|
||||||
|
</template>
|
||||||
123
app/assets/css/tailwind.css
Normal file
123
app/assets/css/tailwind.css
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
: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);
|
||||||
|
--destructive-foreground: 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.145 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.145 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.985 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.396 0.141 25.723);
|
||||||
|
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||||
|
--border: oklch(0.269 0 0);
|
||||||
|
--input: oklch(0.269 0 0);
|
||||||
|
--ring: oklch(0.439 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(0.269 0 0);
|
||||||
|
--sidebar-ring: oklch(0.439 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/components/ui/button/Button.vue
Normal file
29
app/components/ui/button/Button.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<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"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
39
app/components/ui/button/index.ts
Normal file
39
app/components/ui/button/index.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs 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 shadow-xs 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",
|
||||||
|
"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-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||||
29
app/components/ui/container/Container.vue
Normal file
29
app/components/ui/container/Container.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { ContainerVariants } from "."
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { containerVariants } from "."
|
||||||
|
|
||||||
|
|
||||||
|
interface Props extends PrimitiveProps {
|
||||||
|
border?: ContainerVariants["border"]
|
||||||
|
borderRadius?: ContainerVariants["borderRadius"]
|
||||||
|
borderPlacement?: ContainerVariants["borderPlacement"]
|
||||||
|
background?: ContainerVariants["background"]
|
||||||
|
padding?: ContainerVariants["padding"]
|
||||||
|
margin?: ContainerVariants["margin"]
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
as: "container",
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :as="as" :as-child="asChild"
|
||||||
|
:class="cn(containerVariants({ border, borderRadius, borderPlacement, background, padding, margin }), props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
59
app/components/ui/container/index.ts
Normal file
59
app/components/ui/container/index.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import type { VariantProps } from "class-variance-authority"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
|
export { default as Container } from "./Container.vue"
|
||||||
|
|
||||||
|
export const containerVariants = cva(
|
||||||
|
"border-solid",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
border: {
|
||||||
|
default: "border-border",
|
||||||
|
input: "border-input",
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
default: "rounded-md",
|
||||||
|
round: "rounded-2xl",
|
||||||
|
circle: "rounded-full",
|
||||||
|
none: "",
|
||||||
|
},
|
||||||
|
borderPlacement: {
|
||||||
|
default: "border-2",
|
||||||
|
bottom: "border-b-2",
|
||||||
|
left: "border-l-2",
|
||||||
|
right: "border-r-2",
|
||||||
|
top: "border-t-2",
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: "bg-background",
|
||||||
|
muted: "bg-muted",
|
||||||
|
primary: "bg-primary"
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
default: "p-8",
|
||||||
|
loose: "p-12",
|
||||||
|
dense: "p-3",
|
||||||
|
none: "",
|
||||||
|
},
|
||||||
|
margin: {
|
||||||
|
default: "m-12",
|
||||||
|
dense: "m-8",
|
||||||
|
none: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
border: "default",
|
||||||
|
borderRadius: "default",
|
||||||
|
borderPlacement: "default",
|
||||||
|
background: "default",
|
||||||
|
padding: "default",
|
||||||
|
margin: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ContainerVariants = VariantProps<typeof containerVariants>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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 flex 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-[3px]',
|
||||||
|
'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"
|
||||||
24
app/components/ui/outline/Outline.vue
Normal file
24
app/components/ui/outline/Outline.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { ContainerVariants } from "../container"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import Container from "@/components/ui/container/Container.vue"
|
||||||
|
|
||||||
|
|
||||||
|
interface Props extends PrimitiveProps {
|
||||||
|
side?: ContainerVariants["borderPlacement"]
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
as: "container",
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Container :as="as" :as-child="asChild" :class="cn(props.class)" margin="none" borderRadius="none"
|
||||||
|
:borderPlacement="side">
|
||||||
|
<slot />
|
||||||
|
</Container>
|
||||||
|
</template>
|
||||||
5
app/components/ui/outline/index.ts
Normal file
5
app/components/ui/outline/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { default as Outline } from "./Outline.vue"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
29
app/components/ui/separator/Separator.vue
Normal file
29
app/components/ui/separator/Separator.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SeparatorProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Separator } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<
|
||||||
|
SeparatorProps & { class?: HTMLAttributes["class"] }
|
||||||
|
>(), {
|
||||||
|
orientation: "horizontal",
|
||||||
|
decorative: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Separator
|
||||||
|
data-slot="separator-root"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
1
app/components/ui/separator/index.ts
Normal file
1
app/components/ui/separator/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Separator } from "./Separator.vue"
|
||||||
18
app/components/ui/sheet/Sheet.vue
Normal file
18
app/components/ui/sheet/Sheet.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<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
|
||||||
|
data-slot="sheet"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
15
app/components/ui/sheet/SheetClose.vue
Normal file
15
app/components/ui/sheet/SheetClose.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="sheet-close"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogClose>
|
||||||
|
</template>
|
||||||
63
app/components/ui/sheet/SheetContent.vue
Normal file
63
app/components/ui/sheet/SheetContent.vue
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { X } from "lucide-vue-next"
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import SheetOverlay from "./SheetOverlay.vue"
|
||||||
|
|
||||||
|
interface SheetContentProps extends DialogContentProps {
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<SheetContentProps>(), {
|
||||||
|
side: "right",
|
||||||
|
})
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class", "side")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<DialogContent
|
||||||
|
data-slot="sheet-content"
|
||||||
|
:class="cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
|
side === 'right'
|
||||||
|
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||||
|
side === 'left'
|
||||||
|
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||||
|
side === 'top'
|
||||||
|
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||||
|
side === 'bottom'
|
||||||
|
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||||
|
props.class)"
|
||||||
|
v-bind="{ ...forwarded, ...$attrs }"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary 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"
|
||||||
|
>
|
||||||
|
<X class="size-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
21
app/components/ui/sheet/SheetDescription.vue
Normal file
21
app/components/ui/sheet/SheetDescription.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogDescriptionProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { DialogDescription } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogDescription
|
||||||
|
data-slot="sheet-description"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogDescription>
|
||||||
|
</template>
|
||||||
16
app/components/ui/sheet/SheetFooter.vue
Normal file
16
app/components/ui/sheet/SheetFooter.vue
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<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="sheet-footer"
|
||||||
|
:class="cn('mt-auto flex flex-col gap-2 p-4', props.class)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
15
app/components/ui/sheet/SheetHeader.vue
Normal file
15
app/components/ui/sheet/SheetHeader.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<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="sheet-header"
|
||||||
|
:class="cn('flex flex-col gap-1.5 p-4', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
app/components/ui/sheet/SheetOverlay.vue
Normal file
21
app/components/ui/sheet/SheetOverlay.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="sheet-overlay"
|
||||||
|
: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)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogOverlay>
|
||||||
|
</template>
|
||||||
21
app/components/ui/sheet/SheetTitle.vue
Normal file
21
app/components/ui/sheet/SheetTitle.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTitleProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { DialogTitle } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
data-slot="sheet-title"
|
||||||
|
:class="cn('text-foreground font-semibold', props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
||||||
15
app/components/ui/sheet/SheetTrigger.vue
Normal file
15
app/components/ui/sheet/SheetTrigger.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="sheet-trigger"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTrigger>
|
||||||
|
</template>
|
||||||
8
app/components/ui/sheet/index.ts
Normal file
8
app/components/ui/sheet/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export { default as Sheet } from "./Sheet.vue"
|
||||||
|
export { default as SheetClose } from "./SheetClose.vue"
|
||||||
|
export { default as SheetContent } from "./SheetContent.vue"
|
||||||
|
export { default as SheetDescription } from "./SheetDescription.vue"
|
||||||
|
export { default as SheetFooter } from "./SheetFooter.vue"
|
||||||
|
export { default as SheetHeader } from "./SheetHeader.vue"
|
||||||
|
export { default as SheetTitle } from "./SheetTitle.vue"
|
||||||
|
export { default as SheetTrigger } from "./SheetTrigger.vue"
|
||||||
101
app/components/ui/sidebar/AppSidebar.vue
Normal file
101
app/components/ui/sidebar/AppSidebar.vue
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Calendar, ChevronsUpDown, Home, Inbox, Music2, Music4, Search, Settings } from "lucide-vue-next"
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarRail,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
import Container from "@/components/ui/container/Container.vue";
|
||||||
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
title: "Home",
|
||||||
|
url: "#",
|
||||||
|
icon: Home,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Inbox",
|
||||||
|
url: "#",
|
||||||
|
icon: Inbox,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Calendar",
|
||||||
|
url: "#",
|
||||||
|
icon: Calendar,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Search",
|
||||||
|
url: "#",
|
||||||
|
icon: Search,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
url: "#",
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const {
|
||||||
|
open,
|
||||||
|
openMobile,
|
||||||
|
} = useSidebar()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Sidebar collapsible="icon">
|
||||||
|
<SidebarHeader>
|
||||||
|
<div v-if="open">
|
||||||
|
<div class="flex p-2 gap-2 items-center">
|
||||||
|
<Container borderRadius="round" background="primary" padding="dense" margin="none">
|
||||||
|
<Music4 class="text-primary-foreground" :size="24" />
|
||||||
|
</Container>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<h4 class="text-xl font-semibold tracking-tight truncate">
|
||||||
|
My playlist
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
11 track(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto">
|
||||||
|
<ChevronsUpDown />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!open">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Container borderRadius="round" background="primary" padding="dense" margin="none">
|
||||||
|
<Music4 class="text-primary-foreground" :size="24" />
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu class="group-data-[collapsible=icon]:p-1">
|
||||||
|
<SidebarMenuItem v-for="item in items" :key="item.title">
|
||||||
|
<SidebarMenuButton asChild>
|
||||||
|
<a :href="item.url">
|
||||||
|
<div class="text-[1.5rem]">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
</div>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
|
</template>
|
||||||
96
app/components/ui/sidebar/Sidebar.vue
Normal file
96
app/components/ui/sidebar/Sidebar.vue
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SidebarProps } from "."
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||||
|
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
||||||
|
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
|
||||||
|
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
|
||||||
|
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||||
|
side: "left",
|
||||||
|
variant: "sidebar",
|
||||||
|
collapsible: "offcanvas",
|
||||||
|
})
|
||||||
|
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="collapsible === 'none'"
|
||||||
|
data-slot="sidebar"
|
||||||
|
:class="cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
|
||||||
|
<SheetContent
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
:side="side"
|
||||||
|
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||||
|
:style="{
|
||||||
|
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<SheetHeader class="sr-only">
|
||||||
|
<SheetTitle>Sidebar</SheetTitle>
|
||||||
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div class="flex h-full w-full flex-col">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="group peer text-sidebar-foreground hidden md:block"
|
||||||
|
data-slot="sidebar"
|
||||||
|
:data-state="state"
|
||||||
|
:data-collapsible="state === 'collapsed' ? collapsible : ''"
|
||||||
|
:data-variant="variant"
|
||||||
|
:data-side="side"
|
||||||
|
>
|
||||||
|
<!-- This is what handles the sidebar gap on desktop -->
|
||||||
|
<div
|
||||||
|
:class="cn(
|
||||||
|
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
||||||
|
'group-data-[collapsible=offcanvas]:w-0',
|
||||||
|
'group-data-[side=right]:rotate-180',
|
||||||
|
variant === 'floating' || variant === 'inset'
|
||||||
|
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||||
|
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
||||||
|
)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
:class="cn(
|
||||||
|
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||||
|
side === 'left'
|
||||||
|
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||||
|
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === 'floating' || variant === 'inset'
|
||||||
|
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||||
|
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
18
app/components/ui/sidebar/SidebarContent.vue
Normal file
18
app/components/ui/sidebar/SidebarContent.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<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="sidebar-content"
|
||||||
|
data-sidebar="content"
|
||||||
|
:class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
18
app/components/ui/sidebar/SidebarFooter.vue
Normal file
18
app/components/ui/sidebar/SidebarFooter.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<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="sidebar-footer"
|
||||||
|
data-sidebar="footer"
|
||||||
|
:class="cn('flex flex-col gap-2 p-2', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
18
app/components/ui/sidebar/SidebarGroup.vue
Normal file
18
app/components/ui/sidebar/SidebarGroup.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<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="sidebar-group"
|
||||||
|
data-sidebar="group"
|
||||||
|
:class="cn('relative flex w-full min-w-0 flex-col p-2', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
27
app/components/ui/sidebar/SidebarGroupAction.vue
Normal file
27
app/components/ui/sidebar/SidebarGroupAction.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { Primitive } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<PrimitiveProps & {
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="sidebar-group-action"
|
||||||
|
data-sidebar="group-action"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:class="cn(
|
||||||
|
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
'after:absolute after:-inset-2 md:after:hidden',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
18
app/components/ui/sidebar/SidebarGroupContent.vue
Normal file
18
app/components/ui/sidebar/SidebarGroupContent.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<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="sidebar-group-content"
|
||||||
|
data-sidebar="group-content"
|
||||||
|
:class="cn('w-full text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
25
app/components/ui/sidebar/SidebarGroupLabel.vue
Normal file
25
app/components/ui/sidebar/SidebarGroupLabel.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { Primitive } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<PrimitiveProps & {
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="sidebar-group-label"
|
||||||
|
data-sidebar="group-label"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:class="cn(
|
||||||
|
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||||
|
props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
18
app/components/ui/sidebar/SidebarHeader.vue
Normal file
18
app/components/ui/sidebar/SidebarHeader.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<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="sidebar-header"
|
||||||
|
data-sidebar="header"
|
||||||
|
:class="cn('flex flex-col gap-2 p-2', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
22
app/components/ui/sidebar/SidebarInput.vue
Normal file
22
app/components/ui/sidebar/SidebarInput.vue
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<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="sidebar-input"
|
||||||
|
data-sidebar="input"
|
||||||
|
:class="cn(
|
||||||
|
'bg-background h-8 w-full shadow-none',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Input>
|
||||||
|
</template>
|
||||||
21
app/components/ui/sidebar/SidebarInset.vue
Normal file
21
app/components/ui/sidebar/SidebarInset.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>
|
||||||
|
<main
|
||||||
|
data-slot="sidebar-inset"
|
||||||
|
:class="cn(
|
||||||
|
'bg-background relative flex w-full flex-1 flex-col',
|
||||||
|
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
18
app/components/ui/sidebar/SidebarMenu.vue
Normal file
18
app/components/ui/sidebar/SidebarMenu.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu"
|
||||||
|
data-sidebar="menu"
|
||||||
|
:class="cn('flex w-full min-w-0 flex-col gap-1', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
35
app/components/ui/sidebar/SidebarMenuAction.vue
Normal file
35
app/components/ui/sidebar/SidebarMenuAction.vue
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { Primitive } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<PrimitiveProps & {
|
||||||
|
showOnHover?: boolean
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>(), {
|
||||||
|
as: "button",
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="sidebar-menu-action"
|
||||||
|
data-sidebar="menu-action"
|
||||||
|
:class="cn(
|
||||||
|
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
'after:absolute after:-inset-2 md:after:hidden',
|
||||||
|
'peer-data-[size=sm]/menu-button:top-1',
|
||||||
|
'peer-data-[size=default]/menu-button:top-1.5',
|
||||||
|
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
showOnHover
|
||||||
|
&& 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
26
app/components/ui/sidebar/SidebarMenuBadge.vue
Normal file
26
app/components/ui/sidebar/SidebarMenuBadge.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<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="sidebar-menu-badge"
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
:class="cn(
|
||||||
|
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
|
||||||
|
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
|
||||||
|
'peer-data-[size=sm]/menu-button:top-1',
|
||||||
|
'peer-data-[size=default]/menu-button:top-1.5',
|
||||||
|
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
48
app/components/ui/sidebar/SidebarMenuButton.vue
Normal file
48
app/components/ui/sidebar/SidebarMenuButton.vue
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from "vue"
|
||||||
|
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue"
|
||||||
|
import { useSidebar } from "./utils"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<SidebarMenuButtonProps & {
|
||||||
|
tooltip?: string | Component
|
||||||
|
}>(), {
|
||||||
|
as: "button",
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
})
|
||||||
|
|
||||||
|
const { isMobile, state } = useSidebar()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "tooltip")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }">
|
||||||
|
<slot />
|
||||||
|
</SidebarMenuButtonChild>
|
||||||
|
|
||||||
|
<Tooltip v-else>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
|
||||||
|
<slot />
|
||||||
|
</SidebarMenuButtonChild>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
:hidden="state !== 'collapsed' || isMobile"
|
||||||
|
>
|
||||||
|
<template v-if="typeof tooltip === 'string'">
|
||||||
|
{{ tooltip }}
|
||||||
|
</template>
|
||||||
|
<component :is="tooltip" v-else />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</template>
|
||||||
36
app/components/ui/sidebar/SidebarMenuButtonChild.vue
Normal file
36
app/components/ui/sidebar/SidebarMenuButtonChild.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { SidebarMenuButtonVariants } from "."
|
||||||
|
import { Primitive } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { sidebarMenuButtonVariants } from "."
|
||||||
|
|
||||||
|
export interface SidebarMenuButtonProps extends PrimitiveProps {
|
||||||
|
variant?: SidebarMenuButtonVariants["variant"]
|
||||||
|
size?: SidebarMenuButtonVariants["size"]
|
||||||
|
isActive?: boolean
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<SidebarMenuButtonProps>(), {
|
||||||
|
as: "button",
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="sidebar-menu-button"
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
:data-size="size"
|
||||||
|
:data-active="isActive"
|
||||||
|
:class="cn(sidebarMenuButtonVariants({ variant, size }), props.class)"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
18
app/components/ui/sidebar/SidebarMenuItem.vue
Normal file
18
app/components/ui/sidebar/SidebarMenuItem.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-item"
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
:class="cn('group/menu-item relative', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
35
app/components/ui/sidebar/SidebarMenuSkeleton.vue
Normal file
35
app/components/ui/sidebar/SidebarMenuSkeleton.vue
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
showIcon?: boolean
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const width = computed(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-skeleton"
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
:class="cn('flex h-8 items-center gap-2 rounded-md px-2', props.class)"
|
||||||
|
>
|
||||||
|
<Skeleton
|
||||||
|
v-if="showIcon"
|
||||||
|
class="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Skeleton
|
||||||
|
class="h-4 max-w-(--skeleton-width) flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
:style="{ '--skeleton-width': width }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
22
app/components/ui/sidebar/SidebarMenuSub.vue
Normal file
22
app/components/ui/sidebar/SidebarMenuSub.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>
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu-sub"
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
:class="cn(
|
||||||
|
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
36
app/components/ui/sidebar/SidebarMenuSubButton.vue
Normal file
36
app/components/ui/sidebar/SidebarMenuSubButton.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { Primitive } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<PrimitiveProps & {
|
||||||
|
size?: "sm" | "md"
|
||||||
|
isActive?: boolean
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>(), {
|
||||||
|
as: "a",
|
||||||
|
size: "md",
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="sidebar-menu-sub-button"
|
||||||
|
data-sidebar="menu-sub-button"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:data-size="size"
|
||||||
|
:data-active="isActive"
|
||||||
|
:class="cn(
|
||||||
|
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
||||||
|
size === 'sm' && 'text-xs',
|
||||||
|
size === 'md' && 'text-sm',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
18
app/components/ui/sidebar/SidebarMenuSubItem.vue
Normal file
18
app/components/ui/sidebar/SidebarMenuSubItem.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-sub-item"
|
||||||
|
data-sidebar="menu-sub-item"
|
||||||
|
:class="cn('group/menu-sub-item relative', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
82
app/components/ui/sidebar/SidebarProvider.vue
Normal file
82
app/components/ui/sidebar/SidebarProvider.vue
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes, Ref } from "vue"
|
||||||
|
import { defaultDocument, useEventListener, useMediaQuery, useVModel } from "@vueuse/core"
|
||||||
|
import { TooltipProvider } from "reka-ui"
|
||||||
|
import { computed, ref } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from "./utils"
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>(), {
|
||||||
|
defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`),
|
||||||
|
open: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
"update:open": [open: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isMobile = useMediaQuery("(max-width: 768px)")
|
||||||
|
const openMobile = ref(false)
|
||||||
|
|
||||||
|
const open = useVModel(props, "open", emits, {
|
||||||
|
defaultValue: props.defaultOpen ?? false,
|
||||||
|
passive: (props.open === undefined) as false,
|
||||||
|
}) as Ref<boolean>
|
||||||
|
|
||||||
|
function setOpen(value: boolean) {
|
||||||
|
open.value = value // emits('update:open', value)
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOpenMobile(value: boolean) {
|
||||||
|
openMobile.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
function toggleSidebar() {
|
||||||
|
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEventListener("keydown", (event: KeyboardEvent) => {
|
||||||
|
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = computed(() => open.value ? "expanded" : "collapsed")
|
||||||
|
|
||||||
|
provideSidebarContext({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipProvider :delay-duration="0">
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-wrapper"
|
||||||
|
:style="{
|
||||||
|
'--sidebar-width': SIDEBAR_WIDTH,
|
||||||
|
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||||
|
}"
|
||||||
|
:class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</template>
|
||||||
33
app/components/ui/sidebar/SidebarRail.vue
Normal file
33
app/components/ui/sidebar/SidebarRail.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useSidebar } from "./utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
data-sidebar="rail"
|
||||||
|
data-slot="sidebar-rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
:tabindex="-1"
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
:class="cn(
|
||||||
|
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
|
||||||
|
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||||
|
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||||
|
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
|
||||||
|
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
|
||||||
|
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
@click="toggleSidebar"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
19
app/components/ui/sidebar/SidebarSeparator.vue
Normal file
19
app/components/ui/sidebar/SidebarSeparator.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<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>
|
||||||
|
<Separator
|
||||||
|
data-slot="sidebar-separator"
|
||||||
|
data-sidebar="separator"
|
||||||
|
:class="cn('bg-sidebar-border mx-2 w-auto', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Separator>
|
||||||
|
</template>
|
||||||
22
app/components/ui/sidebar/SidebarTrigger.vue
Normal file
22
app/components/ui/sidebar/SidebarTrigger.vue
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { PanelLeft } from "lucide-vue-next"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useSidebar } from "./utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
size?: number
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Button data-sidebar="trigger" data-slot="sidebar-trigger" variant="ghost" size="icon"
|
||||||
|
:class="cn('h-7 w-7', props.class)" @click="toggleSidebar">
|
||||||
|
<PanelLeft :class="`size-${size || 4}`" />
|
||||||
|
<span class="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
60
app/components/ui/sidebar/index.ts
Normal file
60
app/components/ui/sidebar/index.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import type { VariantProps } from "class-variance-authority"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
side?: "left" | "right"
|
||||||
|
variant?: "sidebar" | "floating" | "inset"
|
||||||
|
collapsible?: "offcanvas" | "icon" | "none"
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export { default as Sidebar } from "./Sidebar.vue"
|
||||||
|
export { default as SidebarContent } from "./SidebarContent.vue"
|
||||||
|
export { default as SidebarFooter } from "./SidebarFooter.vue"
|
||||||
|
export { default as SidebarGroup } from "./SidebarGroup.vue"
|
||||||
|
export { default as SidebarGroupAction } from "./SidebarGroupAction.vue"
|
||||||
|
export { default as SidebarGroupContent } from "./SidebarGroupContent.vue"
|
||||||
|
export { default as SidebarGroupLabel } from "./SidebarGroupLabel.vue"
|
||||||
|
export { default as SidebarHeader } from "./SidebarHeader.vue"
|
||||||
|
export { default as SidebarInput } from "./SidebarInput.vue"
|
||||||
|
export { default as SidebarInset } from "./SidebarInset.vue"
|
||||||
|
export { default as SidebarMenu } from "./SidebarMenu.vue"
|
||||||
|
export { default as SidebarMenuAction } from "./SidebarMenuAction.vue"
|
||||||
|
export { default as SidebarMenuBadge } from "./SidebarMenuBadge.vue"
|
||||||
|
export { default as SidebarMenuButton } from "./SidebarMenuButton.vue"
|
||||||
|
export { default as SidebarMenuItem } from "./SidebarMenuItem.vue"
|
||||||
|
export { default as SidebarMenuSkeleton } from "./SidebarMenuSkeleton.vue"
|
||||||
|
export { default as SidebarMenuSub } from "./SidebarMenuSub.vue"
|
||||||
|
export { default as SidebarMenuSubButton } from "./SidebarMenuSubButton.vue"
|
||||||
|
export { default as SidebarMenuSubItem } from "./SidebarMenuSubItem.vue"
|
||||||
|
export { default as SidebarProvider } from "./SidebarProvider.vue"
|
||||||
|
export { default as SidebarRail } from "./SidebarRail.vue"
|
||||||
|
export { default as SidebarSeparator } from "./SidebarSeparator.vue"
|
||||||
|
export { default as SidebarTrigger } from "./SidebarTrigger.vue"
|
||||||
|
|
||||||
|
export { useSidebar } from "./utils"
|
||||||
|
|
||||||
|
export const sidebarMenuButtonVariants = cva(
|
||||||
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 text-sm",
|
||||||
|
sm: "h-7 text-xs",
|
||||||
|
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type SidebarMenuButtonVariants = VariantProps<typeof sidebarMenuButtonVariants>
|
||||||
19
app/components/ui/sidebar/utils.ts
Normal file
19
app/components/ui/sidebar/utils.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { ComputedRef, Ref } from "vue"
|
||||||
|
import { createContext } from "reka-ui"
|
||||||
|
|
||||||
|
export const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||||
|
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
|
export const SIDEBAR_WIDTH = "24rem"
|
||||||
|
export const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||||
|
export const SIDEBAR_WIDTH_ICON = "4rem"
|
||||||
|
export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||||
|
|
||||||
|
export const [useSidebar, provideSidebarContext] = createContext<{
|
||||||
|
state: ComputedRef<"expanded" | "collapsed">
|
||||||
|
open: Ref<boolean>
|
||||||
|
setOpen: (value: boolean) => void
|
||||||
|
isMobile: Ref<boolean>
|
||||||
|
openMobile: Ref<boolean>
|
||||||
|
setOpenMobile: (value: boolean) => void
|
||||||
|
toggleSidebar: () => void
|
||||||
|
}>("Sidebar")
|
||||||
17
app/components/ui/skeleton/Skeleton.vue
Normal file
17
app/components/ui/skeleton/Skeleton.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<SkeletonProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
:class="cn('animate-pulse rounded-md bg-primary/10', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
1
app/components/ui/skeleton/index.ts
Normal file
1
app/components/ui/skeleton/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Skeleton } from "./Skeleton.vue"
|
||||||
18
app/components/ui/tooltip/Tooltip.vue
Normal file
18
app/components/ui/tooltip/Tooltip.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TooltipRootEmits, TooltipRootProps } from "reka-ui"
|
||||||
|
import { TooltipRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<TooltipRootProps>()
|
||||||
|
const emits = defineEmits<TooltipRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipRoot
|
||||||
|
data-slot="tooltip"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TooltipRoot>
|
||||||
|
</template>
|
||||||
34
app/components/ui/tooltip/TooltipContent.vue
Normal file
34
app/components/ui/tooltip/TooltipContent.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TooltipContentEmits, TooltipContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes["class"] }>(), {
|
||||||
|
sideOffset: 4,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits<TooltipContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
v-bind="{ ...forwarded, ...$attrs }"
|
||||||
|
:class="cn('bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 w-fit rounded-md px-3 py-1.5 text-xs text-balance', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<TooltipArrow class="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</template>
|
||||||
14
app/components/ui/tooltip/TooltipProvider.vue
Normal file
14
app/components/ui/tooltip/TooltipProvider.vue
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TooltipProviderProps } from "reka-ui"
|
||||||
|
import { TooltipProvider } from "reka-ui"
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<TooltipProviderProps>(), {
|
||||||
|
delayDuration: 0,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipProvider v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</TooltipProvider>
|
||||||
|
</template>
|
||||||
15
app/components/ui/tooltip/TooltipTrigger.vue
Normal file
15
app/components/ui/tooltip/TooltipTrigger.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TooltipTriggerProps } from "reka-ui"
|
||||||
|
import { TooltipTrigger } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<TooltipTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipTrigger
|
||||||
|
data-slot="tooltip-trigger"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TooltipTrigger>
|
||||||
|
</template>
|
||||||
4
app/components/ui/tooltip/index.ts
Normal file
4
app/components/ui/tooltip/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { default as Tooltip } from "./Tooltip.vue"
|
||||||
|
export { default as TooltipContent } from "./TooltipContent.vue"
|
||||||
|
export { default as TooltipProvider } from "./TooltipProvider.vue"
|
||||||
|
export { default as TooltipTrigger } from "./TooltipTrigger.vue"
|
||||||
20
app/layouts/default.vue
Normal file
20
app/layouts/default.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-full">
|
||||||
|
<header v-if="slots.header">
|
||||||
|
<slot name="header" />
|
||||||
|
</header>
|
||||||
|
<main class="h-screen">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<aside v-if="slots.sidebar" class="w-full">
|
||||||
|
<slot name="sidebar" />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useSlots } from 'vue'
|
||||||
|
const slots = useSlots()
|
||||||
|
</script>
|
||||||
7
app/lib/utils.ts
Normal file
7
app/lib/utils.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { ClassValue } from "clsx"
|
||||||
|
import { clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
41
app/pages/index.vue
Normal file
41
app/pages/index.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Container from '@/components/ui/container/Container.vue';
|
||||||
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
|
import { Outline } from '@/components/ui/outline';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-1">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<template #header>
|
||||||
|
<Outline side="bottom" padding="dense" class="w-full">
|
||||||
|
<div class="flex gap-8 w-full items-center">
|
||||||
|
<SidebarTrigger :size="5" />
|
||||||
|
<h2
|
||||||
|
class="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0">
|
||||||
|
Tracks
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</Outline>
|
||||||
|
</template>
|
||||||
|
<div class="w-full">
|
||||||
|
<Container>
|
||||||
|
Hello
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
<template #sidebar>
|
||||||
|
<Outline padding="none" class="h-full" side="left">
|
||||||
|
<Outline padding="dense" side="bottom">
|
||||||
|
<p class="leading-7 not-first:mt-6 font-semibold">
|
||||||
|
Metadata editor
|
||||||
|
</p>
|
||||||
|
</Outline>
|
||||||
|
</Outline>
|
||||||
|
</template>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
32
app/pages/test.vue
Normal file
32
app/pages/test.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Container from '@/components/ui/container/Container.vue';
|
||||||
|
import { Outline } from '@/components/ui/outline';
|
||||||
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-1">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<template #header>
|
||||||
|
<Outline side="bottom" padding="dense" class="w-full">
|
||||||
|
<div class="flex gap-8 w-full items-center">
|
||||||
|
<SidebarTrigger :size="5" />
|
||||||
|
<h2
|
||||||
|
class="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0">
|
||||||
|
Tracks
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</Outline>
|
||||||
|
</template>
|
||||||
|
<div class="w-full">
|
||||||
|
<Container>
|
||||||
|
Hello
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
components.json
Normal file
21
components.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-vue.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"typescript": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/assets/css/tailwind.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"composables": "@/composables"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
18
nuxt.config.ts
Normal file
18
nuxt.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-07-15',
|
||||||
|
devtools: { enabled: true },
|
||||||
|
modules: ['@nuxt/fonts', '@nuxt/icon', '@nuxt/image', 'shadcn-nuxt'],
|
||||||
|
shadcn: {
|
||||||
|
prefix: '',
|
||||||
|
componentDir: './components/ui'
|
||||||
|
},
|
||||||
|
css: ['~/assets/css/tailwind.css'],
|
||||||
|
vite: {
|
||||||
|
plugins: [
|
||||||
|
tailwindcss()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
34
package.json
Normal file
34
package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "hello-world",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/fonts": "0.11.4",
|
||||||
|
"@nuxt/icon": "2.1.0",
|
||||||
|
"@nuxt/image": "1.11.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
|
"@vueuse/core": "^14.0.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-vue-next": "^0.548.0",
|
||||||
|
"nuxt": "^4.2.0",
|
||||||
|
"oidc-client-ts": "^3.3.0",
|
||||||
|
"reka-ui": "^2.6.0",
|
||||||
|
"shadcn-nuxt": "2.3.2",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwindcss": "^4.1.16",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"vue": "^3.5.22",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.server.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.shared.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user