Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions app/(main)/[code]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use client'

import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { ExclamationTriangleIcon } from '@radix-ui/react-icons'
import Link from 'next/link'
import { useEffect } from 'react'

export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error)
}, [error])

return (
<>
<AlertDialog defaultOpen>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<div className="flex gap-2 items-center">
<ExclamationTriangleIcon className="h-5 w-5" />
Error!
</div>
</AlertDialogTitle>
<AlertDialogDescription>
<p className="mb-2">
This can happen due to <b>connection issues</b> or the{' '}
<b>area code</b> provided is invalid or does not exist.
</p>
<p>
Please try again or search the data manually in the Main Page.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Link href="/">Go to Main Page</Link>
</AlertDialogCancel>
<AlertDialogAction onClick={reset}>Try Again</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
50 changes: 50 additions & 0 deletions app/(main)/[code]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import MapDashboard from '@/components/map-dashboard'
import { Areas, singletonArea } from '@/lib/const'
import { getSpecificData } from '@/lib/data'
import { areaCodeSchema, determineAreaByCode } from '@/lib/utils'
import { ZodError } from 'zod'
import { fromZodError } from 'zod-validation-error'

type Props = {
params: {
code: string
}
}

async function getAreaData(area: Areas, areaCode: string) {
const res = await getSpecificData(area, areaCode.replaceAll('.', ''))

if (!('data' in res)) {
if (res.statusCode === 404) {
throw new Error(`There are no area with code '${areaCode}'`)
}
throw new Error(Array.isArray(res.message) ? res.message[0] : res.message)
}

return res.data
}

export default async function DetailAreaPage({ params }: Props) {
let areaCode

try {
areaCode = areaCodeSchema.parse(params.code)
} catch (error) {
if (error instanceof ZodError) {
throw fromZodError(error)
}
throw error
}

const area = determineAreaByCode(areaCode)
const { parent: parentAreas, ...areaData } = await getAreaData(area, areaCode)

return (
<MapDashboard
defaultSelected={{
[singletonArea[area]]: areaData,
...parentAreas,
}}
/>
)
}
17 changes: 17 additions & 0 deletions app/(main)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Navbar } from '@/components/navbar'

export default function MainLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<header>
<Navbar />
</header>

<main>{children}</main>
</>
)
}
146 changes: 90 additions & 56 deletions components/geojson-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,22 @@ const Popup = dynamic(() => import('react-leaflet').then((mod) => mod.Popup), {
ssr: false,
})

const Pane = dynamic(() => import('react-leaflet').then((mod) => mod.Pane), {
ssr: false,
})

const FeatureGroup = dynamic(
() => import('react-leaflet').then((mod) => mod.FeatureGroup),
{
ssr: false,
},
)

type Areas = Exclude<BaseAreas, 'islands'>

export type GeoJsonAreaProps<A extends Areas> = Omit<
GeoJSONProps,
'key' | 'data' | 'children'
'key' | 'data' | 'children' | 'pane'
> & {
area: A
code: string
Expand All @@ -42,15 +53,27 @@ export type GeoJsonAreaProps<A extends Areas> = Omit<
* @param success Whether the data is loaded successfully or not.
*/
onLoaded?: (success: boolean) => void
/**
* The pane order.
*/
order?: number
}

/**
* The default overlay pane for the GeoJsonArea component.
*
* @link https://leafletjs.com/reference.html#map-pane
*/
const defaultOverlayPaneZIndex = 400

