feat: add global styles and transitions for dark mode support
- Created style.css for base styles and color variables for light and dark themes. - Added tailwind.css for Tailwind CSS integration and custom theme variables. - Introduced transitions.css to manage global transitions and reduce motion preferences. - Updated tsconfig.app.json to include path aliases for easier imports. - Modified tsconfig.json to set baseUrl and paths for module resolution. - Added typed-router.d.ts for auto-generated route types. - Enhanced vite.config.ts with Vue Router plugin, Tailwind CSS integration, and server proxy settings.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTransactionsStore } from "@/stores/transactions";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
// Access the transactions store
|
||||
const transactionsStore = useTransactionsStore();
|
||||
const { totalIncome, totalExpenses, balance, loading, transactions } = transactionsStore;
|
||||
|
||||
// Calculate balance history from transactions
|
||||
const balanceHistory = computed(() => {
|
||||
const now = new Date();
|
||||
const months = [];
|
||||
|
||||
// Get last 6 months
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const month = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const monthName = month.toLocaleDateString('en-US', { month: 'short' });
|
||||
months.push({
|
||||
month: monthName,
|
||||
year: month.getFullYear(),
|
||||
monthIndex: month.getMonth(),
|
||||
balance: 0,
|
||||
income: 0,
|
||||
expenses: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate balance for each month
|
||||
transactionsStore.transactions.forEach(transaction => {
|
||||
const txDate = new Date(transaction.date);
|
||||
const monthIndex = months.findIndex(m =>
|
||||
m.monthIndex === txDate.getMonth() &&
|
||||
m.year === txDate.getFullYear()
|
||||
);
|
||||
|
||||
if (monthIndex !== -1) {
|
||||
if (transaction.type === 'income') {
|
||||
months[monthIndex].income += transaction.amount;
|
||||
} else {
|
||||
months[monthIndex].expenses += transaction.amount;
|
||||
}
|
||||
months[monthIndex].balance = months[monthIndex].income - months[monthIndex].expenses;
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure there's some data for visualization
|
||||
const maxBalance = Math.max(...months.map(m => Math.abs(m.balance)), 1000);
|
||||
return {
|
||||
months,
|
||||
maxBalance
|
||||
};
|
||||
});
|
||||
|
||||
// Format balance with colors - neutral by default, red if negative
|
||||
const balanceClass = computed(() =>
|
||||
balance.value < 0 ? "text-red-500" : "text-gray-700",
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
transactionsStore.fetchTransactions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="w-full shadow-sm hover:shadow transition-shadow duration-300">
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-xl font-bold flex items-center">
|
||||
<span class="mr-2">Balance Summary</span>
|
||||
<span v-if="loading" class="inline-block w-3 h-3 rounded-full bg-primary animate-pulse"></span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-2 gap-4 pb-2">
|
||||
<!-- Income -->
|
||||
<div class="rounded-lg bg-green-50 p-3 transition-all">
|
||||
<div class="text-sm font-medium text-gray-500">Income</div>
|
||||
<div class="text-green-600 font-semibold text-lg mt-1">
|
||||
{{ formatCurrency(totalIncome) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expenses -->
|
||||
<div class="rounded-lg bg-red-50 p-3 transition-all">
|
||||
<div class="text-sm font-medium text-gray-500">Expenses</div>
|
||||
<div class="text-red-600 font-semibold text-lg mt-1">
|
||||
{{ formatCurrency(totalExpenses) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-sm font-bold">Current Balance</div>
|
||||
<div :class="balanceClass" class="text-xl font-bold flex items-center">
|
||||
{{ balance < 0 ? '-' : '' }}{{ formatCurrency(Math.abs(balance)) }}
|
||||
<span class="ml-1 text-xs" :class="balance < 0 ? 'text-red-400' : 'text-gray-500'">
|
||||
({{ balance >= 0 ? '+' : '' }}{{ Math.round(balance / Math.max(totalIncome, 1) * 100) }}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Balance History Chart -->
|
||||
<div class="mt-6">
|
||||
<h3 class="text-sm font-medium mb-2">Balance History</h3>
|
||||
|
||||
<!-- No data message -->
|
||||
<div v-if="transactions.length === 0" class="text-center py-6 bg-gray-50 rounded-md border border-dashed border-gray-200">
|
||||
<p class="text-sm text-gray-500">No transaction data available</p>
|
||||
<router-link to="/add" class="text-xs text-primary hover:underline mt-1 inline-block">
|
||||
Add your first transaction
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
<div v-else class="h-32 flex items-end justify-between">
|
||||
<div
|
||||
v-for="(item, index) in balanceHistory.months"
|
||||
:key="index"
|
||||
class="flex flex-col items-center w-full group relative cursor-pointer">
|
||||
<div class="relative w-full h-24 flex flex-col justify-end items-center">
|
||||
<!-- Bar -->
|
||||
<div
|
||||
class="w-8 rounded-t transition-all duration-500 ease-in-out hover:opacity-80"
|
||||
:class="item.balance >= 0 ? 'bg-green-500/80' : 'bg-red-500/80'"
|
||||
:style="{
|
||||
height: `${Math.abs(item.balance) / (balanceHistory.maxBalance / 100)}%`,
|
||||
minHeight: '4px'
|
||||
}">
|
||||
</div>
|
||||
|
||||
<!-- Tooltip with month data -->
|
||||
<div class="opacity-0 group-hover:opacity-100 absolute bottom-full mb-2 bg-gray-800 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10 shadow-md transition-opacity">
|
||||
<div class="font-medium mb-1">{{ item.month }} {{ item.year }}</div>
|
||||
<div>Income: {{ formatCurrency(item.income) }}</div>
|
||||
<div>Expenses: {{ formatCurrency(item.expenses) }}</div>
|
||||
<div class="mt-1 pt-1 border-t border-gray-700">
|
||||
<span :class="item.balance >= 0 ? 'text-green-400' : 'text-red-400'">
|
||||
Balance: {{ formatCurrency(item.balance) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs mt-1 text-gray-600">{{ item.month }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<router-link to="/reports" class="w-full">
|
||||
<Button variant="outline" size="sm" class="w-full hover:bg-primary/10 transition-colors">
|
||||
View Detailed Report
|
||||
</Button>
|
||||
</router-link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import { LogOut } from "lucide-vue-next";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { computed } from "vue";
|
||||
|
||||
// Auth store
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Get user data from auth store
|
||||
const userData = computed(() => {
|
||||
if (!authStore.user) {
|
||||
return {
|
||||
name: "Guest User",
|
||||
role: "Guest",
|
||||
avatar: "",
|
||||
initials: "GU",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: authStore.user.username || "User",
|
||||
role: authStore.user.role || "User",
|
||||
avatar: "",
|
||||
initials: authStore.user.username
|
||||
? authStore.user.username.substring(0, 2).toUpperCase()
|
||||
: "U",
|
||||
};
|
||||
});
|
||||
|
||||
// Initialize router
|
||||
const router = useRouter();
|
||||
const logout = () => {
|
||||
authStore.logout();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between space-x-3 p-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<Avatar>
|
||||
<AvatarImage :src="userData.avatar" alt="User avatar" />
|
||||
<AvatarFallback>{{ userData.initials }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ userData.name }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ userData.role }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="logout"
|
||||
class="h-8 w-8 p-0"
|
||||
aria-label="Logout">
|
||||
<LogOut class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Navigation Component
|
||||
* -------------------
|
||||
* This component renders the app's navigation menu with links.
|
||||
* It's similar to a Navigation component in React that might use React Router.
|
||||
*/
|
||||
|
||||
import { defineComponent } from "vue";
|
||||
import {
|
||||
Home,
|
||||
LogIn,
|
||||
Info,
|
||||
PieChart,
|
||||
Plus,
|
||||
CreditCard,
|
||||
Settings,
|
||||
Shield,
|
||||
Tag,
|
||||
} from "lucide-vue-next"; // Icon library (equivalent to something like react-icons)
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
// defineComponent is optional in <script setup> but provides better TypeScript support and naming
|
||||
// In React, this would be like: function Navigation() { ... }
|
||||
defineComponent({
|
||||
name: "Navigation", // Component name for debugging
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
/**
|
||||
* Note: With <script setup>, we don't need to explicitly export the component
|
||||
* All top-level variables and functions are automatically available in the template
|
||||
* There's no explicit "return" of JSX like in React functional components
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
Vue Template Section
|
||||
-------------------
|
||||
This is similar to the JSX returned by a React component
|
||||
Key differences:
|
||||
- Vue uses <router-link> instead of React Router's <Link>
|
||||
- Vue directives use v- prefix (v-if, v-for, etc.) instead of React's { } expressions
|
||||
- Vue uses 'class' attribute directly instead of React's 'className'
|
||||
- Vue's binding syntax is :prop="value" instead of React's prop={value}
|
||||
-->
|
||||
<div class="py-2">
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<router-link
|
||||
to="/"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
|
||||
active-class="bg-secondary">
|
||||
<Home class="mr-3 h-5 w-5" />
|
||||
Dashboard
|
||||
</router-link>
|
||||
</li>
|
||||
|
||||
<!-- Auth-required links -->
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<li>
|
||||
<router-link
|
||||
to="/transactions"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
|
||||
active-class="bg-secondary">
|
||||
<CreditCard class="mr-3 h-5 w-5" />
|
||||
Transactions
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
to="/add"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
|
||||
active-class="bg-secondary">
|
||||
<Plus class="mr-3 h-5 w-5" />
|
||||
Add Transaction
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
to="/reports"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
|
||||
active-class="bg-secondary">
|
||||
<PieChart class="mr-3 h-5 w-5" />
|
||||
Reports
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
to="/categories"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
|
||||
active-class="bg-secondary">
|
||||
<Tag class="mr-3 h-5 w-5" />
|
||||
Categories
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
to="/settings"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
|
||||
active-class="bg-secondary">
|
||||
<Settings class="mr-3 h-5 w-5" />
|
||||
Settings
|
||||
</router-link>
|
||||
</li>
|
||||
|
||||
<!-- Admin only link -->
|
||||
<li v-if="authStore.user?.role === 'admin'">
|
||||
<router-link
|
||||
to="/admin/categories"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
|
||||
active-class="bg-secondary">
|
||||
<Shield class="mr-3 h-5 w-5" />
|
||||
Manage Categories
|
||||
</router-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<!-- Guest links -->
|
||||
<li v-if="!authStore.isAuthenticated">
|
||||
<router-link
|
||||
to="/login"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors hover:bg-secondary"
|
||||
active-class="bg-secondary">
|
||||
<LogIn class="mr-3 h-5 w-5" />
|
||||
Login
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
to="/about"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
|
||||
active-class="bg-secondary">
|
||||
<Info class="mr-3 h-5 w-5" />
|
||||
About
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
Notes for React developers:
|
||||
1. In Vue, you don't need to import React or use React.Fragment
|
||||
2. Vue templates can have multiple root elements without needing a wrapper
|
||||
3. Vue's router-link handles active states out of the box (no need for useLocation hook)
|
||||
4. Vue components have built-in optimization - no need for memo or useMemo in most cases
|
||||
-->
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import Navigation from "./Navigation.vue";
|
||||
import LoggedInUser from "./LoggedInUser.vue";
|
||||
import { X, Menu } from "lucide-vue-next";
|
||||
import ThemeToggle from "./ThemeToggle.vue";
|
||||
|
||||
interface Props {
|
||||
header: string;
|
||||
}
|
||||
defineProps<Props>();
|
||||
|
||||
const isSidebarOpen = ref(false);
|
||||
const isDesktopView = ref(window.innerWidth >= 768);
|
||||
const handleResize = () => {
|
||||
isDesktopView.value = window.innerWidth >= 768;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("resize", handleResize);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
const sidebarVisibilityClass = computed(() => {
|
||||
if (isDesktopView.value) {
|
||||
return "translate-x-0";
|
||||
}
|
||||
return isSidebarOpen.value ? "translate-x-0" : "-translate-x-full";
|
||||
});
|
||||
|
||||
const toggleSidebar = () => {
|
||||
isSidebarOpen.value = !isSidebarOpen.value;
|
||||
};
|
||||
|
||||
const closeSidebar = () => {
|
||||
isSidebarOpen.value = false;
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen md:flex">
|
||||
<aside
|
||||
:class="[
|
||||
'fixed inset-y-0 left-0 z-40 w-64 shadow-lg bg-primary-foreground',
|
||||
sidebarVisibilityClass,
|
||||
'md:static md:transform-none md:flex-shrink-0 md:shadow-none',
|
||||
]"
|
||||
aria-label="Sidebar navigation">
|
||||
<div class="h-full flex flex-col md:border-r">
|
||||
<div
|
||||
class="px-4 py-6 sm:px-6 lg:px-8 min-h-[81px] flex items-center border-b gap-4">
|
||||
<h2 class="text-xl font-semibold">Budget Manager</h2>
|
||||
<button
|
||||
@click="closeSidebar"
|
||||
class="p-2 rounded-md md:hidden focus:outline-none focus:ring-2"
|
||||
aria-label="Close sidebar">
|
||||
<X class="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-y-auto" aria-label="Main navigation">
|
||||
<Navigation />
|
||||
</nav>
|
||||
<div class="border-t p-4">
|
||||
<LoggedInUser />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="flex flex-col w-full h-screen md:flex-grow">
|
||||
<header class="shadow-sm flex-shrink-0" role="banner">
|
||||
<div class="px-4 py-6 sm:px-6 lg:px-8 flex items-center">
|
||||
<button
|
||||
@click="toggleSidebar"
|
||||
class="md:hidden mr-4 p-2 rounded-md focus:outline-none focus:ring-2"
|
||||
aria-label="Toggle sidebar"
|
||||
:aria-expanded="isSidebarOpen">
|
||||
<Menu class="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
<h1 class="text-2xl font-semibold">{{ header }}</h1>
|
||||
<div class="flex-grow"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 p-4 sm:p-6 lg:p-8 overflow-y-auto" role="main">
|
||||
<slot></slot>
|
||||
</main>
|
||||
|
||||
<footer class="border-t" role="contentinfo">
|
||||
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
||||
<p class="text-center text-sm">
|
||||
© {{ currentYear }} Budget Manager PWA. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useColorMode } from "@vueuse/core";
|
||||
import { Moon, Sun, Monitor } from "lucide-vue-next";
|
||||
|
||||
// Pass { disableTransition: false } to enable transitions
|
||||
const mode = useColorMode();
|
||||
const isActive = ref(false);
|
||||
|
||||
// Animation effect when theme changes
|
||||
const animate = () => {
|
||||
isActive.value = true;
|
||||
setTimeout(() => {
|
||||
isActive.value = false;
|
||||
}, 700);
|
||||
};
|
||||
|
||||
// Watch for theme changes to trigger animation
|
||||
watch(mode, () => {
|
||||
animate();
|
||||
// Save to localStorage for Settings page
|
||||
localStorage.setItem("darkMode", mode.value === "dark" ? "true" : "false");
|
||||
});
|
||||
|
||||
// Get icon based on current theme
|
||||
const currentIcon = computed(() => {
|
||||
if (mode.value === "dark") return Moon;
|
||||
if (mode.value === "light") return Sun;
|
||||
return Monitor;
|
||||
});
|
||||
|
||||
// Get text label based on current theme
|
||||
const currentModeText = computed(() => {
|
||||
if (mode.value === "dark") return "Dark";
|
||||
if (mode.value === "light") return "Light";
|
||||
return "System";
|
||||
});
|
||||
|
||||
// Initialize on mount - sync with Settings page
|
||||
onMounted(() => {
|
||||
// Check if a theme preference is stored
|
||||
const storedDarkMode = localStorage.getItem("darkMode");
|
||||
if (storedDarkMode === "true") {
|
||||
mode.value = "dark";
|
||||
} else if (storedDarkMode === "false") {
|
||||
mode.value = "light";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-9 w-9 px-0 relative"
|
||||
:class="{ 'animate-pulse': isActive }">
|
||||
<Moon
|
||||
v-if="mode === 'dark'"
|
||||
class="h-[1.2rem] w-[1.2rem] transition-all" />
|
||||
<Sun
|
||||
v-else-if="mode === 'light'"
|
||||
class="h-[1.2rem] w-[1.2rem] transition-all" />
|
||||
<Monitor v-else class="h-[1.2rem] w-[1.2rem] transition-all" />
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
|
||||
<!-- Theme indicator dot -->
|
||||
<span
|
||||
class="absolute bottom-1 right-1 w-1.5 h-1.5 rounded-full bg-primary"></span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="mt-1 w-40">
|
||||
<DropdownMenuItem @click="mode = 'light'" class="cursor-pointer">
|
||||
<Sun class="mr-2 h-4 w-4" /> Light
|
||||
<span v-if="mode === 'light'" class="ml-auto text-xs opacity-70"
|
||||
>Active</span
|
||||
>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="mode = 'dark'" class="cursor-pointer">
|
||||
<Moon class="mr-2 h-4 w-4" /> Dark
|
||||
<span v-if="mode === 'dark'" class="ml-auto text-xs opacity-70"
|
||||
>Active</span
|
||||
>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="mode = 'auto'" class="cursor-pointer">
|
||||
<Monitor class="mr-2 h-4 w-4" /> System
|
||||
<span v-if="mode === 'auto'" class="ml-auto text-xs opacity-70"
|
||||
>Active</span
|
||||
>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<!-- Visual feedback animation when theme changes -->
|
||||
<div
|
||||
v-if="isActive"
|
||||
class="fixed inset-0 z-50 bg-background opacity-10 pointer-events-none"
|
||||
:class="{ 'animate-flash': isActive }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes flash {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
10% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-flash {
|
||||
animation: flash 700ms ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<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>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { AvatarFallback, type AvatarFallbackProps } 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>
|
||||
@@ -0,0 +1,15 @@
|
||||
<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>
|
||||
@@ -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";
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { Primitive, type PrimitiveProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type ButtonVariants, 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>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cva, type VariantProps } 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",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
@@ -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>
|
||||
<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>
|
||||
@@ -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>
|
||||
<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>
|
||||
@@ -0,0 +1,14 @@
|
||||
<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>
|
||||
@@ -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>
|
||||
<p
|
||||
data-slot="card-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
@@ -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="card-footer"
|
||||
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
<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>
|
||||
@@ -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>
|
||||
<h3
|
||||
data-slot="card-title"
|
||||
:class="cn('leading-none font-semibold', props.class)">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
@@ -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";
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenuRoot,
|
||||
type DropdownMenuRootEmits,
|
||||
type DropdownMenuRootProps,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
|
||||
const props = defineProps<DropdownMenuRootProps>();
|
||||
const emits = defineEmits<DropdownMenuRootEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot data-slot="dropdown-menu" v-bind="forwarded">
|
||||
<slot />
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Check } from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
type DropdownMenuCheckboxItemEmits,
|
||||
type DropdownMenuCheckboxItemProps,
|
||||
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>
|
||||
<Check class="size-4" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
type DropdownMenuContentEmits,
|
||||
type DropdownMenuContentProps,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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="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>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownMenuGroup, type DropdownMenuGroupProps } from "reka-ui";
|
||||
|
||||
const props = defineProps<DropdownMenuGroupProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuGroup data-slot="dropdown-menu-group" v-bind="props">
|
||||
<slot />
|
||||
</DropdownMenuGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
type DropdownMenuItemProps,
|
||||
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(
|
||||
`focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
|
||||
props.class,
|
||||
)
|
||||
">
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import {
|
||||
DropdownMenuLabel,
|
||||
type DropdownMenuLabelProps,
|
||||
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>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenuRadioGroup,
|
||||
type DropdownMenuRadioGroupEmits,
|
||||
type DropdownMenuRadioGroupProps,
|
||||
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>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Circle } from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
type DropdownMenuRadioItemEmits,
|
||||
type DropdownMenuRadioItemProps,
|
||||
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>
|
||||
<Circle class="size-2 fill-current" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuRadioItem>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import {
|
||||
DropdownMenuSeparator,
|
||||
type DropdownMenuSeparatorProps,
|
||||
} 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>
|
||||
@@ -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>
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
:class="
|
||||
cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)
|
||||
">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenuSub,
|
||||
type DropdownMenuSubEmits,
|
||||
type DropdownMenuSubProps,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
|
||||
const props = defineProps<DropdownMenuSubProps>();
|
||||
const emits = defineEmits<DropdownMenuSubEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSub data-slot="dropdown-menu-sub" v-bind="forwarded">
|
||||
<slot />
|
||||
</DropdownMenuSub>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import {
|
||||
DropdownMenuSubContent,
|
||||
type DropdownMenuSubContentEmits,
|
||||
type DropdownMenuSubContentProps,
|
||||
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] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
props.class,
|
||||
)
|
||||
">
|
||||
<slot />
|
||||
</DropdownMenuSubContent>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ChevronRight } from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenuSubTrigger,
|
||||
type DropdownMenuSubTriggerProps,
|
||||
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"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
props.class,
|
||||
)
|
||||
">
|
||||
<slot />
|
||||
<ChevronRight class="ml-auto size-4" />
|
||||
</DropdownMenuSubTrigger>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenuTrigger,
|
||||
type DropdownMenuTriggerProps,
|
||||
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>
|
||||
@@ -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";
|
||||
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Input } from "./Input.vue";
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Label, type LabelProps } 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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Label } from "./Label.vue";
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { Loader2 } from "lucide-vue-next";
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Size of the loader
|
||||
* @default "md"
|
||||
*/
|
||||
size?: "sm" | "md" | "lg";
|
||||
/**
|
||||
* Message to display below the loader
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* Whether the loader is centered
|
||||
* @default true
|
||||
*/
|
||||
centered?: boolean;
|
||||
/**
|
||||
* Whether the loader is fullscreen
|
||||
* @default false
|
||||
*/
|
||||
fullScreen?: boolean;
|
||||
}
|
||||
|
||||
// Default props
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: "md",
|
||||
centered: true,
|
||||
message: "",
|
||||
fullScreen: false,
|
||||
});
|
||||
|
||||
// Calculate size class based on props
|
||||
const sizeClass = computed(() => {
|
||||
switch (props.size) {
|
||||
case "sm":
|
||||
return "h-4 w-4";
|
||||
case "lg":
|
||||
return "h-10 w-10";
|
||||
case "md":
|
||||
default:
|
||||
return "h-6 w-6";
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate container class based on props
|
||||
const containerClass = computed(() => {
|
||||
const classes = [];
|
||||
|
||||
if (props.centered) {
|
||||
classes.push("flex flex-col items-center justify-center");
|
||||
}
|
||||
|
||||
if (props.fullScreen) {
|
||||
classes.push("fixed inset-0 z-50 bg-background/80 backdrop-blur-sm");
|
||||
} else {
|
||||
classes.push("py-6");
|
||||
}
|
||||
|
||||
return classes.join(" ");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="containerClass">
|
||||
<Loader2 :class="[sizeClass, 'animate-spin text-primary']" />
|
||||
<p v-if="message" class="mt-2 text-sm text-muted-foreground">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,4 @@
|
||||
import Loading from "./Loading.vue";
|
||||
|
||||
export { Loading };
|
||||
export default Loading;
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Separator, type SeparatorProps } 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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Separator } from "./Separator.vue";
|
||||
Reference in New Issue
Block a user