diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f9ba7f8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+dist
+.DS_Store
+server/public
+vite.config.ts.*
+*.tar.gz
\ No newline at end of file
diff --git a/.replit b/.replit
new file mode 100644
index 0000000..66129d4
--- /dev/null
+++ b/.replit
@@ -0,0 +1,42 @@
+modules = ["nodejs-20", "bash", "web"]
+run = "npm run dev"
+hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"]
+
+[nix]
+channel = "stable-24_05"
+
+[deployment]
+deploymentTarget = "autoscale"
+run = ["npm", "run", "start"]
+build = ["npm", "run", "build"]
+
+[[ports]]
+localPort = 5000
+externalPort = 80
+
+[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.metadata]
+agentRequireRestartOnSave = false
+
+[[workflows.workflow.tasks]]
+task = "packager.installForAll"
+
+[[workflows.workflow.tasks]]
+task = "shell.exec"
+args = "npm run dev"
+waitForPort = 5000
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..ed27b80
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/src/App.tsx b/client/src/App.tsx
new file mode 100644
index 0000000..164f567
--- /dev/null
+++ b/client/src/App.tsx
@@ -0,0 +1,54 @@
+import { Switch, Route } from "wouter";
+import { queryClient } from "./lib/queryClient";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { Toaster } from "@/components/ui/toaster";
+import NotFound from "@/pages/not-found";
+import AuthPage from "@/pages/auth-page";
+import DashboardPage from "@/pages/dashboard";
+import DomainsPage from "@/pages/domains";
+import DnsRecordsPage from "@/pages/dns-records";
+import MetricsPage from "@/pages/metrics";
+import HistoryPage from "@/pages/history";
+import ApiTokensPage from "@/pages/api-tokens";
+import UsersRolesPage from "@/pages/users-roles";
+import ProvidersPage from "@/pages/providers";
+import SettingsPage from "@/pages/settings";
+import { AuthProvider } from "@/hooks/use-auth";
+import { ProtectedRoute } from "@/lib/protected-route";
+import { ThemeProvider } from "@/hooks/use-theme";
+import { OrganizationProvider } from "@/context/organization-context";
+
+function Router() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/client/src/components/activity/recent-activity.tsx b/client/src/components/activity/recent-activity.tsx
new file mode 100644
index 0000000..4273d6c
--- /dev/null
+++ b/client/src/components/activity/recent-activity.tsx
@@ -0,0 +1,117 @@
+import { useQuery } from "@tanstack/react-query";
+import { DnsHistory } from "@shared/schema";
+import { formatDistanceToNow } from "date-fns";
+import { Loader2 } from "lucide-react";
+import { Card, CardContent, CardTitle } from "@/components/ui/card";
+
+interface RecentActivityProps {
+ domainId?: number;
+}
+
+export function RecentActivity({ domainId }: RecentActivityProps) {
+ // Fetch history data for the specified domain or all domains if not specified
+ const queryKey = domainId
+ ? ["/api/dns-history/domain", domainId]
+ : ["/api/dns-history"];
+
+ const { data: historyEntries = [], isLoading } = useQuery({
+ queryKey,
+ enabled: domainId !== undefined,
+ });
+
+ // Limit to last 5 entries
+ const recentEntries = historyEntries.slice(0, 5);
+
+ const getActivityIcon = (action: string) => {
+ switch (action) {
+ case "create":
+ return "border-success";
+ case "update":
+ return "border-primary";
+ case "delete":
+ return "border-destructive";
+ default:
+ return "border-muted-foreground";
+ }
+ };
+
+ const getActivityTitle = (entry: DnsHistory) => {
+ if (!entry.previousValue || !entry.newValue) return "Unknown action";
+
+ try {
+ const prev = JSON.parse(entry.previousValue);
+ const curr = JSON.parse(entry.newValue);
+
+ switch (entry.action) {
+ case "create":
+ return `${curr.name} ${curr.type} record created`;
+ case "update":
+ return `${curr.name} ${curr.type} record updated`;
+ case "delete":
+ return `${prev.name} ${prev.type} record deleted`;
+ default:
+ return "Unknown action";
+ }
+ } catch (e) {
+ return "Error parsing history";
+ }
+ };
+
+ const getActivityDetails = (entry: DnsHistory) => {
+ if (!entry.newValue) return "";
+
+ try {
+ const data = JSON.parse(entry.newValue);
+ switch (entry.action) {
+ case "create":
+ case "update":
+ return `Value: ${data.content}`;
+ default:
+ return "";
+ }
+ } catch (e) {
+ return "";
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ Recent Activity
+ {recentEntries.length === 0 ? (
+
+ No recent activity found
+
+ ) : (
+
+ {recentEntries.map((entry) => (
+
+
+
{getActivityTitle(entry)}
+
{getActivityDetails(entry)}
+
+ {formatDistanceToNow(new Date(entry.timestamp), { addSuffix: true })}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/client/src/components/charts/dns-update-chart.tsx b/client/src/components/charts/dns-update-chart.tsx
new file mode 100644
index 0000000..36ff426
--- /dev/null
+++ b/client/src/components/charts/dns-update-chart.tsx
@@ -0,0 +1,138 @@
+import { useState, useEffect } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Card, CardContent } from "@/components/ui/card";
+import { Loader2 } from "lucide-react";
+import {
+ LineChart,
+ Line,
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ Legend,
+} from "recharts";
+
+interface DnsUpdateChartProps {
+ timeframe?: string;
+ domainId?: number;
+}
+
+export function DnsUpdateChart({
+ timeframe = "day",
+ domainId
+}: DnsUpdateChartProps) {
+ // This would be a real API query in a production app
+ // For this MVP, we'll generate sample data
+
+ // Create a query key that includes the timeframe and domainId
+ const queryKey = ["/api/metrics/dns-updates", timeframe, domainId];
+
+ // In a real app, this would fetch data from the API
+ const { data: chartData, isLoading } = useQuery({
+ queryKey,
+ queryFn: async () => generateSampleData(timeframe),
+ // Keep data fresh for 5 minutes
+ staleTime: 5 * 60 * 1000,
+ });
+
+ // Generate sample data based on timeframe
+ // In a real application, this would come from the API
+ const generateSampleData = (timeframe: string) => {
+ const data = [];
+
+ if (timeframe === "day") {
+ // Generate hourly data for a day
+ for (let i = 0; i < 24; i++) {
+ const hour = i.toString().padStart(2, "0") + ":00";
+ const updateCount = Math.floor(Math.random() * 10) + 1;
+ data.push({
+ time: hour,
+ updates: updateCount,
+ });
+ }
+ } else if (timeframe === "week") {
+ // Generate daily data for a week
+ const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
+ for (let i = 0; i < 7; i++) {
+ const updateCount = Math.floor(Math.random() * 50) + 10;
+ data.push({
+ time: days[i],
+ updates: updateCount,
+ });
+ }
+ } else if (timeframe === "month") {
+ // Generate weekly data for a month
+ for (let i = 1; i <= 4; i++) {
+ const updateCount = Math.floor(Math.random() * 200) + 50;
+ data.push({
+ time: `Week ${i}`,
+ updates: updateCount,
+ });
+ }
+ }
+
+ return data;
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {timeframe === "day" ? (
+
+
+ value.split(":")[0]}
+ />
+
+ [`${value} updates`, "Updates"]}
+ labelFormatter={(label) => `Time: ${label}`}
+ />
+
+
+ ) : (
+
+
+
+
+ [`${value} updates`, "Updates"]}
+ labelFormatter={(label) => `${label}`}
+ />
+
+
+ )}
+
+
+ );
+}
diff --git a/client/src/components/charts/provider-distribution-chart.tsx b/client/src/components/charts/provider-distribution-chart.tsx
new file mode 100644
index 0000000..09c071c
--- /dev/null
+++ b/client/src/components/charts/provider-distribution-chart.tsx
@@ -0,0 +1,98 @@
+import { useQuery } from "@tanstack/react-query";
+import { Card, CardContent } from "@/components/ui/card";
+import { Loader2, Cloud, Home, CircleHelp } from "lucide-react";
+import {
+ PieChart,
+ Pie,
+ Cell,
+ ResponsiveContainer,
+ Legend,
+ Tooltip,
+} from "recharts";
+
+export function ProviderDistributionChart() {
+ // This would be a real API query in a production app
+ // For this MVP, we'll generate sample data
+
+ // In a real app, this would fetch data from the API
+ const { data: chartData, isLoading } = useQuery({
+ queryKey: ["/api/metrics/provider-distribution"],
+ queryFn: async () => generateSampleData(),
+ // Keep data fresh for 5 minutes
+ staleTime: 5 * 60 * 1000,
+ });
+
+ // Generate sample data
+ // In a real application, this would come from the API
+ const generateSampleData = () => {
+ return [
+ { name: "Cloudflare", value: 45, color: "hsl(var(--primary))" },
+ { name: "Route53", value: 30, color: "hsl(var(--success))" },
+ { name: "GoDaddy", value: 15, color: "hsl(var(--warning))" },
+ { name: "Others", value: 10, color: "hsl(var(--destructive))" },
+ ];
+ };
+
+ // Simple icon renderer for the legend
+ const renderCustomizedLegend = (props: any) => {
+ const { payload } = props;
+
+ return (
+
+ {payload.map((entry: any, index: number) => (
+ -
+
+ {entry.value} ({entry.payload.value}%)
+
+ ))}
+
+ );
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ DNS Provider Distribution
+
+
+
+
+ {chartData.map((entry, index) => (
+ |
+ ))}
+
+ [`${value}%`, "Percentage"]}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/charts/record-type-chart.tsx b/client/src/components/charts/record-type-chart.tsx
new file mode 100644
index 0000000..1e566ed
--- /dev/null
+++ b/client/src/components/charts/record-type-chart.tsx
@@ -0,0 +1,88 @@
+import { useQuery } from "@tanstack/react-query";
+import { Card, CardContent } from "@/components/ui/card";
+import { Loader2 } from "lucide-react";
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ Legend,
+} from "recharts";
+
+export function RecordTypeChart() {
+ // This would be a real API query in a production app
+ // For this MVP, we'll generate sample data
+
+ // In a real app, this would fetch data from the API
+ const { data: chartData, isLoading } = useQuery({
+ queryKey: ["/api/metrics/record-type-distribution"],
+ queryFn: async () => generateSampleData(),
+ // Keep data fresh for 5 minutes
+ staleTime: 5 * 60 * 1000,
+ });
+
+ // Generate sample data
+ // In a real application, this would come from the API
+ const generateSampleData = () => {
+ return [
+ { name: "A", count: 70 },
+ { name: "AAAA", count: 20 },
+ { name: "CNAME", count: 45 },
+ { name: "MX", count: 25 },
+ { name: "TXT", count: 15 },
+ { name: "SRV", count: 10 },
+ { name: "Other", count: 5 },
+ ];
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ Record Type Distribution
+
+
+
+
+
+
+ [`${value} records`, "Count"]}
+ labelFormatter={(label) => `${label} Records`}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/domain/domain-status-card.tsx b/client/src/components/domain/domain-status-card.tsx
new file mode 100644
index 0000000..88e8ebe
--- /dev/null
+++ b/client/src/components/domain/domain-status-card.tsx
@@ -0,0 +1,25 @@
+import { ReactNode } from "react";
+import { Card } from "@/components/ui/card";
+
+interface DomainStatusCardProps {
+ title: string;
+ value: string | number;
+ icon: ReactNode;
+ iconClassName?: string;
+}
+
+export function DomainStatusCard({ title, value, icon, iconClassName = "bg-primary/10 text-primary" }: DomainStatusCardProps) {
+ return (
+
+
+
+ );
+}
diff --git a/client/src/components/domain/domain-table.tsx b/client/src/components/domain/domain-table.tsx
new file mode 100644
index 0000000..8c92616
--- /dev/null
+++ b/client/src/components/domain/domain-table.tsx
@@ -0,0 +1,227 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Domain } from "@shared/schema";
+import { Link } from "wouter";
+import { useOrganization } from "@/context/organization-context";
+import { Pagination } from "@/components/shared/pagination";
+import { Button } from "@/components/ui/button";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { formatDistanceToNow } from "date-fns";
+import { Cloud, Home, MoreVertical, Loader2, CircleHelp } from "lucide-react";
+
+interface DomainTableProps {
+ onManageDomain?: (domain: Domain) => void;
+ onDeleteDomain?: (domain: Domain) => void;
+}
+
+export function DomainTable({ onManageDomain, onDeleteDomain }: DomainTableProps) {
+ const { currentOrganization } = useOrganization();
+ const [currentPage, setCurrentPage] = useState(1);
+ const pageSize = 5;
+
+ const { data: domains = [], isLoading } = useQuery({
+ queryKey: ["/api/domains", currentOrganization?.id],
+ enabled: !!currentOrganization,
+ });
+
+ // Get providers to display provider names
+ const { data: providers = [] } = useQuery({
+ queryKey: ["/api/providers"],
+ });
+
+ const getProviderName = (providerId: number) => {
+ const provider = providers.find(p => p.id === providerId);
+ return provider?.name || "Unknown";
+ };
+
+ const getProviderIcon = (providerId: number) => {
+ const provider = providers.find(p => p.id === providerId);
+
+ if (!provider) return ;
+
+ switch (provider.type) {
+ case "cloudflare":
+ return ;
+ case "route53":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const getStatusBadge = (domain: Domain) => {
+ if (!domain.isActive) {
+ return (
+
+ Inactive
+
+ );
+ }
+
+ // If updated within the last hour, show "Active"
+ if (domain.lastUpdated && new Date(domain.lastUpdated).getTime() > Date.now() - 3600000) {
+ return (
+
+ Active
+
+ );
+ }
+
+ // If updated more than 24 hours ago, show "Update Pending"
+ if (domain.lastUpdated && new Date(domain.lastUpdated).getTime() < Date.now() - 86400000) {
+ return (
+
+ Update Pending
+
+ );
+ }
+
+ return (
+
+ Active
+
+ );
+ };
+
+ // Calculate pagination
+ const startIndex = (currentPage - 1) * pageSize;
+ const endIndex = startIndex + pageSize;
+ const paginatedDomains = domains.slice(startIndex, endIndex);
+ const totalPages = Math.ceil(domains.length / pageSize);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (domains.length === 0) {
+ return (
+
+
+
+
+
No domains found
+
+ Get started by adding your first domain.
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
Managed Domains
+
+
+
+
+
+
+
+ Domain
+ Provider
+ Records
+ Last Update
+ Status
+ Actions
+
+
+
+ {paginatedDomains.map(domain => (
+
+ {domain.name}
+
+
+ {getProviderIcon(domain.providerId)}
+ {getProviderName(domain.providerId)}
+
+
+
+
+ View Records
+
+
+
+ {domain.lastUpdated
+ ? formatDistanceToNow(new Date(domain.lastUpdated), { addSuffix: true })
+ : "Never"}
+
+ {getStatusBadge(domain)}
+
+
+
+
+
+
+ onManageDomain && onManageDomain(domain)}
+ >
+ Manage
+
+ onDeleteDomain && onDeleteDomain(domain)}
+ className="text-destructive focus:text-destructive"
+ >
+ Delete
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/layouts/header.tsx b/client/src/components/layouts/header.tsx
new file mode 100644
index 0000000..9dcf4bd
--- /dev/null
+++ b/client/src/components/layouts/header.tsx
@@ -0,0 +1,87 @@
+import { Menu, User } from "lucide-react";
+import { ThemeToggle } from "@/components/shared/theme-toggle";
+import { useAuth } from "@/hooks/use-auth";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Button } from "@/components/ui/button";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+
+interface HeaderProps {
+ title?: string;
+ onMobileMenuOpen: () => void;
+}
+
+export function Header({ title, onMobileMenuOpen }: HeaderProps) {
+ const { user, logoutMutation } = useAuth();
+
+ const getInitials = (name: string) => {
+ return name
+ .split(' ')
+ .map(n => n[0])
+ .join('')
+ .toUpperCase();
+ };
+
+ return (
+
+
+
+
+
+
+ {title ? title : "DynamiDNS"}
+
+
+
+
+
+
+
+
+
+
+
+
+ logoutMutation.mutate()}
+ disabled={logoutMutation.isPending}
+ >
+ Log out
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/layouts/main-layout.tsx b/client/src/components/layouts/main-layout.tsx
new file mode 100644
index 0000000..94de825
--- /dev/null
+++ b/client/src/components/layouts/main-layout.tsx
@@ -0,0 +1,48 @@
+import { ReactNode, useState } from "react";
+import { Sidebar } from "./sidebar";
+import { Header } from "./header";
+import { MobileSidebar } from "./mobile-sidebar";
+
+interface MainLayoutProps {
+ children: ReactNode;
+ title?: string;
+ description?: string;
+}
+
+export function MainLayout({ children, title, description }: MainLayoutProps) {
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+
+ return (
+
+ {/* Mobile Header */}
+
+ );
+}
diff --git a/client/src/components/layouts/mobile-sidebar.tsx b/client/src/components/layouts/mobile-sidebar.tsx
new file mode 100644
index 0000000..5b37989
--- /dev/null
+++ b/client/src/components/layouts/mobile-sidebar.tsx
@@ -0,0 +1,188 @@
+import { Link, useLocation } from "wouter";
+import { cn } from "@/lib/utils";
+import { OrganizationSelector } from "@/components/shared/organization-selector";
+import { useAuth } from "@/hooks/use-auth";
+import {
+ LayoutDashboard,
+ Home,
+ Globe,
+ BarChart2,
+ FileEdit,
+ Key,
+ Users,
+ Settings,
+ CreditCard,
+ X,
+ LogOut,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+interface MobileSidebarProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
+ const [location] = useLocation();
+ const { user, logoutMutation } = useAuth();
+
+ // Define navigation items
+ const navItems = [
+ {
+ title: "Dashboard",
+ href: "/",
+ icon: ,
+ },
+ {
+ title: "Domains",
+ href: "/domains",
+ icon: ,
+ },
+ {
+ title: "DNS Records",
+ href: "/dns-records",
+ icon: ,
+ },
+ {
+ title: "Metrics",
+ href: "/metrics",
+ icon: ,
+ },
+ {
+ title: "History",
+ href: "/history",
+ icon: ,
+ },
+ {
+ title: "API Tokens",
+ href: "/api-tokens",
+ icon: ,
+ },
+ ];
+
+ // Define admin navigation items
+ const adminNavItems = [
+ {
+ title: "Users & Roles",
+ href: "/users-roles",
+ icon: ,
+ roles: ["admin"],
+ },
+ {
+ title: "Providers",
+ href: "/providers",
+ icon: ,
+ roles: ["admin", "manager"],
+ },
+ {
+ title: "Settings",
+ href: "/settings",
+ icon: ,
+ roles: ["admin", "manager"],
+ },
+ ];
+
+ // Filter admin items based on user role
+ const filteredAdminItems = adminNavItems.filter(
+ item => !item.roles || (user && item.roles.includes(user.role))
+ );
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+
+
+ {/* Organization Selector */}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/layouts/sidebar.tsx b/client/src/components/layouts/sidebar.tsx
new file mode 100644
index 0000000..8c0bb49
--- /dev/null
+++ b/client/src/components/layouts/sidebar.tsx
@@ -0,0 +1,165 @@
+import { Link, useLocation } from "wouter";
+import { cn } from "@/lib/utils";
+import { ThemeToggle } from "@/components/shared/theme-toggle";
+import { OrganizationSelector } from "@/components/shared/organization-selector";
+import { useAuth } from "@/hooks/use-auth";
+import {
+ LayoutDashboard,
+ Home,
+ Globe,
+ BarChart2,
+ FileEdit,
+ Key,
+ Users,
+ Settings,
+ CreditCard,
+ LogOut,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+export function Sidebar() {
+ const [location] = useLocation();
+ const { user, logoutMutation } = useAuth();
+
+ // Define navigation items
+ const navItems = [
+ {
+ title: "Dashboard",
+ href: "/",
+ icon: ,
+ },
+ {
+ title: "Domains",
+ href: "/domains",
+ icon: ,
+ },
+ {
+ title: "DNS Records",
+ href: "/dns-records",
+ icon: ,
+ },
+ {
+ title: "Metrics",
+ href: "/metrics",
+ icon: ,
+ },
+ {
+ title: "History",
+ href: "/history",
+ icon: ,
+ },
+ {
+ title: "API Tokens",
+ href: "/api-tokens",
+ icon: ,
+ },
+ ];
+
+ // Define admin navigation items
+ const adminNavItems = [
+ {
+ title: "Users & Roles",
+ href: "/users-roles",
+ icon: ,
+ roles: ["admin"],
+ },
+ {
+ title: "Providers",
+ href: "/providers",
+ icon: ,
+ roles: ["admin", "manager"],
+ },
+ {
+ title: "Settings",
+ href: "/settings",
+ icon: ,
+ roles: ["admin", "manager"],
+ },
+ ];
+
+ // Filter admin items based on user role
+ const filteredAdminItems = adminNavItems.filter(
+ item => !item.roles || (user && item.roles.includes(user.role))
+ );
+
+ return (
+
+ );
+}
diff --git a/client/src/components/shared/organization-selector.tsx b/client/src/components/shared/organization-selector.tsx
new file mode 100644
index 0000000..b4700a0
--- /dev/null
+++ b/client/src/components/shared/organization-selector.tsx
@@ -0,0 +1,63 @@
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useOrganization } from "@/context/organization-context";
+import { ChevronDown } from "lucide-react";
+
+export function OrganizationSelector() {
+ const { organizations, currentOrganization, setCurrentOrganization, isLoading } = useOrganization();
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!currentOrganization || organizations.length === 0) {
+ return (
+
+
+
+ No Organization
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {organizations.map((org) => (
+ setCurrentOrganization(org)}
+ className={org.id === currentOrganization.id ? "bg-muted" : ""}
+ >
+
+
+ {org.name}
+
+
+ ))}
+
+
+ );
+}
diff --git a/client/src/components/shared/pagination.tsx b/client/src/components/shared/pagination.tsx
new file mode 100644
index 0000000..7c089a6
--- /dev/null
+++ b/client/src/components/shared/pagination.tsx
@@ -0,0 +1,110 @@
+import { Button } from "@/components/ui/button";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+
+interface PaginationProps {
+ currentPage: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+ itemLabel?: string;
+ totalItems?: number;
+ itemsPerPage?: number;
+}
+
+export function Pagination({
+ currentPage,
+ totalPages,
+ onPageChange,
+ itemLabel = "items",
+ totalItems,
+ itemsPerPage = 10,
+}: PaginationProps) {
+ // Generate page numbers to display
+ const getPageNumbers = () => {
+ const pages = [];
+
+ // Always include first page
+ pages.push(1);
+
+ // Add ellipsis if needed
+ if (currentPage > 3) {
+ pages.push("ellipsis1");
+ }
+
+ // Add pages around current page
+ for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
+ if (i > 1 && i < totalPages) {
+ pages.push(i);
+ }
+ }
+
+ // Add ellipsis if needed
+ if (currentPage < totalPages - 2) {
+ pages.push("ellipsis2");
+ }
+
+ // Always include last page if more than 1 page
+ if (totalPages > 1) {
+ pages.push(totalPages);
+ }
+
+ return pages;
+ };
+
+ const pageNumbers = getPageNumbers();
+
+ const startItem = (currentPage - 1) * itemsPerPage + 1;
+ const endItem = Math.min(startItem + itemsPerPage - 1, totalItems || 0);
+
+ return (
+
+ {totalItems !== undefined && (
+
+ Showing {startItem}-{endItem} of {totalItems} {itemLabel}
+
+ )}
+
+
+
+
+ {pageNumbers.map((page, index) => {
+ if (page === "ellipsis1" || page === "ellipsis2") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/client/src/components/shared/theme-toggle.tsx b/client/src/components/shared/theme-toggle.tsx
new file mode 100644
index 0000000..78e641e
--- /dev/null
+++ b/client/src/components/shared/theme-toggle.tsx
@@ -0,0 +1,24 @@
+import { Button } from "@/components/ui/button";
+import { useTheme } from "@/hooks/use-theme";
+import { Moon, Sun } from "lucide-react";
+
+export function ThemeToggle() {
+ const { theme, setTheme } = useTheme();
+
+ const toggleTheme = () => {
+ setTheme(theme === "dark" ? "light" : "dark");
+ };
+
+ return (
+
+ );
+}
diff --git a/client/src/components/ui/accordion.tsx b/client/src/components/ui/accordion.tsx
new file mode 100644
index 0000000..e6a723d
--- /dev/null
+++ b/client/src/components/ui/accordion.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+))
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..8722561
--- /dev/null
+++ b/client/src/components/ui/alert-dialog.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogHeader.displayName = "AlertDialogHeader"
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogFooter.displayName = "AlertDialogFooter"
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/client/src/components/ui/alert.tsx b/client/src/components/ui/alert.tsx
new file mode 100644
index 0000000..41fa7e0
--- /dev/null
+++ b/client/src/components/ui/alert.tsx
@@ -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 & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/client/src/components/ui/aspect-ratio.tsx b/client/src/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000..c4abbf3
--- /dev/null
+++ b/client/src/components/ui/aspect-ratio.tsx
@@ -0,0 +1,5 @@
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
+
+const AspectRatio = AspectRatioPrimitive.Root
+
+export { AspectRatio }
diff --git a/client/src/components/ui/avatar.tsx b/client/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..991f56e
--- /dev/null
+++ b/client/src/components/ui/avatar.tsx
@@ -0,0 +1,48 @@
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx
new file mode 100644
index 0000000..f000e3e
--- /dev/null
+++ b/client/src/components/ui/badge.tsx
@@ -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,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/client/src/components/ui/breadcrumb.tsx b/client/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..17f14b5
--- /dev/null
+++ b/client/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,126 @@
+import * as React from "react";
+import { ChevronRight, MoreHorizontal } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { Link } from "wouter";
+
+export interface BreadcrumbProps extends React.ComponentPropsWithoutRef<"nav"> {
+ separator?: React.ReactNode;
+ children?: React.ReactNode;
+}
+
+export interface BreadcrumbItemProps extends React.ComponentPropsWithoutRef<"li"> {
+ children?: React.ReactNode;
+ isCurrent?: boolean;
+}
+
+export interface BreadcrumbLinkProps extends React.ComponentPropsWithoutRef<"a"> {
+ asChild?: boolean;
+ children?: React.ReactNode;
+ href: string;
+}
+
+export interface BreadcrumbSeparatorProps extends React.ComponentPropsWithoutRef<"li"> {
+ children?: React.ReactNode;
+}
+
+export interface BreadcrumbEllipsisProps extends React.ComponentPropsWithoutRef<"li"> {
+ children?: React.ReactNode;
+}
+
+const Breadcrumb = React.forwardRef(
+ ({ separator = , className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Breadcrumb.displayName = "Breadcrumb";
+
+const BreadcrumbList = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+BreadcrumbList.displayName = "BreadcrumbList";
+
+const BreadcrumbItem = React.forwardRef(
+ ({ className, isCurrent, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+BreadcrumbItem.displayName = "BreadcrumbItem";
+
+const BreadcrumbLink = React.forwardRef(
+ ({ asChild, className, href, ...props }, ref) => {
+ return (
+
+
+
+ );
+ }
+);
+BreadcrumbLink.displayName = "BreadcrumbLink";
+
+const BreadcrumbSeparator = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
+
+const BreadcrumbEllipsis = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+
+ More
+
+ );
+ }
+);
+BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx
new file mode 100644
index 0000000..36496a2
--- /dev/null
+++ b/client/src/components/ui/button.tsx
@@ -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,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/client/src/components/ui/calendar.tsx b/client/src/components/ui/calendar.tsx
new file mode 100644
index 0000000..b065f8e
--- /dev/null
+++ b/client/src/components/ui/calendar.tsx
@@ -0,0 +1,64 @@
+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
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+ ,
+ IconRight: ({ ...props }) => ,
+ }}
+ {...props}
+ />
+ )
+}
+Calendar.displayName = "Calendar"
+
+export { Calendar }
diff --git a/client/src/components/ui/card.tsx b/client/src/components/ui/card.tsx
new file mode 100644
index 0000000..afa13ec
--- /dev/null
+++ b/client/src/components/ui/card.tsx
@@ -0,0 +1,79 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/client/src/components/ui/carousel.tsx b/client/src/components/ui/carousel.tsx
new file mode 100644
index 0000000..9c2b9bf
--- /dev/null
+++ b/client/src/components/ui/carousel.tsx
@@ -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
+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[0]
+ api: ReturnType[1]
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: boolean
+ canScrollNext: boolean
+} & CarouselProps
+
+const CarouselContext = React.createContext(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ")
+ }
+
+ return context
+}
+
+const Carousel = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & 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) => {
+ 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 (
+
+
+ {children}
+
+
+ )
+ }
+)
+Carousel.displayName = "Carousel"
+
+const CarouselContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselContent.displayName = "CarouselContent"
+
+const CarouselItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { orientation } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselItem.displayName = "CarouselItem"
+
+const CarouselPrevious = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselPrevious.displayName = "CarouselPrevious"
+
+const CarouselNext = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselNext.displayName = "CarouselNext"
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+}
diff --git a/client/src/components/ui/chart.tsx b/client/src/components/ui/chart.tsx
new file mode 100644
index 0000000..a21d77e
--- /dev/null
+++ b/client/src/components/ui/chart.tsx
@@ -0,0 +1,363 @@
+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 }
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a ")
+ }
+
+ 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 (
+
+
+
+
+ {children}
+
+
+
+ )
+})
+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 (
+