Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
d5051cd
feat: 현재 위치 받는 유틸함수 추가
H0ngJu Jan 24, 2026
8a27e9d
feat: MapView 컴포넌트 추가
H0ngJu Jan 24, 2026
df39ae7
feat: 지도위 이미지 핀 추가
H0ngJu Jan 24, 2026
4e38a98
test: 지도위 이미지 핀 스토리북 추가
H0ngJu Jan 24, 2026
b310a7c
refactor: Circle 버튼에 blur 추가
H0ngJu Jan 26, 2026
fff3e0b
Merge remote-tracking branch 'origin/develop' into feat/#25-map
H0ngJu Jan 26, 2026
7486164
feat: BottomSheet 기본 구조 추가
H0ngJu Jan 26, 2026
39b0b1a
feat: 메인 지도 화면 추가
H0ngJu Jan 26, 2026
331438e
feat: MapView 컴포넌트 추가
H0ngJu Jan 26, 2026
1ac9e5d
chore: mapbox 의존성 추가
H0ngJu Jan 26, 2026
bab7c9e
refactor: theme에 blur 옵션 추가
H0ngJu Jan 26, 2026
ddaa2ef
chore: format:fix 적용
H0ngJu Jan 27, 2026
a9b0bb7
refactor: AlbumContainer for문 -> Map으로 변경
H0ngJu Jan 27, 2026
88fad06
refactor: AlbumContainer type이 small인 경우 border-radius 4px 적용
H0ngJu Jan 27, 2026
953291e
refactor: AlbumContainer type이 small 인 경우 gap 수정
H0ngJu Jan 27, 2026
a19a0a4
feat: Album 타입 추가
H0ngJu Jan 27, 2026
7c73cdd
feat: 바텀시트 small일 때 보여줄 AlbumRow 컴포넌트 추가
H0ngJu Jan 27, 2026
f739fcb
style: format:fix
H0ngJu Jan 27, 2026
bd88d8f
feat: MenuButton의 panel 열림 위치 방향 커스텀 Prop 추가
H0ngJu Jan 27, 2026
ff32029
chore: albumRow 오타 수정
H0ngJu Jan 27, 2026
2c210cb
chore: Map용 임시 MockData 추가
H0ngJu Jan 27, 2026
8bb28b3
refactor: album 컴포넌트 스타일 수정
H0ngJu Jan 27, 2026
c72bca3
refactor: 바텀시트 비즈니스 로직 분리
H0ngJu Jan 27, 2026
8df835e
feat: 바텀시트 컨텐츠 렌더링 분리
H0ngJu Jan 27, 2026
79b1c1e
feat: 바텀시트 도메인, 높이 정책 로직 훅으로 분리
H0ngJu Jan 27, 2026
c712c45
feat: 바텀시트 앨범 그리드 추가
H0ngJu Jan 27, 2026
b8bdf76
feat: input의 타입이 search 일 때 count 보이지 않도록 수정
H0ngJu Jan 27, 2026
34df7ca
refactor: mockData 분리 및 바텀시트 context 주입
H0ngJu Jan 27, 2026
0db583a
feat: 지도 화면 앨범 상세 및 핀 필터링 연결
H0ngJu Jan 28, 2026
a13054f
chore: generate apis
H0ngJu Jan 28, 2026
2016229
refactor: filterAlbum에 useMemo 추가
H0ngJu Jan 28, 2026
c44786e
chore: 불필요한 mockData 제거
H0ngJu Jan 28, 2026
3f7b8b2
refactor: sheetContextType 상수화
H0ngJu Jan 29, 2026
75c4446
refactor: 지도 위치, 줌레벨 상수화
H0ngJu Jan 29, 2026
f21d824
refactor: 타입단언 제거 & early-return
H0ngJu Jan 29, 2026
5f60db1
refactor: 타임아웃 상수화
H0ngJu Jan 29, 2026
03479c8
refactor: bottomSheet 훅 관련된 높이 상수화 분리
H0ngJu Jan 29, 2026
bafd712
feat: 모킹 핸들러 함수 및 데이터 추가
H0ngJu Jan 29, 2026
acdd243
feat: map api 데이터 훅 추가
H0ngJu Jan 29, 2026
54c2c00
Merge remote-tracking branch 'origin/develop' into feat/#25-map
H0ngJu Jan 29, 2026
825b2a8
chore: 폴더명 수정
H0ngJu Jan 29, 2026
8b50003
refactor: 수정된 input prop 적용
H0ngJu Jan 29, 2026
032926a
refactor: MSW handler 수정 (/selectable)
H0ngJu Jan 29, 2026
bad5d4a
chore: format:fix
H0ngJu Jan 29, 2026
fa9c484
refactor: SelectableAlbum 타입 임시 수정
H0ngJu Jan 29, 2026
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
2 changes: 1 addition & 1 deletion apps/web/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
import nextVitals from 'eslint-config-next/core-web-vitals.js';
import nextTs from 'eslint-config-next/typescript';
import testingLibrary from 'eslint-plugin-testing-library';
import jestDom from 'eslint-plugin-jest-dom';
Expand Down
3 changes: 2 additions & 1 deletion apps/web/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { NextConfig } from 'next';