export default function GeoJsonArea<A extends Areas>({
area,
code,
hide,
eventHandlers,
onLoading,
onLoaded,
order,
pathOptions,
...props
}: GeoJsonAreaProps<A>) {
Expand Down Expand Up @@ -101,61 +124,72 @@ export default function GeoJsonArea<A extends Areas>({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [area, code])

return geoJson ? (
<GeoJSON
key={code}
data={geoJson}
eventHandlers={{
...eventHandlers,
click: (e) => {
setLatLng(e.latlng)
eventHandlers?.click?.(e)
},
}}
pathOptions={{
...pathOptions,
...(hide ? { fillOpacity: 0, color: 'transparent' } : {}),
}}
{...props}
return (
<Pane
name={area}
style={{ zIndex: order ? defaultOverlayPaneZIndex + order : undefined }}
>
<Popup>
{areaData ? (
<>
<span className="block font-bold text-sm">{areaData.name}</span>
<span className="text-sm">{addDotSeparator(areaData.code)}</span>

{parents.map((parent) => {
const parentData = areaData.parent[singletonArea[parent]]

if (!parentData) return null

return (
<div key={parent} className="mt-1">
<span className="text-xs text-gray-500">
{ucFirstStr(singletonArea[parent])} :
</span>
<br />
<span className="text-xs">{parentData.name}</span>
</div>
)
})}

<Link
href={`https://www.google.com/maps/search/${latLng?.lat},${latLng?.lng}`}
passHref
target="_blank"
rel="noopener noreferrer"
className="text-xs flex items-center gap-1 mt-3"
title={`Coordinate: ${latLng?.lat}, ${latLng?.lng}`}
>
<ExternalLinkIcon className="h-4 w-4" />
See on Google Maps
</Link>
</>
) : (
<span className="block text-gray-500">Loading...</span>
<FeatureGroup>
{geoJson && (
<GeoJSON
key={code}
data={geoJson}
eventHandlers={{
...eventHandlers,
click: (e) => {
setLatLng(e.latlng)
eventHandlers?.click?.(e)
},
}}
pathOptions={{
...pathOptions,
...(hide ? { fillOpacity: 0, color: 'transparent' } : {}),
}}
{...props}
/>
)}
</Popup>
</GeoJSON>
) : null

{/* Render Popup inside the default `popupPane`.
See https://leafletjs.com/reference.html#map-pane */}
<Popup pane="popupPane">
{areaData ? (
<>
<span className="block font-bold text-sm">{areaData.name}</span>
<span className="text-sm">{addDotSeparator(areaData.code)}</span>

{parents.map((parent) => {
const parentData = areaData.parent[singletonArea[parent]]

if (!parentData) return null

return (
<div key={parent} className="mt-1">
<span className="text-xs text-gray-500">
{ucFirstStr(singletonArea[parent])} :
</span>
<br />
<span className="text-xs">{parentData.name}</span>
</div>
)
})}

<Link
href={`https://www.google.com/maps/search/${latLng?.lat},${latLng?.lng}`}
passHref
target="_blank"
rel="noopener noreferrer"
className="text-xs flex items-center gap-1 mt-3"
title={`Coordinate: ${latLng?.lat}, ${latLng?.lng}`}
>
<ExternalLinkIcon className="h-4 w-4" />
See on Google Maps
</Link>
</>
) : (
<span className="block text-gray-500">Loading...</span>
)}
</Popup>
</FeatureGroup>
</Pane>
)
}
47 changes: 40 additions & 7 deletions components/map-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
Island,
Areas,
singletonArea,
parentArea,
} from '@/lib/const'
import { Switch } from './ui/switch'
import { LatLngBounds } from 'leaflet'
Expand All @@ -48,12 +49,12 @@ const MapMarker = dynamic(() => import('@/components/map-marker'), {
type FeatureAreas = Exclude<Areas, 'islands'>

const featureConfig: {
readonly [A in FeatureAreas]: { color?: string }
readonly [A in FeatureAreas]: { color?: string; order: number }
} = {
provinces: { color: '#2563eb' },
regencies: { color: '#16a34a' },
districts: { color: '#facc15' },
villages: { color: '#ef4444' },
provinces: { color: '#2563eb', order: 0 },
regencies: { color: '#16a34a', order: 1 },
districts: { color: '#facc15', order: 2 },
villages: { color: '#ef4444', order: 3 },
} as const

type Selected = {
Expand All @@ -79,9 +80,15 @@ function MapFlyToBounds({ bounds }: { bounds: LatLngBounds }) {
return null
}

export default function MapDashboard() {
type Props = {
defaultSelected?: Selected
}

export default function MapDashboard({ defaultSelected }: Props) {
const [islands, setIslands] = useState<Island[]>([])
const [selected, setSelected] = useState<Selected>()
const [selected, setSelected] = useState<Selected | undefined>(
defaultSelected,
)
const [query, setQuery] =
useState<{ [A in Exclude<Areas, 'provinces'>]?: Query<A> }>()
const [isLoading, setLoading] = useState<{
Expand All @@ -95,6 +102,31 @@ export default function MapDashboard() {
'horizontal' | 'vertical'
>('horizontal')

useEffect(() => {
if (defaultSelected) {
setQuery({
regencies: {
parentCode: defaultSelected.province?.code,
limit: MAX_PAGE_SIZE,
},
districts: {
parentCode: defaultSelected.regency?.code,
limit: MAX_PAGE_SIZE,
},
villages: {
parentCode: defaultSelected.district?.code,
limit: MAX_PAGE_SIZE,
},
...(defaultSelected.regency && {
islands: {
parentCode: defaultSelected.regency.code,
limit: MAX_PAGE_SIZE,
},
}),
})
}
}, [defaultSelected])

// Island data
useEffect(() => {
async function fetchIslandsRecursively(page = 1, limit = MAX_PAGE_SIZE) {
Expand Down Expand Up @@ -351,6 +383,7 @@ export default function MapDashboard() {
onLoaded={() => {
setLoading((current) => ({ ...current, [area]: false }))
}}
order={config.order}
/>
)
})}
Expand Down
Loading