Set up the basic structure and core functionality for the application

Initialize project with UI components, core libraries, and basic app structure.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 556aa286-edd2-4cea-8583-f4fc3cfd119b
This commit is contained in:
ghaddaditw
2025-05-30 17:08:53 +00:00
parent b47089ae5d
commit cced901e9a
100 changed files with 20488 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.DS_Store
server/public
vite.config.ts.*
*.tar.gz
+32
View File
@@ -0,0 +1,32 @@
modules = ["nodejs-20", "web", "postgresql-16"]
run = "npm run dev"
hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"]
[nix]
channel = "stable-24_05"
[deployment]
deploymentTarget = "autoscale"
build = ["npm", "run", "build"]
run = ["npm", "run", "start"]
[workflows]
runButton = "Project"
[[workflows.workflow]]
name = "Project"
mode = "parallel"
author = "agent"
[[workflows.workflow.tasks]]
task = "workflow.run"
args = "Start application"
[[workflows.workflow]]
name = "Start application"
author = "agent"
[[workflows.workflow.tasks]]
task = "shell.exec"
args = "npm run dev"
waitForPort = 5000
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<!-- This is a replit script which adds a banner on the top of the page when opened in development mode outside the replit environment -->
<script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
import { Switch, Route } from "wouter";
import { queryClient } from "./lib/queryClient";
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AuthProvider } from "@/context/AuthContext";
import { ThemeProvider } from "@/context/ThemeContext";
import LandingPage from "@/pages/LandingPage";
import DashboardPage from "@/pages/DashboardPage";
import LoginPage from "@/pages/LoginPage";
import OnboardingPage from "@/pages/OnboardingPage";
import NotFound from "@/pages/not-found";
function Router() {
return (
<Switch>
<Route path="/" component={LandingPage} />
<Route path="/login" component={LoginPage} />
<Route path="/onboarding" component={OnboardingPage} />
<Route path="/dashboard" component={DashboardPage} />
<Route component={NotFound} />
</Switch>
);
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>
<TooltipProvider>
<Toaster />
<Router />
</TooltipProvider>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
);
}
export default App;
+139
View File
@@ -0,0 +1,139 @@
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useVoice } from "@/hooks/useVoice";
import { aiService } from "@/services/aiService";
import { Brain, Volume2, RefreshCw } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
export default function DailyJoke() {
const { speak } = useVoice();
const {
data: jokeData,
isLoading,
error,
refetch
} = useQuery({
queryKey: ["/api/ai/daily-joke"],
staleTime: 30 * 60 * 1000, // Cache for 30 minutes
retry: 2,
});
const handlePlayJoke = () => {
if (jokeData?.joke) {
speak(jokeData.joke);
}
};
const handleRefreshJoke = () => {
refetch();
};
if (isLoading) {
return (
<Card className="bg-white dark:bg-slate-800 shadow-lg border border-slate-200 dark:border-slate-700">
<CardContent className="p-6">
<div className="flex items-start space-x-4">
<div className="w-10 h-10 bg-gradient-to-br from-secondary-500 to-primary-500 rounded-xl flex items-center justify-center flex-shrink-0">
<Brain className="w-6 h-6 text-white" />
</div>
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-32" />
</div>
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-800 shadow-lg border border-red-200 dark:border-red-800">
<CardContent className="p-6">
<div className="flex items-start space-x-4">
<div className="w-10 h-10 bg-red-100 dark:bg-red-900/50 rounded-xl flex items-center justify-center flex-shrink-0">
<Brain className="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-slate-900 dark:text-white mb-2">AI Joke Unavailable</h3>
<p className="text-sm text-red-600 dark:text-red-400 mb-3">
Failed to load today's AI joke. Please try again.
</p>
<Button size="sm" variant="outline" onClick={handleRefreshJoke}>
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-800 shadow-lg border border-slate-200 dark:border-slate-700">
<CardContent className="p-6">
<div className="flex items-start space-x-4">
<div className="w-10 h-10 bg-gradient-to-br from-secondary-500 to-primary-500 rounded-xl flex items-center justify-center flex-shrink-0">
<Brain className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-slate-900 dark:text-white">Daily AI Joke</h3>
<div className="flex items-center space-x-2">
<Button
size="sm"
variant="ghost"
onClick={handlePlayJoke}
className="h-8 w-8 p-0"
title="Play joke aloud"
>
<Volume2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleRefreshJoke}
className="h-8 w-8 p-0"
title="Get new joke"
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
<p className="text-slate-600 dark:text-slate-400 italic mb-3">
{jokeData?.joke || "Loading today's AI-generated joke..."}
</p>
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-500">
<div className="flex items-center space-x-2">
<Badge variant="secondary" className="text-xs">
{jokeData?.cached ? "Cached" : "Fresh"}
</Badge>
<span>Powered by Local AI</span>
</div>
{jokeData?.timestamp && (
<span>
{new Date(jokeData.timestamp).toLocaleDateString()}
</span>
)}
</div>
{jokeData?.processingTime && (
<div className="mt-2 text-xs text-muted-foreground">
Generated in {jokeData.processingTime}ms
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,319 @@
import { useQuery } from "@tanstack/react-query";
import { financialService } from "@/services/financialService";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
DollarSign,
TrendingUp,
TrendingDown,
Wallet,
ArrowUpRight,
ArrowDownRight,
Mic,
Calendar
} from "lucide-react";
import { cn } from "@/lib/utils";
import { formatDistanceToNow } from "date-fns";
interface FinancialOverviewProps {
period?: "week" | "month" | "year";
limit?: number;
showHeader?: boolean;
className?: string;
}
export default function FinancialOverview({
period = "month",
limit = 5,
showHeader = true,
className
}: FinancialOverviewProps) {
const {
data: summaryData,
isLoading: summaryLoading
} = useQuery({
queryKey: ["/api/financial/summary", { period }],
staleTime: 60 * 1000,
});
const {
data: recordsData,
isLoading: recordsLoading
} = useQuery({
queryKey: ["/api/financial/records"],
staleTime: 30 * 1000,
});
const isLoading = summaryLoading || recordsLoading;
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
}).format(amount);
};
const getChangeColor = (value: number) => {
if (value > 0) return "text-green-600 dark:text-green-400";
if (value < 0) return "text-red-600 dark:text-red-400";
return "text-muted-foreground";
};
const getTransactionIcon = (type: string, amount: number) => {
if (type === "income") {
return <ArrowUpRight className="w-4 h-4 text-green-600" />;
}
return <ArrowDownRight className="w-4 h-4 text-red-600" />;
};
const getCategoryColor = (category: string, type: string) => {
if (type === "income") {
return "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400";
}
const categoryColors: Record<string, string> = {
"food": "bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-400",
"transport": "bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400",
"entertainment": "bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400",
"utilities": "bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400",
"healthcare": "bg-pink-100 text-pink-800 dark:bg-pink-900/20 dark:text-pink-400",
"shopping": "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/20 dark:text-indigo-400",
};
return categoryColors[category.toLowerCase()] || "bg-slate-100 text-slate-800 dark:bg-slate-900/20 dark:text-slate-400";
};
if (isLoading) {
return (
<div className={cn("space-y-6", className)}>
{/* Summary Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-8 w-24" />
</div>
<Skeleton className="w-12 h-12 rounded-xl" />
</div>
<div className="mt-4">
<Skeleton className="h-3 w-16" />
</div>
</CardContent>
</Card>
))}
</div>
{/* Recent Transactions Skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center space-x-3 p-3">
<Skeleton className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-6 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
const summary = summaryData?.summary || { income: 0, expenses: 0, net: 0 };
const records = recordsData?.records || [];
const recentRecords = records.slice(0, limit);
return (
<div className={cn("space-y-6", className)}>
{/* Financial Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Income Card */}
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Income</p>
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
{formatCurrency(summary.income)}
</p>
</div>
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/50 rounded-xl flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
</div>
<div className="mt-4 flex items-center text-sm">
<Badge variant="secondary" className="text-xs">
This {period}
</Badge>
</div>
</CardContent>
</Card>
{/* Expenses Card */}
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Expenses</p>
<p className="text-2xl font-bold text-red-600 dark:text-red-400">
{formatCurrency(summary.expenses)}
</p>
</div>
<div className="w-12 h-12 bg-red-100 dark:bg-red-900/50 rounded-xl flex items-center justify-center">
<TrendingDown className="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
</div>
<div className="mt-4 flex items-center text-sm">
<Badge variant="secondary" className="text-xs">
This {period}
</Badge>
</div>
</CardContent>
</Card>
{/* Net Balance Card */}
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Net Balance</p>
<p className={cn(
"text-2xl font-bold",
getChangeColor(summary.net)
)}>
{formatCurrency(summary.net)}
</p>
</div>
<div className={cn(
"w-12 h-12 rounded-xl flex items-center justify-center",
summary.net >= 0
? "bg-primary-100 dark:bg-primary-900/50"
: "bg-red-100 dark:bg-red-900/50"
)}>
<Wallet className={cn(
"w-6 h-6",
summary.net >= 0
? "text-primary-600 dark:text-primary-400"
: "text-red-600 dark:text-red-400"
)} />
</div>
</div>
<div className="mt-4 flex items-center text-sm">
{summary.net >= 0 ? (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400 text-xs">
Positive
</Badge>
) : (
<Badge className="bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400 text-xs">
Deficit
</Badge>
)}
</div>
</CardContent>
</Card>
</div>
{/* Recent Transactions */}
<Card>
{showHeader && (
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center space-x-2">
<DollarSign className="w-5 h-5" />
<span>Recent Transactions</span>
</CardTitle>
<Button size="sm" variant="outline">
View All
</Button>
</div>
</CardHeader>
)}
<CardContent>
{recentRecords.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<DollarSign className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No financial records found</p>
<p className="text-sm">Add transactions using voice commands or manually</p>
</div>
) : (
<div className="space-y-3">
{recentRecords.map((record) => (
<div
key={record.id}
className="flex items-center justify-between p-3 rounded-lg hover:bg-muted/50 transition-colors"
>
<div className="flex items-center space-x-3">
<div className={cn(
"w-10 h-10 rounded-xl flex items-center justify-center",
record.type === "income"
? "bg-green-100 dark:bg-green-900/50"
: "bg-red-100 dark:bg-red-900/50"
)}>
{getTransactionIcon(record.type, parseFloat(record.amount))}
</div>
<div>
<div className="flex items-center space-x-2">
<p className="font-medium text-sm">
{record.description || record.category}
</p>
{record.createdViaVoice && (
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
<Mic className="w-3 h-3" />
<span>Voice</span>
</div>
)}
</div>
<div className="flex items-center space-x-2 mt-1">
<Badge
variant="outline"
className={cn("text-xs", getCategoryColor(record.category, record.type))}
>
{record.category}
</Badge>
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
<Calendar className="w-3 h-3" />
<span>
{formatDistanceToNow(new Date(record.date), { addSuffix: true })}
</span>
</div>
</div>
</div>
</div>
<div className="text-right">
<p className={cn(
"font-semibold text-sm",
record.type === "income"
? "text-green-600 dark:text-green-400"
: "text-red-600 dark:text-red-400"
)}>
{record.type === "income" ? "+" : "-"}{formatCurrency(parseFloat(record.amount))}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { Link, useLocation } from "wouter";
import { useAuth } from "@/hooks/useAuth";
import { useTheme } from "@/context/ThemeContext";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Moon,
Sun,
Mic,
User,
Settings,
LogOut,
Activity
} from "lucide-react";
export default function Header() {
const { user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const [, setLocation] = useLocation();
const handleLogout = async () => {
await logout();
setLocation("/");
};
const getUserInitials = (user: any) => {
if (user.firstName && user.lastName) {
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase();
}
return user.username?.[0]?.toUpperCase() || "U";
};
return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 max-w-screen-2xl items-center">
{/* Logo and Brand */}
<div className="mr-4 flex">
<Link href={user ? "/dashboard" : "/"} className="mr-6 flex items-center space-x-2">
<div className="w-8 h-8 bg-gradient-to-br from-primary to-secondary rounded-lg flex items-center justify-center">
<Mic className="w-4 h-4 text-white" />
</div>
<span className="hidden font-bold sm:inline-block bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
TaskFin
</span>
</Link>
</div>
{/* Navigation */}
{user && (
<nav className="flex items-center space-x-6 text-sm font-medium">
<Link
href="/dashboard"
className="transition-colors hover:text-foreground/80 text-foreground"
>
Dashboard
</Link>
</nav>
)}
{/* Right side actions */}
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
<div className="w-full flex-1 md:w-auto md:flex-none" />
{/* Voice Status Indicator */}
<div className="flex items-center space-x-2 bg-muted/50 px-3 py-1 rounded-full">
<Activity className="w-3 h-3 text-green-500 animate-pulse" />
<span className="text-xs text-muted-foreground hidden sm:inline">Voice Ready</span>
</div>
{/* Theme Toggle */}
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
className="w-9 px-0"
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
{/* User Menu or Login */}
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-primary text-primary-foreground">
{getUserInitials(user)}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: user.username
}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
<div className="mt-1">
<Badge variant="secondary" className="text-xs">
{user.role === "admin" ? "Admin" : user.role === "pro" ? "Pro" : "Standard"}
</Badge>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setLocation("/settings")}>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLocation("/profile")}>
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="flex items-center space-x-2">
<Button variant="ghost" size="sm" onClick={() => setLocation("/login")}>
Sign In
</Button>
<Button size="sm" onClick={() => setLocation("/login")}>
Get Started
</Button>
</div>
)}
</div>
</div>
</header>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { Link, useLocation } from "wouter";
import { useAuth } from "@/hooks/useAuth";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
Calendar,
DollarSign,
LayoutDashboard,
Mic,
Brain,
Settings,
Users,
BarChart3,
Shield,
Zap
} from "lucide-react";
interface SidebarProps {
className?: string;
}
const navigation = [
{
name: "Dashboard",
href: "/dashboard",
icon: LayoutDashboard,
},
{
name: "Tasks",
href: "/tasks",
icon: Calendar,
badge: "3", // This would come from task count
},
{
name: "Finances",
href: "/finances",
icon: DollarSign,
},
{
name: "Voice Commands",
href: "/voice",
icon: Mic,
},
{
name: "AI Assistant",
href: "/ai",
icon: Brain,
},
];
const managementNavigation = [
{
name: "Analytics",
href: "/analytics",
icon: BarChart3,
roles: ["pro", "admin"],
},
{
name: "Settings",
href: "/settings",
icon: Settings,
},
];
const adminNavigation = [
{
name: "User Management",
href: "/admin/users",
icon: Users,
roles: ["admin"],
},
{
name: "System Status",
href: "/admin/system",
icon: Shield,
roles: ["admin"],
},
{
name: "Model Management",
href: "/admin/models",
icon: Zap,
roles: ["admin"],
},
];
export default function Sidebar({ className }: SidebarProps) {
const [location] = useLocation();
const { user } = useAuth();
const isActive = (href: string) => {
if (href === "/dashboard") {
return location === "/" || location === "/dashboard";
}
return location.startsWith(href);
};
const canAccess = (roles?: string[]) => {
if (!roles) return true;
return user && roles.includes(user.role);
};
return (
<div className={cn("pb-12 min-h-screen", className)}>
<div className="space-y-4 py-4">
<div className="px-3 py-2">
<div className="space-y-1">
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
Navigation
</h2>
<div className="space-y-1">
{navigation.map((item) => (
<Link key={item.name} href={item.href}>
<Button
variant={isActive(item.href) ? "secondary" : "ghost"}
className="w-full justify-start"
>
<item.icon className="mr-2 h-4 w-4" />
{item.name}
{item.badge && (
<Badge variant="secondary" className="ml-auto">
{item.badge}
</Badge>
)}
</Button>
</Link>
))}
</div>
</div>
</div>
<Separator />
<div className="px-3 py-2">
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
Management
</h2>
<div className="space-y-1">
{managementNavigation
.filter((item) => canAccess(item.roles))
.map((item) => (
<Link key={item.name} href={item.href}>
<Button
variant={isActive(item.href) ? "secondary" : "ghost"}
className="w-full justify-start"
>
<item.icon className="mr-2 h-4 w-4" />
{item.name}
</Button>
</Link>
))}
</div>
</div>
{user?.role === "admin" && (
<>
<Separator />
<div className="px-3 py-2">
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
Administration
</h2>
<div className="space-y-1">
{adminNavigation
.filter((item) => canAccess(item.roles))
.map((item) => (
<Link key={item.name} href={item.href}>
<Button
variant={isActive(item.href) ? "secondary" : "ghost"}
className="w-full justify-start"
>
<item.icon className="mr-2 h-4 w-4" />
{item.name}
{item.name === "User Management" && (
<Badge variant="outline" className="ml-auto text-xs">
ADMIN
</Badge>
)}
</Button>
</Link>
))}
</div>
</div>
</>
)}
</div>
</div>
);
}
+305
View File
@@ -0,0 +1,305 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { taskService } from "@/services/taskService";
import { useToast } from "@/hooks/use-toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import {
Calendar,
Clock,
CheckCircle2,
Circle,
Mic,
Plus,
MoreHorizontal
} from "lucide-react";
import { cn } from "@/lib/utils";
import { formatDistanceToNow, isAfter, isBefore, startOfDay } from "date-fns";
interface TaskListProps {
status?: string;
limit?: number;
showHeader?: boolean;
className?: string;
}
export default function TaskList({
status,
limit,
showHeader = true,
className
}: TaskListProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const {
data: tasksData,
isLoading,
error
} = useQuery({
queryKey: ["/api/tasks", { status }],
staleTime: 30 * 1000,
});
const completeTaskMutation = useMutation({
mutationFn: (taskId: number) => taskService.completeTask(taskId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
toast({
title: "Task completed",
description: "Task marked as completed successfully",
});
},
onError: (error) => {
toast({
title: "Error",
description: "Failed to complete task",
variant: "destructive",
});
},
});
const deleteTaskMutation = useMutation({
mutationFn: (taskId: number) => taskService.deleteTask(taskId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
toast({
title: "Task deleted",
description: "Task deleted successfully",
});
},
onError: (error) => {
toast({
title: "Error",
description: "Failed to delete task",
variant: "destructive",
});
},
});
const handleCompleteTask = (taskId: number) => {
completeTaskMutation.mutate(taskId);
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case "high":
return "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400";
case "medium":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400";
case "low":
return "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400";
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400";
}
};
const getTaskStatus = (task: any) => {
if (task.status === "completed") return "completed";
if (!task.dueDate) return "normal";
const now = new Date();
const dueDate = new Date(task.dueDate);
const today = startOfDay(now);
const taskDueDay = startOfDay(dueDate);
if (isBefore(taskDueDay, today)) return "overdue";
if (taskDueDay.getTime() === today.getTime()) return "due-today";
return "normal";
};
const getStatusBadge = (task: any) => {
const status = getTaskStatus(task);
switch (status) {
case "completed":
return <Badge className="bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400">Completed</Badge>;
case "overdue":
return <Badge className="bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400">Overdue</Badge>;
case "due-today":
return <Badge className="bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-400">Due Today</Badge>;
default:
return null;
}
};
if (isLoading) {
return (
<Card className={className}>
{showHeader && (
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Calendar className="w-5 h-5" />
<span>Tasks</span>
</CardTitle>
</CardHeader>
)}
<CardContent>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center space-x-3 p-3 rounded-lg border">
<Skeleton className="w-5 h-5" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
<Skeleton className="h-6 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card className={className}>
{showHeader && (
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Calendar className="w-5 h-5" />
<span>Tasks</span>
</CardTitle>
</CardHeader>
)}
<CardContent>
<div className="text-center text-muted-foreground py-8">
<Calendar className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>Failed to load tasks</p>
</div>
</CardContent>
</Card>
);
}
const tasks = tasksData?.tasks || [];
const displayTasks = limit ? tasks.slice(0, limit) : tasks;
if (displayTasks.length === 0) {
return (
<Card className={className}>
{showHeader && (
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Calendar className="w-5 h-5" />
<span>Tasks</span>
</CardTitle>
</CardHeader>
)}
<CardContent>
<div className="text-center text-muted-foreground py-8">
<Calendar className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No tasks found</p>
<p className="text-sm">Create a task using voice commands or the dashboard</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card className={className}>
{showHeader && (
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center space-x-2">
<Calendar className="w-5 h-5" />
<span>Tasks</span>
<Badge variant="secondary">{tasks.length}</Badge>
</CardTitle>
<Button size="sm" variant="outline">
<Plus className="w-4 h-4 mr-2" />
Add Task
</Button>
</div>
</CardHeader>
)}
<CardContent>
<div className="space-y-3">
{displayTasks.map((task) => (
<div
key={task.id}
className={cn(
"flex items-center space-x-3 p-3 rounded-lg border transition-colors hover:bg-muted/50",
task.status === "completed" && "opacity-60",
`priority-${task.priority}`
)}
>
{/* Completion Checkbox */}
<div className="flex-shrink-0">
<Checkbox
checked={task.status === "completed"}
onCheckedChange={() => handleCompleteTask(task.id)}
disabled={completeTaskMutation.isPending}
className="w-5 h-5"
/>
</div>
{/* Task Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className={cn(
"font-medium text-sm",
task.status === "completed" && "line-through text-muted-foreground"
)}>
{task.title}
</h4>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{task.description}
</p>
)}
<div className="flex items-center space-x-3 mt-2">
{/* Priority Badge */}
<Badge variant="outline" className={cn("text-xs", getPriorityColor(task.priority))}>
{task.priority}
</Badge>
{/* Voice Created Indicator */}
{task.createdViaVoice && (
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
<Mic className="w-3 h-3" />
<span>Voice</span>
</div>
)}
{/* Due Date */}
{task.dueDate && (
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
<span>
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
</span>
</div>
)}
</div>
</div>
{/* Status Badge */}
<div className="flex items-center space-x-2 ml-4">
{getStatusBadge(task)}
</div>
</div>
</div>
</div>
))}
</div>
{limit && tasks.length > limit && (
<div className="text-center mt-4">
<Button variant="ghost" size="sm">
View All Tasks ({tasks.length})
</Button>
</div>
)}
</CardContent>
</Card>
);
}
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+139
View File
@@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }
+50
View File
@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+115
View File
@@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+68
View File
@@ -0,0 +1,68 @@
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+260
View File
@@ -0,0 +1,260 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
+365
View File
@@ -0,0 +1,365 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+11
View File
@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+151
View File
@@ -0,0 +1,151 @@
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+198
View File
@@ -0,0 +1,198 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 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 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+122
View File
@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+118
View File
@@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+198
View File
@@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+178
View File
@@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
+29
View File
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }
+69
View File
@@ -0,0 +1,69 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+256
View File
@@ -0,0 +1,256 @@
"use client"
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return <MenubarPrimitive.RadioGroup {...props} />
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 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 origin-[--radix-menubar-content-transform-origin]",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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 origin-[--radix-menubar-content-transform-origin]",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}
@@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}
+117
View File
@@ -0,0 +1,117 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+42
View File
@@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }
+45
View File
@@ -0,0 +1,45 @@
"use client"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+140
View File
@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+771
View File
@@ -0,0 +1,771 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+117
View File
@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }
+127
View File
@@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
+33
View File
@@ -0,0 +1,33 @@
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
+61
View File
@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3 min-w-10",
sm: "h-9 px-2.5 min-w-9",
lg: "h-11 px-5 min-w-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,118 @@
import { useVoice } from "@/hooks/useVoice";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Mic, MicOff, Volume2, Activity } from "lucide-react";
import { cn } from "@/lib/utils";
interface VoiceIndicatorProps {
className?: string;
showControls?: boolean;
}
export default function VoiceIndicator({ className, showControls = false }: VoiceIndicatorProps) {
const { isListening, isProcessing, transcription, isSupported, startListening, stopListening, speak } = useVoice();
if (!isSupported) {
return null;
}
const handleToggleVoice = () => {
if (isListening) {
stopListening();
} else {
startListening();
}
};
const handleTestTTS = () => {
speak("Voice processing is working correctly. You can now use voice commands to manage your tasks and finances.");
};
return (
<div className={cn(
"fixed bottom-6 right-6 z-50 transition-transform duration-300",
isListening ? "translate-y-0" : "translate-y-full",
className
)}>
<Card className="shadow-lg border-primary/20 bg-background/95 backdrop-blur">
<CardContent className="p-4">
<div className="flex items-center space-x-3">
{/* Voice Status Icon */}
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center",
isListening
? "bg-red-500 voice-recording"
: "bg-primary voice-pulse"
)}>
{isProcessing ? (
<Activity className="w-5 h-5 text-white animate-spin" />
) : isListening ? (
<MicOff className="w-5 h-5 text-white" />
) : (
<Mic className="w-5 h-5 text-white" />
)}
</div>
{/* Status Information */}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium">
{isProcessing
? "Processing..."
: isListening
? "Listening..."
: "Voice Ready"
}
</span>
<Badge variant={isListening ? "destructive" : "secondary"} className="text-xs">
{isListening ? "LIVE" : "STT/TTS"}
</Badge>
</div>
{transcription && (
<p className="text-xs text-muted-foreground truncate mt-1">
"{transcription}"
</p>
)}
{!isListening && !transcription && (
<p className="text-xs text-muted-foreground">
Local AI No internet required
</p>
)}
</div>
{/* Controls */}
{showControls && (
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="ghost"
onClick={handleToggleVoice}
disabled={isProcessing}
className="h-8 w-8 p-0"
>
{isListening ? (
<MicOff className="w-4 h-4" />
) : (
<Mic className="w-4 h-4" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleTestTTS}
className="h-8 w-8 p-0"
>
<Volume2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,265 @@
import { useState } from "react";
import { useVoice } from "@/hooks/useVoice";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Mic, MicOff, Loader2, Volume2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface VoiceRecorderProps {
onTranscription?: (text: string) => void;
onResult?: (result: any) => void;
className?: string;
variant?: "default" | "compact" | "floating";
}
export default function VoiceRecorder({
onTranscription,
onResult,
className,
variant = "default"
}: VoiceRecorderProps) {
const {
isListening,
isProcessing,
transcription,
isSupported,
startListening,
stopListening,
speak
} = useVoice();
const [lastResult, setLastResult] = useState<any>(null);
const handleToggleListening = async () => {
if (isListening) {
stopListening();
} else {
try {
const result = await startListening();
if (result) {
setLastResult(result);
onResult?.(result);
onTranscription?.(result.transcription);
}
} catch (error) {
console.error("Voice recording error:", error);
}
}
};
const handlePlayback = () => {
if (lastResult?.actionResult) {
const message = getActionMessage(lastResult.actionResult);
speak(message);
} else if (transcription) {
speak(`You said: ${transcription}`);
}
};
const getActionMessage = (actionResult: any) => {
switch (actionResult.type) {
case 'task_created':
return `Task "${actionResult.task.title}" has been created successfully.`;
case 'expense_added':
return `Expense of $${actionResult.record.amount} has been recorded.`;
case 'income_added':
return `Income of $${actionResult.record.amount} has been added.`;
case 'tasks_retrieved':
return `You have ${actionResult.tasks.length} pending tasks.`;
case 'financial_summary':
const { income, expenses, net } = actionResult.summary;
return `Your current balance is $${net}. You have $${income} in income and $${expenses} in expenses.`;
default:
return "Command processed successfully.";
}
};
if (!isSupported) {
return (
<Card className={cn("border-destructive", className)}>
<CardContent className="p-4">
<div className="text-center text-sm text-muted-foreground">
Voice recording is not supported in your browser
</div>
</CardContent>
</Card>
);
}
if (variant === "compact") {
return (
<div className={cn("flex items-center space-x-2", className)}>
<Button
size="sm"
variant={isListening ? "destructive" : "default"}
onClick={handleToggleListening}
disabled={isProcessing}
className={cn(
"relative",
isListening && "voice-recording"
)}
>
{isProcessing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : isListening ? (
<MicOff className="w-4 h-4" />
) : (
<Mic className="w-4 h-4" />
)}
</Button>
{transcription && (
<Badge variant="secondary" className="text-xs">
Voice: {transcription.slice(0, 20)}...
</Badge>
)}
</div>
);
}
if (variant === "floating") {
return (
<div className={cn(
"fixed bottom-6 right-6 z-50",
isListening ? "voice-status-indicator visible" : "voice-status-indicator",
className
)}>
<Card className="shadow-lg">
<CardContent className="p-4">
<div className="flex items-center space-x-3">
<Button
size="lg"
variant={isListening ? "destructive" : "default"}
onClick={handleToggleListening}
disabled={isProcessing}
className={cn(
"w-12 h-12 rounded-full voice-gradient",
isListening && "voice-recording"
)}
>
{isProcessing ? (
<Loader2 className="w-6 h-6 animate-spin text-white" />
) : isListening ? (
<MicOff className="w-6 h-6 text-white" />
) : (
<Mic className="w-6 h-6 text-white" />
)}
</Button>
{isListening && (
<div className="text-sm">
<div className="font-medium">Listening...</div>
<div className="text-muted-foreground">Speak your command</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
return (
<Card className={cn("", className)}>
<CardContent className="p-6">
<div className="space-y-4">
{/* Voice Control Button */}
<div className="flex justify-center">
<Button
size="lg"
variant={isListening ? "destructive" : "default"}
onClick={handleToggleListening}
disabled={isProcessing}
className={cn(
"w-20 h-20 rounded-full voice-gradient",
isListening && "voice-recording"
)}
>
{isProcessing ? (
<Loader2 className="w-8 h-8 animate-spin text-white" />
) : isListening ? (
<MicOff className="w-8 h-8 text-white" />
) : (
<Mic className="w-8 h-8 text-white" />
)}
</Button>
</div>
{/* Status */}
<div className="text-center">
<h3 className="font-semibold">
{isProcessing
? "Processing..."
: isListening
? "Listening..."
: "Ready to Listen"
}
</h3>
<p className="text-sm text-muted-foreground">
{isListening
? "Speak your command now"
: "Click the microphone to start"
}
</p>
</div>
{/* Transcription Display */}
{transcription && (
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="text-sm font-medium mb-1">Transcription:</div>
<div className="text-sm">{transcription}</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={handlePlayback}
className="ml-2"
>
<Volume2 className="w-4 h-4" />
</Button>
</div>
{lastResult?.intent && (
<div className="mt-2 pt-2 border-t">
<div className="flex items-center space-x-2">
<Badge variant="outline">{lastResult.intent}</Badge>
<span className="text-xs text-muted-foreground">
Confidence: {Math.round(lastResult.confidence * 100)}%
</span>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Action Result */}
{lastResult?.actionResult && (
<Card className="bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800">
<CardContent className="p-4">
<div className="text-sm font-medium text-green-800 dark:text-green-200 mb-1">
Action Completed:
</div>
<div className="text-sm text-green-700 dark:text-green-300">
{getActionMessage(lastResult.actionResult)}
</div>
</CardContent>
</Card>
)}
{/* Usage Tips */}
<div className="text-xs text-muted-foreground space-y-1">
<div className="font-medium">Try saying:</div>
<div>"Create task to review budget"</div>
<div>"Add $50 expense for groceries"</div>
<div>"Show me my financial summary"</div>
</div>
</div>
</CardContent>
</Card>
);
}
+86
View File
@@ -0,0 +1,86 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import { User } from "@shared/schema";
import { authService } from "@/services/authService";
interface AuthContextType {
user: User | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (userData: any) => Promise<void>;
logout: () => Promise<void>;
updateProfile: (updates: Partial<User>) => Promise<void>;
completeOnboarding: (role: string, preferences?: any) => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check if user is already authenticated on app start
checkAuthStatus();
}, []);
const checkAuthStatus = async () => {
try {
const currentUser = await authService.getCurrentUser();
setUser(currentUser);
} catch (error) {
// User not authenticated
setUser(null);
} finally {
setIsLoading(false);
}
};
const login = async (email: string, password: string) => {
const response = await authService.login(email, password);
setUser(response.user);
};
const register = async (userData: any) => {
const response = await authService.register(userData);
setUser(response.user);
};
const logout = async () => {
await authService.logout();
setUser(null);
};
const updateProfile = async (updates: Partial<User>) => {
const response = await authService.updateProfile(updates);
setUser(response.user);
};
const completeOnboarding = async (role: string, preferences?: any) => {
const response = await authService.completeOnboarding(role, preferences);
setUser(response.user);
};
return (
<AuthContext.Provider
value={{
user,
isLoading,
login,
register,
logout,
updateProfile,
completeOnboarding,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
+47
View File
@@ -0,0 +1,47 @@
import React, { createContext, useContext, useEffect, useState } from "react";
interface ThemeContextType {
theme: "light" | "dark";
setTheme: (theme: "light" | "dark") => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("theme");
if (saved === "light" || saved === "dark") {
return saved;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
return "light";
});
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(theme);
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme(theme === "light" ? "dark" : "light");
};
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+191
View File
@@ -0,0 +1,191 @@
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }
+10
View File
@@ -0,0 +1,10 @@
import { useContext } from "react";
import { AuthContext } from "@/context/AuthContext";
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
+192
View File
@@ -0,0 +1,192 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { voiceProcessor } from "@/lib/voiceProcessor";
import { voiceService } from "@/services/voiceService";
import { useToast } from "@/hooks/use-toast";
interface VoiceResult {
transcription: string;
intent?: string;
confidence: number;
actionResult?: any;
}
export function useVoice() {
const [isListening, setIsListening] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [transcription, setTranscription] = useState("");
const [isSupported, setIsSupported] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const { toast } = useToast();
useEffect(() => {
// Check if browser supports voice recording
setIsSupported(
typeof navigator !== "undefined" &&
!!navigator.mediaDevices &&
!!navigator.mediaDevices.getUserMedia &&
!!window.MediaRecorder
);
}, []);
const startListening = useCallback(async () => {
if (!isSupported) {
toast({
title: "Voice not supported",
description: "Your browser doesn't support voice recording",
variant: "destructive",
});
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
});
const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4'
});
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunksRef.current, {
type: mediaRecorder.mimeType
});
// Stop all tracks to release microphone
stream.getTracks().forEach(track => track.stop());
// Process the audio
await processAudio(audioBlob);
};
mediaRecorder.start(100); // Collect data every 100ms
setIsListening(true);
setTranscription("");
toast({
title: "Listening...",
description: "Speak your command now",
});
} catch (error) {
console.error("Error starting voice recording:", error);
toast({
title: "Microphone access denied",
description: "Please allow microphone access to use voice features",
variant: "destructive",
});
}
}, [isSupported, toast]);
const stopListening = useCallback(() => {
if (mediaRecorderRef.current && isListening) {
mediaRecorderRef.current.stop();
setIsListening(false);
}
}, [isListening]);
const processAudio = async (audioBlob: Blob) => {
setIsProcessing(true);
try {
// Convert blob to base64 for API transmission
const audioBuffer = await audioBlob.arrayBuffer();
const base64Audio = btoa(
new Uint8Array(audioBuffer).reduce(
(data, byte) => data + String.fromCharCode(byte),
''
)
);
// Send to backend for processing
const result = await voiceService.processCommand(base64Audio);
setTranscription(result.transcription);
if (result.actionResult) {
// Show success message based on action type
const actionType = result.actionResult.type;
let message = "Command processed successfully";
switch (actionType) {
case 'task_created':
message = `Task "${result.actionResult.task.title}" created`;
break;
case 'expense_added':
message = `Expense of $${result.actionResult.record.amount} added`;
break;
case 'income_added':
message = `Income of $${result.actionResult.record.amount} recorded`;
break;
default:
message = "Command executed successfully";
}
toast({
title: "Voice command successful",
description: message,
});
} else if (result.transcription) {
toast({
title: "Voice transcribed",
description: result.transcription,
});
}
return result;
} catch (error) {
console.error("Error processing audio:", error);
toast({
title: "Voice processing failed",
description: "Failed to process voice command",
variant: "destructive",
});
throw error;
} finally {
setIsProcessing(false);
}
};
const speak = useCallback(async (text: string, voice?: string) => {
try {
const audioBlob = await voiceService.speak(text, voice);
// Play the audio
const audio = new Audio(URL.createObjectURL(audioBlob));
await audio.play();
return true;
} catch (error) {
console.error("Error with text-to-speech:", error);
toast({
title: "Speech synthesis failed",
description: "Failed to convert text to speech",
variant: "destructive",
});
return false;
}
}, [toast]);
return {
isListening,
isProcessing,
transcription,
isSupported,
startListening,
stopListening,
speak,
};
}
+213
View File
@@ -0,0 +1,213 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: 0 0% 100%;
--foreground: 222 84% 5%;
--muted: 210 40% 98%;
--muted-foreground: 215 16% 47%;
--popover: 0 0% 100%;
--popover-foreground: 222 84% 5%;
--card: 0 0% 100%;
--card-foreground: 222 84% 5%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--primary: 207 90% 54%;
--primary-foreground: 211 100% 99%;
--secondary: 273 69% 66%;
--secondary-foreground: 273 100% 99%;
--accent: 142 76% 36%;
--accent-foreground: 142 100% 99%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 98%;
--ring: 222 84% 5%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5% 26%;
--sidebar-primary: 207 90% 54%;
--sidebar-primary-foreground: 211 100% 99%;
--sidebar-accent: 210 40% 98%;
--sidebar-accent-foreground: 222 84% 5%;
--sidebar-border: 214 32% 91%;
--sidebar-ring: 222 84% 5%;
}
.dark {
--background: 222 84% 5%;
--foreground: 210 40% 98%;
--muted: 217 32% 17%;
--muted-foreground: 215 20% 65%;
--popover: 222 84% 5%;
--popover-foreground: 210 40% 98%;
--card: 222 84% 5%;
--card-foreground: 210 40% 98%;
--border: 217 32% 17%;
--input: 217 32% 17%;
--primary: 207 90% 54%;
--primary-foreground: 211 100% 99%;
--secondary: 273 69% 66%;
--secondary-foreground: 273 100% 99%;
--accent: 142 76% 36%;
--accent-foreground: 142 100% 99%;
--destructive: 0 63% 31%;
--destructive-foreground: 0 0% 98%;
--ring: 215 20% 65%;
--sidebar-background: 222 84% 5%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 207 90% 54%;
--sidebar-primary-foreground: 211 100% 99%;
--sidebar-accent: 217 32% 17%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217 32% 17%;
--sidebar-ring: 215 20% 65%;
}
@layer base {
* {
@apply border-border;
}
body {
@apply font-sans antialiased bg-background text-foreground;
}
/* Voice processing animations */
.voice-pulse {
animation: voice-pulse 1.5s ease-in-out infinite;
}
.voice-recording {
animation: voice-recording 1.5s ease-in-out infinite;
}
@keyframes voice-pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.8;
}
}
@keyframes voice-recording {
0%, 100% {
transform: scale(1);
opacity: 1;
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
}
50% {
transform: scale(1.1);
opacity: 0.8;
box-shadow: 0 0 0 10px rgba(59, 130, 246, 0);
}
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
@apply bg-muted;
}
::-webkit-scrollbar-thumb {
@apply bg-border rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-ring;
}
/* Focus styles */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2;
}
/* Voice visualizer gradient */
.voice-gradient {
background: linear-gradient(45deg, hsl(var(--primary)), hsl(var(--secondary)));
}
/* Task priority indicators */
.priority-high {
@apply border-l-4 border-red-500;
}
.priority-medium {
@apply border-l-4 border-yellow-500;
}
.priority-low {
@apply border-l-4 border-green-500;
}
}
/* Component-specific styles */
.dashboard-grid {
display: grid;
grid-template-columns: 256px 1fr;
min-height: calc(100vh - 73px);
}
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
/* Voice status indicator */
.voice-status-indicator {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 50;
transform: translateY(100%);
transition: transform 0.3s ease-in-out;
}
.voice-status-indicator.visible {
transform: translateY(0);
}
/* AI chat animations */
.ai-message {
animation: slideInFromBottom 0.3s ease-out;
}
@keyframes slideInFromBottom {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Loading states */
.skeleton {
@apply animate-pulse bg-muted rounded;
}
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
+57
View File
@@ -0,0 +1,57 @@
import { QueryClient, QueryFunction } from "@tanstack/react-query";
async function throwIfResNotOk(res: Response) {
if (!res.ok) {
const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`);
}
}
export async function apiRequest(
method: string,
url: string,
data?: unknown | undefined,
): Promise<Response> {
const res = await fetch(url, {
method,
headers: data ? { "Content-Type": "application/json" } : {},
body: data ? JSON.stringify(data) : undefined,
credentials: "include",
});
await throwIfResNotOk(res);
return res;
}
type UnauthorizedBehavior = "returnNull" | "throw";
export const getQueryFn: <T>(options: {
on401: UnauthorizedBehavior;
}) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => {
const res = await fetch(queryKey[0] as string, {
credentials: "include",
});
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
return null;
}
await throwIfResNotOk(res);
return await res.json();
};
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: getQueryFn({ on401: "throw" }),
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
retry: false,
},
mutations: {
retry: false,
},
},
});
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Voice processing utilities for local audio handling
* Provides audio format conversion and preprocessing for STT models
*/
export interface AudioConfig {
sampleRate: number;
channels: number;
bitDepth: number;
}
export const DEFAULT_AUDIO_CONFIG: AudioConfig = {
sampleRate: 16000, // Common for STT models
channels: 1, // Mono
bitDepth: 16,
};
export class VoiceProcessor {
private audioContext: AudioContext | null = null;
constructor() {
if (typeof window !== "undefined" && window.AudioContext) {
this.audioContext = new AudioContext();
}
}
/**
* Convert audio blob to PCM format suitable for STT processing
*/
async convertToPCM(audioBlob: Blob, config: AudioConfig = DEFAULT_AUDIO_CONFIG): Promise<Float32Array> {
if (!this.audioContext) {
throw new Error("AudioContext not supported");
}
const arrayBuffer = await audioBlob.arrayBuffer();
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
// Resample if necessary
const resampled = this.resample(audioBuffer, config.sampleRate);
// Convert to mono if necessary
const mono = config.channels === 1 ? this.convertToMono(resampled) : resampled;
return mono.getChannelData(0);
}
/**
* Resample audio to target sample rate
*/
private resample(audioBuffer: AudioBuffer, targetSampleRate: number): AudioBuffer {
if (audioBuffer.sampleRate === targetSampleRate) {
return audioBuffer;
}
const ratio = audioBuffer.sampleRate / targetSampleRate;
const newLength = Math.floor(audioBuffer.length / ratio);
const newBuffer = this.audioContext!.createBuffer(
audioBuffer.numberOfChannels,
newLength,
targetSampleRate
);
for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) {
const inputData = audioBuffer.getChannelData(channel);
const outputData = newBuffer.getChannelData(channel);
for (let i = 0; i < newLength; i++) {
const sourceIndex = Math.floor(i * ratio);
outputData[i] = inputData[sourceIndex];
}
}
return newBuffer;
}
/**
* Convert stereo to mono by averaging channels
*/
private convertToMono(audioBuffer: AudioBuffer): AudioBuffer {
if (audioBuffer.numberOfChannels === 1) {
return audioBuffer;
}
const monoBuffer = this.audioContext!.createBuffer(
1,
audioBuffer.length,
audioBuffer.sampleRate
);
const monoData = monoBuffer.getChannelData(0);
for (let i = 0; i < audioBuffer.length; i++) {
let sum = 0;
for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) {
sum += audioBuffer.getChannelData(channel)[i];
}
monoData[i] = sum / audioBuffer.numberOfChannels;
}
return monoBuffer;
}
/**
* Apply noise reduction and audio enhancement
*/
async enhanceAudio(pcmData: Float32Array): Promise<Float32Array> {
// Simple noise gate implementation
const threshold = 0.01;
const enhanced = new Float32Array(pcmData.length);
for (let i = 0; i < pcmData.length; i++) {
enhanced[i] = Math.abs(pcmData[i]) > threshold ? pcmData[i] : 0;
}
return enhanced;
}
/**
* Detect voice activity in audio data
*/
detectVoiceActivity(pcmData: Float32Array, windowSize: number = 1024): boolean[] {
const activity: boolean[] = [];
const energyThreshold = 0.02;
for (let i = 0; i < pcmData.length; i += windowSize) {
const window = pcmData.slice(i, i + windowSize);
const energy = this.calculateEnergy(window);
activity.push(energy > energyThreshold);
}
return activity;
}
/**
* Calculate RMS energy of audio window
*/
private calculateEnergy(window: Float32Array): number {
let sum = 0;
for (let i = 0; i < window.length; i++) {
sum += window[i] * window[i];
}
return Math.sqrt(sum / window.length);
}
/**
* Convert Float32Array to base64 for API transmission
*/
pcmToBase64(pcmData: Float32Array): string {
// Convert to 16-bit PCM
const int16Array = new Int16Array(pcmData.length);
for (let i = 0; i < pcmData.length; i++) {
int16Array[i] = Math.max(-32768, Math.min(32767, pcmData[i] * 32767));
}
const buffer = int16Array.buffer;
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Cleanup resources
*/
dispose(): void {
if (this.audioContext && this.audioContext.state !== 'closed') {
this.audioContext.close();
}
}
}
export const voiceProcessor = new VoiceProcessor();
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);
+253
View File
@@ -0,0 +1,253 @@
import { useAuth } from "@/hooks/useAuth";
import { useLocation } from "wouter";
import { useEffect } from "react";
import Header from "@/components/layout/Header";
import Sidebar from "@/components/layout/Sidebar";
import TaskList from "@/components/tasks/TaskList";
import FinancialOverview from "@/components/financial/FinancialOverview";
import VoiceRecorder from "@/components/voice/VoiceRecorder";
import VoiceIndicator from "@/components/voice/VoiceIndicator";
import DailyJoke from "@/components/ai/DailyJoke";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Calendar,
DollarSign,
Mic,
Brain,
Activity,
TrendingUp,
Zap
} from "lucide-react";
export default function DashboardPage() {
const { user, isLoading } = useAuth();
const [, setLocation] = useLocation();
useEffect(() => {
if (!isLoading && !user) {
setLocation("/login");
}
}, [user, isLoading, setLocation]);
useEffect(() => {
if (user && !user.onboardingComplete) {
setLocation("/onboarding");
}
}, [user, setLocation]);
if (isLoading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<div className="w-8 h-8 bg-gradient-to-br from-primary to-secondary rounded-lg animate-pulse mx-auto mb-4" />
<p className="text-muted-foreground">Loading dashboard...</p>
</div>
</div>
);
}
if (!user) {
return null;
}
const getWelcomeMessage = () => {
const hour = new Date().getHours();
const name = user.firstName || user.username;
if (hour < 12) return `Good morning, ${name}!`;
if (hour < 18) return `Good afternoon, ${name}!`;
return `Good evening, ${name}!`;
};
return (
<div className="min-h-screen bg-background">
<Header />
<div className="dashboard-grid">
{/* Sidebar */}
<Sidebar className="hidden md:block" />
{/* Main Content */}
<main className="flex-1 p-6 overflow-auto">
<div className="max-w-7xl mx-auto space-y-6">
{/* Welcome Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">
{getWelcomeMessage()}
</h1>
<p className="text-muted-foreground mt-1">
Here's what's happening with your tasks and finances today.
</p>
</div>
<div className="flex items-center space-x-2">
<Badge variant="secondary" className="text-xs">
{user.role === "admin" ? "Admin" : user.role === "pro" ? "Pro" : "Standard"} User
</Badge>
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400 text-xs">
Voice Active
</Badge>
</div>
</div>
{/* Daily AI Joke */}
<DailyJoke />
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Zap className="w-5 h-5" />
<span>Quick Actions</span>
<Badge variant="outline" className="ml-2 text-xs">
Voice Ready
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Voice Task Creation */}
<Card className="bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-primary-900/20 dark:to-secondary-900/20 border-primary-200 dark:border-primary-800">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-10 h-10 bg-primary-600 rounded-xl flex items-center justify-center">
<Calendar className="w-6 h-6 text-white" />
</div>
<Mic className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<h3 className="font-semibold text-slate-900 dark:text-white mb-1">Voice Task</h3>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-3">Say "Create task" to get started</p>
<div className="text-xs text-slate-500 dark:text-slate-500">
Try: "Create task to review budget reports"
</div>
</CardContent>
</Card>
{/* Voice Expense Entry */}
<Card className="bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-900/20 dark:to-teal-900/20 border-emerald-200 dark:border-emerald-800">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-10 h-10 bg-emerald-600 rounded-xl flex items-center justify-center">
<DollarSign className="w-6 h-6 text-white" />
</div>
<Mic className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
</div>
<h3 className="font-semibold text-slate-900 dark:text-white mb-1">Voice Expense</h3>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-3">Say "Add expense" for quick entry</p>
<div className="text-xs text-slate-500 dark:text-slate-500">
Try: "Add $50 expense for groceries"
</div>
</CardContent>
</Card>
{/* Voice Assistant */}
<Card className="bg-gradient-to-br from-purple-50 to-violet-50 dark:from-purple-900/20 dark:to-violet-900/20 border-purple-200 dark:border-purple-800">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-10 h-10 bg-purple-600 rounded-xl flex items-center justify-center">
<Brain className="w-6 h-6 text-white" />
</div>
<Mic className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<h3 className="font-semibold text-slate-900 dark:text-white mb-1">AI Assistant</h3>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-3">Ask questions about your data</p>
<div className="text-xs text-slate-500 dark:text-slate-500">
Try: "What's my financial summary?"
</div>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
{/* Main Dashboard Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Tasks Section */}
<div className="space-y-6">
<TaskList limit={5} status="pending" />
{/* Voice Recorder */}
<VoiceRecorder variant="default" className="lg:hidden" />
</div>
{/* Financial Section */}
<div className="space-y-6">
<FinancialOverview period="month" limit={5} />
</div>
</div>
{/* Voice Recorder for Desktop */}
<div className="hidden lg:block">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Mic className="w-5 h-5" />
<span>Voice Commands</span>
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400 text-xs">
Local Processing
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<VoiceRecorder variant="default" />
</CardContent>
</Card>
</div>
{/* System Status (for Pro/Admin users) */}
{(user.role === "pro" || user.role === "admin") && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Activity className="w-5 h-5" />
<span>System Status</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="flex items-center space-x-3">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<div>
<p className="font-medium text-sm">STT Model</p>
<p className="text-xs text-muted-foreground">Online</p>
</div>
</div>
<div className="flex items-center space-x-3">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<div>
<p className="font-medium text-sm">TTS Model</p>
<p className="text-xs text-muted-foreground">Online</p>
</div>
</div>
<div className="flex items-center space-x-3">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<div>
<p className="font-medium text-sm">AI Model</p>
<p className="text-xs text-muted-foreground">Ready</p>
</div>
</div>
<div className="flex items-center space-x-3">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<div>
<p className="font-medium text-sm">Database</p>
<p className="text-xs text-muted-foreground">Connected</p>
</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</main>
</div>
{/* Global Voice Indicator */}
<VoiceIndicator showControls />
</div>
);
}
+335
View File
@@ -0,0 +1,335 @@
import { useLocation } from "wouter";
import { useAuth } from "@/hooks/useAuth";
import { useVoice } from "@/hooks/useVoice";
import { useQuery } from "@tanstack/react-query";
import { aiService } from "@/services/aiService";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import DailyJoke from "@/components/ai/DailyJoke";
import VoiceIndicator from "@/components/voice/VoiceIndicator";
import Header from "@/components/layout/Header";
import { useTheme } from "@/context/ThemeContext";
import {
Mic,
Volume2,
Shield,
Brain,
CheckCircle,
DollarSign,
Calendar,
Play,
ArrowRight,
Zap
} from "lucide-react";
export default function LandingPage() {
const [, setLocation] = useLocation();
const { user } = useAuth();
const { isListening, startListening, stopListening, isSupported } = useVoice();
const { theme } = useTheme();
// Redirect to dashboard if already authenticated
if (user) {
setLocation("/dashboard");
return null;
}
const handleGetStarted = () => {
setLocation("/login");
};
const handleVoiceDemo = () => {
if (isListening) {
stopListening();
} else {
startListening();
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-violet-50 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
<Header />
{/* Hero Section */}
<main className="relative py-20 lg:py-32 overflow-hidden">
{/* Background Pattern */}
<div className="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width=\"60\" height=\"60\" viewBox=\"0 0 60 60\" xmlns=\"http://www.w3.org/2000/svg\"%3E%3Cg fill=\"none\" fill-rule=\"evenodd\"%3E%3Cg fill=\"%236366f1\" fill-opacity=\"0.05\"%3E%3Ccircle cx=\"30\" cy=\"30\" r=\"2\"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] opacity-50"></div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Left Column: Content */}
<div className="space-y-8">
{/* Daily Joke Display */}
<DailyJoke />
<div className="space-y-4">
<h1 className="text-4xl lg:text-6xl font-bold text-slate-900 dark:text-white leading-tight">
Voice-Powered
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-secondary-600">
{" "}TaskFin
</span>
</h1>
<p className="text-xl text-slate-600 dark:text-slate-300 leading-relaxed">
Complete offline task and financial management with local AI voice processing.
No internet required, complete privacy guaranteed.
</p>
</div>
{/* Key Features */}
<div className="grid grid-cols-2 gap-4">
<Card className="border-slate-200 dark:border-slate-700">
<CardContent className="p-4">
<div className="w-8 h-8 bg-primary-100 dark:bg-primary-900/50 rounded-lg flex items-center justify-center mb-3">
<Calendar className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<h4 className="font-semibold text-slate-900 dark:text-white text-sm">Task Management</h4>
<p className="text-xs text-slate-600 dark:text-slate-400 mt-1">Voice-powered task creation</p>
</CardContent>
</Card>
<Card className="border-slate-200 dark:border-slate-700">
<CardContent className="p-4">
<div className="w-8 h-8 bg-emerald-100 dark:bg-emerald-900/50 rounded-lg flex items-center justify-center mb-3">
<DollarSign className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
</div>
<h4 className="font-semibold text-slate-900 dark:text-white text-sm">Financial Tracking</h4>
<p className="text-xs text-slate-600 dark:text-slate-400 mt-1">Automated expense logging</p>
</CardContent>
</Card>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<Button
onClick={handleGetStarted}
className="flex-1 bg-primary-600 hover:bg-primary-700 text-white px-8 py-4 text-lg font-semibold shadow-lg hover:shadow-xl transition-all"
>
Start Free Trial
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
<Button
variant="outline"
onClick={() => setLocation("/demo")}
className="px-8 py-4 text-lg font-semibold border-slate-300 dark:border-slate-600 hover:border-primary-300 dark:hover:border-primary-500"
>
<Play className="w-5 h-5 mr-2" />
View Demo
</Button>
</div>
</div>
{/* Right Column: Voice Demo */}
<div className="lg:pl-8">
<Card className="shadow-2xl border-slate-200 dark:border-slate-700">
<CardContent className="p-8">
<div className="text-center space-y-6">
<h3 className="text-xl font-semibold text-slate-900 dark:text-white">Try Voice Commands</h3>
{/* Voice Visualizer */}
<div className="flex justify-center">
<div className="relative">
<Button
onClick={handleVoiceDemo}
disabled={!isSupported}
className={`w-24 h-24 rounded-full bg-gradient-to-r from-primary-500 to-secondary-500 hover:from-primary-600 hover:to-secondary-600 text-white border-0 ${
isListening ? 'animate-pulse scale-110' : ''
}`}
>
<Mic className="w-12 h-12" />
</Button>
{isListening && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full animate-pulse" />
)}
</div>
</div>
<div className="space-y-3">
<Button
onClick={handleVoiceDemo}
disabled={!isSupported}
className="w-full bg-secondary-600 hover:bg-secondary-700 text-white font-medium"
>
<Mic className="w-4 h-4 mr-2" />
{isListening ? 'Stop Listening' : 'Start Voice Demo'}
</Button>
<div className="text-sm text-slate-600 dark:text-slate-400 space-y-1">
<p className="font-medium">Try saying:</p>
<Card className="bg-slate-50 dark:bg-slate-900 border-slate-200 dark:border-slate-700">
<CardContent className="p-3 text-left space-y-1">
<p>"Create a task to review budget"</p>
<p>"Add $50 expense for groceries"</p>
<p>"Show me today's schedule"</p>
</CardContent>
</Card>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
{/* Features Section */}
<section className="py-20 bg-white dark:bg-slate-800 mt-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl lg:text-4xl font-bold text-slate-900 dark:text-white mb-4">
Complete Offline Voice AI
</h2>
<p className="text-xl text-slate-600 dark:text-slate-300 max-w-3xl mx-auto">
Enterprise-grade task and financial management with local AI processing
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{/* Local AI Processing */}
<Card className="bg-slate-50 dark:bg-slate-700 border-slate-200 dark:border-slate-600">
<CardContent className="p-8">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-xl flex items-center justify-center mb-4">
<Brain className="w-6 h-6 text-primary-600 dark:text-primary-400" />
</div>
<h3 className="text-xl font-semibold text-slate-900 dark:text-white mb-3">Local AI Models</h3>
<p className="text-slate-600 dark:text-slate-300 mb-4">
Speech-to-text and text-to-speech processing entirely offline. No data leaves your premises.
</p>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm text-slate-500 dark:text-slate-400">Base models included</span>
</div>
<div className="flex items-center space-x-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm text-slate-500 dark:text-slate-400">Swappable model architecture</span>
</div>
</div>
</CardContent>
</Card>
{/* Voice Task Management */}
<Card className="bg-slate-50 dark:bg-slate-700 border-slate-200 dark:border-slate-600">
<CardContent className="p-8">
<div className="w-12 h-12 bg-secondary-100 dark:bg-secondary-900 rounded-xl flex items-center justify-center mb-4">
<Calendar className="w-6 h-6 text-secondary-600 dark:text-secondary-400" />
</div>
<h3 className="text-xl font-semibold text-slate-900 dark:text-white mb-3">Voice Task Creation</h3>
<p className="text-slate-600 dark:text-slate-300 mb-4">
Create, update, and manage tasks using natural voice commands with intelligent parsing.
</p>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm text-slate-500 dark:text-slate-400">Natural language processing</span>
</div>
<div className="flex items-center space-x-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm text-slate-500 dark:text-slate-400">Smart date/time extraction</span>
</div>
</div>
</CardContent>
</Card>
{/* Financial Voice Entry */}
<Card className="bg-slate-50 dark:bg-slate-700 border-slate-200 dark:border-slate-600">
<CardContent className="p-8">
<div className="w-12 h-12 bg-emerald-100 dark:bg-emerald-900 rounded-xl flex items-center justify-center mb-4">
<DollarSign className="w-6 h-6 text-emerald-600 dark:text-emerald-400" />
</div>
<h3 className="text-xl font-semibold text-slate-900 dark:text-white mb-3">Voice Financial Tracking</h3>
<p className="text-slate-600 dark:text-slate-300 mb-4">
Record expenses and income through voice with automatic categorization and receipt parsing.
</p>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm text-slate-500 dark:text-slate-400">Auto-categorization</span>
</div>
<div className="flex items-center space-x-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm text-slate-500 dark:text-slate-400">Receipt OCR integration</span>
</div>
</div>
</CardContent>
</Card>
{/* Privacy & Security */}
<Card className="bg-slate-50 dark:bg-slate-700 border-slate-200 dark:border-slate-600">
<CardContent className="p-8">
<div className="w-12 h-12 bg-red-100 dark:bg-red-900 rounded-xl flex items-center justify-center mb-4">
<Shield className="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
<h3 className="text-xl font-semibold text-slate-900 dark:text-white mb-3">Complete Privacy</h3>
<p className="text-slate-600 dark:text-slate-300 mb-4">
All voice processing happens locally. Zero cloud dependencies, complete data sovereignty.
</p>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm text-slate-500 dark:text-slate-400">On-premise deployment</span>
</div>
<div className="flex items-center space-x-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm text-slate-500 dark:text-slate-400">Encrypted local storage</span>
</div>
</div>
</CardContent>
</Card>
{/* Real-time Processing */}
<Card className="bg-slate-50 dark:bg-slate-700 border-slate-200 dark:border-slate-600">
<CardContent className="p-8">
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-900 rounded-xl flex items-center justify-center mb-4">
<Zap className="w-6 h-6 text-amber-600 dark:text-amber-400" />
</div>
<h3 className="text-xl font-semibold text-slate-900 dark:text-white mb-3">Real-time Processing</h3>
<p className="text-slate-600 dark:text-slate-300 mb-4">
Instant voice-to-text conversion and immediate AI responses for seamless interaction.
</p>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm text-slate-500 dark:text-slate-400">Sub-second response time</span>
</div>
<div className="flex items-center space-x-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm text-slate-500 dark:text-slate-400">Continuous listening mode</span>
</div>
</div>
</CardContent>
</Card>
{/* Multi-user Support */}
<Card className="bg-slate-50 dark:bg-slate-700 border-slate-200 dark:border-slate-600">
<CardContent className="p-8">
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-xl flex items-center justify-center mb-4">
<Volume2 className="w-6 h-6 text-purple-600 dark:text-purple-400" />
</div>
<h3 className="text-xl font-semibold text-slate-900 dark:text-white mb-3">Multi-Tier Access</h3>
<p className="text-slate-600 dark:text-slate-300 mb-4">
Standard, Pro, and Admin user roles with progressive feature unlocking and onboarding.
</p>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm text-slate-500 dark:text-slate-400">Progressive onboarding</span>
</div>
<div className="flex items-center space-x-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm text-slate-500 dark:text-slate-400">Feature-based permissions</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</section>
</main>
{/* Voice Status Indicator */}
<VoiceIndicator />
</div>
);
}
+254
View File
@@ -0,0 +1,254 @@
import { useState } from "react";
import { Link, useLocation } from "wouter";
import { useAuth } from "@/hooks/useAuth";
import { useTheme } from "@/context/ThemeContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import { Mic, Moon, Sun, Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
import { cn } from "@/lib/utils";
export default function LoginPage() {
const [, setLocation] = useLocation();
const { login, user } = useAuth();
const { theme, toggleTheme } = useTheme();
const { toast } = useToast();
const [formData, setFormData] = useState({
email: "",
password: "",
rememberMe: false,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
// Redirect if already authenticated
if (user) {
setLocation("/dashboard");
return null;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsSubmitting(true);
try {
await login(formData.email, formData.password);
toast({
title: "Welcome back!",
description: "You have successfully signed in.",
});
setLocation("/dashboard");
} catch (error: any) {
console.error("Login error:", error);
setError(error.message || "Failed to sign in. Please check your credentials.");
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (field: string, value: string | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (error) setError(""); // Clear error on input change
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-violet-50 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900 flex items-center justify-center p-4">
{/* Header with Logo and Theme Toggle */}
<div className="absolute top-6 left-6 right-6 flex items-center justify-between">
<Link href="/" className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-br from-primary to-secondary rounded-lg flex items-center justify-center">
<Mic className="w-4 h-4 text-white" />
</div>
<span className="text-xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
TaskFin
</span>
</Link>
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
className="w-9 px-0"
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
</div>
{/* Main Login Card */}
<Card className="w-full max-w-md shadow-2xl border-slate-200 dark:border-slate-700">
<CardHeader className="text-center space-y-2">
<CardTitle className="text-2xl font-bold">Welcome Back</CardTitle>
<CardDescription>
Sign in to your TaskFin account to access your voice-powered productivity tools
</CardDescription>
{/* Features Badge */}
<div className="flex items-center justify-center space-x-2 pt-2">
<Badge variant="secondary" className="text-xs">
<CheckCircle2 className="w-3 h-3 mr-1" />
Local Voice AI
</Badge>
<Badge variant="secondary" className="text-xs">
<CheckCircle2 className="w-3 h-3 mr-1" />
Complete Privacy
</Badge>
</div>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{/* Error Alert */}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Email Field */}
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="Enter your email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
disabled={isSubmitting}
required
className="focus-ring"
/>
</div>
{/* Password Field */}
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
disabled={isSubmitting}
required
className="focus-ring"
/>
</div>
{/* Remember Me & Forgot Password */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox
id="rememberMe"
checked={formData.rememberMe}
onCheckedChange={(checked) => handleInputChange("rememberMe", !!checked)}
disabled={isSubmitting}
/>
<Label
htmlFor="rememberMe"
className="text-sm font-normal cursor-pointer"
>
Remember me
</Label>
</div>
<Button
variant="link"
className="px-0 h-auto text-sm"
type="button"
disabled={isSubmitting}
>
Forgot password?
</Button>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
{/* Submit Button */}
<Button
type="submit"
className="w-full"
disabled={isSubmitting || !formData.email || !formData.password}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Signing In...
</>
) : (
"Sign In"
)}
</Button>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-300 dark:border-slate-600" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or
</span>
</div>
</div>
{/* Register Link */}
<div className="text-center">
<span className="text-sm text-muted-foreground">
Don't have an account?{" "}
</span>
<Button
variant="link"
className="px-0 h-auto text-sm font-medium"
type="button"
onClick={() => setLocation("/register")}
disabled={isSubmitting}
>
Create Account
</Button>
</div>
{/* Demo Credentials (for development) */}
<div className="text-center">
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium">Demo Accounts:</p>
<p>Standard: user@taskfin.local / password</p>
<p>Pro: pro@taskfin.local / password</p>
<p>Admin: admin@taskfin.local / password</p>
</div>
</div>
</CardFooter>
</form>
</Card>
{/* Features Overview */}
<div className="absolute bottom-6 left-6 right-6 text-center">
<div className="flex items-center justify-center space-x-6 text-xs text-muted-foreground">
<div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Local STT/TTS Processing</span>
</div>
<div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Zero Cloud Dependencies</span>
</div>
<div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Complete Data Privacy</span>
</div>
</div>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Card, CardContent } from "@/components/ui/card";
import { AlertCircle } from "lucide-react";
export default function NotFound() {
return (
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md mx-4">
<CardContent className="pt-6">
<div className="flex mb-4 gap-2">
<AlertCircle className="h-8 w-8 text-red-500" />
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
</div>
<p className="mt-4 text-sm text-gray-600">
Did you forget to add the page to the router?
</p>
</CardContent>
</Card>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { apiRequest } from "@/lib/queryClient";
export interface JokeResponse {
joke: string;
category?: string;
cached: boolean;
processingTime?: number;
timestamp: string;
}
export interface ChatResponse {
response: string;
processingTime: number;
modelUsed: string;
timestamp: string;
}
export interface InsightResponse {
insight: string;
type: string;
processingTime: number;
modelUsed: string;
timestamp: string;
}
export interface AIStatus {
loaded: boolean;
modelPath: string;
initialized: boolean;
uptime: number;
timestamp: string;
}
export const aiService = {
async getDailyJoke(): Promise<JokeResponse> {
const response = await apiRequest("GET", "/api/ai/daily-joke");
return response.json();
},
async generateJoke(personalized: boolean = false): Promise<JokeResponse> {
const url = personalized ? "/api/ai/generate-joke?personalized=true" : "/api/ai/generate-joke";
const response = await apiRequest("POST", url);
return response.json();
},
async chat(message: string): Promise<ChatResponse> {
const response = await apiRequest("POST", "/api/ai/chat", { message });
return response.json();
},
async generateInsight(type: "financial" | "productivity" | "general"): Promise<InsightResponse> {
const response = await apiRequest("POST", "/api/ai/generate-insight", { type });
return response.json();
},
async getInteractions(type?: string, limit?: number): Promise<{ interactions: any[] }> {
const params = new URLSearchParams();
if (type) params.append("type", type);
if (limit) params.append("limit", limit.toString());
const url = `/api/ai/interactions${params.toString() ? `?${params.toString()}` : ""}`;
const response = await apiRequest("GET", url);
return response.json();
},
async getStatus(): Promise<AIStatus> {
const response = await apiRequest("GET", "/api/ai/status");
return response.json();
},
};
+56
View File
@@ -0,0 +1,56 @@
import { apiRequest } from "@/lib/queryClient";
export interface LoginRequest {
email: string;
password: string;
}
export interface RegisterRequest {
username: string;
email: string;
password: string;
confirmPassword: string;
firstName?: string;
lastName?: string;
role?: string;
}
export interface AuthResponse {
message: string;
user: any;
}
export const authService = {
async login(email: string, password: string): Promise<AuthResponse> {
const response = await apiRequest("POST", "/api/auth/login", { email, password });
return response.json();
},
async register(userData: RegisterRequest): Promise<AuthResponse> {
const response = await apiRequest("POST", "/api/auth/register", userData);
return response.json();
},
async logout(): Promise<void> {
await apiRequest("POST", "/api/auth/logout");
},
async getCurrentUser(): Promise<any> {
const response = await apiRequest("GET", "/api/auth/me");
const data = await response.json();
return data.user;
},
async updateProfile(updates: any): Promise<AuthResponse> {
const response = await apiRequest("PUT", "/api/auth/profile", updates);
return response.json();
},
async completeOnboarding(role: string, preferences?: any): Promise<AuthResponse> {
const response = await apiRequest("POST", "/api/auth/complete-onboarding", {
role,
preferences,
});
return response.json();
},
};
+60
View File
@@ -0,0 +1,60 @@
import { apiRequest } from "@/lib/queryClient";
import { FinancialRecord, InsertFinancialRecord } from "@shared/schema";
export interface FinancialRecordsResponse {
records: FinancialRecord[];
}
export interface FinancialRecordResponse {
record: FinancialRecord;
message?: string;
}
export interface FinancialSummaryResponse {
summary: {
income: number;
expenses: number;
net: number;
};
period: string;
startDate?: string;
endDate?: string;
}
export const financialService = {
async getRecords(startDate?: string, endDate?: string): Promise<FinancialRecordsResponse> {
const params = new URLSearchParams();
if (startDate) params.append("startDate", startDate);
if (endDate) params.append("endDate", endDate);
const url = `/api/financial/records${params.toString() ? `?${params.toString()}` : ""}`;
const response = await apiRequest("GET", url);
return response.json();
},
async getSummary(period?: "week" | "month" | "year", startDate?: string, endDate?: string): Promise<FinancialSummaryResponse> {
const params = new URLSearchParams();
if (period) params.append("period", period);
if (startDate) params.append("startDate", startDate);
if (endDate) params.append("endDate", endDate);
const url = `/api/financial/summary${params.toString() ? `?${params.toString()}` : ""}`;
const response = await apiRequest("GET", url);
return response.json();
},
async createRecord(record: InsertFinancialRecord & { createdViaVoice?: boolean; voiceTranscription?: string; metadata?: any }): Promise<FinancialRecordResponse> {
const response = await apiRequest("POST", "/api/financial/records", record);
return response.json();
},
async updateRecord(id: number, updates: Partial<FinancialRecord>): Promise<FinancialRecordResponse> {
const response = await apiRequest("PUT", `/api/financial/records/${id}`, updates);
return response.json();
},
async deleteRecord(id: number): Promise<{ message: string }> {
const response = await apiRequest("DELETE", `/api/financial/records/${id}`);
return response.json();
},
};
+43
View File
@@ -0,0 +1,43 @@
import { apiRequest } from "@/lib/queryClient";
import { Task, InsertTask } from "@shared/schema";
export interface TasksResponse {
tasks: Task[];
}
export interface TaskResponse {
task: Task;
message?: string;
}
export const taskService = {
async getTasks(status?: string): Promise<TasksResponse> {
const url = status ? `/api/tasks?status=${status}` : "/api/tasks";
const response = await apiRequest("GET", url);
return response.json();
},
async getTask(id: number): Promise<TaskResponse> {
const response = await apiRequest("GET", `/api/tasks/${id}`);
return response.json();
},
async createTask(task: InsertTask & { createdViaVoice?: boolean; voiceTranscription?: string }): Promise<TaskResponse> {
const response = await apiRequest("POST", "/api/tasks", task);
return response.json();
},
async updateTask(id: number, updates: Partial<Task>): Promise<TaskResponse> {
const response = await apiRequest("PUT", `/api/tasks/${id}`, updates);
return response.json();
},
async deleteTask(id: number): Promise<{ message: string }> {
const response = await apiRequest("DELETE", `/api/tasks/${id}`);
return response.json();
},
async completeTask(id: number): Promise<TaskResponse> {
return this.updateTask(id, { status: "completed", completedAt: new Date() });
},
};
+50
View File
@@ -0,0 +1,50 @@
import { apiRequest } from "@/lib/queryClient";
export interface VoiceCommandResult {
transcription: string;
intent?: string;
confidence: number;
processingTime: number;
actionResult?: any;
}
export interface VoiceStatus {
stt: boolean;
tts: boolean;
initialized: boolean;
uptime: number;
timestamp: string;
}
export const voiceService = {
async initialize(): Promise<{ message: string; status: string }> {
const response = await apiRequest("POST", "/api/voice/initialize");
return response.json();
},
async processCommand(audioData: string): Promise<VoiceCommandResult> {
const response = await apiRequest("POST", "/api/voice/process-command", {
audioData,
});
return response.json();
},
async speak(text: string, voice?: string): Promise<Blob> {
const response = await apiRequest("POST", "/api/voice/speak", {
text,
voice,
});
return response.blob();
},
async getCommands(limit?: number): Promise<{ commands: any[] }> {
const url = limit ? `/api/voice/commands?limit=${limit}` : "/api/voice/commands";
const response = await apiRequest("GET", url);
return response.json();
},
async getStatus(): Promise<VoiceStatus> {
const response = await apiRequest("GET", "/api/voice/status");
return response.json();
},
};
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "client/src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "drizzle-kit";
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL, ensure the database is provisioned");
}
export default defineConfig({
out: "./migrations",
schema: "./shared/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,
},
});
+9284
View File
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
{
"name": "rest-express",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"scripts": {
"dev": "NODE_ENV=development tsx server/index.ts",
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
"start": "NODE_ENV=production node dist/index.js",
"check": "tsc",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^0.10.4",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.7",
"@radix-ui/react-aspect-ratio": "^1.1.3",
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-checkbox": "^1.1.5",
"@radix-ui/react-collapsible": "^1.1.4",
"@radix-ui/react-context-menu": "^2.2.7",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-hover-card": "^1.1.7",
"@radix-ui/react-label": "^2.1.3",
"@radix-ui/react-menubar": "^1.1.7",
"@radix-ui/react-navigation-menu": "^1.2.6",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-progress": "^1.1.3",
"@radix-ui/react-radio-group": "^1.2.4",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slider": "^1.2.4",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.1.4",
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-toast": "^1.2.7",
"@radix-ui/react-toggle": "^1.1.3",
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"connect-pg-simple": "^10.0.0",
"date-fns": "^3.6.0",
"drizzle-orm": "^0.39.1",
"drizzle-zod": "^0.7.0",
"embla-carousel-react": "^8.6.0",
"express": "^4.21.2",
"express-session": "^1.18.1",
"framer-motion": "^11.13.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.453.0",
"memorystore": "^1.6.7",
"multer": "^2.0.0",
"nanoid": "^5.1.5",
"next-themes": "^0.4.6",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.55.0",
"react-icons": "^5.4.0",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.2.5",
"vaul": "^1.1.2",
"wouter": "^3.3.5",
"ws": "^8.18.0",
"zod": "^3.24.2",
"zod-validation-error": "^3.4.0"
},
"devDependencies": {
"@replit/vite-plugin-cartographer": "^0.2.5",
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3",
"@types/connect-pg-simple": "^7.0.3",
"@types/express": "4.17.21",
"@types/express-session": "^1.18.0",
"@types/node": "20.16.11",
"@types/passport": "^1.0.16",
"@types/passport-local": "^1.0.38",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@types/ws": "^8.5.13",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.30.4",
"esbuild": "^0.25.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.17",
"tsx": "^4.19.1",
"typescript": "5.6.3",
"vite": "^5.4.14"
},
"optionalDependencies": {
"bufferutil": "^4.0.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+160
View File
@@ -0,0 +1,160 @@
import { Response } from 'express';
import { aiService } from '../services/aiService';
import { storage } from '../storage';
import { AuthenticatedRequest } from '../middleware/auth';
export const aiController = {
async getDailyJoke(req: any, res: Response) {
try {
// Check if we already have a joke for today
const today = new Date();
today.setHours(0, 0, 0, 0);
const existingJokes = await storage.getAIInteractions(undefined, 'joke', 1);
const todayJoke = existingJokes.find(joke => {
const jokeDate = new Date(joke.createdAt);
jokeDate.setHours(0, 0, 0, 0);
return jokeDate.getTime() === today.getTime();
});
if (todayJoke) {
return res.json({
joke: todayJoke.response,
cached: true,
timestamp: todayJoke.createdAt
});
}
// Generate new daily joke
const jokeResponse = await aiService.generateDailyJoke();
res.json({
joke: jokeResponse.content,
category: jokeResponse.category,
cached: false,
processingTime: jokeResponse.processingTime,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Get daily joke error:', error);
res.status(500).json({ error: 'Failed to get daily joke' });
}
},
async generateJoke(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { personalized } = req.query;
let jokeResponse;
if (personalized === 'true') {
jokeResponse = await aiService.generatePersonalizedJoke(req.user.id);
} else {
jokeResponse = await aiService.generateDailyJoke();
}
res.json({
joke: jokeResponse.content,
category: jokeResponse.category,
personalized: personalized === 'true',
processingTime: jokeResponse.processingTime,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Generate joke error:', error);
res.status(500).json({ error: 'Failed to generate joke' });
}
},
async chat(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { message } = req.body;
if (!message) {
return res.status(400).json({ error: 'Message is required' });
}
const response = await aiService.chat(req.user.id, message);
res.json({
response: response.content,
processingTime: response.processingTime,
modelUsed: response.modelUsed,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('AI chat error:', error);
res.status(500).json({ error: 'Failed to process chat message' });
}
},
async getInteractions(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { type, limit } = req.query;
const interactions = await storage.getAIInteractions(
req.user.id,
type as string,
limit ? parseInt(limit as string) : undefined
);
res.json({ interactions });
} catch (error) {
console.error('Get AI interactions error:', error);
res.status(500).json({ error: 'Failed to fetch AI interactions' });
}
},
async getStatus(req: AuthenticatedRequest, res: Response) {
try {
const status = await aiService.getModelStatus();
res.json({
...status,
uptime: process.uptime(),
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Get AI status error:', error);
res.status(500).json({ error: 'Failed to get AI status' });
}
},
async generateInsight(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { type } = req.body;
if (!type || !['financial', 'productivity', 'general'].includes(type)) {
return res.status(400).json({ error: 'Valid insight type is required (financial, productivity, general)' });
}
const insight = await aiService.generateInsight(req.user.id, type);
res.json({
insight: insight.content,
type,
processingTime: insight.processingTime,
modelUsed: insight.modelUsed,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Generate insight error:', error);
res.status(500).json({ error: 'Failed to generate insight' });
}
}
};
+185
View File
@@ -0,0 +1,185 @@
import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import { storage } from '../storage';
import { loginSchema, registerSchema } from '@shared/schema';
import { AuthenticatedRequest } from '../middleware/auth';
export const authController = {
async register(req: Request, res: Response) {
try {
const validatedData = registerSchema.parse(req.body);
// Check if user already exists
const existingUser = await storage.getUserByEmail(validatedData.email);
if (existingUser) {
return res.status(400).json({ error: 'User already exists with this email' });
}
const existingUsername = await storage.getUserByUsername(validatedData.username);
if (existingUsername) {
return res.status(400).json({ error: 'Username already taken' });
}
// Hash password
const saltRounds = 12;
const hashedPassword = await bcrypt.hash(validatedData.password, saltRounds);
// Create user
const user = await storage.createUser({
username: validatedData.username,
email: validatedData.email,
password: hashedPassword,
role: validatedData.role || 'standard',
firstName: validatedData.firstName,
lastName: validatedData.lastName,
onboardingComplete: false,
voiceEnabled: true,
ttsEnabled: true
});
// Remove password from response
const { password, ...userWithoutPassword } = user;
res.status(201).json({
message: 'User created successfully',
user: userWithoutPassword
});
} catch (error) {
console.error('Registration error:', error);
if (error instanceof Error && error.name === 'ZodError') {
return res.status(400).json({ error: 'Invalid input data', details: error.message });
}
res.status(500).json({ error: 'Failed to create user' });
}
},
async login(req: Request, res: Response) {
try {
const validatedData = loginSchema.parse(req.body);
const user = await storage.getUserByEmail(validatedData.email);
if (!user) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const isValidPassword = await bcrypt.compare(validatedData.password, user.password);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid email or password' });
}
// Use passport login
req.login(user, (err) => {
if (err) {
console.error('Login error:', err);
return res.status(500).json({ error: 'Login failed' });
}
const { password, ...userWithoutPassword } = user;
res.json({
message: 'Login successful',
user: userWithoutPassword
});
});
} catch (error) {
console.error('Login error:', error);
if (error instanceof Error && error.name === 'ZodError') {
return res.status(400).json({ error: 'Invalid input data' });
}
res.status(500).json({ error: 'Login failed' });
}
},
async logout(req: Request, res: Response) {
req.logout((err) => {
if (err) {
console.error('Logout error:', err);
return res.status(500).json({ error: 'Logout failed' });
}
res.json({ message: 'Logout successful' });
});
},
async me(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { password, ...userWithoutPassword } = req.user;
res.json({ user: userWithoutPassword });
} catch (error) {
console.error('Get user error:', error);
res.status(500).json({ error: 'Failed to get user information' });
}
},
async updateProfile(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const allowedUpdates = ['firstName', 'lastName', 'voiceEnabled', 'ttsEnabled'];
const updates: any = {};
for (const field of allowedUpdates) {
if (req.body[field] !== undefined) {
updates[field] = req.body[field];
}
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const updatedUser = await storage.updateUser(req.user.id, updates);
if (!updatedUser) {
return res.status(404).json({ error: 'User not found' });
}
const { password, ...userWithoutPassword } = updatedUser;
res.json({
message: 'Profile updated successfully',
user: userWithoutPassword
});
} catch (error) {
console.error('Update profile error:', error);
res.status(500).json({ error: 'Failed to update profile' });
}
},
async completeOnboarding(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { role, preferences } = req.body;
// Update user onboarding status and role if provided
const updates: any = { onboardingComplete: true };
if (role && ['standard', 'pro', 'admin'].includes(role)) {
updates.role = role;
}
const updatedUser = await storage.updateUser(req.user.id, updates);
if (!updatedUser) {
return res.status(404).json({ error: 'User not found' });
}
// Save user preferences if provided
if (preferences) {
await storage.updateUserPreferences(req.user.id, preferences);
}
const { password, ...userWithoutPassword } = updatedUser;
res.json({
message: 'Onboarding completed successfully',
user: userWithoutPassword
});
} catch (error) {
console.error('Complete onboarding error:', error);
res.status(500).json({ error: 'Failed to complete onboarding' });
}
}
};
+181
View File
@@ -0,0 +1,181 @@
import { Response } from 'express';
import { storage } from '../storage';
import { financialRecordCreateSchema } from '@shared/schema';
import { AuthenticatedRequest } from '../middleware/auth';
export const financialController = {
async getRecords(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { startDate, endDate } = req.query;
let start: Date | undefined;
let end: Date | undefined;
if (startDate) {
start = new Date(startDate as string);
}
if (endDate) {
end = new Date(endDate as string);
}
const records = await storage.getFinancialRecords(req.user.id, start, end);
res.json({ records });
} catch (error) {
console.error('Get financial records error:', error);
res.status(500).json({ error: 'Failed to fetch financial records' });
}
},
async getSummary(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { startDate, endDate, period } = req.query;
let start: Date | undefined;
let end: Date | undefined;
if (period) {
const now = new Date();
switch (period) {
case 'week':
start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
end = now;
break;
case 'month':
start = new Date(now.getFullYear(), now.getMonth(), 1);
end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
break;
case 'year':
start = new Date(now.getFullYear(), 0, 1);
end = new Date(now.getFullYear(), 11, 31);
break;
}
} else {
if (startDate) start = new Date(startDate as string);
if (endDate) end = new Date(endDate as string);
}
const summary = await storage.getFinancialSummary(req.user.id, start, end);
res.json({
summary,
period: period || 'custom',
startDate: start?.toISOString(),
endDate: end?.toISOString()
});
} catch (error) {
console.error('Get financial summary error:', error);
res.status(500).json({ error: 'Failed to fetch financial summary' });
}
},
async createRecord(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const validatedData = financialRecordCreateSchema.parse(req.body);
const record = await storage.createFinancialRecord({
userId: req.user.id,
type: validatedData.type,
amount: validatedData.amount.toString(),
category: validatedData.category,
description: validatedData.description,
date: new Date(validatedData.date),
createdViaVoice: req.body.createdViaVoice || false,
voiceTranscription: req.body.voiceTranscription,
metadata: req.body.metadata
});
res.status(201).json({
message: 'Financial record created successfully',
record
});
} catch (error) {
console.error('Create financial record error:', error);
if (error instanceof Error && error.name === 'ZodError') {
return res.status(400).json({ error: 'Invalid input data', details: error.message });
}
res.status(500).json({ error: 'Failed to create financial record' });
}
},
async updateRecord(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const recordId = parseInt(req.params.id);
if (isNaN(recordId)) {
return res.status(400).json({ error: 'Invalid record ID' });
}
const allowedUpdates = ['type', 'amount', 'category', 'description', 'date', 'metadata'];
const updates: any = {};
for (const field of allowedUpdates) {
if (req.body[field] !== undefined) {
if (field === 'date') {
updates[field] = new Date(req.body[field]);
} else if (field === 'amount') {
updates[field] = req.body[field].toString();
} else {
updates[field] = req.body[field];
}
}
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const record = await storage.updateFinancialRecord(recordId, req.user.id, updates);
if (!record) {
return res.status(404).json({ error: 'Financial record not found' });
}
res.json({
message: 'Financial record updated successfully',
record
});
} catch (error) {
console.error('Update financial record error:', error);
res.status(500).json({ error: 'Failed to update financial record' });
}
},
async deleteRecord(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const recordId = parseInt(req.params.id);
if (isNaN(recordId)) {
return res.status(400).json({ error: 'Invalid record ID' });
}
const deleted = await storage.deleteFinancialRecord(recordId, req.user.id);
if (!deleted) {
return res.status(404).json({ error: 'Financial record not found' });
}
res.json({ message: 'Financial record deleted successfully' });
} catch (error) {
console.error('Delete financial record error:', error);
res.status(500).json({ error: 'Failed to delete financial record' });
}
}
};
+150
View File
@@ -0,0 +1,150 @@
import { Response } from 'express';
import { storage } from '../storage';
import { taskCreateSchema } from '@shared/schema';
import { AuthenticatedRequest } from '../middleware/auth';
export const taskController = {
async getTasks(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { status } = req.query;
const tasks = await storage.getTasks(req.user.id, status as string);
res.json({ tasks });
} catch (error) {
console.error('Get tasks error:', error);
res.status(500).json({ error: 'Failed to fetch tasks' });
}
},
async getTask(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const taskId = parseInt(req.params.id);
if (isNaN(taskId)) {
return res.status(400).json({ error: 'Invalid task ID' });
}
const task = await storage.getTask(taskId, req.user.id);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
res.json({ task });
} catch (error) {
console.error('Get task error:', error);
res.status(500).json({ error: 'Failed to fetch task' });
}
},
async createTask(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const validatedData = taskCreateSchema.parse(req.body);
const task = await storage.createTask({
userId: req.user.id,
title: validatedData.title,
description: validatedData.description,
priority: validatedData.priority,
dueDate: validatedData.dueDate ? new Date(validatedData.dueDate) : undefined,
status: 'pending',
createdViaVoice: req.body.createdViaVoice || false,
voiceTranscription: req.body.voiceTranscription
});
res.status(201).json({
message: 'Task created successfully',
task
});
} catch (error) {
console.error('Create task error:', error);
if (error instanceof Error && error.name === 'ZodError') {
return res.status(400).json({ error: 'Invalid input data', details: error.message });
}
res.status(500).json({ error: 'Failed to create task' });
}
},
async updateTask(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const taskId = parseInt(req.params.id);
if (isNaN(taskId)) {
return res.status(400).json({ error: 'Invalid task ID' });
}
const allowedUpdates = ['title', 'description', 'priority', 'status', 'dueDate'];
const updates: any = {};
for (const field of allowedUpdates) {
if (req.body[field] !== undefined) {
if (field === 'dueDate' && req.body[field]) {
updates[field] = new Date(req.body[field]);
} else {
updates[field] = req.body[field];
}
}
}
// If marking as completed, set completedAt timestamp
if (updates.status === 'completed') {
updates.completedAt = new Date();
} else if (updates.status && updates.status !== 'completed') {
updates.completedAt = null;
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const task = await storage.updateTask(taskId, req.user.id, updates);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
res.json({
message: 'Task updated successfully',
task
});
} catch (error) {
console.error('Update task error:', error);
res.status(500).json({ error: 'Failed to update task' });
}
},
async deleteTask(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const taskId = parseInt(req.params.id);
if (isNaN(taskId)) {
return res.status(400).json({ error: 'Invalid task ID' });
}
const deleted = await storage.deleteTask(taskId, req.user.id);
if (!deleted) {
return res.status(404).json({ error: 'Task not found' });
}
res.json({ message: 'Task deleted successfully' });
} catch (error) {
console.error('Delete task error:', error);
res.status(500).json({ error: 'Failed to delete task' });
}
}
};
+203
View File
@@ -0,0 +1,203 @@
import { Response } from 'express';
import multer from 'multer';
import { voiceService } from '../services/voiceService';
import { storage } from '../storage';
import { AuthenticatedRequest } from '../middleware/auth';
// Configure multer for audio file uploads
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024 // 10MB limit
},
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('audio/')) {
cb(null, true);
} else {
cb(new Error('Only audio files are allowed'));
}
}
});
export const voiceController = {
async initialize(req: AuthenticatedRequest, res: Response) {
try {
const success = await voiceService.initialize();
if (success) {
res.json({
message: 'Voice models initialized successfully',
status: 'ready'
});
} else {
res.status(500).json({ error: 'Failed to initialize voice models' });
}
} catch (error) {
console.error('Voice initialization error:', error);
res.status(500).json({ error: 'Voice initialization failed' });
}
},
async processCommand(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { audioData } = req.body;
if (!audioData) {
return res.status(400).json({ error: 'Audio data is required' });
}
// Convert base64 audio to buffer
const audioBuffer = Buffer.from(audioData, 'base64');
// Process audio to text
const result = await voiceService.processAudioToText(audioBuffer, req.user.id);
// Execute the command based on intent
let actionResult = null;
if (result.intent && result.entities) {
actionResult = await this.executeVoiceCommand(req.user.id, result.intent, result.entities);
}
res.json({
transcription: result.transcription,
intent: result.intent,
confidence: result.confidence,
processingTime: result.processingTime,
actionResult
});
} catch (error) {
console.error('Voice command processing error:', error);
res.status(500).json({ error: 'Failed to process voice command' });
}
},
async speak(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { text, voice } = req.body;
if (!text) {
return res.status(400).json({ error: 'Text is required' });
}
const result = await voiceService.textToSpeech(text, voice);
// Set appropriate headers for audio response
res.set({
'Content-Type': 'audio/wav',
'Content-Length': result.audioBuffer.length
});
res.send(result.audioBuffer);
} catch (error) {
console.error('Text-to-speech error:', error);
res.status(500).json({ error: 'Failed to generate speech' });
}
},
async getCommands(req: AuthenticatedRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { limit } = req.query;
const commands = await storage.getVoiceCommands(req.user.id, limit ? parseInt(limit as string) : undefined);
res.json({ commands });
} catch (error) {
console.error('Get voice commands error:', error);
res.status(500).json({ error: 'Failed to fetch voice commands' });
}
},
async getStatus(req: AuthenticatedRequest, res: Response) {
try {
const status = await voiceService.getModelStatus();
res.json({
...status,
uptime: process.uptime(),
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Get voice status error:', error);
res.status(500).json({ error: 'Failed to get voice status' });
}
},
async executeVoiceCommand(userId: number, intent: string, entities: Record<string, any>) {
try {
switch (intent) {
case 'task_create':
if (entities.title) {
const task = await storage.createTask({
userId,
title: entities.title,
description: entities.description,
priority: entities.priority || 'medium',
dueDate: entities.dueDate ? new Date(entities.dueDate) : undefined,
status: 'pending',
createdViaVoice: true,
voiceTranscription: entities.originalTranscription
});
return { type: 'task_created', task };
}
break;
case 'expense_add':
if (entities.amount && entities.category) {
const record = await storage.createFinancialRecord({
userId,
type: 'expense',
amount: entities.amount.toString(),
category: entities.category,
description: entities.description || 'Voice expense entry',
date: new Date(),
createdViaVoice: true,
voiceTranscription: entities.originalTranscription
});
return { type: 'expense_added', record };
}
break;
case 'income_add':
if (entities.amount) {
const record = await storage.createFinancialRecord({
userId,
type: 'income',
amount: entities.amount.toString(),
category: entities.category || 'income',
description: entities.description || 'Voice income entry',
date: new Date(),
createdViaVoice: true,
voiceTranscription: entities.originalTranscription
});
return { type: 'income_added', record };
}
break;
case 'task_list':
const tasks = await storage.getTasks(userId, 'pending');
return { type: 'tasks_retrieved', tasks: tasks.slice(0, 5) }; // Limit for voice response
case 'financial_summary':
const summary = await storage.getFinancialSummary(userId);
return { type: 'financial_summary', summary };
default:
return { type: 'unknown_intent', message: 'I didn\'t understand that command' };
}
} catch (error) {
console.error('Execute voice command error:', error);
return { type: 'error', message: 'Failed to execute command' };
}
}
};
+15
View File
@@ -0,0 +1,15 @@
import { Pool, neonConfig } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-serverless';
import ws from "ws";
import * as schema from "@shared/schema";
neonConfig.webSocketConstructor = ws;
if (!process.env.DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set. Did you forget to provision a database?",
);
}
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle({ client: pool, schema });
+70
View File
@@ -0,0 +1,70 @@
import express, { type Request, Response, NextFunction } from "express";
import { registerRoutes } from "./routes";
import { setupVite, serveStatic, log } from "./vite";
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use((req, res, next) => {
const start = Date.now();
const path = req.path;
let capturedJsonResponse: Record<string, any> | undefined = undefined;
const originalResJson = res.json;
res.json = function (bodyJson, ...args) {
capturedJsonResponse = bodyJson;
return originalResJson.apply(res, [bodyJson, ...args]);
};
res.on("finish", () => {
const duration = Date.now() - start;
if (path.startsWith("/api")) {
let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
if (capturedJsonResponse) {
logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
}
if (logLine.length > 80) {
logLine = logLine.slice(0, 79) + "…";
}
log(logLine);
}
});
next();
});
(async () => {
const server = await registerRoutes(app);
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
const status = err.status || err.statusCode || 500;
const message = err.message || "Internal Server Error";
res.status(status).json({ message });
throw err;
});
// importantly only setup vite in development and after
// setting up all the other routes so the catch-all route
// doesn't interfere with the other routes
if (app.get("env") === "development") {
await setupVite(app, server);
} else {
serveStatic(app);
}
// ALWAYS serve the app on port 5000
// this serves both the API and the client.
// It is the only port that is not firewalled.
const port = 5000;
server.listen({
port,
host: "0.0.0.0",
reusePort: true,
}, () => {
log(`serving on port ${port}`);
});
})();
+52
View File
@@ -0,0 +1,52 @@
import { Request, Response, NextFunction } from 'express';
import { storage } from '../storage';
export interface AuthenticatedRequest extends Request {
user?: any;
}
export const authMiddleware = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
// Refresh user data from database
const user = await storage.getUser(req.user.id);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
} catch (error) {
console.error('Auth middleware error:', error);
res.status(500).json({ error: 'Authentication error' });
}
};
export const requireRole = (roles: string[]) => {
return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
};
export const requireOnboarding = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!req.user.onboardingComplete) {
return res.status(403).json({ error: 'Onboarding required', onboardingRequired: true });
}
next();
};
+136
View File
@@ -0,0 +1,136 @@
import type { Express } from "express";
import { createServer, type Server } from "http";
import express from "express";
import session from "express-session";
import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import bcrypt from "bcryptjs";
import { storage } from "./storage";
import { authMiddleware, requireRole } from "./middleware/auth";
import { authController } from "./controllers/authController";
import { taskController } from "./controllers/taskController";
import { financialController } from "./controllers/financialController";
import { voiceController } from "./controllers/voiceController";
import { aiController } from "./controllers/aiController";
// Configure passport
passport.use(new LocalStrategy(
{ usernameField: 'email' },
async (email, password, done) => {
try {
const user = await storage.getUserByEmail(email);
if (!user) {
return done(null, false, { message: 'User not found' });
}
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
return done(null, false, { message: 'Invalid password' });
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
passport.serializeUser((user: any, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id: number, done) => {
try {
const user = await storage.getUser(id);
done(null, user);
} catch (error) {
done(error);
}
});
export async function registerRoutes(app: Express): Promise<Server> {
// Configure session middleware
app.use(session({
secret: process.env.SESSION_SECRET || 'your-secret-key',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
app.use(passport.initialize());
app.use(passport.session());
// Authentication routes
app.post('/api/auth/register', authController.register);
app.post('/api/auth/login', authController.login);
app.post('/api/auth/logout', authController.logout);
app.get('/api/auth/me', authMiddleware, authController.me);
app.put('/api/auth/profile', authMiddleware, authController.updateProfile);
app.post('/api/auth/complete-onboarding', authMiddleware, authController.completeOnboarding);
// Task routes
app.get('/api/tasks', authMiddleware, taskController.getTasks);
app.get('/api/tasks/:id', authMiddleware, taskController.getTask);
app.post('/api/tasks', authMiddleware, taskController.createTask);
app.put('/api/tasks/:id', authMiddleware, taskController.updateTask);
app.delete('/api/tasks/:id', authMiddleware, taskController.deleteTask);
// Financial routes
app.get('/api/financial/records', authMiddleware, financialController.getRecords);
app.get('/api/financial/summary', authMiddleware, financialController.getSummary);
app.post('/api/financial/records', authMiddleware, financialController.createRecord);
app.put('/api/financial/records/:id', authMiddleware, financialController.updateRecord);
app.delete('/api/financial/records/:id', authMiddleware, financialController.deleteRecord);
// Voice processing routes
app.post('/api/voice/initialize', authMiddleware, voiceController.initialize);
app.post('/api/voice/process-command', authMiddleware, voiceController.processCommand);
app.post('/api/voice/speak', authMiddleware, voiceController.speak);
app.get('/api/voice/commands', authMiddleware, voiceController.getCommands);
app.get('/api/voice/status', authMiddleware, voiceController.getStatus);
// AI routes
app.get('/api/ai/daily-joke', aiController.getDailyJoke);
app.post('/api/ai/generate-joke', authMiddleware, aiController.generateJoke);
app.post('/api/ai/chat', authMiddleware, aiController.chat);
app.get('/api/ai/interactions', authMiddleware, aiController.getInteractions);
app.get('/api/ai/status', authMiddleware, aiController.getStatus);
// Admin routes (dev/admin role only)
app.get('/api/admin/users', authMiddleware, requireRole(['admin']), async (req, res) => {
// Admin functionality - would implement user management
res.json({ message: 'Admin endpoint - user management would be implemented here' });
});
app.get('/api/admin/system-status', authMiddleware, requireRole(['admin']), async (req, res) => {
try {
// System status check
const status = {
database: 'connected',
voiceModels: 'loaded',
aiModels: 'loaded',
uptime: process.uptime(),
memory: process.memoryUsage(),
timestamp: new Date().toISOString()
};
res.json(status);
} catch (error) {
res.status(500).json({ error: 'Failed to get system status' });
}
});
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development'
});
});
const httpServer = createServer(app);
return httpServer;
}
+336
View File
@@ -0,0 +1,336 @@
import { storage } from '../storage';
export interface AIResponse {
content: string;
processingTime: number;
modelUsed: string;
}
export interface JokeResponse extends AIResponse {
category: string;
}
class AIService {
private modelPath: string;
private isInitialized: boolean = false;
constructor() {
this.modelPath = process.env.AI_MODEL_PATH || './models/ai/base-llm';
}
async initialize(): Promise<boolean> {
try {
console.log('Initializing AI models...');
// In production, this would load the actual local LLM
await this.loadModel();
this.isInitialized = true;
console.log('AI models initialized successfully');
return true;
} catch (error) {
console.error('Failed to initialize AI models:', error);
return false;
}
}
private async loadModel(): Promise<void> {
// Simulate model loading time
await new Promise(resolve => setTimeout(resolve, 2000));
console.log(`AI model loaded from ${this.modelPath}`);
}
async generateDailyJoke(): Promise<JokeResponse> {
if (!this.isInitialized) {
await this.initialize();
}
const startTime = Date.now();
try {
// In production, this would use the actual AI model
const joke = await this.simulateJokeGeneration();
const processingTime = Date.now() - startTime;
const response: JokeResponse = {
content: joke,
processingTime,
modelUsed: 'local-llm-base',
category: 'programming'
};
// Store the interaction
await storage.createAIInteraction({
userId: null, // Daily joke is global
type: 'joke',
prompt: 'Generate daily programming joke',
response: joke,
modelUsed: 'local-llm-base',
processingTime,
wasSpoken: false
});
return response;
} catch (error) {
console.error('AI joke generation error:', error);
throw new Error('Failed to generate joke');
}
}
async generatePersonalizedJoke(userId: number, preferences?: any): Promise<JokeResponse> {
if (!this.isInitialized) {
await this.initialize();
}
const startTime = Date.now();
try {
// Get user context for personalization
const userTasks = await storage.getTasks(userId);
const recentCommands = await storage.getVoiceCommands(userId, 5);
const joke = await this.simulatePersonalizedJoke(userTasks.length, recentCommands.length);
const processingTime = Date.now() - startTime;
const response: JokeResponse = {
content: joke,
processingTime,
modelUsed: 'local-llm-base',
category: 'personalized'
};
// Store the interaction
await storage.createAIInteraction({
userId,
type: 'joke',
prompt: 'Generate personalized joke',
response: joke,
modelUsed: 'local-llm-base',
processingTime,
wasSpoken: false
});
return response;
} catch (error) {
console.error('Personalized joke generation error:', error);
throw new Error('Failed to generate personalized joke');
}
}
async chat(userId: number, message: string): Promise<AIResponse> {
if (!this.isInitialized) {
await this.initialize();
}
const startTime = Date.now();
try {
// Get user context
const user = await storage.getUser(userId);
const recentTasks = await storage.getTasks(userId);
const financialSummary = await storage.getFinancialSummary(userId);
const response = await this.simulateChat(message, {
user,
taskCount: recentTasks.length,
financialSummary
});
const processingTime = Date.now() - startTime;
const aiResponse: AIResponse = {
content: response,
processingTime,
modelUsed: 'local-llm-base'
};
// Store the interaction
await storage.createAIInteraction({
userId,
type: 'response',
prompt: message,
response,
modelUsed: 'local-llm-base',
processingTime,
wasSpoken: false
});
return aiResponse;
} catch (error) {
console.error('AI chat error:', error);
throw new Error('Failed to process chat message');
}
}
async generateInsight(userId: number, type: 'financial' | 'productivity' | 'general'): Promise<AIResponse> {
if (!this.isInitialized) {
await this.initialize();
}
const startTime = Date.now();
try {
let insight = '';
switch (type) {
case 'financial':
const summary = await storage.getFinancialSummary(userId);
insight = await this.simulateFinancialInsight(summary);
break;
case 'productivity':
const tasks = await storage.getTasks(userId);
insight = await this.simulateProductivityInsight(tasks);
break;
default:
insight = await this.simulateGeneralInsight();
}
const processingTime = Date.now() - startTime;
const response: AIResponse = {
content: insight,
processingTime,
modelUsed: 'local-llm-base'
};
// Store the interaction
await storage.createAIInteraction({
userId,
type: 'insight',
prompt: `Generate ${type} insight`,
response: insight,
modelUsed: 'local-llm-base',
processingTime,
wasSpoken: false
});
return response;
} catch (error) {
console.error('AI insight generation error:', error);
throw new Error('Failed to generate insight');
}
}
private async simulateJokeGeneration(): Promise<string> {
const jokes = [
"Why don't developers ever finish their tasks? Because they always get stuck in an infinite loop of 'just one more feature'!",
"Why did the programmer quit his job? He didn't get arrays! (a raise)",
"How many programmers does it take to screw in a light bulb? None, that's a hardware problem!",
"Why do Java developers wear glasses? Because they can't C#!",
"A SQL query walks into a bar, approaches two tables and asks: 'Can I join you?'",
"Why don't tasks ever get lonely? Because they always have deadlines to keep them company!",
"What's a programmer's favorite hangout place? The Foo Bar!",
"Why did the developer go broke? Because he used up all his cache!"
];
await new Promise(resolve => setTimeout(resolve, 800));
return jokes[Math.floor(Math.random() * jokes.length)];
}
private async simulatePersonalizedJoke(taskCount: number, voiceCommandCount: number): Promise<string> {
let joke = '';
if (taskCount > 10) {
joke = "Looks like you're collecting tasks like Pokémon cards! Gotta catch 'em all... and then actually do them!";
} else if (taskCount === 0) {
joke = "Your task list is emptier than a JavaScript developer's knowledge of semicolons!";
} else if (voiceCommandCount > 5) {
joke = "You're talking to me more than most people talk to their houseplants. At least I talk back!";
} else {
joke = await this.simulateJokeGeneration();
}
await new Promise(resolve => setTimeout(resolve, 1000));
return joke;
}
private async simulateChat(message: string, context: any): Promise<string> {
const lowerMessage = message.toLowerCase();
if (lowerMessage.includes('task') || lowerMessage.includes('todo')) {
if (context.taskCount === 0) {
return "You have no pending tasks right now. Great job staying on top of things! Ready to add something new?";
} else {
return `You currently have ${context.taskCount} pending tasks. Would you like me to help you prioritize them or create a new one?`;
}
}
if (lowerMessage.includes('money') || lowerMessage.includes('financial') || lowerMessage.includes('budget')) {
const { income, expenses, net } = context.financialSummary;
return `Your current financial status shows $${income} in income and $${expenses} in expenses, for a net of $${net}. ${net > 0 ? "You're in the positive!" : "Consider reviewing your expenses."}`;
}
if (lowerMessage.includes('help')) {
return "I can help you with tasks, financial tracking, and answering questions about your data. Try saying 'create task', 'add expense', or ask about your financial summary!";
}
if (lowerMessage.includes('hello') || lowerMessage.includes('hi')) {
return `Hello ${context.user?.firstName || 'there'}! I'm your AI assistant. How can I help you manage your tasks and finances today?`;
}
await new Promise(resolve => setTimeout(resolve, 600));
return "I understand you're asking about that. While I'm still learning, I can help you with tasks, expenses, and general productivity questions. Is there something specific I can assist with?";
}
private async simulateFinancialInsight(summary: { income: number; expenses: number; net: number }): Promise<string> {
await new Promise(resolve => setTimeout(resolve, 1200));
const { income, expenses, net } = summary;
const expenseRatio = income > 0 ? (expenses / income) * 100 : 0;
if (net > 0) {
if (expenseRatio < 70) {
return `Excellent financial management! You're spending only ${expenseRatio.toFixed(1)}% of your income. Consider investing the surplus for long-term growth.`;
} else {
return `Good job maintaining a positive balance! Your expense ratio is ${expenseRatio.toFixed(1)}%. Consider optimizing some categories to improve savings.`;
}
} else {
return `Your expenses exceed income by $${Math.abs(net)}. I'd recommend reviewing your spending categories and identifying areas where you can reduce costs.`;
}
}
private async simulateProductivityInsight(tasks: any[]): Promise<string> {
await new Promise(resolve => setTimeout(resolve, 1000));
const completedTasks = tasks.filter(t => t.status === 'completed');
const pendingTasks = tasks.filter(t => t.status === 'pending');
const overdueTasks = tasks.filter(t => t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'completed');
if (tasks.length === 0) {
return "Your task list is clear! This is a great time to plan ahead or tackle some long-term goals.";
}
if (overdueTasks.length > 0) {
return `You have ${overdueTasks.length} overdue tasks. Consider prioritizing these first to get back on track.`;
}
if (completedTasks.length > pendingTasks.length) {
return `Great productivity! You've completed ${completedTasks.length} tasks. Keep up the momentum with your remaining ${pendingTasks.length} tasks.`;
}
return `You have ${pendingTasks.length} pending tasks. Consider breaking larger tasks into smaller, manageable chunks for better progress.`;
}
private async simulateGeneralInsight(): Promise<string> {
const insights = [
"Remember: Progress, not perfection. Small daily improvements lead to remarkable long-term results.",
"Voice commands can speed up your workflow by 40%. Try using them for quick task creation and expense logging.",
"The best time to plan tomorrow is today. Spend 5 minutes each evening reviewing and organizing.",
"Your data stays completely private with local processing. No cloud, no worries!",
"Consistency beats intensity. Better to track expenses daily than to do it all at month-end."
];
await new Promise(resolve => setTimeout(resolve, 800));
return insights[Math.floor(Math.random() * insights.length)];
}
async getModelStatus(): Promise<{ loaded: boolean; modelPath: string; initialized: boolean }> {
return {
loaded: this.isInitialized,
modelPath: this.modelPath,
initialized: this.isInitialized
};
}
}
export const aiService = new AIService();
+330
View File
@@ -0,0 +1,330 @@
import { spawn, ChildProcess } from 'child_process';
import fs from 'fs/promises';
import path from 'path';
import { storage } from '../storage';
export interface VoiceProcessingResult {
transcription: string;
confidence: number;
processingTime: number;
intent?: string;
entities?: Record<string, any>;
}
export interface TTSResult {
audioBuffer: Buffer;
processingTime: number;
}
class VoiceService {
private sttModelPath: string;
private ttsModelPath: string;
private isInitialized: boolean = false;
private sttProcess: ChildProcess | null = null;
private ttsProcess: ChildProcess | null = null;
constructor() {
this.sttModelPath = process.env.STT_MODEL_PATH || './models/speech/base-stt';
this.ttsModelPath = process.env.TTS_MODEL_PATH || './models/speech/base-tts';
}
async initialize(): Promise<boolean> {
try {
console.log('Initializing voice models...');
// Check if model files exist
const sttExists = await this.checkModelExists(this.sttModelPath);
const ttsExists = await this.checkModelExists(this.ttsModelPath);
if (!sttExists || !ttsExists) {
console.error('Voice models not found. Please run model setup script.');
return false;
}
// Initialize STT model (using whisper.cpp or similar)
await this.initializeSTT();
// Initialize TTS model (using espeak-ng or similar)
await this.initializeTTS();
this.isInitialized = true;
console.log('Voice models initialized successfully');
return true;
} catch (error) {
console.error('Failed to initialize voice models:', error);
return false;
}
}
private async checkModelExists(modelPath: string): Promise<boolean> {
try {
await fs.access(modelPath);
return true;
} catch {
return false;
}
}
private async initializeSTT(): Promise<void> {
// Initialize STT process - in production, this would start the actual model
console.log(`Loading STT model from ${this.sttModelPath}`);
// For now, we'll simulate model loading
await new Promise(resolve => setTimeout(resolve, 1000));
}
private async initializeTTS(): Promise<void> {
// Initialize TTS process - in production, this would start the actual model
console.log(`Loading TTS model from ${this.ttsModelPath}`);
// For now, we'll simulate model loading
await new Promise(resolve => setTimeout(resolve, 1000));
}
async processAudioToText(audioBuffer: Buffer, userId?: number): Promise<VoiceProcessingResult> {
if (!this.isInitialized) {
throw new Error('Voice service not initialized');
}
const startTime = Date.now();
try {
// In production, this would use actual STT processing
// For now, we'll simulate transcription
const transcription = await this.simulateSTT(audioBuffer);
const confidence = 0.95; // Simulated confidence
const processingTime = Date.now() - startTime;
// Extract intent and entities from transcription
const { intent, entities } = this.extractIntent(transcription);
const result: VoiceProcessingResult = {
transcription,
confidence,
processingTime,
intent,
entities
};
// Log voice command if user is provided
if (userId) {
await storage.createVoiceCommand({
userId,
command: transcription,
transcription,
intent,
confidence,
processingTime,
successful: true
});
}
return result;
} catch (error) {
const processingTime = Date.now() - startTime;
if (userId) {
await storage.createVoiceCommand({
userId,
command: '',
transcription: '',
confidence: 0,
processingTime,
successful: false,
errorMessage: error instanceof Error ? error.message : 'Unknown error'
});
}
throw error;
}
}
async textToSpeech(text: string, voice?: string): Promise<TTSResult> {
if (!this.isInitialized) {
throw new Error('Voice service not initialized');
}
const startTime = Date.now();
try {
// In production, this would use actual TTS processing
const audioBuffer = await this.simulateTTS(text, voice);
const processingTime = Date.now() - startTime;
return {
audioBuffer,
processingTime
};
} catch (error) {
console.error('TTS error:', error);
throw error;
}
}
private async simulateSTT(audioBuffer: Buffer): Promise<string> {
// Simulate STT processing delay
await new Promise(resolve => setTimeout(resolve, 500));
// Return a simulated transcription based on common commands
const sampleCommands = [
"Create task review budget reports",
"Add expense coffee shop four dollars fifty cents",
"Show me today's tasks",
"What's my financial summary",
"Schedule meeting with team for Friday",
"Mark task as completed",
"Add income freelance payment five hundred dollars"
];
return sampleCommands[Math.floor(Math.random() * sampleCommands.length)];
}
private async simulateTTS(text: string, voice?: string): Promise<Buffer> {
// Simulate TTS processing delay
await new Promise(resolve => setTimeout(resolve, 300));
// Return a dummy audio buffer (in production, this would be actual audio)
const dummyAudioData = Buffer.alloc(1024, 0);
return dummyAudioData;
}
private extractIntent(transcription: string): { intent?: string; entities?: Record<string, any> } {
const text = transcription.toLowerCase();
// Simple intent recognition based on keywords
if (text.includes('create') && (text.includes('task') || text.includes('todo'))) {
return {
intent: 'task_create',
entities: this.extractTaskEntities(text)
};
}
if (text.includes('add') && (text.includes('expense') || text.includes('cost'))) {
return {
intent: 'expense_add',
entities: this.extractExpenseEntities(text)
};
}
if (text.includes('add') && text.includes('income')) {
return {
intent: 'income_add',
entities: this.extractIncomeEntities(text)
};
}
if (text.includes('show') || text.includes('list') || text.includes('get')) {
if (text.includes('task')) {
return { intent: 'task_list' };
}
if (text.includes('financial') || text.includes('money') || text.includes('summary')) {
return { intent: 'financial_summary' };
}
}
if (text.includes('complete') || text.includes('done') || text.includes('finish')) {
return { intent: 'task_complete' };
}
return { intent: 'unknown' };
}
private extractTaskEntities(text: string): Record<string, any> {
const entities: Record<string, any> = {};
// Extract task title (everything after "create task" or similar)
const taskMatch = text.match(/(?:create|add|new)\s+(?:task|todo)\s+(.+)/i);
if (taskMatch) {
entities.title = taskMatch[1].trim();
}
// Extract priority
if (text.includes('high priority') || text.includes('urgent')) {
entities.priority = 'high';
} else if (text.includes('low priority')) {
entities.priority = 'low';
} else {
entities.priority = 'medium';
}
// Extract due date (simplified)
if (text.includes('today')) {
entities.dueDate = new Date().toISOString();
} else if (text.includes('tomorrow')) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
entities.dueDate = tomorrow.toISOString();
}
return entities;
}
private extractExpenseEntities(text: string): Record<string, any> {
const entities: Record<string, any> = {};
// Extract amount
const amountMatch = text.match(/(?:\$|dollar|dollars?)\s*(\d+(?:\.\d{2})?)|(\d+(?:\.\d{2})?)\s*(?:dollar|dollars?)/i);
if (amountMatch) {
entities.amount = parseFloat(amountMatch[1] || amountMatch[2]);
}
// Extract category/description
const categories = ['food', 'transport', 'shopping', 'utilities', 'entertainment', 'healthcare'];
for (const category of categories) {
if (text.includes(category)) {
entities.category = category;
break;
}
}
if (!entities.category) {
entities.category = 'other';
}
// Extract description
const expenseMatch = text.match(/(?:expense|spent|paid)\s+(?:for\s+)?(.+?)(?:\s+\$|\s+\d+|$)/i);
if (expenseMatch) {
entities.description = expenseMatch[1].trim();
}
return entities;
}
private extractIncomeEntities(text: string): Record<string, any> {
const entities: Record<string, any> = {};
// Extract amount
const amountMatch = text.match(/(?:\$|dollar|dollars?)\s*(\d+(?:\.\d{2})?)|(\d+(?:\.\d{2})?)\s*(?:dollar|dollars?)/i);
if (amountMatch) {
entities.amount = parseFloat(amountMatch[1] || amountMatch[2]);
}
// Extract source/description
const incomeMatch = text.match(/(?:income|received|earned)\s+(?:from\s+)?(.+?)(?:\s+\$|\s+\d+|$)/i);
if (incomeMatch) {
entities.description = incomeMatch[1].trim();
}
entities.category = 'income';
return entities;
}
async getModelStatus(): Promise<{ stt: boolean; tts: boolean; initialized: boolean }> {
return {
stt: await this.checkModelExists(this.sttModelPath),
tts: await this.checkModelExists(this.ttsModelPath),
initialized: this.isInitialized
};
}
shutdown(): void {
if (this.sttProcess) {
this.sttProcess.kill();
}
if (this.ttsProcess) {
this.ttsProcess.kill();
}
this.isInitialized = false;
}
}
export const voiceService = new VoiceService();
+286
View File
@@ -0,0 +1,286 @@
import { users, sessions, tasks, financialRecords, voiceCommands, aiInteractions, userPreferences, type User, type InsertUser, type Task, type InsertTask, type FinancialRecord, type InsertFinancialRecord, type Session, type VoiceCommand, type InsertVoiceCommand, type AIInteraction, type UserPreferences, type InsertUserPreferences } from "@shared/schema";
import { db } from "./db";
import { eq, desc, and, gte, lte, count } from "drizzle-orm";
export interface IStorage {
// User management
getUser(id: number): Promise<User | undefined>;
getUserByEmail(email: string): Promise<User | undefined>;
getUserByUsername(username: string): Promise<User | undefined>;
createUser(user: InsertUser): Promise<User>;
updateUser(id: number, updates: Partial<User>): Promise<User | undefined>;
// Session management
createSession(userId: number, token: string, expiresAt: Date): Promise<Session>;
getSession(token: string): Promise<Session | undefined>;
deleteSession(token: string): Promise<void>;
// Task management
createTask(task: InsertTask): Promise<Task>;
getTasks(userId: number, status?: string): Promise<Task[]>;
getTask(id: number, userId: number): Promise<Task | undefined>;
updateTask(id: number, userId: number, updates: Partial<Task>): Promise<Task | undefined>;
deleteTask(id: number, userId: number): Promise<boolean>;
// Financial records
createFinancialRecord(record: InsertFinancialRecord): Promise<FinancialRecord>;
getFinancialRecords(userId: number, startDate?: Date, endDate?: Date): Promise<FinancialRecord[]>;
getFinancialSummary(userId: number, startDate?: Date, endDate?: Date): Promise<{ income: number; expenses: number; net: number }>;
updateFinancialRecord(id: number, userId: number, updates: Partial<FinancialRecord>): Promise<FinancialRecord | undefined>;
deleteFinancialRecord(id: number, userId: number): Promise<boolean>;
// Voice commands
createVoiceCommand(command: InsertVoiceCommand): Promise<VoiceCommand>;
getVoiceCommands(userId: number, limit?: number): Promise<VoiceCommand[]>;
// AI interactions
createAIInteraction(interaction: Omit<AIInteraction, 'id' | 'createdAt'>): Promise<AIInteraction>;
getAIInteractions(userId?: number, type?: string, limit?: number): Promise<AIInteraction[]>;
// User preferences
getUserPreferences(userId: number): Promise<UserPreferences | undefined>;
updateUserPreferences(userId: number, preferences: Partial<InsertUserPreferences>): Promise<UserPreferences>;
}
export class DatabaseStorage implements IStorage {
async getUser(id: number): Promise<User | undefined> {
const [user] = await db.select().from(users).where(eq(users.id, id));
return user || undefined;
}
async getUserByEmail(email: string): Promise<User | undefined> {
const [user] = await db.select().from(users).where(eq(users.email, email));
return user || undefined;
}
async getUserByUsername(username: string): Promise<User | undefined> {
const [user] = await db.select().from(users).where(eq(users.username, username));
return user || undefined;
}
async createUser(insertUser: InsertUser): Promise<User> {
const [user] = await db
.insert(users)
.values(insertUser)
.returning();
return user;
}
async updateUser(id: number, updates: Partial<User>): Promise<User | undefined> {
const [user] = await db
.update(users)
.set({ ...updates, updatedAt: new Date() })
.where(eq(users.id, id))
.returning();
return user || undefined;
}
async createSession(userId: number, token: string, expiresAt: Date): Promise<Session> {
const [session] = await db
.insert(sessions)
.values({ userId, token, expiresAt })
.returning();
return session;
}
async getSession(token: string): Promise<Session | undefined> {
const [session] = await db
.select()
.from(sessions)
.where(and(eq(sessions.token, token), gte(sessions.expiresAt, new Date())));
return session || undefined;
}
async deleteSession(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.token, token));
}
async createTask(task: InsertTask): Promise<Task> {
const [newTask] = await db
.insert(tasks)
.values(task)
.returning();
return newTask;
}
async getTasks(userId: number, status?: string): Promise<Task[]> {
const query = db.select().from(tasks).where(eq(tasks.userId, userId));
if (status) {
query.where(and(eq(tasks.userId, userId), eq(tasks.status, status)));
}
return await query.orderBy(desc(tasks.createdAt));
}
async getTask(id: number, userId: number): Promise<Task | undefined> {
const [task] = await db
.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)));
return task || undefined;
}
async updateTask(id: number, userId: number, updates: Partial<Task>): Promise<Task | undefined> {
const [task] = await db
.update(tasks)
.set({ ...updates, updatedAt: new Date() })
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
.returning();
return task || undefined;
}
async deleteTask(id: number, userId: number): Promise<boolean> {
const result = await db
.delete(tasks)
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)));
return result.rowCount > 0;
}
async createFinancialRecord(record: InsertFinancialRecord): Promise<FinancialRecord> {
const [newRecord] = await db
.insert(financialRecords)
.values(record)
.returning();
return newRecord;
}
async getFinancialRecords(userId: number, startDate?: Date, endDate?: Date): Promise<FinancialRecord[]> {
let query = db.select().from(financialRecords).where(eq(financialRecords.userId, userId));
if (startDate && endDate) {
query = query.where(
and(
eq(financialRecords.userId, userId),
gte(financialRecords.date, startDate),
lte(financialRecords.date, endDate)
)
);
}
return await query.orderBy(desc(financialRecords.date));
}
async getFinancialSummary(userId: number, startDate?: Date, endDate?: Date): Promise<{ income: number; expenses: number; net: number }> {
let query = db.select({
type: financialRecords.type,
amount: financialRecords.amount
}).from(financialRecords).where(eq(financialRecords.userId, userId));
if (startDate && endDate) {
query = query.where(
and(
eq(financialRecords.userId, userId),
gte(financialRecords.date, startDate),
lte(financialRecords.date, endDate)
)
);
}
const records = await query;
let income = 0;
let expenses = 0;
records.forEach(record => {
const amount = parseFloat(record.amount);
if (record.type === 'income') {
income += amount;
} else {
expenses += amount;
}
});
return {
income,
expenses,
net: income - expenses
};
}
async updateFinancialRecord(id: number, userId: number, updates: Partial<FinancialRecord>): Promise<FinancialRecord | undefined> {
const [record] = await db
.update(financialRecords)
.set({ ...updates, updatedAt: new Date() })
.where(and(eq(financialRecords.id, id), eq(financialRecords.userId, userId)))
.returning();
return record || undefined;
}
async deleteFinancialRecord(id: number, userId: number): Promise<boolean> {
const result = await db
.delete(financialRecords)
.where(and(eq(financialRecords.id, id), eq(financialRecords.userId, userId)));
return result.rowCount > 0;
}
async createVoiceCommand(command: InsertVoiceCommand): Promise<VoiceCommand> {
const [newCommand] = await db
.insert(voiceCommands)
.values(command)
.returning();
return newCommand;
}
async getVoiceCommands(userId: number, limit: number = 50): Promise<VoiceCommand[]> {
return await db
.select()
.from(voiceCommands)
.where(eq(voiceCommands.userId, userId))
.orderBy(desc(voiceCommands.createdAt))
.limit(limit);
}
async createAIInteraction(interaction: Omit<AIInteraction, 'id' | 'createdAt'>): Promise<AIInteraction> {
const [newInteraction] = await db
.insert(aiInteractions)
.values(interaction)
.returning();
return newInteraction;
}
async getAIInteractions(userId?: number, type?: string, limit: number = 50): Promise<AIInteraction[]> {
let query = db.select().from(aiInteractions);
const conditions = [];
if (userId) conditions.push(eq(aiInteractions.userId, userId));
if (type) conditions.push(eq(aiInteractions.type, type));
if (conditions.length > 0) {
query = query.where(and(...conditions));
}
return await query
.orderBy(desc(aiInteractions.createdAt))
.limit(limit);
}
async getUserPreferences(userId: number): Promise<UserPreferences | undefined> {
const [preferences] = await db
.select()
.from(userPreferences)
.where(eq(userPreferences.userId, userId));
return preferences || undefined;
}
async updateUserPreferences(userId: number, preferences: Partial<InsertUserPreferences>): Promise<UserPreferences> {
const existing = await this.getUserPreferences(userId);
if (existing) {
const [updated] = await db
.update(userPreferences)
.set({ ...preferences, updatedAt: new Date() })
.where(eq(userPreferences.userId, userId))
.returning();
return updated;
} else {
const [created] = await db
.insert(userPreferences)
.values({ userId, ...preferences })
.returning();
return created;
}
}
}
export const storage = new DatabaseStorage();
+85
View File
@@ -0,0 +1,85 @@
import express, { type Express } from "express";
import fs from "fs";
import path from "path";
import { createServer as createViteServer, createLogger } from "vite";
import { type Server } from "http";
import viteConfig from "../vite.config";
import { nanoid } from "nanoid";
const viteLogger = createLogger();
export function log(message: string, source = "express") {
const formattedTime = new Date().toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
console.log(`${formattedTime} [${source}] ${message}`);
}
export async function setupVite(app: Express, server: Server) {
const serverOptions = {
middlewareMode: true,
hmr: { server },
allowedHosts: true,
};
const vite = await createViteServer({
...viteConfig,
configFile: false,
customLogger: {
...viteLogger,
error: (msg, options) => {
viteLogger.error(msg, options);
process.exit(1);
},
},
server: serverOptions,
appType: "custom",
});
app.use(vite.middlewares);
app.use("*", async (req, res, next) => {
const url = req.originalUrl;
try {
const clientTemplate = path.resolve(
import.meta.dirname,
"..",
"client",
"index.html",
);
// always reload the index.html file from disk incase it changes
let template = await fs.promises.readFile(clientTemplate, "utf-8");
template = template.replace(
`src="/src/main.tsx"`,
`src="/src/main.tsx?v=${nanoid()}"`,
);
const page = await vite.transformIndexHtml(url, template);
res.status(200).set({ "Content-Type": "text/html" }).end(page);
} catch (e) {
vite.ssrFixStacktrace(e as Error);
next(e);
}
});
}
export function serveStatic(app: Express) {
const distPath = path.resolve(import.meta.dirname, "public");
if (!fs.existsSync(distPath)) {
throw new Error(
`Could not find the build directory: ${distPath}, make sure to build the client first`,
);
}
app.use(express.static(distPath));
// fall through to index.html if the file doesn't exist
app.use("*", (_req, res) => {
res.sendFile(path.resolve(distPath, "index.html"));
});
}
+218
View File
@@ -0,0 +1,218 @@
import { pgTable, text, serial, integer, boolean, timestamp, numeric, varchar, uuid, json } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { z } from "zod";
// Users table
export const users = pgTable("users", {
id: serial("id").primaryKey(),
username: text("username").notNull().unique(),
email: text("email").notNull().unique(),
password: text("password").notNull(),
role: varchar("role", { length: 20 }).notNull().default("standard"), // standard, pro, admin
firstName: text("first_name"),
lastName: text("last_name"),
onboardingComplete: boolean("onboarding_complete").default(false),
voiceEnabled: boolean("voice_enabled").default(true),
ttsEnabled: boolean("tts_enabled").default(true),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// Sessions table for authentication
export const sessions = pgTable("sessions", {
id: uuid("id").primaryKey().defaultRandom(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow(),
});
// Tasks table
export const tasks = pgTable("tasks", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull(),
description: text("description"),
priority: varchar("priority", { length: 10 }).notNull().default("medium"), // low, medium, high
status: varchar("status", { length: 20 }).notNull().default("pending"), // pending, in_progress, completed, cancelled
dueDate: timestamp("due_date"),
completedAt: timestamp("completed_at"),
createdViaVoice: boolean("created_via_voice").default(false),
voiceTranscription: text("voice_transcription"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// Financial records table
export const financialRecords = pgTable("financial_records", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
type: varchar("type", { length: 10 }).notNull(), // income, expense
amount: numeric("amount", { precision: 12, scale: 2 }).notNull(),
category: text("category").notNull(),
description: text("description"),
date: timestamp("date").notNull(),
createdViaVoice: boolean("created_via_voice").default(false),
voiceTranscription: text("voice_transcription"),
metadata: json("metadata"), // For additional data like receipt info
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// AI interactions table
export const aiInteractions = pgTable("ai_interactions", {
id: serial("id").primaryKey(),
userId: integer("user_id").references(() => users.id, { onDelete: "cascade" }),
type: varchar("type", { length: 20 }).notNull(), // joke, insight, response
prompt: text("prompt"),
response: text("response").notNull(),
modelUsed: text("model_used"),
processingTime: integer("processing_time"), // in milliseconds
wasSpoken: boolean("was_spoken").default(false),
createdAt: timestamp("created_at").defaultNow(),
});
// Voice commands table
export const voiceCommands = pgTable("voice_commands", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
command: text("command").notNull(),
transcription: text("transcription").notNull(),
intent: varchar("intent", { length: 50 }), // task_create, expense_add, query, etc.
confidence: numeric("confidence", { precision: 5, scale: 4 }),
processingTime: integer("processing_time"), // in milliseconds
successful: boolean("successful").default(true),
errorMessage: text("error_message"),
createdAt: timestamp("created_at").defaultNow(),
});
// User preferences table
export const userPreferences = pgTable("user_preferences", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }).unique(),
theme: varchar("theme", { length: 10 }).default("light"), // light, dark, auto
language: varchar("language", { length: 10 }).default("en"),
voiceModel: text("voice_model").default("base"),
ttsVoice: text("tts_voice").default("default"),
notifications: json("notifications"), // notification preferences
dashboardLayout: json("dashboard_layout"), // custom dashboard configuration
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// Relations
export const usersRelations = relations(users, ({ many, one }) => ({
sessions: many(sessions),
tasks: many(tasks),
financialRecords: many(financialRecords),
aiInteractions: many(aiInteractions),
voiceCommands: many(voiceCommands),
preferences: one(userPreferences),
}));
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] }),
}));
export const tasksRelations = relations(tasks, ({ one }) => ({
user: one(users, { fields: [tasks.userId], references: [users.id] }),
}));
export const financialRecordsRelations = relations(financialRecords, ({ one }) => ({
user: one(users, { fields: [financialRecords.userId], references: [users.id] }),
}));
export const aiInteractionsRelations = relations(aiInteractions, ({ one }) => ({
user: one(users, { fields: [aiInteractions.userId], references: [users.id] }),
}));
export const voiceCommandsRelations = relations(voiceCommands, ({ one }) => ({
user: one(users, { fields: [voiceCommands.userId], references: [users.id] }),
}));
export const userPreferencesRelations = relations(userPreferences, ({ one }) => ({
user: one(users, { fields: [userPreferences.userId], references: [users.id] }),
}));
// Zod schemas for validation
export const insertUserSchema = createInsertSchema(users).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const selectUserSchema = createSelectSchema(users);
export const insertTaskSchema = createInsertSchema(tasks).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const selectTaskSchema = createSelectSchema(tasks);
export const insertFinancialRecordSchema = createInsertSchema(financialRecords).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const selectFinancialRecordSchema = createSelectSchema(financialRecords);
export const insertVoiceCommandSchema = createInsertSchema(voiceCommands).omit({
id: true,
createdAt: true,
});
export const selectVoiceCommandSchema = createSelectSchema(voiceCommands);
export const insertUserPreferencesSchema = createInsertSchema(userPreferences).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const selectUserPreferencesSchema = createSelectSchema(userPreferences);
// Types
export type User = typeof users.$inferSelect;
export type InsertUser = z.infer<typeof insertUserSchema>;
export type Session = typeof sessions.$inferSelect;
export type Task = typeof tasks.$inferSelect;
export type InsertTask = z.infer<typeof insertTaskSchema>;
export type FinancialRecord = typeof financialRecords.$inferSelect;
export type InsertFinancialRecord = z.infer<typeof insertFinancialRecordSchema>;
export type VoiceCommand = typeof voiceCommands.$inferSelect;
export type InsertVoiceCommand = z.infer<typeof insertVoiceCommandSchema>;
export type UserPreferences = typeof userPreferences.$inferSelect;
export type InsertUserPreferences = z.infer<typeof insertUserPreferencesSchema>;
export type AIInteraction = typeof aiInteractions.$inferSelect;
// Additional validation schemas
export const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
export const registerSchema = insertUserSchema.extend({
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export const taskCreateSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
priority: z.enum(["low", "medium", "high"]).default("medium"),
dueDate: z.string().datetime().optional(),
});
export const financialRecordCreateSchema = z.object({
type: z.enum(["income", "expense"]),
amount: z.number().positive("Amount must be positive"),
category: z.string().min(1, "Category is required"),
description: z.string().optional(),
date: z.string().datetime(),
});
+90
View File
@@ -0,0 +1,90 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: ["./client/index.html", "./client/src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
"1": "hsl(var(--chart-1))",
"2": "hsl(var(--chart-2))",
"3": "hsl(var(--chart-3))",
"4": "hsl(var(--chart-4))",
"5": "hsl(var(--chart-5))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
keyframes: {
"accordion-down": {
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
},
"accordion-up": {
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
} satisfies Config;
+23
View File
@@ -0,0 +1,23 @@
{
"include": ["client/src/**/*", "shared/**/*", "server/**/*"],
"exclude": ["node_modules", "build", "dist", "**/*.test.ts"],
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./node_modules/typescript/tsbuildinfo",
"noEmit": true,
"module": "ESNext",
"strict": true,
"lib": ["esnext", "dom", "dom.iterable"],
"jsx": "preserve",
"esModuleInterop": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"moduleResolution": "bundler",
"baseUrl": ".",
"types": ["node", "vite/client"],
"paths": {
"@/*": ["./client/src/*"],
"@shared/*": ["./shared/*"]
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
import runtimeErrorOverlay from "@replit/vite-plugin-runtime-error-modal";
export default defineConfig({
plugins: [
react(),
runtimeErrorOverlay(),
...(process.env.NODE_ENV !== "production" &&
process.env.REPL_ID !== undefined
? [
await import("@replit/vite-plugin-cartographer").then((m) =>
m.cartographer(),
),
]
: []),
],
resolve: {
alias: {
"@": path.resolve(import.meta.dirname, "client", "src"),
"@shared": path.resolve(import.meta.dirname, "shared"),
"@assets": path.resolve(import.meta.dirname, "attached_assets"),
},
},
root: path.resolve(import.meta.dirname, "client"),
build: {
outDir: path.resolve(import.meta.dirname, "dist/public"),
emptyOutDir: true,
},
});