From 15f418d803dcdd6ea3b53240d2c856e90a68dbee Mon Sep 17 00:00:00 2001 From: pahmiudahgede Date: Sun, 6 Jul 2025 13:25:02 +0700 Subject: [PATCH] initial commit:migrate next js to remix --- app/components/layoutadmin/header.tsx | 246 +++ app/components/layoutadmin/layout-wrapper.tsx | 79 + app/components/layoutadmin/sidebar.tsx | 587 +++++++ app/components/layoutpengelola/header.tsx | 246 +++ .../layoutpengelola/layout-wrapper.tsx | 78 + app/components/layoutpengelola/sidebar.tsx | 621 +++++++ app/components/map/leaflet-map.tsx | 192 +++ app/components/ui/3d-card.tsx | 155 ++ app/components/ui/alert-dialog.tsx | 139 ++ app/components/ui/alert.tsx | 59 + app/components/ui/avatar.tsx | 48 + app/components/ui/badge.tsx | 36 + app/components/ui/button.tsx | 57 + app/components/ui/card.tsx | 76 + app/components/ui/collapsible.tsx | 9 + app/components/ui/dark-mode-toggle.tsx | 34 + app/components/ui/dialog.tsx | 120 ++ app/components/ui/dropdown-menu.tsx | 199 +++ app/components/ui/input.tsx | 22 + app/components/ui/label.tsx | 24 + app/components/ui/macbook-scroll.tsx | 710 ++++++++ app/components/ui/progress.tsx | 26 + app/components/ui/scroll-area.tsx | 48 + app/components/ui/select.tsx | 157 ++ app/components/ui/separator.tsx | 29 + app/components/ui/sheet.tsx | 138 ++ app/components/ui/switch.tsx | 27 + app/components/ui/table.tsx | 120 ++ app/components/ui/tabs.tsx | 53 + app/components/ui/textarea.tsx | 22 + app/components/ui/tooltip.tsx | 30 + app/lib/utils.ts | 6 + app/root.tsx | 56 +- app/routes/_index.tsx | 138 -- app/routes/_landing._index.tsx | 1104 ++++++++++++ app/routes/_landing.tsx | 405 +++++ app/routes/action.set-theme.ts | 4 + app/routes/pengelola._index.tsx | 6 + app/routes/pengelola.dashboard._index.tsx | 509 ++++++ .../pengelola.dashboard.chat._index.tsx | 596 +++++++ .../pengelola.dashboard.collection._index.tsx | 984 +++++++++++ app/routes/pengelola.tsx | 25 + app/routes/sys-rijig-adminpanel._index.tsx | 6 + .../sys-rijig-adminpanel.dashboard._index.tsx | 352 ++++ ...ijig-adminpanel.dashboard.areacoverage.tsx | 709 ++++++++ ...minpanel.dashboard.artikel-blog._index.tsx | 208 +++ ...dminpanel.dashboard.content-management.tsx | 37 + ...-rijig-adminpanel.dashboard.pengaturan.tsx | 770 +++++++++ ...minpanel.dashboard.tips-panduan._index.tsx | 158 ++ ...ijig-adminpanel.dashboard.users._index.tsx | 212 +++ ...ijig-adminpanel.dashboard.waste._index.tsx | 297 ++++ app/routes/sys-rijig-adminpanel.tsx | 25 + app/sessions.server.tsx | 16 + app/tailwind.css | 67 + components.json | 21 + package-lock.json | 1510 +++++++++++++++-- package.json | 29 +- public/assets/dashboard_example.png | Bin 0 -> 231446 bytes tailwind.config.ts | 80 +- 59 files changed, 12445 insertions(+), 272 deletions(-) create mode 100644 app/components/layoutadmin/header.tsx create mode 100644 app/components/layoutadmin/layout-wrapper.tsx create mode 100644 app/components/layoutadmin/sidebar.tsx create mode 100644 app/components/layoutpengelola/header.tsx create mode 100644 app/components/layoutpengelola/layout-wrapper.tsx create mode 100644 app/components/layoutpengelola/sidebar.tsx create mode 100644 app/components/map/leaflet-map.tsx create mode 100644 app/components/ui/3d-card.tsx create mode 100644 app/components/ui/alert-dialog.tsx create mode 100644 app/components/ui/alert.tsx create mode 100644 app/components/ui/avatar.tsx create mode 100644 app/components/ui/badge.tsx create mode 100644 app/components/ui/button.tsx create mode 100644 app/components/ui/card.tsx create mode 100644 app/components/ui/collapsible.tsx create mode 100644 app/components/ui/dark-mode-toggle.tsx create mode 100644 app/components/ui/dialog.tsx create mode 100644 app/components/ui/dropdown-menu.tsx create mode 100644 app/components/ui/input.tsx create mode 100644 app/components/ui/label.tsx create mode 100644 app/components/ui/macbook-scroll.tsx create mode 100644 app/components/ui/progress.tsx create mode 100644 app/components/ui/scroll-area.tsx create mode 100644 app/components/ui/select.tsx create mode 100644 app/components/ui/separator.tsx create mode 100644 app/components/ui/sheet.tsx create mode 100644 app/components/ui/switch.tsx create mode 100644 app/components/ui/table.tsx create mode 100644 app/components/ui/tabs.tsx create mode 100644 app/components/ui/textarea.tsx create mode 100644 app/components/ui/tooltip.tsx create mode 100644 app/lib/utils.ts delete mode 100644 app/routes/_index.tsx create mode 100644 app/routes/_landing._index.tsx create mode 100644 app/routes/_landing.tsx create mode 100644 app/routes/action.set-theme.ts create mode 100644 app/routes/pengelola._index.tsx create mode 100644 app/routes/pengelola.dashboard._index.tsx create mode 100644 app/routes/pengelola.dashboard.chat._index.tsx create mode 100644 app/routes/pengelola.dashboard.collection._index.tsx create mode 100644 app/routes/pengelola.tsx create mode 100644 app/routes/sys-rijig-adminpanel._index.tsx create mode 100644 app/routes/sys-rijig-adminpanel.dashboard._index.tsx create mode 100644 app/routes/sys-rijig-adminpanel.dashboard.areacoverage.tsx create mode 100644 app/routes/sys-rijig-adminpanel.dashboard.artikel-blog._index.tsx create mode 100644 app/routes/sys-rijig-adminpanel.dashboard.content-management.tsx create mode 100644 app/routes/sys-rijig-adminpanel.dashboard.pengaturan.tsx create mode 100644 app/routes/sys-rijig-adminpanel.dashboard.tips-panduan._index.tsx create mode 100644 app/routes/sys-rijig-adminpanel.dashboard.users._index.tsx create mode 100644 app/routes/sys-rijig-adminpanel.dashboard.waste._index.tsx create mode 100644 app/routes/sys-rijig-adminpanel.tsx create mode 100644 app/sessions.server.tsx create mode 100644 components.json create mode 100644 public/assets/dashboard_example.png diff --git a/app/components/layoutadmin/header.tsx b/app/components/layoutadmin/header.tsx new file mode 100644 index 0000000..83aea3e --- /dev/null +++ b/app/components/layoutadmin/header.tsx @@ -0,0 +1,246 @@ +// app/components/layoutadmin/header.tsx +import { useState } from "react"; +import { Form } from "@remix-run/react"; +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Badge } from "~/components/ui/badge"; +import { ModeToggle } from "~/components/ui/dark-mode-toggle"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "~/components/ui/dropdown-menu"; +import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; +import { + Menu, + Search, + Bell, + Sun, + Moon, + User, + Settings, + LogOut, + ChevronDown, + MoreHorizontal, + PanelLeftClose, + PanelLeft +} from "lucide-react"; + +interface AdminHeaderProps { + onMenuClick: () => void; + sidebarCollapsed: boolean; + isMobile: boolean; +} + +export function AdminHeader({ + onMenuClick, + sidebarCollapsed, + isMobile +}: AdminHeaderProps) { + const [isDark, setIsDark] = useState(false); + + return ( +
+
+ {/* Mobile header */} +
+ {/* Hamburger menu button */} + + + {/* Mobile logo */} +
+
+ LOGO +
+
+ + {/* Mobile more menu */} + + + + + +
+ + +
+
+
+ + {/* Desktop search - Commented out for now */} + {/*
+
+ + +
+ + ⌘K + +
+
+
*/} +
+ + {/* Desktop header actions */} +
+
+ {/* Theme toggle */} + + + {/* Notifications */} + + + + + +
+

