Skip to content

Commit 1da56b8

Browse files
feat: add detail area page /{code} (#16)
1 parent ad237c4 commit 1da56b8

File tree

9 files changed

+487
-65
lines changed

9 files changed

+487
-65
lines changed

app/(main)/[code]/error.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
'use client'
2+
3+
import {
4+
AlertDialog,
5+
AlertDialogAction,
6+
AlertDialogCancel,
7+
AlertDialogContent,
8+
AlertDialogDescription,
9+
AlertDialogFooter,
10+
AlertDialogHeader,
11+
AlertDialogTitle,
12+
} from '@/components/ui/alert-dialog'
13+
import { ExclamationTriangleIcon } from '@radix-ui/react-icons'
14+
import Link from 'next/link'
15+
import { useEffect } from 'react'
16+
17+
export default function Error({
18+
error,
19+
reset,
20+
}: {
21+
error: Error & { digest?: string }
22+
reset: () => void
23+
}) {
24+
useEffect(() => {
25+
// Log the error to an error reporting service
26+
console.error(error)
27+
}, [error])
28+
29+
return (
30+
<>
31+
<AlertDialog defaultOpen>
32+
<AlertDialogContent>
33+
<AlertDialogHeader>
34+
<AlertDialogTitle>
35+
<div className="flex gap-2 items-center">
36+
<ExclamationTriangleIcon className="h-5 w-5" />
37+
Error!
38+
</div>
39+
</AlertDialogTitle>
40+
<AlertDialogDescription>
41+
<p className="mb-2">
42+
This can happen due to <b>connection issues</b> or the{' '}
43+
<b>area code</b> provided is invalid or does not exist.
44+
</p>
45+
<p>
46+
Please try again or search the data manually in the Main Page.
47+
</p>
48+
</AlertDialogDescription>
49+
</AlertDialogHeader>
50+
<AlertDialogFooter>
51+
<AlertDialogCancel asChild>
52+
<Link href="/">Go to Main Page</Link>
53+
</AlertDialogCancel>
54+
<AlertDialogAction onClick={reset}>Try Again</AlertDialogAction>
55+
</AlertDialogFooter>
56+
</AlertDialogContent>
57+
</AlertDialog>
58+
</>
59+
)
60+
}

app/(main)/[code]/page.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import MapDashboard from '@/components/map-dashboard'
2+
import { Areas, singletonArea } from '@/lib/const'
3+
import { getSpecificData } from '@/lib/data'
4+
import { areaCodeSchema, determineAreaByCode } from '@/lib/utils'
5+
import { ZodError } from 'zod'
6+
import { fromZodError } from 'zod-validation-error'
7+
8+
type Props = {
9+
params: {
10+
code: string
11+
}
12+
}
13+
14+
async function getAreaData(area: Areas, areaCode: string) {
15+
const res = await getSpecificData(area, areaCode.replaceAll('.', ''))
16+
17+
if (!('data' in res)) {
18+
if (res.statusCode === 404) {
19+
throw new Error(`There are no area with code '${areaCode}'`)
20+
}
21+
throw new Error(Array.isArray(res.message) ? res.message[0] : res.message)
22+
}
23+
24+
return res.data
25+
}
26+
27+
export default async function DetailAreaPage({ params }: Props) {
28+
let areaCode
29+
30+
try {
31+
areaCode = areaCodeSchema.parse(params.code)
32+
} catch (error) {
33+
if (error instanceof ZodError) {
34+
throw fromZodError(error)
35+
}
36+
throw error
37+
}
38+
39+
const area = determineAreaByCode(areaCode)
40+
const { parent: parentAreas, ...areaData } = await getAreaData(area, areaCode)
41+
42+
return (
43+
<MapDashboard
44+
defaultSelected={{
45+
[singletonArea[area]]: areaData,
46+
...parentAreas,
47+
}}
48+
/>
49+
)
50+
}

app/(main)/layout.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Navbar } from '@/components/navbar'
2+
3+
export default function MainLayout({
4+
children,
5+
}: {
6+
children: React.ReactNode
7+
}) {
8+
return (
9+
<>
10+
<header>
11+
<Navbar />
12+
</header>
13+
14+
<main>{children}</main>
15+
</>
16+
)
17+
}

components/geojson-area.tsx

Lines changed: 90 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,22 @@ const Popup = dynamic(() => import('react-leaflet').then((mod) => mod.Popup), {
2121
ssr: false,
2222
})
2323

