mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 10:42:08 +00:00
Refactor: switch data fetching from server side to client
This commit is contained in:
@@ -51,7 +51,9 @@ const BeerCard: FC<{ post: z.infer<typeof beerPostQueryResult> }> = ({ post }) =
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{!!user && <BeerPostLikeButton beerPostId={post.id} mutateCount={mutate} />}
|
{!!user && !isLoading && (
|
||||||
|
<BeerPostLikeButton beerPostId={post.id} mutateCount={mutate} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FC, useMemo } from 'react';
|
|||||||
import Map, { Marker } from 'react-map-gl';
|
import Map, { Marker } from 'react-map-gl';
|
||||||
|
|
||||||
import LocationMarker from '../ui/LocationMarker';
|
import LocationMarker from '../ui/LocationMarker';
|
||||||
|
import ControlPanel from '../ui/maps/ControlPanel';
|
||||||
|
|
||||||
interface BreweryMapProps {
|
interface BreweryMapProps {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
@@ -45,6 +46,7 @@ const BreweryPostMap: FC<BreweryMapProps> = ({ latitude, longitude }) => {
|
|||||||
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN as string}
|
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN as string}
|
||||||
scrollZoom
|
scrollZoom
|
||||||
>
|
>
|
||||||
|
<ControlPanel />
|
||||||
{pin}
|
{pin}
|
||||||
</Map>
|
</Map>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const BreweryCard: FC<{ brewery: z.infer<typeof BreweryPostQueryResult> }> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
{!isLoading && <span>liked by {likeCount} users</span>}
|
{!isLoading && <span>liked by {likeCount} users</span>}
|
||||||
{user && (
|
{!!user && !isLoading && (
|
||||||
<BreweryPostLikeButton breweryPostId={brewery.id} mutateCount={mutate} />
|
<BreweryPostLikeButton breweryPostId={brewery.id} mutateCount={mutate} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ interface LocationMarkerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sizeClasses: Record<NonNullable<LocationMarkerProps['size']>, `text-${string}`> = {
|
const sizeClasses: Record<NonNullable<LocationMarkerProps['size']>, `text-${string}`> = {
|
||||||
sm: 'text-2xl',
|
sm: 'text-lg',
|
||||||
md: 'text-3xl',
|
md: 'text-xl',
|
||||||
lg: 'text-4xl',
|
lg: 'text-2xl',
|
||||||
xl: 'text-5xl',
|
xl: 'text-3xl',
|
||||||
};
|
};
|
||||||
|
|
||||||
const LocationMarker: FC<LocationMarkerProps> = ({ size = 'md', color = 'blue' }) => {
|
const LocationMarker: FC<LocationMarkerProps> = ({ size = 'md', color = 'blue' }) => {
|
||||||
return <HiLocationMarker className={`${sizeClasses[size]} text-${color}-400`} />;
|
return <HiLocationMarker className={`${sizeClasses[size]} text-${color}-600`} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(LocationMarker);
|
export default React.memo(LocationMarker);
|
||||||
|
|||||||
12
src/components/ui/maps/ControlPanel.tsx
Normal file
12
src/components/ui/maps/ControlPanel.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { FC, memo } from 'react';
|
||||||
|
import { FullscreenControl, NavigationControl, ScaleControl } from 'react-map-gl';
|
||||||
|
|
||||||
|
const ControlPanel: FC = () => (
|
||||||
|
<>
|
||||||
|
<FullscreenControl position="top-left" />
|
||||||
|
<NavigationControl position="top-left" />
|
||||||
|
<ScaleControl />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default memo(ControlPanel);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import BreweryPostMapQueryResult from '@/services/BreweryPost/types/BreweryPostMapQueryResult';
|
||||||
|
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
|
||||||
|
import useSWRInfinite from 'swr/infinite';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const useBreweryMapPagePosts = ({ pageSize }: { pageSize: number }) => {
|
||||||
|
const fetcher = async (url: string) => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
const count = response.headers.get('X-Total-Count');
|
||||||
|
|
||||||
|
const parsed = APIResponseValidationSchema.safeParse(json);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error('API response validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedPayload = z
|
||||||
|
.array(BreweryPostMapQueryResult)
|
||||||
|
.safeParse(parsed.data.payload);
|
||||||
|
if (!parsedPayload.success) {
|
||||||
|
throw new Error('API payload validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageCount = Math.ceil((count as string, 10) / pageSize);
|
||||||
|
|
||||||
|
return { breweryPosts: parsedPayload.data, pageCount };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, error, isLoading, setSize, size } = useSWRInfinite(
|
||||||
|
(index) => `/api/breweries/map?page_num=${index + 1}&page_size=${pageSize}`,
|
||||||
|
fetcher,
|
||||||
|
);
|
||||||
|
|
||||||
|
const breweryPosts = data?.flatMap((d) => d.breweryPosts) ?? [];
|
||||||
|
const pageCount = data?.[0].pageCount ?? 0;
|
||||||
|
|
||||||
|
const isLoadingMore = size > 0 && data && typeof data[size - 1] === 'undefined';
|
||||||
|
const isAtEnd = !(size < data?.[0].pageCount!);
|
||||||
|
|
||||||
|
return {
|
||||||
|
breweries: breweryPosts,
|
||||||
|
isLoading,
|
||||||
|
isLoadingMore,
|
||||||
|
isAtEnd,
|
||||||
|
size,
|
||||||
|
setSize,
|
||||||
|
pageCount,
|
||||||
|
error: error as unknown,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useBreweryMapPagePosts;
|
||||||
68
src/pages/api/breweries/map/index.ts
Normal file
68
src/pages/api/breweries/map/index.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
|
||||||
|
import DBClient from '@/prisma/DBClient';
|
||||||
|
import BreweryPostMapQueryResult from '@/services/BreweryPost/types/BreweryPostMapQueryResult';
|
||||||
|
|
||||||
|
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
|
||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { createRouter } from 'next-connect';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
interface GetBreweryPostsRequest extends NextApiRequest {
|
||||||
|
query: {
|
||||||
|
page_num: string;
|
||||||
|
page_size: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBreweryPosts = async (
|
||||||
|
req: GetBreweryPostsRequest,
|
||||||
|
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
|
||||||
|
) => {
|
||||||
|
const pageNum = parseInt(req.query.page_num, 10);
|
||||||
|
const pageSize = parseInt(req.query.page_size, 10);
|
||||||
|
|
||||||
|
const skip = (pageNum - 1) * pageSize;
|
||||||
|
const take = pageSize;
|
||||||
|
|
||||||
|
const breweryPosts: z.infer<typeof BreweryPostMapQueryResult>[] =
|
||||||
|
await DBClient.instance.breweryPost.findMany({
|
||||||
|
select: {
|
||||||
|
location: {
|
||||||
|
select: { coordinates: true, city: true, country: true, stateOrProvince: true },
|
||||||
|
},
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
});
|
||||||
|
const breweryPostCount = await DBClient.instance.breweryPost.count();
|
||||||
|
|
||||||
|
res.setHeader('X-Total-Count', breweryPostCount);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
message: 'Brewery posts retrieved successfully',
|
||||||
|
statusCode: 200,
|
||||||
|
payload: breweryPosts,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = createRouter<
|
||||||
|
GetBreweryPostsRequest,
|
||||||
|
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
|
||||||
|
>();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
validateRequest({
|
||||||
|
querySchema: z.object({
|
||||||
|
page_num: z.string().regex(/^\d+$/),
|
||||||
|
page_size: z.string().regex(/^\d+$/),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
getBreweryPosts,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handler = router.handler();
|
||||||
|
|
||||||
|
export default handler;
|
||||||
@@ -1,46 +1,31 @@
|
|||||||
import { GetServerSideProps, NextPage } from 'next';
|
import { NextPage } from 'next';
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import Map, {
|
import Map, { Marker, Popup } from 'react-map-gl';
|
||||||
FullscreenControl,
|
|
||||||
Marker,
|
|
||||||
NavigationControl,
|
|
||||||
Popup,
|
|
||||||
ScaleControl,
|
|
||||||
} from 'react-map-gl';
|
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
import DBClient from '@/prisma/DBClient';
|
|
||||||
|
|
||||||
import LocationMarker from '@/components/ui/LocationMarker';
|
import LocationMarker from '@/components/ui/LocationMarker';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import useGeolocation from '@/hooks/utilities/useGeolocation';
|
import useGeolocation from '@/hooks/utilities/useGeolocation';
|
||||||
|
import BreweryPostMapQueryResult from '@/services/BreweryPost/types/BreweryPostMapQueryResult';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import useBreweryMapPagePosts from '@/hooks/data-fetching/brewery-posts/useBreweryMapPagePosts';
|
||||||
|
import ControlPanel from '@/components/ui/maps/ControlPanel';
|
||||||
|
|
||||||
type MapStyles = Record<'light' | 'dark', `mapbox://styles/mapbox/${string}`>;
|
type MapStyles = Record<'light' | 'dark', `mapbox://styles/mapbox/${string}`>;
|
||||||
|
|
||||||
interface BreweryMapPageProps {
|
const BreweryMapPage: NextPage = () => {
|
||||||
breweries: {
|
const [popupInfo, setPopupInfo] = useState<z.infer<
|
||||||
location: {
|
typeof BreweryPostMapQueryResult
|
||||||
city: string;
|
> | null>(null);
|
||||||
stateOrProvince: string | null;
|
|
||||||
country: string | null;
|
|
||||||
coordinates: number[];
|
|
||||||
};
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const BreweryMapPage: NextPage<BreweryMapPageProps> = ({ breweries }) => {
|
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
||||||
const windowIsDefined = typeof window !== 'undefined';
|
|
||||||
const themeIsDefined = windowIsDefined && !!window.localStorage.getItem('theme');
|
|
||||||
|
|
||||||
const [popupInfo, setPopupInfo] = useState<BreweryMapPageProps['breweries'][0] | null>(
|
useEffect(() => {
|
||||||
null,
|
setTheme(localStorage.getItem('theme') === 'dark' ? 'dark' : 'light');
|
||||||
);
|
}, []);
|
||||||
|
|
||||||
const theme = (
|
const { breweries } = useBreweryMapPagePosts({ pageSize: 50 });
|
||||||
windowIsDefined && themeIsDefined ? window.localStorage.getItem('theme') : 'light'
|
|
||||||
) as 'light' | 'dark';
|
|
||||||
|
|
||||||
const mapStyles: MapStyles = {
|
const mapStyles: MapStyles = {
|
||||||
light: 'mapbox://styles/mapbox/light-v10',
|
light: 'mapbox://styles/mapbox/light-v10',
|
||||||
@@ -52,11 +37,12 @@ const BreweryMapPage: NextPage<BreweryMapPageProps> = ({ breweries }) => {
|
|||||||
<>
|
<>
|
||||||
{breweries.map((brewery) => {
|
{breweries.map((brewery) => {
|
||||||
const [longitude, latitude] = brewery.location.coordinates;
|
const [longitude, latitude] = brewery.location.coordinates;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
|
key={brewery.id}
|
||||||
latitude={latitude}
|
latitude={latitude}
|
||||||
longitude={longitude}
|
longitude={longitude}
|
||||||
key={brewery.id}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.originalEvent.stopPropagation();
|
e.originalEvent.stopPropagation();
|
||||||
setPopupInfo(brewery);
|
setPopupInfo(brewery);
|
||||||
@@ -71,16 +57,16 @@ const BreweryMapPage: NextPage<BreweryMapPageProps> = ({ breweries }) => {
|
|||||||
[breweries],
|
[breweries],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { coords, error } = useGeolocation();
|
const { coords, error: geoError } = useGeolocation();
|
||||||
|
|
||||||
const userLocationPin = useMemo(
|
const userLocationPin = useMemo(
|
||||||
() =>
|
() =>
|
||||||
coords && !error ? (
|
coords && !geoError ? (
|
||||||
<Marker latitude={coords.latitude} longitude={coords.longitude}>
|
<Marker latitude={coords.latitude} longitude={coords.longitude}>
|
||||||
<LocationMarker size="lg" color="red" />
|
<LocationMarker size="lg" color="red" />
|
||||||
</Marker>
|
</Marker>
|
||||||
) : null,
|
) : null,
|
||||||
[coords, error],
|
[coords, geoError],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -94,15 +80,14 @@ const BreweryMapPage: NextPage<BreweryMapPageProps> = ({ breweries }) => {
|
|||||||
</Head>
|
</Head>
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<Map
|
<Map
|
||||||
initialViewState={{ zoom: 2 }}
|
// center the map on North America
|
||||||
|
initialViewState={{ zoom: 3, latitude: 48.3544, longitude: -99.9981 }}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
mapStyle={mapStyles[theme]}
|
mapStyle={mapStyles[theme]}
|
||||||
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN}
|
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN}
|
||||||
scrollZoom
|
scrollZoom
|
||||||
>
|
>
|
||||||
<FullscreenControl position="top-left" />
|
<ControlPanel />
|
||||||
<NavigationControl position="top-left" />
|
|
||||||
<ScaleControl />
|
|
||||||
{pins}
|
{pins}
|
||||||
{userLocationPin}
|
{userLocationPin}
|
||||||
{popupInfo && (
|
{popupInfo && (
|
||||||
@@ -136,17 +121,3 @@ const BreweryMapPage: NextPage<BreweryMapPageProps> = ({ breweries }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default BreweryMapPage;
|
export default BreweryMapPage;
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps<BreweryMapPageProps> = async () => {
|
|
||||||
const breweries = await DBClient.instance.breweryPost.findMany({
|
|
||||||
select: {
|
|
||||||
location: {
|
|
||||||
select: { coordinates: true, city: true, country: true, stateOrProvince: true },
|
|
||||||
},
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { props: { breweries } };
|
|
||||||
};
|
|
||||||
|
|||||||
14
src/services/BreweryPost/types/BreweryPostMapQueryResult.ts
Normal file
14
src/services/BreweryPost/types/BreweryPostMapQueryResult.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const BreweryPostMapQueryResult = z.object({
|
||||||
|
location: z.object({
|
||||||
|
coordinates: z.array(z.number()),
|
||||||
|
city: z.string(),
|
||||||
|
country: z.string().nullable(),
|
||||||
|
stateOrProvince: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default BreweryPostMapQueryResult;
|
||||||
Reference in New Issue
Block a user