const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://develop-api.lokit.co.kr';
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL || 'https://develop-api.lokit.co.kr';

const nextConfig: NextConfig = {
turbopack: {
Expand Down
4 changes: 3 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@
"@repo/api-client": "workspace:*",
"@tanstack/react-query": "^5.64.0",
"@tanstack/react-query-devtools": "^5.64.0",
"mapbox-gl": "^3.18.1",
"framer-motion": "^12.29.0",
"next": "15.4.10",
"react": "18.2.0",
"react-dom": "18.2.0"
"react-dom": "18.2.0",
"react-map-gl": "^8.1.0"
},
"devDependencies": {
"@netlify/plugin-nextjs": "^5.15.3",
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/app/map/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const DEFAULT_LOCATION = {
latitude: 37.5665,
longitude: 126.978,
zoom: 12,
} as const;

export const DEFAULT_ZOOM = 14;
14 changes: 14 additions & 0 deletions apps/web/src/app/map/page.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import styled from '@emotion/styled';

export const Wrapper = styled.div`
width: 100%;
height: 100vh;
position: relative;
`;

export const HeaderContainer = styled.div`
width: 100%;
position: absolute;
top: 0;
z-index: 10;
`;
108 changes: 108 additions & 0 deletions apps/web/src/app/map/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use client';
import { ExploreHeader, MenuHeader } from '@/components/header';
import MapView from '@/components/map/MapView';
import * as S from './page.styles';
import { useEffect, useMemo, useState } from 'react';
import { getCurrentPosition } from '@/utils/getCurrentPosition';
import { LocationState } from '@/types/map.type';
import BottomSheet from '@/components/bottomSheet/BottomSheet';
import { SHEET_CONTEXT_TYPE, SheetContext } from '@/components/bottomSheet/constants';
import { DEFAULT_LOCATION, DEFAULT_ZOOM } from './constants';
import { useSelectableAlbums } from '@/hooks/queries/useSelectableAlbums';
import { useAlbumPhotos } from '@/hooks/queries/useAlbumPhotos';
import { useMapPhotos } from '@/hooks/queries/useMapPhotos';
import type { AlbumDetailData } from '@/types/album.type';

const calculateBbox = (viewState: LocationState): string => {
const offset = 0.05;
const west = viewState.longitude - offset;
const south = viewState.latitude - offset;
const east = viewState.longitude + offset;
const north = viewState.latitude + offset;
return `${west},${south},${east},${north}`;
};

export default function MapPage() {
const [viewState, setViewState] = useState<LocationState | null>(null);
const [sheetContext, setSheetContext] = useState<SheetContext>({
type: SHEET_CONTEXT_TYPE.HOME,
});

const selectedAlbumId =
sheetContext.type === SHEET_CONTEXT_TYPE.ALBUM_DETAIL ? sheetContext.albumId : null;

const { albumList } = useSelectableAlbums();
const { albumDetail } = useAlbumPhotos(selectedAlbumId);
const { mapPins } = useMapPhotos({
zoom: viewState?.zoom ?? DEFAULT_ZOOM,
bbox: viewState ? calculateBbox(viewState) : '',
albumId: selectedAlbumId,
});

const albumDetailById = useMemo<Record<number, AlbumDetailData>>(() => {
if (!albumDetail) return {};
return { [albumDetail.id]: albumDetail };
}, [albumDetail]);

useEffect(() => {
const init = async () => {
try {
const pos = await getCurrentPosition();

setViewState({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
zoom: DEFAULT_ZOOM,
});
} catch (err) {
console.log(err);
setViewState(DEFAULT_LOCATION);
}
};

init();
}, []);

const selectedAlbumTitle = albumDetail?.title;

const handleSelectAlbum = (albumId: number) => {
setSheetContext({ type: SHEET_CONTEXT_TYPE.ALBUM_DETAIL, albumId });
};

const handleCloseAlbumDetail = () => {
setSheetContext({ type: SHEET_CONTEXT_TYPE.ALBUM_LIST });
};

return (
<S.Wrapper>
<S.HeaderContainer>
{sheetContext.type === SHEET_CONTEXT_TYPE.ALBUM_DETAIL ? (
<MenuHeader
title={selectedAlbumTitle ?? '앨범'}
onClickBack={handleCloseAlbumDetail}
/>
) : (
<ExploreHeader
title="서울특별시 마포구"
onClickProfile={() => {}}
onClickExplore={() => {}}
/>
)}
</S.HeaderContainer>
{viewState && (
<MapView
locationState={viewState}
pins={mapPins}
selectedAlbumId={selectedAlbumId}
/>
)}
<BottomSheet
context={sheetContext}
albums={albumList}
albumDetailById={albumDetailById}
onChangeContext={setSheetContext}
onSelectAlbum={handleSelectAlbum}
/>
</S.Wrapper>
);
}
2 changes: 1 addition & 1 deletion apps/web/src/app/photo/[photoId]/_hooks/usePhotoData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const usePhotoData = ({ photoId, albumIdFromQuery }: UsePhotoDataProps) => {

if (selectableAlbums?.albums && photoDetail?.albumName) {
const matchedAlbum = selectableAlbums.albums.find(
(album) => album.title === photoDetail.albumName
(album) => album.title === photoDetail.albumName,
);
return matchedAlbum?.id;
}
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/app/photo/_contexts/PhotoContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
'use client';

import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from 'react';
import type { SelectedPhoto } from '../add/_types/photo';

export interface PhotoRect {
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/app/photo/add/_hooks/usePhotoSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ export const usePhotoSelect = (options?: UsePhotoSelectOptions): UsePhotoSelectR
setIsLoading(true);
const photoPromises = Array.from(files).map((file) => fileToSelectedPhoto(file));
const results = await Promise.all(photoPromises);
const newPhotos = results.filter((photo): photo is SelectedPhoto => photo !== null);
const newPhotos = results.filter(
(photo): photo is SelectedPhoto => photo !== null,
);

options?.onPhotosSelected?.(newPhotos);
setIsLoading(false);
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/app/photo/add/_utils/extractGpsFromExif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const findGpsIfdPointer = (
dataView: DataView,
tiffOffset: number,
ifdOffset: number,
littleEndian: boolean
littleEndian: boolean,
): number | null => {
const entries = dataView.getUint16(tiffOffset + ifdOffset, littleEndian);

Expand All @@ -106,7 +106,7 @@ const parseGpsIfd = (
dataView: DataView,
tiffOffset: number,
gpsIfdOffset: number,
littleEndian: boolean
littleEndian: boolean,
): GpsCoordinates | null => {
const entries = dataView.getUint16(tiffOffset + gpsIfdOffset, littleEndian);

Expand Down Expand Up @@ -154,7 +154,7 @@ const parseGpsIfd = (
const parseGpsCoordinate = (
dataView: DataView,
offset: number,
littleEndian: boolean
littleEndian: boolean,
): number => {
// GPS 좌표는 도/분/초 형식의 RATIONAL 3개로 저장됨
const degrees = readRational(dataView, offset, littleEndian);
Expand All @@ -164,7 +164,11 @@ const parseGpsCoordinate = (
return degrees + minutes / 60 + seconds / 3600;
};

const readRational = (dataView: DataView, offset: number, littleEndian: boolean): number => {
const readRational = (
dataView: DataView,
offset: number,
littleEndian: boolean,
): number => {
const numerator = dataView.getUint32(offset, littleEndian);
const denominator = dataView.getUint32(offset + 4, littleEndian);

Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/app/photo/add/_utils/fileToSelectedPhoto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { extractGpsFromExif } from './extractGpsFromExif';
*/
export const fileToSelectedPhoto = async (file: File): Promise<SelectedPhoto | null> => {
try {
const [dataUrl, gps] = await Promise.all([readFileAsDataUrl(file), extractGpsFromExif(file)]);
const [dataUrl, gps] = await Promise.all([
readFileAsDataUrl(file),
extractGpsFromExif(file),
]);

if (!dataUrl) {
return null;
Expand Down Expand Up @@ -46,7 +49,7 @@ const readFileAsDataUrl = (file: File): Promise<string | null> => {
};

const getImageDimensions = (
dataUrl: string
dataUrl: string,
): Promise<{ width: number; height: number } | null> => {
return new Promise((resolve) => {
const img = new Image();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import TextButton from '@/components/buttons/textButton/TextButton';
import Input from '@/components/input/Input';
import { INPUT_TYPE } from '@/components/input/Input.constants';
import AlbumListContainer from '@/components/album-list-container/AlbumListContainer';
import { Album } from '@/types/album.type';
import { SelectableAlbum } from '@/types/album.type';
import * as S from './AlbumSelectOverlay.styles';

interface AlbumSelectOverlayProps {
isOpen: boolean;
albums: Album[];
albums: SelectableAlbum[];
isLoading: boolean;
selectedAlbumId: string | null;
searchQuery: string;
Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/app/photo/add/note/_hooks/useAlbumModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useMemo, useState } from 'react';
import { useGetSelectableAlbums } from '@repo/api-client';
import type { SelectableAlbum } from '@/types/album.type';

// TODO: 사용자 컨텍스트에서 가져오도록 수정
const TEMP_USER_ID = 1;
Expand All @@ -19,24 +20,25 @@ const useAlbumModal = () => {

const { data, isLoading } = useGetSelectableAlbums({ userId: TEMP_USER_ID });

const filteredAlbums = useMemo(
const filteredAlbums: SelectableAlbum[] = useMemo(
() =>
(data?.albums ?? [])
.filter((album): album is typeof album & { id: number; title: string } =>
album.id !== undefined && album.title !== undefined
.filter(
(album): album is typeof album & { id: number; title: string } =>
album.id !== undefined && album.title !== undefined,
)
.filter((album) =>
searchQuery
? album.title.toLowerCase().includes(searchQuery.toLowerCase())
: true
: true,
)
.map((album) => ({
id: String(album.id),
title: album.title,
thumbnail: album.thumbnailUrl ?? '',
photoCount: album.photoCount ?? 0,
})),
[data?.albums, searchQuery]
[data?.albums, searchQuery],
);

const openModal = () => {
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/app/photo/add/note/_hooks/useLocationModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ const DEBOUNCE_DELAY = 100;

const useLocationModal = () => {
const [selectedLocation, setSelectedLocation] = useState<PlaceResponse | null>(null);
const [tempSelectedLocationId, setTempSelectedLocationId] = useState<string | null>(null);
const [tempSelectedLocationId, setTempSelectedLocationId] = useState<string | null>(
null,
);
const [searchQuery, setSearchQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);

Expand All @@ -25,7 +27,9 @@ const useLocationModal = () => {

const openModal = () => {
setTempSelectedLocationId(
selectedLocation ? `${selectedLocation.longitude}-${selectedLocation.latitude}` : null
selectedLocation
? `${selectedLocation.longitude}-${selectedLocation.latitude}`
: null,
);
setSearchQuery('');
setIsOpen(true);
Expand All @@ -38,7 +42,7 @@ const useLocationModal = () => {
const submitLocation = () => {
if (tempSelectedLocationId && locations.length > 0) {
const location = locations.find(
(l) => `${l.longitude}-${l.latitude}` === tempSelectedLocationId
(l) => `${l.longitude}-${l.latitude}` === tempSelectedLocationId,
);
if (location) {
setSelectedLocation(location);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ interface AddressResponse {

const fetchAddress = async (
latitude: number,
longitude: number
longitude: number,
): Promise<AddressResponse> => {
const url = buildUrlWithQueryParams(`/api${API_URL.LOCATION.ADDRESS}`, {
latitude,
Expand Down
Loading