+ Notifications + + 3 new + +

+
+
+

+ New user registered +

+

+ 2 minutes ago +

+
+
+

+ System update available +

+

+ 1 hour ago +

+
+
+

+ New message received +

+

+ 3 hours ago +

+
+
+ +
+
+
+ + {/* User menu */} + + + + + +
+
Musharof
+
+ admin@example.com +
+
+ + + + Profile + + + + Settings + + + +
+ +
+
+
+
+
+
+
+
+ ); +} diff --git a/app/components/layoutadmin/layout-wrapper.tsx b/app/components/layoutadmin/layout-wrapper.tsx new file mode 100644 index 0000000..d129dba --- /dev/null +++ b/app/components/layoutadmin/layout-wrapper.tsx @@ -0,0 +1,79 @@ +// app/components/layoutadmin/layout-wrapper.tsx +import { useState, useEffect } from "react"; +import { AdminSidebar } from "./sidebar"; +import { AdminHeader } from "./header"; + +interface AdminLayoutWrapperProps { + children: React.ReactNode; +} + +export function AdminLayoutWrapper({ children }: AdminLayoutWrapperProps) { + const [sidebarOpen, setSidebarOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth < 1024); + if (window.innerWidth >= 1024) { + setSidebarOpen(false); + } + }; + + checkMobile(); + window.addEventListener("resize", checkMobile); + return () => window.removeEventListener("resize", checkMobile); + }, []); + + const handleToggleSidebar = () => { + if (isMobile) { + setSidebarOpen(!sidebarOpen); + } else { + setSidebarCollapsed(!sidebarCollapsed); + } + }; + + const sidebarWidth = sidebarCollapsed && !isHovered ? "80px" : "290px"; + const contentMargin = isMobile ? "0" : sidebarWidth; + + return ( +
+ {/* Sidebar */} + setSidebarOpen(false)} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + /> + + {/* Mobile overlay */} + {sidebarOpen && isMobile && ( +
setSidebarOpen(false)} + /> + )} + + {/* Main content */} +
+ + + {/* Page content */} +
{children}
+
+
+ ); +} diff --git a/app/components/layoutadmin/sidebar.tsx b/app/components/layoutadmin/sidebar.tsx new file mode 100644 index 0000000..cffb20c --- /dev/null +++ b/app/components/layoutadmin/sidebar.tsx @@ -0,0 +1,587 @@ +import { useState, useMemo } from "react"; +import { Link, useLocation } from "@remix-run/react"; +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 { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from "~/components/ui/collapsible"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "~/components/ui/tooltip"; +import { + LayoutDashboard, + Recycle, + Users, + TrendingUp, + FileText, + CreditCard, + BarChart3, + Settings, + MessageCircle, + MapPin, + Package, + DollarSign, + UserCheck, + Building2, + Newspaper, + HelpCircle, + Bell, + Shield, + ChevronDown, + X +} from "lucide-react"; +import { cn } from "~/lib/utils"; + +interface SidebarProps { + isOpen: boolean; + isCollapsed: boolean; + isHovered: boolean; + isMobile: boolean; + onClose: () => void; + onMouseEnter: () => void; + onMouseLeave: () => void; +} + +interface MenuItem { + title: string; + icon?: React.ReactNode; + href?: string; + badge?: string; + badgeVariant?: "pro" | "new" | "urgent"; + children?: MenuItem[]; +} + +const menuItems: MenuItem[] = [ + { + title: "Dashboard", + icon: , + children: [ + { + title: "Overview", + href: "/sys-rijig-adminpanel/dashboard" + }, + { title: "Analytics", href: "/admin/analytics" }, + { title: "Statistik Sampah", href: "/admin/waste-stats", badge: "new" }, + { title: "Laporan Harian", href: "/admin/daily-reports" } + ] + }, + { + title: "Data Sampah", + icon: , + children: [ + { title: "Jenis Sampah", href: "/sys-rijig-adminpanel/dashboard/waste" }, + { title: "Harga Sampah", href: "/admin/waste-prices" }, + { title: "Volume Sampah", href: "/admin/waste-volume" }, + { title: "Tracking Sampah", href: "/admin/waste-tracking", badge: "new" }, + { title: "Kualitas Sampah", href: "/admin/waste-quality" } + ] + }, + { + title: "Manajemen User", + icon: , + children: [ + { title: "Masyarakat", href: "/admin/users/community" }, + { title: "Pengepul", href: "/admin/users/collectors" }, + { title: "Pengelola Daur Ulang", href: "/admin/users/recyclers" }, + { + title: "Verifikasi User", + href: "/sys-rijig-adminpanel/dashboard/users", + badge: "urgent" + }, + { title: "Rating & Review", href: "/admin/users/reviews" } + ] + }, + { + title: "Transaksi", + icon: , + children: [ + { title: "Semua Transaksi", href: "/admin/transactions/all" }, + { + title: "Pembayaran Pending", + href: "/admin/transactions/pending", + badge: "urgent" + }, + { title: "Riwayat Pembayaran", href: "/admin/transactions/history" }, + { title: "Komisi & Fee", href: "/admin/transactions/commission" }, + { + title: "Laporan Keuangan", + href: "/admin/transactions/financial-report" + } + ] + } +]; + +const contentMenuItems: MenuItem[] = [ + { + title: "Content Management", + icon: , + children: [ + { title: "Artikel & Blog", href: "/sys-rijig-adminpanel/dashboard/artikel-blog" }, + { title: "Tips & Panduan", href: "/sys-rijig-adminpanel/dashboard/tips-panduan" }, + { title: "FAQ", href: "/admin/content/faq" }, + { + title: "Pengumuman", + href: "/admin/content/announcements", + badge: "new" + }, + { title: "Testimoni", href: "/admin/content/testimonials" } + ] + }, + { + title: "Lokasi & Mapping", + icon: , + children: [ + { title: "Peta Pengepul", href: "/admin/mapping/collectors" }, + { title: "Area Coverage", href: "/sys-rijig-adminpanel/dashboard/areacoverage" }, + { title: "Titik Pengumpulan", href: "/admin/mapping/collection-points" }, + { title: "Rute Optimal", href: "/admin/mapping/routes", badge: "new" } + ] + }, + { + title: "Notifikasi", + icon: , + children: [ + { title: "Push Notifications", href: "/admin/notifications/push" }, + { title: "Email Broadcast", href: "/admin/notifications/email" }, + { title: "SMS Gateway", href: "/admin/notifications/sms" }, + { title: "Template Pesan", href: "/admin/notifications/templates" } + ] + } +]; + +const analyticsMenuItems: MenuItem[] = [ + { + title: "Reports & Analytics", + icon: , + children: [ + { title: "Laporan Bulanan", href: "/admin/reports/monthly" }, + { + title: "Performa Pengepul", + href: "/admin/reports/collector-performance" + }, + { title: "Tren Harga", href: "/admin/reports/price-trends" }, + { + title: "Dampak Lingkungan", + href: "/admin/reports/environmental-impact", + badge: "new" + }, + { title: "ROI Analysis", href: "/admin/reports/roi-analysis" } + ] + }, + { + title: "Partner & Kerjasama", + icon: , + children: [ + { title: "Bank Sampah", href: "/admin/partners/waste-banks" }, + { + title: "Industri Daur Ulang", + href: "/admin/partners/recycling-industry" + }, + { title: "Pemerintah Daerah", href: "/admin/partners/government" }, + { title: "NGO & Komunitas", href: "/admin/partners/ngo-community" } + ] + }, + { + title: "Support & Help", + icon: , + children: [ + { + title: "Tiket Support", + href: "/admin/support/tickets", + badge: "urgent" + }, + { title: "Live Chat", href: "/admin/support/chat" }, + { title: "Knowledge Base", href: "/admin/support/knowledge-base" }, + { title: "Training Materials", href: "/admin/support/training" } + ] + }, + { + title: "Pengaturan", + icon: , + children: [ + { title: "Konfigurasi Sistem", href: "/sys-rijig-adminpanel/dashboard/pengaturan" }, + { title: "Pengaturan Harga", href: "/admin/settings/pricing" }, + { + title: "Role & Permission", + href: "/admin/settings/roles", + badge: "pro" + }, + { title: "Backup & Restore", href: "/admin/settings/backup" }, + { title: "API Management", href: "/admin/settings/api", badge: "pro" } + ] + } +]; + +function MenuSection({ + title, + items, + isCollapsed, + isHovered +}: { + title: string; + items: MenuItem[]; + isCollapsed: boolean; + isHovered: boolean; +}) { + const showText = !isCollapsed || isHovered; + + return ( +
+ {showText && ( +

+ {title} +

+ )} +
    + {items.map((item, index) => ( + + ))} +
+
+ ); +} + +function MenuItemComponent({ + item, + isCollapsed, + isHovered +}: { + item: MenuItem; + isCollapsed: boolean; + isHovered: boolean; +}) { + const location = useLocation(); + const pathname = location.pathname; + const [isOpen, setIsOpen] = useState(false); + const hasChildren = item.children && item.children.length > 0; + const showText = !isCollapsed || isHovered; + + // Check if any child is active + const isChildActive = useMemo(() => { + if (!hasChildren) return false; + return item.children!.some((child) => child.href === pathname); + }, [hasChildren, item.children, pathname]); + + // Auto open if child is active + useMemo(() => { + if (isChildActive && !isOpen) { + setIsOpen(true); + } + }, [isChildActive, isOpen]); + + // For collapsed state without hover, show tooltip + if (isCollapsed && !isHovered) { + return ( +
  • + + + + + + +

    {item.title}

    +
    +
    +
    +
  • + ); + } + + if (hasChildren) { + return ( +
  • + + + + + {showText && ( + + {item.children?.map((child, childIndex) => { + const isActive = pathname === child.href; + return ( + + + {isActive && ( +
    + )} + {child.title} + + {child.badge && ( + + {child.badge} + + )} + + ); + })} + + )} + +
  • + ); + } + + // Single menu item (no children) + const isActive = pathname === item.href; + + return ( +
  • + +
  • + ); +} + +export function AdminSidebar({ + isOpen, + isCollapsed, + isHovered, + isMobile, + onClose, + onMouseEnter, + onMouseLeave +}: SidebarProps) { + const sidebarWidth = isCollapsed && !isHovered ? "w-20" : "w-72"; + const showText = !isCollapsed || isHovered; + + return ( + + ); +} diff --git a/app/components/layoutpengelola/header.tsx b/app/components/layoutpengelola/header.tsx new file mode 100644 index 0000000..447e8db --- /dev/null +++ b/app/components/layoutpengelola/header.tsx @@ -0,0 +1,246 @@ +// app/components/layoutadmin/header.tsx +import { useState } from "react"; +import { Form } from "@remix-run/react"; +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Badge } from "~/components/ui/badge"; +import { ModeToggle } from "~/components/ui/dark-mode-toggle"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "~/components/ui/dropdown-menu"; +import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; +import { + Menu, + Search, + Bell, + Sun, + Moon, + User, + Settings, + LogOut, + ChevronDown, + MoreHorizontal, + PanelLeftClose, + PanelLeft +} from "lucide-react"; + +interface PengelolaHeaderProps { + onMenuClick: () => void; + sidebarCollapsed: boolean; + isMobile: boolean; +} + +export function PengelolaHeader({ + onMenuClick, + sidebarCollapsed, + isMobile +}: PengelolaHeaderProps) { + // const [isDark, setIsDark] = useState(false); + + return ( +
    +
    + {/* Mobile header */} +
    + {/* Hamburger menu button */} + + + {/* Mobile logo */} +
    +
    + LOGO +
    +
    + + {/* Mobile more menu */} + + + + + +
    + + +
    +
    +
    + + {/* Desktop search - Commented out for now */} + {/*
    +
    + + +
    + + ⌘K + +
    +
    +
    */} +
    + + {/* Desktop header actions */} +
    +
    + {/* Theme toggle */} + + + {/* Notifications */} + + + + + +
    +

    + Notifications + + 3 new + +

    +
    +
    +

    + New user registered +

    +

    + 2 minutes ago +

    +
    +
    +

    + System update available +

    +

    + 1 hour ago +

    +
    +
    +

    + New message received +

    +

    + 3 hours ago +

    +
    +
    + +
    +
    +
    + + {/* User menu */} + + + + + +
    +
    Fahmi Kurniawan
    +
    + pengelola@example.com +
    +
    + + + + Profile + + + + Settings + + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + ); +} diff --git a/app/components/layoutpengelola/layout-wrapper.tsx b/app/components/layoutpengelola/layout-wrapper.tsx new file mode 100644 index 0000000..bef09b5 --- /dev/null +++ b/app/components/layoutpengelola/layout-wrapper.tsx @@ -0,0 +1,78 @@ +import { useState, useEffect } from "react"; +import { PengelolaSidebar } from "./sidebar"; +import { PengelolaHeader } from "./header"; + +interface PengelolaLayoutWrapperProps { + children: React.ReactNode; +} + +export function PengelolaLayoutWrapper({ children }: PengelolaLayoutWrapperProps) { + const [sidebarOpen, setSidebarOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth < 1024); + if (window.innerWidth >= 1024) { + setSidebarOpen(false); + } + }; + + checkMobile(); + window.addEventListener("resize", checkMobile); + return () => window.removeEventListener("resize", checkMobile); + }, []); + + const handleToggleSidebar = () => { + if (isMobile) { + setSidebarOpen(!sidebarOpen); + } else { + setSidebarCollapsed(!sidebarCollapsed); + } + }; + + const sidebarWidth = sidebarCollapsed && !isHovered ? "80px" : "290px"; + const contentMargin = isMobile ? "0" : sidebarWidth; + + return ( +
    + {/* Sidebar */} + setSidebarOpen(false)} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + /> + + {/* Mobile overlay */} + {sidebarOpen && isMobile && ( +
    setSidebarOpen(false)} + /> + )} + + {/* Main content */} +
    + + + {/* Page content */} +
    {children}
    +
    +
    + ); +} diff --git a/app/components/layoutpengelola/sidebar.tsx b/app/components/layoutpengelola/sidebar.tsx new file mode 100644 index 0000000..7c4a0d9 --- /dev/null +++ b/app/components/layoutpengelola/sidebar.tsx @@ -0,0 +1,621 @@ +import { useState, useMemo } from "react"; +import { Link, useLocation } from "@remix-run/react"; +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 { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from "~/components/ui/collapsible"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "~/components/ui/tooltip"; +import { + LayoutDashboard, + Recycle, + Users, + TrendingUp, + FileText, + CreditCard, + BarChart3, + Settings, + MessageCircle, + MapPin, + Package, + DollarSign, + UserCheck, + Building2, + Newspaper, + HelpCircle, + Bell, + Shield, + ChevronDown, + X, + Truck, + Calendar, + ClipboardList, + Target, + Timer, + AlertCircle, + CheckCircle, + Route, + Phone +} from "lucide-react"; +import { cn } from "~/lib/utils"; + +interface SidebarProps { + isOpen: boolean; + isCollapsed: boolean; + isHovered: boolean; + isMobile: boolean; + onClose: () => void; + onMouseEnter: () => void; + onMouseLeave: () => void; +} + +interface MenuItem { + title: string; + icon?: React.ReactNode; + href?: string; + badge?: string; + badgeVariant?: "pro" | "new" | "urgent"; + children?: MenuItem[]; +} + +const operationalMenuItems: MenuItem[] = [ + { + title: "Dashboard", + icon: , + children: [ + { + title: "Overview Harian", + href: "/pengelola/dashboard" + }, + { + title: "Monitoring Real-time", + href: "/pengelola/dashboard/monitoring", + badge: "new" + }, + { title: "Target vs Pencapaian", href: "/pengelola/dashboard/targets" }, + { title: "Jadwal Hari Ini", href: "/pengelola/dashboard/schedule" } + ] + }, + { + title: "Operasional Sampah", + icon: , + children: [ + { + title: "Pengumpulan Harian", + href: "/pengelola/dashboard/collection", + badge: "urgent" + }, + { title: "Sortir & Klasifikasi", href: "/pengelola/dashboard/sorting" }, + { title: "Stok Sampah", href: "/pengelola/dashboard/inventory" }, + { title: "Kualitas Check", href: "/pengelola/dashboard/quality" }, + { + title: "Input Data Berat", + href: "/pengelola/dashboard/weight-input", + badge: "new" + } + ] + }, + { + title: "Manajemen Pengepul", + icon: , + children: [ + { title: "Daftar Pengepul Aktif", href: "/pengelola/collectors/active" }, + { title: "Jadwal Pickup", href: "/pengelola/collectors/schedule" }, + { title: "Performa Pengepul", href: "/pengelola/collectors/performance" }, + { + title: "Pengepul Pending", + href: "/pengelola/collectors/pending", + badge: "urgent" + }, + { title: "Rating & Feedback", href: "/pengelola/collectors/feedback" } + ] + }, + { + title: "Transaksi & Pembayaran", + icon: , + children: [ + { title: "Transaksi Hari Ini", href: "/pengelola/transactions/today" }, + { + title: "Pembayaran Tertunda", + href: "/pengelola/transactions/pending", + badge: "urgent" + }, + { + title: "Verifikasi Pembayaran", + href: "/pengelola/transactions/verification" + }, + { title: "Riwayat Transaksi", href: "/pengelola/transactions/history" }, + { title: "Rekap Harian", href: "/pengelola/transactions/daily-recap" } + ] + } +]; + +const fieldMenuItems: MenuItem[] = [ + { + title: "Area Coverage", + icon: , + children: [ + { title: "Peta Operasional", href: "/pengelola/coverage/map" }, + { title: "Rute Pengumpulan", href: "/pengelola/coverage/routes" }, + { + title: "Titik Pengumpulan", + href: "/pengelola/coverage/collection-points" + }, + { + title: "Area Prioritas", + href: "/pengelola/coverage/priority-areas", + badge: "new" + } + ] + }, + { + title: "Penjadwalan", + icon: , + children: [ + { title: "Jadwal Mingguan", href: "/pengelola/schedule/weekly" }, + { title: "Pengepul On-duty", href: "/pengelola/schedule/on-duty" }, + { title: "Shift Management", href: "/pengelola/schedule/shifts" }, + { + title: "Emergency Pickup", + href: "/pengelola/schedule/emergency", + badge: "urgent" + } + ] + }, + { + title: "Komunikasi", + icon: , + children: [ + { title: "Chat Pengepul", href: "/pengelola/dashboard/chat" }, + { + title: "Broadcast Message", + href: "/pengelola/dashboard/broadcast" + }, + { + title: "Notifikasi Operasional", + href: "/pengelola/dashboard/notifications" + }, + { + title: "Support Ticket", + href: "/pengelola/dashboard/support", + badge: "urgent" + } + ] + }, + { + title: "Task Management", + icon: , + children: [ + { title: "Task Harian", href: "/pengelola/tasks/daily", badge: "urgent" }, + { title: "Checklist Operasi", href: "/pengelola/tasks/checklist" }, + { title: "Follow-up Required", href: "/pengelola/tasks/followup" }, + { title: "Completed Tasks", href: "/pengelola/tasks/completed" } + ] + } +]; + +const reportingMenuItems: MenuItem[] = [ + { + title: "Laporan Operasional", + icon: , + children: [ + { title: "Laporan Harian", href: "/pengelola/reports/daily" }, + { title: "Laporan Mingguan", href: "/pengelola/reports/weekly" }, + { title: "Performa Tim", href: "/pengelola/reports/team-performance" }, + { title: "Analisis Trend", href: "/pengelola/reports/trends" } + ] + }, + { + title: "Monitoring Kinerja", + icon: , + children: [ + { title: "KPI Dashboard", href: "/pengelola/kpi/dashboard" }, + { title: "Target Achievement", href: "/pengelola/kpi/targets" }, + { title: "Efficiency Metrics", href: "/pengelola/kpi/efficiency" }, + { title: "Quality Metrics", href: "/pengelola/kpi/quality" } + ] + }, + { + title: "Alerts & Issues", + icon: , + children: [ + { + title: "Alert Aktif", + href: "/pengelola/alerts/active", + badge: "urgent" + }, + { title: "Issue Tracking", href: "/pengelola/alerts/issues" }, + { title: "Maintenance Schedule", href: "/pengelola/alerts/maintenance" }, + { title: "Escalation Log", href: "/pengelola/alerts/escalation" } + ] + }, + { + title: "Pengaturan", + icon: , + children: [ + { title: "Profil Pengelola", href: "/pengelola/settings/profile" }, + { + title: "Notifikasi Setting", + href: "/pengelola/settings/notifications" + }, + { title: "Area Tanggung Jawab", href: "/pengelola/settings/area" }, + { title: "Tim & Koordinator", href: "/pengelola/settings/team" } + ] + } +]; + +function MenuSection({ + title, + items, + isCollapsed, + isHovered +}: { + title: string; + items: MenuItem[]; + isCollapsed: boolean; + isHovered: boolean; +}) { + const showText = !isCollapsed || isHovered; + + return ( +
    + {showText && ( +

    + {title} +

    + )} +
      + {items.map((item, index) => ( + + ))} +
    +
    + ); +} + +function MenuItemComponent({ + item, + isCollapsed, + isHovered +}: { + item: MenuItem; + isCollapsed: boolean; + isHovered: boolean; +}) { + const location = useLocation(); + const pathname = location.pathname; + const [isOpen, setIsOpen] = useState(false); + const hasChildren = item.children && item.children.length > 0; + const showText = !isCollapsed || isHovered; + + // Check if any child is active + const isChildActive = useMemo(() => { + if (!hasChildren) return false; + return item.children!.some((child) => child.href === pathname); + }, [hasChildren, item.children, pathname]); + + // Auto open if child is active + useMemo(() => { + if (isChildActive && !isOpen) { + setIsOpen(true); + } + }, [isChildActive, isOpen]); + + // For collapsed state without hover, show tooltip + if (isCollapsed && !isHovered) { + return ( +
  • + + + + + + +

    {item.title}

    +
    +
    +
    +
  • + ); + } + + if (hasChildren) { + return ( +
  • + + + + + {showText && ( + + {item.children?.map((child, childIndex) => { + const isActive = pathname === child.href; + return ( + + + {isActive && ( +
    + )} + {child.title} + + {child.badge && ( + + {child.badge} + + )} + + ); + })} + + )} + +
  • + ); + } + + // Single menu item (no children) + const isActive = pathname === item.href; + + return ( +
  • + +
  • + ); +} + +export function PengelolaSidebar({ + isOpen, + isCollapsed, + isHovered, + isMobile, + onClose, + onMouseEnter, + onMouseLeave +}: SidebarProps) { + const sidebarWidth = isCollapsed && !isHovered ? "w-20" : "w-72"; + const showText = !isCollapsed || isHovered; + + return ( + + ); +} diff --git a/app/components/map/leaflet-map.tsx b/app/components/map/leaflet-map.tsx new file mode 100644 index 0000000..eca32a9 --- /dev/null +++ b/app/components/map/leaflet-map.tsx @@ -0,0 +1,192 @@ +import { useEffect, useState } from "react"; +import { MapPin } from "lucide-react"; + +interface Location { + id: string; + name: string; + type: string; + address: string; + coordinates: [number, number]; + status: string; + capacity: number; + currentLoad: number; + lastPickup: string; + coverage: string; + population: number; + schedule: string; +} + +interface MapComponentProps { + locations: Location[]; + onLocationSelect: (location: Location) => void; +} + +export function LeafletMap({ locations, onLocationSelect }: MapComponentProps) { + const [mapComponents, setMapComponents] = useState(null); + const [leaflet, setLeaflet] = useState(null); + + useEffect(() => { + const loadMapComponents = async () => { + try { + const [{ MapContainer, TileLayer, Marker, Popup }, L] = + await Promise.all([import("react-leaflet"), import("leaflet")]); + + delete (L as any).Icon.Default.prototype._getIconUrl; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png", + iconUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png", + shadowUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png" + }); + + setMapComponents({ MapContainer, TileLayer, Marker, Popup }); + setLeaflet(L); + } catch (error) { + console.error("Error loading map components:", error); + } + }; + + loadMapComponents(); + }, []); + + // Custom icon function + const getMarkerIcon = (type: string, status: string) => { + if (!leaflet) return undefined; + + const color = + status === "active" + ? "#10b981" + : status === "maintenance" + ? "#f59e0b" + : "#ef4444"; + + return leaflet.divIcon({ + html: ` +
    +
    +
    + `, + className: "custom-div-icon", + iconSize: [24, 24], + iconAnchor: [12, 12], + popupAnchor: [0, -12] + }); + }; + + if (!mapComponents || !leaflet) { + return ( +
    +
    + +

    Loading map...

    +
    +
    + ); + } + + const { MapContainer, TileLayer, Marker, Popup } = mapComponents; + + return ( + + + + {locations.map((location) => ( + onLocationSelect(location) + }} + > + +
    +

    {location.name}

    +

    {location.address}

    + +
    +
    + Status: + + {location.status} + +
    + +
    + Load: + + {Math.round( + (location.currentLoad / location.capacity) * 100 + )} + % + +
    + +
    +
    +
    + +
    + Population: + {location.population.toLocaleString()} +
    + +
    + Last Pickup: + {location.lastPickup} +
    +
    + + +
    +
    +
    + ))} +
    + ); +} diff --git a/app/components/ui/3d-card.tsx b/app/components/ui/3d-card.tsx new file mode 100644 index 0000000..fe0d9c4 --- /dev/null +++ b/app/components/ui/3d-card.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { cn } from "~/lib/utils"; + +import React, { + createContext, + useState, + useContext, + useRef, + useEffect, +} from "react"; + +const MouseEnterContext = createContext< + [boolean, React.Dispatch>] | undefined +>(undefined); + +export const CardContainer = ({ + children, + className, + containerClassName, +}: { + children?: React.ReactNode; + className?: string; + containerClassName?: string; +}) => { + const containerRef = useRef(null); + const [isMouseEntered, setIsMouseEntered] = useState(false); + + const handleMouseMove = (e: React.MouseEvent) => { + if (!containerRef.current) return; + const { left, top, width, height } = + containerRef.current.getBoundingClientRect(); + const x = (e.clientX - left - width / 2) / 25; + const y = (e.clientY - top - height / 2) / 25; + containerRef.current.style.transform = `rotateY(${x}deg) rotateX(${y}deg)`; + }; + + const handleMouseEnter = (e: React.MouseEvent) => { + setIsMouseEntered(true); + if (!containerRef.current) return; + }; + + const handleMouseLeave = (e: React.MouseEvent) => { + if (!containerRef.current) return; + setIsMouseEntered(false); + containerRef.current.style.transform = `rotateY(0deg) rotateX(0deg)`; + }; + return ( + +
    +
    + {children} +
    +
    +
    + ); +}; + +export const CardBody = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => { + return ( +
    *]:[transform-style:preserve-3d]", + className + )} + > + {children} +
    + ); +}; + +export const CardItem = ({ + as: Tag = "div", + children, + className, + translateX = 0, + translateY = 0, + translateZ = 0, + rotateX = 0, + rotateY = 0, + rotateZ = 0, + ...rest +}: { + as?: React.ElementType; + children: React.ReactNode; + className?: string; + translateX?: number | string; + translateY?: number | string; + translateZ?: number | string; + rotateX?: number | string; + rotateY?: number | string; + rotateZ?: number | string; + [key: string]: any; +}) => { + const ref = useRef(null); + const [isMouseEntered] = useMouseEnter(); + + useEffect(() => { + handleAnimations(); + }, [isMouseEntered]); + + const handleAnimations = () => { + if (!ref.current) return; + if (isMouseEntered) { + ref.current.style.transform = `translateX(${translateX}px) translateY(${translateY}px) translateZ(${translateZ}px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) rotateZ(${rotateZ}deg)`; + } else { + ref.current.style.transform = `translateX(0px) translateY(0px) translateZ(0px) rotateX(0deg) rotateY(0deg) rotateZ(0deg)`; + } + }; + + return ( + + {children} + + ); +}; + +// Create a hook to use the context +export const useMouseEnter = () => { + const context = useContext(MouseEnterContext); + if (context === undefined) { + throw new Error("useMouseEnter must be used within a MouseEnterProvider"); + } + return context; +}; diff --git a/app/components/ui/alert-dialog.tsx b/app/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..1b3980b --- /dev/null +++ b/app/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/app/components/ui/alert.tsx b/app/components/ui/alert.tsx new file mode 100644 index 0000000..6c72a38 --- /dev/null +++ b/app/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 px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + 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/app/components/ui/avatar.tsx b/app/components/ui/avatar.tsx new file mode 100644 index 0000000..706f177 --- /dev/null +++ b/app/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/app/components/ui/badge.tsx b/app/components/ui/badge.tsx new file mode 100644 index 0000000..5e2b7ac --- /dev/null +++ b/app/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-md 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 shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow 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/app/components/ui/button.tsx b/app/components/ui/button.tsx new file mode 100644 index 0000000..ef511cf --- /dev/null +++ b/app/components/ui/button.tsx @@ -0,0 +1,57 @@ +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 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring 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 shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + 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/app/components/ui/card.tsx b/app/components/ui/card.tsx new file mode 100644 index 0000000..874ce09 --- /dev/null +++ b/app/components/ui/card.tsx @@ -0,0 +1,76 @@ +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< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLDivElement, + 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/app/components/ui/collapsible.tsx b/app/components/ui/collapsible.tsx new file mode 100644 index 0000000..a23e7a2 --- /dev/null +++ b/app/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/app/components/ui/dark-mode-toggle.tsx b/app/components/ui/dark-mode-toggle.tsx new file mode 100644 index 0000000..7b8e8fb --- /dev/null +++ b/app/components/ui/dark-mode-toggle.tsx @@ -0,0 +1,34 @@ +import { Moon, Sun } from "lucide-react"; +import { Theme, useTheme } from "remix-themes"; + +import { Button } from "./button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "./dropdown-menu"; + +export function ModeToggle() { + const [, setTheme] = useTheme(); + + return ( + + + + + + setTheme(Theme.LIGHT)}> + Light + + setTheme(Theme.DARK)}> + Dark + + + + ); +} diff --git a/app/components/ui/dialog.tsx b/app/components/ui/dialog.tsx new file mode 100644 index 0000000..343a00b --- /dev/null +++ b/app/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/app/components/ui/dropdown-menu.tsx b/app/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..3f394dc --- /dev/null +++ b/app/components/ui/dropdown-menu.tsx @@ -0,0 +1,199 @@ +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, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/app/components/ui/input.tsx b/app/components/ui/input.tsx new file mode 100644 index 0000000..601b3d2 --- /dev/null +++ b/app/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "~/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/app/components/ui/label.tsx b/app/components/ui/label.tsx new file mode 100644 index 0000000..dbefb85 --- /dev/null +++ b/app/components/ui/label.tsx @@ -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, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/app/components/ui/macbook-scroll.tsx b/app/components/ui/macbook-scroll.tsx new file mode 100644 index 0000000..fe4fa57 --- /dev/null +++ b/app/components/ui/macbook-scroll.tsx @@ -0,0 +1,710 @@ +// app/components/ui/macbook-scroll.tsx +import React, { useEffect, useRef, useState } from "react"; +import { MotionValue, motion, useScroll, useTransform } from "motion/react"; +import { cn } from "~/lib/utils"; +import { + Monitor, + MonitorSpeaker, + Sun, + Moon, + Search, + Mic, + SkipBack, + Play, + SkipForward, + Volume, + Volume1, + Volume2, + VolumeX, + Globe, + Command, + ChevronUp, + ChevronLeft, + ChevronRight, + ChevronDown, + Grid3x3 +} from "lucide-react"; + +interface MacbookScrollProps { + src?: string; + showGradient?: boolean; + title?: string | React.ReactNode; + badge?: React.ReactNode; + className?: string; + // Target landing - best practice approach + landingTargetId?: string; + landingOffset?: number; +} + +export const MacbookScroll = ({ + src, + showGradient = true, + title, + badge, + className, + landingTargetId, + landingOffset = 0 +}: MacbookScrollProps) => { + const ref = useRef(null); + const [landingDistance, setLandingDistance] = useState(800); + + const { scrollYProgress } = useScroll({ + target: ref, + offset: ["start start", "end start"], + }); + + // Responsive landing calculation - best practice + useEffect(() => { + const calculateLanding = () => { + if (!landingTargetId) return; + + const target = document.getElementById(landingTargetId); + const container = ref.current; + + if (target && container) { + const containerRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + + // Calculate absolute positions + const containerTop = containerRect.top + scrollTop; + const targetTop = targetRect.top + scrollTop; + + // Distance from container to target + const distance = targetTop - containerTop + landingOffset; + + // Mobile responsive adjustment - more aggressive + const isMobile = window.innerWidth < 1024; // Changed from 768 to 1024 + const finalDistance = isMobile ? distance * 2.5 : distance; // More reduction for mobile + + setLandingDistance(Math.max(finalDistance, 200)); + } + }; + + calculateLanding(); + const handleResize = () => calculateLanding(); + const handleLoad = () => calculateLanding(); + + window.addEventListener('resize', handleResize); + window.addEventListener('load', handleLoad); + + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('load', handleLoad); + }; + }, [landingTargetId, landingOffset]); + + // Simple transforms + const scaleX = useTransform(scrollYProgress, [0, 0.3], [1.2, 1.5]); + const scaleY = useTransform(scrollYProgress, [0, 0.3], [0.6, 1.5]); + const translate = useTransform(scrollYProgress, [0, 1], [0, landingDistance]); + const rotate = useTransform(scrollYProgress, [0.1, 0.12, 0.3], [-28, -28, 0]); + const textTransform = useTransform(scrollYProgress, [0, 0.3], [0, 100]); + const textOpacity = useTransform(scrollYProgress, [0, 0.2], [1, 0]); + + return ( +
    + {/* Menggunakan CSS variables untuk responsive - best practice Remix */} + + + + {title || ( + + This Macbook is built with Tailwindcss.
    No kidding. +
    + )} +
    + + {/* Lid */} + + + {/* Base - responsive sizing */} +
    + {/* Keyboard bar */} +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + + + +
    + + {showGradient && ( +
    + )} + + {badge &&
    {badge}
    } +
    +
    + ); +}; + +export const Lid = ({ + scaleX, + scaleY, + rotate, + translate, + src, +}: { + scaleX: MotionValue; + scaleY: MotionValue; + rotate: MotionValue; + translate: MotionValue; + src?: string; +}) => { + return ( +
    +
    +
    +
    +
    + + +
    + {src && ( + screen content + )} + +
    + ); +}; + +export const Trackpad = () => { + return ( +
    + ); +}; + +export const Keypad = () => { + return ( +
    + {/* First Row */} +
    + + esc + + + + F1 + + + + F2 + + + + F3 + + + + F4 + + + + F5 + + + + F6 + + + + F7 + + + + F8 + + + + F9 + + + + F10 + + + + F11 + + + + F12 + + +
    +
    +
    + +
    + + {/* Second row */} +
    + + ~ + ` + + + ! + 1 + + + @ + 2 + + + # + 3 + + + $ + 4 + + + % + 5 + + + ^ + 6 + + + & + 7 + + + * + 8 + + + ( + 9 + + + ) + 0 + + + _ + - + + + + + = + + + delete + +
    + + {/* Third row */} +
    + + tab + + + Q + + + W + + + E + + + R + + + T + + + Y + + + U + + + I + + + O + + + P + + + {`{`} + {`[`} + + + {`}`} + {`]`} + + + {`|`} + {`\\`} + +
    + + {/* Fourth Row */} +
    + + caps lock + + + A + + + S + + + D + + + F + + + G + + + H + + + J + + + K + + + L + + + {`:`} + {`;`} + + + {`"`} + {`'`} + + + return + +
    + + {/* Fifth Row */} +
    + + shift + + + Z + + + X + + + C + + + V + + + B + + + N + + + M + + + {`<`} + {`,`} + + + {`>`} + {`.`} + + + {`?`} + {`/`} + + + shift + +
    + + {/* Sixth Row */} +
    + +
    + fn +
    +
    + +
    +
    + +
    + +
    +
    + control +
    +
    + +
    + +
    +
    + option +
    +
    + +
    + +
    +
    + command +
    +
    + + +
    + +
    +
    + command +
    +
    + +
    + +
    +
    + option +
    +
    +
    + + + +
    + + + + + + + + + +
    +
    +
    +
    + ); +}; + +export const KBtn = ({ + className, + children, + childrenClassName, + backlit = true, +}: { + className?: string; + children?: React.ReactNode; + childrenClassName?: string; + backlit?: boolean; +}) => { + return ( +
    +
    +
    + {children} +
    +
    +
    + ); +}; + +export const SpeakerGrid = () => { + return ( +
    + ); +}; + +export const OptionKey = ({ className }: { className: string }) => { + return ( + + + + + + ); +}; \ No newline at end of file diff --git a/app/components/ui/progress.tsx b/app/components/ui/progress.tsx new file mode 100644 index 0000000..ba7cb0b --- /dev/null +++ b/app/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "~/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/app/components/ui/scroll-area.tsx b/app/components/ui/scroll-area.tsx new file mode 100644 index 0000000..f66ab4f --- /dev/null +++ b/app/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/app/components/ui/select.tsx b/app/components/ui/select.tsx new file mode 100644 index 0000000..2a7b354 --- /dev/null +++ b/app/components/ui/select.tsx @@ -0,0 +1,157 @@ +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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/app/components/ui/separator.tsx b/app/components/ui/separator.tsx new file mode 100644 index 0000000..464677f --- /dev/null +++ b/app/components/ui/separator.tsx @@ -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, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/app/components/ui/sheet.tsx b/app/components/ui/sheet.tsx new file mode 100644 index 0000000..88c8b23 --- /dev/null +++ b/app/components/ui/sheet.tsx @@ -0,0 +1,138 @@ +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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, 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=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", + { + 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, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/app/components/ui/switch.tsx b/app/components/ui/switch.tsx new file mode 100644 index 0000000..87485a3 --- /dev/null +++ b/app/components/ui/switch.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/app/components/ui/table.tsx b/app/components/ui/table.tsx new file mode 100644 index 0000000..a09f16e --- /dev/null +++ b/app/components/ui/table.tsx @@ -0,0 +1,120 @@ +import * as React from "react" + +import { cn } from "~/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    + + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
    [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/app/components/ui/tabs.tsx b/app/components/ui/tabs.tsx new file mode 100644 index 0000000..93f2914 --- /dev/null +++ b/app/components/ui/tabs.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/app/components/ui/textarea.tsx b/app/components/ui/textarea.tsx new file mode 100644 index 0000000..c480928 --- /dev/null +++ b/app/components/ui/textarea.tsx @@ -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 ( +