feat: initial commit with seeded database and premium ui changes
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
|
||||
/** Current page segment shown in the header — pass a nav item or `{ title, icon? }`. */
|
||||
export type AppBreadcrumbPage = {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export function AppBreadcrumbs({ page }: { page?: AppBreadcrumbPage | null }) {
|
||||
if (!page?.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="flex items-center gap-2 [&>svg]:size-3.5">
|
||||
{page.icon}
|
||||
{page.title}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DecorIcon } from "@/components/decor-icon";
|
||||
import { AppBreadcrumbs } from "@/components/app-breadcrumbs";
|
||||
import { MapsBreadcrumb } from "@/components/maps/maps-breadcrumb";
|
||||
import { getNavLinks } from "@/components/app-shared";
|
||||
import { CustomSidebarTrigger } from "@/components/custom-sidebar-trigger";
|
||||
import { NavUser } from "@/components/nav-user";
|
||||
import { TrendingDownIcon, RoadHorizonIcon, FuelIcon } from "@/components/ui/phosphor-icons";
|
||||
|
||||
const MAP_PAGES: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||
"/dashboard/poverty": { label: "Peta Kemiskinan", icon: <TrendingDownIcon className="size-3.5" /> },
|
||||
"/dashboard/land": { label: "Lahan dan Jalan", icon: <RoadHorizonIcon className="size-3.5" /> },
|
||||
"/dashboard/gas-station": { label: "SPBU dan Charger", icon: <FuelIcon className="size-3.5" /> },
|
||||
};
|
||||
|
||||
export function AppHeader({ activePath, fullBleed = false, onExitFullBleed }: { activePath?: string; fullBleed?: boolean; onExitFullBleed?: () => void }) {
|
||||
const links = getNavLinks(activePath);
|
||||
const activeItem = links.find((item) => item.isActive);
|
||||
const mapPage = activePath ? MAP_PAGES[activePath] : undefined;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"sticky top-0 z-50 flex h-14 shrink-0 items-center justify-between gap-2 border-b px-4 md:px-6",
|
||||
"bg-background/95 backdrop-blur-sm supports-backdrop-filter:bg-background/50"
|
||||
)}
|
||||
>
|
||||
<DecorIcon className="hidden md:block" position="bottom-left" />
|
||||
<div className="flex items-center gap-3">
|
||||
<CustomSidebarTrigger />
|
||||
<Separator
|
||||
className="mr-2 h-4 data-[orientation=vertical]:self-center"
|
||||
orientation="vertical"
|
||||
/>
|
||||
{mapPage ? (
|
||||
<MapsBreadcrumb currentPage={mapPage.label} currentIcon={mapPage.icon} isLeafletOpen={fullBleed} onExitMap={onExitFullBleed} />
|
||||
) : (
|
||||
<AppBreadcrumbs page={activeItem} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<NavUser />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
FuelIcon,
|
||||
SettingsIcon,
|
||||
HelpCircleIcon,
|
||||
BookOpenIcon,
|
||||
TrendingDownIcon,
|
||||
RoadHorizonIcon,
|
||||
HouseIcon
|
||||
} from "@/components/ui/phosphor-icons";
|
||||
|
||||
export type SidebarNavItem = {
|
||||
title: string;
|
||||
path?: string;
|
||||
icon?: ReactNode;
|
||||
isActive?: boolean;
|
||||
subItems?: SidebarNavItem[];
|
||||
};
|
||||
|
||||
export type SidebarNavGroup = {
|
||||
label?: string;
|
||||
items: SidebarNavItem[];
|
||||
};
|
||||
|
||||
const ROLE_ALLOWED_MAP_PATHS: Record<string, string[]> = {
|
||||
superadmin: ["/dashboard/poverty", "/dashboard/land", "/dashboard/gas-station"],
|
||||
admin_poverty: ["/dashboard/poverty"],
|
||||
admin_lands_roads: ["/dashboard/land"],
|
||||
admin_gas_stations: ["/dashboard/gas-station"],
|
||||
};
|
||||
|
||||
const checkIsActive = (activePath?: string, itemPath?: string): boolean => {
|
||||
if (!activePath || !itemPath) return false;
|
||||
if (itemPath.startsWith("#")) return false; // Ignore hash placeholders
|
||||
if (itemPath === "/dashboard") {
|
||||
return activePath === "/dashboard";
|
||||
}
|
||||
return activePath === itemPath || activePath.startsWith(itemPath + "/");
|
||||
};
|
||||
|
||||
export const getNavGroups = (activePath?: string, role?: string): SidebarNavGroup[] => {
|
||||
const allowedMapPaths = role ? (ROLE_ALLOWED_MAP_PATHS[role] ?? []) : null;
|
||||
|
||||
const allMapItems = [
|
||||
{
|
||||
title: "Peta Kemiskinan",
|
||||
path: "/dashboard/poverty",
|
||||
icon: <TrendingDownIcon />,
|
||||
},
|
||||
{
|
||||
title: "Lahan & Jalan",
|
||||
path: "/dashboard/land",
|
||||
icon: <RoadHorizonIcon />,
|
||||
},
|
||||
{
|
||||
title: "SPBU & Charger",
|
||||
path: "/dashboard/gas-station",
|
||||
icon: <FuelIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
const mapItems = allMapItems
|
||||
.filter((item) => !allowedMapPaths || allowedMapPaths.includes(item.path))
|
||||
.map((item) => ({ ...item, isActive: checkIsActive(activePath, item.path) }));
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Ikhtisar",
|
||||
items: [
|
||||
{
|
||||
title: "Beranda",
|
||||
path: "/dashboard",
|
||||
icon: <HouseIcon />,
|
||||
isActive: checkIsActive(activePath, "/dashboard"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Peta Visual",
|
||||
items: mapItems,
|
||||
},
|
||||
{
|
||||
label: "Administrasi",
|
||||
items: [
|
||||
{
|
||||
title: "Pengaturan",
|
||||
path: "/dashboard/settings",
|
||||
icon: <SettingsIcon />,
|
||||
isActive: checkIsActive(activePath, "/dashboard/settings"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const navGroups = getNavGroups();
|
||||
|
||||
export const footerNavLinks: SidebarNavItem[] = [
|
||||
{
|
||||
title: "Pusat Bantuan",
|
||||
path: "#/help",
|
||||
icon: <HelpCircleIcon />,
|
||||
},
|
||||
{
|
||||
title: "Dokumentasi",
|
||||
path: "#/documentation",
|
||||
icon: <BookOpenIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
export const getNavLinks = (activePath?: string, role?: string): SidebarNavItem[] => [
|
||||
...getNavGroups(activePath, role).flatMap((group) =>
|
||||
group.items.flatMap((item) =>
|
||||
item.subItems?.length ? [item, ...item.subItems] : [item]
|
||||
)
|
||||
),
|
||||
...footerNavLinks,
|
||||
];
|
||||
|
||||
export const navLinks = getNavLinks();
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { AppHeader } from "@/components/app-header";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
activePath,
|
||||
fullBleed,
|
||||
onExitFullBleed,
|
||||
hideHeader = false
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
activePath?: string;
|
||||
fullBleed?: boolean;
|
||||
onExitFullBleed?: () => void;
|
||||
hideHeader?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider className={cn("[--app-wrapper-max-width:80rem]", fullBleed && "h-screen overflow-hidden")}>
|
||||
<AppSidebar activePath={activePath} />
|
||||
<SidebarInset className={cn(fullBleed && "h-screen overflow-hidden flex flex-col")}>
|
||||
{!hideHeader && (
|
||||
<AppHeader activePath={activePath} fullBleed={fullBleed} onExitFullBleed={onExitFullBleed} />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 flex-col",
|
||||
fullBleed
|
||||
? cn("p-0 max-w-none overflow-hidden", hideHeader ? "h-screen" : "h-[calc(100vh-3.5rem)]")
|
||||
: "p-4 md:p-6 mx-auto w-full max-w-(--app-wrapper-max-width)"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { footerNavLinks, getNavGroups } from "@/components/app-shared";
|
||||
import { NavGroup } from "@/components/nav-group";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
export function AppSidebar({ activePath }: { activePath?: string }) {
|
||||
const { user } = useAuth();
|
||||
const groups = getNavGroups(activePath, user?.role);
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
className={cn(
|
||||
"*:data-[slot=sidebar-inner]:bg-background",
|
||||
"*:data-[slot=sidebar-inner]:dark:bg-[radial-gradient(60%_18%_at_10%_0%,--theme(--color-foreground/.08),transparent)]",
|
||||
|
||||
// Default text/icon colors for sidebar buttons (faded)
|
||||
"**:data-[slot=sidebar-menu-button]:[&>span]:text-foreground/60",
|
||||
"**:data-[slot=sidebar-menu-button]:[&>svg]:text-foreground/50",
|
||||
"**:data-[slot=sidebar-menu-button]:[&>span]:transition-colors",
|
||||
"**:data-[slot=sidebar-menu-button]:[&>svg]:transition-colors",
|
||||
|
||||
// Hover states for sidebar buttons
|
||||
"**:data-[slot=sidebar-menu-button]:hover:bg-sidebar-accent/50",
|
||||
"**:data-[slot=sidebar-menu-button]:hover:[&>span]:text-foreground",
|
||||
"**:data-[slot=sidebar-menu-button]:hover:[&>svg]:text-foreground",
|
||||
|
||||
// Active state (Vermilion Pulse theme color)
|
||||
"**:data-[slot=sidebar-menu-button]:data-[active]:bg-primary/10!",
|
||||
"**:data-[slot=sidebar-menu-button]:data-[active]:[&>span]:text-primary!",
|
||||
"**:data-[slot=sidebar-menu-button]:data-[active]:[&>span]:font-semibold",
|
||||
"**:data-[slot=sidebar-menu-button]:data-[active]:[&>svg]:text-primary!",
|
||||
"**:data-[slot=sidebar-menu-button]:data-[active]:hover:bg-primary/15!",
|
||||
|
||||
// Default text/icon colors for sidebar sub-buttons (faded)
|
||||
"**:data-[slot=sidebar-menu-sub-button]:[&>span]:text-foreground/60",
|
||||
"**:data-[slot=sidebar-menu-sub-button]:[&>span]:transition-colors",
|
||||
|
||||
// Hover states for sub-buttons
|
||||
"**:data-[slot=sidebar-menu-sub-button]:hover:bg-sidebar-accent/50",
|
||||
"**:data-[slot=sidebar-menu-sub-button]:hover:[&>span]:text-foreground",
|
||||
|
||||
// Active state for sub-buttons
|
||||
"**:data-[slot=sidebar-menu-sub-button]:data-[active]:bg-primary/10!",
|
||||
"**:data-[slot=sidebar-menu-sub-button]:data-[active]:[&>span]:text-primary!",
|
||||
"**:data-[slot=sidebar-menu-sub-button]:data-[active]:[&>span]:font-semibold",
|
||||
"**:data-[slot=sidebar-menu-sub-button]:data-[active]:hover:bg-primary/15!"
|
||||
)}
|
||||
collapsible="icon"
|
||||
variant="sidebar"
|
||||
>
|
||||
<SidebarHeader className="h-14 justify-center border-b px-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<SidebarMenuButton render={<a href="/dashboard" />}><img src="/waras_io/favicon-32x32.png" className="size-5 shrink-0 rounded" alt="Waras Logo" /><span className="font-medium text-foreground!">Waras</span></SidebarMenuButton>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{groups.map((group, index) => (
|
||||
<NavGroup key={`sidebar-group-${index}`} {...group} />
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="gap-0 p-0">
|
||||
<SidebarMenu className="border-t p-2">
|
||||
{footerNavLinks.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton className="text-muted-foreground" isActive={item.isActive} size="sm" tooltip={item.title} render={<a href={item.path} />}>{item.icon}<span>{item.title}</span></SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
<div className="px-4 pt-4 pb-2 transition-opacity group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:opacity-0">
|
||||
<p className="text-nowrap text-[9px] text-muted-foreground">
|
||||
© {new Date().getFullYear()} Waras
|
||||
</p>
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type React from "react";
|
||||
|
||||
export function AuthDivider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className="relative flex w-full items-center" {...props}>
|
||||
<div className="w-full border-t" />
|
||||
<div className="flex w-max justify-center text-nowrap px-2 text-muted-foreground text-xs">
|
||||
{children}
|
||||
</div>
|
||||
<div className="w-full border-t" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { Logo } from "@/components/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group";
|
||||
import { FloatingPaths } from "@/components/floating-paths";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
AtSignIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
KeyRoundIcon,
|
||||
} from "@/components/ui/phosphor-icons";
|
||||
|
||||
const BACKEND_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:4000";
|
||||
|
||||
const ROLE_HOME: Record<string, string> = {
|
||||
superadmin: "/dashboard",
|
||||
admin_poverty: "/dashboard/poverty",
|
||||
admin_lands_roads: "/dashboard/land",
|
||||
admin_gas_stations: "/dashboard/gas-station",
|
||||
};
|
||||
|
||||
const DEMO_CREDENTIALS = [
|
||||
{
|
||||
label: "Super Admin",
|
||||
username: "superadmin",
|
||||
password: "Super@1234",
|
||||
routes: "All routes",
|
||||
},
|
||||
{
|
||||
label: "Admin Poverty Map",
|
||||
username: "admin_poverty",
|
||||
password: "Poverty@1234",
|
||||
routes: "/dashboard/poverty only",
|
||||
},
|
||||
{
|
||||
label: "Admin Lahan & Jalan",
|
||||
username: "admin_lands",
|
||||
password: "Lands@1234",
|
||||
routes: "/dashboard/land only",
|
||||
},
|
||||
{
|
||||
label: "Admin SPBU",
|
||||
username: "admin_gas",
|
||||
password: "Gas@1234",
|
||||
routes: "/dashboard/gas-station only",
|
||||
},
|
||||
];
|
||||
|
||||
export function AuthPage() {
|
||||
const { login } = useAuth();
|
||||
const router = useRouter();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: identifier.trim(),
|
||||
password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Login failed");
|
||||
return;
|
||||
}
|
||||
|
||||
login(data.token, data.user);
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const redirectTo = params.get("redirectTo");
|
||||
|
||||
router.replace(
|
||||
redirectTo ?? ROLE_HOME[data.user.role] ?? "/dashboard",
|
||||
);
|
||||
} catch {
|
||||
setError("Cannot connect to server. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative md:h-screen md:overflow-hidden lg:grid lg:grid-cols-2">
|
||||
<div className="relative hidden h-full flex-col border-r bg-secondary p-10 lg:flex dark:bg-secondary/20">
|
||||
<div className="absolute inset-0 bg-linear-to-b from-transparent via-transparent to-background" />
|
||||
|
||||
<Logo className="relative z-10 mr-auto h-10 w-auto" />
|
||||
|
||||
<div className="z-10 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-xl">
|
||||
“Aplikasi ini berguna dalam membantu mengelola data spasial
|
||||
dengan lebih efisien.”
|
||||
</p>
|
||||
|
||||
<footer className="font-mono font-semibold text-sm">
|
||||
~ Pangkywara
|
||||
</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0">
|
||||
<FloatingPaths position={1} />
|
||||
<FloatingPaths position={-1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex min-h-screen flex-col justify-center px-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="absolute top-6 left-6 z-20 inline-flex h-6 items-center justify-center gap-2 rounded-md px-3 font-medium text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Kembali
|
||||
</Link>
|
||||
|
||||
{/* Top Shades */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 isolate -z-10 opacity-60 contain-strict"
|
||||
>
|
||||
<div className="absolute top-0 right-0 h-320 w-140 -translate-y-87.5 rounded-full bg-[radial-gradient(68.54%_68.72%_at_55.02%_31.46%,--theme(--color-foreground/.06)_0,hsla(0,0%,55%,.02)_50%,--theme(--color-foreground/.01)_80%)]" />
|
||||
<div className="absolute top-0 right-0 h-320 w-60 rounded-full bg-[radial-gradient(50%_50%_at_50%_50%,--theme(--color-foreground/.04)_0,--theme(--color-foreground/.01)_80%,transparent_100%)] [translate:5%_-50%]" />
|
||||
<div className="absolute top-0 right-0 h-320 w-60 -translate-y-87.5 rounded-full bg-[radial-gradient(50%_50%_at_50%_50%,--theme(--color-foreground/.04)_0,--theme(--color-foreground/.01)_80%,transparent_100%)]" />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto space-y-4 sm:w-sm">
|
||||
<Logo className="h-4.5 w-auto lg:hidden" />
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<h1 className="font-bold text-2xl tracking-wide">Login</h1>
|
||||
|
||||
<p className="text-base text-muted-foreground">
|
||||
Masuk ke akun Anda untuk mengakses dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-3" onSubmit={handleSubmit}>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
placeholder="Username or email"
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
|
||||
<InputGroupAddon align="inline-start">
|
||||
<AtSignIcon />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
placeholder="Password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
|
||||
<InputGroupAddon align="inline-start">
|
||||
<KeyRoundIcon />
|
||||
</InputGroupAddon>
|
||||
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
aria-label={
|
||||
showPassword ? "Hide password" : "Show password"
|
||||
}
|
||||
onClick={() => setShowPassword((value) => !value)}
|
||||
>
|
||||
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button className="w-full" type="submit" disabled={isLoading}>
|
||||
{isLoading ? "Logging in…" : "Login Sekarang"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Quick Demo Credentials */}
|
||||
<div className="pt-4 border-t border-border/50 flex flex-col gap-2.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h2 className="text-[11px] font-bold text-foreground/60 uppercase tracking-wider">Demo Credentials (Quick Login)</h2>
|
||||
<p className="text-[11px] text-muted-foreground">Click a role to auto-fill the login form:</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{DEMO_CREDENTIALS.map((cred) => (
|
||||
<button
|
||||
key={cred.username}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIdentifier(cred.username);
|
||||
setPassword(cred.password);
|
||||
}}
|
||||
className="flex flex-col items-start gap-0.5 p-2.5 rounded-lg border border-border bg-card/50 text-left transition-all hover:bg-muted/80 hover:border-primary/40 group cursor-pointer"
|
||||
>
|
||||
<span className="text-xs font-bold text-foreground group-hover:text-primary transition-colors">
|
||||
{cred.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono truncate w-full">
|
||||
U: {cred.username} | P: {cred.password}
|
||||
</span>
|
||||
<span className="text-[9.5px] font-medium text-primary bg-primary/10 px-1 rounded-sm mt-0.5">
|
||||
{cred.routes}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
|
||||
export function CustomSidebarTrigger() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger delay={1000} render={<SidebarTrigger />} />
|
||||
<TooltipContent className="px-2 py-1" side="right">
|
||||
Toggle Sidebar{" "}
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>b</Kbd>
|
||||
</KbdGroup>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { DashboardCard } from "@/components/dashboard-card";
|
||||
import {
|
||||
FuelIcon,
|
||||
LayersIcon,
|
||||
UserPlusIcon,
|
||||
} from "@/components/ui/phosphor-icons";
|
||||
import type { ActivityItem, ActivityKind } from "@/lib/dashboard-types";
|
||||
|
||||
interface DashboardActivityProps {
|
||||
items: ActivityItem[];
|
||||
}
|
||||
|
||||
const KIND_ICONS: Record<ActivityKind, React.ReactNode> = {
|
||||
household: <UserPlusIcon />,
|
||||
land: <LayersIcon />,
|
||||
gas: <FuelIcon />,
|
||||
};
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return "";
|
||||
const diffMs = Date.now() - then;
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 1) return "Baru saja";
|
||||
if (minutes < 60) return `${minutes} menit lalu`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} jam lalu`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days} hari lalu`;
|
||||
return new Date(iso).toLocaleDateString("id-ID", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function DashboardActivity({ items }: DashboardActivityProps) {
|
||||
return (
|
||||
<DashboardCard className="gap-0">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Aktivitas</CardTitle>
|
||||
<CardDescription>Pembaruan terbaru di basis data.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-48 items-center justify-center text-xs text-muted-foreground">
|
||||
Belum ada aktivitas.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{items.map((item) => (
|
||||
<li className="flex h-16 items-center gap-3 px-6" key={item.id}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-10 shrink-0 items-center justify-center [&_svg]:size-4"
|
||||
>
|
||||
{KIND_ICONS[item.kind]}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="line-clamp-1 text-pretty text-foreground text-sm leading-snug">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{timeAgo(item.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</DashboardCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type * as React from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export function DashboardCard({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Card>) {
|
||||
return (
|
||||
<Card
|
||||
className={cn("rounded-none bg-background shadow-none ring-0 border-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-2 gap-px bg-border p-px lg:grid-cols-4",
|
||||
"*:min-h-48 *:w-full *:bg-background/90"
|
||||
)}
|
||||
>
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div className="col-span-2 min-h-114! lg:col-span-4" />
|
||||
<div className="col-span-2 min-h-92! lg:col-span-2" />
|
||||
<div className="col-span-2 min-h-92! lg:col-span-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DashboardActivity } from "@/components/dashboard-activity";
|
||||
import { DashboardSkeleton } from "@/components/dashboard-skeleton";
|
||||
import { HouseholdsTrendChart } from "@/components/households-trend-chart";
|
||||
import { PriorityBreakdown } from "@/components/priority-breakdown";
|
||||
import { PriorityScoreChart } from "@/components/priority-score-chart";
|
||||
import { RecentHouseholds } from "@/components/recent-households";
|
||||
import { DashboardStats } from "@/components/stats";
|
||||
import type { ActivityItem, GasStationStats } from "@/lib/dashboard-types";
|
||||
import type { HouseholdRow, PovertyOverview } from "@/lib/poverty-types";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
|
||||
|
||||
type NamedRow = { id: number; name: string; created_at?: string };
|
||||
|
||||
export function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [overview, setOverview] = useState<PovertyOverview | null>(null);
|
||||
const [households, setHouseholds] = useState<HouseholdRow[]>([]);
|
||||
const [gasStats, setGasStats] = useState<GasStationStats | null>(null);
|
||||
const [landMarkers, setLandMarkers] = useState<NamedRow[]>([]);
|
||||
const [landShapes, setLandShapes] = useState<NamedRow[]>([]);
|
||||
const [gasMarkers, setGasMarkers] = useState<NamedRow[]>([]);
|
||||
|
||||
// Aggregates everything the overview widgets need, straight from the DB.
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
const get = (path: string) => fetch(`${BACKEND_URL}${path}`, { headers }).then((r) => r.json());
|
||||
const asRows = (r: PromiseSettledResult<unknown>) =>
|
||||
r.status === "fulfilled" && Array.isArray(r.value) ? (r.value as NamedRow[]) : [];
|
||||
|
||||
Promise.allSettled([
|
||||
get("/households/stats-overview"),
|
||||
get("/households"),
|
||||
get("/gas-station/stats"),
|
||||
get("/land/markers"),
|
||||
get("/land/shapes"),
|
||||
get("/gas-station/markers"),
|
||||
]).then(([overviewR, hhR, gasStatsR, landMarkersR, landShapesR, gasMarkersR]) => {
|
||||
if (overviewR.status === "fulfilled" && overviewR.value?.totals) {
|
||||
setOverview(overviewR.value as PovertyOverview);
|
||||
}
|
||||
if (hhR.status === "fulfilled" && Array.isArray(hhR.value)) {
|
||||
setHouseholds(hhR.value as HouseholdRow[]);
|
||||
}
|
||||
if (gasStatsR.status === "fulfilled" && typeof gasStatsR.value?.total === "number") {
|
||||
setGasStats(gasStatsR.value as GasStationStats);
|
||||
}
|
||||
setLandMarkers(asRows(landMarkersR));
|
||||
setLandShapes(asRows(landShapesR));
|
||||
setGasMarkers(asRows(gasMarkersR));
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const activity = useMemo<ActivityItem[]>(() => {
|
||||
const items: ActivityItem[] = [
|
||||
...households.map((h) => ({
|
||||
id: `hh-${h.id}`,
|
||||
kind: "household" as const,
|
||||
title: `Warga "${h.head_name}" terdata`,
|
||||
created_at: h.created_at,
|
||||
})),
|
||||
...landMarkers.map((m) => ({
|
||||
id: `lm-${m.id}`,
|
||||
kind: "land" as const,
|
||||
title: `Penanda lahan "${m.name}" ditambahkan`,
|
||||
created_at: m.created_at ?? "",
|
||||
})),
|
||||
...landShapes.map((s) => ({
|
||||
id: `ls-${s.id}`,
|
||||
kind: "land" as const,
|
||||
title: `Bidang lahan "${s.name}" digambar`,
|
||||
created_at: s.created_at ?? "",
|
||||
})),
|
||||
...gasMarkers.map((g) => ({
|
||||
id: `gs-${g.id}`,
|
||||
kind: "gas" as const,
|
||||
title: `Fasilitas "${g.name}" dipetakan`,
|
||||
created_at: g.created_at ?? "",
|
||||
})),
|
||||
];
|
||||
return items
|
||||
.filter((i) => i.created_at)
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at))
|
||||
.slice(0, 4);
|
||||
}, [households, landMarkers, landShapes, gasMarkers]);
|
||||
|
||||
if (loading) return <DashboardSkeleton />;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-px bg-border border rounded-xl overflow-hidden md:grid-cols-2 lg:grid-cols-4 shadow-none">
|
||||
<DashboardStats
|
||||
overview={overview}
|
||||
gasStats={gasStats}
|
||||
landMarkerCount={landMarkers.length}
|
||||
landShapeCount={landShapes.length}
|
||||
/>
|
||||
<HouseholdsTrendChart monthly={overview?.monthly ?? []} />
|
||||
<PriorityScoreChart monthly={overview?.monthly ?? []} />
|
||||
<RecentHouseholds households={households.slice(0, 4)} />
|
||||
<PriorityBreakdown byLabel={overview?.by_label ?? null} />
|
||||
<DashboardActivity items={activity} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
const DecorIconVariants = cva(
|
||||
"pointer-events-none absolute z-1 size-5 shrink-0 stroke-1 stroke-muted-foreground",
|
||||
{
|
||||
variants: {
|
||||
position: {
|
||||
"top-left":
|
||||
"top-0 left-0 -translate-x-[calc(50%+0.5px)] -translate-y-[calc(50%+0.5px)]",
|
||||
"top-right":
|
||||
"top-0 right-0 translate-x-[calc(50%+0.5px)] -translate-y-[calc(50%+0.5px)]",
|
||||
"bottom-right":
|
||||
"right-0 bottom-0 translate-x-[calc(50%+0.5px)] translate-y-[calc(50%+0.5px)]",
|
||||
"bottom-left":
|
||||
"bottom-0 left-0 -translate-x-[calc(50%+0.5px)] translate-y-[calc(50%+0.5px)]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
position: "top-left",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
type DecorIconProps = React.ComponentProps<"svg"> &
|
||||
VariantProps<typeof DecorIconVariants>;
|
||||
|
||||
export function DecorIcon({ position, className, ...props }: DecorIconProps) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn(DecorIconVariants({ position, className }))}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { MinusIcon, TrendingUpIcon, ArrowUpIcon, ChevronUpIcon, TrendingDownIcon, ArrowDownIcon, ChevronDownIcon } from "@/components/ui/phosphor-icons";
|
||||
|
||||
type DeltaIconVariant = "default" | "trend" | "arrow";
|
||||
type DeltaVariant = "default" | "badge";
|
||||
|
||||
type DeltaContextValue = {
|
||||
value: number;
|
||||
};
|
||||
|
||||
const DeltaContext = React.createContext<DeltaContextValue | null>(null);
|
||||
|
||||
function useDeltaValue() {
|
||||
const context = React.useContext(DeltaContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"DeltaIcon and DeltaValue must be used inside a `Delta` component."
|
||||
);
|
||||
}
|
||||
|
||||
return context.value;
|
||||
}
|
||||
|
||||
function Delta({
|
||||
className,
|
||||
value,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
value: number;
|
||||
variant?: DeltaVariant;
|
||||
}) {
|
||||
return (
|
||||
<DeltaContext.Provider value={{ value }}>
|
||||
{variant === "badge" ? (
|
||||
<Badge
|
||||
className={cn(
|
||||
"gap-1 border-none tabular-nums [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
value > 0
|
||||
? "bg-emerald-500/10 text-emerald-500"
|
||||
: "bg-red-500/10 text-red-500",
|
||||
className
|
||||
)}
|
||||
data-slot="delta"
|
||||
variant="secondary"
|
||||
{...(props as React.ComponentProps<typeof Badge>)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-muted-foreground tabular-nums",
|
||||
"[&_svg]:size-3 [&_svg]:shrink-0",
|
||||
value > 0 ? "text-emerald-600 dark:text-emerald-400" : "",
|
||||
value < 0 ? "text-rose-600 dark:text-rose-400" : "",
|
||||
className
|
||||
)}
|
||||
data-slot="delta"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</DeltaContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FilledShell({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex size-3 shrink-0 items-center justify-center rounded-full",
|
||||
"[&_svg]:size-2! [&_svg]:shrink-0 [&_svg]:stroke-3! [&_svg]:text-background",
|
||||
value > 0 && "bg-emerald-500",
|
||||
value < 0 && "bg-red-500",
|
||||
(!value || value === 0) && "bg-muted-foreground"
|
||||
)}
|
||||
data-slot="delta-icon"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DeltaIcon({
|
||||
variant = "default",
|
||||
filled = false,
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<"svg">, "fill"> & {
|
||||
variant?: DeltaIconVariant;
|
||||
filled?: boolean;
|
||||
}) {
|
||||
const resolvedValue = useDeltaValue();
|
||||
|
||||
const mergedClassName = cn(className);
|
||||
|
||||
const shell = (node: React.ReactElement) =>
|
||||
filled ? <FilledShell value={resolvedValue}>{node}</FilledShell> : node;
|
||||
|
||||
const slotProps = filled ? {} : { "data-slot": "delta-icon" as const };
|
||||
|
||||
if (!resolvedValue || resolvedValue === 0) {
|
||||
return shell(
|
||||
<MinusIcon {...slotProps} className={mergedClassName} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedValue > 0) {
|
||||
if (variant === "trend") {
|
||||
return shell(
|
||||
<TrendingUpIcon {...slotProps} className={mergedClassName} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "arrow") {
|
||||
return shell(
|
||||
<ArrowUpIcon {...slotProps} className={mergedClassName} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
return shell(
|
||||
<ChevronUpIcon {...slotProps} className={mergedClassName} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "trend") {
|
||||
return shell(
|
||||
<TrendingDownIcon {...slotProps} className={mergedClassName} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "arrow") {
|
||||
return shell(
|
||||
<ArrowDownIcon {...slotProps} className={mergedClassName} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
return shell(
|
||||
<ChevronDownIcon {...slotProps} className={mergedClassName} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DeltaValue({
|
||||
className,
|
||||
precision = 1,
|
||||
suffix = "%",
|
||||
absolute = true,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> & {
|
||||
precision?: number;
|
||||
suffix?: string;
|
||||
absolute?: boolean;
|
||||
}) {
|
||||
const resolvedValue = useDeltaValue();
|
||||
|
||||
const formattedValue = (
|
||||
absolute ? Math.abs(resolvedValue) : resolvedValue
|
||||
).toFixed(precision);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("tabular-nums", className)}
|
||||
data-slot="delta-value"
|
||||
{...props}
|
||||
>
|
||||
{formattedValue}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
export { Delta, DeltaIcon, DeltaValue };
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
export function FloatingPaths({ position }: { position: number }) {
|
||||
const paths = Array.from({ length: 36 }, (_, i) => ({
|
||||
id: i,
|
||||
d: `M-${380 - i * 5 * position} -${189 + i * 6}C-${
|
||||
380 - i * 5 * position
|
||||
} -${189 + i * 6} -${312 - i * 5 * position} ${216 - i * 6} ${
|
||||
152 - i * 5 * position
|
||||
} ${343 - i * 6}C${616 - i * 5 * position} ${470 - i * 6} ${
|
||||
684 - i * 5 * position
|
||||
} ${875 - i * 6} ${684 - i * 5 * position} ${875 - i * 6}`,
|
||||
color: `rgba(15,23,42,${0.1 + i * 0.03})`,
|
||||
width: 0.5 + i * 0.03,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<svg
|
||||
className="h-full w-full text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 696 316"
|
||||
>
|
||||
<title>Background Paths</title>
|
||||
{paths.map((path) => (
|
||||
<motion.path
|
||||
animate={{
|
||||
pathLength: 1,
|
||||
opacity: [0.3, 0.6, 0.3],
|
||||
}}
|
||||
d={path.d}
|
||||
initial={{ pathLength: 0.3, opacity: 0.6 }}
|
||||
key={path.id}
|
||||
stroke="currentColor"
|
||||
strokeOpacity={0.1 + path.id * 0.03}
|
||||
strokeWidth={path.width}
|
||||
transition={{
|
||||
// Animating pathOffset forces a stroke-dasharray recompute on
|
||||
// every frame for all 36 paths — opacity alone is far cheaper.
|
||||
pathLength: { duration: 2, ease: "easeOut" },
|
||||
opacity: {
|
||||
duration: 20 + (path.id % 10),
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "linear",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
export const DASHBOARD_LOCALE = "en-US";
|
||||
|
||||
/** Noon anchor avoids off-by-one labels around timezone boundaries for ISO date strings. */
|
||||
export function parseIsoCalendarDate(isoDate: string): Date {
|
||||
return new Date(`${isoDate}T12:00:00`);
|
||||
}
|
||||
|
||||
export type DashboardDateStyle = "month" | "day-month" | "full";
|
||||
|
||||
export function formatDate(isoDate: string, style: DashboardDateStyle): string {
|
||||
const date = parseIsoCalendarDate(isoDate);
|
||||
if (style === "month") {
|
||||
return date.toLocaleDateString(DASHBOARD_LOCALE, { month: "short" });
|
||||
}
|
||||
if (style === "day-month") {
|
||||
return date.toLocaleDateString(DASHBOARD_LOCALE, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
return date.toLocaleDateString(DASHBOARD_LOCALE, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** X-axis for range charts: weekday when showing ~a week, otherwise month + day. */
|
||||
export function formatChartAxisTick(
|
||||
isoDate: string,
|
||||
periodDays: number
|
||||
): string {
|
||||
const date = parseIsoCalendarDate(isoDate);
|
||||
if (periodDays <= 7) {
|
||||
return date.toLocaleDateString(DASHBOARD_LOCALE, { weekday: "short" });
|
||||
}
|
||||
return formatDate(isoDate, "day-month");
|
||||
}
|
||||
|
||||
export type ChartTooltipWeekdayStyle = "short" | "long";
|
||||
|
||||
/** Tooltip label for a chart point (weekday + month + day). */
|
||||
export function formatChartTooltipDate(
|
||||
isoDate: string,
|
||||
weekdayStyle: ChartTooltipWeekdayStyle = "short"
|
||||
): string {
|
||||
const date = parseIsoCalendarDate(isoDate);
|
||||
return date.toLocaleDateString(DASHBOARD_LOCALE, {
|
||||
weekday: weekdayStyle,
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatCompactCurrency(
|
||||
value: number,
|
||||
options?: { maximumFractionDigits?: number }
|
||||
) {
|
||||
const { maximumFractionDigits = 0 } = options ?? {};
|
||||
return new Intl.NumberFormat(DASHBOARD_LOCALE, {
|
||||
currency: "USD",
|
||||
maximumFractionDigits,
|
||||
notation: "compact",
|
||||
style: "currency",
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** Full-precision USD (e.g. average order value). */
|
||||
export function formatFullCurrency(value: number) {
|
||||
return new Intl.NumberFormat(DASHBOARD_LOCALE, {
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
style: "currency",
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatCompactNumber(value: number) {
|
||||
return new Intl.NumberFormat(DASHBOARD_LOCALE, {
|
||||
maximumFractionDigits: 1,
|
||||
notation: "compact",
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** Whole numbers with grouping (visits, sessions, counts). */
|
||||
export function formatInteger(value: number) {
|
||||
return new Intl.NumberFormat(DASHBOARD_LOCALE, {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** Percentage with fixed decimal places (e.g. conversion rate). */
|
||||
export function formatPercent(value: number, fractionDigits = 2) {
|
||||
return `${value.toFixed(fractionDigits)}%`;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchIcon, ZapIcon } from "@/components/ui/phosphor-icons";
|
||||
import { BRAND_COLORS } from "@/lib/map-data";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
import { ContinuousTabs } from "@/components/ui/continuous-tabs";
|
||||
|
||||
interface GasStationDirectoryProps {
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
brandFilter: string;
|
||||
setBrandFilter: (brand: string) => void;
|
||||
evOnlyFilter: boolean;
|
||||
setEvOnlyFilter: (evOnly: boolean) => void;
|
||||
filteredStations: UserMarker[];
|
||||
selectedStationId: string;
|
||||
onSelectStation: (station: UserMarker) => void;
|
||||
}
|
||||
|
||||
export function GasStationDirectory({
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
brandFilter,
|
||||
setBrandFilter,
|
||||
evOnlyFilter,
|
||||
setEvOnlyFilter,
|
||||
filteredStations,
|
||||
selectedStationId,
|
||||
onSelectStation,
|
||||
}: GasStationDirectoryProps) {
|
||||
return (
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
|
||||
<CardHeader className="border-b bg-muted/20 px-6 py-4 lg:h-[73px] lg:py-0 flex flex-col justify-center">
|
||||
<CardTitle className="text-base font-semibold">Direktori Stasiun Aktif</CardTitle>
|
||||
<CardDescription>Menyaring {filteredStations.length} target stasiun regional aktif dari database.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 flex-1">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4 items-center">
|
||||
<div className="relative flex-grow w-full">
|
||||
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama lokasi..."
|
||||
className="pl-9 bg-background h-10"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ContinuousTabs
|
||||
tabs={[
|
||||
{ id: "All", label: "Semua" },
|
||||
{ id: "Pertamina", label: "Pertamina" },
|
||||
{ id: "Shell", label: "Shell" },
|
||||
{ id: "BP", label: "BP" },
|
||||
{ id: "Vivo", label: "Vivo" },
|
||||
]}
|
||||
activeId={brandFilter}
|
||||
onChange={setBrandFilter}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Button
|
||||
variant={evOnlyFilter ? "default" : "outline"}
|
||||
onClick={() => setEvOnlyFilter(!evOnlyFilter)}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<ZapIcon className="h-3.5 w-3.5 text-amber-500 fill-amber-500" />
|
||||
Pengisi Daya EV Tersedia
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Grid List */}
|
||||
{filteredStations.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
Tidak ada lokasi yang cocok. Tambah lokasi baru melalui peta stasiun.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 max-h-[360px] overflow-y-auto pr-1">
|
||||
{filteredStations.map((station) => {
|
||||
const isSelected = selectedStationId === station.id;
|
||||
const brandName = station.type === "gas-pump" ? station.meta?.poi_type : null;
|
||||
const brandColor = BRAND_COLORS[brandName as keyof typeof BRAND_COLORS] || { bg: "#6b7280", text: "#ffffff" };
|
||||
|
||||
return (
|
||||
<div
|
||||
key={station.id}
|
||||
onClick={() => onSelectStation(station)}
|
||||
className={`p-4 border rounded-lg cursor-pointer flex flex-col gap-1.5 transition-all ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 font-semibold"
|
||||
: "border-border/60 hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-xs text-foreground font-bold">{station.name}</span>
|
||||
{station.type === "gas-pump" && brandName ? (
|
||||
<span
|
||||
className="text-[9px] uppercase font-bold px-2 py-0.5 rounded border shrink-0 ml-2"
|
||||
style={{
|
||||
color: brandColor.bg,
|
||||
borderColor: `${brandColor.bg}33`,
|
||||
backgroundColor: `${brandColor.bg}11`
|
||||
}}
|
||||
>
|
||||
{brandName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[9px] uppercase font-bold px-2 py-0.5 rounded border shrink-0 ml-2 bg-primary/10 text-primary border-primary/20">
|
||||
{station.type === "charging-station" ? "EV Charger" : "Bengkel"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground font-mono tabular-nums">
|
||||
Koordinat: {station.lat.toFixed(5)}, {station.lng.toFixed(5)}
|
||||
</p>
|
||||
|
||||
{station.type === "gas-pump" && station.meta?.gas_types && station.meta.gas_types.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{station.meta.gas_types.map((gt) => (
|
||||
<span
|
||||
key={gt}
|
||||
className="px-1.5 py-0.5 rounded text-[8px] font-medium bg-muted text-muted-foreground border border-border/40"
|
||||
>
|
||||
{gt}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center text-[10px] mt-1 text-muted-foreground border-t pt-2 mt-auto">
|
||||
{station.type === "gas-pump" ? (
|
||||
<span>Operasional: {station.meta?.notes?.replace("Jam: ", "") || "24 Jam"}</span>
|
||||
) : station.type === "charging-station" ? (
|
||||
<span>Tipe: {station.meta?.poi_type} · {station.meta?.notes}</span>
|
||||
) : (
|
||||
<span>Spesialisasi: {station.meta?.poi_type} · {station.meta?.notes}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ArrowLeftIcon } from "@/components/ui/phosphor-icons";
|
||||
import { ContinuousTabs } from "@/components/ui/continuous-tabs";
|
||||
|
||||
interface GasStationHudProps {
|
||||
fuelPriceDisplay: "ron92" | "ron95" | "diesel";
|
||||
setFuelPriceDisplay: (price: "ron92" | "ron95" | "diesel") => void;
|
||||
brandFilter: string;
|
||||
setBrandFilter: (brand: string) => void;
|
||||
onCloseMap: () => void;
|
||||
}
|
||||
|
||||
export function GasStationHud({
|
||||
fuelPriceDisplay,
|
||||
setFuelPriceDisplay,
|
||||
brandFilter,
|
||||
setBrandFilter,
|
||||
onCloseMap,
|
||||
}: GasStationHudProps) {
|
||||
return (
|
||||
<div className="absolute top-4 left-4 z-[999] flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3 bg-background/90 backdrop-blur-md border border-border/40 p-2.5 rounded-lg shadow-lg max-w-[90vw]">
|
||||
<span className="text-[10px] font-bold text-muted-foreground">TAMPILAN:</span>
|
||||
<ContinuousTabs
|
||||
tabs={[
|
||||
{ id: "ron92", label: "RON 92" },
|
||||
{ id: "ron95", label: "RON 95" },
|
||||
{ id: "diesel", label: "Diesel" },
|
||||
]}
|
||||
activeId={fuelPriceDisplay}
|
||||
onChange={(id) => setFuelPriceDisplay(id as any)}
|
||||
size="sm"
|
||||
/>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<ContinuousTabs
|
||||
tabs={[
|
||||
{ id: "All", label: "Semua" },
|
||||
{ id: "Pertamina", label: "Pertamina" },
|
||||
{ id: "Shell", label: "Shell" },
|
||||
{ id: "BP", label: "BP" },
|
||||
]}
|
||||
activeId={brandFilter}
|
||||
onChange={setBrandFilter}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MapPinIcon, ZapIcon, ClockIcon, NavigationIcon } from "@/components/ui/phosphor-icons";
|
||||
import { BRAND_COLORS } from "@/lib/map-data";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
|
||||
interface GasStationInspectorProps {
|
||||
selectedStation: UserMarker | null;
|
||||
}
|
||||
|
||||
const BRAND_PRICES = {
|
||||
Pertamina: { ron92: 13200, ron95: 14400, diesel: 14900 },
|
||||
Shell: { ron92: 13850, ron95: 14600, diesel: 15100 },
|
||||
BP: { ron92: 13400, ron95: 14500, diesel: 14800 },
|
||||
Vivo: { ron92: 13400, ron95: 14500, diesel: 14800 }
|
||||
};
|
||||
|
||||
export function GasStationInspector({ selectedStation }: GasStationInspectorProps) {
|
||||
if (!selectedStation) {
|
||||
return (
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-center items-center h-full p-6 text-center text-muted-foreground/60 border-l border-border/40">
|
||||
<MapPinIcon className="h-8 w-8 text-muted-foreground/30 mb-2" />
|
||||
<p className="text-xs">Pilih stasiun dari direktori atau peta untuk melihat detail.</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const isGasPump = selectedStation.type === "gas-pump";
|
||||
const isEvCharger = selectedStation.type === "charging-station";
|
||||
const isWrench = selectedStation.type === "wrench";
|
||||
|
||||
const brandName = isGasPump ? selectedStation.meta?.poi_type : null;
|
||||
const brandColor = BRAND_COLORS[brandName as keyof typeof BRAND_COLORS] || { bg: "#6b7280", text: "#ffffff" };
|
||||
|
||||
// Get price index for gas stations
|
||||
const prices = isGasPump
|
||||
? (BRAND_PRICES[brandName as keyof typeof BRAND_PRICES] || BRAND_PRICES.Pertamina)
|
||||
: null;
|
||||
|
||||
// Extract clean values from meta
|
||||
const cleanNotes = selectedStation.meta?.notes ?? "";
|
||||
const cleanPoiType = selectedStation.meta?.poi_type ?? "";
|
||||
|
||||
return (
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full border-l border-border/40">
|
||||
<CardHeader className="border-b bg-muted/20 px-6 py-4 lg:h-[73px] lg:py-0 flex flex-col justify-center">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<MapPinIcon className="h-4 w-4 text-primary" />
|
||||
Detail Stasiun
|
||||
</CardTitle>
|
||||
<CardDescription>Metrik stasiun langsung & detail dari database.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 flex flex-col gap-5 flex-1 overflow-y-auto">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-foreground leading-tight">{selectedStation.name}</h3>
|
||||
<span
|
||||
className="inline-block mt-2 text-[9px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border"
|
||||
style={isGasPump && brandName ? {
|
||||
color: brandColor.bg,
|
||||
borderColor: `${brandColor.bg}33`,
|
||||
backgroundColor: `${brandColor.bg}11`
|
||||
} : undefined}
|
||||
>
|
||||
{isGasPump ? `Jaringan ${brandName}` : isEvCharger ? "Pengisi Daya EV" : "Bengkel (Repair Shop)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Gas Pump: Pricing Cards */}
|
||||
{isGasPump && prices && (
|
||||
<div className="flex flex-col gap-2 border rounded-lg p-3 bg-muted/10">
|
||||
<div className="flex justify-between items-center text-xs py-1 border-b">
|
||||
<span className="font-semibold text-foreground">Harga RON 92</span>
|
||||
<span className="font-bold text-foreground">Rp {prices.ron92.toLocaleString()} / L</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs py-1 border-b">
|
||||
<span className="font-semibold text-primary font-semibold">Harga RON 95</span>
|
||||
<span className="font-bold text-primary">Rp {prices.ron95.toLocaleString()} / L</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs py-1">
|
||||
<span className="font-semibold text-foreground">Harga Diesel</span>
|
||||
<span className="font-bold text-foreground">Rp {prices.diesel.toLocaleString()} / L</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gas Pump: Fuel Types */}
|
||||
{isGasPump && selectedStation.meta?.gas_types && selectedStation.meta.gas_types.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground">Tipe Bahan Bakar</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedStation.meta.gas_types.map((gt) => (
|
||||
<span
|
||||
key={gt}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-primary/10 text-primary border border-primary/20"
|
||||
>
|
||||
{gt}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EV Charger Details */}
|
||||
{isEvCharger && (
|
||||
<div className="flex flex-col gap-3 border rounded-lg p-3 bg-muted/15">
|
||||
<div className="flex justify-between items-center text-xs py-1 border-b">
|
||||
<span className="font-semibold text-muted-foreground">Tipe Charger</span>
|
||||
<span className="font-bold text-foreground">{cleanPoiType}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs py-1">
|
||||
<span className="font-semibold text-muted-foreground">Status Operasional</span>
|
||||
<span className={`font-bold px-2 py-0.5 rounded text-[10px] uppercase ${
|
||||
cleanNotes.includes("Active") ? "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20" :
|
||||
cleanNotes.includes("Maintenance") ? "bg-amber-500/10 text-amber-600 border border-amber-500/20" :
|
||||
"bg-red-500/10 text-red-600 border border-red-500/20"
|
||||
}`}>
|
||||
{cleanNotes.replace("Status: ", "")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repair Shop Details */}
|
||||
{isWrench && (
|
||||
<div className="flex flex-col gap-3 border rounded-lg p-3 bg-muted/15">
|
||||
<div className="flex justify-between items-center text-xs py-1 border-b">
|
||||
<span className="font-semibold text-muted-foreground">Spesialisasi</span>
|
||||
<span className="font-bold text-foreground">{cleanPoiType}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs py-1">
|
||||
<span className="font-semibold text-muted-foreground">Status / Jam Buka</span>
|
||||
<span className="font-bold text-foreground">{cleanNotes}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* General: Operating Hours HUD style */}
|
||||
{isGasPump && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground">Jam Operasional</span>
|
||||
<div className="flex items-center gap-3 p-3 border rounded-lg bg-background">
|
||||
<ClockIcon className="h-6 w-6 shrink-0 text-emerald-500" />
|
||||
<div>
|
||||
<p className="text-xs font-bold text-foreground">Buka Layanan</p>
|
||||
<p className="text-[10px] text-muted-foreground">{cleanNotes.replace("Jam: ", "") || "24 Jam"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coordinates & Route Buttons */}
|
||||
<div className="border-t pt-4 text-xs text-muted-foreground flex flex-col gap-2 mt-auto">
|
||||
<p className="flex items-center gap-1 font-mono">
|
||||
<MapPinIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
Lat: {selectedStation.lat.toFixed(5)}, Lng: {selectedStation.lng.toFixed(5)}
|
||||
</p>
|
||||
<Button className="w-full mt-2 h-9 gap-1.5" variant="outline">
|
||||
<NavigationIcon className="h-4 w-4" /> Dapatkan Arah Rute
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { FuelIcon } from "@/components/ui/phosphor-icons";
|
||||
|
||||
export interface FuelPriceData {
|
||||
region: string;
|
||||
pertalite: number | null;
|
||||
pertamax: number | null;
|
||||
pertamaxGreen: number | null;
|
||||
pertamaxTurbo: number | null;
|
||||
pertamaxPertashop: number | null;
|
||||
pertaminaDex: number | null;
|
||||
dexlite: number | null;
|
||||
bioSolarNonSubsidi: number | null;
|
||||
bioSolarSubsidi: number | null;
|
||||
}
|
||||
|
||||
export interface FuelPricesResponse {
|
||||
succeeded: boolean;
|
||||
lastUpdated: string;
|
||||
gasoline: FuelPriceData[];
|
||||
diesel: FuelPriceData[];
|
||||
}
|
||||
|
||||
interface GasStationStatsCardsProps {
|
||||
fuelPrices: FuelPricesResponse | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function GasStationStatsCards({ fuelPrices, loading }: GasStationStatsCardsProps) {
|
||||
// Find Kalimantan Barat prices
|
||||
const kalbarGas = fuelPrices?.gasoline?.find(
|
||||
(g) => g.region === "Prov. Kalimantan Barat"
|
||||
);
|
||||
const kalbarDiesel = fuelPrices?.diesel?.find(
|
||||
(d) => d.region === "Prov. Kalimantan Barat"
|
||||
);
|
||||
|
||||
const formatPrice = (p: number | null | undefined) => {
|
||||
if (loading) return "Loading...";
|
||||
if (p == null) return "Rp —";
|
||||
return "Rp " + p.toLocaleString("id-ID");
|
||||
};
|
||||
|
||||
const lastUpdatedText = fuelPrices?.lastUpdated || "Update terbaru";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Card 1: Pertalite */}
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<div className="flex justify-between items-center pb-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Pertalite (RON 90)</span>
|
||||
<FuelIcon className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-foreground mt-2">
|
||||
{formatPrice(kalbarGas?.pertalite)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-6 py-3 flex items-center justify-between text-[10px] text-muted-foreground mt-auto font-sans">
|
||||
<span>Prov. Kalimantan Barat</span>
|
||||
<span className="text-muted-foreground/80">{lastUpdatedText}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Pertamax */}
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<div className="flex justify-between items-center pb-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Pertamax (RON 92)</span>
|
||||
<FuelIcon className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-foreground mt-2">
|
||||
{formatPrice(kalbarGas?.pertamax)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-6 py-3 flex items-center justify-between text-[10px] text-muted-foreground mt-auto font-sans">
|
||||
<span>Prov. Kalimantan Barat</span>
|
||||
<span className="text-muted-foreground/80">{lastUpdatedText}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Card 3: Dexlite */}
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<div className="flex justify-between items-center pb-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Dexlite (Diesel)</span>
|
||||
<FuelIcon className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-foreground mt-2">
|
||||
{formatPrice(kalbarDiesel?.dexlite)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-6 py-3 flex items-center justify-between text-[10px] text-muted-foreground mt-auto font-sans">
|
||||
<span>Prov. Kalimantan Barat</span>
|
||||
<span className="text-muted-foreground/80">{lastUpdatedText}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Card 4: Bio Solar (Subsidi) */}
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<div className="flex justify-between items-center pb-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Bio Solar (Subsidi)</span>
|
||||
<FuelIcon className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-foreground mt-2">
|
||||
{formatPrice(kalbarDiesel?.bioSolarSubsidi)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-6 py-3 flex items-center justify-between text-[10px] text-muted-foreground mt-auto font-sans">
|
||||
<span>Prov. Kalimantan Barat</span>
|
||||
<span className="text-muted-foreground/80">{lastUpdatedText}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DashboardCard } from "@/components/dashboard-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SearchIcon, FuelIcon } from "@/components/ui/phosphor-icons";
|
||||
import { ContinuousTabs } from "@/components/ui/continuous-tabs";
|
||||
import type { FuelPricesResponse } from "./gas-station-stats-cards";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
interface PertaminaPriceTableProps {
|
||||
fuelPrices: FuelPricesResponse | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function PertaminaPriceTable({ fuelPrices, loading }: PertaminaPriceTableProps) {
|
||||
const [activeTab, setActiveTab] = useState<string>("gasoline");
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
|
||||
const dataList = useMemo(() => {
|
||||
if (!fuelPrices) return [];
|
||||
return activeTab === "gasoline" ? fuelPrices.gasoline : fuelPrices.diesel;
|
||||
}, [fuelPrices, activeTab]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return dataList.filter((row) =>
|
||||
row.region?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [dataList, searchQuery]);
|
||||
|
||||
const formatVal = (val: number | null) => {
|
||||
if (val == null) return <span className="text-muted-foreground/45">—</span>;
|
||||
return <span className="font-semibold text-foreground">Rp {val.toLocaleString("id-ID")}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardCard className="relative gap-0 h-full flex flex-col">
|
||||
<CardHeader className="border-b border-border/60 bg-muted/10 px-6 py-4 flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<FuelIcon className="h-5 w-5 text-primary" />
|
||||
Daftar Harga BBM Pertamina Seluruh Indonesia
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Data real-time disinkronkan dari Pertamina Patra Niaga ({fuelPrices?.lastUpdated || "Update terbaru"}).
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-center w-full sm:w-auto">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<SearchIcon className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Cari provinsi..."
|
||||
className="pl-9 bg-background h-9 text-xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ContinuousTabs
|
||||
tabs={[
|
||||
{ id: "gasoline", label: "Bensin (Gasoline)" },
|
||||
{ id: "diesel", label: "Solar (Gasoil)" },
|
||||
]}
|
||||
activeId={activeTab}
|
||||
onChange={setActiveTab}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0 flex-1 flex flex-col">
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
Memuat data harga BBM terbaru...
|
||||
</div>
|
||||
) : filteredData.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
Tidak ada data provinsi yang cocok.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto w-full max-h-[420px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30 sticky top-0 backdrop-blur z-10">
|
||||
<TableRow>
|
||||
<TableHead className="px-6 py-3 min-w-[200px] text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Provinsi / Wilayah</TableHead>
|
||||
{activeTab === "gasoline" ? (
|
||||
<>
|
||||
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertalite (90)</TableHead>
|
||||
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamax (92)</TableHead>
|
||||
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamax Green 95</TableHead>
|
||||
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamax Turbo (98)</TableHead>
|
||||
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamax Pertashop</TableHead>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Bio Solar (Subsidi)</TableHead>
|
||||
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Bio Solar (Non-Sub)</TableHead>
|
||||
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Dexlite</TableHead>
|
||||
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamina Dex</TableHead>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredData.map((row) => {
|
||||
const isKalbar = row.region === "Prov. Kalimantan Barat";
|
||||
return (
|
||||
<TableRow
|
||||
key={row.region}
|
||||
className={`text-xs transition-colors hover:bg-muted/40 h-12 ${
|
||||
isKalbar
|
||||
? "bg-amber-500/5 hover:bg-amber-500/10 border-l-4 border-l-amber-500 font-semibold"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<TableCell className="px-6 py-3.5 font-medium flex items-center gap-2">
|
||||
{isKalbar && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500 animate-pulse" />
|
||||
)}
|
||||
{row.region}
|
||||
</TableCell>
|
||||
{activeTab === "gasoline" ? (
|
||||
<>
|
||||
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertalite)}</TableCell>
|
||||
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertamax)}</TableCell>
|
||||
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertamaxGreen)}</TableCell>
|
||||
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertamaxTurbo)}</TableCell>
|
||||
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertamaxPertashop)}</TableCell>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.bioSolarSubsidi)}</TableCell>
|
||||
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.bioSolarNonSubsidi)}</TableCell>
|
||||
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.dexlite)}</TableCell>
|
||||
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertaminaDex)}</TableCell>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</DashboardCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
"use client";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/set-state-in-effect */
|
||||
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
import { TimedUndoAction } from "@/components/time-undo-action";
|
||||
import {
|
||||
EV_TYPES,
|
||||
EV_STATUSES,
|
||||
GAS_BRANDS,
|
||||
REPAIR_SPECIALITIES,
|
||||
GAS_TYPES_BY_BRAND,
|
||||
} from "@/components/gas-station/station-placement-dialog";
|
||||
|
||||
/* ─── Helpers ───────────────────────────────────── */
|
||||
|
||||
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipSelector({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
options: { value: string; label: string; color: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{options.map((opt) => {
|
||||
const isSelected = value === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={cn(
|
||||
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
|
||||
isSelected
|
||||
? "border-transparent text-white shadow-sm animate-in fade-in zoom-in-95 duration-150"
|
||||
: "border-border text-foreground/70 hover:bg-muted"
|
||||
)}
|
||||
style={isSelected ? { backgroundColor: opt.color } : undefined}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiChipSelector({
|
||||
options,
|
||||
values,
|
||||
onChange,
|
||||
color,
|
||||
}: {
|
||||
options: string[];
|
||||
values: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{options.map((opt) => {
|
||||
const isSelected = values.includes(opt);
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
onChange(values.filter((v) => v !== opt));
|
||||
} else {
|
||||
onChange([...values, opt]);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
|
||||
isSelected
|
||||
? "border-transparent text-white shadow-sm"
|
||||
: "border-border text-foreground/70 hover:bg-muted"
|
||||
)}
|
||||
style={isSelected ? { backgroundColor: color } : undefined}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Edit Dialog ───────────────────────────────── */
|
||||
|
||||
interface StationEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
marker: UserMarker | null;
|
||||
onUpdated: (updated: UserMarker) => void;
|
||||
onDeleted: (deleted: UserMarker) => void;
|
||||
}
|
||||
|
||||
export function StationEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
marker,
|
||||
onUpdated,
|
||||
onDeleted,
|
||||
}: StationEditDialogProps) {
|
||||
const [category, setCategory] = useState("gas-pump");
|
||||
const [name, setName] = useState("");
|
||||
|
||||
// Form fields for EV Charger
|
||||
const [evType, setEvType] = useState("DC Fast");
|
||||
const [evStatus, setEvStatus] = useState("Active");
|
||||
|
||||
// Form fields for Gas Station
|
||||
const [gasBrand, setGasBrand] = useState("Pertamina");
|
||||
const [gasHours, setGasHours] = useState("24 Jam");
|
||||
const [selectedGasTypes, setSelectedGasTypes] = useState<string[]>([]);
|
||||
|
||||
// Form fields for Repair Shop
|
||||
const [repairSpeciality, setRepairSpeciality] = useState("Umum");
|
||||
const [repairStatus, setRepairStatus] = useState("Buka 08:00 - 17:00");
|
||||
|
||||
// Timed Delete State
|
||||
const DELETE_SECONDS = 5;
|
||||
const TOAST_ID = "station-delete";
|
||||
const [deleteActive, setDeleteActive] = useState(false);
|
||||
const [deleteCountdown, setDeleteCountdown] = useState(DELETE_SECONDS);
|
||||
const pendingDeleteMarker = useRef<UserMarker | null>(null);
|
||||
|
||||
// Fetching and saving state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
|
||||
|
||||
// Synchronize when marker opens and load latest from DB
|
||||
useEffect(() => {
|
||||
if (!open || !marker) return;
|
||||
|
||||
setDeleteActive(false);
|
||||
setDeleteCountdown(DELETE_SECONDS);
|
||||
pendingDeleteMarker.current = null;
|
||||
toast.dismiss(TOAST_ID);
|
||||
setError(null);
|
||||
|
||||
const dbId = marker.id.split("-").slice(1).join("-");
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
fetch(`${BACKEND_URL}/gas-station/markers/${dbId}`, { headers })
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("Gagal memuat data dari server");
|
||||
return r.json();
|
||||
})
|
||||
.then((row) => {
|
||||
setCategory(row.marker_category);
|
||||
setName(row.name);
|
||||
if (row.marker_category === "charging-station") {
|
||||
setEvType(row.sub_type ?? "DC Fast");
|
||||
setEvStatus(row.status ?? "Active");
|
||||
} else if (row.marker_category === "gas-pump") {
|
||||
setGasBrand(row.brand ?? "Pertamina");
|
||||
setGasHours(row.operating_hours ?? "24 Jam");
|
||||
setSelectedGasTypes(row.gas_types ?? GAS_TYPES_BY_BRAND[row.brand ?? "Pertamina"] ?? []);
|
||||
} else if (row.marker_category === "wrench") {
|
||||
setRepairSpeciality(row.sub_type ?? "Umum");
|
||||
setRepairStatus(row.status ?? "Buka 08:00 - 17:00");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setError("Gagal memuat data terbaru, menggunakan data lokal.");
|
||||
// Fallback
|
||||
setCategory(marker.type);
|
||||
setName(marker.name);
|
||||
const meta = marker.meta;
|
||||
if (marker.type === "charging-station") {
|
||||
setEvType(meta?.poi_type ?? "DC Fast");
|
||||
setEvStatus(meta?.notes?.replace("Status: ", "") ?? "Active");
|
||||
} else if (marker.type === "gas-pump") {
|
||||
setGasBrand(meta?.poi_type ?? "Pertamina");
|
||||
setGasHours(meta?.notes?.replace("Jam: ", "") ?? "24 Jam");
|
||||
setSelectedGasTypes(meta?.gas_types ?? GAS_TYPES_BY_BRAND[meta?.poi_type ?? "Pertamina"] ?? []);
|
||||
} else if (marker.type === "wrench") {
|
||||
setRepairSpeciality(meta?.poi_type ?? "Umum");
|
||||
setRepairStatus(meta?.notes ?? "Buka 08:00 - 17:00");
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [open, marker]);
|
||||
|
||||
async function handleDelete() {
|
||||
const target = pendingDeleteMarker.current;
|
||||
if (!target) return;
|
||||
|
||||
const dbId = target.id.split("-").slice(1).join("-");
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/gas-station/markers/${dbId}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok && res.status !== 204) {
|
||||
throw new Error("Gagal menghapus lokasi dari server");
|
||||
}
|
||||
|
||||
pendingDeleteMarker.current = null;
|
||||
onDeleted(target);
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Terjadi kesalahan saat menghapus");
|
||||
toast.error(err instanceof Error ? err.message : "Gagal menghapus lokasi");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Countdown tick
|
||||
useEffect(() => {
|
||||
if (!deleteActive) return;
|
||||
const id = setInterval(() => setDeleteCountdown((p) => Math.max(0, p - 1)), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [deleteActive]);
|
||||
|
||||
// Fire delete when countdown hits 0
|
||||
useEffect(() => {
|
||||
if (!deleteActive || deleteCountdown > 0) return;
|
||||
setDeleteActive(false);
|
||||
setDeleteCountdown(DELETE_SECONDS);
|
||||
toast.dismiss(TOAST_ID);
|
||||
handleDelete();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deleteActive, deleteCountdown]);
|
||||
|
||||
// Toast showing countdown
|
||||
useEffect(() => {
|
||||
if (!deleteActive || !pendingDeleteMarker.current) {
|
||||
toast.dismiss(TOAST_ID);
|
||||
return;
|
||||
}
|
||||
const mName = pendingDeleteMarker.current.name;
|
||||
const pct = (deleteCountdown / DELETE_SECONDS) * 100;
|
||||
toast.custom(
|
||||
() => (
|
||||
<div className="relative flex items-center gap-3 w-72 rounded-xl border border-border bg-popover px-4 py-3 shadow-lg overflow-hidden font-sans">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-[3px] bg-red-500 transition-[width] duration-1000 ease-linear"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground truncate">Menghapus lokasi</p>
|
||||
<p className="text-xs text-muted-foreground truncate">"{mName}" — dalam {deleteCountdown} detik</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeleteActive(false);
|
||||
setDeleteCountdown(DELETE_SECONDS);
|
||||
pendingDeleteMarker.current = null;
|
||||
toast.dismiss(TOAST_ID);
|
||||
}}
|
||||
className="shrink-0 h-7 rounded-full border border-border bg-background px-3 text-xs font-medium hover:bg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
Batalkan
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
{ id: TOAST_ID, duration: Infinity, position: "bottom-left" },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deleteActive, deleteCountdown]);
|
||||
|
||||
function toggleDelete() {
|
||||
setDeleteActive((prev) => {
|
||||
if (!prev) {
|
||||
pendingDeleteMarker.current = marker;
|
||||
setDeleteCountdown(DELETE_SECONDS);
|
||||
} else {
|
||||
pendingDeleteMarker.current = null;
|
||||
toast.dismiss(TOAST_ID);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!marker) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
let finalPoiType = "";
|
||||
let finalNotes = "";
|
||||
|
||||
const brand = category === "gas-pump" ? gasBrand : null;
|
||||
const sub_type = category === "charging-station" ? evType : (category === "wrench" ? repairSpeciality : null);
|
||||
const gas_types = category === "gas-pump" ? selectedGasTypes : null;
|
||||
const operating_hours = category === "gas-pump" ? gasHours : null;
|
||||
const status = category === "charging-station" ? evStatus : (category === "wrench" ? repairStatus : null);
|
||||
|
||||
if (category === "charging-station") {
|
||||
finalPoiType = evType;
|
||||
finalNotes = `Status: ${evStatus}`;
|
||||
} else if (category === "gas-pump") {
|
||||
finalPoiType = gasBrand;
|
||||
finalNotes = `Jam: ${gasHours}`;
|
||||
} else if (category === "wrench") {
|
||||
finalPoiType = repairSpeciality;
|
||||
finalNotes = repairStatus;
|
||||
}
|
||||
|
||||
try {
|
||||
const dbId = marker.id.split("-").slice(1).join("-");
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
|
||||
const res = await fetch(`${BACKEND_URL}/gas-station/markers/${dbId}`, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
name: name.trim() || marker.name,
|
||||
marker_category: category,
|
||||
brand,
|
||||
sub_type,
|
||||
gas_types,
|
||||
operating_hours,
|
||||
status,
|
||||
notes: null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error ?? "Gagal menyimpan perubahan");
|
||||
}
|
||||
|
||||
const saved = await res.json();
|
||||
|
||||
const updated: UserMarker = {
|
||||
...marker,
|
||||
type: category,
|
||||
name: saved.name,
|
||||
meta: {
|
||||
...marker.meta,
|
||||
poi_type: finalPoiType,
|
||||
notes: finalNotes,
|
||||
gas_types: category === "gas-pump" ? selectedGasTypes : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
onUpdated(updated);
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
"charging-station": "Edit EV Charger",
|
||||
"gas-pump": "Edit Gas Station",
|
||||
wrench: "Edit Bengkel",
|
||||
};
|
||||
|
||||
const titleLabel = categoryLabels[category] || "Edit Lokasi";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onOpenChange(false); }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<DialogTitle className="text-center text-base font-semibold">{titleLabel}</DialogTitle>
|
||||
<p className="text-center text-xs text-muted-foreground font-mono tabular-nums mt-1">
|
||||
{marker ? `${marker.lat.toFixed(5)}, ${marker.lng.toFixed(5)}` : ""}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-5 flex flex-col gap-5">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">Memuat data…</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Category Selector */}
|
||||
<Field label="Kategori Lokasi">
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full h-10 rounded-lg border border-border bg-muted/50 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring cursor-pointer"
|
||||
>
|
||||
<option value="charging-station">EV Charger</option>
|
||||
<option value="gas-pump">Gas Station</option>
|
||||
<option value="wrench">Repair Shop (Bengkel)</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{/* Location Name */}
|
||||
<Field label="Nama Lokasi">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="bg-muted/50 border border-border focus-visible:border-ring"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Dynamic Fields: EV Charger */}
|
||||
{category === "charging-station" && (
|
||||
<>
|
||||
<Field label="Tipe Charging">
|
||||
<ChipSelector options={EV_TYPES} value={evType} onChange={setEvType} />
|
||||
</Field>
|
||||
<Field label="Status Operasional">
|
||||
<ChipSelector options={EV_STATUSES} value={evStatus} onChange={setEvStatus} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dynamic Fields: Gas Station */}
|
||||
{category === "gas-pump" && (
|
||||
<>
|
||||
<Field label="Brand SPBU">
|
||||
<ChipSelector
|
||||
options={GAS_BRANDS}
|
||||
value={gasBrand}
|
||||
onChange={(brand) => {
|
||||
setGasBrand(brand);
|
||||
setSelectedGasTypes(GAS_TYPES_BY_BRAND[brand] ?? []);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Tipe Bahan Bakar">
|
||||
<MultiChipSelector
|
||||
options={GAS_TYPES_BY_BRAND[gasBrand] ?? []}
|
||||
values={selectedGasTypes}
|
||||
onChange={setSelectedGasTypes}
|
||||
color={GAS_BRANDS.find((b) => b.value === gasBrand)?.color ?? "#ff3b1f"}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Jam Operasional">
|
||||
<Input
|
||||
value={gasHours}
|
||||
onChange={(e) => setGasHours(e.target.value)}
|
||||
required
|
||||
className="bg-muted/50 border border-border focus-visible:border-ring"
|
||||
/>
|
||||
<div className="flex gap-1.5 mt-1">
|
||||
{["24 Jam", "06:00 - 22:00", "07:00 - 21:00"].map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
onClick={() => setGasHours(h)}
|
||||
className="text-[10px] bg-muted hover:bg-muted-foreground/20 text-muted-foreground px-2 py-0.5 rounded transition-colors cursor-pointer"
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dynamic Fields: Repair Shop */}
|
||||
{category === "wrench" && (
|
||||
<>
|
||||
<Field label="Spesialisasi">
|
||||
<ChipSelector options={REPAIR_SPECIALITIES} value={repairSpeciality} onChange={setRepairSpeciality} />
|
||||
</Field>
|
||||
<Field label="Status / Jam Buka">
|
||||
<Input
|
||||
value={repairStatus}
|
||||
onChange={(e) => setRepairStatus(e.target.value)}
|
||||
required
|
||||
className="bg-muted/50 border border-border focus-visible:border-ring"
|
||||
/>
|
||||
<div className="flex gap-1.5 mt-1">
|
||||
{["Buka 24 Jam", "Buka 08:00 - 17:00", "Tutup"].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setRepairStatus(s)}
|
||||
className="text-[10px] bg-muted hover:bg-muted-foreground/20 text-muted-foreground px-2 py-0.5 rounded transition-colors cursor-pointer"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive mt-2">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between gap-2 px-6 py-4 border-t">
|
||||
<TimedUndoAction
|
||||
compact
|
||||
deleteLabel="Hapus Lokasi"
|
||||
undoLabel="Batal"
|
||||
isDeleting={deleteActive}
|
||||
countDown={deleteCountdown}
|
||||
onToggle={toggleDelete}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting || isDeleting}
|
||||
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isLoading || isDeleting || !name.trim()}
|
||||
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40 cursor-pointer"
|
||||
>
|
||||
{isSubmitting ? "Menyimpan…" : "Simpan"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
"use client";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/set-state-in-effect */
|
||||
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
|
||||
/* ─── Selectors Options ────────────────────────── */
|
||||
|
||||
const EV_TYPES = [
|
||||
{ value: "DC Fast", label: "DC Fast", color: "#10b981" },
|
||||
{ value: "Supercharger", label: "Supercharger", color: "#14b8a6" },
|
||||
{ value: "AC Level 2", label: "AC Level 2", color: "#3b82f6" },
|
||||
];
|
||||
|
||||
const EV_STATUSES = [
|
||||
{ value: "Active", label: "Active", color: "#10b981" },
|
||||
{ value: "Maintenance", label: "Maintenance", color: "#eab308" },
|
||||
{ value: "Offline", label: "Offline", color: "#ef4444" },
|
||||
];
|
||||
|
||||
const GAS_BRANDS = [
|
||||
{ value: "Pertamina", label: "Pertamina", color: "#ef4444" },
|
||||
{ value: "Shell", label: "Shell", color: "#eab308" },
|
||||
{ value: "BP", label: "BP", color: "#10b981" },
|
||||
{ value: "Vivo", label: "Vivo", color: "#3b82f6" },
|
||||
];
|
||||
|
||||
const GAS_TYPES_BY_BRAND: Record<string, string[]> = {
|
||||
Pertamina: ["Pertalite", "Pertamax", "Pertamax Green", "Pertamax Turbo", "Dexlite", "Pertamina Dex", "Solar"],
|
||||
Shell: ["Shell Super", "Shell V-Power", "Shell V-Power Nitro+", "Shell V-Power Diesel"],
|
||||
BP: ["BP 92", "BP 95", "BP Ultimate", "BP Ultimate Diesel"],
|
||||
Vivo: ["Revvo 90", "Revvo 92", "Revvo 95"],
|
||||
};
|
||||
|
||||
const REPAIR_SPECIALITIES = [
|
||||
{ value: "Umum", label: "Umum", color: "#6b7280" },
|
||||
{ value: "AC & Kelistrikan",label: "AC & Kelistrikan",color: "#0ea5e9" },
|
||||
{ value: "Mesin", label: "Mesin", color: "#f97316" },
|
||||
{ value: "Ban & Velg", label: "Ban & Velg", color: "#f59e0b" },
|
||||
];
|
||||
|
||||
/* ─── Helpers ───────────────────────────────────── */
|
||||
|
||||
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipSelector({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
options: { value: string; label: string; color: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{options.map((opt) => {
|
||||
const isSelected = value === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={cn(
|
||||
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
|
||||
isSelected
|
||||
? "border-transparent text-white shadow-sm animate-in fade-in zoom-in-95 duration-150"
|
||||
: "border-border text-foreground/70 hover:bg-muted"
|
||||
)}
|
||||
style={isSelected ? { backgroundColor: opt.color } : undefined}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiChipSelector({
|
||||
options,
|
||||
values,
|
||||
onChange,
|
||||
color,
|
||||
}: {
|
||||
options: string[];
|
||||
values: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{options.map((opt) => {
|
||||
const isSelected = values.includes(opt);
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
onChange(values.filter((v) => v !== opt));
|
||||
} else {
|
||||
onChange([...values, opt]);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
|
||||
isSelected
|
||||
? "border-transparent text-white shadow-sm"
|
||||
: "border-border text-foreground/70 hover:bg-muted"
|
||||
)}
|
||||
style={isSelected ? { backgroundColor: color } : undefined}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Placement Dialog ─────────────────────────── */
|
||||
|
||||
interface StationPlacementDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pendingClick: { lat: number; lng: number } | null;
|
||||
markerType: string | null;
|
||||
onConfirm: (marker: UserMarker) => void;
|
||||
}
|
||||
|
||||
export function StationPlacementDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
pendingClick,
|
||||
markerType,
|
||||
onConfirm,
|
||||
}: StationPlacementDialogProps) {
|
||||
const [category, setCategory] = useState("gas-pump");
|
||||
const [name, setName] = useState("");
|
||||
|
||||
// Form fields for EV Charger
|
||||
const [evType, setEvType] = useState("DC Fast");
|
||||
const [evStatus, setEvStatus] = useState("Active");
|
||||
|
||||
// Form fields for Gas Station
|
||||
const [gasBrand, setGasBrand] = useState("Pertamina");
|
||||
const [gasHours, setGasHours] = useState("24 Jam");
|
||||
const [selectedGasTypes, setSelectedGasTypes] = useState<string[]>([]);
|
||||
|
||||
// Form fields for Repair Shop
|
||||
const [repairSpeciality, setRepairSpeciality] = useState("Umum");
|
||||
const [repairStatus, setRepairStatus] = useState("Buka 08:00 - 17:00");
|
||||
|
||||
// Submission state
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
|
||||
|
||||
// Synchronize with initial markerType when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && markerType) {
|
||||
setCategory(markerType);
|
||||
setName("");
|
||||
setEvType("DC Fast");
|
||||
setEvStatus("Active");
|
||||
setGasBrand("Pertamina");
|
||||
setGasHours("24 Jam");
|
||||
setSelectedGasTypes(GAS_TYPES_BY_BRAND["Pertamina"]);
|
||||
setRepairSpeciality("Umum");
|
||||
setRepairStatus("Buka 08:00 - 17:00");
|
||||
setError(null);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [open, markerType]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!pendingClick) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
let finalPoiType = "";
|
||||
let finalNotes = "";
|
||||
|
||||
const brand = category === "gas-pump" ? gasBrand : null;
|
||||
const sub_type = category === "charging-station" ? evType : (category === "wrench" ? repairSpeciality : null);
|
||||
const gas_types = category === "gas-pump" ? selectedGasTypes : null;
|
||||
const operating_hours = category === "gas-pump" ? gasHours : null;
|
||||
const status = category === "charging-station" ? evStatus : (category === "wrench" ? repairStatus : null);
|
||||
|
||||
if (category === "charging-station") {
|
||||
finalPoiType = evType;
|
||||
finalNotes = `Status: ${evStatus}`;
|
||||
} else if (category === "gas-pump") {
|
||||
finalPoiType = gasBrand;
|
||||
finalNotes = `Jam: ${gasHours}`;
|
||||
} else if (category === "wrench") {
|
||||
finalPoiType = repairSpeciality;
|
||||
finalNotes = repairStatus;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const res = await fetch(`${BACKEND_URL}/gas-station/markers`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name.trim() || `New ${category.replace("-", " ")}`,
|
||||
marker_category: category,
|
||||
brand,
|
||||
sub_type,
|
||||
gas_types,
|
||||
operating_hours,
|
||||
status,
|
||||
latitude: pendingClick.lat,
|
||||
longitude: pendingClick.lng,
|
||||
notes: null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error ?? "Gagal menyimpan data");
|
||||
}
|
||||
|
||||
const saved = await res.json();
|
||||
|
||||
const savedMarker: UserMarker = {
|
||||
id: `gsm-${saved.id}`,
|
||||
lat: pendingClick.lat,
|
||||
lng: pendingClick.lng,
|
||||
type: category,
|
||||
name: saved.name,
|
||||
meta: {
|
||||
poi_type: finalPoiType,
|
||||
notes: finalNotes,
|
||||
gas_types: category === "gas-pump" ? selectedGasTypes : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
onConfirm(savedMarker);
|
||||
handleCancel();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setName("");
|
||||
setError(null);
|
||||
setIsSubmitting(false);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
"charging-station": "EV Charger Baru",
|
||||
"gas-pump": "Gas Station Baru",
|
||||
wrench: "Bengkel Baru",
|
||||
};
|
||||
|
||||
const titleLabel = categoryLabels[category] || "Lokasi Baru";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<DialogTitle className="text-center text-base font-semibold">{titleLabel}</DialogTitle>
|
||||
<p className="text-center text-xs text-muted-foreground font-mono tabular-nums mt-1">
|
||||
{pendingClick ? `${pendingClick.lat.toFixed(5)}, ${pendingClick.lng.toFixed(5)}` : ""}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-5 flex flex-col gap-5">
|
||||
{/* Category Selector */}
|
||||
<Field label="Kategori Lokasi">
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full h-10 rounded-lg border border-border bg-muted/50 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring cursor-pointer"
|
||||
>
|
||||
<option value="charging-station">EV Charger</option>
|
||||
<option value="gas-pump">Gas Station</option>
|
||||
<option value="wrench">Repair Shop (Bengkel)</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{/* Location Name */}
|
||||
<Field label="Nama Lokasi">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={
|
||||
category === "charging-station"
|
||||
? "Contoh: VoltCharge EV Station"
|
||||
: category === "gas-pump"
|
||||
? "Contoh: EcoFuel Station Menteng"
|
||||
: "Contoh: Bengkel Motor Jaya"
|
||||
}
|
||||
required
|
||||
autoFocus
|
||||
className="bg-muted/50 border border-border focus-visible:border-ring"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Dynamic Fields: EV Charger */}
|
||||
{category === "charging-station" && (
|
||||
<>
|
||||
<Field label="Tipe Charging">
|
||||
<ChipSelector options={EV_TYPES} value={evType} onChange={setEvType} />
|
||||
</Field>
|
||||
<Field label="Status Operasional">
|
||||
<ChipSelector options={EV_STATUSES} value={evStatus} onChange={setEvStatus} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dynamic Fields: Gas Station */}
|
||||
{category === "gas-pump" && (
|
||||
<>
|
||||
<Field label="Brand SPBU">
|
||||
<ChipSelector
|
||||
options={GAS_BRANDS}
|
||||
value={gasBrand}
|
||||
onChange={(brand) => {
|
||||
setGasBrand(brand);
|
||||
setSelectedGasTypes(GAS_TYPES_BY_BRAND[brand] ?? []);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Tipe Bahan Bakar">
|
||||
<MultiChipSelector
|
||||
options={GAS_TYPES_BY_BRAND[gasBrand] ?? []}
|
||||
values={selectedGasTypes}
|
||||
onChange={setSelectedGasTypes}
|
||||
color={GAS_BRANDS.find((b) => b.value === gasBrand)?.color ?? "#ff3b1f"}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Jam Operasional">
|
||||
<Input
|
||||
value={gasHours}
|
||||
onChange={(e) => setGasHours(e.target.value)}
|
||||
placeholder="Contoh: 24 Jam atau 06:00 - 22:00"
|
||||
required
|
||||
className="bg-muted/50 border border-border focus-visible:border-ring"
|
||||
/>
|
||||
<div className="flex gap-1.5 mt-1">
|
||||
{["24 Jam", "06:00 - 22:00", "07:00 - 21:00"].map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
onClick={() => setGasHours(h)}
|
||||
className="text-[10px] bg-muted hover:bg-muted-foreground/20 text-muted-foreground px-2 py-0.5 rounded transition-colors cursor-pointer"
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dynamic Fields: Repair Shop */}
|
||||
{category === "wrench" && (
|
||||
<>
|
||||
<Field label="Spesialisasi">
|
||||
<ChipSelector options={REPAIR_SPECIALITIES} value={repairSpeciality} onChange={setRepairSpeciality} />
|
||||
</Field>
|
||||
<Field label="Status / Jam Buka">
|
||||
<Input
|
||||
value={repairStatus}
|
||||
onChange={(e) => setRepairStatus(e.target.value)}
|
||||
placeholder="Contoh: Buka 08:00 - 17:00 atau Tutup"
|
||||
required
|
||||
className="bg-muted/50 border border-border focus-visible:border-ring"
|
||||
/>
|
||||
<div className="flex gap-1.5 mt-1">
|
||||
{["Buka 24 Jam", "Buka 08:00 - 17:00", "Tutup"].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setRepairStatus(s)}
|
||||
className="text-[10px] bg-muted hover:bg-muted-foreground/20 text-muted-foreground px-2 py-0.5 rounded transition-colors cursor-pointer"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive mt-2">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !name.trim()}
|
||||
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40 cursor-pointer"
|
||||
>
|
||||
{isSubmitting ? "Menyimpan…" : "Simpan & Pasang"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export { EV_TYPES, EV_STATUSES, GAS_BRANDS, REPAIR_SPECIALITIES, GAS_TYPES_BY_BRAND };
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/components/logo";
|
||||
import { useScroll } from "@/hooks/use-scroll";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MobileNav } from "@/components/mobile-nav";
|
||||
|
||||
export const navLinks = [
|
||||
{
|
||||
label: "Features",
|
||||
href: "#",
|
||||
},
|
||||
{
|
||||
label: "Pricing",
|
||||
href: "#",
|
||||
},
|
||||
{
|
||||
label: "About",
|
||||
href: "#",
|
||||
},
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
const scrolled = useScroll(10);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"sticky top-0 z-50 mx-auto w-full max-w-4xl border-transparent border-b md:rounded-md md:border md:transition-all md:ease-out",
|
||||
{
|
||||
"border-border bg-background/95 backdrop-blur-sm supports-backdrop-filter:bg-background/50 md:top-2 md:max-w-3xl md:shadow":
|
||||
scrolled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
className={cn(
|
||||
"flex h-14 w-full items-center justify-between px-4 md:h-12 md:transition-all md:ease-out",
|
||||
{
|
||||
"md:px-2": scrolled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<a
|
||||
className="rounded-md p-2 hover:bg-muted dark:hover:bg-muted/50"
|
||||
href="#"
|
||||
>
|
||||
<Logo className="h-4" />
|
||||
</a>
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<div>
|
||||
{navLinks.map((link) => (
|
||||
<Button key={link.label} size="sm" variant="ghost" render={<a href={link.href} />} nativeButton={false}>{link.label}</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" variant="outline">
|
||||
Sign In
|
||||
</Button>
|
||||
<Button size="sm">Get Started</Button>
|
||||
</div>
|
||||
<MobileNav />
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import type * as React from "react";
|
||||
import { Bar, BarChart, XAxis } from "recharts";
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { Delta, DeltaIcon, DeltaValue } from "@/components/delta";
|
||||
import { DashboardCard } from "@/components/dashboard-card";
|
||||
import { formatDate } from "@/components/formater";
|
||||
import type { PovertyOverview } from "@/lib/poverty-types";
|
||||
|
||||
interface HouseholdsTrendChartProps {
|
||||
monthly: PovertyOverview["monthly"];
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
label: "KK Terdata",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
function CustomGradientBar(
|
||||
props: React.SVGProps<SVGRectElement> & {
|
||||
index?: number;
|
||||
dataKey?: string | number;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
fill,
|
||||
x = 0,
|
||||
y = 0,
|
||||
width = 0,
|
||||
height = 0,
|
||||
dataKey = "count",
|
||||
index = 0,
|
||||
} = props;
|
||||
const gid = `gradient-bar-${String(dataKey)}-${index}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<rect
|
||||
fill={`url(#${gid})`}
|
||||
height={height}
|
||||
stroke="none"
|
||||
width={width}
|
||||
x={x}
|
||||
y={y}
|
||||
/>
|
||||
<rect fill={fill} height={2} stroke="none" width={width} x={x} y={y} />
|
||||
<defs>
|
||||
<linearGradient id={gid} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={fill} stopOpacity={0.5} />
|
||||
<stop offset="100%" stopColor={fill} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function HouseholdsTrendChart({ monthly }: HouseholdsTrendChartProps) {
|
||||
const chartRows = monthly.map((m) => ({ month: m.month, count: m.count }));
|
||||
|
||||
const first = chartRows[0]?.count ?? 0;
|
||||
const last = chartRows.at(-1)?.count ?? 0;
|
||||
const growthPct = first > 0 ? ((last - first) / first) * 100 : 0;
|
||||
|
||||
return (
|
||||
<DashboardCard className="gap-0 md:col-span-2">
|
||||
<CardHeader className="gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CardTitle>Pendataan Warga</CardTitle>
|
||||
{chartRows.length > 1 && (
|
||||
<Delta value={Number(growthPct.toFixed(1))} variant="badge">
|
||||
<DeltaIcon variant="trend" />
|
||||
<DeltaValue />
|
||||
</Delta>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
KK baru terdata per bulan, 12 bulan terakhir.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartRows.length === 0 ? (
|
||||
<div className="flex h-60 w-full items-center justify-center text-xs text-muted-foreground md:h-80">
|
||||
Belum ada data pendataan warga.
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer
|
||||
className="aspect-auto h-60 w-full md:h-80"
|
||||
config={chartConfig}
|
||||
>
|
||||
<BarChart accessibilityLayer data={chartRows}>
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="month"
|
||||
interval={0}
|
||||
tickFormatter={(value) => formatDate(`${value}-01`, "month")}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent hideLabel />}
|
||||
cursor={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="var(--color-count)"
|
||||
shape={<CustomGradientBar />}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</DashboardCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function AppleIcon({
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<g>
|
||||
<path d="M18.546,12.763c0.024-1.87,1.004-3.597,2.597-4.576c-1.009-1.442-2.64-2.323-4.399-2.378 c-1.851-0.194-3.645,1.107-4.588,1.107c-0.961,0-2.413-1.088-3.977-1.056C6.122,5.927,4.25,7.068,3.249,8.867 c-2.131,3.69-0.542,9.114,1.5,12.097c1.022,1.461,2.215,3.092,3.778,3.035c1.529-0.063,2.1-0.975,3.945-0.975 c1.828,0,2.364,0.975,3.958,0.938c1.64-0.027,2.674-1.467,3.66-2.942c0.734-1.041,1.299-2.191,1.673-3.408 C19.815,16.788,18.548,14.879,18.546,12.763z" />
|
||||
<path d="M15.535,3.847C16.429,2.773,16.87,1.393,16.763,0c-1.366,0.144-2.629,0.797-3.535,1.829 c-0.895,1.019-1.349,2.351-1.261,3.705C13.352,5.548,14.667,4.926,15.535,3.847z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function GithubIcon(props: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 438.549 438.549" {...props}>
|
||||
<path
|
||||
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function GoogleIcon(props: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<g>
|
||||
<path d="M12.479,14.265v-3.279h11.049c0.108,0.571,0.164,1.247,0.164,1.979c0,2.46-0.672,5.502-2.84,7.669C18.744,22.829,16.051,24,12.483,24C5.869,24,0.308,18.613,0.308,12S5.869,0,12.483,0c3.659,0,6.265,1.436,8.223,3.307L18.392,5.62c-1.404-1.317-3.307-2.341-5.913-2.341C7.65,3.279,3.873,7.171,3.873,12s3.777,8.721,8.606,8.721c3.132,0,4.916-1.258,6.059-2.401c0.927-0.927,1.537-2.251,1.777-4.059L12.479,14.265z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
|
||||
|
||||
interface LandAllocationChartProps {
|
||||
pieChartData: Array<{
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function LandAllocationChart({ pieChartData }: LandAllocationChartProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold font-sans">Land Status Breakdown</CardTitle>
|
||||
<CardDescription>Acreage and share representation of registered parcels by ownership status.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col sm:flex-row items-center justify-around gap-6 py-6 min-h-[260px]">
|
||||
{pieChartData.length > 0 ? (
|
||||
<>
|
||||
<div className="h-56 w-56 shrink-0">
|
||||
{!isMounted ? (
|
||||
<div className="h-full w-full" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieChartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={65}
|
||||
outerRadius={85}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{pieChartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "rgba(12, 10, 8, 0.95)",
|
||||
border: "1px solid #4d505d",
|
||||
borderRadius: "6px",
|
||||
fontSize: "12px",
|
||||
color: "#ffffff"
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-xs w-full max-w-sm">
|
||||
{pieChartData.map((d, index) => (
|
||||
<div key={index} className="flex items-center gap-2 border p-2 rounded bg-background">
|
||||
<div className="h-3 w-3 rounded shrink-0" style={{ backgroundColor: d.color }} />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-semibold text-foreground truncate">{d.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{d.value} Hectares</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center text-xs text-muted-foreground py-12">Belum ada data poligon lahan.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SearchIcon } from "@/components/ui/phosphor-icons";
|
||||
import { LAND_MARKER_LABELS, LAND_MARKER_COLORS } from "@/lib/map-data";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
import { calcLineLength, calcPolygonArea } from "@/components/land/shape-placement-dialog";
|
||||
|
||||
interface LandDirectoryProps {
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
filteredItems: UserMarker[];
|
||||
selectedItemId: string | null;
|
||||
visibilityFilters: Record<string, boolean>;
|
||||
onSelectItem: (item: UserMarker) => void;
|
||||
}
|
||||
|
||||
function itemBadge(item: UserMarker): string {
|
||||
const coordinates = item.meta?.coordinates ?? [];
|
||||
if (item.type === "polygon" && coordinates.length > 0) {
|
||||
return `${(calcPolygonArea(coordinates) / 10000).toFixed(2)} Ha`;
|
||||
}
|
||||
if (item.type === "line" && coordinates.length > 0) {
|
||||
const length = calcLineLength(coordinates);
|
||||
return length >= 1000 ? `${(length / 1000).toFixed(2)} km` : `${length.toFixed(1)} m`;
|
||||
}
|
||||
if (item.type === "circle") {
|
||||
const radius = item.meta?.radius ?? 0;
|
||||
return `${radius.toFixed(1)} m`;
|
||||
}
|
||||
return LAND_MARKER_LABELS[item.type] ?? item.type;
|
||||
}
|
||||
|
||||
export function LandDirectory({
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredItems,
|
||||
selectedItemId,
|
||||
visibilityFilters,
|
||||
onSelectItem,
|
||||
}: LandDirectoryProps) {
|
||||
return (
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
|
||||
<CardHeader className="py-4">
|
||||
<CardTitle className="text-base font-semibold">GIS Layer Directory</CardTitle>
|
||||
<CardDescription>Select a marker or shape to review survey metadata and inspector details.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-6 flex-1">
|
||||
<div className="relative mb-4">
|
||||
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name or type..."
|
||||
className="pl-9 bg-background h-10"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{filteredItems.length > 0 ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{filteredItems.map((item) => {
|
||||
const isSelected = selectedItemId === item.id;
|
||||
const isVisible = visibilityFilters[item.type] !== false;
|
||||
const color = LAND_MARKER_COLORS[item.type] ?? "#6b7280";
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => onSelectItem(item)}
|
||||
className={`p-3 border rounded-lg cursor-pointer flex justify-between items-center gap-2 transition-all ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 text-primary font-semibold"
|
||||
: "border-border/60 hover:bg-muted"
|
||||
} ${!isVisible ? "opacity-45" : ""}`}
|
||||
>
|
||||
<span className="text-xs text-foreground font-medium truncate">{item.name}</span>
|
||||
<span
|
||||
className="text-[9px] border px-2 py-0.5 rounded font-bold shrink-0"
|
||||
style={{
|
||||
color,
|
||||
borderColor: `${color}33`,
|
||||
backgroundColor: `${color}11`
|
||||
}}
|
||||
>
|
||||
{itemBadge(item)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-xs text-muted-foreground py-12">
|
||||
Belum ada penanda atau bentuk yang ditambahkan. Buka peta untuk mulai menambahkan.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LayersIcon, EyeIcon, EyeOffIcon, ShieldAlertIcon } from "@/components/ui/phosphor-icons";
|
||||
import { LAND_MARKER_LABELS, LAND_MARKER_COLORS } from "@/lib/map-data";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
import { calcLineLength, calcPolygonArea } from "@/components/land/shape-placement-dialog";
|
||||
|
||||
function formatDate(iso?: string): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("id-ID", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
interface LandInspectorTopProps {
|
||||
selectedItem: UserMarker | null;
|
||||
visibilityFilters: Record<string, boolean>;
|
||||
toggleFilter: (type: string) => void;
|
||||
}
|
||||
|
||||
export function LandInspectorTop({ selectedItem, visibilityFilters, toggleFilter }: LandInspectorTopProps) {
|
||||
if (!selectedItem) {
|
||||
return (
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
|
||||
<CardHeader className="border-b bg-muted/20 px-6 py-4 flex flex-col justify-center">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<LayersIcon className="h-4 w-4 text-primary" />
|
||||
Boundary Inspector
|
||||
</CardTitle>
|
||||
<CardDescription>Reviewing border registries.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 flex-1 flex items-center justify-center">
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Belum ada item dipilih. Buka peta atau pilih item dari direktori.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const color = LAND_MARKER_COLORS[selectedItem.type] ?? "#6b7280";
|
||||
const label = LAND_MARKER_LABELS[selectedItem.type] ?? selectedItem.type;
|
||||
const coordinates = selectedItem.meta?.coordinates ?? [];
|
||||
const radius = selectedItem.meta?.radius ?? 0;
|
||||
const length = selectedItem.type === "line" ? calcLineLength(coordinates) : 0;
|
||||
|
||||
return (
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
|
||||
<CardHeader className="border-b bg-muted/20 px-6 py-4 flex flex-col justify-center">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<LayersIcon className="h-4 w-4 text-primary" />
|
||||
Boundary Inspector
|
||||
</CardTitle>
|
||||
<CardDescription>Reviewing border registries.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 flex flex-col gap-5 flex-1 justify-center">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-foreground">{selectedItem.name}</h3>
|
||||
<span
|
||||
className="inline-block mt-1.5 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border"
|
||||
style={{
|
||||
color,
|
||||
borderColor: `${color}33`,
|
||||
backgroundColor: `${color}11`
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5 text-xs py-3 border-y">
|
||||
{selectedItem.type === "polygon" && coordinates.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Luas Terdaftar:</span>
|
||||
<span className="font-semibold text-foreground">{(calcPolygonArea(coordinates) / 10000).toFixed(2)} Ha</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedItem.type === "line" && coordinates.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Panjang:</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{length >= 1000 ? `${(length / 1000).toFixed(2)} km` : `${length.toFixed(1)} m`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedItem.type === "circle" && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Radius:</span>
|
||||
<span className="font-semibold text-foreground">{radius.toFixed(1)} m</span>
|
||||
</div>
|
||||
)}
|
||||
{(selectedItem.type === "line" || selectedItem.type === "polygon") && selectedItem.meta?.poi_type && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Status:</span>
|
||||
<span className="font-semibold text-foreground">{selectedItem.meta.poi_type}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Created By:</span>
|
||||
<span className="font-semibold text-foreground text-right max-w-44 truncate">{selectedItem.meta?.created_by_username ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Created:</span>
|
||||
<span className="font-semibold text-foreground">{formatDate(selectedItem.meta?.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 justify-center py-2">
|
||||
<span className="text-[10px] font-bold text-muted-foreground uppercase">Visibility filter status:</span>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleFilter(selectedItem.type)}
|
||||
className="h-8 gap-1 text-xs"
|
||||
>
|
||||
{visibilityFilters[selectedItem.type] !== false ? (
|
||||
<>
|
||||
<EyeOffIcon className="h-3.5 w-3.5" />
|
||||
Hide Category
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeIcon className="h-3.5 w-3.5" />
|
||||
Show Category
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface LandInspectorBottomProps {
|
||||
recentFlags: UserMarker[];
|
||||
}
|
||||
|
||||
export function LandInspectorBottom({ recentFlags }: LandInspectorBottomProps) {
|
||||
return (
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col justify-between">
|
||||
<CardHeader className="py-3 px-6 border-b bg-muted/10">
|
||||
<CardTitle className="text-xs font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<ShieldAlertIcon className="h-3.5 w-3.5 text-primary" />
|
||||
Surveyor Logs
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 flex flex-col gap-2 flex-1">
|
||||
{recentFlags.length > 0 ? (
|
||||
recentFlags.map((flag) => (
|
||||
<div key={flag.id} className="p-2 border rounded bg-background text-[11px] flex flex-col gap-1">
|
||||
<div className="flex justify-between font-bold text-primary">
|
||||
<span className="truncate">{flag.name}</span>
|
||||
</div>
|
||||
{flag.meta?.notes && (
|
||||
<p className="text-muted-foreground">{flag.meta.notes}</p>
|
||||
)}
|
||||
<span className="text-[9px] text-muted-foreground/60">Reported: {formatDate(flag.meta?.created_at)}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-center text-xs text-muted-foreground py-6">Belum ada tengara lahan tercatat.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { LayersIcon, RoadHorizonIcon, MapPinIcon } from "@/components/ui/phosphor-icons";
|
||||
|
||||
interface LandStatsCardsProps {
|
||||
aggregateStats: {
|
||||
totalParcelArea: string;
|
||||
totalRoadLength: string;
|
||||
activeMarkers: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function LandStatsCards({ aggregateStats }: LandStatsCardsProps) {
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<div className="flex justify-between items-center pb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">Total Parcel Area</span>
|
||||
<LayersIcon className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-foreground mt-2">{aggregateStats.totalParcelArea} Ha</div>
|
||||
</div>
|
||||
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
|
||||
Sum of all registered polygon parcels
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<div className="flex justify-between items-center pb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">Total Road Length</span>
|
||||
<RoadHorizonIcon className="h-4 w-4 text-cobalt-glow" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-foreground mt-2">{aggregateStats.totalRoadLength} km</div>
|
||||
</div>
|
||||
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
|
||||
Sum of all measured road/line segments
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<div className="flex justify-between items-center pb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">Active Markers</span>
|
||||
<MapPinIcon className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-foreground mt-2">{aggregateStats.activeMarkers} Markers</div>
|
||||
</div>
|
||||
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
|
||||
Land pins, survey flags, protected zones & registries
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
import { TimedUndoAction } from "@/components/time-undo-action";
|
||||
import {
|
||||
ROAD_STATUSES,
|
||||
LAND_STATUSES,
|
||||
calcLineLength,
|
||||
calcPolygonArea,
|
||||
} from "@/components/land/shape-placement-dialog";
|
||||
import { LAND_MARKER_LABELS } from "@/lib/map-data";
|
||||
|
||||
/* ─── Shared Field component ─────────────────────────────── */
|
||||
|
||||
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Stat Chip ──────────────────────────────────────────── */
|
||||
|
||||
function StatChip({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2">
|
||||
<span className="text-[10px] font-semibold text-foreground/50 uppercase tracking-wider">{label}</span>
|
||||
<span className="text-sm font-semibold text-foreground tabular-nums ml-auto">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Status Chip Selector ───────────────────────────────── */
|
||||
|
||||
function StatusSelector({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
options: { value: string; label: string; color: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{options.map((opt) => {
|
||||
const isSelected = value === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={cn(
|
||||
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all",
|
||||
isSelected
|
||||
? "border-transparent text-white shadow-sm"
|
||||
: "border-border text-foreground/70 hover:bg-muted"
|
||||
)}
|
||||
style={isSelected ? { backgroundColor: opt.color } : undefined}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Dialog Component ───────────────────────────────────── */
|
||||
|
||||
interface ShapeEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
shape: UserMarker | null;
|
||||
onUpdated: (updated: UserMarker) => void;
|
||||
onDeleted: (deleted: UserMarker) => void;
|
||||
}
|
||||
|
||||
export function ShapeEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
shape,
|
||||
onUpdated,
|
||||
onDeleted,
|
||||
}: ShapeEditDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const DELETE_SECONDS = 5;
|
||||
const TOAST_ID = "shape-delete";
|
||||
|
||||
const [deleteActive, setDeleteActive] = useState(false);
|
||||
const [deleteCountdown, setDeleteCountdown] = useState(DELETE_SECONDS);
|
||||
const pendingDeleteShape = useRef<UserMarker | null>(null);
|
||||
|
||||
const shapeType = shape?.type ?? "";
|
||||
const isLine = shapeType === "line";
|
||||
const isPolygon = shapeType === "polygon";
|
||||
const isCircle = shapeType === "circle";
|
||||
|
||||
const titleLabel = `Edit ${LAND_MARKER_LABELS[shapeType] ?? "Item"}`;
|
||||
const coordinates = shape?.meta?.coordinates ?? [];
|
||||
const radius = shape?.meta?.radius ?? 0;
|
||||
|
||||
const length = isLine ? calcLineLength(coordinates) : 0;
|
||||
const area = isPolygon
|
||||
? calcPolygonArea(coordinates)
|
||||
: isCircle
|
||||
? Math.PI * radius * radius
|
||||
: 0;
|
||||
|
||||
const statusOptions = isLine ? ROAD_STATUSES : isPolygon ? LAND_STATUSES : [];
|
||||
|
||||
// Reset when different shape opens
|
||||
useEffect(() => {
|
||||
if (!shape?.id) return;
|
||||
setName(shape.name);
|
||||
setStatus(shape.meta?.poi_type ?? (isLine ? "Regency Road" : isPolygon ? "SHM" : ""));
|
||||
setNotes(shape.meta?.notes ?? "");
|
||||
setDeleteActive(false);
|
||||
setDeleteCountdown(DELETE_SECONDS);
|
||||
pendingDeleteShape.current = null;
|
||||
toast.dismiss(TOAST_ID);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shape?.id]);
|
||||
|
||||
// Countdown tick
|
||||
useEffect(() => {
|
||||
if (!deleteActive) return;
|
||||
const id = setInterval(() => setDeleteCountdown((p) => Math.max(0, p - 1)), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [deleteActive]);
|
||||
|
||||
// Fire delete when countdown hits 0
|
||||
useEffect(() => {
|
||||
if (!deleteActive || deleteCountdown > 0) return;
|
||||
setDeleteActive(false);
|
||||
setDeleteCountdown(DELETE_SECONDS);
|
||||
toast.dismiss(TOAST_ID);
|
||||
handleDelete();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deleteActive, deleteCountdown]);
|
||||
|
||||
// Toast while countdown is running
|
||||
useEffect(() => {
|
||||
if (!deleteActive || !pendingDeleteShape.current) {
|
||||
toast.dismiss(TOAST_ID);
|
||||
return;
|
||||
}
|
||||
const shapeName = pendingDeleteShape.current.name;
|
||||
const pct = (deleteCountdown / DELETE_SECONDS) * 100;
|
||||
toast.custom(
|
||||
() => (
|
||||
<div className="relative flex items-center gap-3 w-72 rounded-xl border border-border bg-popover px-4 py-3 shadow-lg overflow-hidden font-sans">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-[3px] bg-red-500 transition-[width] duration-1000 ease-linear"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground truncate">Menghapus shape</p>
|
||||
<p className="text-xs text-muted-foreground truncate">"{shapeName}" — dalam {deleteCountdown} detik</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeleteActive(false);
|
||||
setDeleteCountdown(DELETE_SECONDS);
|
||||
pendingDeleteShape.current = null;
|
||||
toast.dismiss(TOAST_ID);
|
||||
}}
|
||||
className="shrink-0 h-7 rounded-full border border-border bg-background px-3 text-xs font-medium hover:bg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
Batalkan
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
{ id: TOAST_ID, duration: Infinity, position: "bottom-left" },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deleteActive, deleteCountdown]);
|
||||
|
||||
function toggleDelete() {
|
||||
setDeleteActive((prev) => {
|
||||
if (!prev) {
|
||||
pendingDeleteShape.current = shape;
|
||||
setDeleteCountdown(DELETE_SECONDS);
|
||||
} else {
|
||||
pendingDeleteShape.current = null;
|
||||
toast.dismiss(TOAST_ID);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
const target = pendingDeleteShape.current;
|
||||
if (!target) return;
|
||||
pendingDeleteShape.current = null;
|
||||
onDeleted(target);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!shape) return;
|
||||
|
||||
const updated: UserMarker = {
|
||||
...shape,
|
||||
name: name.trim() || shape.name,
|
||||
meta: {
|
||||
...shape.meta,
|
||||
poi_type: status || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
onUpdated(updated);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onOpenChange(false); }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<DialogTitle className="text-center text-base font-semibold">{titleLabel}</DialogTitle>
|
||||
<p className="text-center text-xs text-muted-foreground font-mono tabular-nums mt-1">
|
||||
{shape ? `${shape.lat.toFixed(5)}, ${shape.lng.toFixed(5)}` : ""}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-5 flex flex-col gap-5">
|
||||
{/* Name */}
|
||||
<Field label={isLine ? "Nama Jalan" : isPolygon ? "Nama Lahan" : isCircle ? "Nama Area" : "Nama Penanda"}>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="bg-muted/50 border border-border focus-visible:border-ring"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Notes (optional, all types) */}
|
||||
<Field label="Catatan">
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Catatan tambahan (opsional)"
|
||||
rows={2}
|
||||
className="w-full min-w-0 rounded-lg border border-input bg-muted/50 px-2.5 py-1.5 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 resize-none"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Status (line / polygon only) */}
|
||||
{statusOptions.length > 0 && (
|
||||
<Field label="Status">
|
||||
<StatusSelector options={statusOptions} value={status} onChange={setStatus} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Auto-calculated metrics */}
|
||||
<div className={cn("grid gap-3", isCircle ? "grid-cols-2" : "grid-cols-1")}>
|
||||
{isLine && (
|
||||
<StatChip
|
||||
label="Panjang"
|
||||
value={length >= 1000
|
||||
? `${(length / 1000).toFixed(2)} km`
|
||||
: `${length.toFixed(1)} m`}
|
||||
/>
|
||||
)}
|
||||
{(isPolygon || isCircle) && (
|
||||
<StatChip
|
||||
label="Luas"
|
||||
value={area >= 10000
|
||||
? `${(area / 10000).toFixed(2)} ha`
|
||||
: `${area.toFixed(1)} m²`}
|
||||
/>
|
||||
)}
|
||||
{isCircle && (
|
||||
<StatChip label="Radius" value={`${radius.toFixed(1)} m`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(isLine || isPolygon) && coordinates.length > 0 && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{coordinates.length} titik koordinat
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between gap-2 px-6 py-4 border-t">
|
||||
<TimedUndoAction
|
||||
compact
|
||||
deleteLabel="Hapus Shape"
|
||||
undoLabel="Batal"
|
||||
isDeleting={deleteActive}
|
||||
countDown={deleteCountdown}
|
||||
onToggle={toggleDelete}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim()}
|
||||
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40"
|
||||
>
|
||||
Simpan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
import { LAND_MARKER_LABELS } from "@/lib/map-data";
|
||||
|
||||
/* ─── Status options per shape type ──────────────────────── */
|
||||
|
||||
const ROAD_STATUSES = [
|
||||
{ value: "National Road", label: "National", color: "#ef4444" },
|
||||
{ value: "Provincial Road", label: "Provincial", color: "#eab308" },
|
||||
{ value: "Regency Road", label: "Regency", color: "#22c55e" },
|
||||
];
|
||||
|
||||
const LAND_STATUSES = [
|
||||
{ value: "SHM", label: "SHM", color: "#a855f7" },
|
||||
{ value: "HGB", label: "HGB", color: "#f97316" },
|
||||
{ value: "HGU", label: "HGU", color: "#14b8a6" },
|
||||
{ value: "HP", label: "HP", color: "#ec4899" },
|
||||
];
|
||||
|
||||
/* ─── Geometry helpers ───────────────────────────────────── */
|
||||
|
||||
/** Calculate polyline length from [lat, lng][] coordinates in meters */
|
||||
function calcLineLength(coords: [number, number][]): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
total += haversineDistance(coords[i - 1], coords[i]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Calculate polygon area from [lat, lng][] using shoelfoot formula + spherical correction */
|
||||
function calcPolygonArea(coords: [number, number][]): number {
|
||||
// Shoelace formula on projected coords (approximate for small areas)
|
||||
const R = 6371000; // Earth radius in meters
|
||||
let area = 0;
|
||||
const n = coords.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
const lat1 = coords[i][0] * Math.PI / 180;
|
||||
const lat2 = coords[j][0] * Math.PI / 180;
|
||||
const lng1 = coords[i][1] * Math.PI / 180;
|
||||
const lng2 = coords[j][1] * Math.PI / 180;
|
||||
area += (lng2 - lng1) * (2 + Math.sin(lat1) + Math.sin(lat2));
|
||||
}
|
||||
area = Math.abs(area * R * R / 2);
|
||||
return area;
|
||||
}
|
||||
|
||||
/** Haversine distance between two [lat, lng] points in meters */
|
||||
function haversineDistance(a: [number, number], b: [number, number]): number {
|
||||
const R = 6371000;
|
||||
const dLat = (b[0] - a[0]) * Math.PI / 180;
|
||||
const dLng = (b[1] - a[1]) * Math.PI / 180;
|
||||
const lat1 = a[0] * Math.PI / 180;
|
||||
const lat2 = b[0] * Math.PI / 180;
|
||||
const s = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
|
||||
}
|
||||
|
||||
/* ─── Shared Field component ─────────────────────────────── */
|
||||
|
||||
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Stat Chip (for read-only auto-calculated values) ──── */
|
||||
|
||||
function StatChip({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2">
|
||||
<span className="text-[10px] font-semibold text-foreground/50 uppercase tracking-wider">{label}</span>
|
||||
<span className="text-sm font-semibold text-foreground tabular-nums ml-auto">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Status Chip Selector ───────────────────────────────── */
|
||||
|
||||
function StatusSelector({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
options: { value: string; label: string; color: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{options.map((opt) => {
|
||||
const isSelected = value === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={cn(
|
||||
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all",
|
||||
isSelected
|
||||
? "border-transparent text-white shadow-sm"
|
||||
: "border-border text-foreground/70 hover:bg-muted"
|
||||
)}
|
||||
style={isSelected ? { backgroundColor: opt.color } : undefined}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Dialog Component ───────────────────────────────────── */
|
||||
|
||||
interface ShapePlacementDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pendingShape: UserMarker | null;
|
||||
onConfirm: (shape: UserMarker) => void;
|
||||
}
|
||||
|
||||
export function ShapePlacementDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
pendingShape,
|
||||
onConfirm,
|
||||
}: ShapePlacementDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const shapeType = pendingShape?.type ?? "";
|
||||
const isLine = shapeType === "line";
|
||||
const isPolygon = shapeType === "polygon";
|
||||
const isCircle = shapeType === "circle";
|
||||
|
||||
const titleLabel = isLine ? "Jalan Baru" : isPolygon ? "Lahan Baru" : isCircle ? "Area Baru" : (LAND_MARKER_LABELS[shapeType] ?? "Item Baru");
|
||||
|
||||
// Auto-calculated metrics
|
||||
const coordinates = pendingShape?.meta?.coordinates ?? [];
|
||||
const radius = pendingShape?.meta?.radius ?? 0;
|
||||
|
||||
const length = isLine ? calcLineLength(coordinates) : 0;
|
||||
const area = isPolygon
|
||||
? calcPolygonArea(coordinates)
|
||||
: isCircle
|
||||
? Math.PI * radius * radius
|
||||
: 0;
|
||||
|
||||
const statusOptions = isLine ? ROAD_STATUSES : isPolygon ? LAND_STATUSES : [];
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!pendingShape) return;
|
||||
|
||||
const finalName = name.trim() || pendingShape.name;
|
||||
const updated: UserMarker = {
|
||||
...pendingShape,
|
||||
name: finalName,
|
||||
meta: {
|
||||
...pendingShape.meta,
|
||||
poi_type: status || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
onConfirm(updated);
|
||||
// Reset
|
||||
setName("");
|
||||
setStatus("");
|
||||
setNotes("");
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setName("");
|
||||
setStatus("");
|
||||
setNotes("");
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
// Reset form when dialog opens with a new shape
|
||||
useEffect(() => {
|
||||
if (!pendingShape?.id) return;
|
||||
setName("");
|
||||
const t = pendingShape.type;
|
||||
setStatus(t === "line" ? "Regency Road" : t === "polygon" ? "SHM" : "");
|
||||
setNotes(pendingShape.meta?.notes ?? "");
|
||||
}, [pendingShape?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<DialogTitle className="text-center text-base font-semibold">{titleLabel}</DialogTitle>
|
||||
<p className="text-center text-xs text-muted-foreground font-mono tabular-nums mt-1">
|
||||
{pendingShape ? `${pendingShape.lat.toFixed(5)}, ${pendingShape.lng.toFixed(5)}` : ""}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-5 flex flex-col gap-5">
|
||||
{/* Name */}
|
||||
<Field label={isLine ? "Nama Jalan" : isPolygon ? "Nama Lahan" : isCircle ? "Nama Area" : "Nama Penanda"}>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={isLine ? "Contoh: Jl. Merdeka" : isPolygon ? "Contoh: Lahan Sawah A1" : isCircle ? "Contoh: Buffer Zone A" : "Contoh: Patok Batas Utara"}
|
||||
required
|
||||
className="bg-muted/50 border border-border focus-visible:border-ring"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Notes (optional, all types) */}
|
||||
<Field label="Catatan">
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Catatan tambahan (opsional)"
|
||||
rows={2}
|
||||
className="w-full min-w-0 rounded-lg border border-input bg-muted/50 px-2.5 py-1.5 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 resize-none"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Status selector (line / polygon only) */}
|
||||
{statusOptions.length > 0 && (
|
||||
<Field label="Status">
|
||||
<StatusSelector options={statusOptions} value={status} onChange={setStatus} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Auto-calculated metrics */}
|
||||
<div className={cn("grid gap-3", isCircle ? "grid-cols-2" : "grid-cols-1")}>
|
||||
{isLine && (
|
||||
<StatChip
|
||||
label="Panjang"
|
||||
value={length >= 1000
|
||||
? `${(length / 1000).toFixed(2)} km`
|
||||
: `${length.toFixed(1)} m`}
|
||||
/>
|
||||
)}
|
||||
{(isPolygon || isCircle) && (
|
||||
<StatChip
|
||||
label="Luas"
|
||||
value={area >= 10000
|
||||
? `${(area / 10000).toFixed(2)} ha`
|
||||
: `${area.toFixed(1)} m²`}
|
||||
/>
|
||||
)}
|
||||
{isCircle && (
|
||||
<StatChip label="Radius" value={`${radius.toFixed(1)} m`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Coordinate count */}
|
||||
{(isLine || isPolygon) && coordinates.length > 0 && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{coordinates.length} titik koordinat
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim()}
|
||||
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40"
|
||||
>
|
||||
Simpan & Pasang
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export helpers for use in edit dialog and leaflet-map
|
||||
export { ROAD_STATUSES, LAND_STATUSES, calcLineLength, calcPolygonArea };
|
||||
@@ -0,0 +1,111 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
export function LandingAbout() {
|
||||
return (
|
||||
<section id="about" className="bg-white pt-16 sm:pt-20 lg:pt-32 pb-12 sm:pb-16 lg:pb-24 overflow-hidden w-full">
|
||||
<div className="w-full max-w-[1440px] mx-auto">
|
||||
|
||||
{/* Badge Row */}
|
||||
<div className="px-5 sm:px-8 lg:px-12 flex items-center gap-3 mb-6 sm:mb-8">
|
||||
<span className="w-6 h-6 sm:w-7 sm:h-7 rounded-full bg-gray-900 text-white text-[11px] sm:text-[12px] font-semibold flex items-center justify-center">
|
||||
1
|
||||
</span>
|
||||
<span className="text-[12px] sm:text-[13px] font-medium text-gray-900 border border-gray-200 rounded-full px-3 sm:px-4 py-1 sm:py-1.5">
|
||||
Introducing Waras GIS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<div className="px-5 sm:px-8 lg:px-12">
|
||||
<h2 className="font-medium leading-[1.12] tracking-[-0.02em] text-gray-900 mb-12 sm:mb-16 lg:mb-28 text-[clamp(1.5rem,4vw,3.2rem)] max-w-[900px]">
|
||||
Kecerdasan spasial terpadu, menyajikan <br className="hidden sm:block" />
|
||||
visualisasi data yang akurat & interaktif.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Content Area - Mobile & Tablet Stacked */}
|
||||
<div className="lg:hidden px-5 sm:px-8 flex flex-col gap-8">
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<p className="text-[15px] sm:text-[17px] leading-[1.6] font-medium text-gray-900 max-w-[600px]">
|
||||
Melalui integrasi data kemiskinan daerah, tata guna lahan, dan jaringan fasilitas pengisian energi, kami membantu mewujudkan tata ruang wilayah yang transparan dan berbasis data.
|
||||
</p>
|
||||
|
||||
{/* About Button with Text Roll and Arrow - routes to Login */}
|
||||
<Link href="/auth/login" className="group flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[13px] font-medium rounded-full pl-5 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer mt-2">
|
||||
<span className="overflow-hidden h-[20px] relative inline-block">
|
||||
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
|
||||
<span className="h-[20px] flex items-center">Pelajari Selengkapnya</span>
|
||||
<span className="h-[20px] flex items-center">Pelajari Selengkapnya</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-7 h-7 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 w-full">
|
||||
<img
|
||||
src="https://images.higgs.ai/?default=1&output=webp&url=https%3A%2F%2Fd8j0ntlcm91z4.cloudfront.net%2Fuser_38xzZboKViGWJOttwIXH07lWA1P%2Fhf_20260516_090123_74be96d4-9c1b-40cf-932a-96f4f4babed3.png&w=1280&q=85"
|
||||
alt="Analisis spasial tim perencanaan"
|
||||
className="w-full sm:w-[45%] aspect-[438/346] rounded-xl sm:rounded-2xl object-cover"
|
||||
/>
|
||||
<img
|
||||
src="https://images.higgs.ai/?default=1&output=webp&url=https%3A%2F%2Fd8j0ntlcm91z4.cloudfront.net%2Fuser_38xzZboKViGWJOttwIXH07lWA1P%2Fhf_20260516_090133_c157d30b-a99a-4477-bec1-a446149ec3f2.png&w=1280&q=85"
|
||||
alt="Stasiun kerja visualisasi GIS"
|
||||
className="w-full sm:w-[55%] aspect-[900/600] rounded-xl sm:rounded-2xl object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area - Desktop Grid */}
|
||||
<div className="hidden lg:grid grid-cols-[26%_1fr_48%] items-end gap-6 xl:gap-8 px-8 lg:px-12">
|
||||
|
||||
{/* Left Column Image */}
|
||||
<div className="w-full">
|
||||
<img
|
||||
src="https://images.higgs.ai/?default=1&output=webp&url=https%3A%2F%2Fd8j0ntlcm91z4.cloudfront.net%2Fuser_38xzZboKViGWJOttwIXH07lWA1P%2Fhf_20260516_090123_74be96d4-9c1b-40cf-932a-96f4f4babed3.png&w=1280&q=85"
|
||||
alt="Analisis spasial tim perencanaan"
|
||||
className="w-full aspect-[438/346] rounded-2xl object-cover shadow-[0_4px_20px_rgba(0,0,0,0.02)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center Column Copy & CTA */}
|
||||
<div className="flex flex-col items-start justify-between self-start h-full pl-6 pr-4">
|
||||
<p className="text-[16px] xl:text-[18px] leading-[1.65] font-medium text-gray-900 whitespace-nowrap">
|
||||
Melalui integrasi data kemiskinan daerah,<br />
|
||||
tata guna lahan, dan jaringan SPBU/EV,<br />
|
||||
kami membantu pengambilan keputusan.
|
||||
</p>
|
||||
|
||||
{/* About Button - routes to Login */}
|
||||
<Link href="/auth/login" className="group flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[13px] font-medium rounded-full pl-5 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer mt-8">
|
||||
<span className="overflow-hidden h-[20px] relative inline-block">
|
||||
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
|
||||
<span className="h-[20px] flex items-center">Pelajari Selengkapnya</span>
|
||||
<span className="h-[20px] flex items-center">Pelajari Selengkapnya</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-7 h-7 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right Column Image */}
|
||||
<div className="w-full">
|
||||
<img
|
||||
src="https://images.higgs.ai/?default=1&output=webp&url=https%3A%2F%2Fd8j0ntlcm91z4.cloudfront.net%2Fuser_38xzZboKViGWJOttwIXH07lWA1P%2Fhf_20260516_090133_c157d30b-a99a-4477-bec1-a446149ec3f2.png&w=1280&q=85"
|
||||
alt="Stasiun kerja visualisasi GIS"
|
||||
className="w-full aspect-[3/2] rounded-2xl object-cover shadow-[0_4px_24px_rgba(0,0,0,0.03)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
export function LandingCta() {
|
||||
return (
|
||||
<section className="bg-white py-24 sm:py-32 relative overflow-hidden w-full border-t border-gray-100">
|
||||
<div className="w-full max-w-[1440px] mx-auto px-5 sm:px-8 lg:px-12 relative flex flex-col items-center justify-center text-center">
|
||||
|
||||
{/* Left Side ASCII Art - Enlarged Variant */}
|
||||
<div className="absolute left-2 sm:left-6 lg:left-10 top-1/2 -translate-y-1/2 hidden md:block pointer-events-none z-10 select-none opacity-25">
|
||||
<pre className="text-[10px] sm:text-[13px] md:text-[14px] leading-[1.15] font-mono text-gray-400 text-left">
|
||||
{` .
|
||||
...
|
||||
..\\\\/ .
|
||||
..AAA.AA
|
||||
......AA .
|
||||
..A .AAAAA .
|
||||
..A .AAAA * . ..A . ..hA\\/h .
|
||||
. . . .. * . .. V . Vh .
|
||||
. . . . . . .. h A - V .
|
||||
A . . ..A A . .V . V4 .
|
||||
4V44A.*.VVV A444V4---AA
|
||||
..A4\\\\----.V . .AAAA444444A4V444A...
|
||||
A.--444444444V4444A..* .AVA
|
||||
..V--AAA. V4....... ..A .
|
||||
... A44V4 .. ...
|
||||
.. . VVV .
|
||||
.
|
||||
`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Right Side ASCII Art - Enlarged Variant */}
|
||||
<div className="absolute right-2 sm:right-6 lg:right-10 top-1/2 -translate-y-1/2 hidden md:block pointer-events-none z-10 select-none opacity-25">
|
||||
<pre className="text-[10px] sm:text-[13px] md:text-[14px] leading-[1.15] font-mono text-gray-400 text-left">
|
||||
{` A
|
||||
-.
|
||||
..A .
|
||||
A444A .
|
||||
..A . 4 .
|
||||
.... AA . .
|
||||
..A .AAAA . ..A . . . .
|
||||
.....AAAA . . ..AA . .A .AVA .
|
||||
. . A . ..A . .. .V4 . .4V44A .
|
||||
. A . ..A4V . A44444444V4 .
|
||||
4V4V4.--AA
|
||||
..AAAA444444A4V444A...
|
||||
A.AVAAAAA4A4A....
|
||||
A4 ..V.AVA
|
||||
..V4V4A...
|
||||
..A...
|
||||
..A .
|
||||
.. .
|
||||
.
|
||||
`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Center Heading */}
|
||||
<h2 className="font-medium leading-[1.12] tracking-[-0.03em] text-gray-900 text-[clamp(1.75rem,5vw,3.5rem)] max-w-[850px] mb-10 sm:mb-12 relative z-20">
|
||||
Kelola data pemetaan daerah Anda <br className="hidden sm:block" /> tanpa hambatan.
|
||||
</h2>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-5 relative z-20">
|
||||
{/* Orange Button */}
|
||||
<Link href="/auth/login" className="group flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[13px] sm:text-[14px] font-medium rounded-full pl-5 sm:pl-6 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer">
|
||||
<span className="overflow-hidden h-[20px] relative inline-block">
|
||||
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
|
||||
<span className="h-[20px] flex items-center">Buka Dashboard</span>
|
||||
<span className="h-[20px] flex items-center">Buka Dashboard</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Outline Button */}
|
||||
<Link href="/auth/login" className="group flex items-center gap-3 border border-gray-900 text-gray-900 hover:bg-gray-900 hover:text-white text-[13px] sm:text-[14px] font-medium rounded-full pl-5 sm:pl-6 pr-2 py-2 transition-all duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer">
|
||||
<span className="overflow-hidden h-[20px] relative inline-block">
|
||||
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
|
||||
<span className="h-[20px] flex items-center">Pelajari Dokumentasi</span>
|
||||
<span className="h-[20px] flex items-center text-white">Pelajari Dokumentasi</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-gray-900 text-white flex items-center justify-center transition-all duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:bg-white group-hover:text-gray-900 group-hover:rotate-[-45deg] shrink-0">
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function LandingFooter() {
|
||||
return (
|
||||
<footer className="bg-[#0b0a09] text-gray-400 relative overflow-hidden pt-20 sm:pt-28">
|
||||
|
||||
{/*
|
||||
Gradient Layer matching image_7203fe.jpg:
|
||||
A smooth radial ambient glow originating from the center-bottom to softly illuminate
|
||||
the giant typography, fading into a richer onyx black near the top.
|
||||
*/}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center_bottom,_rgba(40,38,35,0.45)_0%,_rgba(11,10,9,0)_70%)] pointer-events-none z-0"></div>
|
||||
|
||||
{/*
|
||||
High-fidelity Film Grain Texture Overlay:
|
||||
Using a dense, fine-grained SVG noise filter pattern to replicate the textured,
|
||||
premium look from the reference image.
|
||||
*/}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.18] mix-blend-overlay z-10 select-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
|
||||
<filter id="grainNoise">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" stitchTiles="stitch" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.15 0" />
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#grainNoise)" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-[1440px] mx-auto px-5 sm:px-8 lg:px-12 relative z-20">
|
||||
|
||||
{/* Top Info and Links columns */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12 pb-16 sm:pb-20">
|
||||
|
||||
{/* Logo and Pitch */}
|
||||
<div className="col-span-2 flex flex-col items-start gap-4 pr-0 sm:pr-8">
|
||||
<div className="w-10 h-10 bg-white text-gray-900 rounded-full flex items-center justify-center font-bold text-[11px] tracking-tight">
|
||||
W
|
||||
</div>
|
||||
<p className="text-[14px] leading-relaxed text-gray-400 max-w-[320px] mt-2">
|
||||
Sistem Informasi Geografis terpadu untuk pemetaan statistik kemiskinan, tata guna lahan, dan jaringan fasilitas infrastruktur energi.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Column 1: Modul Peta */}
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-[11px] tracking-wider text-gray-500 font-bold uppercase mb-4 sm:mb-5">
|
||||
Modul Peta
|
||||
</span>
|
||||
<div className="flex flex-col gap-2.5 sm:gap-3 text-[14px]">
|
||||
<a href="#projects" className="hover:text-white transition-colors duration-300">Peta Kemiskinan</a>
|
||||
<a href="#projects" className="hover:text-white transition-colors duration-300">Lahan & Jalan</a>
|
||||
<a href="#projects" className="hover:text-white transition-colors duration-300">SPBU & EV Charger</a>
|
||||
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Analisis Wilayah</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column 2: Dokumen & Kontak */}
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Dokumen */}
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-[11px] tracking-wider text-gray-500 font-bold uppercase mb-4">
|
||||
Dokumen
|
||||
</span>
|
||||
<div className="flex flex-col gap-2.5 text-[14px]">
|
||||
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Pusat Bantuan</Link>
|
||||
<Link href="/auth/login" className="hover:text-white transition-colors duration-300 font-medium text-gray-300">Play by the Rules</Link>
|
||||
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Privacy Policy</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kontak */}
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-[11px] tracking-wider text-gray-500 font-bold uppercase mb-4">
|
||||
Kontak
|
||||
</span>
|
||||
<div className="flex flex-col gap-2.5 text-[14px]">
|
||||
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Hubungi Kami</Link>
|
||||
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Keamanan Data</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Giant typography section matching the second image layout */}
|
||||
<div className="w-full text-center select-none pointer-events-none overflow-hidden relative h-[10vw] sm:h-[12vw] flex items-end justify-center mt-8 sm:mt-16">
|
||||
<span className="text-[28vw] font-bold text-white leading-[0.7] tracking-normal block opacity-95 translate-y-[20%] select-none">
|
||||
Waras
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { ArrowRight, Menu, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useScroll } from "@/hooks/use-scroll";
|
||||
import { Portal, PortalBackdrop } from "@/components/portal";
|
||||
|
||||
const navLinks = [
|
||||
{ label: "Tentang", href: "#about" },
|
||||
{ label: "Modul Peta", href: "#projects" },
|
||||
{ label: "Dokumentasi", href: "/auth/login" },
|
||||
];
|
||||
|
||||
export function LandingHeader() {
|
||||
const scrolled = useScroll(10);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="fixed inset-x-0 top-0 z-50">
|
||||
<div className="w-full max-w-[1440px] mx-auto p-2 sm:p-3">
|
||||
<nav
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-full p-[5px] border border-transparent transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)]",
|
||||
scrolled
|
||||
? "bg-white shadow-[0_2px_12px_rgba(0,0,0,0.06)] border-gray-100/50"
|
||||
: "bg-white/70 backdrop-blur-sm"
|
||||
)}
|
||||
>
|
||||
{/* Brand */}
|
||||
<Link
|
||||
href="/"
|
||||
className="w-9 h-9 sm:w-10 sm:h-10 bg-gray-900 rounded-full flex items-center justify-center text-white font-bold tracking-tight text-[10px] sm:text-[11px] hover:bg-gray-800 transition-colors duration-300 shrink-0"
|
||||
>
|
||||
W
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav links */}
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className="text-[14px] font-medium text-gray-900 hover:text-gray-500 transition-colors duration-300"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop CTA - routes to Login */}
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="group hidden md:flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[14px] font-medium rounded-full pl-6 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer"
|
||||
>
|
||||
<span className="overflow-hidden h-[20px] relative inline-block">
|
||||
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
|
||||
<span className="h-[20px] flex items-center">Login</span>
|
||||
<span className="h-[20px] flex items-center">Login</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-8 h-8 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Mobile menu trigger */}
|
||||
<button
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="md:hidden w-9 h-9 sm:w-10 sm:h-10 bg-gray-900 rounded-full flex items-center justify-center text-white transition-transform duration-300 active:scale-95 cursor-pointer shrink-0"
|
||||
aria-label="Toggle Navigation Menu"
|
||||
aria-controls="landing-mobile-nav"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
{open && (
|
||||
<Portal className="md:hidden justify-end" id="landing-mobile-nav">
|
||||
<PortalBackdrop onClick={() => setOpen(false)} />
|
||||
<div className="bg-white rounded-2xl mx-3 mb-3 p-6 flex flex-col gap-6 shadow-2xl animate-in slide-in-from-bottom duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]">
|
||||
<div className="flex justify-between items-center border-b pb-4 border-gray-100">
|
||||
<span className="text-[12px] font-semibold tracking-wider uppercase text-gray-900">
|
||||
Waras — Portal GIS
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center text-gray-700 hover:bg-gray-200 transition-colors"
|
||||
aria-label="Tutup Menu"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-[28px] sm:text-[32px] font-medium text-gray-900 hover:text-primary transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Button - routes to Login */}
|
||||
<Link
|
||||
href="/auth/login"
|
||||
onClick={() => setOpen(false)}
|
||||
className="group w-full flex items-center justify-between bg-primary text-primary-foreground text-[14px] font-medium rounded-full pl-6 pr-2 py-2.5 transition-all duration-300 hover:bg-primary/80"
|
||||
>
|
||||
<span>Login</span>
|
||||
<span className="w-8 h-8 rounded-full bg-white flex items-center justify-center text-primary">
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
// Dynamically import shaders to prevent SSR errors (WebGL/Canvas requires browser APIs)
|
||||
const Shader = dynamic(() => import("shaders/react").then((mod) => mod.Shader), { ssr: false });
|
||||
const Swirl = dynamic(() => import("shaders/react").then((mod) => mod.Swirl), { ssr: false });
|
||||
const ChromaFlow = dynamic(() => import("shaders/react").then((mod) => mod.ChromaFlow), { ssr: false });
|
||||
const FlutedGlass = dynamic(() => import("shaders/react").then((mod) => mod.FlutedGlass), { ssr: false });
|
||||
const FilmGrain = dynamic(() => import("shaders/react").then((mod) => mod.FilmGrain), { ssr: false });
|
||||
|
||||
export function LandingHero() {
|
||||
return (
|
||||
<section className="relative flex min-h-screen w-full flex-col justify-center overflow-hidden bg-[#EFEFEF]">
|
||||
|
||||
{/* Animated WebGL Shader Overlay */}
|
||||
<div className="absolute inset-0 z-10 pointer-events-none w-full h-full">
|
||||
<Shader className="w-full h-full">
|
||||
<Swirl colorA="#ffffff" colorB="#f0f0f0" detail={1.7} />
|
||||
<ChromaFlow
|
||||
baseColor="#ffffff"
|
||||
downColor="#ff5f03"
|
||||
leftColor="#ff5f03"
|
||||
rightColor="#ff5f03"
|
||||
upColor="#ff5f03"
|
||||
momentum={13}
|
||||
radius={3.5}
|
||||
/>
|
||||
<FlutedGlass
|
||||
aberration={0.61}
|
||||
angle={31}
|
||||
frequency={8}
|
||||
highlight={0.12}
|
||||
highlightSoftness={0}
|
||||
lightAngle={-90}
|
||||
refraction={4}
|
||||
shape="rounded"
|
||||
softness={1}
|
||||
speed={0.15}
|
||||
/>
|
||||
<FilmGrain strength={0.05} />
|
||||
</Shader>
|
||||
</div>
|
||||
|
||||
{/* Hero Content */}
|
||||
<div className="relative z-20 w-full max-w-[1440px] mx-auto px-5 sm:px-8 lg:px-12 pb-14 sm:pb-16 lg:pb-20">
|
||||
|
||||
<div className="text-[13px] sm:text-[14px] text-gray-900 font-semibold tracking-wider uppercase mb-5 sm:mb-8">
|
||||
Waras — Portal GIS
|
||||
</div>
|
||||
|
||||
<h1 className="font-medium leading-[1.08] tracking-[-0.03em] text-gray-900 text-[clamp(1.75rem,7vw,4.2rem)] sm:text-[clamp(2.5rem,5vw,4.2rem)] max-w-[1100px]">
|
||||
Satu peta untuk keputusan daerah yang lebih baik.
|
||||
</h1>
|
||||
|
||||
{/* CTA Row */}
|
||||
<div className="mt-8 sm:mt-12 flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-5">
|
||||
|
||||
{/* Orange Button with Text Roll and Rotated Arrow - routes to Login */}
|
||||
<Link href="/auth/login" className="group flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[13px] sm:text-[14px] font-medium rounded-full pl-5 sm:pl-6 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer">
|
||||
<span className="overflow-hidden h-[20px] relative inline-block">
|
||||
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
|
||||
<span className="h-[20px] flex items-center">Buka Dashboard</span>
|
||||
<span className="h-[20px] flex items-center">Buka Dashboard</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { LinkIcon } from "@/components/landing/link-icon";
|
||||
|
||||
export function LandingProjects() {
|
||||
return (
|
||||
<section id="projects" className="bg-[#F5F5F5] pt-16 sm:pt-20 lg:pt-28 pb-16 sm:pb-20 lg:pb-28 w-full">
|
||||
<div className="w-full max-w-[1440px] mx-auto">
|
||||
|
||||
{/* Badge Row */}
|
||||
<div className="px-5 sm:px-8 lg:px-12 flex items-center gap-3 mb-6 sm:mb-8">
|
||||
<span className="w-6 h-6 sm:w-7 sm:h-7 rounded-full bg-gray-900 text-white text-[11px] sm:text-[12px] font-semibold flex items-center justify-center">
|
||||
2
|
||||
</span>
|
||||
<span className="text-[12px] sm:text-[13px] font-medium text-gray-900 border border-gray-300 rounded-full px-3 sm:px-4 py-1 sm:py-1.5">
|
||||
Modul Peta Visual
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<div className="px-5 sm:px-8 lg:px-12 mb-10 sm:mb-14 lg:mb-16">
|
||||
<h2 className="font-medium leading-[1.08] tracking-[-0.03em] text-gray-900 text-[clamp(1.75rem,7vw,4.2rem)] sm:text-[clamp(2.5rem,5vw,4.2rem)]">
|
||||
Modul Sistem
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Grid Layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 sm:gap-6 lg:gap-7 px-5 sm:px-8 lg:px-12">
|
||||
|
||||
{/* Card 1: Poverty Map - links to Login */}
|
||||
<Link href="/auth/login" className="flex flex-col group cursor-pointer text-left">
|
||||
{/* Media Container */}
|
||||
<div className="relative aspect-[329/246] rounded-2xl overflow-hidden bg-[#1a1d2e] shadow-[0_4px_16px_rgba(0,0,0,0.015)]">
|
||||
<video
|
||||
src="https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260516_122702_390f5305-8719-41d5-ae80-d23ab3796c28.mp4"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-102"
|
||||
/>
|
||||
|
||||
{/* Hover expandable white circle button */}
|
||||
<div className="absolute bottom-4 left-4 h-9 bg-white text-gray-900 rounded-full flex items-center overflow-hidden transition-all duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] w-9 group-hover:w-[148px] px-2.5 justify-between shadow-md">
|
||||
<span className="text-[13px] font-medium whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100 pl-1 text-gray-900">
|
||||
Buka Peta
|
||||
</span>
|
||||
<LinkIcon className="w-3.5 h-3.5 shrink-0 text-gray-900 transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] -rotate-45 group-hover:rotate-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Descriptions */}
|
||||
<p className="text-[13px] sm:text-[14px] text-gray-600 mt-4 leading-relaxed font-normal">
|
||||
Sistem analisis kemiskinan terpadu dengan visualisasi statistik desil wilayah dan indeks prioritas bantuan.
|
||||
</p>
|
||||
<h3 className="text-[14px] sm:text-[15px] font-semibold text-gray-900 mt-1">
|
||||
Peta & Statistik Kemiskinan
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
{/* Card 2: Gas Station Map - links to Login */}
|
||||
<Link href="/auth/login" className="flex flex-col group cursor-pointer text-left">
|
||||
{/* Media Container */}
|
||||
<div className="relative aspect-square rounded-2xl overflow-hidden bg-[#6b6b6b] shadow-[0_4px_16px_rgba(0,0,0,0.015)]">
|
||||
<video
|
||||
src="https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260516_123323_f909c2b8-ff6c-4edf-882b-8ebcdbe389b5.mp4"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-102"
|
||||
/>
|
||||
|
||||
{/* Hover expandable dark circle button */}
|
||||
<div className="absolute bottom-4 left-4 h-9 bg-gray-900 text-white rounded-full flex items-center overflow-hidden transition-all duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] w-9 group-hover:w-[168px] px-2.5 justify-between shadow-md">
|
||||
<span className="text-[13px] font-medium whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100 pl-1 text-white">
|
||||
Buka Peta
|
||||
</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 shrink-0 text-white transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] -rotate-45 group-hover:rotate-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Descriptions */}
|
||||
<p className="text-[13px] sm:text-[14px] text-gray-600 mt-4 leading-relaxed font-normal">
|
||||
Pemetaan stasiun pengisian energi (SPBU & EV Charger) beserta rincian fasilitas bengkel dan status operasional 24 jam.
|
||||
</p>
|
||||
<h3 className="text-[14px] sm:text-[15px] font-semibold text-gray-900 mt-1">
|
||||
Peta SPBU, EV Charger & Bengkel
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
export function LinkIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { XIcon } from "@/components/ui/phosphor-icons";
|
||||
|
||||
const latestChange = {
|
||||
badge: "CHANGELOG",
|
||||
title: "Product update",
|
||||
description: "Performance boosts and UI polish.", // TIP: Use a single line of text for the description. (max 5 words)
|
||||
readMore: { href: "#", label: "Learn more" },
|
||||
} as const;
|
||||
|
||||
export function LatestChange() {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/latest-change size-full min-h-27 justify-center border-t",
|
||||
"relative flex size-full flex-col gap-1 overflow-hidden px-4 pt-3 pb-1 *:text-nowrap",
|
||||
"transition-opacity group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:opacity-0"
|
||||
)}
|
||||
>
|
||||
<span className="font-light font-mono text-[10px] text-muted-foreground">
|
||||
{latestChange.badge}
|
||||
</span>
|
||||
<p className="font-medium text-xs">{latestChange.title}</p>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{latestChange.description}
|
||||
</span>
|
||||
<Button className="w-max px-0 font-light text-xs" size="sm" variant="link" render={<a href={latestChange.readMore.href} />} nativeButton={false}>{latestChange.readMore.label}</Button>
|
||||
<Button
|
||||
className="absolute top-2 right-2 z-10 size-6 rounded-full opacity-0 transition-opacity group-hover/latest-change:opacity-100"
|
||||
onClick={() => setIsOpen(false)}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon className="size-3.5 text-muted-foreground" />{" "}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
"use client";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BRAND_COLORS } from "@/lib/map-data";
|
||||
|
||||
export interface UserMarker {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
type: string;
|
||||
name: string;
|
||||
meta?: {
|
||||
poverty_level?: string;
|
||||
family_count?: number;
|
||||
penghasilan?: number;
|
||||
fuzzy_label?: string;
|
||||
fuzzy_score?: number;
|
||||
fuzzy_detail?: Record<string, { kategori: string; derajat: number }>;
|
||||
notes?: string;
|
||||
poi_type?: string;
|
||||
radius?: number;
|
||||
coordinates?: [number, number][];
|
||||
gas_types?: string[];
|
||||
created_by_username?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LeafletMapProps {
|
||||
mode: "poverty" | "land" | "gas-station";
|
||||
selectedYear?: number;
|
||||
selectedId?: string;
|
||||
onSelect?: (id: string) => void;
|
||||
visibilityFilters?: Record<string, boolean>;
|
||||
fuelPriceDisplay?: "ron92" | "ron95" | "diesel";
|
||||
fullBleed?: boolean;
|
||||
mapStyle?: "street" | "satellite";
|
||||
onMapClick?: (lat: number, lng: number) => void;
|
||||
userMarkers?: UserMarker[];
|
||||
onMarkerClick?: (marker: UserMarker) => void;
|
||||
onMapReady?: (mapInstance: any) => void;
|
||||
onShapeCreated?: (shape: UserMarker) => void;
|
||||
placingMarkerType?: string | null;
|
||||
dragDropEnabled?: boolean;
|
||||
onMarkerDragEnd?: (marker: UserMarker, newLat: number, newLng: number) => void;
|
||||
}
|
||||
|
||||
const POVERTY_LEVEL_COLOR: Record<string, string> = {
|
||||
Extreme: "#ef4444",
|
||||
Miskin: "#f59e0b",
|
||||
Rentan: "#3b82f6",
|
||||
};
|
||||
const FUZZY_LABEL_COLOR: Record<string, string> = {
|
||||
"SANGAT TINGGI": "#ef4444",
|
||||
"TINGGI": "#f97316",
|
||||
"SEDANG": "#f59e0b",
|
||||
"RENDAH": "#10b981",
|
||||
"TIDAK PRIORITAS":"#6b7280",
|
||||
};
|
||||
|
||||
function fmt(n: number) {
|
||||
return "Rp " + n.toLocaleString("id-ID");
|
||||
}
|
||||
|
||||
const POI_TYPE_LABELS: Record<string, string> = {
|
||||
marker: "Data Warga",
|
||||
mosque: "Masjid",
|
||||
church: "Gereja",
|
||||
cathedral: "Gereja Katolik",
|
||||
temple: "Pura",
|
||||
vihara: "Vihara",
|
||||
klenteng: "Klenteng",
|
||||
synagogue: "Sinagog",
|
||||
clinic: "Klinik",
|
||||
"food-bank": "Lumbung Pangan",
|
||||
school: "Sekolah",
|
||||
flag: "Tengara",
|
||||
protected: "Kawasan Lindung",
|
||||
registry: "Kantor Pendaftaran",
|
||||
"charging-station": "Pengisi Daya EV",
|
||||
"gas-pump": "SPBU",
|
||||
wrench: "Bengkel",
|
||||
line: "Garis",
|
||||
polygon: "Poligon",
|
||||
circle: "Lingkaran",
|
||||
};
|
||||
|
||||
function buildMarkerTooltip(m: UserMarker): string {
|
||||
const meta = m.meta;
|
||||
const isHousehold = m.type === "marker";
|
||||
const typeLabel = POI_TYPE_LABELS[m.type] ?? m.type.replace(/-/g, " ");
|
||||
|
||||
const base = `
|
||||
<div style="font-family:system-ui,sans-serif;min-width:160px;max-width:220px;">
|
||||
<div style="font-size:13px;font-weight:700;margin-bottom:4px;border-bottom:1px solid rgba(0,0,0,0.1);padding-bottom:4px;">
|
||||
${m.name}
|
||||
</div>`;
|
||||
|
||||
if (isHousehold && meta) {
|
||||
const lvlColor = POVERTY_LEVEL_COLOR[meta.poverty_level ?? ""] ?? "#6b7280";
|
||||
const fuzzyColor = FUZZY_LABEL_COLOR[meta.fuzzy_label ?? ""] ?? "#6b7280";
|
||||
const displayPovertyLevel = meta.poverty_level === "Extreme" ? "Ekstrem" : (meta.poverty_level ?? "—");
|
||||
return base + `
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 8px;font-size:11px;color:#374151;">
|
||||
<span style="color:#9ca3af;">Tingkat</span>
|
||||
<span style="font-weight:600;color:${lvlColor};">${displayPovertyLevel}</span>
|
||||
<span style="color:#9ca3af;">Tanggungan</span>
|
||||
<span>${meta.family_count ?? "—"} jiwa</span>
|
||||
<span style="color:#9ca3af;">Penghasilan</span>
|
||||
<span>${meta.penghasilan != null ? fmt(meta.penghasilan) : "—"}/bln</span>
|
||||
${meta.fuzzy_label ? `
|
||||
<span style="color:#9ca3af;">Prioritas</span>
|
||||
<span style="font-weight:600;color:${fuzzyColor};">${meta.fuzzy_label}</span>
|
||||
` : ""}
|
||||
${meta.fuzzy_score != null ? `
|
||||
<span style="color:#9ca3af;">Skor</span>
|
||||
<span>${meta.fuzzy_score.toFixed(2)}</span>
|
||||
` : ""}
|
||||
${meta.notes ? `
|
||||
<span style="color:#9ca3af;">Catatan</span>
|
||||
<span style="font-style:italic;">${meta.notes}</span>
|
||||
` : ""}
|
||||
</div>
|
||||
<div style="margin-top:4px;font-size:10px;color:#9ca3af;">
|
||||
${m.lat.toFixed(5)}, ${m.lng.toFixed(5)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const gasTypesHtml = m.type === "gas-pump" && meta?.gas_types && meta.gas_types.length > 0
|
||||
? `<div style="margin-top:4px;margin-bottom:4px;display:flex;flex-wrap:wrap;gap:2px;">
|
||||
${meta.gas_types.map((gt: string) => `<span style="background-color:rgba(255,59,31,0.1);color:#ff3b1f;border:1px solid rgba(255,59,31,0.2);padding:1px 4px;border-radius:3px;font-size:9px;font-weight:600;white-space:nowrap;">${gt}</span>`).join("")}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
return base + `
|
||||
<div style="font-size:11px;color:#6b7280;text-transform:capitalize;margin-bottom:4px;">${typeLabel}</div>
|
||||
${meta?.poi_type ? `<div style="font-size:11px;font-weight:600;color:#374151;margin-bottom:2px;">${meta.poi_type}</div>` : ""}
|
||||
${meta?.notes ? `<div style="font-size:11px;color:#374151;font-style:italic;margin-bottom:2px;">${meta.notes}</div>` : ""}
|
||||
${gasTypesHtml}
|
||||
<div style="margin-top:4px;font-size:10px;color:#9ca3af;">
|
||||
${m.lat.toFixed(5)}, ${m.lng.toFixed(5)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export default function LeafletMap({
|
||||
mode,
|
||||
selectedYear = 2026,
|
||||
selectedId,
|
||||
onSelect,
|
||||
visibilityFilters,
|
||||
fuelPriceDisplay = "ron95",
|
||||
fullBleed = true,
|
||||
mapStyle = "street",
|
||||
onMapClick,
|
||||
userMarkers,
|
||||
onMarkerClick,
|
||||
onMapReady,
|
||||
onShapeCreated,
|
||||
placingMarkerType,
|
||||
dragDropEnabled = false,
|
||||
onMarkerDragEnd,
|
||||
}: LeafletMapProps) {
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<any>(null);
|
||||
const [leafletLoaded, setLeafletLoaded] = useState(false);
|
||||
const layersRef = useRef<any[]>([]);
|
||||
const streetLayerRef = useRef<any>(null);
|
||||
const satelliteLayerRef = useRef<any>(null);
|
||||
|
||||
// 1. Inject Leaflet JS/CSS and optionally Geoman for land drawing
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
// Promise-based script loader that handles already-loaded scripts
|
||||
const loadScript = (id: string, src: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const existing = document.getElementById(id) as HTMLScriptElement | null;
|
||||
if (existing) {
|
||||
// Script tag exists — check if it already finished loading
|
||||
// by testing for the global it should have created
|
||||
if (id === "leaflet-js" && (window as any).L) { resolve(); return; }
|
||||
if (id === "geoman-js" && (window as any).L?.PM) { resolve(); return; }
|
||||
// Still loading — listen for completion
|
||||
const onLoad = () => { existing.removeEventListener("load", onLoad); resolve(); };
|
||||
const onError = () => { existing.removeEventListener("error", onError); reject(new Error(`Script ${id} failed`)); };
|
||||
existing.addEventListener("load", onLoad);
|
||||
existing.addEventListener("error", onError);
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.id = id;
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
};
|
||||
|
||||
const loadAll = async () => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Inject Leaflet CSS
|
||||
if (!document.getElementById("leaflet-css")) {
|
||||
const link = document.createElement("link");
|
||||
link.id = "leaflet-css";
|
||||
link.rel = "stylesheet";
|
||||
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// Inject Geoman CSS for land mode
|
||||
if (mode === "land" && !document.getElementById("geoman-css")) {
|
||||
const link = document.createElement("link");
|
||||
link.id = "geoman-css";
|
||||
link.rel = "stylesheet";
|
||||
link.href = "https://unpkg.com/@geoman-io/leaflet-geoman-free@2.17.0/dist/leaflet-geoman.css";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Load Leaflet core (skip if already loaded)
|
||||
if (!(window as any).L) {
|
||||
await loadScript("leaflet-js", "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js");
|
||||
}
|
||||
|
||||
// Step 2: For land mode, load Geoman AFTER Leaflet (skip if already loaded)
|
||||
if (mode === "land" && !(window as any).L?.PM) {
|
||||
await loadScript("geoman-js", "https://unpkg.com/@geoman-io/leaflet-geoman-free@2.17.0/dist/leaflet-geoman.js");
|
||||
}
|
||||
|
||||
// Both scripts loaded — safe to create the map
|
||||
if (isMounted) setLeafletLoaded(true);
|
||||
} catch (err) {
|
||||
console.error("[LeafletMap] Script loading failed:", err);
|
||||
// Still allow non-land modes to proceed without Geoman
|
||||
if (mode !== "land" && (window as any).L && isMounted) {
|
||||
setLeafletLoaded(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadAll();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [mode]);
|
||||
|
||||
// 2. Initialize Leaflet Map
|
||||
useEffect(() => {
|
||||
if (!leafletLoaded || !mapContainerRef.current) return;
|
||||
|
||||
const L = (window as any).L;
|
||||
if (!L) return;
|
||||
|
||||
// For land mode, verify Geoman is truly available before creating the map
|
||||
if (mode === "land" && !L.PM) {
|
||||
console.warn("[LeafletMap] Geoman not available for land mode — aborting map init");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create Map Instance centered in Jakarta
|
||||
const map = L.map(mapContainerRef.current, {
|
||||
center: [-6.20, 106.82],
|
||||
zoom: 12,
|
||||
zoomControl: true
|
||||
});
|
||||
|
||||
// Safety net: if Geoman loaded but map.pm is still undefined,
|
||||
// manually re-initialize Geoman on this map instance
|
||||
if (mode === "land" && L.PM && !map.pm) {
|
||||
try {
|
||||
L.PM.reInitLayer(map);
|
||||
} catch (e) {
|
||||
console.warn("[LeafletMap] L.PM.reInitLayer failed, trying manual init:", e);
|
||||
// Fallback: Geoman v2 attaches via addInitHook, try calling it directly
|
||||
if (typeof map._initPathRoot === "function") map._initPathRoot();
|
||||
}
|
||||
}
|
||||
|
||||
// Hide Geoman's default toolbar — we drive drawing programmatically
|
||||
if (mode === "land" && map.pm) {
|
||||
try {
|
||||
map.pm.addControls({ position: "topleft" });
|
||||
map.pm.removeControls();
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
const street = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
});
|
||||
|
||||
const satellite = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", {
|
||||
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
|
||||
});
|
||||
|
||||
streetLayerRef.current = street;
|
||||
satelliteLayerRef.current = satellite;
|
||||
|
||||
// Only the active layer lives on the map — a hidden (opacity 0) tile
|
||||
// layer still downloads tiles on every pan/zoom.
|
||||
const active = mapStyle === "satellite" ? satellite : street;
|
||||
active.setOpacity(1);
|
||||
active.addTo(map);
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
onMapReady?.(map);
|
||||
|
||||
return () => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove();
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [leafletLoaded]);
|
||||
|
||||
// 2.5 Handle Map Style Cross-Fade Animation
|
||||
useEffect(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
const street = streetLayerRef.current;
|
||||
const satellite = satelliteLayerRef.current;
|
||||
if (!map || !street || !satellite) return;
|
||||
|
||||
const incoming = mapStyle === "satellite" ? satellite : street;
|
||||
const outgoing = mapStyle === "satellite" ? street : satellite;
|
||||
|
||||
if (!map.hasLayer(incoming)) {
|
||||
incoming.setOpacity(0);
|
||||
incoming.addTo(map);
|
||||
}
|
||||
if (!map.hasLayer(outgoing)) {
|
||||
// Initial mount — active layer already on the map, nothing to fade.
|
||||
incoming.setOpacity(1);
|
||||
return;
|
||||
}
|
||||
|
||||
let rafId = 0;
|
||||
let start: number | null = null;
|
||||
const duration = 400; // 400ms transition
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!start) start = timestamp;
|
||||
const progress = Math.min((timestamp - start) / duration, 1);
|
||||
|
||||
incoming.setOpacity(progress);
|
||||
outgoing.setOpacity(1 - progress);
|
||||
|
||||
if (progress < 1) {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
// Detach the hidden layer so it stops requesting tiles.
|
||||
map.removeLayer(outgoing);
|
||||
}
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [mapStyle, leafletLoaded]);
|
||||
|
||||
// 3. Render Layers based on Props
|
||||
useEffect(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
const L = (window as any).L;
|
||||
if (!map || !L) return;
|
||||
|
||||
// Clear existing layers
|
||||
layersRef.current.forEach((layer) => map.removeLayer(layer));
|
||||
layersRef.current = [];
|
||||
|
||||
if (mode === "poverty") {
|
||||
// Poverty mode renders only real data: household + POI markers from
|
||||
// the database (drawn below from userMarkers). No mock choropleth.
|
||||
} else if (mode === "land") {
|
||||
// Land mode renders only real data: land_markers + land_shapes from
|
||||
// the database (drawn below from userMarkers). No mock zone polygons.
|
||||
} else if (mode === "gas-station") {
|
||||
// Gas station mode renders only real data from the database (drawn below from userMarkers)
|
||||
}
|
||||
|
||||
// Status-based colors for shapes
|
||||
const ROAD_STATUS_COLORS: Record<string, string> = {
|
||||
"National Road": "#ef4444",
|
||||
"Provincial Road": "#eab308",
|
||||
"Regency Road": "#22c55e",
|
||||
};
|
||||
const LAND_STATUS_COLORS: Record<string, string> = {
|
||||
"SHM": "#a855f7",
|
||||
"HGB": "#f97316",
|
||||
"HGU": "#14b8a6",
|
||||
"HP": "#ec4899",
|
||||
};
|
||||
|
||||
// Draw User-Placed Markers and Shapes
|
||||
const RELIGION_TYPES = new Set(["mosque","church","cathedral","temple","vihara","klenteng","synagogue"]);
|
||||
if (userMarkers && userMarkers.length > 0) {
|
||||
userMarkers.forEach((m) => {
|
||||
// Visibility filter (land mode): skip rendering if this type is hidden.
|
||||
// visibilityFilters is undefined for poverty/gas-station, so this is a no-op there.
|
||||
if (visibilityFilters && visibilityFilters[m.type] === false) return;
|
||||
|
||||
// Render user-placed polylines (lines)
|
||||
if (m.type === "line" && m.meta?.coordinates) {
|
||||
const validCoords = m.meta.coordinates.filter(
|
||||
(c: any) => Array.isArray(c) && typeof c[0] === "number" && !isNaN(c[0]) && typeof c[1] === "number" && !isNaN(c[1])
|
||||
);
|
||||
if (validCoords.length >= 2) {
|
||||
const lineColor = ROAD_STATUS_COLORS[m.meta?.poi_type ?? ""] ?? "#ff3b1f";
|
||||
const polyline = L.polyline(validCoords, {
|
||||
color: lineColor,
|
||||
weight: 4,
|
||||
opacity: 0.85
|
||||
}).addTo(map);
|
||||
|
||||
const statusLabel = m.meta?.poi_type ? ` · ${m.meta.poi_type}` : "";
|
||||
polyline.bindTooltip(`<strong>${m.name}</strong><br/><span style="color:${lineColor}">GARIS${statusLabel}</span>`, { sticky: true });
|
||||
polyline.on("click", (e: any) => {
|
||||
e.originalEvent?.stopPropagation();
|
||||
onMarkerClick?.(m);
|
||||
});
|
||||
layersRef.current.push(polyline);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Render user-placed polygons
|
||||
if (m.type === "polygon" && m.meta?.coordinates) {
|
||||
const validCoords = m.meta.coordinates.filter(
|
||||
(c: any) => Array.isArray(c) && typeof c[0] === "number" && !isNaN(c[0]) && typeof c[1] === "number" && !isNaN(c[1])
|
||||
);
|
||||
if (validCoords.length >= 3) {
|
||||
const polyColor = LAND_STATUS_COLORS[m.meta?.poi_type ?? ""] ?? "#ff3b1f";
|
||||
const polygon = L.polygon(validCoords, {
|
||||
fillColor: polyColor,
|
||||
fillOpacity: 0.25,
|
||||
color: polyColor,
|
||||
weight: 2,
|
||||
opacity: 0.85
|
||||
}).addTo(map);
|
||||
|
||||
const statusLabel = m.meta?.poi_type ? ` · ${m.meta.poi_type}` : "";
|
||||
polygon.bindTooltip(`<strong>${m.name}</strong><br/><span style="color:${polyColor}">POLIGON${statusLabel}</span>`, { sticky: true });
|
||||
polygon.on("click", (e: any) => {
|
||||
e.originalEvent?.stopPropagation();
|
||||
onMarkerClick?.(m);
|
||||
});
|
||||
layersRef.current.push(polygon);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Render user-placed circles
|
||||
if (m.type === "circle" && typeof m.lat === "number" && !isNaN(m.lat) && typeof m.lng === "number" && !isNaN(m.lng) && (m.meta?.radius ?? 0) > 0) {
|
||||
const circle = L.circle([m.lat, m.lng], {
|
||||
radius: m.meta!.radius!,
|
||||
fillColor: "#ff3b1f",
|
||||
fillOpacity: 0.2,
|
||||
color: "#ff3b1f",
|
||||
weight: 2,
|
||||
opacity: 0.85
|
||||
}).addTo(map);
|
||||
|
||||
circle.bindTooltip(`<strong>${m.name}</strong><br/>Jenis: LINGKARAN<br/>Radius: ${m.meta!.radius!.toFixed(1)}m`, { sticky: true });
|
||||
circle.on("click", (e: any) => {
|
||||
e.originalEvent?.stopPropagation();
|
||||
onMarkerClick?.(m);
|
||||
});
|
||||
layersRef.current.push(circle);
|
||||
return;
|
||||
}
|
||||
|
||||
let markerColor = "rgb(59, 130, 246)";
|
||||
let iconName = "map-pin";
|
||||
|
||||
switch (m.type) {
|
||||
case "marker":
|
||||
markerColor = FUZZY_LABEL_COLOR[m.meta?.fuzzy_label ?? ""] ?? markerColor;
|
||||
iconName = "map-pin";
|
||||
break;
|
||||
case "mosque":
|
||||
markerColor = "rgb(16, 185, 129)";
|
||||
iconName = "mosque";
|
||||
break;
|
||||
case "church":
|
||||
markerColor = "rgb(139, 92, 246)";
|
||||
iconName = "church";
|
||||
break;
|
||||
case "cathedral":
|
||||
markerColor = "rgb(124, 58, 237)";
|
||||
iconName = "crown-cross";
|
||||
break;
|
||||
case "temple":
|
||||
markerColor = "rgb(234, 88, 12)";
|
||||
iconName = "hands-praying";
|
||||
break;
|
||||
case "vihara":
|
||||
markerColor = "rgb(202, 138, 4)";
|
||||
iconName = "hands-praying";
|
||||
break;
|
||||
case "klenteng":
|
||||
markerColor = "rgb(185, 28, 28)";
|
||||
iconName = "hands-praying";
|
||||
break;
|
||||
case "synagogue":
|
||||
markerColor = "rgb(245, 158, 11)";
|
||||
iconName = "synagogue";
|
||||
break;
|
||||
case "clinic":
|
||||
markerColor = "rgb(239, 68, 68)";
|
||||
iconName = "first-aid";
|
||||
break;
|
||||
case "food-bank":
|
||||
markerColor = "rgb(217, 70, 239)";
|
||||
iconName = "bowl-food";
|
||||
break;
|
||||
case "school":
|
||||
markerColor = "rgb(6, 182, 212)";
|
||||
iconName = "graduation-cap";
|
||||
break;
|
||||
case "flag":
|
||||
markerColor = "rgb(244, 63, 94)";
|
||||
iconName = "flag";
|
||||
break;
|
||||
case "protected":
|
||||
markerColor = "rgb(4, 120, 87)";
|
||||
iconName = "tree";
|
||||
break;
|
||||
case "registry":
|
||||
markerColor = "rgb(29, 78, 216)";
|
||||
iconName = "bank";
|
||||
break;
|
||||
case "charging-station": {
|
||||
iconName = "charging-station";
|
||||
const evType = m.meta?.poi_type ?? "";
|
||||
if (evType === "DC Fast") {
|
||||
markerColor = "#10b981"; // Emerald
|
||||
} else if (evType === "Supercharger") {
|
||||
markerColor = "#14b8a6"; // Teal
|
||||
} else if (evType === "AC Level 2") {
|
||||
markerColor = "#3b82f6"; // Blue
|
||||
} else {
|
||||
markerColor = "rgb(16, 185, 129)"; // Default green
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "gas-pump": {
|
||||
iconName = "gas-pump";
|
||||
const brand = m.meta?.poi_type ?? "";
|
||||
if (brand === "Pertamina") {
|
||||
markerColor = "#ef4444"; // Red
|
||||
} else if (brand === "Shell") {
|
||||
markerColor = "#eab308"; // Yellow
|
||||
} else if (brand === "BP") {
|
||||
markerColor = "#10b981"; // Green
|
||||
} else if (brand === "Vivo") {
|
||||
markerColor = "#3b82f6"; // Blue
|
||||
} else {
|
||||
markerColor = "rgb(220, 38, 38)"; // Default red
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "wrench": {
|
||||
iconName = "wrench";
|
||||
const spec = m.meta?.poi_type ?? "";
|
||||
if (spec === "Umum") {
|
||||
markerColor = "#6b7280"; // Gray
|
||||
} else if (spec === "AC & Kelistrikan") {
|
||||
markerColor = "#0ea5e9"; // Sky
|
||||
} else if (spec === "Mesin") {
|
||||
markerColor = "#f97316"; // Orange
|
||||
} else if (spec === "Ban & Velg") {
|
||||
markerColor = "#f59e0b"; // Amber
|
||||
} else {
|
||||
markerColor = "rgb(107, 114, 128)"; // Default gray
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let borderStyle = "2px solid white";
|
||||
if (m.type === "gas-pump" || m.type === "charging-station" || m.type === "wrench") {
|
||||
const notesLower = (m.meta?.notes ?? "").toLowerCase();
|
||||
if (notesLower.includes("24 jam") || notesLower.includes("24 hours")) {
|
||||
borderStyle = "2.5px solid #22c55e"; // Emerald green border for 24h
|
||||
} else if (notesLower.includes("tutup") || notesLower.includes("offline") || notesLower.includes("maintenance")) {
|
||||
borderStyle = "2.5px solid #ef4444"; // Red border for closed/offline
|
||||
} else if (notesLower.includes("buka") || notesLower.includes("active") || notesLower.includes("open") || notesLower.includes("jam:")) {
|
||||
borderStyle = "2.5px solid #3b82f6"; // Blue border for active/open/hours
|
||||
}
|
||||
}
|
||||
|
||||
const customIcon = L.divIcon({
|
||||
className: "custom-user-marker",
|
||||
html: `
|
||||
<div style="
|
||||
background-color: ${markerColor};
|
||||
color: white;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: ${borderStyle};
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
">
|
||||
<div style="
|
||||
transform: rotate(45deg);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
mask-image: url(/light/${iconName}-light.svg);
|
||||
-webkit-mask-image: url(/light/${iconName}-light.svg);
|
||||
mask-size: contain;
|
||||
-webkit-mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
-webkit-mask-position: center;
|
||||
"></div>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 32],
|
||||
popupAnchor: [0, -32]
|
||||
});
|
||||
|
||||
const isDraggableMarker = !!dragDropEnabled && (m.type === "marker" || RELIGION_TYPES.has(m.type) || ["clinic", "food-bank", "school", "flag", "protected", "registry", "charging-station", "gas-pump", "wrench"].includes(m.type));
|
||||
const marker = L.marker([m.lat, m.lng], {
|
||||
icon: customIcon,
|
||||
draggable: isDraggableMarker
|
||||
}).addTo(map);
|
||||
|
||||
if (isDraggableMarker) {
|
||||
marker.on("dragend", (e: any) => {
|
||||
const newLatLng = e.target.getLatLng();
|
||||
onMarkerDragEnd?.(m, newLatLng.lat, newLatLng.lng);
|
||||
});
|
||||
}
|
||||
|
||||
const tooltipHtml = buildMarkerTooltip(m);
|
||||
marker.bindTooltip(tooltipHtml, {
|
||||
direction: "top",
|
||||
className: "user-marker-tooltip",
|
||||
});
|
||||
|
||||
marker.on("click", (e: any) => {
|
||||
e.originalEvent?.stopPropagation();
|
||||
onMarkerClick?.(m);
|
||||
});
|
||||
|
||||
layersRef.current.push(marker);
|
||||
|
||||
if (RELIGION_TYPES.has(m.type) && (m.meta?.radius ?? 0) > 0) {
|
||||
const circle = L.circle([m.lat, m.lng], {
|
||||
radius: m.meta!.radius!,
|
||||
color: markerColor,
|
||||
fillColor: markerColor,
|
||||
fillOpacity: 0.08,
|
||||
weight: 1.5,
|
||||
dashArray: "5 4",
|
||||
interactive: false,
|
||||
}).addTo(map);
|
||||
layersRef.current.push(circle);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [mode, visibilityFilters, leafletLoaded, userMarkers, dragDropEnabled]);
|
||||
|
||||
// 4. Handle Map Click Callback
|
||||
useEffect(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map || !onMapClick) return;
|
||||
|
||||
const handleMapClick = (e: any) => {
|
||||
onMapClick(e.latlng.lat, e.latlng.lng);
|
||||
};
|
||||
|
||||
map.on("click", handleMapClick);
|
||||
|
||||
return () => {
|
||||
map.off("click", handleMapClick);
|
||||
};
|
||||
}, [onMapClick, leafletLoaded]);
|
||||
|
||||
// 5. Handle Geoman Shape Creation
|
||||
useEffect(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map || mode !== "land" || !map.pm) return;
|
||||
|
||||
const handlePmCreate = (e: any) => {
|
||||
try {
|
||||
const layer = e.layer;
|
||||
const type = String(e.shape || e.layerType || "").toLowerCase();
|
||||
|
||||
// Remove Geoman temp layer asynchronously in the next tick to prevent event loop crashes
|
||||
setTimeout(() => {
|
||||
try { layer.remove(); } catch (_) { /* ignore */ }
|
||||
}, 0);
|
||||
|
||||
const id = `shape-${Date.now()}`;
|
||||
let newShape: UserMarker | null = null;
|
||||
|
||||
const geojson = layer.toGeoJSON();
|
||||
|
||||
if (type === "line" || type === "polyline") {
|
||||
const geomCoords = geojson.geometry.coordinates;
|
||||
if (Array.isArray(geomCoords) && geomCoords.length > 0) {
|
||||
const coordinates: [number, number][] = geomCoords.map(([lng, lat]: [number, number]) => [lat, lng]);
|
||||
newShape = {
|
||||
id,
|
||||
lat: coordinates[0][0],
|
||||
lng: coordinates[0][1],
|
||||
type: "line",
|
||||
name: "Survey Line " + new Date().toLocaleDateString("id-ID"),
|
||||
meta: { coordinates }
|
||||
};
|
||||
}
|
||||
} else if (type === "polygon" || type === "rectangle") {
|
||||
const geomCoords = geojson.geometry.coordinates[0];
|
||||
if (Array.isArray(geomCoords) && geomCoords.length > 0) {
|
||||
const coordinates: [number, number][] = geomCoords.map(([lng, lat]: [number, number]) => [lat, lng]);
|
||||
newShape = {
|
||||
id,
|
||||
lat: coordinates[0][0],
|
||||
lng: coordinates[0][1],
|
||||
type: "polygon",
|
||||
name: "Survey Area " + new Date().toLocaleDateString("id-ID"),
|
||||
meta: { coordinates }
|
||||
};
|
||||
}
|
||||
} else if (type === "circle") {
|
||||
const latlng = layer.getLatLng();
|
||||
const radius = layer.getRadius();
|
||||
newShape = {
|
||||
id,
|
||||
lat: latlng.lat,
|
||||
lng: latlng.lng,
|
||||
type: "circle",
|
||||
name: "Buffer Zone " + new Date().toLocaleDateString("id-ID"),
|
||||
meta: { radius }
|
||||
};
|
||||
}
|
||||
|
||||
if (newShape && onShapeCreated) {
|
||||
onShapeCreated(newShape);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[LeafletMap] pm:create handler error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
map.on("pm:create", handlePmCreate);
|
||||
|
||||
return () => {
|
||||
map.off("pm:create", handlePmCreate);
|
||||
};
|
||||
}, [leafletLoaded, mode, onShapeCreated]);
|
||||
|
||||
// 6. Programmatically enable Geoman drawing tools based on placingMarkerType
|
||||
useEffect(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map || mode !== "land" || !map.pm) return;
|
||||
|
||||
try {
|
||||
map.pm.disableDraw();
|
||||
|
||||
if (!placingMarkerType) return;
|
||||
|
||||
const options = {
|
||||
templineStyle: { color: "#ff3b1f" },
|
||||
hintlineStyle: { color: "#ff3b1f", dashArray: "5 5" },
|
||||
pathOptions: { color: "#ff3b1f", fillColor: "#ff3b1f", fillOpacity: 0.2 }
|
||||
};
|
||||
|
||||
if (placingMarkerType === "line") {
|
||||
map.pm.enableDraw("Line", options);
|
||||
} else if (placingMarkerType === "polygon") {
|
||||
map.pm.enableDraw("Polygon", options);
|
||||
} else if (placingMarkerType === "circle") {
|
||||
map.pm.enableDraw("Circle", options);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[LeafletMap] Geoman enableDraw error:", err);
|
||||
}
|
||||
}, [placingMarkerType, leafletLoaded, mode]);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"relative w-full h-full min-h-[380px] bg-paper dark:bg-obsidian overflow-hidden",
|
||||
!fullBleed && "rounded-lg border"
|
||||
)}>
|
||||
{!leafletLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-card/80 backdrop-blur-sm z-50">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-xs font-semibold text-muted-foreground">Menginisialisasi peta Leaflet...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={mapContainerRef} className="w-full h-full min-h-[380px]" style={{ zIndex: 1 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type React from "react";
|
||||
|
||||
export const LogoIcon = (props: React.ComponentProps<"svg">) => (
|
||||
<svg fill="currentColor" viewBox="0 -2.5 29 29" {...props}>
|
||||
<path d="M 0.09375 23.921875 C 0 23.851562 -0.00390625 23.640625 0.0078125 21.367188 L 0.0234375 18.890625 L 3.324219 18.871094 L 6.628906 18.859375 L 6.664062 18.992188 C 6.683594 19.070312 6.699219 20.191406 6.699219 21.492188 C 6.699219 23.246094 6.683594 23.867188 6.628906 23.921875 C 6.523438 24.027344 0.238281 24.023438 0.09375 23.921875 Z M 0.09375 23.921875 " />
|
||||
<path d="M 9.679688 23.890625 C 9.625 23.75 9.625 19.289062 9.6875 19.085938 L 9.726562 18.925781 L 11.4375 18.90625 L 13.152344 18.890625 L 13.371094 18.722656 C 13.492188 18.628906 13.632812 18.492188 13.679688 18.40625 C 13.8125 18.214844 13.851562 17.328125 13.792969 15.925781 C 13.75 14.808594 13.75 14.800781 13.5625 14.492188 C 13.460938 14.320312 13.265625 14.089844 13.121094 13.984375 L 12.875 13.792969 L 10.101562 13.792969 C 7.679688 13.789062 7.316406 13.773438 7.242188 13.699219 C 7.15625 13.613281 7.140625 13.25 7.117188 11.023438 C 7.09375 8.96875 7.070312 8.414062 7.003906 8.296875 C 6.832031 7.984375 6.570312 7.707031 6.28125 7.515625 L 5.976562 7.316406 L 3.082031 7.335938 C 0.457031 7.347656 0.179688 7.335938 0.0898438 7.246094 C 0 7.15625 -0.00390625 6.832031 0.0078125 3.640625 C 0.0195312 1.707031 0.046875 0.109375 0.0703125 0.078125 C 0.148438 0.0078125 7.121094 -0.0195312 7.195312 0.0546875 C 7.230469 0.0898438 7.265625 1.097656 7.277344 2.816406 C 7.289062 4.304688 7.3125 5.65625 7.328125 5.832031 C 7.355469 6.085938 7.414062 6.203125 7.679688 6.546875 C 8.230469 7.257812 8.042969 7.210938 10.617188 7.226562 L 12.847656 7.246094 L 13.167969 6.988281 C 13.566406 6.671875 13.859375 6.339844 13.964844 6.074219 C 14.035156 5.914062 14.046875 5.355469 14.042969 3.097656 C 14.035156 0.542969 14.042969 0.304688 14.136719 0.160156 L 14.238281 0 L 28.691406 0 L 29.0625 0.347656 L 29.429688 0.691406 L 29.441406 3.34375 C 29.46875 6.988281 29.46875 6.972656 29.253906 7.1875 C 29.128906 7.316406 29.003906 7.371094 28.796875 7.394531 C 28.640625 7.414062 26.457031 7.414062 23.941406 7.382812 C 21.429688 7.359375 18.386719 7.347656 17.175781 7.351562 L 14.980469 7.371094 L 14.753906 7.515625 C 14.503906 7.667969 14.21875 8.027344 14.082031 8.351562 C 14.019531 8.511719 14 8.988281 14 10.765625 L 14 12.980469 L 14.207031 13.292969 C 14.417969 13.609375 14.753906 13.875 15.128906 14.03125 C 15.300781 14.101562 15.910156 14.117188 18.640625 14.117188 C 21.269531 14.117188 21.964844 14.132812 22.023438 14.191406 C 22.085938 14.253906 22.109375 15.191406 22.121094 18.902344 C 22.132812 23.0625 22.125 23.546875 22.035156 23.675781 C 21.828125 24.003906 22.019531 23.992188 15.632812 23.992188 C 10.058594 23.992188 9.722656 23.988281 9.679688 23.890625 Z M 9.679688 23.890625 " />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Logo = (props: React.ComponentProps<"svg">) => (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 114 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M 0.09375 23.921875 C 0 23.851562 -0.00390625 23.640625 0.0078125 21.367188 L 0.0234375 18.890625 L 3.324219 18.871094 L 6.628906 18.859375 L 6.664062 18.992188 C 6.683594 19.070312 6.699219 20.191406 6.699219 21.492188 C 6.699219 23.246094 6.683594 23.867188 6.628906 23.921875 C 6.523438 24.027344 0.238281 24.023438 0.09375 23.921875 Z M 0.09375 23.921875 " />
|
||||
<path d="M 9.679688 23.890625 C 9.625 23.75 9.625 19.289062 9.6875 19.085938 L 9.726562 18.925781 L 11.4375 18.90625 L 13.152344 18.890625 L 13.371094 18.722656 C 13.492188 18.628906 13.632812 18.492188 13.679688 18.40625 C 13.8125 18.214844 13.851562 17.328125 13.792969 15.925781 C 13.75 14.808594 13.75 14.800781 13.5625 14.492188 C 13.460938 14.320312 13.265625 14.089844 13.121094 13.984375 L 12.875 13.792969 L 10.101562 13.792969 C 7.679688 13.789062 7.316406 13.773438 7.242188 13.699219 C 7.15625 13.613281 7.140625 13.25 7.117188 11.023438 C 7.09375 8.96875 7.070312 8.414062 7.003906 8.296875 C 6.832031 7.984375 6.570312 7.707031 6.28125 7.515625 L 5.976562 7.316406 L 3.082031 7.335938 C 0.457031 7.347656 0.179688 7.335938 0.0898438 7.246094 C 0 7.15625 -0.00390625 6.832031 0.0078125 3.640625 C 0.0195312 1.707031 0.046875 0.109375 0.0703125 0.078125 C 0.148438 0.0078125 7.121094 -0.0195312 7.195312 0.0546875 C 7.230469 0.0898438 7.265625 1.097656 7.277344 2.816406 C 7.289062 4.304688 7.3125 5.65625 7.328125 5.832031 C 7.355469 6.085938 7.414062 6.203125 7.679688 6.546875 C 8.230469 7.257812 8.042969 7.210938 10.617188 7.226562 L 12.847656 7.246094 L 13.167969 6.988281 C 13.566406 6.671875 13.859375 6.339844 13.964844 6.074219 C 14.035156 5.914062 14.046875 5.355469 14.042969 3.097656 C 14.035156 0.542969 14.042969 0.304688 14.136719 0.160156 L 14.238281 0 L 28.691406 0 L 29.0625 0.347656 L 29.429688 0.691406 L 29.441406 3.34375 C 29.46875 6.988281 29.46875 6.972656 29.253906 7.1875 C 29.128906 7.316406 29.003906 7.371094 28.796875 7.394531 C 28.640625 7.414062 26.457031 7.414062 23.941406 7.382812 C 21.429688 7.359375 18.386719 7.347656 17.175781 7.351562 L 14.980469 7.371094 L 14.753906 7.515625 C 14.503906 7.667969 14.21875 8.027344 14.082031 8.351562 C 14.019531 8.511719 14 8.988281 14 10.765625 L 14 12.980469 L 14.207031 13.292969 C 14.417969 13.609375 14.753906 13.875 15.128906 14.03125 C 15.300781 14.101562 15.910156 14.117188 18.640625 14.117188 C 21.269531 14.117188 21.964844 14.132812 22.023438 14.191406 C 22.085938 14.253906 22.109375 15.191406 22.121094 18.902344 C 22.132812 23.0625 22.125 23.546875 22.035156 23.675781 C 21.828125 24.003906 22.019531 23.992188 15.632812 23.992188 C 10.058594 23.992188 9.722656 23.988281 9.679688 23.890625 Z M 9.679688 23.890625 " />
|
||||
<path d="M 28.703125 22.320312 C 27.429688 22.054688 26.429688 21.3125 26.058594 20.371094 C 25.652344 19.320312 25.664062 19.492188 25.664062 15.644531 L 25.664062 12.152344 L 25.851562 12.105469 C 26.046875 12.050781 29.984375 11.941406 30.148438 11.984375 C 30.226562 12.003906 30.25 12.128906 30.28125 12.636719 C 30.304688 12.976562 30.332031 13.285156 30.355469 13.316406 C 30.398438 13.386719 37.679688 13.386719 37.792969 13.316406 C 37.859375 13.273438 37.875 13.082031 37.863281 12.378906 C 37.851562 11.398438 37.800781 11.238281 37.425781 10.96875 C 37.234375 10.832031 37.234375 10.832031 34.851562 10.832031 C 33.539062 10.832031 32.445312 10.808594 32.417969 10.785156 C 32.394531 10.753906 32.363281 9.957031 32.351562 9 C 32.339844 7.652344 32.351562 7.25 32.40625 7.214844 C 32.445312 7.191406 33.835938 7.167969 35.484375 7.164062 C 38.652344 7.15625 39.199219 7.191406 40.023438 7.453125 C 41.371094 7.871094 42.136719 8.703125 42.421875 10.058594 C 42.523438 10.527344 42.542969 10.878906 42.554688 12.863281 L 42.566406 15.128906 L 42.40625 15.519531 C 42.167969 16.089844 41.90625 16.335938 41.269531 16.542969 C 41.160156 16.582031 39.171875 16.597656 35.769531 16.59375 L 30.445312 16.585938 L 30.410156 16.789062 C 30.386719 16.902344 30.382812 17.296875 30.394531 17.667969 L 30.410156 18.328125 L 30.683594 18.589844 C 30.902344 18.800781 31.011719 18.855469 31.265625 18.890625 C 31.4375 18.914062 34.007812 18.925781 36.972656 18.914062 C 41.722656 18.890625 42.382812 18.902344 42.429688 18.980469 C 42.464844 19.027344 42.488281 19.773438 42.488281 20.6875 C 42.488281 22.171875 42.480469 22.316406 42.386719 22.359375 C 42.328125 22.386719 39.34375 22.417969 35.753906 22.421875 C 29.574219 22.433594 29.195312 22.429688 28.703125 22.320312 Z M 28.703125 22.320312 " />
|
||||
<path d="M 56.488281 22.398438 C 56.261719 22.382812 56.203125 22.351562 56.171875 22.246094 C 56.15625 22.171875 56.136719 19.996094 56.132812 17.398438 C 56.125 14.808594 56.109375 12.28125 56.085938 11.789062 L 56.050781 10.890625 L 55.8125 10.832031 C 55.679688 10.804688 55.539062 10.75 55.503906 10.726562 C 55.445312 10.691406 55.425781 10.277344 55.425781 9.007812 L 55.425781 7.339844 L 55.59375 7.339844 C 55.679688 7.339844 55.816406 7.300781 55.890625 7.246094 C 56.019531 7.15625 56.019531 7.144531 56.019531 5.382812 C 56.019531 4.058594 56.042969 3.503906 56.109375 3.191406 C 56.28125 2.371094 56.839844 1.5 57.414062 1.15625 C 57.847656 0.90625 58.625 0.675781 59.285156 0.597656 C 59.996094 0.511719 65.769531 0.53125 65.851562 0.613281 C 65.929688 0.699219 65.949219 4.230469 65.867188 4.332031 C 65.8125 4.40625 65.402344 4.417969 63.53125 4.417969 C 61.070312 4.417969 61.1875 4.398438 60.914062 4.769531 C 60.773438 4.972656 60.765625 5 60.765625 5.992188 C 60.765625 6.554688 60.792969 7.042969 60.816406 7.078125 C 60.84375 7.125 61.554688 7.15625 63.0625 7.179688 C 64.28125 7.199219 65.308594 7.234375 65.34375 7.257812 C 65.46875 7.335938 65.332031 10.714844 65.195312 10.84375 C 65.171875 10.875 64.179688 10.898438 62.992188 10.910156 L 60.839844 10.929688 L 60.796875 13.359375 C 60.773438 14.695312 60.765625 17.242188 60.785156 19.023438 C 60.816406 21.863281 60.808594 22.273438 60.730469 22.351562 C 60.65625 22.429688 60.359375 22.441406 58.703125 22.429688 C 57.632812 22.421875 56.636719 22.410156 56.488281 22.398438 Z M 56.488281 22.398438 " />
|
||||
<path d="M 69.371094 22.320312 C 68.445312 22.121094 67.640625 21.726562 67.234375 21.265625 C 66.945312 20.929688 66.566406 20.171875 66.421875 19.636719 C 66.328125 19.285156 66.316406 18.859375 66.316406 14.921875 C 66.316406 10.648438 66.316406 10.589844 66.445312 10.027344 C 66.714844 8.910156 67.332031 8.007812 68.0625 7.675781 C 68.59375 7.4375 69.273438 7.289062 70.027344 7.257812 C 71.484375 7.1875 78.792969 7.164062 79.515625 7.222656 C 80.472656 7.300781 81.105469 7.492188 81.6875 7.882812 C 82.226562 8.25 82.488281 8.546875 82.792969 9.144531 C 83.207031 9.980469 83.230469 10.160156 83.230469 12.890625 C 83.230469 15.230469 83.226562 15.316406 83.105469 15.582031 C 82.875 16.066406 82.335938 16.507812 81.878906 16.570312 C 81.742188 16.585938 79.238281 16.609375 76.316406 16.617188 L 71.003906 16.621094 L 70.988281 17.339844 C 70.980469 17.730469 70.988281 18.121094 71.003906 18.199219 C 71.058594 18.40625 71.484375 18.789062 71.707031 18.832031 C 71.8125 18.84375 74.382812 18.867188 77.425781 18.878906 C 80.472656 18.882812 82.980469 18.914062 83.003906 18.945312 C 83.027344 18.96875 83.058594 19.71875 83.070312 20.613281 C 83.089844 22.066406 83.082031 22.257812 82.992188 22.347656 C 82.902344 22.433594 82.351562 22.441406 76.386719 22.433594 C 70.335938 22.429688 69.839844 22.421875 69.371094 22.320312 Z M 78.441406 13.296875 C 78.542969 13.195312 78.535156 11.542969 78.433594 11.339844 C 78.394531 11.261719 78.246094 11.113281 78.109375 11.019531 L 77.855469 10.832031 L 74.726562 10.851562 L 71.597656 10.863281 L 71.332031 11.03125 C 71.183594 11.121094 71.035156 11.261719 70.992188 11.339844 C 70.910156 11.5 70.890625 12.308594 70.957031 12.96875 L 70.992188 13.371094 L 74.679688 13.371094 C 77.472656 13.371094 78.386719 13.351562 78.441406 13.296875 Z M 78.441406 13.296875 " />
|
||||
<path d="M 85.308594 22.398438 C 85.148438 22.386719 84.980469 22.347656 84.941406 22.308594 C 84.875 22.261719 84.863281 21.082031 84.878906 16.269531 C 84.898438 9.628906 84.878906 9.925781 85.308594 9.042969 C 85.710938 8.195312 86.3125 7.710938 87.367188 7.390625 C 87.792969 7.257812 87.867188 7.257812 91.640625 7.234375 C 95.398438 7.214844 95.480469 7.214844 95.539062 7.335938 C 95.617188 7.476562 95.539062 8.300781 95.335938 9.488281 C 95.253906 9.96875 95.1875 10.4375 95.1875 10.535156 C 95.1875 10.636719 95.148438 10.777344 95.09375 10.851562 L 95.003906 10.980469 L 92.703125 10.980469 C 91.433594 10.980469 90.316406 11 90.222656 11.019531 C 90.125 11.035156 89.914062 11.167969 89.753906 11.308594 L 89.460938 11.566406 L 89.425781 16.921875 C 89.402344 20.996094 89.375 22.285156 89.320312 22.339844 C 89.265625 22.394531 88.773438 22.410156 87.425781 22.417969 C 86.421875 22.417969 85.472656 22.410156 85.308594 22.398438 Z M 85.308594 22.398438 " />
|
||||
<path d="M 99.632812 22.351562 C 98.210938 22.089844 97.1875 21.300781 96.738281 20.113281 C 96.394531 19.21875 96.371094 18.832031 96.386719 14.527344 L 96.410156 10.652344 L 96.566406 10.0625 C 96.75 9.394531 97.113281 8.671875 97.433594 8.339844 C 97.871094 7.882812 98.511719 7.566406 99.4375 7.363281 C 99.855469 7.269531 100.410156 7.257812 104.597656 7.226562 L 109.285156 7.191406 L 109.300781 3.898438 C 109.3125 0.835938 109.320312 0.597656 109.421875 0.542969 C 109.582031 0.460938 113.820312 0.453125 113.917969 0.542969 C 113.992188 0.597656 114.003906 1.929688 113.996094 9.214844 C 113.996094 13.941406 113.972656 18.179688 113.945312 18.621094 C 113.882812 19.539062 113.789062 19.964844 113.492188 20.574219 C 113.078125 21.445312 112.414062 21.964844 111.300781 22.273438 C 110.828125 22.410156 110.828125 22.410156 105.457031 22.421875 C 101.066406 22.429688 100.003906 22.417969 99.632812 22.351562 Z M 108.898438 18.632812 C 108.980469 18.574219 109.105469 18.429688 109.183594 18.324219 L 109.3125 18.125 L 109.300781 14.570312 L 109.285156 11.011719 L 101.527344 11.011719 L 101.265625 11.296875 L 101.003906 11.589844 L 101.003906 14.734375 C 101.003906 17.882812 101.003906 17.882812 101.140625 18.144531 C 101.214844 18.289062 101.375 18.492188 101.5 18.585938 L 101.730469 18.769531 L 104.941406 18.800781 C 106.707031 18.820312 108.289062 18.8125 108.453125 18.789062 C 108.613281 18.765625 108.816406 18.691406 108.898438 18.632812 Z M 108.898438 18.632812 " />
|
||||
<path d="M 44.839844 22.011719 C 44.8125 21.804688 44.796875 19.238281 44.785156 16.304688 C 44.773438 11.644531 44.761719 10.96875 44.683594 10.921875 C 44.636719 10.890625 44.371094 10.863281 44.089844 10.863281 C 43.8125 10.863281 43.542969 10.832031 43.496094 10.804688 C 43.417969 10.753906 43.402344 10.488281 43.390625 9.125 C 43.378906 8.238281 43.382812 7.441406 43.402344 7.363281 C 43.4375 7.234375 43.460938 7.226562 44.109375 7.210938 L 44.773438 7.191406 L 44.808594 5.402344 C 44.832031 4.417969 44.875 3.460938 44.914062 3.28125 C 45.230469 1.773438 45.996094 0.992188 47.472656 0.675781 C 47.925781 0.578125 48.328125 0.566406 51.3125 0.566406 C 53.148438 0.566406 54.667969 0.585938 54.683594 0.609375 C 54.707031 0.632812 54.730469 1.492188 54.738281 2.519531 L 54.742188 4.386719 L 52.328125 4.417969 L 49.917969 4.445312 L 49.765625 4.589844 C 49.675781 4.671875 49.582031 4.847656 49.542969 4.976562 C 49.457031 5.300781 49.527344 7.109375 49.628906 7.179688 C 49.667969 7.203125 50.179688 7.222656 50.765625 7.214844 C 51.355469 7.210938 52.328125 7.210938 52.925781 7.214844 L 54.011719 7.222656 L 53.988281 8.945312 C 53.972656 10.273438 53.949219 10.691406 53.890625 10.765625 C 53.816406 10.851562 53.566406 10.863281 51.679688 10.863281 C 50.507812 10.863281 49.542969 10.867188 49.539062 10.878906 C 49.527344 10.886719 49.507812 13.472656 49.492188 16.621094 L 49.460938 22.351562 L 47.164062 22.371094 L 44.875 22.382812 Z M 44.839844 22.011719 " />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CosmicButton } from "@/components/ui/cosmic-button";
|
||||
import { MapIcon } from "@/components/ui/phosphor-icons";
|
||||
|
||||
interface MapPageHeaderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
onOpenMap: () => void;
|
||||
buttonText: string;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MapPageHeader({ title, description, onOpenMap, buttonText, icon, children }: MapPageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col justify-between gap-4 border-b pb-5 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<CosmicButton
|
||||
as="button"
|
||||
size="sm"
|
||||
onClick={onOpenMap}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{icon ?? <MapIcon className="h-4 w-4" />}
|
||||
<span className="capitalize">{buttonText}</span>
|
||||
</span>
|
||||
</CosmicButton>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { HouseIcon, GlobeHemisphereWestIcon, CaretDoubleRightIcon } from "@/components/ui/phosphor-icons";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface MapsBreadcrumbProps {
|
||||
currentPage: string;
|
||||
currentIcon?: ReactNode;
|
||||
isLeafletOpen?: boolean;
|
||||
onExitMap?: () => void;
|
||||
}
|
||||
|
||||
export function MapsBreadcrumb({ currentPage, currentIcon, isLeafletOpen = false, onExitMap }: MapsBreadcrumbProps) {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/dashboard" className="flex items-center gap-1">
|
||||
<HouseIcon className="size-3.5" />
|
||||
Beranda
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator>
|
||||
<CaretDoubleRightIcon />
|
||||
</BreadcrumbSeparator>
|
||||
|
||||
{!isLeafletOpen ? (
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="flex items-center gap-1">
|
||||
{currentIcon}
|
||||
{currentPage}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
) : (
|
||||
<>
|
||||
<BreadcrumbItem>
|
||||
{onExitMap ? (
|
||||
<BreadcrumbLink
|
||||
render={<button type="button" onClick={onExitMap} />}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{currentIcon}
|
||||
{currentPage}
|
||||
</BreadcrumbLink>
|
||||
) : (
|
||||
<BreadcrumbLink
|
||||
href={
|
||||
currentPage === "Peta Kemiskinan" || currentPage === "Poverty" ? "/dashboard/poverty" :
|
||||
currentPage === "Lahan dan Jalan" || currentPage === "Lands and Roads" ? "/dashboard/land" :
|
||||
currentPage === "SPBU dan Charger" || currentPage === "Gas Stations" ? "/dashboard/gas-station" :
|
||||
"/dashboard"
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{currentIcon}
|
||||
{currentPage}
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator>
|
||||
<CaretDoubleRightIcon />
|
||||
</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="flex items-center gap-1">
|
||||
<GlobeHemisphereWestIcon className="size-3.5" />
|
||||
Peta
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Portal, PortalBackdrop } from "@/components/portal";
|
||||
import { navLinks } from "@/components/header";
|
||||
import { XIcon, MenuIcon } from "lucide-react";
|
||||
|
||||
export function MobileNav() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="md:hidden">
|
||||
<Button
|
||||
aria-controls="mobile-menu"
|
||||
aria-expanded={open}
|
||||
aria-label="Toggle menu"
|
||||
className="md:hidden"
|
||||
onClick={() => setOpen(!open)}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
{open ? (
|
||||
<XIcon className="size-4.5" />
|
||||
) : (
|
||||
<MenuIcon className="size-4.5" />
|
||||
)}
|
||||
</Button>
|
||||
{open && (
|
||||
<Portal className="top-14" id="mobile-menu">
|
||||
<PortalBackdrop />
|
||||
<div
|
||||
className={cn(
|
||||
"data-[slot=open]:zoom-in-97 ease-out data-[slot=open]:animate-in",
|
||||
"size-full p-4"
|
||||
)}
|
||||
data-slot={open ? "open" : "closed"}
|
||||
>
|
||||
<div className="grid gap-y-2">
|
||||
{navLinks.map((link) => (
|
||||
<Button className="justify-start" key={link.label} variant="ghost" render={<a href={link.href} />} nativeButton={false}>{link.label}</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-12 flex flex-col gap-2">
|
||||
<Button className="w-full" variant="outline">
|
||||
Sign In
|
||||
</Button>
|
||||
<Button className="w-full">Get Started</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import type { SidebarNavGroup } from "@/components/app-shared";
|
||||
import { ChevronRightIcon } from "@/components/ui/phosphor-icons";
|
||||
|
||||
export function NavGroup({ label, items }: SidebarNavGroup) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
{label && <SidebarGroupLabel>{label}</SidebarGroupLabel>}
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<Collapsible className="group/collapsible" defaultOpen={
|
||||
!!item.isActive ||
|
||||
item.subItems?.some((i) => !!i.isActive)
|
||||
} key={item.title} render={<SidebarMenuItem />}>{item.subItems?.length ? (
|
||||
<>
|
||||
<CollapsibleTrigger render={<SidebarMenuButton isActive={item.isActive} tooltip={item.title} />}>{item.icon}<span>{item.title}</span><ChevronRightIcon className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" /></CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.subItems?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton isActive={subItem.isActive} render={<a href={subItem.path} />}>{subItem.icon}<span>{subItem.title}</span></SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
) : (
|
||||
<SidebarMenuButton isActive={item.isActive} tooltip={item.title} render={<a href={item.path} />}>{item.icon}<span>{item.title}</span></SidebarMenuButton>
|
||||
)}</Collapsible>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
} from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SettingsIcon, LogOutIcon } from "@/components/ui/phosphor-icons";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
export function NavUser() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const displayName = user?.username ?? "User";
|
||||
const displayEmail = user?.email ?? "";
|
||||
const initials = displayName.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger nativeButton={false} render={<Avatar className="size-8" />}>
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuItem className="flex items-center justify-start gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-10">
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">{displayName}</span>
|
||||
<br />
|
||||
<div className="max-w-full overflow-hidden overflow-ellipsis whitespace-nowrap text-muted-foreground text-xs">
|
||||
{displayEmail}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon />
|
||||
Pengaturan
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
variant="destructive"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOutIcon />
|
||||
Keluar
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
function Portal({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||||
const scrollbarWidth =
|
||||
window.innerWidth - document.documentElement.clientWidth;
|
||||
const originalPaddingRight = document.body.style.paddingRight;
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
if (scrollbarWidth > 0) {
|
||||
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalStyle;
|
||||
document.body.style.paddingRight = originalPaddingRight;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn("fixed inset-0 isolate z-40 flex flex-col", className)}
|
||||
{...props}
|
||||
/>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function PortalBackdrop({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 -z-1 bg-background/95 backdrop-blur-sm duration-500 data-[state=closed]:animate-out data-[state=open]:animate-in supports-backdrop-filter:bg-background/60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Portal, PortalBackdrop };
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DashboardCard } from "@/components/dashboard-card";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
|
||||
|
||||
const LABELS = [
|
||||
"SANGAT TINGGI",
|
||||
"TINGGI",
|
||||
"SEDANG",
|
||||
"RENDAH",
|
||||
"TIDAK PRIORITAS",
|
||||
] as const;
|
||||
|
||||
const LABEL_STYLES: Record<string, { bar: string; text: string; dot: string }> = {
|
||||
"SANGAT TINGGI": { bar: "bg-red-500", text: "text-red-700", dot: "bg-red-500" },
|
||||
"TINGGI": { bar: "bg-orange-500", text: "text-orange-700", dot: "bg-orange-500" },
|
||||
"SEDANG": { bar: "bg-amber-500", text: "text-amber-700", dot: "bg-amber-500" },
|
||||
"RENDAH": { bar: "bg-emerald-500", text: "text-emerald-700", dot: "bg-emerald-500" },
|
||||
"TIDAK PRIORITAS": { bar: "bg-blue-500", text: "text-blue-700", dot: "bg-blue-500" },
|
||||
};
|
||||
|
||||
export function FuzzyPriorityStats({
|
||||
refreshKey,
|
||||
onRecomputed,
|
||||
}: {
|
||||
refreshKey?: number;
|
||||
onRecomputed?: () => void;
|
||||
}) {
|
||||
const [stats, setStats] = useState<Record<string, number> | null>(null);
|
||||
const [recomputing, setRecomputing] = useState(false);
|
||||
const [recomputeMsg, setRecomputeMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${BACKEND_URL}/households/stats-fuzzy`)
|
||||
.then((r) => r.json())
|
||||
.then(setStats)
|
||||
.catch(() => setStats(null));
|
||||
}, [refreshKey]);
|
||||
|
||||
async function handleRecompute() {
|
||||
setRecomputing(true);
|
||||
setRecomputeMsg(null);
|
||||
try {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const res = await fetch(`${BACKEND_URL}/households/recompute-fuzzy`, {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error ?? "Gagal menghitung ulang");
|
||||
}
|
||||
const data = await res.json();
|
||||
setRecomputeMsg(`${data.updated} dari ${data.total} warga dihitung ulang`);
|
||||
onRecomputed?.();
|
||||
} catch (err) {
|
||||
setRecomputeMsg(err instanceof Error ? err.message : "Terjadi kesalahan");
|
||||
} finally {
|
||||
setRecomputing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const total = LABELS.reduce((sum, l) => sum + (stats[l] ?? 0), 0);
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="md:col-span-2 lg:col-span-4 flex flex-col h-full">
|
||||
<DashboardCard className="gap-0 h-full py-0">
|
||||
<CardHeader className="border-b pt-4 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Distribusi Prioritas Fuzzy</CardTitle>
|
||||
<CardDescription>
|
||||
{recomputeMsg ?? `${total} warga teranalisis oleh sistem fuzzy`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecompute}
|
||||
disabled={recomputing}
|
||||
className="h-8 shrink-0 rounded-full border border-border px-4 text-xs font-medium transition-colors hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{recomputing ? "Menghitung…" : "Hitung Ulang Semua"}
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 py-0 flex-1 flex flex-col">
|
||||
<div className="grid grid-cols-5 divide-x flex-1">
|
||||
{LABELS.map((label) => {
|
||||
const count = stats[label] ?? 0;
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
const style = LABEL_STYLES[label];
|
||||
return (
|
||||
<div key={label} className="flex flex-col items-center gap-2 px-4 py-5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`h-2 w-2 rounded-full shrink-0 ${style.dot}`} />
|
||||
<span className={`text-[11px] font-medium leading-tight text-center ${style.text}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold tabular-nums">{count}</span>
|
||||
<div className="w-full h-1.5 rounded-full bg-border overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${style.bar}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</DashboardCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
|
||||
export const FUZZY_BADGE: Record<string, { bg: string; text: string; bar: string }> = {
|
||||
"SANGAT TINGGI": { bg: "#fef2f2", text: "#b91c1c", bar: "#ef4444" },
|
||||
"TINGGI": { bg: "#fff7ed", text: "#c2410c", bar: "#f97316" },
|
||||
"SEDANG": { bg: "#fffbeb", text: "#b45309", bar: "#f59e0b" },
|
||||
"RENDAH": { bg: "#f0fdf4", text: "#15803d", bar: "#10b981" },
|
||||
"TIDAK PRIORITAS": { bg: "#eff6ff", text: "#1d4ed8", bar: "#6b7280" },
|
||||
};
|
||||
|
||||
const FACTOR_LABELS: Record<string, string> = {
|
||||
penghasilan: "Penghasilan",
|
||||
tanggungan: "Tanggungan",
|
||||
kondisi_rumah: "Kondisi Rumah",
|
||||
kepemilikan_aset: "Kepemilikan Aset",
|
||||
};
|
||||
|
||||
interface FuzzyResultStepProps {
|
||||
marker: UserMarker;
|
||||
confirmLabel: string;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function FuzzyResultStep({ marker, confirmLabel, onConfirm }: FuzzyResultStepProps) {
|
||||
const label = marker.meta?.fuzzy_label ?? "";
|
||||
const score = marker.meta?.fuzzy_score ?? null;
|
||||
const detail = marker.meta?.fuzzy_detail ?? null;
|
||||
const badge = FUZZY_BADGE[label] ?? { bg: "#f3f4f6", text: "#374151", bar: "#6b7280" };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<DialogTitle className="text-center text-base font-semibold">
|
||||
Hasil Analisis Fuzzy
|
||||
</DialogTitle>
|
||||
<p className="text-center text-[11px] text-muted-foreground mt-0.5">
|
||||
{marker.name}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-5 px-6 py-6">
|
||||
{/* Priority badge */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
|
||||
Tingkat Prioritas
|
||||
</span>
|
||||
<span
|
||||
className="px-5 py-1.5 rounded-full text-sm font-bold"
|
||||
style={{ backgroundColor: badge.bg, color: badge.text }}
|
||||
>
|
||||
{label || "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Score bar */}
|
||||
{score != null && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">Skor Fuzzy</span>
|
||||
<span className="text-sm font-bold tabular-nums">{score.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${score}%`, backgroundColor: badge.bar }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[9px] text-muted-foreground">
|
||||
<span>0</span>
|
||||
<span>100</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Factor breakdown — dominant fuzzy category per input */}
|
||||
{detail && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
|
||||
Faktor Penilaian
|
||||
</span>
|
||||
<div className="rounded-xl border border-border divide-y divide-border overflow-hidden">
|
||||
{Object.entries(FACTOR_LABELS).map(([key, name]) => {
|
||||
const f = detail[key];
|
||||
if (!f) return null;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between px-4 py-2 text-[11px]">
|
||||
<span className="text-muted-foreground">{name}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-medium">{f.kategori}</span>
|
||||
<span className="w-12 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<span
|
||||
className="block h-full rounded-full"
|
||||
style={{ width: `${Math.round(f.derajat * 100)}%`, backgroundColor: badge.bar }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary grid */}
|
||||
<div className="rounded-xl bg-muted/50 border border-border px-4 py-3 grid grid-cols-2 gap-y-2 gap-x-6 text-[11px]">
|
||||
<span className="text-muted-foreground">Kemiskinan</span>
|
||||
<span className="font-medium text-right">{marker.meta?.poverty_level ?? "—"}</span>
|
||||
<span className="text-muted-foreground">Tanggungan</span>
|
||||
<span className="font-medium text-right">{marker.meta?.family_count ?? "—"} jiwa</span>
|
||||
<span className="text-muted-foreground">Penghasilan</span>
|
||||
<span className="font-medium text-right">
|
||||
Rp {(marker.meta?.penghasilan ?? 0).toLocaleString("id-ID")}/bln
|
||||
</span>
|
||||
<span className="text-muted-foreground">Koordinat</span>
|
||||
<span className="font-mono text-right text-[10px]">
|
||||
{marker.lat.toFixed(5)}, {marker.lng.toFixed(5)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end px-6 py-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { PhosphorIcon } from "@/components/ui/collection-grid-disclosure";
|
||||
import { ChevronDownIcon } from "@/components/ui/phosphor-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RadiusSlider } from "@/components/poverty/radius-slider";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
import { TimedUndoAction } from "@/components/time-undo-action";
|
||||
import { FuzzyResultStep } from "@/components/poverty/fuzzy-result-step";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
|
||||
|
||||
const RELIGION_OPTIONS = [
|
||||
{ value: "mosque", label: "Masjid (Islam)", icon: "mosque" },
|
||||
{ value: "church", label: "Gereja (Kristen)", icon: "church" },
|
||||
{ value: "cathedral", label: "Gereja Katolik", icon: "crown-cross" },
|
||||
{ value: "temple", label: "Pura (Hindu)", icon: "hands-praying" },
|
||||
{ value: "vihara", label: "Vihara (Buddha)", icon: "hands-praying" },
|
||||
{ value: "klenteng", label: "Klenteng (Konghucu)", icon: "hands-praying" },
|
||||
{ value: "synagogue", label: "Sinagog (Yahudi)", icon: "synagogue" },
|
||||
];
|
||||
|
||||
const MARKER_LABELS: Record<string, string> = {
|
||||
marker: "Warga Miskin",
|
||||
mosque: "Masjid",
|
||||
church: "Gereja",
|
||||
cathedral: "Gereja Katolik",
|
||||
temple: "Pura",
|
||||
vihara: "Vihara",
|
||||
klenteng: "Klenteng",
|
||||
synagogue: "Sinagog",
|
||||
clinic: "Klinik",
|
||||
"food-bank": "Bank Makanan",
|
||||
school: "Sekolah",
|
||||
};
|
||||
|
||||
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-1.5 ${className ?? ""}`}>
|
||||
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdaptiveSlider({ value, onChange, hint }: { value: number; onChange: (v: number) => void; hint: string }) {
|
||||
const pct = ((value - 1) / 9) * 100;
|
||||
const color = value <= 3 ? "#ef4444" : value <= 6 ? "#f59e0b" : "#10b981";
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range" min={1} max={10} value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
style={{ background: `linear-gradient(to right, ${color} 0%, ${color} ${pct}%, var(--border) ${pct}%, var(--border) 100%)` }}
|
||||
className="flex-1 h-[3px] cursor-pointer appearance-none rounded-full outline-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow [&::-webkit-slider-thumb]:border [&::-webkit-slider-thumb]:border-border"
|
||||
/>
|
||||
<span className="w-6 shrink-0 text-center text-xs font-bold tabular-nums" style={{ color }}>{value}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkerEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
marker: UserMarker | null;
|
||||
onUpdated: (updated: UserMarker) => void;
|
||||
onDeleted: (deleted: UserMarker) => void;
|
||||
}
|
||||
|
||||
export function MarkerEditDialog({ open, onOpenChange, marker, onUpdated, onDeleted }: MarkerEditDialogProps) {
|
||||
const isHousehold = marker?.id.startsWith("hh-");
|
||||
const isPOI = marker?.id.startsWith("poi-");
|
||||
const isReligion = isPOI && RELIGION_OPTIONS.some((o) => o.value === marker?.type);
|
||||
|
||||
// Household fields
|
||||
const [headName, setHeadName] = useState("");
|
||||
const [familyCount, setFamilyCount] = useState(3);
|
||||
const [penghasilan, setPenghasilan] = useState(1500000);
|
||||
const [kondisiRumah, setKondisiRumah] = useState(5);
|
||||
const [kepemilikanAset, setKepemilikanAset] = useState(5);
|
||||
const [notes, setNotes] = useState("");
|
||||
// POI fields
|
||||
const [placeName, setPlaceName] = useState("");
|
||||
const [religionType, setReligionType] = useState<string | null>(null);
|
||||
const [radiusMeters, setRadiusMeters] = useState(0);
|
||||
|
||||
const selectedReligion = RELIGION_OPTIONS.find((o) => o.value === religionType);
|
||||
|
||||
const DELETE_SECONDS = 5;
|
||||
const TOAST_ID = "marker-delete";
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Post-save fuzzy result step (household only)
|
||||
const [step, setStep] = useState<"form" | "result">("form");
|
||||
const [savedMarker, setSavedMarker] = useState<UserMarker | null>(null);
|
||||
|
||||
// Delete countdown — lives here so it survives dialog close
|
||||
const [deleteActive, setDeleteActive] = useState(false);
|
||||
const [deleteCountdown, setDeleteCountdown] = useState(DELETE_SECONDS);
|
||||
// Holds the marker being deleted so handleDelete works after dialog closes
|
||||
const pendingDeleteMarker = useRef<UserMarker | null>(null);
|
||||
|
||||
// Reset only when a DIFFERENT non-null marker is opened — skip on dialog close (marker → null)
|
||||
useEffect(() => {
|
||||
if (!marker?.id) return;
|
||||
setDeleteActive(false);
|
||||
setDeleteCountdown(DELETE_SECONDS);
|
||||
pendingDeleteMarker.current = null;
|
||||
toast.dismiss(TOAST_ID);
|
||||
setStep("form");
|
||||
setSavedMarker(null);
|
||||
}, [marker?.id]);
|
||||
|
||||
// Tick
|
||||
useEffect(() => {
|
||||
if (!deleteActive) return;
|
||||
const id = setInterval(() => setDeleteCountdown((p) => Math.max(0, p - 1)), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [deleteActive]);
|
||||
|
||||
// Fire when countdown hits 0
|
||||
useEffect(() => {
|
||||
if (!deleteActive || deleteCountdown > 0) return;
|
||||
setDeleteActive(false);
|
||||
setDeleteCountdown(DELETE_SECONDS);
|
||||
toast.dismiss(TOAST_ID);
|
||||
handleDelete();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deleteActive, deleteCountdown]);
|
||||
|
||||
// Show / update toast while countdown is running
|
||||
useEffect(() => {
|
||||
if (!deleteActive || !pendingDeleteMarker.current) {
|
||||
toast.dismiss(TOAST_ID);
|
||||
return;
|
||||
}
|
||||
const name = pendingDeleteMarker.current.name;
|
||||
const pct = (deleteCountdown / DELETE_SECONDS) * 100;
|
||||
toast.custom(
|
||||
() => (
|
||||
<div className="relative flex items-center gap-3 w-72 rounded-xl border border-border bg-popover px-4 py-3 shadow-lg overflow-hidden font-sans">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-[3px] bg-red-500 transition-[width] duration-1000 ease-linear"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground truncate">Menghapus marker</p>
|
||||
<p className="text-xs text-muted-foreground truncate">"{name}" — dalam {deleteCountdown} detik</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeleteActive(false);
|
||||
setDeleteCountdown(DELETE_SECONDS);
|
||||
pendingDeleteMarker.current = null;
|
||||
toast.dismiss(TOAST_ID);
|
||||
}}
|
||||
className="shrink-0 h-7 rounded-full border border-border bg-background px-3 text-xs font-medium hover:bg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
Batalkan
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
{ id: TOAST_ID, duration: Infinity, position: "bottom-left" },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deleteActive, deleteCountdown]);
|
||||
|
||||
function toggleDelete() {
|
||||
setDeleteActive((prev) => {
|
||||
if (!prev) {
|
||||
pendingDeleteMarker.current = marker;
|
||||
setDeleteCountdown(DELETE_SECONDS);
|
||||
} else {
|
||||
pendingDeleteMarker.current = null;
|
||||
toast.dismiss(TOAST_ID);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}
|
||||
|
||||
// Load existing data when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open || !marker) return;
|
||||
const dbId = marker.id.split("-").slice(1).join("-");
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {} as Record<string, string>;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (isHousehold) {
|
||||
fetch(`${BACKEND_URL}/households`, { headers })
|
||||
.then((r) => r.json())
|
||||
.then((rows: any[]) => {
|
||||
const row = rows.find((r: any) => String(r.id) === dbId);
|
||||
if (row) {
|
||||
setHeadName(row.head_name ?? "");
|
||||
setFamilyCount(row.family_count ?? 3);
|
||||
setPenghasilan(row.penghasilan ?? 1500000);
|
||||
setKondisiRumah(row.kondisi_rumah ?? 5);
|
||||
setKepemilikanAset(row.kepemilikan_aset ?? 5);
|
||||
setNotes(row.notes ?? "");
|
||||
}
|
||||
})
|
||||
.catch(() => setError("Gagal memuat data"))
|
||||
.finally(() => setIsLoading(false));
|
||||
} else if (isPOI) {
|
||||
fetch(`${BACKEND_URL}/poi/${dbId}`, { headers })
|
||||
.then((r) => r.json())
|
||||
.then((row: any) => {
|
||||
setPlaceName(row.name ?? "");
|
||||
setReligionType(row.religion_subtype ?? null);
|
||||
setRadiusMeters(row.radius_meters ?? 0);
|
||||
})
|
||||
.catch(() => setError("Gagal memuat data"))
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [open, marker]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!marker) return;
|
||||
const dbId = marker.id.split("-").slice(1).join("-");
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers = { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) };
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (isHousehold) {
|
||||
const res = await fetch(`${BACKEND_URL}/households/${dbId}`, {
|
||||
method: "PUT", headers,
|
||||
body: JSON.stringify({ head_name: headName, family_count: familyCount, notes: notes || null, penghasilan, kondisi_rumah: kondisiRumah, kepemilikan_aset: kepemilikanAset }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal menyimpan");
|
||||
const saved = await res.json();
|
||||
setSavedMarker({
|
||||
...marker,
|
||||
name: saved.head_name,
|
||||
meta: {
|
||||
poverty_level: saved.poverty_level,
|
||||
family_count: saved.family_count,
|
||||
penghasilan: saved.penghasilan,
|
||||
fuzzy_label: saved.fuzzy_label ?? undefined,
|
||||
fuzzy_score: saved.fuzzy_score != null ? parseFloat(saved.fuzzy_score) : undefined,
|
||||
fuzzy_detail: saved.fuzzy_detail ?? undefined,
|
||||
notes: saved.notes ?? undefined,
|
||||
},
|
||||
});
|
||||
setStep("result");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
} else {
|
||||
const name = placeName.trim() || (marker.name);
|
||||
const body: any = { name };
|
||||
if (isReligion) { body.poi_type = "religion"; body.religion_subtype = religionType; body.radius_meters = radiusMeters; }
|
||||
const res = await fetch(`${BACKEND_URL}/poi/${dbId}`, {
|
||||
method: "PUT", headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal menyimpan");
|
||||
onUpdated({
|
||||
...marker,
|
||||
name,
|
||||
...(isReligion && religionType ? { type: religionType } : {}),
|
||||
...(isReligion ? { meta: { ...marker.meta, radius: radiusMeters } } : {}),
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
const target = pendingDeleteMarker.current;
|
||||
if (!target) return;
|
||||
const dbId = target.id.split("-").slice(1).join("-");
|
||||
const isTargetHousehold = target.id.startsWith("hh-");
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const url = isTargetHousehold ? `${BACKEND_URL}/households/${dbId}` : `${BACKEND_URL}/poi/${dbId}`;
|
||||
const res = await fetch(url, { method: "DELETE", headers });
|
||||
if (!res.ok && res.status !== 204) throw new Error("Gagal menghapus");
|
||||
pendingDeleteMarker.current = null;
|
||||
onDeleted(target);
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const title = isHousehold
|
||||
? `Edit ${MARKER_LABELS["marker"]}`
|
||||
: `Edit ${MARKER_LABELS[marker?.type ?? ""] ?? marker?.type ?? "Marker"}`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) { onOpenChange(false); } }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
|
||||
{step === "result" && savedMarker && (
|
||||
<FuzzyResultStep
|
||||
marker={savedMarker}
|
||||
confirmLabel="Selesai"
|
||||
onConfirm={() => {
|
||||
onUpdated(savedMarker);
|
||||
setStep("form");
|
||||
setSavedMarker(null);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === "form" && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<DialogTitle className="text-center text-base font-semibold">{title}</DialogTitle>
|
||||
{marker && (
|
||||
<p className="text-center font-mono text-[10px] text-muted-foreground mt-0.5">
|
||||
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
||||
</p>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-5 px-6 py-5">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">Memuat data…</p>
|
||||
) : isHousehold ? (
|
||||
<>
|
||||
<Field label="Nama Kepala Keluarga">
|
||||
<Input value={headName} onChange={(e) => setHeadName(e.target.value)} required className="bg-muted/50 border border-border focus-visible:border-ring" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Penghasilan / Bulan">
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-2.5 text-xs text-muted-foreground font-semibold">Rp</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={penghasilan ? penghasilan.toLocaleString("id-ID") : ""}
|
||||
onChange={(e) => {
|
||||
const clean = e.target.value.replace(/\D/g, "");
|
||||
setPenghasilan(clean ? parseInt(clean, 10) : 0);
|
||||
}}
|
||||
className="pl-9 bg-muted/50 border border-border focus-visible:border-ring"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Jumlah Tanggungan (jiwa)">
|
||||
<Input type="number" min={1} max={20} value={familyCount} onChange={(e) => setFamilyCount(Number(e.target.value))} className="bg-muted/50 border border-border focus-visible:border-ring" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Kondisi Rumah"><AdaptiveSlider value={kondisiRumah} onChange={setKondisiRumah} hint="1 = sangat buruk · 10 = sangat baik" /></Field>
|
||||
<Field label="Kepemilikan Aset"><AdaptiveSlider value={kepemilikanAset} onChange={setKepemilikanAset} hint="1 = tidak ada · 10 = banyak" /></Field>
|
||||
</div>
|
||||
<Field label="Catatan">
|
||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} className="min-h-[72px] bg-muted/50 border border-border focus-visible:border-ring resize-none" />
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isReligion && (
|
||||
<Field label="Jenis Tempat Ibadah">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm transition-colors hover:bg-muted focus-visible:border-ring focus-visible:outline-none"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{selectedReligion?.icon && (
|
||||
<span className="size-4 shrink-0 text-foreground">
|
||||
<PhosphorIcon name={selectedReligion.icon} className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("truncate", !selectedReligion && "text-muted-foreground")}>
|
||||
{selectedReligion ? selectedReligion.label : "Pilih jenis…"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-(--anchor-width)">
|
||||
<DropdownMenuRadioGroup
|
||||
value={religionType ?? ""}
|
||||
onValueChange={setReligionType}
|
||||
>
|
||||
{RELIGION_OPTIONS.map((opt) => (
|
||||
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
|
||||
<span className="size-4 shrink-0 text-foreground">
|
||||
<PhosphorIcon name={opt.icon} className="size-4" />
|
||||
</span>
|
||||
{opt.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Nama Tempat">
|
||||
<Input value={placeName} onChange={(e) => setPlaceName(e.target.value)} className="bg-muted/50 border border-border focus-visible:border-ring" />
|
||||
</Field>
|
||||
{isReligion && (
|
||||
<Field label="Radius Jangkauan (meter)">
|
||||
<RadiusSlider value={radiusMeters} onChange={setRadiusMeters} />
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-6 py-4 border-t">
|
||||
<TimedUndoAction
|
||||
compact
|
||||
deleteLabel="Hapus Marker"
|
||||
undoLabel="Batal"
|
||||
isDeleting={deleteActive}
|
||||
countDown={deleteCountdown}
|
||||
onToggle={toggleDelete}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => onOpenChange(false)} disabled={isSubmitting}
|
||||
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50">
|
||||
Batal
|
||||
</button>
|
||||
<button type="submit" disabled={isSubmitting || isLoading || (isHousehold && !headName.trim())}
|
||||
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40">
|
||||
{isSubmitting ? "Menyimpan…" : "Simpan"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
import { FuzzyResultStep } from "@/components/poverty/fuzzy-result-step";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
|
||||
|
||||
const MARKER_LABELS: Record<string, string> = {
|
||||
marker: "Warga Miskin",
|
||||
mosque: "Masjid",
|
||||
church: "Gereja",
|
||||
synagogue: "Sinagog",
|
||||
clinic: "Klinik",
|
||||
"food-bank": "Bank Makanan",
|
||||
school: "Sekolah",
|
||||
};
|
||||
|
||||
interface PendingClick {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
interface MarkerPlacementDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
markerType: string;
|
||||
pendingClick: PendingClick | null;
|
||||
onConfirm: (marker: UserMarker) => void;
|
||||
}
|
||||
|
||||
/* ─── Adaptive Slider ─────────────────────────────────────── */
|
||||
function AdaptiveSlider({
|
||||
value,
|
||||
onChange,
|
||||
hint,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
hint: string;
|
||||
}) {
|
||||
const pct = ((value - 1) / 9) * 100;
|
||||
const color =
|
||||
value <= 3 ? "#ef4444" : value <= 6 ? "#f59e0b" : "#10b981";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={10}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${color} 0%, ${color} ${pct}%, var(--border) ${pct}%, var(--border) 100%)`,
|
||||
}}
|
||||
className="
|
||||
flex-1 h-[3px] cursor-pointer appearance-none rounded-full outline-none
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-white
|
||||
[&::-webkit-slider-thumb]:shadow
|
||||
[&::-webkit-slider-thumb]:border
|
||||
[&::-webkit-slider-thumb]:border-border
|
||||
[&::-moz-range-thumb]:w-3
|
||||
[&::-moz-range-thumb]:h-3
|
||||
[&::-moz-range-thumb]:rounded-full
|
||||
[&::-moz-range-thumb]:bg-white
|
||||
[&::-moz-range-thumb]:border
|
||||
[&::-moz-range-thumb]:border-border
|
||||
"
|
||||
/>
|
||||
<span
|
||||
className="w-6 shrink-0 text-center text-xs font-bold tabular-nums"
|
||||
style={{ color }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Field wrapper ───────────────────────────────────────── */
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-1.5 ${className ?? ""}`}>
|
||||
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main dialog ─────────────────────────────────────────── */
|
||||
export function MarkerPlacementDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
markerType,
|
||||
pendingClick,
|
||||
onConfirm,
|
||||
}: MarkerPlacementDialogProps) {
|
||||
const isHousehold = markerType === "marker";
|
||||
|
||||
const [headName, setHeadName] = useState("");
|
||||
const [familyCount, setFamilyCount] = useState(3);
|
||||
const [penghasilan, setPenghasilan] = useState(1500000);
|
||||
const [kondisiRumah, setKondisiRumah] = useState(5);
|
||||
const [kepemilikanAset, setKepemilikanAset] = useState(5);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [placeName, setPlaceName] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [step, setStep] = useState<"form" | "result">("form");
|
||||
const [savedMarker, setSavedMarker] = useState<UserMarker | null>(null);
|
||||
|
||||
function reset() {
|
||||
setHeadName(""); setFamilyCount(3); setPenghasilan(1500000);
|
||||
setKondisiRumah(5); setKepemilikanAset(5);
|
||||
setNotes(""); setPlaceName(""); setError(null);
|
||||
setStep("form"); setSavedMarker(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!pendingClick) return;
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (isHousehold) {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const res = await fetch(`${BACKEND_URL}/households`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
head_name: headName,
|
||||
family_count: familyCount,
|
||||
latitude: pendingClick.lat,
|
||||
longitude: pendingClick.lng,
|
||||
notes: notes || null,
|
||||
penghasilan,
|
||||
kondisi_rumah: kondisiRumah,
|
||||
kepemilikan_aset: kepemilikanAset,
|
||||
marker_type: "marker",
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error ?? "Gagal menyimpan data");
|
||||
}
|
||||
const saved = await res.json();
|
||||
const marker: UserMarker = {
|
||||
id: `hh-${saved.id}`,
|
||||
lat: pendingClick.lat,
|
||||
lng: pendingClick.lng,
|
||||
type: "marker",
|
||||
name: saved.head_name,
|
||||
meta: {
|
||||
poverty_level: saved.poverty_level,
|
||||
family_count: saved.family_count,
|
||||
penghasilan: saved.penghasilan,
|
||||
fuzzy_label: saved.fuzzy_label ?? undefined,
|
||||
fuzzy_score: saved.fuzzy_score != null ? parseFloat(saved.fuzzy_score) : undefined,
|
||||
fuzzy_detail: saved.fuzzy_detail ?? undefined,
|
||||
notes: saved.notes ?? undefined,
|
||||
},
|
||||
};
|
||||
setSavedMarker(marker);
|
||||
setStep("result");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
} else {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const name = placeName.trim() || MARKER_LABELS[markerType] || markerType;
|
||||
const res = await fetch(`${BACKEND_URL}/poi`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
poi_type: markerType,
|
||||
latitude: pendingClick.lat,
|
||||
longitude: pendingClick.lng,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error ?? "Gagal menyimpan data");
|
||||
}
|
||||
const saved = await res.json();
|
||||
onConfirm({
|
||||
id: `poi-${saved.id}`,
|
||||
lat: pendingClick.lat,
|
||||
lng: pendingClick.lng,
|
||||
type: markerType,
|
||||
name,
|
||||
meta: { poi_type: markerType, notes: saved.notes ?? undefined },
|
||||
});
|
||||
}
|
||||
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0"
|
||||
>
|
||||
|
||||
{/* ── Fuzzy Result Step ── */}
|
||||
{step === "result" && savedMarker && (
|
||||
<FuzzyResultStep
|
||||
marker={savedMarker}
|
||||
confirmLabel="Selesai & Pasang Marker"
|
||||
onConfirm={() => {
|
||||
onConfirm(savedMarker);
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Form Step ── */}
|
||||
{step === "form" && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<DialogTitle className="text-center text-base font-semibold">
|
||||
{isHousehold
|
||||
? "Tambah Data Warga Miskin"
|
||||
: `Tambah ${MARKER_LABELS[markerType] ?? markerType}`}
|
||||
</DialogTitle>
|
||||
{pendingClick && (
|
||||
<p className="text-center font-mono text-[10px] text-muted-foreground mt-0.5">
|
||||
{pendingClick.lat.toFixed(6)}, {pendingClick.lng.toFixed(6)}
|
||||
</p>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="flex flex-col gap-5 px-6 py-5">
|
||||
|
||||
{isHousehold ? (
|
||||
<>
|
||||
{/* Row 1: Nama KK */}
|
||||
<Field label="Nama Kepala Keluarga">
|
||||
<Input
|
||||
placeholder="cth. Budi Santoso"
|
||||
value={headName}
|
||||
onChange={(e) => setHeadName(e.target.value)}
|
||||
required
|
||||
className="bg-muted/50 border border-border focus-visible:border-ring"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Row 2: Penghasilan + Tanggungan */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Penghasilan / Bulan">
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-2.5 text-xs text-muted-foreground font-semibold">Rp</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={penghasilan ? penghasilan.toLocaleString("id-ID") : ""}
|
||||
onChange={(e) => {
|
||||
const clean = e.target.value.replace(/\D/g, "");
|
||||
setPenghasilan(clean ? parseInt(clean, 10) : 0);
|
||||
}}
|
||||
className="pl-9 bg-muted/50 border border-border focus-visible:border-ring"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Jumlah Tanggungan (jiwa)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={familyCount}
|
||||
onChange={(e) => setFamilyCount(Number(e.target.value))}
|
||||
className="bg-muted/50 border border-border focus-visible:border-ring"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Sliders */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Kondisi Rumah">
|
||||
<AdaptiveSlider
|
||||
value={kondisiRumah}
|
||||
onChange={setKondisiRumah}
|
||||
hint="1 = sangat buruk · 10 = sangat baik"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kepemilikan Aset">
|
||||
<AdaptiveSlider
|
||||
value={kepemilikanAset}
|
||||
onChange={setKepemilikanAset}
|
||||
hint="1 = tidak ada · 10 = banyak"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Catatan */}
|
||||
<Field label="Catatan">
|
||||
<Textarea
|
||||
placeholder="Informasi tambahan (opsional)"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="min-h-[72px] bg-muted/50 border border-border focus-visible:border-ring resize-none"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label="Nama Tempat">
|
||||
<Input
|
||||
placeholder={MARKER_LABELS[markerType] ?? "Nama lokasi"}
|
||||
value={placeName}
|
||||
onChange={(e) => setPlaceName(e.target.value)}
|
||||
className="bg-muted/50 border border-border focus-visible:border-ring"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || (isHousehold && !headName.trim())}
|
||||
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40"
|
||||
>
|
||||
{isSubmitting ? "Menyimpan…" : "Simpan & Pasang Marker"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SearchIcon } from "@/components/ui/phosphor-icons";
|
||||
import type { HouseholdRow } from "@/lib/poverty-types";
|
||||
|
||||
const SCORE_COLOR: Record<string, string> = {
|
||||
"SANGAT TINGGI": "text-red-600",
|
||||
"TINGGI": "text-orange-600",
|
||||
"SEDANG": "text-amber-600",
|
||||
"RENDAH": "text-emerald-600",
|
||||
"TIDAK PRIORITAS": "text-blue-600",
|
||||
};
|
||||
|
||||
interface PovertyDirectoryProps {
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
households: HouseholdRow[];
|
||||
selectedId: number | null;
|
||||
onSelect: (household: HouseholdRow) => void;
|
||||
}
|
||||
|
||||
export function PovertyDirectory({
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
households,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: PovertyDirectoryProps) {
|
||||
const filtered = households
|
||||
.filter((h) => h.head_name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
.sort((a, b) => Number(b.fuzzy_score ?? -1) - Number(a.fuzzy_score ?? -1));
|
||||
|
||||
return (
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
|
||||
<CardHeader className="py-4">
|
||||
<CardTitle className="text-base font-semibold">Direktori Warga Prioritas</CardTitle>
|
||||
<CardDescription>Diurutkan dari skor fuzzy tertinggi. Pilih warga untuk melihat detail di panel inspektur.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-6 flex-1">
|
||||
<div className="relative mb-4">
|
||||
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Cari nama kepala keluarga..."
|
||||
className="pl-9 bg-background h-10"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{households.length === 0
|
||||
? "Belum ada warga terdata. Tambah marker di peta untuk memulai."
|
||||
: "Tidak ada warga yang cocok dengan pencarian."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2 max-h-[320px] overflow-y-auto pr-1">
|
||||
{filtered.map((h) => {
|
||||
const isSelected = selectedId === h.id;
|
||||
const score = h.fuzzy_score != null ? Number(h.fuzzy_score).toFixed(1) : "—";
|
||||
return (
|
||||
<div
|
||||
key={h.id}
|
||||
onClick={() => onSelect(h)}
|
||||
className={`p-3 border rounded-lg cursor-pointer flex justify-between items-center transition-all ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 text-primary font-semibold"
|
||||
: "border-border/60 hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs text-foreground font-medium truncate me-2">{h.head_name}</span>
|
||||
<span className={`text-xs bg-muted border px-2 py-0.5 rounded font-bold tabular-nums shrink-0 ${SCORE_COLOR[h.fuzzy_label ?? ""] ?? "text-muted-foreground"}`}>
|
||||
{score}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { DashboardCard } from "@/components/dashboard-card";
|
||||
import { ArrowRightIcon, ArrowUpIcon } from "@/components/ui/phosphor-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HouseholdRow } from "@/lib/poverty-types";
|
||||
|
||||
const PREVIEW_COUNT = 5;
|
||||
|
||||
function PovertyBadge({ level }: { level: string }) {
|
||||
if (level === "Extreme") {
|
||||
return (
|
||||
<Badge className="bg-red-100 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-800">
|
||||
Ekstrem
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (level === "Miskin") {
|
||||
return (
|
||||
<Badge className="bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-800">
|
||||
Miskin
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge className="bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-400 dark:border-blue-800">
|
||||
Rentan
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function FuzzyPriorityBadge({ label }: { label: string | null }) {
|
||||
if (!label) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const colors: Record<string, string> = {
|
||||
"SANGAT TINGGI": "bg-red-100 text-red-700 border-red-200",
|
||||
"TINGGI": "bg-orange-100 text-orange-700 border-orange-200",
|
||||
"SEDANG": "bg-amber-100 text-amber-700 border-amber-200",
|
||||
"RENDAH": "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
"TIDAK PRIORITAS": "bg-blue-100 text-blue-700 border-blue-200",
|
||||
};
|
||||
return (
|
||||
<Badge className={colors[label] ?? "bg-muted text-muted-foreground"}>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreBar({ value }: { value: number }) {
|
||||
const pct = Math.round((value / 10) * 100);
|
||||
const color =
|
||||
value <= 3 ? "bg-red-400" : value <= 6 ? "bg-amber-400" : "bg-emerald-400";
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-14 h-1.5 rounded-full bg-border overflow-hidden">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{value}/10</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PovertyHouseholdsTable({
|
||||
rows,
|
||||
loading = false,
|
||||
}: {
|
||||
rows: HouseholdRow[];
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const visible = showAll ? rows : rows.slice(0, PREVIEW_COUNT);
|
||||
const hasMore = rows.length > PREVIEW_COUNT;
|
||||
|
||||
return (
|
||||
<DashboardCard className="relative gap-0 h-full flex flex-col">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-base">Data Warga Miskin</CardTitle>
|
||||
<CardDescription>
|
||||
{loading ? "Memuat data…" : `${rows.length} entri tersimpan dari peta · skor prioritas fuzzy`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className={cn("px-0 flex-1", !showAll && hasMore && "mask-b-from-50% mask-b-to-100%")}>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12 text-xs text-muted-foreground">
|
||||
Memuat data...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && rows.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12 text-xs text-muted-foreground">
|
||||
Belum ada data. Tambah marker di peta untuk mencatat warga miskin.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && rows.length > 0 && (
|
||||
<Table>
|
||||
<TableCaption className="sr-only">
|
||||
Data warga miskin dengan skor prioritas fuzzy.
|
||||
</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="ps-6 w-8">#</TableHead>
|
||||
<TableHead>Kepala Keluarga</TableHead>
|
||||
<TableHead className="text-center">Jiwa</TableHead>
|
||||
<TableHead>Rumah</TableHead>
|
||||
<TableHead>Aset</TableHead>
|
||||
<TableHead>Tingkat</TableHead>
|
||||
<TableHead className="text-right">Penghasilan</TableHead>
|
||||
<TableHead className="text-right tabular-nums">Skor Fuzzy</TableHead>
|
||||
<TableHead className="pe-6">Prioritas</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visible.map((row, i) => (
|
||||
<TableRow className="h-12" key={row.id}>
|
||||
<TableCell className="ps-6 text-xs text-muted-foreground tabular-nums">
|
||||
{i + 1}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium max-w-[160px] truncate">
|
||||
{row.head_name}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums text-muted-foreground">
|
||||
{row.family_count}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ScoreBar value={Number(row.kondisi_rumah)} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ScoreBar value={Number(row.kepemilikan_aset)} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PovertyBadge level={row.poverty_level} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">
|
||||
Rp {Number(row.penghasilan).toLocaleString("id-ID")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">
|
||||
{row.fuzzy_score != null ? Number(row.fuzzy_score).toFixed(1) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="pe-6">
|
||||
<FuzzyPriorityBadge label={row.fuzzy_label} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* View All overlay — only when collapsed and there are hidden rows */}
|
||||
{!showAll && hasMore && (
|
||||
<div className="mask-t-from-30% absolute inset-x-0 bottom-0 flex h-1/5 items-center justify-center bg-background">
|
||||
<Button variant="ghost" onClick={() => setShowAll(true)}>
|
||||
Lihat Semua ({rows.length})
|
||||
<ArrowRightIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapse button — only when expanded */}
|
||||
{showAll && rows.length > PREVIEW_COUNT && (
|
||||
<div className="flex items-center justify-center border-t py-3">
|
||||
<Button variant="ghost" onClick={() => setShowAll(false)}>
|
||||
<ArrowUpIcon aria-hidden="true" />
|
||||
Tutup
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const LEGEND = [
|
||||
{ label: "Sangat Tinggi", color: "#ef4444" },
|
||||
{ label: "Tinggi", color: "#f97316" },
|
||||
{ label: "Sedang", color: "#f59e0b" },
|
||||
{ label: "Rendah", color: "#10b981" },
|
||||
{ label: "Tidak Prioritas", color: "#6b7280" },
|
||||
];
|
||||
|
||||
export function PovertyHud() {
|
||||
return (
|
||||
<>
|
||||
{/* Floating Legend — fuzzy priority marker colors */}
|
||||
<div className="absolute bottom-6 left-4 z-[999] bg-background/90 backdrop-blur-md border border-border/40 p-3.5 rounded-lg shadow-lg text-xs flex flex-col gap-2">
|
||||
<span className="font-bold text-foreground">Prioritas Fuzzy</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{LEGEND.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: item.color }} />
|
||||
<span className="text-muted-foreground text-[10px]">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { HouseholdRow } from "@/lib/poverty-types";
|
||||
|
||||
const LABEL_BADGE: Record<string, string> = {
|
||||
"SANGAT TINGGI": "bg-red-100 text-red-700 border-red-200",
|
||||
"TINGGI": "bg-orange-100 text-orange-700 border-orange-200",
|
||||
"SEDANG": "bg-amber-100 text-amber-700 border-amber-200",
|
||||
"RENDAH": "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
"TIDAK PRIORITAS": "bg-blue-100 text-blue-700 border-blue-200",
|
||||
};
|
||||
|
||||
interface PovertyInspectorTopProps {
|
||||
selected: HouseholdRow | null;
|
||||
}
|
||||
|
||||
export function PovertyInspectorTop({ selected }: PovertyInspectorTopProps) {
|
||||
return (
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col h-full">
|
||||
<CardHeader className="border-b bg-muted/20 px-6 py-4 flex flex-col justify-center">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-primary" />
|
||||
Inspektur Warga
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{selected ? `Analisis untuk ${selected.head_name}.` : "Pilih warga dari direktori."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-6 py-6 flex-1 flex flex-col justify-center">
|
||||
{selected ? (
|
||||
<>
|
||||
<h3 className="text-2xl font-bold text-foreground truncate">{selected.head_name}</h3>
|
||||
<span className={`inline-block mt-2 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border w-fit ${LABEL_BADGE[selected.fuzzy_label ?? ""] ?? "bg-muted text-muted-foreground border-border"}`}>
|
||||
{selected.fuzzy_label ?? "Belum dianalisis"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Belum ada warga dipilih.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface PovertyInspectorBottomProps {
|
||||
selected: HouseholdRow | null;
|
||||
}
|
||||
|
||||
export function PovertyInspectorBottom({ selected }: PovertyInspectorBottomProps) {
|
||||
const score = selected?.fuzzy_score != null ? Number(selected.fuzzy_score).toFixed(1) : "—";
|
||||
|
||||
return (
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
|
||||
<CardContent className="px-0 py-6 flex flex-col justify-between flex-1">
|
||||
<div className="grid grid-cols-2 gap-4 px-6">
|
||||
<div className="p-3 rounded-lg border bg-muted/10">
|
||||
<span className="text-xs text-muted-foreground">Skor Fuzzy</span>
|
||||
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">{score}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg border bg-muted/10">
|
||||
<span className="text-xs text-muted-foreground">Penghasilan</span>
|
||||
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">
|
||||
{selected ? `Rp ${Number(selected.penghasilan).toLocaleString("id-ID")}` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg border bg-muted/10">
|
||||
<span className="text-xs text-muted-foreground">Tanggungan</span>
|
||||
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">
|
||||
{selected ? `${selected.family_count} jiwa` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg border bg-muted/10">
|
||||
<span className="text-xs text-muted-foreground">Rumah · Aset</span>
|
||||
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">
|
||||
{selected ? `${selected.kondisi_rumah} · ${selected.kepemilikan_aset}` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t mt-6 pt-6 px-6 text-xs text-muted-foreground flex flex-col gap-2">
|
||||
<p>
|
||||
Skor fuzzy 0–100 dihitung dari penghasilan, tanggungan, kondisi rumah, dan kepemilikan aset. Semakin tinggi skor, semakin prioritas untuk bantuan.
|
||||
</p>
|
||||
<p>
|
||||
Kondisi rumah dan kepemilikan aset dinilai pada skala 1–10 saat pendataan di peta.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { PovertyOverview } from "@/lib/poverty-types";
|
||||
|
||||
interface PovertyStatsCardsProps {
|
||||
overview: PovertyOverview | null;
|
||||
}
|
||||
|
||||
export function PovertyStatsCards({ overview }: PovertyStatsCardsProps) {
|
||||
const totals = overview?.totals;
|
||||
const byLabel = overview?.by_label ?? {};
|
||||
const highPriority = (byLabel["SANGAT TINGGI"] ?? 0) + (byLabel["TINGGI"] ?? 0);
|
||||
const poiTotal = Object.values(overview?.poi ?? {}).reduce((s, n) => s + n, 0);
|
||||
const avgDependents =
|
||||
totals && totals.household_count > 0
|
||||
? (totals.total_dependents / totals.household_count).toFixed(1)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<span className="text-xs font-medium text-muted-foreground">Warga Terdata</span>
|
||||
<div className="text-3xl font-bold text-foreground mt-2">
|
||||
{totals ? totals.household_count : "—"} <span className="text-base font-semibold text-muted-foreground">KK</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
|
||||
{poiTotal} fasilitas umum terpetakan
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<span className="text-xs font-medium text-muted-foreground">Total Tanggungan</span>
|
||||
<div className="text-3xl font-bold text-foreground mt-2">
|
||||
{totals ? totals.total_dependents : "—"} <span className="text-base font-semibold text-muted-foreground">jiwa</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
|
||||
{avgDependents ? `rata-rata ${avgDependents} jiwa per KK` : "belum ada data"}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<span className="text-xs font-medium text-muted-foreground">Rata-rata Penghasilan</span>
|
||||
<div className="text-3xl font-bold text-foreground mt-2">
|
||||
{totals ? `Rp ${Math.round(totals.avg_income).toLocaleString("id-ID")}` : "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
|
||||
per kepala keluarga per bulan
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<span className="text-xs font-medium text-muted-foreground">Rata-rata Skor Fuzzy</span>
|
||||
<div className="text-3xl font-bold text-foreground mt-2">
|
||||
{totals?.avg_score != null ? Number(totals.avg_score).toFixed(1) : "—"}
|
||||
<span className="text-base font-semibold text-muted-foreground"> / 100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-6 py-3 text-xs mt-auto">
|
||||
<span className="font-medium text-rose-500">{highPriority} KK</span>
|
||||
<span className="text-muted-foreground"> prioritas tinggi ke atas</span>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
|
||||
import type { PovertyOverview } from "@/lib/poverty-types";
|
||||
|
||||
const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"];
|
||||
|
||||
function formatMonth(ym: string) {
|
||||
const [year, month] = ym.split("-");
|
||||
return `${MONTH_NAMES[Number(month) - 1] ?? month} '${year.slice(2)}`;
|
||||
}
|
||||
|
||||
interface PovertyTrendChartProps {
|
||||
monthly: PovertyOverview["monthly"];
|
||||
}
|
||||
|
||||
export function PovertyTrendChart({ monthly }: PovertyTrendChartProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const chartData = monthly.map((m) => ({
|
||||
month: formatMonth(m.month),
|
||||
pendataan: m.count,
|
||||
skor: m.avg_score != null ? Number(Number(m.avg_score).toFixed(1)) : null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">Tren Pendataan Warga</CardTitle>
|
||||
<CardDescription>Jumlah pendataan baru dan rata-rata skor prioritas fuzzy per bulan, dari basis data.</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 w-full pt-4 min-h-[320px] relative">
|
||||
{chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-xs text-muted-foreground">
|
||||
Belum ada data pendataan. Tambah marker warga di peta untuk memulai.
|
||||
</div>
|
||||
) : !isMounted ? (
|
||||
<div className="h-full w-full" />
|
||||
) : (
|
||||
<div className="absolute inset-0 pt-4 px-6 pb-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 10, right: 15, left: -15, bottom: 0 }}>
|
||||
<XAxis dataKey="month" tickLine={false} style={{ fontSize: 11 }} stroke="#999ba3" />
|
||||
<YAxis tickLine={false} style={{ fontSize: 11 }} stroke="#999ba3" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "rgba(12, 10, 8, 0.95)",
|
||||
border: "1px solid #4d505d",
|
||||
borderRadius: "6px",
|
||||
fontSize: "12px",
|
||||
color: "#ffffff"
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="skor"
|
||||
stroke="#ff3b1f"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: "#ff3b1f", r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
name="Rata-rata Skor Fuzzy"
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="pendataan"
|
||||
stroke="#5683d2"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#5683d2", r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
name="Pendataan Baru (KK)"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
interface RadiusSliderProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export function RadiusSlider({ value, onChange }: RadiusSliderProps) {
|
||||
const pct = (value / 2000) * 100;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={2000}
|
||||
step={50}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
style={{
|
||||
background: value === 0
|
||||
? "var(--border)"
|
||||
: `linear-gradient(to right, #10b981 0%, #10b981 ${pct}%, var(--border) ${pct}%, var(--border) 100%)`,
|
||||
}}
|
||||
className="
|
||||
flex-1 h-[3px] cursor-pointer appearance-none rounded-full outline-none
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-white
|
||||
[&::-webkit-slider-thumb]:shadow
|
||||
[&::-webkit-slider-thumb]:border
|
||||
[&::-webkit-slider-thumb]:border-border
|
||||
[&::-moz-range-thumb]:w-3
|
||||
[&::-moz-range-thumb]:h-3
|
||||
[&::-moz-range-thumb]:rounded-full
|
||||
[&::-moz-range-thumb]:bg-white
|
||||
[&::-moz-range-thumb]:border
|
||||
[&::-moz-range-thumb]:border-border
|
||||
"
|
||||
/>
|
||||
<span
|
||||
className="w-12 shrink-0 text-center text-xs font-bold tabular-nums"
|
||||
style={{ color: value === 0 ? "var(--muted-foreground)" : "#10b981" }}
|
||||
>
|
||||
{value === 0 ? "—" : `${value}m`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">0 = tidak tampilkan lingkaran</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { PhosphorIcon } from "@/components/ui/collection-grid-disclosure";
|
||||
import { ChevronDownIcon } from "@/components/ui/phosphor-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RadiusSlider } from "@/components/poverty/radius-slider";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
|
||||
|
||||
const RELIGION_OPTIONS = [
|
||||
{ value: "mosque", label: "Masjid (Islam)", icon: "mosque" },
|
||||
{ value: "church", label: "Gereja (Kristen)", icon: "church" },
|
||||
{ value: "cathedral", label: "Gereja Katolik", icon: "crown-cross" },
|
||||
{ value: "temple", label: "Pura (Hindu)", icon: "hands-praying" },
|
||||
{ value: "vihara", label: "Vihara (Buddha)", icon: "hands-praying" },
|
||||
{ value: "klenteng", label: "Klenteng (Konghucu)", icon: "hands-praying" },
|
||||
{ value: "synagogue", label: "Sinagog (Yahudi)", icon: "synagogue" },
|
||||
];
|
||||
|
||||
interface PendingClick { lat: number; lng: number; }
|
||||
|
||||
interface ReligionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pendingClick: PendingClick | null;
|
||||
onConfirm: (marker: UserMarker) => void;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReligionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
pendingClick,
|
||||
onConfirm,
|
||||
}: ReligionDialogProps) {
|
||||
const [religionType, setReligionType] = useState<string | null>(null);
|
||||
const [placeName, setPlaceName] = useState("");
|
||||
const [radiusMeters, setRadiusMeters] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedReligion = RELIGION_OPTIONS.find((o) => o.value === religionType);
|
||||
|
||||
function reset() {
|
||||
setReligionType(null);
|
||||
setPlaceName("");
|
||||
setRadiusMeters(0);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!pendingClick || !religionType) return;
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const label = RELIGION_OPTIONS.find((o) => o.value === religionType)?.label ?? religionType;
|
||||
const name = placeName.trim() || label;
|
||||
|
||||
const res = await fetch(`${BACKEND_URL}/poi`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
poi_type: "religion",
|
||||
religion_subtype: religionType,
|
||||
latitude: pendingClick.lat,
|
||||
longitude: pendingClick.lng,
|
||||
radius_meters: radiusMeters,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error ?? "Gagal menyimpan data");
|
||||
}
|
||||
|
||||
const saved = await res.json();
|
||||
onConfirm({
|
||||
id: `poi-${saved.id}`,
|
||||
lat: pendingClick.lat,
|
||||
lng: pendingClick.lng,
|
||||
type: religionType,
|
||||
name,
|
||||
meta: { poi_type: "religion", notes: saved.notes ?? undefined, radius: saved.radius_meters ?? 0 },
|
||||
});
|
||||
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="sm:max-w-sm w-full rounded-2xl p-0 gap-0"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<DialogTitle className="text-center text-base font-semibold">
|
||||
Tambah Tempat Ibadah
|
||||
</DialogTitle>
|
||||
{pendingClick && (
|
||||
<p className="text-center font-mono text-[10px] text-muted-foreground mt-0.5">
|
||||
{pendingClick.lat.toFixed(6)}, {pendingClick.lng.toFixed(6)}
|
||||
</p>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-5 px-6 py-5">
|
||||
<Field label="Jenis Tempat Ibadah">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm transition-colors hover:bg-muted focus-visible:border-ring focus-visible:outline-none"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{selectedReligion?.icon && (
|
||||
<span className="size-4 shrink-0 text-foreground">
|
||||
<PhosphorIcon name={selectedReligion.icon} className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("truncate", !selectedReligion && "text-muted-foreground")}>
|
||||
{selectedReligion ? selectedReligion.label : "Pilih jenis…"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-(--anchor-width)">
|
||||
<DropdownMenuRadioGroup
|
||||
value={religionType ?? ""}
|
||||
onValueChange={setReligionType}
|
||||
>
|
||||
{RELIGION_OPTIONS.map((opt) => (
|
||||
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
|
||||
<span className="size-4 shrink-0 text-foreground">
|
||||
<PhosphorIcon name={opt.icon} className="size-4" />
|
||||
</span>
|
||||
{opt.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Field>
|
||||
|
||||
<Field label="Nama Tempat Ibadah">
|
||||
<Input
|
||||
placeholder="cth. Masjid Al-Ikhlas"
|
||||
value={placeName}
|
||||
onChange={(e) => setPlaceName(e.target.value)}
|
||||
className="bg-muted/50 border border-border focus-visible:border-ring"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Radius Jangkauan (meter)">
|
||||
<RadiusSlider value={radiusMeters} onChange={setRadiusMeters} />
|
||||
</Field>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !religionType}
|
||||
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40"
|
||||
>
|
||||
{isSubmitting ? "Menyimpan…" : "Simpan & Pasang Marker"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { DashboardCard } from "@/components/dashboard-card";
|
||||
import { CircleCheckIcon, ArrowRightIcon } from "@/components/ui/phosphor-icons";
|
||||
|
||||
interface PriorityBreakdownProps {
|
||||
byLabel: Record<string, number> | null;
|
||||
}
|
||||
|
||||
const LABELS: { key: string; label: string; dotClass: string }[] = [
|
||||
{ key: "SANGAT TINGGI", label: "Sangat Tinggi", dotClass: "bg-rose-500" },
|
||||
{ key: "TINGGI", label: "Tinggi", dotClass: "bg-orange-500" },
|
||||
{ key: "SEDANG", label: "Sedang", dotClass: "bg-amber-500" },
|
||||
{ key: "RENDAH", label: "Rendah", dotClass: "bg-sky-500" },
|
||||
{ key: "TIDAK PRIORITAS", label: "Tidak Prioritas", dotClass: "bg-emerald-500" },
|
||||
];
|
||||
|
||||
export function PriorityBreakdown({ byLabel }: PriorityBreakdownProps) {
|
||||
const total = LABELS.reduce((sum, l) => sum + (byLabel?.[l.key] ?? 0), 0);
|
||||
|
||||
return (
|
||||
<DashboardCard className="gap-0">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-balance text-base">
|
||||
Prioritas Bantuan
|
||||
</CardTitle>
|
||||
<CardDescription className="text-pretty">
|
||||
Distribusi skor fuzzy seluruh KK terdata.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-full flex-col px-0">
|
||||
{total === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<CircleCheckIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Belum ada skor fuzzy.</EmptyTitle>
|
||||
<EmptyDescription className="text-xs">
|
||||
Tambahkan data warga atau jalankan perhitungan ulang skor di
|
||||
halaman kemiskinan.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
render={<a href="/dashboard/poverty" />}
|
||||
nativeButton={false}
|
||||
>
|
||||
Kelola data warga
|
||||
<ArrowRightIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{LABELS.map((l) => {
|
||||
const count = byLabel?.[l.key] ?? 0;
|
||||
const pct = total > 0 ? (count / total) * 100 : 0;
|
||||
return (
|
||||
<li className="flex flex-col gap-1.5 px-6 py-3" key={l.key}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 text-sm text-foreground">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`size-2 shrink-0 rounded-full ${l.dotClass}`}
|
||||
/>
|
||||
{l.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{count} KK
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full ${l.dotClass}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</DashboardCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { CartesianGrid, Line, LineChart, XAxis } from "recharts";
|
||||
import { formatDate } from "@/components/formater";
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { Delta, DeltaIcon, DeltaValue } from "@/components/delta";
|
||||
import { DashboardCard } from "@/components/dashboard-card";
|
||||
import type { PovertyOverview } from "@/lib/poverty-types";
|
||||
|
||||
interface PriorityScoreChartProps {
|
||||
monthly: PovertyOverview["monthly"];
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
score: {
|
||||
label: "Skor Rata-rata",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
count: {
|
||||
label: "KK Terdata",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function PriorityScoreChart({ monthly }: PriorityScoreChartProps) {
|
||||
const chartUid = useId().replace(/:/g, "");
|
||||
const idLineGlow = `priority-score-line-glow-${chartUid}`;
|
||||
|
||||
const chartRows = monthly.map((m) => ({
|
||||
month: m.month,
|
||||
score: m.avg_score != null ? Number(Number(m.avg_score).toFixed(1)) : null,
|
||||
count: m.count,
|
||||
}));
|
||||
|
||||
const scored = chartRows.filter((r) => r.score != null);
|
||||
const firstScore = scored[0]?.score ?? 0;
|
||||
const lastScore = scored.at(-1)?.score ?? 0;
|
||||
const growthPct = firstScore > 0 ? ((lastScore - firstScore) / firstScore) * 100 : 0;
|
||||
|
||||
return (
|
||||
<DashboardCard className="gap-0 md:col-span-2">
|
||||
<CardHeader>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CardTitle>Tren Skor Prioritas</CardTitle>
|
||||
{scored.length > 1 && (
|
||||
<Delta value={Number(growthPct.toFixed(1))} variant="badge">
|
||||
<DeltaIcon variant="trend" />
|
||||
<DeltaValue />
|
||||
</Delta>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Skor fuzzy rata-rata dan jumlah KK terdata per bulan.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartRows.length === 0 ? (
|
||||
<div className="flex h-60 w-full items-center justify-center text-xs text-muted-foreground md:h-80">
|
||||
Belum ada skor fuzzy yang dihitung.
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer
|
||||
className="aspect-auto h-60 w-full p-0 md:h-80"
|
||||
config={chartConfig}
|
||||
>
|
||||
<LineChart
|
||||
accessibilityLayer
|
||||
data={chartRows}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
top: 8,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid className="stroke-border" vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="month"
|
||||
interval={0}
|
||||
tickFormatter={(value) => formatDate(`${value}-01`, "month")}
|
||||
tickLine={false}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent hideLabel />}
|
||||
cursor={false}
|
||||
/>
|
||||
<defs>
|
||||
<filter
|
||||
height="140%"
|
||||
id={idLineGlow}
|
||||
width="140%"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
>
|
||||
<feGaussianBlur result="blur" stdDeviation="10" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
<Line
|
||||
dataKey="score"
|
||||
dot={false}
|
||||
filter={`url(#${idLineGlow})`}
|
||||
stroke="var(--color-score)"
|
||||
strokeWidth={2}
|
||||
type="step"
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
dataKey="count"
|
||||
dot={false}
|
||||
filter={`url(#${idLineGlow})`}
|
||||
stroke="var(--color-count)"
|
||||
strokeWidth={2}
|
||||
type="step"
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</DashboardCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { DashboardCard } from "@/components/dashboard-card";
|
||||
import { ArrowRightIcon } from "@/components/ui/phosphor-icons";
|
||||
import type { HouseholdRow } from "@/lib/poverty-types";
|
||||
|
||||
interface RecentHouseholdsProps {
|
||||
households: HouseholdRow[];
|
||||
}
|
||||
|
||||
export function RecentHouseholds({ households }: RecentHouseholdsProps) {
|
||||
return (
|
||||
<DashboardCard className="relative gap-0 md:col-span-2">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-base">Pendataan Terbaru</CardTitle>
|
||||
<CardDescription>
|
||||
Warga terbaru yang masuk basis data kemiskinan.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mask-b-from-50% mask-b-to-100% px-0">
|
||||
{households.length === 0 ? (
|
||||
<div className="flex h-48 items-center justify-center text-xs text-muted-foreground">
|
||||
Belum ada data warga.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableCaption className="sr-only">
|
||||
Warga terbaru dengan nama kepala keluarga, prioritas, dan
|
||||
penghasilan.
|
||||
</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="ps-6">Kepala Keluarga</TableHead>
|
||||
<TableHead>Prioritas</TableHead>
|
||||
<TableHead className="pe-6 text-right tabular-nums">
|
||||
Penghasilan
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{households.map((h) => (
|
||||
<TableRow className="h-12" key={h.id}>
|
||||
<TableCell className="max-w-40 truncate ps-6 font-medium">
|
||||
{h.head_name}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{h.fuzzy_label ?? h.poverty_level}
|
||||
</TableCell>
|
||||
<TableCell className="pe-6 text-right tabular-nums">
|
||||
Rp {h.penghasilan.toLocaleString("id-ID")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
<div className="mask-t-from-30% absolute inset-x-0 bottom-0 flex h-1/5 items-center justify-center bg-background">
|
||||
<Button
|
||||
className="relative"
|
||||
variant="ghost"
|
||||
render={<a href="/dashboard/poverty" />}
|
||||
nativeButton={false}
|
||||
>
|
||||
Lihat Semua
|
||||
<ArrowRightIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</DashboardCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from '@/components/ui/breadcrumb'
|
||||
import { HouseIcon, CaretDoubleRightIcon, GlobeHemisphereWestIcon, FileTextIcon } from '@/components/ui/phosphor-icons'
|
||||
|
||||
const BreadcrumbChevronsSeparatorDemo = () => {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href='#'>
|
||||
<HouseIcon className='size-4' />
|
||||
<span className='sr-only'>Home</span>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator>
|
||||
<CaretDoubleRightIcon />
|
||||
</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href='#' className='flex items-center gap-1'>
|
||||
<GlobeHemisphereWestIcon className='size-4' />
|
||||
Documents
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator>
|
||||
<CaretDoubleRightIcon />
|
||||
</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className='flex items-center gap-1'>
|
||||
<FileTextIcon className='inline size-4' />
|
||||
Add Document
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
|
||||
export default BreadcrumbChevronsSeparatorDemo
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { DashboardCard } from "@/components/dashboard-card";
|
||||
import type { GasStationStats } from "@/lib/dashboard-types";
|
||||
import type { PovertyOverview } from "@/lib/poverty-types";
|
||||
|
||||
interface DashboardStatsProps {
|
||||
overview: PovertyOverview | null;
|
||||
gasStats: GasStationStats | null;
|
||||
landMarkerCount: number;
|
||||
landShapeCount: number;
|
||||
}
|
||||
|
||||
type Stat = {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
footer: string;
|
||||
};
|
||||
|
||||
export function DashboardStats({
|
||||
overview,
|
||||
gasStats,
|
||||
landMarkerCount,
|
||||
landShapeCount,
|
||||
}: DashboardStatsProps) {
|
||||
const totals = overview?.totals;
|
||||
|
||||
const stats: Stat[] = [
|
||||
{
|
||||
label: "Warga Terdata",
|
||||
value: totals ? totals.household_count.toLocaleString("id-ID") : "—",
|
||||
unit: "KK",
|
||||
footer: totals
|
||||
? `${totals.total_dependents.toLocaleString("id-ID")} jiwa tanggungan`
|
||||
: "belum ada data warga",
|
||||
},
|
||||
{
|
||||
label: "Rata-rata Penghasilan",
|
||||
value: totals
|
||||
? `Rp ${Math.round(totals.avg_income).toLocaleString("id-ID")}`
|
||||
: "—",
|
||||
footer: "per kepala keluarga per bulan",
|
||||
},
|
||||
{
|
||||
label: "Aset Lahan",
|
||||
value: (landMarkerCount + landShapeCount).toLocaleString("id-ID"),
|
||||
unit: "item",
|
||||
footer: `${landMarkerCount} penanda · ${landShapeCount} bidang & garis`,
|
||||
},
|
||||
{
|
||||
label: "SPBU & Fasilitas",
|
||||
value: gasStats ? gasStats.total.toLocaleString("id-ID") : "—",
|
||||
unit: "titik",
|
||||
footer: gasStats
|
||||
? `${gasStats.gas_pump} SPBU · ${gasStats.charging_station} pengisi daya EV`
|
||||
: "belum ada fasilitas terpetakan",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{stats.map((s) => (
|
||||
<DashboardCard className="" key={s.label}>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="font-normal text-xs tracking-wide">
|
||||
{s.label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row items-baseline gap-1.5">
|
||||
<p className="font-semibold text-2xl tabular-nums">{s.value}</p>
|
||||
{s.unit && (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{s.unit}
|
||||
</span>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="gap-1 rounded-none bg-background text-xs">
|
||||
<span className="text-muted-foreground">{s.footer}</span>
|
||||
</CardFooter>
|
||||
</DashboardCard>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Undo2 } from 'lucide-react';
|
||||
import { useEffect, useState, type FC, type ReactNode } from 'react';
|
||||
import { AnimatePresence, motion, MotionConfig } from 'motion/react';
|
||||
import useMeasure from 'react-use-measure';
|
||||
|
||||
export interface TimedUndoActionProps {
|
||||
initialSeconds?: number;
|
||||
deleteLabel?: string;
|
||||
undoLabel?: string;
|
||||
icon?: ReactNode;
|
||||
onConfirm?: () => void;
|
||||
compact?: boolean;
|
||||
/** Controlled mode: external isDeleting state */
|
||||
isDeleting?: boolean;
|
||||
/** Controlled mode: external countdown value */
|
||||
countDown?: number;
|
||||
/** Controlled mode: called when user clicks to toggle */
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export const TimedUndoAction: FC<TimedUndoActionProps> = ({
|
||||
initialSeconds = 10,
|
||||
deleteLabel = 'Delete Account',
|
||||
undoLabel = 'Cancel Delete',
|
||||
icon,
|
||||
onConfirm,
|
||||
compact = false,
|
||||
isDeleting: controlledIsDeleting,
|
||||
countDown: controlledCountDown,
|
||||
onToggle,
|
||||
}) => {
|
||||
const isControlled = controlledIsDeleting !== undefined;
|
||||
|
||||
const [uncontrolledIsDeleting, setUncontrolledIsDeleting] = useState(false);
|
||||
const [uncontrolledCountDown, setUncontrolledCountDown] = useState(initialSeconds);
|
||||
const [ref, bounds] = useMeasure({ offsetSize: true });
|
||||
|
||||
const isDeleting = isControlled ? controlledIsDeleting! : uncontrolledIsDeleting;
|
||||
const countDown = isControlled ? (controlledCountDown ?? initialSeconds) : uncontrolledCountDown;
|
||||
|
||||
const handleClick = () => {
|
||||
if (isControlled) {
|
||||
onToggle?.();
|
||||
} else {
|
||||
setUncontrolledIsDeleting((prev) => {
|
||||
const next = !prev;
|
||||
if (next) setUncontrolledCountDown(initialSeconds);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Uncontrolled: tick the countdown
|
||||
useEffect(() => {
|
||||
if (isControlled || !uncontrolledIsDeleting) return;
|
||||
const id = setInterval(() => {
|
||||
setUncontrolledCountDown((p) => Math.max(0, p - 1));
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [isControlled, uncontrolledIsDeleting]);
|
||||
|
||||
// Uncontrolled: fire onConfirm when countdown hits 0
|
||||
useEffect(() => {
|
||||
if (isControlled || !uncontrolledIsDeleting || uncontrolledCountDown > 0) return;
|
||||
setUncontrolledIsDeleting(false);
|
||||
setUncontrolledCountDown(initialSeconds);
|
||||
onConfirm?.();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isControlled, uncontrolledIsDeleting, uncontrolledCountDown]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center font-sans">
|
||||
<div className="flex flex-col items-center justify-center will-change-transform">
|
||||
<MotionConfig
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 250,
|
||||
damping: 22,
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative flex cursor-pointer items-center justify-start overflow-hidden rounded-full border border-red-500 bg-transparent transition-colors duration-300 hover:bg-red-500/5 dark:border-red-400 dark:hover:bg-red-400/5',
|
||||
compact && 'w-36 h-9',
|
||||
isDeleting && 'bg-red-500/10 border-red-500/20 hover:bg-red-500/10 dark:bg-red-500/20 dark:border-red-500/10 dark:hover:bg-red-500/20',
|
||||
)}
|
||||
animate={compact ? undefined : {
|
||||
width: bounds.width > 0 ? bounds.width : 'auto',
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2',
|
||||
compact ? 'w-full h-full px-1.5' : 'px-6 py-3',
|
||||
compact ? (isDeleting ? 'justify-between' : 'justify-center') : 'justify-center',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{isDeleting && (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"rounded-full bg-red-500 flex items-center justify-center shrink-0",
|
||||
compact ? "w-6 h-6" : "p-2"
|
||||
)}
|
||||
initial={{ opacity: 0, filter: 'blur(2px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, filter: 'blur(2px)' }}
|
||||
>
|
||||
{icon ?? <Undo2 className={cn(compact ? "size-3.5" : "size-5", "text-white")} />}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<AnimatedText
|
||||
text={isDeleting ? undoLabel : deleteLabel}
|
||||
className={cn(
|
||||
'z-10 font-medium',
|
||||
compact ? 'text-sm' : 'text-lg',
|
||||
'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
{isDeleting && (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full bg-red-500 text-neutral-50 tabular-nums shrink-0",
|
||||
compact ? "w-6 h-6 text-xs" : "px-3 py-1 text-neutral-50"
|
||||
)}
|
||||
initial={{ opacity: 0, filter: 'blur(2px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, filter: 'blur(2px)' }}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.span
|
||||
key={countDown}
|
||||
className={compact ? 'text-[11px]' : 'text-lg'}
|
||||
initial={{ opacity: 0, y: -20, filter: 'blur(2px)', scale: 0.5 }}
|
||||
animate={{ opacity: 1, y: 0, filter: 'blur(0px)', scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, filter: 'blur(2px)', scale: 0.5 }}
|
||||
transition={{ type: 'spring', stiffness: 240, damping: 20, mass: 1 }}
|
||||
>
|
||||
{countDown}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
</MotionConfig>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function AnimatedText({
|
||||
text,
|
||||
className,
|
||||
delayStep = 0.014,
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
delayStep?: number;
|
||||
}) {
|
||||
const chars = text.split('');
|
||||
|
||||
return (
|
||||
<span className={className} style={{ display: 'inline-flex' }}>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span key={text} style={{ display: 'inline-flex ', willChange: 'transform' }}>
|
||||
{chars.map((char, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ y: 10, opacity: 0, scale: 0.5, filter: 'blur(2px)' }}
|
||||
animate={{ y: 0, opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
||||
exit={{ y: -10, opacity: 0, scale: 0.5, filter: 'blur(2px)' }}
|
||||
transition={{ type: 'spring', stiffness: 240, damping: 16, mass: 1.2, delay: i * delayStep }}
|
||||
style={{ display: 'inline-block', whiteSpace: char === ' ' ? 'pre' : undefined }}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimedUndoAction;
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, type FC, type ChangeEvent } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AdaptiveSliderProps {
|
||||
value?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
defaultValue?: number;
|
||||
onChange?: (value: number) => void;
|
||||
}
|
||||
|
||||
interface ColorSettings {
|
||||
text: string;
|
||||
gradient: string;
|
||||
thumbBorder: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MIN = 50;
|
||||
const DEFAULT_MAX = 350;
|
||||
const DEFAULT_STEP = 25;
|
||||
const DEFAULT_VALUE = 200;
|
||||
|
||||
const getColorSettings = (
|
||||
value: number,
|
||||
min: number,
|
||||
max: number
|
||||
): ColorSettings => {
|
||||
const percentage = (value - min) / (max - min);
|
||||
|
||||
if (percentage < 0.5) {
|
||||
return {
|
||||
text: "#10B981",
|
||||
gradient: "linear-gradient(to right, #FEB101, #FE7C09)",
|
||||
thumbBorder: "#10B981",
|
||||
};
|
||||
} else if (percentage < 0.7) {
|
||||
return {
|
||||
text: "#FE55B7",
|
||||
gradient: "linear-gradient(to right, #FE55B74D, #FE55B7)",
|
||||
thumbBorder: "#F97316",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
text: "#D946EF",
|
||||
gradient: "linear-gradient(to right, #DAB0FE, #4946FF)",
|
||||
thumbBorder: "#D946EF",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const AdaptiveSlider: FC<AdaptiveSliderProps> = ({
|
||||
value,
|
||||
min = DEFAULT_MIN,
|
||||
max = DEFAULT_MAX,
|
||||
step = DEFAULT_STEP,
|
||||
defaultValue = DEFAULT_VALUE,
|
||||
onChange,
|
||||
}) => {
|
||||
const [internalValue, setInternalValue] = useState<number>(defaultValue);
|
||||
|
||||
const calories = value ?? internalValue;
|
||||
|
||||
const colorSettings = useMemo(
|
||||
() => getColorSettings(calories, min, max),
|
||||
[calories, min, max]
|
||||
);
|
||||
|
||||
const percentage = ((calories - min) / (max - min)) * 100;
|
||||
|
||||
const dots = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="z-30 h-1.5 w-1.5 rounded-full bg-[#C4B9FA] transition-colors dark:bg-neutral-600"
|
||||
style={{ opacity: 0.8 }}
|
||||
/>
|
||||
)),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSliderChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const val = Number(e.target.value);
|
||||
setInternalValue(val);
|
||||
onChange?.(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div className="flex h-[60vh] w-xs flex-col items-center justify-center rounded-[36px] bg-[#FEFEFE] p-6 shadow-2xl shadow-black/5 transition-colors select-none sm:w-sm sm:p-12 dark:bg-neutral-900 dark:shadow-none">
|
||||
<span className="mb-2 text-xl font-bold text-[#878787] sm:text-2xl dark:text-neutral-500">
|
||||
Calories
|
||||
</span>
|
||||
|
||||
<div className="mb-8 flex items-baseline gap-2">
|
||||
<AnimatedText
|
||||
value={calories.toString()}
|
||||
className="overflow-hidden text-5xl font-extrabold tracking-tight sm:text-6xl"
|
||||
/>
|
||||
<motion.span
|
||||
layout
|
||||
className="text-4xl font-extrabold text-[#010101] transition-colors sm:text-5xl dark:text-neutral-100"
|
||||
>
|
||||
kCal
|
||||
</motion.span>
|
||||
</div>
|
||||
|
||||
<div className="group relative flex h-13 w-full items-center overflow-hidden rounded-full bg-[#f1f3f5] transition-colors dark:bg-neutral-800">
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-between px-4 transition-colors sm:px-8">
|
||||
{dots}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="pointer-events-none absolute top-0 left-0 h-full rounded-full"
|
||||
animate={{
|
||||
width: `calc((${percentage} / 100) * (100% - 52px) + 52px)`,
|
||||
background: colorSettings.gradient,
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
|
||||
<input
|
||||
title="range"
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={calories}
|
||||
onChange={handleSliderChange}
|
||||
className="absolute inset-0 z-50 h-13 w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="pointer-events-none absolute top-0 z-40 flex size-13 items-center justify-center rounded-full border-none"
|
||||
animate={{
|
||||
left: `calc((${percentage} / 100) * (100% - 52px))`,
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
>
|
||||
<div className="size-10 rounded-full bg-white shadow-[inset_0_2px_4px_rgba(0,0,0,0.06)]" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const AnimatedText = ({
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex text-lg tracking-tight will-change-transform",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{value.split("").map((char, index) => {
|
||||
const displayChar = char === " " ? "\u00A0" : char;
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
key={char + index}
|
||||
initial={{ opacity: 1, y: 0, scale: 1 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 200,
|
||||
damping: 20,
|
||||
// delay: 0.03 * index,
|
||||
},
|
||||
}}
|
||||
exit={{ opacity: 0, y: 0, scale: 1, transition: { duration: 0 } }}
|
||||
>
|
||||
{displayChar}
|
||||
</motion.span>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, MoreHorizontalIcon } from "@/components/ui/phosphor-icons"
|
||||
|
||||
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
data-slot="breadcrumb"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"a">) {
|
||||
return useRender({
|
||||
defaultTagName: "a",
|
||||
props: mergeProps<"a">(
|
||||
{
|
||||
className: cn("transition-colors hover:text-foreground", className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "breadcrumb-link",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<ChevronRightIcon />
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex size-5 items-center justify-center [&>svg]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon
|
||||
/>
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-white py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client"
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import type { TooltipValueType } from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
||||
type TooltipNameType = number | string
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
initialDimension?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||
const [isMounted, setIsMounted] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
{isMounted ? (
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
initialDimension={initialDimension}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
) : (
|
||||
<div className="w-full h-full" />
|
||||
)}
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme ?? config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
TooltipNameType
|
||||
>,
|
||||
"accessibilityLayer"
|
||||
>) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||
|
||||
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -0,0 +1,540 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FC } from "react";
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
MotionConfig,
|
||||
type Transition,
|
||||
} from "framer-motion";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
// 1. PhosphorIcon mask component
|
||||
export interface PhosphorIconProps {
|
||||
name: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PhosphorIcon = ({ name, className }: PhosphorIconProps) => {
|
||||
const fileName = name.endsWith("-light") ? name : `${name}-light`;
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-block bg-current shrink-0", className)}
|
||||
style={{
|
||||
maskImage: `url(/light/${fileName}.svg)`,
|
||||
WebkitMaskImage: `url(/light/${fileName}.svg)`,
|
||||
maskSize: "contain",
|
||||
WebkitMaskSize: "contain",
|
||||
maskRepeat: "no-repeat",
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
maskPosition: "center",
|
||||
WebkitMaskPosition: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export interface CollectionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price?: number;
|
||||
unit?: string; // e.g. "jiwa", "KK" — shown after the value instead of a "$" prefix
|
||||
icon: string; // Phosphor icon name
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: string;
|
||||
name: string;
|
||||
items: CollectionItem[];
|
||||
}
|
||||
|
||||
interface DisclosureCardProps {
|
||||
collections: Collection[];
|
||||
compact?: boolean;
|
||||
onItemClick?: (itemId: string, collectionId: string) => void;
|
||||
dragDropEnabled?: boolean;
|
||||
}
|
||||
|
||||
const springConfig: Transition = {
|
||||
type: "spring",
|
||||
stiffness: 200,
|
||||
damping: 20,
|
||||
mass: 1.1,
|
||||
};
|
||||
|
||||
export const DisclosureCard: FC<DisclosureCardProps> = ({
|
||||
collections,
|
||||
compact = false,
|
||||
onItemClick,
|
||||
dragDropEnabled = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn("flex w-full items-center justify-center", compact && "w-auto")}>
|
||||
<motion.div
|
||||
className="flex flex-col gap-2 will-change-transform"
|
||||
layout="position"
|
||||
transition={springConfig}
|
||||
>
|
||||
{collections.map((collection) => (
|
||||
<GridContainer
|
||||
key={collection.id}
|
||||
title={collection.name}
|
||||
items={collection.items}
|
||||
compact={compact}
|
||||
collectionId={collection.id}
|
||||
onItemClick={onItemClick}
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Draggable wrapper for each expanded item
|
||||
const DraggableItem = ({
|
||||
item,
|
||||
collectionId,
|
||||
compact,
|
||||
onItemClick,
|
||||
dragDropEnabled = false,
|
||||
}: {
|
||||
item: CollectionItem;
|
||||
collectionId: string;
|
||||
compact: boolean;
|
||||
onItemClick?: (itemId: string, collectionId: string) => void;
|
||||
dragDropEnabled?: boolean;
|
||||
}) => {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: `${collectionId}::${item.id}`,
|
||||
data: { itemId: item.id, collectionId },
|
||||
disabled: !dragDropEnabled || collectionId === "maps-settings" || collectionId === "demographics",
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? { transform: CSS.Translate.toString(transform), zIndex: isDragging ? 9999 : undefined, opacity: isDragging ? 0.8 : undefined }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center gap-2 rounded-md p-1 transition-colors touch-none",
|
||||
onItemClick ? "cursor-grab active:cursor-grabbing hover:bg-zinc-200/30 dark:hover:bg-zinc-700/30" : ""
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isDragging) return;
|
||||
if (onItemClick) onItemClick(item.id, collectionId);
|
||||
}}
|
||||
>
|
||||
{item.isActive && (
|
||||
<motion.div
|
||||
layoutId={`active-highlight-${collectionId}`}
|
||||
className="absolute inset-0 bg-transparent rounded-md border border-primary/30 pointer-events-none"
|
||||
initial={false}
|
||||
transition={springConfig}
|
||||
/>
|
||||
)}
|
||||
<motion.div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full z-10 transition-colors",
|
||||
compact ? "size-7" : "size-10",
|
||||
item.isActive ? "bg-transparent text-primary" : "bg-transparent text-zinc-700 dark:text-zinc-300"
|
||||
)}
|
||||
layoutId={`${item.name}`}
|
||||
>
|
||||
<PhosphorIcon name={item.icon} className={cn("text-current", compact ? "size-3.5" : "size-5")} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="flex flex-1 flex-col items-start justify-center gap-0.5 leading-none z-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<motion.p className={cn("text-zinc-800 dark:text-zinc-100 font-medium", compact ? "text-[11px]" : "text-md")}>
|
||||
{item.name}
|
||||
</motion.p>
|
||||
{item.price !== undefined && item.price > 0 && (
|
||||
<div className="flex gap-1 text-zinc-400 dark:text-zinc-500">
|
||||
<p className={compact ? "text-[9px]" : "text-sm"}>
|
||||
{item.unit ? `${item.price.toLocaleString("id-ID")} ${item.unit}` : `$${item.price}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
<div className="z-10 flex items-center justify-center">
|
||||
{item.isActive ? (
|
||||
<PhosphorIcon name="check" className={cn("text-primary font-bold", compact ? "size-3.5" : "size-5")} />
|
||||
) : (
|
||||
<PhosphorIcon name="caret-right" className={cn("text-zinc-400 dark:text-zinc-500", compact ? "size-3.5" : "size-5")} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GridContainer = ({
|
||||
items,
|
||||
title,
|
||||
compact = false,
|
||||
collectionId,
|
||||
onItemClick,
|
||||
dragDropEnabled = false,
|
||||
}: {
|
||||
items: CollectionItem[];
|
||||
title: string;
|
||||
compact?: boolean;
|
||||
collectionId: string;
|
||||
onItemClick?: (itemId: string, collectionId: string) => void;
|
||||
dragDropEnabled?: boolean;
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [ref, bounds] = useMeasure({ offsetSize: true });
|
||||
|
||||
const cardWidth = compact ? "w-[220px]" : "w-[300px]";
|
||||
const cardBg = compact
|
||||
? "border-border/60 bg-background/95 backdrop-blur-md shadow-md"
|
||||
: "border-gray-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800";
|
||||
const titleSize = compact ? "text-xs font-semibold text-foreground" : "text-lg text-zinc-900 dark:text-zinc-100";
|
||||
const subtitleSize = compact ? "text-[10px] text-muted-foreground" : "text-sm text-zinc-500 dark:text-zinc-400";
|
||||
const circleSize = compact ? "size-5" : "size-6";
|
||||
const circleIconSize = compact ? "size-3" : "size-4";
|
||||
const chevronSize = compact ? "size-3.5" : "size-6";
|
||||
|
||||
return (
|
||||
<MotionConfig transition={springConfig}>
|
||||
<motion.div
|
||||
className={cn("cursor-pointer overflow-hidden rounded-lg border", cardWidth, cardBg)}
|
||||
animate={{
|
||||
height: bounds.height > 0 ? bounds.height : "auto",
|
||||
}}
|
||||
>
|
||||
<div className="p-2" ref={ref}>
|
||||
<AnimatePresence
|
||||
mode="popLayout"
|
||||
key={isExpanded ? "expanded" : "collapsed"}
|
||||
propagate
|
||||
>
|
||||
{!isExpanded ? (
|
||||
<motion.div
|
||||
key={"collapsed"}
|
||||
className="flex w-full items-center space-x-2"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1, ease: "easeOut" }}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{items.slice(0, 4).map((item, index) => (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"relative flex items-center justify-center rounded-full bg-zinc-300 p-1 dark:bg-zinc-700",
|
||||
circleSize
|
||||
)}
|
||||
key={`${item.name}-${index}`}
|
||||
layoutId={`${item.name}`}
|
||||
transition={{ ...springConfig, delay: 0.01 }}
|
||||
>
|
||||
<PhosphorIcon name={item.icon} className={cn("text-zinc-700 dark:text-zinc-200", circleIconSize)} />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex flex-1 flex-col items-start justify-center">
|
||||
<motion.span
|
||||
layoutId={`title-${title}`}
|
||||
layout="position"
|
||||
className={titleSize}
|
||||
>
|
||||
{title}
|
||||
</motion.span>
|
||||
<span className={subtitleSize}>
|
||||
{items.length} Item
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<PhosphorIcon name="caret-right" className={cn("text-zinc-500 dark:text-zinc-400", chevronSize)} />
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={"expanded"}
|
||||
className="flex w-full flex-col gap-3"
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.01, ease: "easeOut" }}
|
||||
>
|
||||
<motion.div className="flex items-center px-1" layout>
|
||||
<motion.span
|
||||
className={cn("flex-1", titleSize)}
|
||||
layoutId={`title-${title}`}
|
||||
layout="position"
|
||||
>
|
||||
{title}
|
||||
</motion.span>
|
||||
<div
|
||||
className="flex items-center justify-center rounded-full bg-zinc-300 dark:bg-zinc-700 p-1 hover:bg-zinc-400 dark:hover:bg-zinc-600 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(false);
|
||||
}}
|
||||
>
|
||||
<PhosphorIcon name="x" className={cn("text-zinc-700 dark:text-zinc-200", compact ? "size-3" : "size-4")} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className={cn("flex flex-col", compact ? "gap-1.5" : "gap-3")}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{items.map((item) => (
|
||||
<DraggableItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
collectionId={collectionId}
|
||||
compact={compact}
|
||||
onItemClick={onItemClick}
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
</MotionConfig>
|
||||
);
|
||||
};
|
||||
|
||||
// 2. Specialized Poverty Disclosure Card
|
||||
interface DashboardDisclosureCardProps {
|
||||
mapStyle: "street" | "satellite";
|
||||
onMapStyleChange: (style: "street" | "satellite") => void;
|
||||
onToolSelect: (toolId: string | null) => void;
|
||||
activeTool: string | null;
|
||||
compact?: boolean;
|
||||
dragDropEnabled: boolean;
|
||||
onDragDropToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export interface PovertyDemographics {
|
||||
population: number; // total jiwa across all registered households
|
||||
beneficiaries: number; // households (KK) classified Extreme/Miskin
|
||||
socialSecurity: number; // households (KK) classified Rentan
|
||||
}
|
||||
|
||||
interface PovertyDisclosureCardProps extends DashboardDisclosureCardProps {
|
||||
demographics?: PovertyDemographics;
|
||||
}
|
||||
|
||||
export const PovertyDisclosureCard: FC<PovertyDisclosureCardProps> = ({
|
||||
mapStyle,
|
||||
onMapStyleChange,
|
||||
onToolSelect,
|
||||
activeTool,
|
||||
compact = true,
|
||||
demographics,
|
||||
dragDropEnabled,
|
||||
onDragDropToggle,
|
||||
}) => {
|
||||
const collections: Collection[] = [
|
||||
{
|
||||
id: "maps-settings",
|
||||
name: "Pengaturan Peta",
|
||||
items: [
|
||||
{ id: "street", name: "Tampilan Jalan", icon: "map-trifold", isActive: mapStyle === "street" },
|
||||
{ id: "satellite", name: "Tampilan Satelit", icon: "globe", isActive: mapStyle === "satellite" },
|
||||
{ id: "drag-drop", name: "Seret & Lepas", icon: "hand-grabbing", isActive: dragDropEnabled },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "poverty-tools",
|
||||
name: "Alat Peta Kemiskinan",
|
||||
items: [
|
||||
{ id: "marker", name: "Tambah Data Warga", icon: "map-pin", isActive: activeTool === "marker" },
|
||||
{ id: "religion", name: "Tambah Rumah Ibadah", icon: "church", isActive: activeTool === "religion" },
|
||||
{ id: "clinic", name: "Tambah Klinik", icon: "first-aid", isActive: activeTool === "clinic" },
|
||||
{ id: "food-bank", name: "Tambah Lumbung Pangan", icon: "bowl-food", isActive: activeTool === "food-bank" },
|
||||
{ id: "school", name: "Tambah Sekolah", icon: "graduation-cap", isActive: activeTool === "school" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "demographics",
|
||||
name: "Demografi",
|
||||
items: [
|
||||
{ id: "dm-1", name: "Total Warga Terdata", price: demographics?.population ?? 0, unit: "jiwa", icon: "users" },
|
||||
{ id: "dm-2", name: "KK Miskin & Ekstrem", price: demographics?.beneficiaries ?? 0, unit: "KK", icon: "hand-heart" },
|
||||
{ id: "dm-3", name: "KK Rentan", price: demographics?.socialSecurity ?? 0, unit: "KK", icon: "shield-check" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DisclosureCard
|
||||
collections={collections}
|
||||
compact={compact}
|
||||
onItemClick={(itemId, collectionId) => {
|
||||
if (collectionId === "maps-settings") {
|
||||
if (itemId === "drag-drop") {
|
||||
onDragDropToggle(!dragDropEnabled);
|
||||
} else {
|
||||
onMapStyleChange(itemId as "street" | "satellite");
|
||||
}
|
||||
} else if (collectionId === "poverty-tools") {
|
||||
onToolSelect(activeTool === itemId ? null : itemId);
|
||||
}
|
||||
}}
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 3. Specialized Land Disclosure Card
|
||||
export const LandDisclosureCard: FC<DashboardDisclosureCardProps> = ({
|
||||
mapStyle,
|
||||
onMapStyleChange,
|
||||
onToolSelect,
|
||||
activeTool,
|
||||
compact = true,
|
||||
dragDropEnabled,
|
||||
onDragDropToggle,
|
||||
}) => {
|
||||
const collections: Collection[] = [
|
||||
{
|
||||
id: "maps-settings",
|
||||
name: "Pengaturan Peta",
|
||||
items: [
|
||||
{ id: "street", name: "Tampilan Jalan", icon: "map-trifold", isActive: mapStyle === "street" },
|
||||
{ id: "satellite", name: "Tampilan Satelit", icon: "globe", isActive: mapStyle === "satellite" },
|
||||
{ id: "drag-drop", name: "Seret & Lepas", icon: "hand-grabbing", isActive: dragDropEnabled },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "land-tools",
|
||||
name: "Alat Peta Lahan",
|
||||
items: [
|
||||
{ id: "marker", name: "Tambah Penanda", icon: "map-pin", isActive: activeTool === "marker" },
|
||||
{ id: "flag", name: "Tambah Tengara", icon: "flag", isActive: activeTool === "flag" },
|
||||
{ id: "protected", name: "Tambah Kawasan Lindung", icon: "tree", isActive: activeTool === "protected" },
|
||||
{ id: "registry", name: "Tambah Kantor Pendaftaran", icon: "bank", isActive: activeTool === "registry" },
|
||||
{ id: "line", name: "Gambar Garis", icon: "bezier-curve", isActive: activeTool === "line" },
|
||||
{ id: "polygon", name: "Gambar Poligon", icon: "hexagon", isActive: activeTool === "polygon" },
|
||||
{ id: "circle", name: "Gambar Lingkaran", icon: "circle", isActive: activeTool === "circle" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "land-permits",
|
||||
name: "Izin Lahan",
|
||||
items: [
|
||||
{ id: "lp-1", name: "Izin Mendirikan Bangunan (IMB)", price: 342, icon: "building" },
|
||||
{ id: "lp-2", name: "Sertifikat Tanah", price: 1580, icon: "certificate" },
|
||||
{ id: "lp-3", name: "Banding Zonasi", price: 47, icon: "gavel" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "environmental",
|
||||
name: "Lingkungan",
|
||||
items: [
|
||||
{ id: "ev-1", name: "Tutupan Hijau", price: 67.8, icon: "leaf" },
|
||||
{ id: "ev-2", name: "Aliran Air", price: 23.4, icon: "drop" },
|
||||
{ id: "ev-3", name: "Jaringan Jalan", price: 1420, icon: "road-horizon" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DisclosureCard
|
||||
collections={collections}
|
||||
compact={compact}
|
||||
onItemClick={(itemId, collectionId) => {
|
||||
if (collectionId === "maps-settings") {
|
||||
if (itemId === "drag-drop") {
|
||||
onDragDropToggle(!dragDropEnabled);
|
||||
} else {
|
||||
onMapStyleChange(itemId as "street" | "satellite");
|
||||
}
|
||||
} else if (collectionId === "land-tools") {
|
||||
onToolSelect(activeTool === itemId ? null : itemId);
|
||||
}
|
||||
}}
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 4. Specialized Gas Station Disclosure Card
|
||||
export const GasStationDisclosureCard: FC<DashboardDisclosureCardProps> = ({
|
||||
mapStyle,
|
||||
onMapStyleChange,
|
||||
onToolSelect,
|
||||
activeTool,
|
||||
compact = true,
|
||||
dragDropEnabled,
|
||||
onDragDropToggle,
|
||||
}) => {
|
||||
const collections: Collection[] = [
|
||||
{
|
||||
id: "maps-settings",
|
||||
name: "Pengaturan Peta",
|
||||
items: [
|
||||
{ id: "street", name: "Tampilan Jalan", icon: "map-trifold", isActive: mapStyle === "street" },
|
||||
{ id: "satellite", name: "Tampilan Satelit", icon: "globe", isActive: mapStyle === "satellite" },
|
||||
{ id: "drag-drop", name: "Seret & Lepas", icon: "hand-grabbing", isActive: dragDropEnabled },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "station-tools",
|
||||
name: "Alat Peta Stasiun",
|
||||
items: [
|
||||
{ id: "charging-station", name: "Tambah Pengisi Daya EV", icon: "charging-station", isActive: activeTool === "charging-station" },
|
||||
{ id: "gas-pump", name: "Tambah SPBU", icon: "gas-pump", isActive: activeTool === "gas-pump" },
|
||||
{ id: "wrench", name: "Tambah Bengkel", icon: "wrench", isActive: activeTool === "wrench" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "station-services",
|
||||
name: "Layanan Stasiun",
|
||||
items: [
|
||||
{ id: "ss-1", name: "Toko Kelontong", price: 450, icon: "shopping-bag" },
|
||||
{ id: "ss-2", name: "Pencucian Mobil", price: 75, icon: "bathtub" },
|
||||
{ id: "ss-3", name: "Mekanik", price: 320, icon: "wrench" },
|
||||
{ id: "ss-4", name: "Tukar Baterai", price: 180, icon: "car-battery" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fleet-logistics",
|
||||
name: "Armada & Logistik",
|
||||
items: [
|
||||
{ id: "fl-1", name: "Kartu Armada", price: 1240, icon: "credit-card" },
|
||||
{ id: "fl-2", name: "Rencana Rute", price: 89, icon: "paper-plane" },
|
||||
{ id: "fl-3", name: "Laporan Bahan Bakar", price: 56, icon: "file-text" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DisclosureCard
|
||||
collections={collections}
|
||||
compact={compact}
|
||||
onItemClick={(itemId, collectionId) => {
|
||||
if (collectionId === "maps-settings") {
|
||||
if (itemId === "drag-drop") {
|
||||
onDragDropToggle(!dragDropEnabled);
|
||||
} else {
|
||||
onMapStyleChange(itemId as "street" | "satellite");
|
||||
}
|
||||
} else if (collectionId === "station-tools") {
|
||||
onToolSelect(activeTool === itemId ? null : itemId);
|
||||
}
|
||||
}}
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
import { useState, useEffect, type FC, useId } from "react";
|
||||
import { motion, LayoutGroup } from "framer-motion";
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
interface TabItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ContinuousTabsProps {
|
||||
tabs?: TabItem[];
|
||||
activeId?: string;
|
||||
defaultActiveId?: string;
|
||||
onChange?: (id: string) => void;
|
||||
size?: "sm" | "md";
|
||||
layoutId?: string;
|
||||
}
|
||||
|
||||
/* ---------- Defaults ---------- */
|
||||
const DEFAULT_TABS: TabItem[] = [
|
||||
{ id: "home", label: "Home" },
|
||||
{ id: "interactions", label: "Interactions" },
|
||||
{ id: "resources", label: "Resources" },
|
||||
{ id: "docs", label: "Docs" },
|
||||
];
|
||||
|
||||
export const ContinuousTabs: FC<ContinuousTabsProps> = ({
|
||||
tabs = DEFAULT_TABS,
|
||||
activeId,
|
||||
defaultActiveId = "home",
|
||||
onChange,
|
||||
size = "md",
|
||||
layoutId,
|
||||
}) => {
|
||||
const [activeState, setActiveState] = useState<string>(defaultActiveId);
|
||||
const [isMounted, setIsMounted] = useState<boolean>(false);
|
||||
const uniqueId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const active = activeId !== undefined ? activeId : activeState;
|
||||
const finalLayoutId = layoutId ?? `active-pill-${uniqueId}`;
|
||||
|
||||
const handleChange = (id: string) => {
|
||||
setActiveState(id);
|
||||
onChange?.(id);
|
||||
};
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
const isSm = size === "sm";
|
||||
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<nav
|
||||
className={`
|
||||
relative flex items-center bg-muted/40 dark:bg-charcoal/50
|
||||
border border-border
|
||||
rounded-full transition-all duration-300
|
||||
shadow-[inset_0_-1px_2px_rgba(0,0,0,0.05)]
|
||||
${isSm ? "gap-0.5 p-1" : "gap-0.5 sm:gap-1 p-1.5 sm:p-2"}
|
||||
`}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = active === tab.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleChange(tab.id)}
|
||||
className={`
|
||||
relative flex items-center justify-center rounded-full outline-none transition-transform active:scale-[0.98]
|
||||
${isSm ? "px-3 py-1.5" : "px-5 py-2 sm:px-6 sm:py-2.5"}
|
||||
`}
|
||||
>
|
||||
{/* Active pill */}
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId={finalLayoutId}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 380,
|
||||
damping: 30,
|
||||
mass: 0.9,
|
||||
}}
|
||||
className="
|
||||
absolute inset-0 rounded-full
|
||||
bg-[#ff3b1f]
|
||||
shadow-sm
|
||||
"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
<motion.span
|
||||
layout="position"
|
||||
className={`
|
||||
relative z-10 font-semibold leading-none transition-colors duration-200
|
||||
${isSm ? "text-[11px] sm:text-xs" : "text-sm sm:text-base"}
|
||||
${isActive
|
||||
? "text-white"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.label}
|
||||
</motion.span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</LayoutGroup>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import type { ComponentPropsWithoutRef } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Requires these keyframes and utilities in your CSS (e.g. global CSS or Tailwind):
|
||||
*
|
||||
* @keyframes cosmic-spin {
|
||||
* from { transform: rotate(0deg); }
|
||||
* to { transform: rotate(360deg); }
|
||||
* }
|
||||
* @keyframes cosmic-spin-slow {
|
||||
* from { transform: rotate(0deg); }
|
||||
* to { transform: rotate(-360deg); }
|
||||
* }
|
||||
* @utility animate-cosmic-spin {
|
||||
* animation: cosmic-spin 3s linear infinite;
|
||||
* }
|
||||
* @utility animate-cosmic-spin-slow {
|
||||
* animation: cosmic-spin-slow 5s linear infinite;
|
||||
* }
|
||||
*/
|
||||
|
||||
export type CosmicButtonProps<E extends "a" | "button" = "a"> = {
|
||||
/** The HTML element to render as. @default "a" */
|
||||
as?: E
|
||||
size?: "sm" | "md"
|
||||
} & ComponentPropsWithoutRef<E>
|
||||
|
||||
/**
|
||||
* An animated button/link with a cosmic gradient border effect.
|
||||
* Renders as an anchor by default; use `as="button"` for button behavior.
|
||||
*
|
||||
* @example
|
||||
* // As link (default)
|
||||
* <CosmicButton href="/about">About</CosmicButton>
|
||||
*
|
||||
* @example
|
||||
* // As button
|
||||
* <CosmicButton as="button" onClick={handleClick}>Submit</CosmicButton>
|
||||
*/
|
||||
export function CosmicButton<E extends "a" | "button" = "a">({
|
||||
as,
|
||||
size = "md",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CosmicButtonProps<E>) {
|
||||
const Element = as ?? "a"
|
||||
const isAnchor = Element === "a"
|
||||
const isSm = size === "sm"
|
||||
|
||||
const baseClassName = cn(
|
||||
"group/cosmic relative inline-flex items-center justify-center rounded-full p-[2px] transition-transform ",
|
||||
isSm ? "min-h-8 min-w-8" : "min-h-11 min-w-11",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ff3b1f] focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
className
|
||||
)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* Animated cosmic border - enlarges on hover */}
|
||||
<span className="absolute inset-0 overflow-hidden rounded-full transition-all duration-300 ease-out group-hover/cosmic:inset-[-2px] group-hover/cosmic:rounded-full">
|
||||
<span className="absolute inset-[-200%] animate-cosmic-spin bg-[conic-gradient(from_0deg,#ff3b1f,#5683d2,#ffffff,#5683d2,#ff3b1f)] opacity-95" />
|
||||
</span>
|
||||
|
||||
{/* Noise/texture overlay on the border - enlarges on hover */}
|
||||
<span className="absolute inset-0 overflow-hidden rounded-full opacity-45 mix-blend-soft-light transition-all duration-300 ease-out group-hover/cosmic:inset-[-2px] group-hover/cosmic:rounded-full dark:opacity-60 dark:mix-blend-overlay">
|
||||
<span className="absolute inset-[-200%] animate-cosmic-spin-slow bg-[conic-gradient(from_180deg,#ffffff_0%,transparent_30%,#ff3b1f_50%,transparent_70%,#5683d2_100%)]" />
|
||||
</span>
|
||||
|
||||
{/* Theme-aware inner background */}
|
||||
<span className={cn(
|
||||
"relative z-10 flex items-center rounded-full bg-muted shadow-[inset_0_1px_0_rgba(255,255,255,0.72),inset_0_-1px_0_rgba(15,23,42,0.08),0_1px_1px_rgba(15,23,42,0.08),0_8px_24px_rgba(15,23,42,0.14)] transition-all duration-300 group-hover/cosmic:shadow-[inset_0_1px_0_rgba(255,255,255,0.82),inset_0_-1px_0_rgba(15,23,42,0.12),0_2px_6px_rgba(15,23,42,0.14),0_12px_34px_rgba(15,23,42,0.2)] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(0,0,0,0.5),0_1px_1px_rgba(0,0,0,0.45),0_10px_28px_rgba(0,0,0,0.35)] dark:group-hover/cosmic:shadow-[inset_0_1px_0_rgba(255,255,255,0.16),inset_0_-1px_0_rgba(0,0,0,0.6),0_2px_6px_rgba(0,0,0,0.55),0_14px_34px_rgba(0,0,0,0.42)] active:scale-[0.98]",
|
||||
isSm ? "px-3.5 py-1.5 gap-1.5" : "px-5 py-2.5 gap-3"
|
||||
)}>
|
||||
<span className={cn(
|
||||
"font-medium tracking-wide text-foreground",
|
||||
isSm ? "text-xs" : "text-base"
|
||||
)}>
|
||||
{children ?? "Placeholder text"}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isAnchor) {
|
||||
const { href, rel, target, ...rest } =
|
||||
props as ComponentPropsWithoutRef<"a">
|
||||
return (
|
||||
<a
|
||||
className={baseClassName}
|
||||
href={href ?? "https://aisdkagents.com"}
|
||||
rel={rel ?? "noopener noreferrer"}
|
||||
target={target ?? "_blank"}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={baseClassName}
|
||||
{...(props as ComponentPropsWithoutRef<"button">)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "@/components/ui/phosphor-icons"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, type FC, type ReactNode } from "react";
|
||||
import { motion, AnimatePresence, MotionConfig } from "motion/react";
|
||||
import { ChevronUpIcon } from "lucide-react";
|
||||
|
||||
export interface ActivityItemType {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
desc: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface ActivitiesCardProps {
|
||||
headerIcon: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
activities: ActivityItemType[];
|
||||
}
|
||||
|
||||
const ActivityItem: FC<ActivityItemType> = ({ icon, title, desc, time }) => {
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex cursor-pointer items-center gap-3 px-3 py-3 transition-colors hover:bg-neutral-50 sm:gap-4 sm:px-5 dark:hover:bg-neutral-800/50"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-gray-100/50 bg-gradient-to-b from-[#f4f4f7]/90 to-[#E9EAF0]/90 text-gray-400 sm:h-12 sm:w-12 dark:border-neutral-700 dark:from-neutral-800 dark:to-neutral-900 dark:text-neutral-500">
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[15px] leading-tight font-bold text-[#3E3E43] sm:text-[17px] dark:text-neutral-200">
|
||||
{title}
|
||||
</p>
|
||||
<p className="truncate text-[13px] text-[#909092] sm:text-[15px] dark:text-neutral-500">
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="pt-1 text-[11px] whitespace-nowrap text-[#9F9FA1] sm:text-[13px] dark:text-neutral-600">
|
||||
{time}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ActivitiesCard: FC<ActivitiesCardProps> = ({
|
||||
headerIcon,
|
||||
title,
|
||||
subtitle,
|
||||
activities,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 640);
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MotionConfig transition={{ type: "spring", bounce: 0, duration: 0.6 }}>
|
||||
<motion.div
|
||||
layout
|
||||
className="w-xs overflow-hidden rounded-xl border-2 border-[#e7e6e6]/60 bg-[#FEFEFE] shadow-lg sm:w-sm sm:rounded-[20px] dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<motion.button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2 transition-colors sm:gap-3 sm:px-4 sm:py-3.5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 text-left sm:gap-4">
|
||||
<motion.div
|
||||
initial={{
|
||||
width: isMobile ? 48 : 60,
|
||||
height: isMobile ? 48 : 60,
|
||||
}}
|
||||
animate={{
|
||||
width: open ? (isMobile ? 36 : 48) : isMobile ? 48 : 60,
|
||||
height: open ? (isMobile ? 36 : 48) : isMobile ? 48 : 60,
|
||||
}}
|
||||
className="relative flex shrink-0 items-center justify-center overflow-hidden rounded-lg border border-gray-100/50 bg-gradient-to-b from-[#f4f4f7] via-[#efeef2] to-[#E9EAF0] shadow-sm sm:rounded-xl dark:border-neutral-700 dark:from-neutral-700 dark:via-neutral-800 dark:to-neutral-900"
|
||||
>
|
||||
<motion.span className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_1px_1px_2px_rgba(255,255,255,0.8),_inset_-1px_-1px_2px_rgba(165,172,190,0.2)] dark:shadow-[inset_1px_1px_1px_rgba(255,255,255,0.1),inset_-1px_-1px_3px_rgba(0,0,0,0.6)]" />
|
||||
<motion.div animate={{ scale: open ? 0.7 : 1 }}>
|
||||
{headerIcon}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<motion.p
|
||||
layout
|
||||
className="truncate text-[16px] font-bold tracking-tight text-neutral-900 sm:text-[17px] dark:text-neutral-100"
|
||||
>
|
||||
{title}
|
||||
</motion.p>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{!open && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="truncate text-[14px] tracking-tight text-[#BFBFC2] sm:text-[15px] dark:text-neutral-500"
|
||||
>
|
||||
{subtitle}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
animate={{ rotate: open ? 180 : 0 }}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-[#9C97A8]/70 to-[#7A7596]/70 shadow-xs dark:from-neutral-700 dark:to-neutral-800"
|
||||
>
|
||||
<ChevronUpIcon className="size-5 text-white" />
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="border-t-2 border-[#e7e6e6]/60 dark:border-neutral-800"
|
||||
>
|
||||
<div className="py-2">
|
||||
{activities.map((item, i) => (
|
||||
<ActivityItem key={i} {...item} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</MotionConfig>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn(
|
||||
"font-heading text-sm font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||
"inline-end":
|
||||
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"flex items-center gap-2 text-sm shadow-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||
VariantProps<typeof inputGroupButtonVariants> & {
|
||||
type?: "button" | "submit" | "reset"
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
@@ -0,0 +1,709 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface IconProps extends React.SVGProps<SVGSVGElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MapIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="96" y1="184" x2="96" y2="40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="160" y1="72" x2="160" y2="216" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polygon points="96 184 32 200 32 56 96 40 160 72 224 56 224 200 160 216 96 184" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayersIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><polyline points="32 176 128 232 224 176" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="32 128 128 184 224 128" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polygon points="32 80 128 136 224 80 128 24 32 80" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FuelIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><path d="M56,216V56A16,16,0,0,1,72,40h80a16,16,0,0,1,16,16V216" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="32" y1="216" x2="192" y2="216" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M168,112h24a16,16,0,0,1,16,16v40a16,16,0,0,0,16,16h0a16,16,0,0,0,16-16V86.63a16,16,0,0,0-4.69-11.32L216,56" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="136" y1="112" x2="88" y2="112" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarChart3Icon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><polyline points="48 208 48 136 96 136" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="224" y1="208" x2="32" y2="208" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="96 208 96 88 152 88" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="152 208 152 40 208 40 208 208" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BriefcaseIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="112" y1="112" x2="144" y2="112" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><rect x="32" y="64" width="192" height="144" rx="8" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M168,64V48a16,16,0,0,0-16-16H104A16,16,0,0,0,88,48V64" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M224,118.31A191.09,191.09,0,0,1,128,144a191.14,191.14,0,0,1-96-25.68" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><path d="M10.23,200a88,88,0,0,1,147.54,0" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M172,160a87.93,87.93,0,0,1,73.77,40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="84" cy="108" r="52" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M152.69,59.7A52,52,0,1,1,172,160" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlugIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="144" y1="64" x2="184" y2="24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="232" y1="72" x2="192" y2="112" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="224" y1="144" x2="112" y2="32" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M212,132l-58.63,58.63a32,32,0,0,1-45.25,0L65.37,147.88a32,32,0,0,1,0-45.25L124,44" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="86.75" y1="169.25" x2="32" y2="224" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyRoundIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><path d="M93.17,122.83A71.68,71.68,0,0,1,88,95.91c0-38.58,31.08-70.64,69.64-71.87A72,72,0,0,1,232,98.36C230.73,136.92,198.67,168,160.09,168a71.68,71.68,0,0,1-26.92-5.17h0L120,176H96v24H72v24H40a8,8,0,0,1-8-8V187.31a8,8,0,0,1,2.34-5.65l58.83-58.83Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="180" cy="76" r="10"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M41.43,178.09A99.14,99.14,0,0,1,31.36,153.8l16.78-21a81.59,81.59,0,0,1,0-9.64l-16.77-21a99.43,99.43,0,0,1,10.05-24.3l26.71-3a81,81,0,0,1,6.81-6.81l3-26.7A99.14,99.14,0,0,1,102.2,31.36l21,16.78a81.59,81.59,0,0,1,9.64,0l21-16.77a99.43,99.43,0,0,1,24.3,10.05l3,26.71a81,81,0,0,1,6.81,6.81l26.7,3a99.14,99.14,0,0,1,10.07,24.29l-16.78,21a81.59,81.59,0,0,1,0,9.64l16.77,21a99.43,99.43,0,0,1-10,24.3l-26.71,3a81,81,0,0,1-6.81,6.81l-3,26.7a99.14,99.14,0,0,1-24.29,10.07l-21-16.78a81.59,81.59,0,0,1-9.64,0l-21,16.77a99.43,99.43,0,0,1-24.3-10l-3-26.71a81,81,0,0,1-6.81-6.81Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreditCardIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><rect x="24" y="56" width="208" height="144" rx="8" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="168" y1="168" x2="200" y2="168" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="120" y1="168" x2="136" y2="168" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="24" y1="96" x2="232" y2="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HelpCircleIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><circle cx="128" cy="180" r="10"/><path d="M128,144v-8c17.67,0,32-12.54,32-28s-14.33-28-32-28S96,92.54,96,108v4" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookOpenIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><path d="M128,88a32,32,0,0,1,32-32h72V200H160a32,32,0,0,0-32,32" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M24,200H96a32,32,0,0,1,32,32V88A32,32,0,0,0,96,56H24Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SendIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="108" y1="148" x2="160" y2="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M223.69,42.18a8,8,0,0,0-9.87-9.87l-192,58.22a8,8,0,0,0-1.25,14.93L108,148l42.54,87.42a8,8,0,0,0,14.93-1.25Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BellIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><path d="M96,192a32,32,0,0,0,64,0" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M56,104a72,72,0,0,1,144,0c0,35.82,8.3,64.6,14.9,76A8,8,0,0,1,208,192H48a8,8,0,0,1-6.88-12C47.71,168.6,56,139.81,56,104Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><circle cx="112" cy="112" r="80" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="168.57" y1="168.57" x2="224" y2="224" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowLeftIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="216" y1="128" x2="40" y2="128" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="112 56 40 128 112 200" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrendingDownIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><polyline points="232 192 136 96 96 136 24 64" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="232 128 232 192 168 192" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><path d="M120,120a8,8,0,0,1,8,8v40a8,8,0,0,0,8,8" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="124" cy="84" r="10"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EyeIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><path d="M128,56C48,56,16,128,16,128s32,72,112,72,112-72,112-72S208,56,128,56Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="128" cy="128" r="40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EyeOffIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="48" y1="40" x2="208" y2="216" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M154.91,157.6a40,40,0,0,1-53.82-59.2" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M135.53,88.71a40,40,0,0,1,32.3,35.53" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M208.61,169.1C230.41,149.58,240,128,240,128S208,56,128,56a126,126,0,0,0-20.68,1.68" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M74,68.6C33.23,89.24,16,128,16,128s32,72,112,72a118.05,118.05,0,0,0,54-12.6" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShieldAlertIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="128" y1="136" x2="128" y2="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="128" cy="172" r="10"/><path d="M216,112V56a8,8,0,0,0-8-8H48a8,8,0,0,0-8,8v56c0,96,88,120,88,120S216,208,216,112Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapPinIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><circle cx="128" cy="104" r="32" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M208,104c0,72-80,128-80,128S48,176,48,104a80,80,0,0,1,160,0Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ZapIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><polygon points="160 16 144 96 208 120 96 240 112 160 48 136 160 16" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShoppingBagIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><rect x="32" y="48" width="192" height="160" rx="8" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M168,88a40,40,0,0,1-80,0" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoffeeIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><path d="M83.3,216A88,88,0,0,1,32,136V88H208v48a88,88,0,0,1-51.3,80" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="88" y1="24" x2="88" y2="56" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="120" y1="24" x2="120" y2="56" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="152" y1="24" x2="152" y2="56" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="32" y1="216" x2="208" y2="216" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M208,88h0a32,32,0,0,1,32,32v8a32,32,0,0,1-32,32h-3.38" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SparklesIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><path d="M84.27,171.73l-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3a7.92,7.92,0,0,1,0,14.86l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="176" y1="16" x2="176" y2="64" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="224" y1="72" x2="224" y2="104" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="152" y1="40" x2="200" y2="40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="208" y1="88" x2="240" y2="88" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClockIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="128 72 128 128 184 128" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function NavigationIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><path d="M152,152,234.35,129a8,8,0,0,0,.27-15.21l-176-65.28A8,8,0,0,0,48.46,58.63l65.28,176a8,8,0,0,0,15.21-.27Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function XIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="200" y1="200" x2="56" y2="56" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronRightIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><polyline points="96 48 176 128 96 208" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronLeftIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><polyline points="160 208 80 128 160 48" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AtSignIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M184,208c-15.21,10.11-36.37,16-56,16a96,96,0,1,1,96-96c0,22.09-8,40-28,40s-28-17.91-28-40V88" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CircleCheckIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><polyline points="88 136 112 160 168 104" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowRightIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="40" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="144 56 216 128 144 200" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserPlusIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="200" y1="136" x2="248" y2="136" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="224" y1="112" x2="224" y2="160" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="108" cy="100" r="60" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M24,200c20.55-24.45,49.56-40,84-40s63.45,15.55,84,40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileTextIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><path d="M200,224H56a8,8,0,0,1-8-8V40a8,8,0,0,1,8-8h96l56,56V216A8,8,0,0,1,200,224Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="152 32 152 88 208 88" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="96" y1="136" x2="160" y2="136" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="96" y1="168" x2="160" y2="168" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RocketIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="144" y1="224" x2="112" y2="224" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="128" cy="100" r="10"/><path d="M94.81,192C37.52,95.32,103.87,32.53,123.09,17.68a8,8,0,0,1,9.82,0C152.13,32.53,218.48,95.32,161.19,192Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M183.84,110.88l30.31,36.36a8,8,0,0,1,1.66,6.86l-12.36,55.63a8,8,0,0,1-12.81,4.51L161.19,192" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M72.16,110.88,41.85,147.24a8,8,0,0,0-1.66,6.86l12.36,55.63a8,8,0,0,0,12.81,4.51L94.81,192" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MinusIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="40" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrendingUpIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><polyline points="232 56 136 152 96 112 24 184" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="232 120 232 56 168 56" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowUpIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="128" y1="216" x2="128" y2="40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="56 112 128 40 200 112" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronUpIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><polyline points="48 160 128 80 208 160" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDownIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="128" y1="40" x2="128" y2="216" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="56 144 128 216 200 144" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronDownIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><polyline points="208 96 128 176 48 96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoreHorizontalIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="10"/><circle cx="60" cy="128" r="10"/><circle cx="196" cy="128" r="10"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><polyline points="40 144 96 200 224 72" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelLeftIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="88" y1="48" x2="88" y2="208" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><rect x="32" y="48" width="192" height="160" rx="8" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="32" y1="80" x2="56" y2="80" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="32" y1="112" x2="56" y2="112" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="32" y1="144" x2="56" y2="144" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><circle cx="128" cy="96" r="64" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M32,216c19.37-33.47,54.55-56,96-56s76.63,22.53,96,56" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogOutIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><polyline points="112 40 48 40 48 216 112 216" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="112" y1="128" x2="224" y2="128" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="184 88 224 128 184 168" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HandHeartIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><path d="M128,120a36,36,0,0,1,36-36c19.55,0,36,16.36,36,36v4a68,68,0,0,1-68,68H116" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M72,168H48a8,8,0,0,1-8-8V128a8,8,0,0,1,8-8H72" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M72,168a32,32,0,0,0,32,32H176" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M72,120v48" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M148,84a20,20,0,0,0-20,20" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M108.28,44.72a24,24,0,0,1,39.44,0L164,68H92Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoadHorizonIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><line x1="8" y1="200" x2="248" y2="200" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="128" y1="120" x2="128" y2="136" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="128" y1="168" x2="128" y2="200" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="96" y1="72" x2="160" y2="72" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="64" y1="200" x2="120" y2="72" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="192" y1="200" x2="136" y2="72" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GlobeHemisphereWestIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M186.36,187.64l-26-9.33a8,8,0,0,1-5.23-5.63L150.24,153a8,8,0,0,1,1.68-7.27l22.77-26.67A8,8,0,0,1,180.8,116l18.83,2.68" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M89.55,43.35l5.31,17.72a8,8,0,0,1-2.87,8.56L71.33,84.94a8,8,0,0,0-2.69,8.84l7.36,22.09a8,8,0,0,1-2.71,8.79l-32,25.14" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WalletIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><path d="M40,56V184a16,16,0,0,0,16,16H216a8,8,0,0,0,8-8V80a8,8,0,0,0-8-8H56A16,16,0,0,1,40,56h0A16,16,0,0,1,56,40H192" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="180" cy="132" r="10"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HouseIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><path d="M104,216V152h48v64h64V120a8,8,0,0,0-2.34-5.66l-80-80a8,8,0,0,0-11.32,0l-80,80A8,8,0,0,0,40,120v96Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CaretDoubleRightIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
|
||||
{...props}
|
||||
>
|
||||
<rect width="256" height="256" fill="none"/><polyline points="56 48 136 128 56 208" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="136 48 216 128 136 208" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "@/components/ui/phosphor-icons"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { PanelLeftIcon } from "@/components/ui/phosphor-icons"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-label",
|
||||
sidebar: "group-label",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-action",
|
||||
sidebar: "group-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
render,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const { isMobile, state } = useSidebar()
|
||||
const comp = useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||
"data-active": isActive ? "true" : undefined,
|
||||
} as any,
|
||||
props
|
||||
),
|
||||
render: !tooltip ? render : <TooltipTrigger render={render} />,
|
||||
state: {
|
||||
slot: "sidebar-menu-button",
|
||||
sidebar: "menu-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
|
||||
if (!tooltip) {
|
||||
return comp
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
render,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-action",
|
||||
sidebar: "menu-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
render,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"a"> &
|
||||
React.ComponentProps<"a"> & {
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "a",
|
||||
props: mergeProps<"a">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
),
|
||||
"data-active": isActive ? "true" : undefined,
|
||||
} as any,
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-sub-button",
|
||||
sidebar: "menu-sub-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
Reference in New Issue
Block a user