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:
2025-05-23 21:35:43 +02:00
parent 97908db3e0
commit 26e3b76b11
102 changed files with 14233 additions and 668 deletions
+170
View File
@@ -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>
-41
View File
@@ -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>
+65
View File
@@ -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>
+150
View File
@@ -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
-->
+101
View File
@@ -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>
+127
View File
@@ -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>;
+21
View 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>
<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>
+7
View File
@@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue";
export { default as CardAction } from "./CardAction.vue";
export { default as CardContent } from "./CardContent.vue";
export { default as CardDescription } from "./CardDescription.vue";
export { default as CardFooter } from "./CardFooter.vue";
export { default as CardHeader } from "./CardHeader.vue";
export { default as CardTitle } from "./CardTitle.vue";
@@ -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";