24+
const Pane = dynamic(() => import('react-leaflet').then((mod) => mod.Pane), {
25+
ssr: false,
26+
})
27+
28+
const FeatureGroup = dynamic(
29+
() => import('react-leaflet').then((mod) => mod.FeatureGroup),
30+
{
31+
ssr: false,
32+
},
33+
)
34+
2435
type Areas = Exclude<BaseAreas, 'islands'>
2536

2637
export type GeoJsonAreaProps<A extends Areas> = Omit<
2738
GeoJSONProps,
28-
'key' | 'data' | 'children'
39+
'key' | 'data' | 'children' | 'pane'
2940
> & {
3041
area: A
3142
code: string
@@ -42,15 +53,27 @@ export type GeoJsonAreaProps<A extends Areas> = Omit<
4253
* @param success Whether the data is loaded successfully or not.
4354
*/
4455
onLoaded?: (success: boolean) => void
56+
/**
57+
* The pane order.
58+
*/
59+
order?: number
4560
}
4661

62+
/**
63+
* The default overlay pane for the GeoJsonArea component.
64+
*
65+
* @link https://leafletjs.com/reference.html#map-pane
66+
*/
67+
const defaultOverlayPaneZIndex = 400
68+
4769
export default function GeoJsonArea<A extends Areas>({
4870
area,
4971
code,
5072
hide,
5173
eventHandlers,
5274
onLoading,
5375
onLoaded,
76+
order,
5477
pathOptions,
5578
...props
5679
}: GeoJsonAreaProps<A>) {
@@ -101,61 +124,72 @@ export default function GeoJsonArea<A extends Areas>({
101124
// eslint-disable-next-line react-hooks/exhaustive-deps
102125
}, [area, code])
103126

104-
return geoJson ? (
105-
<GeoJSON
106-
key={code}
107-
data={geoJson}
108-
eventHandlers={{
109-
...eventHandlers,
110-
click: (e) => {
111-
setLatLng(e.latlng)
112-
eventHandlers?.click?.(e)
113-
},
114-
}}
115-
pathOptions={{
116-
...pathOptions,
117-
...(hide ? { fillOpacity: 0, color: 'transparent' } : {}),
118-
}}
119-
{...props}
127+
return (
128+
<Pane
129+
name={area}
130+
style={{ zIndex: order ? defaultOverlayPaneZIndex + order : undefined }}
120131
>
121-
<Popup>
122-
{areaData ? (
123-
<>
124-
<span className="block font-bold text-sm">{areaData.name}</span>
125-
<span className="text-sm">{addDotSeparator(areaData.code)}</span>
126-
127-
{parents.map((parent) => {
128-
const parentData = areaData.parent[singletonArea[parent]]
129-
130-
if (!parentData) return null
131-
132-
return (
133-
<div key={parent} className="mt-1">
134-
<span className="text-xs text-gray-500">
135-
{ucFirstStr(singletonArea[parent])} :
136-
</span>
137-
<br />
138-
<span className="text-xs">{parentData.name}</span>
139-
</div>
140-
)
141-
})}
142-
143-
<Link
144-
href={`https://www.google.com/maps/search/${latLng?.lat},${latLng?.lng}`}
145-
passHref
146-
target="_blank"
147-
rel="noopener noreferrer"
148-
className="text-xs flex items-center gap-1 mt-3"
149-
title={`Coordinate: ${latLng?.lat}, ${latLng?.lng}`}
150-
>
151-
<ExternalLinkIcon className="h-4 w-4" />
152-
See on Google Maps
153-
</Link>
154-
</>
155-
) : (
156-
<span className="block text-gray-500">Loading...</span>
132+
<FeatureGroup>
133+
{geoJson && (
134+
<GeoJSON
135+
key={code}
136+
data={geoJson}
137+
eventHandlers={{
138+
...eventHandlers,
139+
click: (e) => {
140+
setLatLng(e.latlng)
141+
eventHandlers?.click?.(e)
142+
},
143+
}}
144+
pathOptions={{
145+
...pathOptions,
146+
...(hide ? { fillOpacity: 0, color: 'transparent' } : {}),
147+
}}
148+
{...props}
149+
/>
157150
)}
158-
</Popup>
159-
</GeoJSON>
160-
) : null
151+
152+
{/* Render Popup inside the default `popupPane`.
153+
See https://leafletjs.com/reference.html#map-pane */}
154+
<Popup pane="popupPane">
155+
{areaData ? (
156+
<>
157+
<span className="block font-bold text-sm">{areaData.name}</span>
158+
<span className="text-sm">{addDotSeparator(areaData.code)}</span>
159+
160+
{parents.map((parent) => {
161+
const parentData = areaData.parent[singletonArea[parent]]
162+
163+
if (!parentData) return null
164+
165+
return (
166+
<div key={parent} className="mt-1">
167+
<span className="text-xs text-gray-500">
168+
{ucFirstStr(singletonArea[parent])} :
169+
</span>
170+
<br />
171+
<span className="text-xs">{parentData.name}</span>
172+
</div>
173+
)
174+
})}
175+
176+
<Link
177+
href={`https://www.google.com/maps/search/${latLng?.lat},${latLng?.lng}`}
178+
passHref
179+
target="_blank"
180+
rel="noopener noreferrer"
181+
className="text-xs flex items-center gap-1 mt-3"
182+
title={`Coordinate: ${latLng?.lat}, ${latLng?.lng}`}
183+
>
184+
<ExternalLinkIcon className="h-4 w-4" />
185+
See on Google Maps
186+
</Link>
187+
</>
188+
) : (
189+
<span className="block text-gray-500">Loading...</span>
190+
)}
191+
</Popup>
192+
</FeatureGroup>
193+
</Pane>
194+
)
161195
}

components/map-dashboard.tsx

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
Island,
2525
Areas,
2626
singletonArea,
27+
parentArea,
2728
} from '@/lib/const'
2829
import { Switch } from './ui/switch'
2930
import { LatLngBounds } from 'leaflet'
@@ -48,12 +49,12 @@ const MapMarker = dynamic(() => import('@/components/map-marker'), {
4849
type FeatureAreas = Exclude<Areas, 'islands'>
4950

5051
const featureConfig: {
51-
readonly [A in FeatureAreas]: { color?: string }
52+
readonly [A in FeatureAreas]: { color?: string; order: number }
5253
} = {
53-
provinces: { color: '#2563eb' },
54-
regencies: { color: '#16a34a' },
55-
districts: { color: '#facc15' },
56-
villages: { color: '#ef4444' },
54+
provinces: { color: '#2563eb', order: 0 },
55+
regencies: { color: '#16a34a', order: 1 },
56+
districts: { color: '#facc15', order: 2 },
57+
villages: { color: '#ef4444', order: 3 },
5758
} as const
5859

5960
type Selected = {
@@ -79,9 +80,15 @@ function MapFlyToBounds({ bounds }: { bounds: LatLngBounds }) {
7980
return null
8081
}
8182

82-
export default function MapDashboard() {
83+
type Props = {
84+
defaultSelected?: Selected
85+
}
86+
87+
export default function MapDashboard({ defaultSelected }: Props) {
8388
const [islands, setIslands] = useState<Island[]>([])
84-
const [selected, setSelected] = useState<Selected>()
89+
const [selected, setSelected] = useState<Selected | undefined>(
90+
defaultSelected,
91+
)
8592
const [query, setQuery] =
8693
useState<{ [A in Exclude<Areas, 'provinces'>]?: Query<A> }>()
8794
const [isLoading, setLoading] = useState<{
@@ -95,6 +102,31 @@ export default function MapDashboard() {
95102
'horizontal' | 'vertical'
96103
>('horizontal')
97104

105+
useEffect(() => {
106+
if (defaultSelected) {
107+
setQuery({
108+
regencies: {
109+
parentCode: defaultSelected.province?.code,
110+
limit: MAX_PAGE_SIZE,
111+
},
112+
districts: {
113+
parentCode: defaultSelected.regency?.code,
114+
limit: MAX_PAGE_SIZE,
115+
},
116+
villages: {
117+
parentCode: defaultSelected.district?.code,
118+
limit: MAX_PAGE_SIZE,
119+
},
120+
...(defaultSelected.regency && {
121+
islands: {
122+
parentCode: defaultSelected.regency.code,
123+
limit: MAX_PAGE_SIZE,
124+
},
125+
}),
126+
})
127+
}
128+
}, [defaultSelected])
129+
98130
// Island data
99131
useEffect(() => {
100132
async function fetchIslandsRecursively(page = 1, limit = MAX_PAGE_SIZE) {
@@ -351,6 +383,7 @@ export default function MapDashboard() {
351383
onLoaded={() => {
352384
setLoading((current) => ({ ...current, [area]: false }))
353385
}}
386+
order={config.order}
354387
/>
355388
)
356389
})}

0 commit comments

Comments
 (0)