feat: initial commit with seeded database and premium ui changes
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { AuthPage } from "@/components/auth-page";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <AuthPage />;
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/set-state-in-effect */
|
||||
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { MapPageHeader } from "@/components/maps/map-page-header";
|
||||
import { GasStationStatsCards } from "@/components/gas-station/gas-station-stats-cards";
|
||||
import { GasStationDirectory } from "@/components/gas-station/gas-station-directory";
|
||||
import { GasStationInspector } from "@/components/gas-station/gas-station-inspector";
|
||||
import { PertaminaPriceTable } from "@/components/gas-station/pertamina-price-table";
|
||||
import { GasStationHud } from "@/components/gas-station/gas-station-hud";
|
||||
import { GasStationDisclosureCard } from "@/components/ui/collection-grid-disclosure";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
import { FuelIcon } from "@/components/ui/phosphor-icons";
|
||||
import { StationPlacementDialog } from "@/components/gas-station/station-placement-dialog";
|
||||
import { StationEditDialog } from "@/components/gas-station/station-edit-dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const PLACING_LABELS: Record<string, string> = {
|
||||
"charging-station": "Pengisi Daya EV",
|
||||
"gas-pump": "SPBU",
|
||||
wrench: "Bengkel",
|
||||
};
|
||||
|
||||
const LeafletMap = dynamic(() => import("@/components/leaflet-map"), { ssr: false });
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
|
||||
|
||||
export default function GasStationMapPage() {
|
||||
const [selectedStation, setSelectedStation] = useState<UserMarker | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [brandFilter, setBrandFilter] = useState<string>("All");
|
||||
const [fuelPriceDisplay, setFuelPriceDisplay] = useState<"ron92" | "ron95" | "diesel">("ron95");
|
||||
const [evOnlyFilter, setEvOnlyFilter] = useState(false);
|
||||
const [useLeaflet, setUseLeaflet] = useState(false);
|
||||
const [mapStyle, setMapStyle] = useState<"street" | "satellite">("street");
|
||||
const [userMarkers, setUserMarkers] = useState<UserMarker[]>([]);
|
||||
const [placingMarkerType, setPlacingMarkerType] = useState<string | null>(null);
|
||||
const [dragDropEnabled, setDragDropEnabled] = useState(false);
|
||||
|
||||
// Custom Marker Dialog State
|
||||
const [pendingClick, setPendingClick] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [pendingMarkerType, setPendingMarkerType] = useState<string | null>(null);
|
||||
const [editingMarker, setEditingMarker] = useState<UserMarker | null>(null);
|
||||
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [fuelPrices, setFuelPrices] = useState<any>(null);
|
||||
const [fuelPricesLoading, setFuelPricesLoading] = useState(true);
|
||||
|
||||
// Fetch dynamic Pertamina fuel prices
|
||||
useEffect(() => {
|
||||
setFuelPricesLoading(true);
|
||||
fetch(`${BACKEND_URL}/gas-station/fuel-prices`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data && data.succeeded) {
|
||||
setFuelPrices(data);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error("Gagal memuat harga BBM:", err))
|
||||
.finally(() => setFuelPricesLoading(false));
|
||||
}, []);
|
||||
|
||||
const filteredStations = useMemo(() => {
|
||||
return userMarkers.filter((st) => {
|
||||
const matchesSearch = st.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesBrand = brandFilter === "All" || (st.type === "gas-pump" && st.meta?.poi_type === brandFilter);
|
||||
const matchesEv = !evOnlyFilter || st.type === "charging-station";
|
||||
return matchesSearch && matchesBrand && matchesEv;
|
||||
});
|
||||
}, [userMarkers, searchQuery, brandFilter, evOnlyFilter]);
|
||||
|
||||
const activeSelectedStation = useMemo(() => {
|
||||
if (filteredStations.length === 0) return null;
|
||||
const found = filteredStations.find((st) => st.id === selectedStation?.id);
|
||||
if (found) return found;
|
||||
return filteredStations[0];
|
||||
}, [filteredStations, selectedStation]);
|
||||
|
||||
// Load existing markers when page loads or refreshKey changes
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
|
||||
fetch(`${BACKEND_URL}/gas-station/markers`, { headers })
|
||||
.then((r) => r.json())
|
||||
.then((rows) => {
|
||||
if (Array.isArray(rows)) {
|
||||
const mapped = rows.map((row: any) => {
|
||||
let finalPoiType = "";
|
||||
let finalNotes = "";
|
||||
|
||||
if (row.marker_category === "charging-station") {
|
||||
finalPoiType = row.sub_type;
|
||||
finalNotes = row.status ? `Status: ${row.status}` : "";
|
||||
} else if (row.marker_category === "gas-pump") {
|
||||
finalPoiType = row.brand;
|
||||
finalNotes = row.operating_hours ? `Jam: ${row.operating_hours}` : "";
|
||||
} else if (row.marker_category === "wrench") {
|
||||
finalPoiType = row.sub_type;
|
||||
finalNotes = row.status;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `gsm-${row.id}`,
|
||||
lat: parseFloat(row.latitude),
|
||||
lng: parseFloat(row.longitude),
|
||||
type: row.marker_category,
|
||||
name: row.name,
|
||||
meta: {
|
||||
poi_type: finalPoiType,
|
||||
notes: finalNotes,
|
||||
gas_types: row.gas_types || undefined,
|
||||
},
|
||||
};
|
||||
});
|
||||
setUserMarkers(mapped);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Gagal memuat markers:", err);
|
||||
});
|
||||
}, [refreshKey]);
|
||||
|
||||
// Session storage synchronization (identical pattern to poverty)
|
||||
useEffect(() => {
|
||||
setUseLeaflet(sessionStorage.getItem("gas_useLeaflet") === "true");
|
||||
setMapStyle((sessionStorage.getItem("gas_mapStyle") as "street" | "satellite") ?? "street");
|
||||
setBrandFilter(sessionStorage.getItem("gas_brandFilter") ?? "All");
|
||||
setFuelPriceDisplay((sessionStorage.getItem("gas_fuelPriceDisplay") as "ron92" | "ron95" | "diesel") ?? "ron95");
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (hydrated) sessionStorage.setItem("gas_useLeaflet", String(useLeaflet)); }, [useLeaflet, hydrated]);
|
||||
useEffect(() => { if (hydrated) sessionStorage.setItem("gas_mapStyle", mapStyle); }, [mapStyle, hydrated]);
|
||||
useEffect(() => { if (hydrated) sessionStorage.setItem("gas_brandFilter", brandFilter); }, [brandFilter, hydrated]);
|
||||
useEffect(() => { if (hydrated) sessionStorage.setItem("gas_fuelPriceDisplay", fuelPriceDisplay); }, [fuelPriceDisplay, hydrated]);
|
||||
|
||||
// FULL BLEED MAP LAYOUT
|
||||
if (useLeaflet) {
|
||||
return (
|
||||
<AppShell activePath="/dashboard/gas-station" fullBleed onExitFullBleed={() => setUseLeaflet(false)}>
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
{/* Leaflet Map */}
|
||||
<LeafletMap
|
||||
mode="gas-station"
|
||||
selectedId={activeSelectedStation?.id}
|
||||
onSelect={(id) => {
|
||||
const found = userMarkers.find((st) => st.id === id);
|
||||
if (found) setSelectedStation(found);
|
||||
}}
|
||||
fuelPriceDisplay={fuelPriceDisplay}
|
||||
mapStyle={mapStyle}
|
||||
userMarkers={userMarkers}
|
||||
onMarkerClick={(m) => {
|
||||
if (["charging-station", "gas-pump", "wrench"].includes(m.type)) {
|
||||
setEditingMarker(m);
|
||||
}
|
||||
}}
|
||||
onMapClick={(lat, lng) => {
|
||||
if (placingMarkerType) {
|
||||
setPendingClick({ lat, lng });
|
||||
setPendingMarkerType(placingMarkerType);
|
||||
setPlacingMarkerType(null);
|
||||
}
|
||||
}}
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
onMarkerDragEnd={handleMarkerDragEnd}
|
||||
/>
|
||||
|
||||
{/* Placing Marker Banner */}
|
||||
{placingMarkerType && (
|
||||
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-[1000] bg-background/95 backdrop-blur-md border border-primary/20 px-4 py-2 rounded-full shadow-lg flex items-center gap-3 animate-in fade-in slide-in-from-top-4 duration-300 pointer-events-auto">
|
||||
<span className="text-xs font-semibold text-foreground flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
|
||||
Menempatkan {PLACING_LABELS[placingMarkerType] ?? placingMarkerType.replace("-", " ")}... Klik pada peta untuk menentukan posisi
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPlacingMarkerType(null)}
|
||||
className="text-[10px] uppercase font-bold text-muted-foreground hover:text-foreground border px-2 py-0.5 rounded-full transition-colors"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Filters HUD */}
|
||||
<GasStationHud
|
||||
fuelPriceDisplay={fuelPriceDisplay}
|
||||
setFuelPriceDisplay={setFuelPriceDisplay}
|
||||
brandFilter={brandFilter}
|
||||
setBrandFilter={setBrandFilter}
|
||||
onCloseMap={() => setUseLeaflet(false)}
|
||||
/>
|
||||
|
||||
{/* Floating Collection Grid Disclosure */}
|
||||
<div className="absolute bottom-6 right-4 z-[999] max-h-[calc(100vh-8rem)] overflow-y-auto no-scrollbar pointer-events-auto">
|
||||
<GasStationDisclosureCard
|
||||
mapStyle={mapStyle}
|
||||
onMapStyleChange={setMapStyle}
|
||||
activeTool={placingMarkerType}
|
||||
onToolSelect={setPlacingMarkerType}
|
||||
compact
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
onDragDropToggle={setDragDropEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Placement Dialog */}
|
||||
<StationPlacementDialog
|
||||
open={!!pendingClick}
|
||||
onOpenChange={(v) => { if (!v) { setPendingClick(null); setPendingMarkerType(null); } }}
|
||||
pendingClick={pendingClick}
|
||||
markerType={pendingMarkerType}
|
||||
onConfirm={(newMarker) => {
|
||||
setUserMarkers((prev) => [...prev, newMarker]);
|
||||
setPendingClick(null);
|
||||
setPendingMarkerType(null);
|
||||
setRefreshKey((k) => k + 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<StationEditDialog
|
||||
open={!!editingMarker}
|
||||
onOpenChange={(v) => { if (!v) setEditingMarker(null); }}
|
||||
marker={editingMarker}
|
||||
onUpdated={(updated) => {
|
||||
setUserMarkers((prev) => prev.map((m) => m.id === updated.id ? updated : m));
|
||||
setEditingMarker(null);
|
||||
setRefreshKey((k) => k + 1);
|
||||
}}
|
||||
onDeleted={(deleted) => {
|
||||
setUserMarkers((prev) => prev.filter((m) => m.id !== deleted.id));
|
||||
setEditingMarker(null);
|
||||
setRefreshKey((k) => k + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleMarkerDragEnd(marker: UserMarker, newLat: number, newLng: number) {
|
||||
const dbId = marker.id.split("-").slice(1).join("-");
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/gas-station/markers/${dbId}`, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify({ latitude: newLat, longitude: newLng }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Gagal memperbarui posisi di server");
|
||||
|
||||
setUserMarkers((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === marker.id ? { ...m, lat: newLat, lng: newLng } : m
|
||||
)
|
||||
);
|
||||
toast.success(`Posisi "${marker.name}" berhasil diperbarui`);
|
||||
setRefreshKey((k) => k + 1);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(err instanceof Error ? err.message : "Gagal memindahkan penanda");
|
||||
}
|
||||
}
|
||||
|
||||
// STANDARD DASHBOARD LAYOUT
|
||||
return (
|
||||
<AppShell activePath="/dashboard/gas-station">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Top Header Card */}
|
||||
<MapPageHeader
|
||||
title="Pencari SPBU & Charger"
|
||||
description="Pencari stasiun waktu nyata, indeks harga, info antrean, dan pengisi daya EV. Buka tampilan Leaflet JS untuk memeriksa koordinat pemetaan."
|
||||
onOpenMap={() => setUseLeaflet(true)}
|
||||
buttonText="Peta Stasiun"
|
||||
icon={<FuelIcon className="h-4 w-4" />}
|
||||
/>
|
||||
|
||||
{/* Unified Grid Layout */}
|
||||
<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">
|
||||
{/* Row 1: 4 stats cards (rendered inside GasStationStatsCards as direct children) */}
|
||||
<GasStationStatsCards fuelPrices={fuelPrices} loading={fuelPricesLoading} />
|
||||
|
||||
{/* Row 2: Directory */}
|
||||
<div className="md:col-span-2 lg:col-span-3 flex flex-col h-full">
|
||||
<GasStationDirectory
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
brandFilter={brandFilter}
|
||||
setBrandFilter={setBrandFilter}
|
||||
evOnlyFilter={evOnlyFilter}
|
||||
setEvOnlyFilter={setEvOnlyFilter}
|
||||
filteredStations={filteredStations}
|
||||
selectedStationId={activeSelectedStation?.id ?? ""}
|
||||
onSelectStation={setSelectedStation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Inspector (right column) */}
|
||||
<div className="md:col-span-2 lg:col-span-1 flex flex-col h-full">
|
||||
<GasStationInspector selectedStation={activeSelectedStation} />
|
||||
</div>
|
||||
{/* Row 3: Pricing Table (full width) */}
|
||||
<div className="md:col-span-2 lg:col-span-4 flex flex-col h-full">
|
||||
<PertaminaPriceTable fuelPrices={fuelPrices} loading={fuelPricesLoading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { MapPageHeader } from "@/components/maps/map-page-header";
|
||||
import { LandStatsCards } from "@/components/land/land-stats-cards";
|
||||
import { LandAllocationChart } from "@/components/land/land-allocation-chart";
|
||||
import { LandDirectory } from "@/components/land/land-directory";
|
||||
import { LandInspectorTop, LandInspectorBottom } from "@/components/land/land-inspector";
|
||||
import { LAND_MARKER_LABELS } from "@/lib/map-data";
|
||||
import { LayersIcon } from "@/components/ui/phosphor-icons";
|
||||
import { LandDisclosureCard } from "@/components/ui/collection-grid-disclosure";
|
||||
import {
|
||||
ShapePlacementDialog,
|
||||
LAND_STATUSES,
|
||||
calcLineLength,
|
||||
calcPolygonArea,
|
||||
} from "@/components/land/shape-placement-dialog";
|
||||
import { ShapeEditDialog } from "@/components/land/shape-edit-dialog";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const LeafletMap = dynamic(() => import("@/components/leaflet-map"), { ssr: false });
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
|
||||
|
||||
function authHeaders(json = false) {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
return { ...(json ? { "Content-Type": "application/json" } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) };
|
||||
}
|
||||
|
||||
export default function LandMapPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [visibilityFilters, setVisibilityFilters] = useState<Record<string, boolean>>({
|
||||
marker: true,
|
||||
flag: true,
|
||||
protected: true,
|
||||
registry: true,
|
||||
line: true,
|
||||
polygon: true,
|
||||
circle: true,
|
||||
});
|
||||
|
||||
const [useLeaflet, setUseLeaflet] = useState(false);
|
||||
const [mapStyle, setMapStyle] = useState<"street" | "satellite">("street");
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [userMarkers, setUserMarkers] = useState<UserMarker[]>([]);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [placingMarkerType, setPlacingMarkerType] = useState<string | null>(null);
|
||||
const [dragDropEnabled, setDragDropEnabled] = useState(false);
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||
|
||||
// Shape/marker dialog state
|
||||
const [pendingShape, setPendingShape] = useState<UserMarker | null>(null);
|
||||
const [editingShape, setEditingShape] = useState<UserMarker | null>(null);
|
||||
|
||||
// Hydrate persisted view state from sessionStorage on mount
|
||||
useEffect(() => {
|
||||
setUseLeaflet(sessionStorage.getItem("land_useLeaflet") === "true");
|
||||
setMapStyle((sessionStorage.getItem("land_mapStyle") as "street" | "satellite") ?? "street");
|
||||
const storedFilters = sessionStorage.getItem("land_visibilityFilters");
|
||||
if (storedFilters) {
|
||||
try {
|
||||
setVisibilityFilters(JSON.parse(storedFilters));
|
||||
} catch {
|
||||
/* keep defaults */
|
||||
}
|
||||
}
|
||||
setSelectedItemId(sessionStorage.getItem("land_selectedItemId"));
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (hydrated) sessionStorage.setItem("land_useLeaflet", String(useLeaflet)); }, [useLeaflet, hydrated]);
|
||||
useEffect(() => { if (hydrated) sessionStorage.setItem("land_mapStyle", mapStyle); }, [mapStyle, hydrated]);
|
||||
useEffect(() => { if (hydrated) sessionStorage.setItem("land_visibilityFilters", JSON.stringify(visibilityFilters)); }, [visibilityFilters, hydrated]);
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
if (selectedItemId) sessionStorage.setItem("land_selectedItemId", selectedItemId);
|
||||
else sessionStorage.removeItem("land_selectedItemId");
|
||||
}, [selectedItemId, hydrated]);
|
||||
|
||||
// Always-on data fetch — feeds both the dashboard widgets and the map.
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
|
||||
Promise.allSettled([
|
||||
fetch(`${BACKEND_URL}/land/markers`, { headers }).then((r) => r.json()),
|
||||
fetch(`${BACKEND_URL}/land/shapes`, { headers }).then((r) => r.json()),
|
||||
]).then(([markersResult, shapesResult]) => {
|
||||
const markerRows = markersResult.status === "fulfilled" && Array.isArray(markersResult.value) ? markersResult.value : [];
|
||||
const shapeRows = shapesResult.status === "fulfilled" && Array.isArray(shapesResult.value) ? shapesResult.value : [];
|
||||
|
||||
const pointMarkers: UserMarker[] = markerRows.map((r: any) => ({
|
||||
id: `lm-${r.id}`,
|
||||
lat: parseFloat(r.latitude),
|
||||
lng: parseFloat(r.longitude),
|
||||
type: r.marker_type,
|
||||
name: r.name,
|
||||
meta: {
|
||||
notes: r.notes ?? undefined,
|
||||
created_by_username: r.created_by_username ?? undefined,
|
||||
created_at: r.created_at ?? undefined,
|
||||
updated_at: r.updated_at ?? undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
const shapeMarkers: UserMarker[] = shapeRows.map((r: any) => ({
|
||||
id: `ls-${r.id}`,
|
||||
lat: parseFloat(r.latitude),
|
||||
lng: parseFloat(r.longitude),
|
||||
type: r.shape_type,
|
||||
name: r.name,
|
||||
meta: {
|
||||
poi_type: r.status ?? undefined,
|
||||
coordinates: r.coordinates ?? undefined,
|
||||
radius: r.radius_meters != null ? parseFloat(r.radius_meters) : undefined,
|
||||
notes: r.notes ?? undefined,
|
||||
created_by_username: r.created_by_username ?? undefined,
|
||||
created_at: r.created_at ?? undefined,
|
||||
updated_at: r.updated_at ?? undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
setUserMarkers([...pointMarkers, ...shapeMarkers]);
|
||||
});
|
||||
}, [refreshKey]);
|
||||
|
||||
const landMarkerItems = useMemo(() => userMarkers.filter((m) => m.id.startsWith("lm-")), [userMarkers]);
|
||||
const landShapeItems = useMemo(() => userMarkers.filter((m) => m.id.startsWith("ls-")), [userMarkers]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return userMarkers.filter((m) => {
|
||||
const label = LAND_MARKER_LABELS[m.type] ?? m.type;
|
||||
return m.name.toLowerCase().includes(q) || label.toLowerCase().includes(q);
|
||||
});
|
||||
}, [searchQuery, userMarkers]);
|
||||
|
||||
const aggregateStats = useMemo(() => {
|
||||
let totalAreaHa = 0;
|
||||
let totalRoadKm = 0;
|
||||
landShapeItems.forEach((m) => {
|
||||
if (m.type === "polygon" && m.meta?.coordinates) totalAreaHa += calcPolygonArea(m.meta.coordinates) / 10000;
|
||||
else if (m.type === "line" && m.meta?.coordinates) totalRoadKm += calcLineLength(m.meta.coordinates) / 1000;
|
||||
});
|
||||
return {
|
||||
totalParcelArea: totalAreaHa.toLocaleString(undefined, { maximumFractionDigits: 1 }),
|
||||
totalRoadLength: totalRoadKm.toLocaleString(undefined, { maximumFractionDigits: 1 }),
|
||||
activeMarkers: landMarkerItems.length,
|
||||
};
|
||||
}, [landShapeItems, landMarkerItems]);
|
||||
|
||||
const pieChartData = useMemo(() => {
|
||||
const totals: Record<string, number> = {};
|
||||
landShapeItems.forEach((m) => {
|
||||
if (m.type === "polygon" && m.meta?.coordinates) {
|
||||
const status = m.meta.poi_type ?? "Unknown";
|
||||
totals[status] = (totals[status] ?? 0) + calcPolygonArea(m.meta.coordinates) / 10000;
|
||||
}
|
||||
});
|
||||
return Object.entries(totals).map(([status, value]) => {
|
||||
const found = LAND_STATUSES.find((s) => s.value === status);
|
||||
return { name: found?.label ?? status, value: parseFloat(value.toFixed(1)), color: found?.color ?? "#6b7280" };
|
||||
});
|
||||
}, [landShapeItems]);
|
||||
|
||||
const selectedItem = useMemo(() => userMarkers.find((m) => m.id === selectedItemId) ?? null, [selectedItemId, userMarkers]);
|
||||
|
||||
const recentFlags = useMemo(
|
||||
() => landMarkerItems
|
||||
.filter((m) => m.type === "flag")
|
||||
.sort((a, b) => (b.meta?.created_at ?? "").localeCompare(a.meta?.created_at ?? ""))
|
||||
.slice(0, 2),
|
||||
[landMarkerItems]
|
||||
);
|
||||
|
||||
const toggleFilter = (type: string) => {
|
||||
setVisibilityFilters((prev) => ({
|
||||
...prev,
|
||||
[type]: !prev[type],
|
||||
}));
|
||||
};
|
||||
|
||||
async function handleConfirmPlacement(item: UserMarker) {
|
||||
const isPointType = !["line", "polygon", "circle"].includes(item.type);
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/land/${isPointType ? "markers" : "shapes"}`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify(
|
||||
isPointType
|
||||
? { name: item.name, marker_type: item.type, latitude: item.lat, longitude: item.lng, notes: item.meta?.notes ?? null }
|
||||
: {
|
||||
name: item.name,
|
||||
shape_type: item.type,
|
||||
status: item.meta?.poi_type ?? null,
|
||||
latitude: item.lat,
|
||||
longitude: item.lng,
|
||||
coordinates: item.meta?.coordinates ?? null,
|
||||
radius_meters: item.meta?.radius ?? null,
|
||||
notes: item.meta?.notes ?? null,
|
||||
}
|
||||
),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal menyimpan");
|
||||
setPendingShape(null);
|
||||
setRefreshKey((k) => k + 1);
|
||||
toast.success(`"${item.name}" berhasil disimpan`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Gagal menyimpan");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateItem(updated: UserMarker) {
|
||||
const isMarker = updated.id.startsWith("lm-");
|
||||
const dbId = updated.id.split("-").slice(1).join("-");
|
||||
const url = `${BACKEND_URL}/land/${isMarker ? "markers" : "shapes"}/${dbId}`;
|
||||
const body = isMarker
|
||||
? { name: updated.name, marker_type: updated.type, notes: updated.meta?.notes ?? null }
|
||||
: { name: updated.name, shape_type: updated.type, status: updated.meta?.poi_type ?? null, notes: updated.meta?.notes ?? null };
|
||||
try {
|
||||
const res = await fetch(url, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal memperbarui");
|
||||
setEditingShape(null);
|
||||
setRefreshKey((k) => k + 1);
|
||||
toast.success(`"${updated.name}" berhasil diperbarui`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Gagal memperbarui");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteItem(deleted: UserMarker) {
|
||||
const isMarker = deleted.id.startsWith("lm-");
|
||||
const dbId = deleted.id.split("-").slice(1).join("-");
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/land/${isMarker ? "markers" : "shapes"}/${dbId}`, { method: "DELETE", headers: authHeaders() });
|
||||
if (!res.ok) throw new Error("Gagal menghapus");
|
||||
setEditingShape(null);
|
||||
if (selectedItemId === deleted.id) setSelectedItemId(null);
|
||||
setRefreshKey((k) => k + 1);
|
||||
toast.success(`"${deleted.name}" berhasil dihapus`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Gagal menghapus");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkerDragEnd(marker: UserMarker, newLat: number, newLng: number) {
|
||||
const isMarker = marker.id.startsWith("lm-");
|
||||
const dbId = marker.id.split("-").slice(1).join("-");
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/land/${isMarker ? "markers" : "shapes"}/${dbId}`, {
|
||||
method: "PUT",
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify({ latitude: newLat, longitude: newLng }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Gagal memindahkan penanda");
|
||||
setUserMarkers((prev) => prev.map((m) => (m.id === marker.id ? { ...m, lat: newLat, lng: newLng } : m)));
|
||||
toast.success(`Posisi "${marker.name}" berhasil diperbarui`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Gagal memindahkan penanda");
|
||||
}
|
||||
}
|
||||
|
||||
// FULL BLEED MAP LAYOUT
|
||||
if (useLeaflet) {
|
||||
return (
|
||||
<AppShell activePath="/dashboard/land" fullBleed onExitFullBleed={() => setUseLeaflet(false)}>
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
{/* Leaflet Map */}
|
||||
<LeafletMap
|
||||
mode="land"
|
||||
visibilityFilters={visibilityFilters}
|
||||
mapStyle={mapStyle}
|
||||
userMarkers={userMarkers}
|
||||
placingMarkerType={placingMarkerType}
|
||||
onShapeCreated={(shape) => {
|
||||
// Open placement dialog instead of adding directly
|
||||
setPendingShape(shape);
|
||||
setPlacingMarkerType(null);
|
||||
}}
|
||||
onMarkerClick={(marker) => {
|
||||
setEditingShape(marker);
|
||||
setSelectedItemId(marker.id);
|
||||
}}
|
||||
onMapClick={(lat, lng) => {
|
||||
if (placingMarkerType && !["line", "polygon", "circle"].includes(placingMarkerType)) {
|
||||
setPendingShape({ id: `pending-${Date.now()}`, lat, lng, type: placingMarkerType, name: "" });
|
||||
setPlacingMarkerType(null);
|
||||
}
|
||||
}}
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
onMarkerDragEnd={handleMarkerDragEnd}
|
||||
/>
|
||||
|
||||
{/* Placing Marker Banner */}
|
||||
{placingMarkerType && (
|
||||
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-[1000] bg-background/95 backdrop-blur-md border border-primary/20 px-4 py-2 rounded-full shadow-lg flex items-center gap-3 animate-in fade-in slide-in-from-top-4 duration-300 pointer-events-auto">
|
||||
<span className="text-xs font-semibold text-foreground flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
|
||||
Menempatkan {LAND_MARKER_LABELS[placingMarkerType] ?? placingMarkerType.replace("-", " ")}... Klik pada peta untuk menentukan posisi
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPlacingMarkerType(null)}
|
||||
className="text-[10px] uppercase font-bold text-muted-foreground hover:text-foreground border px-2 py-0.5 rounded-full transition-colors"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Collection Grid Disclosure */}
|
||||
<div className="absolute bottom-6 right-4 z-[999] max-h-[calc(100vh-8rem)] overflow-y-auto no-scrollbar pointer-events-auto">
|
||||
<LandDisclosureCard
|
||||
mapStyle={mapStyle}
|
||||
onMapStyleChange={setMapStyle}
|
||||
activeTool={placingMarkerType}
|
||||
onToolSelect={setPlacingMarkerType}
|
||||
compact
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
onDragDropToggle={setDragDropEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Shape Placement Dialog */}
|
||||
<ShapePlacementDialog
|
||||
open={!!pendingShape}
|
||||
onOpenChange={(v) => { if (!v) setPendingShape(null); }}
|
||||
pendingShape={pendingShape}
|
||||
onConfirm={handleConfirmPlacement}
|
||||
/>
|
||||
|
||||
{/* Shape Edit Dialog */}
|
||||
<ShapeEditDialog
|
||||
open={!!editingShape}
|
||||
onOpenChange={(v) => { if (!v) setEditingShape(null); }}
|
||||
shape={editingShape}
|
||||
onUpdated={handleUpdateItem}
|
||||
onDeleted={handleDeleteItem}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
// STANDARD DASHBOARD LAYOUT
|
||||
return (
|
||||
<AppShell activePath="/dashboard/land">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Top Header Card */}
|
||||
<MapPageHeader
|
||||
title="Statistik Penggunaan Lahan"
|
||||
description="Klasifikasi GIS, catatan kepemilikan, dan registrasi luas zona. Buka tampilan Leaflet JS untuk memeriksa pemetaan geografis."
|
||||
onOpenMap={() => setUseLeaflet(true)}
|
||||
buttonText="Peta Lahan"
|
||||
icon={<LayersIcon className="h-4 w-4" />}
|
||||
/>
|
||||
|
||||
{/* Unified Grid Layout */}
|
||||
<div className="grid grid-cols-1 gap-px bg-border border rounded-xl overflow-hidden md:grid-cols-2 lg:grid-cols-3 shadow-none">
|
||||
{/* Row 1: 3 stats cards (rendered inside LandStatsCards as direct children) */}
|
||||
<LandStatsCards aggregateStats={aggregateStats} />
|
||||
|
||||
{/* Row 2: Allocation Chart */}
|
||||
<div className="md:col-span-2 lg:col-start-1 lg:col-span-2 lg:row-start-2 lg:row-end-3 flex flex-col h-full">
|
||||
<LandAllocationChart pieChartData={pieChartData} />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Inspector Top */}
|
||||
<div className="md:col-span-2 lg:col-start-3 lg:col-span-1 lg:row-start-2 lg:row-end-3 flex flex-col h-full">
|
||||
<LandInspectorTop
|
||||
selectedItem={selectedItem}
|
||||
visibilityFilters={visibilityFilters}
|
||||
toggleFilter={toggleFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Inspector Bottom */}
|
||||
<div className="md:col-span-2 lg:col-start-3 lg:col-span-1 lg:row-start-3 lg:row-end-4 flex flex-col h-full">
|
||||
<LandInspectorBottom recentFlags={recentFlags} />
|
||||
</div>
|
||||
|
||||
{/* Row 3: Directory */}
|
||||
<div className="md:col-span-2 lg:col-start-1 lg:col-span-2 lg:row-start-3 lg:row-end-4 flex flex-col h-full">
|
||||
<LandDirectory
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
filteredItems={filteredItems}
|
||||
selectedItemId={selectedItemId}
|
||||
visibilityFilters={visibilityFilters}
|
||||
onSelectItem={(item) => setSelectedItemId(item.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Dashboard } from "@/components/dashboard";
|
||||
import { HouseIcon } from "@/components/ui/phosphor-icons";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<AppShell activePath="/dashboard">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl md:text-3xl font-semibold text-foreground tracking-tight">
|
||||
Beranda Analitik
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Overview status statistik, data kemiskinan, peta wilayah, dan operasional SPBU.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dashboard />
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DndContext, DragOverlay, useDroppable, useSensor, useSensors, PointerSensor, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { MapPageHeader } from "@/components/maps/map-page-header";
|
||||
import { PovertyStatsCards } from "@/components/poverty/poverty-stats-cards";
|
||||
import { PovertyTrendChart } from "@/components/poverty/poverty-trend-chart";
|
||||
import { PovertyDirectory } from "@/components/poverty/poverty-directory";
|
||||
import { PovertyInspectorTop, PovertyInspectorBottom } from "@/components/poverty/poverty-inspector";
|
||||
import { PovertyHud } from "@/components/poverty/poverty-hud";
|
||||
import { TrendingDownIcon } from "@/components/ui/phosphor-icons";
|
||||
import { PovertyDisclosureCard } from "@/components/ui/collection-grid-disclosure";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
import type { HouseholdRow, PovertyOverview } from "@/lib/poverty-types";
|
||||
import { MarkerPlacementDialog } from "@/components/poverty/marker-placement-dialog";
|
||||
import { ReligionDialog } from "@/components/poverty/religion-dialog";
|
||||
import { MarkerEditDialog } from "@/components/poverty/marker-edit-dialog";
|
||||
import { PovertyHouseholdsTable } from "@/components/poverty/poverty-households-table";
|
||||
import { FuzzyPriorityStats } from "@/components/poverty/fuzzy-priority-stats";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const LeafletMap = dynamic(() => import("@/components/leaflet-map"), { ssr: false });
|
||||
|
||||
const PLACING_LABELS: Record<string, string> = {
|
||||
marker: "Data Warga",
|
||||
religion: "Rumah Ibadah",
|
||||
clinic: "Klinik",
|
||||
"food-bank": "Lumbung Pangan",
|
||||
school: "Sekolah",
|
||||
};
|
||||
|
||||
function MapDropZone() {
|
||||
const { setNodeRef } = useDroppable({ id: "map-canvas" });
|
||||
return <div ref={setNodeRef} className="absolute inset-0 z-[1] pointer-events-none" />;
|
||||
}
|
||||
|
||||
export default function PovertyMapPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// Initialize deterministically (matches SSR) and hydrate from sessionStorage
|
||||
// after mount to avoid a hydration mismatch that regenerates the whole tree.
|
||||
const [useLeaflet, setUseLeaflet] = useState(false);
|
||||
const [mapStyle, setMapStyle] = useState<"street" | "satellite">("street");
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [userMarkers, setUserMarkers] = useState<UserMarker[]>([]);
|
||||
const [placingMarkerType, setPlacingMarkerType] = useState<string | null>(null);
|
||||
const [dragDropEnabled, setDragDropEnabled] = useState(false);
|
||||
const [pendingClick, setPendingClick] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [householdsRefreshKey, setHouseholdsRefreshKey] = useState(0);
|
||||
const [editingMarker, setEditingMarker] = useState<UserMarker | null>(null);
|
||||
const [draggedItemId, setDraggedItemId] = useState<string | null>(null);
|
||||
const [households, setHouseholds] = useState<HouseholdRow[]>([]);
|
||||
const [householdsLoading, setHouseholdsLoading] = useState(true);
|
||||
const [overview, setOverview] = useState<PovertyOverview | null>(null);
|
||||
const [selectedHouseholdId, setSelectedHouseholdId] = useState<number | null>(null);
|
||||
const mapInstanceRef = useRef<any>(null);
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Require a small drag distance before activating dnd, so a plain click on a
|
||||
// disclosure item is treated as a tool toggle (not a drag). Without this the
|
||||
// click is swallowed and the marker tool never activates — the dialog would
|
||||
// then only appear via drag-drop instead of the click-tool → click-map flow.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
|
||||
);
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
|
||||
|
||||
// Dashboard data — households + aggregated overview, straight from the DB.
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
|
||||
Promise.allSettled([
|
||||
fetch(`${BACKEND_URL}/households`, { headers }).then((r) => r.json()),
|
||||
fetch(`${BACKEND_URL}/households/stats-overview`, { headers }).then((r) => r.json()),
|
||||
]).then(([hhResult, overviewResult]) => {
|
||||
if (hhResult.status === "fulfilled" && Array.isArray(hhResult.value)) {
|
||||
setHouseholds(hhResult.value as HouseholdRow[]);
|
||||
}
|
||||
if (overviewResult.status === "fulfilled" && overviewResult.value?.totals) {
|
||||
setOverview(overviewResult.value as PovertyOverview);
|
||||
}
|
||||
setHouseholdsLoading(false);
|
||||
});
|
||||
}, [householdsRefreshKey]);
|
||||
|
||||
// Load existing markers when map view opens. Households are reused from the
|
||||
// dashboard fetch above; only POIs need their own request here.
|
||||
useEffect(() => {
|
||||
if (!useLeaflet) return;
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
|
||||
fetch(`${BACKEND_URL}/poi`, { headers })
|
||||
.then((r) => r.json())
|
||||
.catch(() => [])
|
||||
.then((poiRows: any[]) => {
|
||||
const hhMarkers: UserMarker[] = households.map((r) => ({
|
||||
id: `hh-${r.id}`,
|
||||
lat: parseFloat(r.latitude),
|
||||
lng: parseFloat(r.longitude),
|
||||
type: "marker",
|
||||
name: r.head_name,
|
||||
meta: {
|
||||
poverty_level: r.poverty_level,
|
||||
family_count: r.family_count,
|
||||
penghasilan: r.penghasilan,
|
||||
fuzzy_label: r.fuzzy_label ?? undefined,
|
||||
fuzzy_score: r.fuzzy_score != null ? Number(r.fuzzy_score) : undefined,
|
||||
notes: r.notes ?? undefined,
|
||||
},
|
||||
}));
|
||||
const poiMarkers: UserMarker[] = (Array.isArray(poiRows) ? poiRows : []).map((r) => ({
|
||||
id: `poi-${r.id}`,
|
||||
lat: parseFloat(r.latitude),
|
||||
lng: parseFloat(r.longitude),
|
||||
type: r.religion_subtype ?? r.poi_type,
|
||||
name: r.name,
|
||||
meta: {
|
||||
poi_type: r.poi_type,
|
||||
notes: r.notes ?? undefined,
|
||||
radius: r.radius_meters ?? 0,
|
||||
},
|
||||
}));
|
||||
setUserMarkers([...hhMarkers, ...poiMarkers]);
|
||||
});
|
||||
}, [useLeaflet, households]);
|
||||
|
||||
useEffect(() => {
|
||||
setUseLeaflet(sessionStorage.getItem("poverty_useLeaflet") === "true");
|
||||
setMapStyle((sessionStorage.getItem("poverty_mapStyle") as "street" | "satellite") ?? "street");
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (hydrated) sessionStorage.setItem("poverty_useLeaflet", String(useLeaflet)); }, [useLeaflet, hydrated]);
|
||||
useEffect(() => { if (hydrated) sessionStorage.setItem("poverty_mapStyle", mapStyle); }, [mapStyle, hydrated]);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
setDraggedItemId(null);
|
||||
if (over?.id !== "map-canvas") return;
|
||||
const itemId = (active.data.current as any)?.itemId as string | undefined;
|
||||
if (!itemId || !mapInstanceRef.current) return;
|
||||
|
||||
// Get drop coordinates in the map container
|
||||
const nativeEvent = event.activatorEvent as PointerEvent;
|
||||
const rect = mapContainerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const L = (window as any).L;
|
||||
if (!L) return;
|
||||
|
||||
const x = nativeEvent.clientX + (event.delta?.x ?? 0) - rect.left;
|
||||
const y = nativeEvent.clientY + (event.delta?.y ?? 0) - rect.top;
|
||||
const latLng = mapInstanceRef.current.containerPointToLatLng(L.point(x, y));
|
||||
|
||||
setPendingClick({ lat: latLng.lat, lng: latLng.lng });
|
||||
setPlacingMarkerType(itemId);
|
||||
}
|
||||
|
||||
const demographics = useMemo(() => {
|
||||
const householdMarkers = userMarkers.filter((m) => m.type === "marker");
|
||||
return {
|
||||
population: householdMarkers.reduce((sum, h) => sum + (h.meta?.family_count ?? 0), 0),
|
||||
beneficiaries: householdMarkers.filter((h) => h.meta?.poverty_level === "Extreme" || h.meta?.poverty_level === "Miskin").length,
|
||||
socialSecurity: householdMarkers.filter((h) => h.meta?.poverty_level === "Rentan").length,
|
||||
};
|
||||
}, [userMarkers]);
|
||||
|
||||
// Default the inspector to the highest-priority household.
|
||||
const selectedHousehold = useMemo(() => {
|
||||
if (households.length === 0) return null;
|
||||
const found = households.find((h) => h.id === selectedHouseholdId);
|
||||
if (found) return found;
|
||||
return [...households].sort(
|
||||
(a, b) => Number(b.fuzzy_score ?? -1) - Number(a.fuzzy_score ?? -1)
|
||||
)[0];
|
||||
}, [households, selectedHouseholdId]);
|
||||
|
||||
async function handleMarkerDragEnd(marker: UserMarker, newLat: number, newLng: number) {
|
||||
const isHousehold = marker.id.startsWith("hh-");
|
||||
const dbId = marker.id.split("-").slice(1).join("-");
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers = { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) };
|
||||
|
||||
try {
|
||||
const url = isHousehold ? `${BACKEND_URL}/households/${dbId}` : `${BACKEND_URL}/poi/${dbId}`;
|
||||
const res = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify({ latitude: newLat, longitude: newLng }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Gagal memindahkan penanda");
|
||||
|
||||
setUserMarkers((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === marker.id ? { ...m, lat: newLat, lng: newLng } : m
|
||||
)
|
||||
);
|
||||
toast.success(`Posisi "${marker.name}" berhasil diperbarui`);
|
||||
if (isHousehold) {
|
||||
setHouseholdsRefreshKey((k) => k + 1);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Gagal memindahkan penanda");
|
||||
}
|
||||
}
|
||||
|
||||
// FULL BLEED MAP LAYOUT
|
||||
if (useLeaflet) {
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={(e) => setDraggedItemId((e.active.data.current as any)?.itemId ?? null)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => setDraggedItemId(null)}
|
||||
>
|
||||
<AppShell activePath="/dashboard/poverty" fullBleed onExitFullBleed={() => setUseLeaflet(false)}>
|
||||
<div ref={mapContainerRef} className="relative w-full h-full overflow-hidden">
|
||||
<MapDropZone />
|
||||
{/* Leaflet Map filling the screen */}
|
||||
<LeafletMap
|
||||
mode="poverty"
|
||||
mapStyle={mapStyle}
|
||||
userMarkers={userMarkers}
|
||||
onMapClick={(lat, lng) => {
|
||||
if (placingMarkerType && !editingMarker) {
|
||||
setPendingClick({ lat, lng });
|
||||
}
|
||||
}}
|
||||
onMarkerClick={(marker) => {
|
||||
if (!placingMarkerType) setEditingMarker(marker);
|
||||
}}
|
||||
onMapReady={(map) => { mapInstanceRef.current = map; }}
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
onMarkerDragEnd={handleMarkerDragEnd}
|
||||
/>
|
||||
|
||||
{/* Placing Marker Banner — hidden while the dialog is open */}
|
||||
{placingMarkerType && !pendingClick && (
|
||||
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-[1000] bg-background/95 backdrop-blur-md border border-primary/20 px-4 py-2 rounded-full shadow-lg flex items-center gap-3 animate-in fade-in slide-in-from-top-4 duration-300 pointer-events-auto">
|
||||
<span className="text-xs font-semibold text-foreground flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
|
||||
Menempatkan {PLACING_LABELS[placingMarkerType] ?? placingMarkerType.replace("-", " ")}... Klik pada peta untuk menentukan posisi
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPlacingMarkerType(null)}
|
||||
className="text-[10px] uppercase font-bold text-muted-foreground hover:text-foreground border px-2 py-0.5 rounded-full transition-colors"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Legend HUD overlay */}
|
||||
<PovertyHud />
|
||||
|
||||
{/* Floating Collection Grid Disclosure */}
|
||||
<div className="absolute bottom-6 right-4 z-[999] max-h-[calc(100vh-8rem)] overflow-y-auto no-scrollbar pointer-events-auto">
|
||||
<PovertyDisclosureCard
|
||||
mapStyle={mapStyle}
|
||||
onMapStyleChange={setMapStyle}
|
||||
activeTool={placingMarkerType}
|
||||
onToolSelect={setPlacingMarkerType}
|
||||
demographics={demographics}
|
||||
compact
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
onDragDropToggle={setDragDropEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Marker Placement Dialog (households + non-religion POIs) */}
|
||||
<MarkerPlacementDialog
|
||||
open={!!pendingClick && placingMarkerType !== "religion"}
|
||||
onOpenChange={(v) => { if (!v) { setPendingClick(null); setPlacingMarkerType(null); } }}
|
||||
markerType={placingMarkerType ?? "marker"}
|
||||
pendingClick={pendingClick}
|
||||
onConfirm={(marker) => {
|
||||
setUserMarkers((prev) => [...prev, marker]);
|
||||
setPendingClick(null);
|
||||
setPlacingMarkerType(null);
|
||||
if (marker.type === "marker") setHouseholdsRefreshKey((k) => k + 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Religion Place Dialog */}
|
||||
<ReligionDialog
|
||||
open={!!pendingClick && placingMarkerType === "religion"}
|
||||
onOpenChange={(v) => { if (!v) { setPendingClick(null); setPlacingMarkerType(null); } }}
|
||||
pendingClick={pendingClick}
|
||||
onConfirm={(marker) => {
|
||||
setUserMarkers((prev) => [...prev, marker]);
|
||||
setPendingClick(null);
|
||||
setPlacingMarkerType(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Marker Edit/Delete Dialog */}
|
||||
<MarkerEditDialog
|
||||
open={!!editingMarker}
|
||||
onOpenChange={(v) => { if (!v) setEditingMarker(null); }}
|
||||
marker={editingMarker}
|
||||
onUpdated={(updated) => {
|
||||
setUserMarkers((prev) => prev.map((m) => m.id === updated.id ? updated : m));
|
||||
setEditingMarker(null);
|
||||
if (updated.type === "marker") setHouseholdsRefreshKey((k) => k + 1);
|
||||
}}
|
||||
onDeleted={(deleted) => {
|
||||
setUserMarkers((prev) => prev.filter((m) => m.id !== deleted.id));
|
||||
setEditingMarker(null);
|
||||
if (deleted.type === "marker") setHouseholdsRefreshKey((k) => k + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
{/* Drag overlay badge */}
|
||||
<DragOverlay>
|
||||
{draggedItemId && (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-foreground/90 px-3 py-1.5 text-xs font-medium text-background shadow-lg">
|
||||
<span className="capitalize">{PLACING_LABELS[draggedItemId] ?? draggedItemId.replace("-", " ")}</span>
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
// STANDARD DASHBOARD LAYOUT (DEFAULT)
|
||||
return (
|
||||
<AppShell activePath="/dashboard/poverty">
|
||||
<div className="flex flex-col gap-6">
|
||||
<MapPageHeader
|
||||
title="Statistik Kemiskinan"
|
||||
description="Statistik kemiskinan langsung dari data warga dan fasilitas yang terpetakan. Buka tampilan Leaflet untuk mengelola marker."
|
||||
onOpenMap={() => setUseLeaflet(true)}
|
||||
buttonText="Peta Kemiskinan"
|
||||
icon={<TrendingDownIcon className="h-4 w-4" />}
|
||||
/>
|
||||
|
||||
{/* Unified Grid Layout */}
|
||||
<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">
|
||||
{/* Row 1: 4 stats cards (rendered inside PovertyStatsCards as direct children) */}
|
||||
<PovertyStatsCards overview={overview} />
|
||||
|
||||
{/* Row 2: Trend Chart */}
|
||||
<div className="md:col-span-2 lg:col-start-1 lg:col-span-3 lg:row-start-2 lg:row-end-3 flex flex-col h-full">
|
||||
<PovertyTrendChart monthly={overview?.monthly ?? []} />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Inspector Top */}
|
||||
<div className="md:col-span-2 lg:col-start-4 lg:col-span-1 lg:row-start-2 lg:row-end-3 flex flex-col h-full">
|
||||
<PovertyInspectorTop selected={selectedHousehold} />
|
||||
</div>
|
||||
|
||||
{/* Row 3: Inspector Bottom */}
|
||||
<div className="md:col-span-2 lg:col-start-4 lg:col-span-1 lg:row-start-3 lg:row-end-4 flex flex-col h-full">
|
||||
<PovertyInspectorBottom selected={selectedHousehold} />
|
||||
</div>
|
||||
|
||||
{/* Row 3: Directory */}
|
||||
<div className="md:col-span-2 lg:col-start-1 lg:col-span-3 lg:row-start-3 lg:row-end-4 flex flex-col h-full">
|
||||
<PovertyDirectory
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
households={households}
|
||||
selectedId={selectedHousehold?.id ?? null}
|
||||
onSelect={(h) => setSelectedHouseholdId(h.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Fuzzy Priority Distribution */}
|
||||
<FuzzyPriorityStats
|
||||
refreshKey={householdsRefreshKey}
|
||||
onRecomputed={() => setHouseholdsRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
|
||||
{/* Row 5: Households Table (full width) */}
|
||||
<div className="md:col-span-2 lg:col-span-4 flex flex-col h-full">
|
||||
<PovertyHouseholdsTable rows={households} loading={householdsLoading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Role } from "@/lib/auth-types";
|
||||
import {
|
||||
UserIcon,
|
||||
AtSignIcon,
|
||||
KeyRoundIcon,
|
||||
SettingsIcon
|
||||
} from "@/components/ui/phosphor-icons";
|
||||
|
||||
const ROLE_DETAILS = [
|
||||
{
|
||||
value: "superadmin" as Role,
|
||||
label: "Super Administrator",
|
||||
description: "Akses penuh ke semua peta (Kemiskinan, Lahan & Jalan, SPBU) serta statistik analitik.",
|
||||
},
|
||||
{
|
||||
value: "admin_poverty" as Role,
|
||||
label: "Admin Poverty Map",
|
||||
description: "Akses terbatas khusus untuk peta kemiskinan dan analisis data warga miskin.",
|
||||
},
|
||||
{
|
||||
value: "admin_lands_roads" as Role,
|
||||
label: "Admin Lahan & Jalan",
|
||||
description: "Akses terbatas khusus untuk manajemen lahan tata ruang dan jalan transportasi.",
|
||||
},
|
||||
{
|
||||
value: "admin_gas_stations" as Role,
|
||||
label: "Admin SPBU",
|
||||
description: "Akses terbatas khusus untuk pemetaan SPBU, EV Charger, dan Bengkel.",
|
||||
}
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, token, login } = useAuth();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [selectedRole, setSelectedRole] = useState<Role>("superadmin");
|
||||
const [mapStyle, setMapStyle] = useState<"street" | "satellite">("street");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Load user details when loaded
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setName(user.username || "");
|
||||
setEmail(user.email || "");
|
||||
setSelectedRole(user.role || "superadmin");
|
||||
|
||||
// Load map preference from localStorage if set
|
||||
const savedMapStyle = localStorage.getItem("default_map_style") as "street" | "satellite";
|
||||
if (savedMapStyle) {
|
||||
setMapStyle(savedMapStyle);
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!user || !token) return;
|
||||
|
||||
setSaving(true);
|
||||
|
||||
setTimeout(() => {
|
||||
const updatedUser = {
|
||||
...user,
|
||||
username: name.trim(),
|
||||
email: email.trim(),
|
||||
role: selectedRole
|
||||
};
|
||||
|
||||
// Save profile and role to AuthContext
|
||||
login(token, updatedUser);
|
||||
|
||||
// Save map preferences to localStorage
|
||||
localStorage.setItem("default_map_style", mapStyle);
|
||||
|
||||
toast.success("Pengaturan berhasil disimpan!");
|
||||
setSaving(false);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell activePath="/dashboard/settings">
|
||||
<div className="max-w-4xl mx-auto flex flex-col gap-8 py-6 px-4">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-md bg-primary/10 text-primary">
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-semibold text-foreground tracking-tight">
|
||||
Pengaturan Akun
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kelola informasi profil, peran administrator sistem, dan preferensi tampilan peta global.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="flex flex-col gap-6">
|
||||
{/* Profile Card */}
|
||||
<div className="rounded-xl border border-border bg-card p-6 flex flex-col gap-5">
|
||||
<h2 className="text-base font-semibold text-foreground">Informasi Profil</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<UserIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span>Nama Pengguna</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Masukkan nama pengguna"
|
||||
required
|
||||
className="bg-background border border-border focus-visible:border-ring rounded-md h-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<AtSignIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span>Email</span>
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Masukkan email"
|
||||
required
|
||||
className="bg-background border border-border focus-visible:border-ring rounded-md h-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Gating Card */}
|
||||
<div className="rounded-xl border border-border bg-card p-6 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-base font-semibold text-foreground flex items-center gap-1.5">
|
||||
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Peran & Hak Akses</span>
|
||||
</h2>
|
||||
<p className="text-[11.5px] text-muted-foreground leading-normal">
|
||||
Pilih peran admin untuk membatasi atau membuka modul visualisasi peta di sidebar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{ROLE_DETAILS.map((roleOpt) => {
|
||||
const isSelected = selectedRole === roleOpt.value;
|
||||
return (
|
||||
<div
|
||||
key={roleOpt.value}
|
||||
onClick={() => setSelectedRole(roleOpt.value)}
|
||||
className={`p-4 border rounded-xl cursor-pointer flex flex-col gap-2 transition-all select-none ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-border/60 bg-background hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-xs font-semibold ${isSelected ? "text-primary" : "text-foreground"}`}>
|
||||
{roleOpt.label}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{roleOpt.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map Preferences Card */}
|
||||
<div className="rounded-xl border border-border bg-card p-6 flex flex-col gap-5">
|
||||
<h2 className="text-base font-semibold text-foreground">Preferensi Peta</h2>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
|
||||
Default Map Style
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{["street", "satellite"].map((style) => {
|
||||
const isSelected = mapStyle === style;
|
||||
return (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
onClick={() => setMapStyle(style as any)}
|
||||
className={`h-9 px-4 rounded-md border text-xs font-medium uppercase tracking-wider transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-primary border-transparent text-primary-foreground shadow-sm"
|
||||
: "bg-background border-border text-foreground/75 hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{style === "street" ? "Street View" : "Satellite View"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Action */}
|
||||
<div className="flex justify-end gap-2 border-t pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !name.trim() || !email.trim()}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-md px-6 py-2.5 h-10 text-xs uppercase tracking-wider transition-colors disabled:opacity-40 flex items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
{saving ? "Menyimpan..." : "Simpan Perubahan"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,181 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-vermilion-pulse: #ff3b1f;
|
||||
--color-cobalt-glow: #5683d2;
|
||||
--color-obsidian: #0c0a08;
|
||||
--color-paper: #ffffff;
|
||||
--color-limestone: #f4f2f0;
|
||||
--color-charcoal: #1a1919;
|
||||
--color-slate: #4d505d;
|
||||
--color-fog: #999ba3;
|
||||
--color-bone: #d2cecb;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
/* Ramp's strict dual-radius system: 4px for functional controls, 12px for surfaces */
|
||||
--radius-sm: calc(var(--radius) - 8px);
|
||||
--radius-md: calc(var(--radius) - 8px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: var(--radius);
|
||||
--radius-2xl: var(--radius);
|
||||
--radius-3xl: var(--radius);
|
||||
--radius-4xl: 9999px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #ffffff; /* Paper */
|
||||
--foreground: #0c0a08; /* Obsidian */
|
||||
--card: #f4f2f0; /* Limestone — separates surfaces without drop shadows */
|
||||
--card-foreground: #0c0a08;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0c0a08;
|
||||
--primary: #ff3b1f; /* Vermilion Pulse — the single bold ignition accent */
|
||||
--primary-foreground: #ffffff; /* white text for contrast on vermilion */
|
||||
--secondary: #f4f2f0; /* Limestone */
|
||||
--secondary-foreground: #0c0a08;
|
||||
--muted: #f4f2f0; /* Limestone */
|
||||
--muted-foreground: #999ba3; /* Fog */
|
||||
--accent: #f4f2f0; /* Limestone */
|
||||
--accent-foreground: #0c0a08;
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: #d2cecb; /* Bone — hairline borders on light surfaces */
|
||||
--input: #d2cecb;
|
||||
--ring: #5683d2; /* Cobalt Glow — atmospheric accent reserved for focus rings */
|
||||
--chart-1: #5683d2; /* Cobalt Glow */
|
||||
--chart-2: #4d505d; /* Slate */
|
||||
--chart-3: #999ba3; /* Fog */
|
||||
--chart-4: #d2cecb; /* Bone */
|
||||
--chart-5: #1a1919; /* Charcoal */
|
||||
--radius: 0.75rem; /* 12px surface radius — controls derive 4px via --radius-sm/md */
|
||||
--sidebar: #f4f2f0;
|
||||
--sidebar-foreground: #0c0a08;
|
||||
--sidebar-primary: #0c0a08;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #ffffff;
|
||||
--sidebar-accent-foreground: #0c0a08;
|
||||
--sidebar-border: #d2cecb;
|
||||
--sidebar-ring: #5683d2;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0c0a08; /* Obsidian ground */
|
||||
--foreground: #ffffff; /* Paper */
|
||||
--card: #1a1919; /* Charcoal — one step lifted from Obsidian */
|
||||
--card-foreground: #ffffff;
|
||||
--popover: #1a1919;
|
||||
--popover-foreground: #ffffff;
|
||||
--primary: #ff3b1f; /* Vermilion Pulse stays the only chromatic accent */
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #1a1919;
|
||||
--secondary-foreground: #ffffff;
|
||||
--muted: #1a1919;
|
||||
--muted-foreground: #999ba3; /* Fog */
|
||||
--accent: #1a1919;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: #4d505d; /* Slate */
|
||||
--input: #4d505d;
|
||||
--ring: #5683d2; /* Cobalt Glow */
|
||||
--chart-1: #5683d2;
|
||||
--chart-2: #999ba3;
|
||||
--chart-3: #d2cecb;
|
||||
--chart-4: #4d505d;
|
||||
--chart-5: #f4f2f0;
|
||||
--sidebar: #1a1919;
|
||||
--sidebar-foreground: #ffffff;
|
||||
--sidebar-primary: #ff3b1f;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #0c0a08;
|
||||
--sidebar-accent-foreground: #ffffff;
|
||||
--sidebar-border: #4d505d;
|
||||
--sidebar-ring: #5683d2;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
[data-sonner-toaster] {
|
||||
transition: left 0.2s ease, right 0.2s ease, transform 0.2s ease !important;
|
||||
}
|
||||
html:has([data-slot="sidebar"][data-state="expanded"]) [data-sonner-toaster][data-x-position="left"] {
|
||||
left: calc(16rem + 16px) !important;
|
||||
}
|
||||
html:has([data-slot="sidebar"][data-state="collapsed"]) [data-sonner-toaster][data-x-position="left"] {
|
||||
left: calc(3rem + 16px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom glassmorphism utility classes */
|
||||
.liquid-glass {
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.1), 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
}
|
||||
.liquid-glass::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.liquid-glass-strong {
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
backdrop-filter: blur(50px);
|
||||
-webkit-backdrop-filter: blur(50px);
|
||||
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.1), 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist_Mono, Plus_Jakarta_Sans } from "next/font/google";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import "./globals.css";
|
||||
|
||||
const fontSans = Plus_Jakarta_Sans({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${fontSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<AuthProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
<Toaster position="bottom-left" />
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { LandingHeader } from "@/components/landing/landing-header";
|
||||
import { LandingHero } from "@/components/landing/landing-hero";
|
||||
import { LandingAbout } from "@/components/landing/landing-about";
|
||||
import { LandingProjects } from "@/components/landing/landing-projects";
|
||||
import { LandingCta } from "@/components/landing/landing-cta";
|
||||
import { LandingFooter } from "@/components/landing/landing-footer";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white font-sans text-gray-900 selection:bg-primary selection:text-primary-foreground">
|
||||
<LandingHeader />
|
||||
<LandingHero />
|
||||
<LandingAbout />
|
||||
<LandingProjects />
|
||||
<LandingCta />
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user