diff --git a/components/auth-callback-screen.tsx b/components/auth-callback-screen.tsx index 881b0bb..7d6b182 100644 --- a/components/auth-callback-screen.tsx +++ b/components/auth-callback-screen.tsx @@ -56,6 +56,7 @@ export function AuthCallbackScreen() { const code = searchParams.get("code"); if (result === "LINK_SUCCESS") { authLogger.info("authentication_completed", { result }); + queryClient.removeQueries({ queryKey: ["members", "me"], exact: true }); markAccountPanelReturn(); showToast("소셜 계정을 연동했어요.", "SUCCESS"); router.replace("/mypage"); diff --git a/components/home-screen.tsx b/components/home-screen.tsx index 3e71241..9160420 100644 --- a/components/home-screen.tsx +++ b/components/home-screen.tsx @@ -2,7 +2,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { CloudOff, RefreshCw } from "lucide-react"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { AppHeader } from "@/components/app-header"; import { EmptyState } from "@/components/empty-state"; import { ErrorState } from "@/components/error-state"; @@ -17,9 +17,10 @@ import { getLocationName } from "@/lib/constants"; import { useToastStore } from "@/store/toast-store"; export function HomeScreen() { - const { location, setLocation, isDetecting, detectionError, needsManualInput, setNeedsManualInput, detectLocation } = useCurrentLocation(); + const { location, setLocation, isDetecting, detectionError, needsManualInput, setNeedsManualInput, detectLocation } = useCurrentLocation({ refreshOnHomeResume: true }); const showToast = useToastStore((state) => state.showToast); const loadMoreRef = useRef(null); + const [isManualRefreshAnimating, setIsManualRefreshAnimating] = useState(false); const locationLabel = location ? getLocationName(location, "short") : ""; const locationKey = location?.id ?? locationLabel; const summary = useQuery({ queryKey: ["weather-summary", locationKey], queryFn: () => weatherApi.getSummary(location!), enabled: !!location, refetchInterval: 10_000, refetchIntervalInBackground: false }); @@ -48,7 +49,16 @@ export function HomeScreen() { const isEmptyFeed = Boolean(reports.data && items.length === 0); const showFeedEndMessage = Boolean(reports.data && !reports.hasNextPage && items.length > 0); const refreshWeather = async () => { - await Promise.all([summary.refetch(), reports.refetch()]); + if (isManualRefreshAnimating) return; + setIsManualRefreshAnimating(true); + const minimumAnimation = new Promise((resolve) => { + window.setTimeout(resolve, 650); + }); + try { + await Promise.all([summary.refetch(), reports.refetch(), minimumAnimation]); + } finally { + setIsManualRefreshAnimating(false); + } }; const hasSummaryData = Boolean(summary.data); const hasReportData = Boolean(reports.data); @@ -64,12 +74,12 @@ export function HomeScreen() { location={locationLabel} isDetecting={isDetecting && !needsManualInput} updatedAt={summary.dataUpdatedAt} - isRefreshing={summary.isFetching || reports.isRefetching} + isRefreshing={summary.isFetching || reports.isRefetching || isManualRefreshAnimating} canRefresh={Boolean(location)} onLocationClick={() => setNeedsManualInput(true)} onRefresh={() => void refreshWeather()} /> - {hasWeatherData && hasWeatherError && } + {hasWeatherData && hasWeatherError && } {showLocationError ? : showFullWeatherError ? : <> {summary.data ? diff --git a/components/home-weather-controls.tsx b/components/home-weather-controls.tsx index 78e2998..54a0018 100644 --- a/components/home-weather-controls.tsx +++ b/components/home-weather-controls.tsx @@ -58,7 +58,7 @@ export function HomeWeatherControls({ className="flex h-10 shrink-0 items-center gap-1.5 rounded-2xl px-2 text-xs font-bold text-[#718594] transition-colors hover:text-[#268fc7] disabled:cursor-wait disabled:opacity-50" aria-label={`${updatedAtLabel}, 날씨 통계와 피드 새로고침`} > - + {updatedAtLabel} diff --git a/components/legal-modal.tsx b/components/legal-modal.tsx index c8fa23b..35a58ec 100644 --- a/components/legal-modal.tsx +++ b/components/legal-modal.tsx @@ -3,38 +3,43 @@ import { useEffect } from "react"; import { ArrowLeft, X } from "lucide-react"; import { PrivacyPolicyContent, TermsContent } from "@/components/legal-content"; +import { useModalNavigation } from "@/hooks/use-modal-navigation"; import { useLegalModalStore } from "@/store/legal-modal-store"; export function LegalModal() { const document = useLegalModalStore((state) => state.document); const origin = useLegalModalStore((state) => state.origin); const close = useLegalModalStore((state) => state.closeLegalDocument); + const closeModal = useModalNavigation({ + open: Boolean(document), + onBack: close, + }); useEffect(() => { if (!document) return; const previousOverflow = window.document.body.style.overflow; window.document.body.style.overflow = "hidden"; const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") close(); + if (event.key === "Escape") closeModal(); }; window.addEventListener("keydown", closeOnEscape); return () => { window.document.body.style.overflow = previousOverflow; window.removeEventListener("keydown", closeOnEscape); }; - }, [close, document]); + }, [closeModal, document]); if (!document) return null; const isPrivacy = document === "PRIVACY"; const title = isPrivacy ? "개인정보처리방침" : "서비스 이용약관"; return ( -
{ if (event.target === event.currentTarget) close(); }}> +
{ if (event.target === event.currentTarget) closeModal(); }}>
- {origin === "SETTINGS" ? : } + {origin === "SETTINGS" ? : } - +

시행일 2026년 7월 21일

diff --git a/components/location-picker.tsx b/components/location-picker.tsx index 3045b23..fac6af3 100644 --- a/components/location-picker.tsx +++ b/components/location-picker.tsx @@ -1,17 +1,32 @@ "use client"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertCircle, Check, Flame, LockKeyhole, LocateFixed, MapPin, RefreshCw, Search, Star, X } from "lucide-react"; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { AlertCircle, Check, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Flame, LockKeyhole, LocateFixed, MapPin, RefreshCw, RotateCw, Search, Star, X } from "lucide-react"; import { FormEvent, useEffect, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { LocationDetectingIndicator } from "@/components/location-detecting-indicator"; +import { useModalNavigation } from "@/hooks/use-modal-navigation"; import { getLocationName } from "@/lib/constants"; -import { locationApi } from "@/lib/api/location-api"; +import { + locationApi, + type PopularLocationItem, + type PopularLocationMovement, +} from "@/lib/api/location-api"; +import { + clampLocationPage, + getLocationPageCount, + getLocationPageItems, +} from "@/lib/location-pagination"; import { truncateText } from "@/lib/text"; import type { Location } from "@/lib/types"; import { useAuthStore } from "@/store/auth-store"; +import { useToastStore } from "@/store/toast-store"; type LocationTab = "SEARCH" | "FAVORITES" | "POPULAR"; +type LocationPages = Record; +const FAVORITES_QUERY_KEY = ["locations", "favorites"] as const; interface LocationPickerProps { open: boolean; @@ -34,47 +49,115 @@ export function LocationPicker({ open, current, isDetecting, detectionError, req function LocationPickerDialog({ current, isDetecting, detectionError, required, onClose, onDetect, onSelect }: Omit) { const queryClient = useQueryClient(); + const closeModal = useModalNavigation({ open: true, onBack: onClose }); const [value, setValue] = useState(""); const [debouncedValue, setDebouncedValue] = useState(""); const [selectedLocation, setSelectedLocation] = useState(current ?? null); const [activeTab, setActiveTab] = useState("SEARCH"); + const [popularOpenedAt] = useState(() => Date.now()); + const [isPopularRefreshAnimating, setIsPopularRefreshAnimating] = useState(false); + const [pages, setPages] = useState({ + SEARCH: 1, + FAVORITES: 1, + POPULAR: 1, + }); const isMember = useAuthStore((state) => state.user.type === "MEMBER"); + const showToast = useToastStore((state) => state.showToast); const normalizedValue = debouncedValue.replaceAll(" ", "").toLowerCase(); const serverSearch = useQuery({ - queryKey: ["locations", "search", debouncedValue], - queryFn: () => locationApi.search(debouncedValue), + queryKey: ["locations", "search", debouncedValue, pages.SEARCH - 1], + queryFn: () => locationApi.search(debouncedValue, pages.SEARCH - 1), enabled: Boolean(debouncedValue.trim()), + placeholderData: (previousData, previousQuery) => { + const previousKeyword = previousQuery?.queryKey[2]; + return previousKeyword === debouncedValue ? previousData : undefined; + }, staleTime: 60_000, retry: false, }); const serverPopular = useQuery({ queryKey: ["locations", "popular"], queryFn: locationApi.popular, + enabled: activeTab === "POPULAR", staleTime: 60_000, + refetchInterval: activeTab === "POPULAR" ? 10_000 : false, + refetchIntervalInBackground: false, retry: false, }); const serverFavorites = useQuery({ - queryKey: ["locations", "favorites"], - queryFn: locationApi.favorites, + queryKey: [...FAVORITES_QUERY_KEY, "page", pages.FAVORITES - 1], + queryFn: () => locationApi.favorites(pages.FAVORITES - 1), enabled: isMember, + placeholderData: keepPreviousData, retry: false, }); - const favorites = serverFavorites.data ?? []; - const popularLocations = serverPopular.data ?? []; - const searchResults = serverSearch.data ?? []; + const serverFavoriteCatalog = useQuery({ + queryKey: [...FAVORITES_QUERY_KEY, "catalog"], + queryFn: locationApi.favoriteCatalog, + enabled: isMember, + staleTime: 60_000, + retry: 1, + }); + const favorites = serverFavorites.data?.items ?? []; + const favoriteCatalog = serverFavoriteCatalog.data ?? []; + const popularItems = serverPopular.data?.items ?? []; + const popularLocations = popularItems.map((item) => item.location); + const searchResults = serverSearch.data?.items ?? []; + const visiblePages: LocationPages = { + SEARCH: (serverSearch.data?.page ?? pages.SEARCH - 1) + 1, + FAVORITES: (serverFavorites.data?.page ?? pages.FAVORITES - 1) + 1, + POPULAR: clampLocationPage(pages.POPULAR, popularLocations.length), + }; const exactMatch = searchResults.find( (location) => [location.label, location.shortName, location.fullName] .filter((name): name is string => Boolean(name)) .some((name) => name.replaceAll(" ", "").toLowerCase() === normalizedValue), ); + const isFavorite = (location: Location) => [...favoriteCatalog, ...favorites] + .some((item) => (item.id ?? item.label) === (location.id ?? location.label)); const favoriteMutation = useMutation({ - mutationFn: async (location: Location) => { + mutationFn: async ({ location, favorite }: { location: Location; favorite: boolean }) => { if (!location.id) return; - const favorite = favorites.some((item) => (item.id ?? item.label) === (location.id ?? location.label)); - if (favorite) await locationApi.removeFavorite(location.id); - else await locationApi.addFavorite(location.id); + if (favorite) { + await locationApi.removeFavorite(location.id); + return "REMOVED" as const; + } + await locationApi.addFavorite(location.id); + return "ADDED" as const; + }, + onSuccess: (action, { location }) => { + if (!action) return; + queryClient.setQueryData( + [...FAVORITES_QUERY_KEY, "catalog"], + (current) => { + if (!current) return current; + if (action === "REMOVED") { + return current.filter( + (item) => (item.id ?? item.label) !== (location.id ?? location.label), + ); + } + return current.some( + (item) => (item.id ?? item.label) === (location.id ?? location.label), + ) + ? current + : [...current, location]; + }, + ); + const removedLastItemOnPage = action === "REMOVED" + && favorites.some((item) => (item.id ?? item.label) === (location.id ?? location.label)) + && favorites.length === 1 + && pages.FAVORITES > 1; + if (removedLastItemOnPage) { + setPages((currentPages) => ({ + ...currentPages, + FAVORITES: currentPages.FAVORITES - 1, + })); + } + void queryClient.invalidateQueries({ queryKey: FAVORITES_QUERY_KEY }); + }, + onError: () => { + showToast("즐겨찾기를 변경하지 못했어요.", "ERROR"); }, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["locations", "favorites"] }), }); useEffect(() => { @@ -89,14 +172,71 @@ function LocationPickerDialog({ current, isDetecting, detectionError, required, }; const chooseLocation = (location: Location) => onSelect(location); - const isFavorite = (location: Location) => favorites.some((item) => (item.id ?? item.label) === (location.id ?? location.label)); - const toggleFavorite = (location: Location) => { - favoriteMutation.mutate(location); + const toggleFavorite = async (location: Location) => { + if (favoriteMutation.isPending || serverFavoriteCatalog.isFetching) return; + const favoriteOnVisiblePage = favorites.some( + (item) => (item.id ?? item.label) === (location.id ?? location.label), + ); + let catalog = serverFavoriteCatalog.data; + if (!catalog && !favoriteOnVisiblePage) { + const refreshedCatalog = await serverFavoriteCatalog.refetch(); + catalog = refreshedCatalog.data; + if (!catalog) { + showToast("즐겨찾기 정보를 불러오지 못했어요.", "ERROR"); + return; + } + } + const favorite = favoriteOnVisiblePage || (catalog ?? []).some( + (item) => (item.id ?? item.label) === (location.id ?? location.label), + ); + favoriteMutation.mutate({ location, favorite }); + }; + const changePage = (tab: LocationTab, page: number) => { + const pageCount = tab === "SEARCH" + ? Math.max(1, serverSearch.data?.totalPages ?? 1) + : tab === "FAVORITES" + ? Math.max(1, serverFavorites.data?.totalPages ?? 1) + : getLocationPageCount(popularLocations.length); + setPages((currentPages) => ({ + ...currentPages, + [tab]: Math.min(Math.max(1, page), pageCount), + })); }; const detectCandidateLocation = async () => { const location = await onDetect(); if (location) setSelectedLocation(location); }; + const refreshPopular = async () => { + if (isPopularRefreshAnimating) return; + setIsPopularRefreshAnimating(true); + const minimumAnimation = new Promise((resolve) => { + window.setTimeout(resolve, 650); + }); + try { + await Promise.all([ + serverPopular.isFetching ? Promise.resolve() : serverPopular.refetch(), + minimumAnimation, + ]); + } finally { + setIsPopularRefreshAnimating(false); + } + }; + const selectTab = (tab: LocationTab) => { + if (tab !== activeTab) { + setActiveTab(tab); + if (tab === "POPULAR") void refreshPopular(); + return; + } + if (tab === "FAVORITES" && isMember && !serverFavorites.isFetching && !serverFavoriteCatalog.isFetching) { + void Promise.all([serverFavorites.refetch(), serverFavoriteCatalog.refetch()]); + } + if (tab === "POPULAR") { + void refreshPopular(); + if (isMember && !serverFavoriteCatalog.isSuccess && !serverFavoriteCatalog.isFetching) { + void serverFavoriteCatalog.refetch(); + } + } + }; return (
@@ -105,19 +245,19 @@ function LocationPickerDialog({ current, isDetecting, detectionError, required,

어느 동네 날씨를 볼까요?

- {!required && } + {!required && }
{isDetecting ? :

{selectedLocation ? getLocationName(selectedLocation, "full") : current ? getLocationName(current, "full") : "아직 설정되지 않았어요"}

}
- +
{detectionError &&

{detectionError}

}
-
- setActiveTab("SEARCH")} icon={} label="검색" /> - setActiveTab("FAVORITES")} icon={} label="즐겨찾기" /> - setActiveTab("POPULAR")} icon={} label="인기" /> +
+ selectTab("SEARCH")} icon={} label="검색" /> + selectTab("FAVORITES")} icon={} label="즐겨찾기" /> + selectTab("POPULAR")} icon={} label="인기" />
@@ -125,28 +265,31 @@ function LocationPickerDialog({ current, isDetecting, detectionError, required,
- { setValue(truncateText(event.target.value, 30)); setSelectedLocation(null); }} placeholder="시·구 또는 동 이름을 검색하세요" maxLength={30} autoComplete="off" className="h-14 min-w-0 flex-1 border-0 bg-transparent outline-none" /> + { setValue(truncateText(event.target.value, 30)); setSelectedLocation(null); setPages((currentPages) => currentPages.SEARCH === 1 ? currentPages : { ...currentPages, SEARCH: 1 }); }} placeholder="시·구 또는 동 이름을 검색하세요" maxLength={30} autoComplete="off" className="h-14 min-w-0 flex-1 border-0 bg-transparent outline-none" />
- {!serverSearch.isError && searchResults.map((location) => )} - {serverSearch.isFetching &&
{Array.from({ length: 3 }).map((_, index) =>
)}
} + {!serverSearch.isError && searchResults.length > 0 && changePage("SEARCH", page)} onSelect={chooseLocation} isFavorite={isMember ? isFavorite : undefined} onToggleFavorite={isMember ? toggleFavorite : undefined} favoriteDisabled={favoriteMutation.isPending || serverFavoriteCatalog.isFetching} />} + {serverSearch.isFetching && !serverSearch.data &&
{Array.from({ length: 3 }).map((_, index) =>
)}
} {serverSearch.isError && serverSearch.refetch()} />} {value.trim() && !serverSearch.isFetching && !serverSearch.isError && searchResults.length === 0 &&

검색 결과가 없습니다.

} {!value.trim() &&

검색 결과가 없습니다.

}
} {activeTab === "FAVORITES" && (isMember - ? serverFavorites.isError + ? serverFavorites.isError && !serverFavorites.data ? serverFavorites.refetch()} /> - : + : serverFavorites.isPending + ?
{Array.from({ length: 5 }).map((_, index) =>
)}
+ : changePage("FAVORITES", page)} onSelect={chooseLocation} isFavorite={isFavorite} onToggleFavorite={toggleFavorite} favoriteDisabled={favoriteMutation.isPending || serverFavoriteCatalog.isFetching} /> :

회원 전용 기능이에요

로그인하면 자주 보는 동네를
즐겨찾기에 저장할 수 있어요.

)} - {activeTab === "POPULAR" && (serverPopular.isError - ? serverPopular.refetch()} /> - : serverPopular.isFetching - ?
{Array.from({ length: 5 }).map((_, index) =>
)}
- : )} + {activeTab === "POPULAR" && <> + void refreshPopular()} /> + {popularLocations.length === 0 + ?

인기 동네를 찾고 있어요

+ : changePage("POPULAR", page)} onSelect={chooseLocation} isFavorite={isMember ? isFavorite : undefined} onToggleFavorite={isMember ? toggleFavorite : undefined} favoriteDisabled={favoriteMutation.isPending || serverFavoriteCatalog.isFetching} />} + }
- +
@@ -161,21 +304,131 @@ function TabButton({ active, onClick, icon, label }: { active: boolean; onClick: return ; } -function LocationList({ empty, locations, selectedLocation, onSelect, isFavorite, onToggleFavorite }: { +function PopularRefreshControl({ updatedAt, calculatedAt, isFetching, onRefresh }: { + updatedAt: number; + calculatedAt?: string; + isFetching: boolean; + onRefresh: () => void; +}) { + const updatedDate = new Date(updatedAt); + const calculatedDate = calculatedAt ? new Date(calculatedAt) : null; + const updatedAtLabel = `${format(updatedDate, "a h:mm", { locale: ko })} 기준`; + const calculatedAtLabel = calculatedDate && !Number.isNaN(calculatedDate.getTime()) + ? `인기 순위 집계: ${format(calculatedDate, "M월 d일 a h:mm", { locale: ko })}` + : undefined; + + return
+ +
; +} + +function LocationList({ empty, locations, popularities, selectedLocation, onSelect, isFavorite, onToggleFavorite, favoriteDisabled }: { empty?: string; locations: ReadonlyArray; + popularities?: ReadonlyArray; selectedLocation: Location | null; onSelect: (location: Location) => void; isFavorite?: (location: Location) => boolean; onToggleFavorite?: (location: Location) => void; + favoriteDisabled?: boolean; }) { - return locations.length === 0 ?

{empty}

:
{locations.map((location) => )}
; + return locations.length === 0 ?

{empty}

:
{locations.map((location, index) => )}
; } -function LocationRow({ location, selected, favorite, onSelect, onToggleFavorite }: { location: Location; selected: boolean; favorite?: boolean; onSelect: (location: Location) => void; onToggleFavorite?: (location: Location) => void }) { - return
+ + {page} / {pageCount} + + + ; +} + +function getMovementDisplay(movement: PopularLocationMovement, rankChange: number | null) { + const amount = rankChange === null ? "" : Math.abs(rankChange); + if (movement === "UP") return { label: `▲${amount}`, description: `${amount || 0}계단 상승`, className: "text-[#e45f55]" }; + if (movement === "DOWN") return { label: `▼${amount}`, description: `${amount || 0}계단 하락`, className: "text-[#438fce]" }; + if (movement === "NEW") return { label: "NEW", description: "순위 신규 진입", className: "text-[#e78f2d]" }; + if (movement === "SAME") return { label: "―", description: "순위 변동 없음", className: "text-[#8ba0ae]" }; + return { label: "·", description: "이전 순위 없음", className: "text-[#a4b3bd]" }; +} + +function LocationRow({ location, popularity, selected, favorite, onSelect, onToggleFavorite, favoriteDisabled }: { location: Location; popularity?: PopularLocationItem; selected: boolean; favorite?: boolean; onSelect: (location: Location) => void; onToggleFavorite?: (location: Location) => void; favoriteDisabled?: boolean }) { + const movement = popularity ? getMovementDisplay(popularity.movement, popularity.rankChange) : null; + + return
{onToggleFavorite && }
; + {movement && {movement.label}} + {selected && !popularity && } + {onToggleFavorite && }
; } diff --git a/components/name-edit-modal.tsx b/components/name-edit-modal.tsx index c3cb70e..4f329c9 100644 --- a/components/name-edit-modal.tsx +++ b/components/name-edit-modal.tsx @@ -2,6 +2,7 @@ import { X } from "lucide-react"; import { useState } from "react"; +import { useModalNavigation } from "@/hooks/use-modal-navigation"; import { getTextLength } from "@/lib/text"; export function NameEditModal({ @@ -19,6 +20,13 @@ export function NameEditModal({ const nameLength = getTextLength(name); const isValid = getTextLength(normalizedName) > 0 && getTextLength(normalizedName) <= 30; const canSave = isValid && normalizedName !== currentName.trim() && !isSaving; + const closeModal = useModalNavigation({ + open: true, + onBack: () => { + if (!isSaving) onClose(); + }, + onDismiss: onClose, + }); const save = async () => { if (!canSave) return; @@ -38,7 +46,7 @@ export function NameEditModal({

이름 변경

- +
diff --git a/components/nickname-edit-modal.tsx b/components/nickname-edit-modal.tsx index 35d1d87..752374c 100644 --- a/components/nickname-edit-modal.tsx +++ b/components/nickname-edit-modal.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Check, LoaderCircle, X } from "lucide-react"; +import { useModalNavigation } from "@/hooks/use-modal-navigation"; import { authApi } from "@/lib/api/auth-api"; import { getTextLength } from "@/lib/text"; @@ -27,6 +28,13 @@ export function NicknameEditModal({ const normalizedNickname = nickname.trim(); const isValid = isNicknameValid(nickname); const hasFeedback = (!isValid && nickname.length > 0) || checkState === "AVAILABLE" || checkState === "TAKEN"; + const closeModal = useModalNavigation({ + open: true, + onBack: () => { + if (!isSaving) onClose(); + }, + onDismiss: onClose, + }); useEffect(() => () => { if (checkTimerRef.current) clearTimeout(checkTimerRef.current); @@ -76,7 +84,7 @@ export function NicknameEditModal({

닉네임 변경

- +
diff --git a/components/photo-source-sheet.tsx b/components/photo-source-sheet.tsx index 4040099..b0f9670 100644 --- a/components/photo-source-sheet.tsx +++ b/components/photo-source-sheet.tsx @@ -2,6 +2,7 @@ import { Camera, Images, X } from "lucide-react"; import { createPortal } from "react-dom"; +import { useModalNavigation } from "@/hooks/use-modal-navigation"; export function PhotoSourceSheet({ open, @@ -14,16 +15,18 @@ export function PhotoSourceSheet({ onSelectGallery: () => void; onTakePhoto: () => void; }) { + const closeSheet = useModalNavigation({ open, onBack: onClose }); + if (!open || typeof document === "undefined") return null; return createPortal( -
+
closeSheet()}>
event.stopPropagation()}>
, document.body, diff --git a/components/profile-image-modal.tsx b/components/profile-image-modal.tsx index 486fb3c..43fc905 100644 --- a/components/profile-image-modal.tsx +++ b/components/profile-image-modal.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ChangeEvent } from "react"; import { ImagePlus, UserRound, X } from "lucide-react"; +import { useModalNavigation } from "@/hooks/use-modal-navigation"; import { DEFAULT_PROFILE_IMAGES } from "@/lib/constants"; import { isSupportedAvatarSource, normalizeSelectedImage } from "@/lib/image"; import { useToastStore } from "@/store/toast-store"; @@ -27,6 +28,13 @@ export function ProfileImageModal({ const objectUrlRef = useRef(undefined); const [isSaving, setIsSaving] = useState(false); + const closeModal = useModalNavigation({ + open: true, + onBack: () => { + if (!isSaving) onClose(); + }, + onDismiss: onClose, + }); useEffect(() => () => { if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current); @@ -76,7 +84,7 @@ export function ProfileImageModal({

프로필 사진 변경

- +
diff --git a/components/profile-preview-modal.tsx b/components/profile-preview-modal.tsx index c6d62cb..f33ee58 100644 --- a/components/profile-preview-modal.tsx +++ b/components/profile-preview-modal.tsx @@ -1,12 +1,15 @@ "use client"; import { UserRound, X } from "lucide-react"; +import { useModalNavigation } from "@/hooks/use-modal-navigation"; export function ProfilePreviewModal({ avatarUrl, onClose }: { avatarUrl?: string; onClose: () => void }) { + const closeModal = useModalNavigation({ open: true, onBack: onClose }); + return ( -
+
closeModal()}>
event.stopPropagation()}> - + void) => void; export function ReportDeleteConfirmModal({ onClose, @@ -8,16 +11,24 @@ export function ReportDeleteConfirmModal({ isSubmitting = false, }: { onClose: () => void; - onConfirm: () => void; + onConfirm: (dismiss: ModalDismiss) => void; isSubmitting?: boolean; }) { + const closeModal = useModalNavigation({ + open: true, + onBack: () => { + if (!isSubmitting) onClose(); + }, + onDismiss: onClose, + }); + return (

제보 삭제

- +
@@ -25,8 +36,8 @@ export function ReportDeleteConfirmModal({

삭제한 제보와 사진은 다시 복구할 수 없어요.

- - + +
diff --git a/components/report-detail.tsx b/components/report-detail.tsx index 4d822ac..5d7209d 100644 --- a/components/report-detail.tsx +++ b/components/report-detail.tsx @@ -1,18 +1,19 @@ "use client"; import { useMutation, useQuery, useQueryClient, type InfiniteData, type QueryKey } from "@tanstack/react-query"; -import { ArrowLeft, ChevronLeft, ChevronRight, CloudRain, Heart, Sun, Thermometer, Trash2, UserRound } from "lucide-react"; +import { ArrowLeft, CloudRain, Heart, Sun, Thermometer, Trash2, UserRound } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; -import { useRef, useState, type ReactNode } from "react"; +import { useState, type ReactNode, type UIEvent } from "react"; import { ErrorState } from "@/components/error-state"; -import { ReportDeleteConfirmModal } from "@/components/report-delete-confirm-modal"; +import { ReportDeleteConfirmModal, type ModalDismiss } from "@/components/report-delete-confirm-modal"; import { weatherApi } from "@/lib/api"; import { ApiError } from "@/lib/api/http-client"; import { PRECIPITATION_OPTIONS, SUNLIGHT_OPTIONS, TEMPERATURE_OPTIONS, formatThanksCount, getLocationName, statusLabel } from "@/lib/constants"; import { formatReportDateTime } from "@/lib/date"; -import type { ReportPage, ThanksState, WeatherReport } from "@/lib/types"; +import type { ReportPage, ThanksState, WeatherReport, WeatherStatus } from "@/lib/types"; +import { getWeatherStatusTone } from "@/lib/weather-status-tone"; import { useToastStore } from "@/store/toast-store"; import { useAuthStore } from "@/store/auth-store"; import { useLocationStore } from "@/store/location-store"; @@ -119,21 +120,22 @@ export function ReportDetail() { ); }, }); - const deleteReport = useMutation({ + const deleteReport = useMutation({ mutationFn: () => weatherApi.deleteReport(id), - onSuccess: () => { + onSuccess: (_result, dismissModal) => { queryClient.setQueriesData>( { predicate: (query) => isReportListQuery(query.queryKey) }, (data) => removeReportFromPages(data, id), ); void queryClient.invalidateQueries({ queryKey: ["weather-summary"] }); void queryClient.invalidateQueries({ queryKey: ["weather-reports"] }); - setIsDeleteModalOpen(false); showToast("날씨 제보를 삭제했어요.", "SUCCESS"); - router.back(); - window.setTimeout(() => { - queryClient.removeQueries({ queryKey: ["weather-report", id], exact: true }); - }, 100); + dismissModal(() => { + router.back(); + window.setTimeout(() => { + queryClient.removeQueries({ queryKey: ["weather-report", id], exact: true }); + }, 100); + }); }, onError: (error) => { if (error instanceof ApiError && error.code === "REPORT_NOT_FOUND") { @@ -183,14 +185,14 @@ export function ReportDetail() {
- } value={statusLabel(TEMPERATURE_OPTIONS, item.temperature)} /> - } value={statusLabel(PRECIPITATION_OPTIONS, item.precipitation)} /> - } value={statusLabel(SUNLIGHT_OPTIONS, item.sunlight)} /> + } value={statusLabel(TEMPERATURE_OPTIONS, item.temperature)} /> + } value={statusLabel(PRECIPITATION_OPTIONS, item.precipitation)} /> + } value={statusLabel(SUNLIGHT_OPTIONS, item.sunlight)} />

{item.content}

{canDelete &&
}
- {isDeleteModalOpen && setIsDeleteModalOpen(false)} onConfirm={() => deleteReport.mutate()} />} + {isDeleteModalOpen && setIsDeleteModalOpen(false)} onConfirm={(dismissModal) => deleteReport.mutate(dismissModal)} />} ); } @@ -199,46 +201,37 @@ function DetailHeader({ onBack, title, onTitleClick }: { onBack: () => void; tit return
{title ? onTitleClick ? :

{title}

: }
; } -function StatusPill({ icon, value }: { icon: ReactNode; value: string }) { - return
{value}
; +function StatusPill({ status, icon, value }: { status: WeatherStatus; icon: ReactNode; value: string }) { + return
{value}
; } function PhotoCarousel({ images, author }: { images: string[]; author: string }) { - const scrollRef = useRef(null); - const scrollTimerRef = useRef | null>(null); const [currentIndex, setCurrentIndex] = useState(0); const hasMultipleImages = images.length > 1; - const updateIndexAfterScroll = () => { - if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); - scrollTimerRef.current = setTimeout(() => { - const scroller = scrollRef.current; - if (!scroller || scroller.clientWidth === 0) return; - setCurrentIndex(Math.round(scroller.scrollLeft / scroller.clientWidth)); - }, 120); - }; - - const moveTo = (index: number) => { - const nextIndex = Math.max(0, Math.min(index, images.length - 1)); - const scroller = scrollRef.current; - if (!scroller) return; - scroller.scrollTo({ left: scroller.clientWidth * nextIndex, behavior: "smooth" }); - setCurrentIndex(nextIndex); + const updateIndexDuringScroll = (event: UIEvent) => { + const scroller = event.currentTarget; + if (scroller.clientWidth === 0) return; + const nextIndex = Math.max( + 0, + Math.min(images.length - 1, Math.round(scroller.scrollLeft / scroller.clientWidth)), + ); + setCurrentIndex((previousIndex) => previousIndex === nextIndex ? previousIndex : nextIndex); }; return (
-
+
{images.map((image, index) =>
{`${author}의
)}
- {hasMultipleImages && <> - {currentIndex + 1} / {images.length} - {currentIndex > 0 && } - {currentIndex < images.length - 1 && } -
- {images.map((_, index) =>
- } + {hasMultipleImages && {currentIndex + 1} / {images.length}}
); } diff --git a/components/report-form.tsx b/components/report-form.tsx index 17b0c4c..73773ff 100644 --- a/components/report-form.tsx +++ b/components/report-form.tsx @@ -5,7 +5,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, ChevronRight, ImagePlus, LoaderCircle, MapPin, X } from "lucide-react"; import Image from "next/image"; import { useRouter } from "next/navigation"; -import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; @@ -13,11 +13,13 @@ import { LocationDetectingIndicator } from "@/components/location-detecting-indi import { LocationPicker } from "@/components/location-picker"; import { PhotoSourceSheet } from "@/components/photo-source-sheet"; import { useCurrentLocation } from "@/hooks/use-current-location"; +import { useModalNavigation } from "@/hooks/use-modal-navigation"; import { weatherApi } from "@/lib/api"; import { PRECIPITATION_OPTIONS, SUGGESTED_MESSAGES, SUNLIGHT_OPTIONS, TEMPERATURE_OPTIONS, getLocationName } from "@/lib/constants"; import { normalizeSelectedImage, optimizeReportImage } from "@/lib/image"; import { getTextLength, truncateText } from "@/lib/text"; -import type { CreateReportInput, Location, PrecipitationStatus, ReportUploadProgress, SunlightStatus, TemperatureStatus } from "@/lib/types"; +import type { CreateReportInput, Location, PrecipitationStatus, ReportUploadProgress, SunlightStatus, TemperatureStatus, WeatherStatus } from "@/lib/types"; +import { getWeatherStatusTone } from "@/lib/weather-status-tone"; import { useToastStore } from "@/store/toast-store"; const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"]; @@ -81,16 +83,16 @@ function clearReportDraft() { } } -interface StatusOption { value: T; label: string } +interface StatusOption { value: T; label: string } -function StatusField({ title, options, value, onChange }: { title: string; options: ReadonlyArray>; value?: T; onChange: (value?: T) => void }) { +function StatusField({ title, options, value, onChange }: { title: string; options: ReadonlyArray>; value?: T; onChange: (value?: T) => void }) { return (
{title} *
{options.map((option) => { const selected = value === option.value; - return ; })} @@ -106,6 +108,7 @@ export function ReportForm() { const cameraInputRef = useRef(null); const uploadControllerRef = useRef(null); const submitLockRef = useRef(false); + const previewUrlsRef = useRef(new Map()); const [step, setStep] = useState<1 | 2>(1); const [selectedReportLocation, setSelectedReportLocation] = useState(null); const [fileError, setFileError] = useState(""); @@ -113,6 +116,7 @@ export function ReportForm() { const [isPhotoSourceOpen, setIsPhotoSourceOpen] = useState(false); const [uploadProgress, setUploadProgress] = useState(null); const [isDraftReady, setIsDraftReady] = useState(false); + const [previews, setPreviews] = useState>([]); const showToast = useToastStore((state) => state.showToast); const { location, setLocation, isDetecting, detectionError, needsManualInput, setNeedsManualInput, detectLocation } = useCurrentLocation(); const { register, handleSubmit, control, setValue, reset, resetField, formState: { errors } } = useForm({ @@ -129,8 +133,25 @@ export function ReportForm() { const reportLocation = selectedReportLocation ?? location; const canGoToStory = Boolean(reportLocation && temperature && precipitation && sunlight); const canSubmitReport = content.trim().length > 0 && contentLength <= 100; - const previews = useMemo(() => files.map((file) => ({ file, url: URL.createObjectURL(file) })), [files]); - useEffect(() => () => previews.forEach(({ url }) => URL.revokeObjectURL(url)), [previews]); + useEffect(() => { + const activeFiles = new Set(files); + previewUrlsRef.current.forEach((url, file) => { + if (activeFiles.has(file)) return; + URL.revokeObjectURL(url); + previewUrlsRef.current.delete(file); + }); + setPreviews(files.map((file) => { + const existingUrl = previewUrlsRef.current.get(file); + if (existingUrl) return { file, url: existingUrl }; + const url = URL.createObjectURL(file); + previewUrlsRef.current.set(file, url); + return { file, url }; + })); + }, [files]); + useEffect(() => () => { + previewUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); + previewUrlsRef.current.clear(); + }, []); useEffect(() => () => uploadControllerRef.current?.abort(), []); useEffect(() => { const timeout = window.setTimeout(() => { @@ -196,7 +217,7 @@ export function ReportForm() { queryClient.invalidateQueries({ queryKey: ["weather-summary"] }); queryClient.invalidateQueries({ queryKey: ["my-weather-reports"] }); showToast("날씨 제보를 올렸어요.", "SUCCESS"); - router.replace(`/reports/${report.id}`); + dismissStoryStep(() => router.replace(`/reports/${report.id}`)); }, onError: (error) => { if (error instanceof DOMException && error.name === "AbortError") return; @@ -209,6 +230,18 @@ export function ReportForm() { }, }); + const dismissStoryStep = useModalNavigation({ + open: step === 2, + onBack: () => { + if (mutation.isPending) { + showToast("제보를 등록하는 동안에는 화면을 이동할 수 없어요.", "INFO"); + return; + } + setStep(1); + }, + onDismiss: () => setStep(1), + }); + useEffect(() => { const discardDraftBeforeHistoryExit = () => { if (mutation.isPending) return; @@ -238,19 +271,12 @@ export function ReportForm() { event.preventDefault(); event.returnValue = ""; }; - const preventHistoryBack = (event: PopStateEvent) => { - event.stopImmediatePropagation(); - window.history.forward(); - showToast("제보를 등록하는 동안에는 화면을 이동할 수 없어요.", "INFO"); - }; window.addEventListener("beforeunload", preventPageExit); - window.addEventListener("popstate", preventHistoryBack, true); return () => { window.removeEventListener("beforeunload", preventPageExit); - window.removeEventListener("popstate", preventHistoryBack, true); }; - }, [mutation.isPending, showToast]); + }, [mutation.isPending]); const addImages = async (event: ChangeEvent) => { const requested = Array.from(event.target.files ?? []).map(normalizeSelectedImage); @@ -272,7 +298,10 @@ export function ReportForm() { setFileError(""); setIsOptimizingImages(true); try { - const optimized = await Promise.all(selected.map(optimizeReportImage)); + const optimized: File[] = []; + for (const file of selected) { + optimized.push(await optimizeReportImage(file)); + } if (optimized.some((file) => file.size > MAX_IMAGE_SIZE)) { setFileError("최적화 후에도 5MB를 넘는 사진이 있어요. 다른 사진을 선택해 주세요."); return; @@ -300,7 +329,8 @@ export function ReportForm() { }; const goToStoryStep = () => { - if (canGoToStory) setStep(2); + if (!canGoToStory) return; + setStep(2); }; const uploadLabel = uploadProgress?.stage === "UPLOADING" @@ -312,10 +342,10 @@ export function ReportForm() { return (
-
- -

날씨 제보하기

- +
+ +

날씨 제보하기

+
1 @@ -328,7 +358,7 @@ export function ReportForm() { - diff --git a/components/user-panel.tsx b/components/user-panel.tsx index ee75685..6401105 100644 --- a/components/user-panel.tsx +++ b/components/user-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { useRouter } from "next/navigation"; import { @@ -22,6 +22,7 @@ import { import { NameEditModal } from "@/components/name-edit-modal"; import { WithdrawalConfirmModal } from "@/components/withdrawal-confirm-modal"; import { SocialIcon } from "@/components/social-icon"; +import { useModalNavigation } from "@/hooks/use-modal-navigation"; import { authApi } from "@/lib/api/auth-api"; import { resolveApiUrl } from "@/lib/api/config"; import { memberApi } from "@/lib/api/member-api"; @@ -64,7 +65,29 @@ export function UserPanel({ const [feedback, setFeedback] = useState(""); const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false); const [checkingProvider, setCheckingProvider] = useState(null); + const socialNavigationRequestRef = useRef(0); + const isOpenRef = useRef(open); const member = user.type === "MEMBER" ? user : null; + const closePanelState = () => { + socialNavigationRequestRef.current += 1; + setView("MAIN"); + setIsWithdrawalOpen(false); + setIsNameEditOpen(false); + setCheckingProvider(null); + onClose(); + }; + const closePanel = useModalNavigation({ + open, + onBack: () => { + if (isSubmittingFeedback || isWithdrawing) return; + if ((member && view === "ACCOUNT") || view === "FEEDBACK") { + setView("MAIN"); + return; + } + closePanelState(); + }, + onDismiss: closePanelState, + }); const account = useQuery({ queryKey: ["members", "me"], queryFn: memberApi.getMe, @@ -72,6 +95,10 @@ export function UserPanel({ retry: false, }); + useEffect(() => { + isOpenRef.current = open; + }, [open]); + useEffect(() => { const resetSocialNavigationState = () => setCheckingProvider(null); const resetWhenVisible = () => { @@ -92,25 +119,20 @@ export function UserPanel({ if (!open) return null; - const closePanel = () => { - setView("MAIN"); - setIsWithdrawalOpen(false); - setIsNameEditOpen(false); - setCheckingProvider(null); - onClose(); - }; - - const navigateToSocialAuth = (url: string) => { + const navigateToSocialAuth = (url: string, requestId: number) => { + if (!isOpenRef.current || socialNavigationRequestRef.current !== requestId) return; flushSync(() => setCheckingProvider(null)); - window.location.assign(url); + closePanel(() => window.location.assign(url)); }; const openServerLogin = async (provider: SocialProvider) => { + const requestId = ++socialNavigationRequestRef.current; setCheckingProvider(provider); try { await authApi.checkHealth(); - navigateToSocialAuth(authApi.getLoginUrl(provider)); + navigateToSocialAuth(authApi.getLoginUrl(provider), requestId); } catch { + if (socialNavigationRequestRef.current !== requestId) return; setCheckingProvider(null); showToast("로그인 서버에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.", "ERROR"); } @@ -119,11 +141,12 @@ export function UserPanel({ const handleLogout = async () => { try { await authApi.logout(); - queryClient.clear(); - clearUser(); - closePanel(); showToast("로그아웃했어요.", "INFO"); - router.replace("/"); + closePanel(() => { + queryClient.clear(); + clearUser(); + router.replace("/"); + }); } catch (error) { showToast(error instanceof Error ? error.message : "로그아웃하지 못했어요.", "ERROR"); } @@ -152,6 +175,7 @@ export function UserPanel({ const handleSocialToggle = async (provider: SocialProvider) => { const memberAccount = account.data; if (!memberAccount) return; + const requestId = ++socialNavigationRequestRef.current; const linked = memberAccount.connectedProviders.includes(provider); if (linked && memberAccount.connectedProviders.length === 1) { showToast("로그인 수단을 하나 이상 유지해야 해요. 다른 소셜 계정을 연동한 뒤 해제해 주세요.", "INFO"); @@ -165,12 +189,13 @@ export function UserPanel({ showToast(`${providerLabel[provider]} 계정 연동을 해제했어요.`, "SUCCESS"); } else { const { authorizationUrl } = await authApi.linkSocial(provider); - navigateToSocialAuth(resolveApiUrl(authorizationUrl)); + navigateToSocialAuth(resolveApiUrl(authorizationUrl), requestId); } } catch (error) { + if (socialNavigationRequestRef.current !== requestId) return; showToast(error instanceof Error ? error.message : `소셜 계정 ${linked ? "연동을 해제" : "연동을 시작"}하지 못했어요.`, "ERROR"); } finally { - setCheckingProvider(null); + if (socialNavigationRequestRef.current === requestId) setCheckingProvider(null); } }; @@ -191,11 +216,12 @@ export function UserPanel({ setIsWithdrawing(true); try { await authApi.withdraw(); - queryClient.clear(); - clearUser(); - closePanel(); showToast("회원 탈퇴가 완료됐어요.", "SUCCESS"); - router.replace("/"); + closePanel(() => { + queryClient.clear(); + clearUser(); + router.replace("/"); + }); } catch (error) { showToast(error instanceof Error ? error.message : "회원 탈퇴를 완료하지 못했어요.", "ERROR"); } finally { @@ -214,14 +240,14 @@ export function UserPanel({
{isSubView ? : }

{isAccountView ? "계정 정보" : isFeedbackView ? "서비스 피드백" : member ? "설정" : "로그인"}

- +
{isAccountView ? (
{account.isLoading ?
: <> -
+

이름

{memberAccount?.name || "정보 없음"}

@@ -234,12 +260,12 @@ export function UserPanel({
-
소셜 연동
+
소셜 연동
{socialProviders.map((provider, index) => { const isCurrent = memberAccount?.currentProvider === provider; const isLinked = memberAccount?.connectedProviders.includes(provider) ?? false; const isLastLinked = isLinked && memberAccount?.connectedProviders.length === 1; - return
+ return

{providerLabel[provider]}

{isCurrent ? "현재 로그인" : isLinked ? "연동됨" : "연동 안 됨"}

@@ -260,11 +286,11 @@ export function UserPanel({
) : member ? <>
- +
- +
@@ -275,8 +301,8 @@ export function UserPanel({
- - + +

© 2026 날씨로그. All rights reserved.

diff --git a/components/weather-summary.tsx b/components/weather-summary.tsx index 48d4304..9c4bb9a 100644 --- a/components/weather-summary.tsx +++ b/components/weather-summary.tsx @@ -2,6 +2,7 @@ import { CloudRain, Sun, Thermometer } from "lucide-react"; import type { ReactNode } from "react"; import { PRECIPITATION_OPTIONS, SUNLIGHT_OPTIONS, TEMPERATURE_OPTIONS, statusLabel } from "@/lib/constants"; import type { WeatherSummary as Summary } from "@/lib/types"; +import { getWeatherStatusTone } from "@/lib/weather-status-tone"; export function WeatherSummary({ summary }: { summary: Summary }) { const temperature = summary.temperature; @@ -10,9 +11,9 @@ export function WeatherSummary({ summary }: { summary: Summary }) { return (
- } label="체감온도" value={temperature ? statusLabel(TEMPERATURE_OPTIONS, temperature) : "제보 없음"} tone={temperature ? temperatureTone[temperature] : emptyTone} /> - } label="비" value={precipitation ? statusLabel(PRECIPITATION_OPTIONS, precipitation) : "제보 없음"} tone={precipitation ? precipitationTone[precipitation] : emptyTone} /> - } label="햇빛" value={sunlight ? statusLabel(SUNLIGHT_OPTIONS, sunlight) : "제보 없음"} tone={sunlight ? sunlightTone[sunlight] : emptyTone} /> + } label="체감온도" value={temperature ? statusLabel(TEMPERATURE_OPTIONS, temperature) : "제보 없음"} tone={temperature ? getWeatherStatusTone(temperature).badge : emptyTone} /> + } label="비" value={precipitation ? statusLabel(PRECIPITATION_OPTIONS, precipitation) : "제보 없음"} tone={precipitation ? getWeatherStatusTone(precipitation).badge : emptyTone} /> + } label="햇빛" value={sunlight ? statusLabel(SUNLIGHT_OPTIONS, sunlight) : "제보 없음"} tone={sunlight ? getWeatherStatusTone(sunlight).badge : emptyTone} />
); @@ -36,24 +37,6 @@ export function WeatherSummarySkeleton() { ); } -const temperatureTone = { - COLD: "bg-[#e8f3ff] text-[#397bb5]", - FRESH: "bg-[#e4f7ee] text-[#318561]", - HOT: "bg-[#fff0e8] text-[#c86638]", -} as const; - -const precipitationTone = { - NONE: "bg-[#edf3f6] text-[#617887]", - LIGHT: "bg-[#e5f4ff] text-[#3587bd]", - HEAVY: "bg-[#e8edff] text-[#536bb2]", -} as const; - -const sunlightTone = { - LOW: "bg-[#edf1f4] text-[#667986]", - MODERATE: "bg-[#fff7dc] text-[#a8791e]", - STRONG: "bg-[#fff0d9] text-[#c66d19]", -} as const; - const emptyTone = "bg-[#edf3f6] text-[#718594]"; function SummaryItem({ icon, label, value, tone }: { icon: ReactNode; label: string; value: string; tone: string }) { diff --git a/components/withdrawal-confirm-modal.tsx b/components/withdrawal-confirm-modal.tsx index f66896c..5e23e9b 100644 --- a/components/withdrawal-confirm-modal.tsx +++ b/components/withdrawal-confirm-modal.tsx @@ -1,6 +1,7 @@ "use client"; import { AlertTriangle, X } from "lucide-react"; +import { useModalNavigation } from "@/hooks/use-modal-navigation"; export function WithdrawalConfirmModal({ onClose, @@ -11,13 +12,21 @@ export function WithdrawalConfirmModal({ onConfirm: () => void; isSubmitting?: boolean; }) { + const closeModal = useModalNavigation({ + open: true, + onBack: () => { + if (!isSubmitting) onClose(); + }, + onDismiss: onClose, + }); + return (

회원 탈퇴

- +
@@ -25,7 +34,7 @@ export function WithdrawalConfirmModal({

회원 정보와 즐겨찾기는 복구할 수 없고,
작성한 제보는 익명으로 유지돼요.

- +
diff --git a/hooks/use-current-location.ts b/hooks/use-current-location.ts index dde1551..53bbb0e 100644 --- a/hooks/use-current-location.ts +++ b/hooks/use-current-location.ts @@ -3,86 +3,256 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { weatherApi } from "@/lib/api"; import { locationApi } from "@/lib/api/location-api"; +import { shouldRefreshLocationAfterResume } from "@/lib/location-auto-refresh"; import { logger } from "@/lib/logging"; import type { Location } from "@/lib/types"; import { useLocationStore } from "@/store/location-store"; const locationLogger = logger.child("location.detection"); const refreshedLocationIds = new Set(); +interface LocationDetectionResult { + location: Location | null; + error: string | null; +} + +let activeDetectionPromise: Promise | null = null; +let activeDetectionMode: "automatic" | "manual" | null = null; +let manualDetectionGeneration = 0; +let hasRequestedColdDetection = false; +let lastAutomaticDetectionAt = 0; + +const unsupportedMessage = "이 브라우저에서는 현재 위치를 사용할 수 없어요."; +const permissionDeniedMessage = "위치 권한이 없어 현재 동네를 찾지 못했어요. 동네를 직접 검색해 주세요."; +const locationFailureMessage = "현재 위치를 확인하지 못했어요. 다시 시도하거나 동네를 검색해 주세요."; +const reverseGeocodeFailureMessage = "위치 서버에 연결하지 못했어요. 다시 시도하거나 동네를 검색해 주세요."; + +interface AutomaticDetectionOptions { + automatic?: boolean; + allowPermissionPrompt?: boolean; + silentError?: boolean; +} + +function getLocationIdentity(location: Location | null) { + if (!location) return null; + return location.id || `${location.latitude}:${location.longitude}:${location.label}`; +} + +async function getGeolocationPermissionState(): Promise { + if (!navigator.permissions?.query) return "unsupported"; + try { + const permission = await navigator.permissions.query({ name: "geolocation" }); + return permission.state; + } catch { + return "unsupported"; + } +} + +function getCurrentPosition() { + return new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: true, + timeout: 12_000, + maximumAge: 0, + }); + }); +} + +function isGeolocationError(error: unknown): error is GeolocationPositionError { + return typeof error === "object" + && error !== null + && "code" in error + && typeof (error as { code?: unknown }).code === "number"; +} + +async function detectCurrentLocation( + applyImmediately = false, + { + automatic = false, + allowPermissionPrompt = false, + silentError = false, + }: AutomaticDetectionOptions = {}, +): Promise { + const startingLocationIdentity = getLocationIdentity(useLocationStore.getState().location); + const manualGenerationAtRequest = manualDetectionGeneration; + if (!automatic) manualDetectionGeneration += 1; + + if (typeof navigator === "undefined" || !navigator.geolocation) { + if (!silentError) useLocationStore.getState().finishDetection(unsupportedMessage); + locationLogger.warn("geolocation_unavailable", { reason: "unsupported_browser" }); + return null; + } + + if (automatic) { + const permissionState = await getGeolocationPermissionState(); + if (permissionState !== "granted" && !allowPermissionPrompt) { + locationLogger.debug("automatic_geolocation_skipped", { permissionState }); + return null; + } + } + + const canApplyAutomaticResult = !activeDetectionPromise || activeDetectionMode === "automatic"; + const applyResult = async (detectionPromise: Promise) => { + const result = await detectionPromise; + if (result.error) { + if (silentError) { + useLocationStore.getState().stopDetection(); + } else { + useLocationStore.getState().finishDetection(result.error); + } + return null; + } + + useLocationStore.getState().finishDetection(); + if (!applyImmediately || !result.location) return result.location; + + const currentLocationIdentity = getLocationIdentity(useLocationStore.getState().location); + if (automatic && ( + !canApplyAutomaticResult + || manualDetectionGeneration !== manualGenerationAtRequest + || currentLocationIdentity !== startingLocationIdentity + )) { + locationLogger.debug("automatic_geolocation_apply_skipped", { + reason: "manual_detection_or_location_change", + }); + return result.location; + } + + useLocationStore.getState().setLocation(result.location); + return result.location; + }; + + if (activeDetectionPromise) return applyResult(activeDetectionPromise); + + const store = useLocationStore.getState(); + store.markDetectionAttempted(); + store.startDetection(); + + const detectionPromise = (async () => { + let position: GeolocationPosition; + try { + position = await getCurrentPosition(); + locationLogger.debug("geolocation_resolved", { + accuracy: Math.round(position.coords.accuracy), + }); + } catch (error) { + if (isGeolocationError(error) && error.code === error.PERMISSION_DENIED) { + locationLogger.info("geolocation_permission_denied"); + } else { + locationLogger.warn("geolocation_failed", { + code: isGeolocationError(error) ? error.code : "unknown", + }); + } + return { + location: null, + error: isGeolocationError(error) && error.code === error.PERMISSION_DENIED + ? permissionDeniedMessage + : locationFailureMessage, + }; + } + + try { + const location = await weatherApi.reverseGeocode( + position.coords.latitude, + position.coords.longitude, + ); + return { location, error: null }; + } catch (error) { + locationLogger.warn("reverse_geocode_failed", { + reason: error instanceof Error ? error.name : "unknown", + }); + return { location: null, error: reverseGeocodeFailureMessage }; + } + })(); + + activeDetectionPromise = detectionPromise; + activeDetectionMode = automatic ? "automatic" : "manual"; + void detectionPromise.finally(() => { + if (activeDetectionPromise === detectionPromise) { + activeDetectionPromise = null; + activeDetectionMode = null; + } + }); + return applyResult(detectionPromise); +} -export function useCurrentLocation() { - const { location, setLocation, hasAttemptedDetection, markDetectionAttempted } = useLocationStore(); - const [isDetecting, setIsDetecting] = useState(false); +export function useCurrentLocation({ refreshOnHomeResume = false } = {}) { + const { + location, + setLocation, + isDetecting, + detectionError, + } = useLocationStore(); const [needsManualInput, setNeedsManualInputState] = useState(false); - const [detectionError, setDetectionError] = useState(""); - const detectionPromiseRef = useRef | null>(null); + const hiddenAtRef = useRef(null); + const needsManualInputRef = useRef(false); const setNeedsManualInput = useCallback((next: boolean) => { + needsManualInputRef.current = next; setNeedsManualInputState(next); }, []); - const detectLocation = useCallback((applyImmediately = false): Promise => { - if (detectionPromiseRef.current) return detectionPromiseRef.current; - markDetectionAttempted(); - setDetectionError(""); - if (!navigator.geolocation) { - locationLogger.warn("geolocation_unavailable", { reason: "unsupported_browser" }); - setDetectionError("이 브라우저에서는 현재 위치를 사용할 수 없어요."); - return Promise.resolve(null); - } - setIsDetecting(true); - const detectionPromise = new Promise((resolve) => { - navigator.geolocation.getCurrentPosition( - async ({ coords }) => { - try { - locationLogger.debug("geolocation_resolved", { - accuracy: Math.round(coords.accuracy), - }); - const result = await weatherApi.reverseGeocode(coords.latitude, coords.longitude); - if (applyImmediately) { - setLocation(result); - setNeedsManualInputState(false); - } - resolve(result); - } catch (error) { - locationLogger.warn("reverse_geocode_failed", { - reason: error instanceof Error ? error.name : "unknown", - }); - setDetectionError("위치 서버에 연결하지 못했어요. 다시 시도하거나 동네를 검색해 주세요."); - resolve(null); - } finally { - setIsDetecting(false); - } - }, - (error) => { - if (error.code === error.PERMISSION_DENIED) { - locationLogger.info("geolocation_permission_denied"); - } else { - locationLogger.warn("geolocation_failed", { code: error.code }); - } - setIsDetecting(false); - setDetectionError(error.code === error.PERMISSION_DENIED - ? "위치 권한이 없어 현재 동네를 찾지 못했어요. 동네를 직접 검색해 주세요." - : "현재 위치를 확인하지 못했어요. 다시 시도하거나 동네를 검색해 주세요."); - resolve(null); - }, - { enableHighAccuracy: true, timeout: 12_000, maximumAge: 0 }, - ); - }); - const trackedPromise = detectionPromise.finally(() => { - detectionPromiseRef.current = null; - }); - detectionPromiseRef.current = trackedPromise; - return trackedPromise; - }, [markDetectionAttempted, setLocation]); + const detectLocation = useCallback((applyImmediately = false) => ( + detectCurrentLocation(applyImmediately) + ), []); useEffect(() => { - if (hasAttemptedDetection || location) return; + if (!refreshOnHomeResume || hasRequestedColdDetection) return; + const timeout = window.setTimeout(() => { - void detectLocation(true); + if (hasRequestedColdDetection) return; + hasRequestedColdDetection = true; + const currentState = useLocationStore.getState(); + lastAutomaticDetectionAt = Date.now(); + void detectCurrentLocation(true, { + automatic: true, + allowPermissionPrompt: !currentState.location && !currentState.hasAttemptedDetection, + silentError: Boolean(currentState.location), + }); }, 0); return () => window.clearTimeout(timeout); - }, [detectLocation, hasAttemptedDetection, location]); + }, [refreshOnHomeResume]); + + useEffect(() => { + if (!refreshOnHomeResume) return; + + const markHidden = () => { + if (hiddenAtRef.current === null) hiddenAtRef.current = Date.now(); + }; + const refreshAfterResume = () => { + const now = Date.now(); + const hiddenAt = hiddenAtRef.current; + hiddenAtRef.current = null; + if (needsManualInputRef.current) return; + if (!shouldRefreshLocationAfterResume({ + hiddenAt, + now, + lastRefreshAt: lastAutomaticDetectionAt, + })) return; + + lastAutomaticDetectionAt = now; + void detectCurrentLocation(true, { + automatic: true, + allowPermissionPrompt: false, + silentError: true, + }); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") markHidden(); + if (document.visibilityState === "visible") refreshAfterResume(); + }; + const handlePageShow = (event: PageTransitionEvent) => { + if (event.persisted) refreshAfterResume(); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("pagehide", markHidden); + window.addEventListener("pageshow", handlePageShow); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("pagehide", markHidden); + window.removeEventListener("pageshow", handlePageShow); + }; + }, [refreshOnHomeResume]); useEffect(() => { const locationId = location?.id; @@ -93,5 +263,13 @@ export function useCurrentLocation() { }); }, [location?.id, setLocation]); - return { location, setLocation, isDetecting, detectionError, needsManualInput, setNeedsManualInput, detectLocation }; + return { + location, + setLocation, + isDetecting, + detectionError, + needsManualInput, + setNeedsManualInput, + detectLocation, + }; } diff --git a/hooks/use-modal-navigation.ts b/hooks/use-modal-navigation.ts new file mode 100644 index 0000000..c1b1d1d --- /dev/null +++ b/hooks/use-modal-navigation.ts @@ -0,0 +1,172 @@ +"use client"; + +import { useCallback, useEffect, useId, useRef } from "react"; + +const MODAL_HISTORY_KEY = "__nalssilogModal"; +const MODAL_HISTORY_VALUE = "open"; + +interface ModalLayer { + id: string; + onBack: () => void; + onDismiss: () => void; +} + +interface PendingDismiss { + id: string; + afterDismiss?: () => void; +} + +const modalLayers: ModalLayer[] = []; +let pendingDismiss: PendingDismiss | null = null; +let ensureTimer: number | null = null; +let isListening = false; +let ignoreNextPop = false; + +const hasModalHistoryEntry = () => + typeof window !== "undefined" + && window.history.state?.[MODAL_HISTORY_KEY] === MODAL_HISTORY_VALUE; + +const pushModalHistoryEntry = () => { + if (hasModalHistoryEntry()) return; + const currentState = window.history.state; + const nextState = currentState && typeof currentState === "object" + ? { ...currentState, [MODAL_HISTORY_KEY]: MODAL_HISTORY_VALUE } + : { [MODAL_HISTORY_KEY]: MODAL_HISTORY_VALUE }; + window.history.pushState(nextState, "", window.location.href); +}; + +const scheduleHistorySync = () => { + if (ensureTimer) window.clearTimeout(ensureTimer); + ensureTimer = window.setTimeout(() => { + ensureTimer = null; + if (modalLayers.length > 0) { + pushModalHistoryEntry(); + return; + } + + // 외부 성공 콜백처럼 상태가 직접 닫힌 경우에도 보이지 않는 모달 + // history 항목이 남지 않도록 같은 URL의 sentinel을 소비한다. + if (hasModalHistoryEntry()) { + ignoreNextPop = true; + ensurePopStateListener(); + window.history.back(); + return; + } + removePopStateListenerWhenIdle(); + }, 0); +}; + +const handlePopState = (event: PopStateEvent) => { + if (ignoreNextPop) { + ignoreNextPop = false; + event.stopImmediatePropagation(); + if (modalLayers.length > 0) pushModalHistoryEntry(); + else removePopStateListenerWhenIdle(); + return; + } + + const topLayer = modalLayers.at(-1); + if (!topLayer) return; + event.stopImmediatePropagation(); + + const requestedDismiss = pendingDismiss; + pendingDismiss = null; + let afterDismiss: (() => void) | undefined; + + if (requestedDismiss) { + const requestedLayer = modalLayers.find((layer) => layer.id === requestedDismiss.id); + (requestedLayer ?? topLayer).onDismiss(); + afterDismiss = requestedDismiss.afterDismiss; + } else { + topLayer.onBack(); + } + + // 하위 모달을 닫았거나 패널의 서브 화면에서 돌아온 경우에는 + // 남아 있는 최상위 모달을 위해 동일 URL의 history 항목을 다시 둔다. + scheduleHistorySync(); + if (afterDismiss) window.setTimeout(afterDismiss, 0); +}; + +const ensurePopStateListener = () => { + if (isListening) return; + window.addEventListener("popstate", handlePopState, true); + isListening = true; +}; + +const removePopStateListenerWhenIdle = () => { + if (!isListening || modalLayers.length > 0) return; + window.removeEventListener("popstate", handlePopState, true); + isListening = false; +}; + +const registerModalLayer = (layer: ModalLayer) => { + const existingIndex = modalLayers.findIndex((candidate) => candidate.id === layer.id); + if (existingIndex >= 0) modalLayers.splice(existingIndex, 1); + modalLayers.push(layer); + ensurePopStateListener(); + pushModalHistoryEntry(); + + return () => { + const index = modalLayers.findIndex((candidate) => candidate.id === layer.id); + if (index >= 0) modalLayers.splice(index, 1); + if (pendingDismiss?.id === layer.id) pendingDismiss = null; + scheduleHistorySync(); + }; +}; + +const dismissModalLayer = (id: string, afterDismiss?: () => void) => { + const requestedLayer = modalLayers.find((layer) => layer.id === id); + if (!requestedLayer) { + afterDismiss?.(); + return; + } + + if (!hasModalHistoryEntry()) { + requestedLayer.onDismiss(); + scheduleHistorySync(); + if (afterDismiss) window.setTimeout(afterDismiss, 0); + return; + } + + if (pendingDismiss) return; + pendingDismiss = { id, afterDismiss }; + window.history.back(); +}; + +/** + * 열린 모달 위에 현재 URL과 같은 history 항목을 하나 둔다. + * 모바일/브라우저 뒤로가기는 페이지보다 최상위 모달에 먼저 전달된다. + */ +export function useModalNavigation({ + open, + onBack, + onDismiss = onBack, +}: { + open: boolean; + onBack: () => void; + onDismiss?: () => void; +}) { + const reactId = useId(); + const layerId = `modal-${reactId}`; + const onBackRef = useRef(onBack); + const onDismissRef = useRef(onDismiss); + + useEffect(() => { + onBackRef.current = onBack; + onDismissRef.current = onDismiss; + }, [onBack, onDismiss]); + + useEffect(() => { + if (!open) return; + return registerModalLayer({ + id: layerId, + onBack: () => onBackRef.current(), + onDismiss: () => onDismissRef.current(), + }); + }, [layerId, open]); + + return useCallback( + (afterDismiss?: () => void) => dismissModalLayer(layerId, afterDismiss), + [layerId], + ); +} diff --git a/lib/api/http-weather-api.ts b/lib/api/http-weather-api.ts index 055f899..64fb3c9 100644 --- a/lib/api/http-weather-api.ts +++ b/lib/api/http-weather-api.ts @@ -97,7 +97,7 @@ function normalizeReport(report: BackendReport): WeatherReport { async function resolveBackendLocation(location: Location) { if (location.id && /^\d+$/.test(location.id)) return location; - const candidates = await locationApi.search(location.fullName ?? location.label); + const { items: candidates } = await locationApi.search(location.fullName ?? location.label); return candidates.find((candidate) => candidate.fullName === location.fullName || candidate.label === location.label, ) ?? candidates[0] ?? location; diff --git a/lib/api/location-api.test.ts b/lib/api/location-api.test.ts index 97c5e01..69e0b10 100644 --- a/lib/api/location-api.test.ts +++ b/lib/api/location-api.test.ts @@ -1,7 +1,19 @@ -import { describe, expect, it } from "vitest"; -import { normalizeLocation } from "@/lib/api/location-api"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { apiRequest } from "@/lib/api/http-client"; +import { locationApi, normalizeLocation } from "@/lib/api/location-api"; + +vi.mock("@/lib/api/http-client", () => ({ + apiRequest: vi.fn(), + jsonRequest: vi.fn(), +})); + +const apiRequestMock = vi.mocked(apiRequest); describe("normalizeLocation", () => { + beforeEach(() => { + apiRequestMock.mockReset(); + }); + it("uses backend labels without composing administrative fields", () => { const location = normalizeLocation({ id: "101", @@ -16,4 +28,136 @@ describe("normalizeLocation", () => { expect(location.fullName).toBe("경기도 수원시 영통구 이의동"); expect(location.shortName).toBe("수원시 영통구 이의동"); }); + + it("requests a zero-based search page and normalizes its items", async () => { + apiRequestMock.mockResolvedValueOnce({ + items: [{ + id: "101", + sido: "경기도", + sigungu: "수원시 영통구", + dong: "이의동", + label: "경기도 수원시 영통구 이의동", + shortLabel: "수원시 영통구 이의동", + }], + page: 2, + size: 5, + totalElements: 18, + totalPages: 4, + hasPrevious: true, + hasNext: true, + }); + + const page = await locationApi.search("서", 2); + + expect(apiRequestMock).toHaveBeenCalledWith("/api/locations?keyword=%EC%84%9C&page=2"); + expect(page.items[0]?.label).toBe("경기도 수원시 영통구 이의동"); + expect(page.page).toBe(2); + expect(page.totalPages).toBe(4); + }); + + it("requests a zero-based favorites page", async () => { + apiRequestMock.mockResolvedValueOnce({ + items: [], + page: 0, + size: 5, + totalElements: 0, + totalPages: 0, + hasPrevious: false, + hasNext: false, + }); + + const page = await locationApi.favorites(0); + + expect(apiRequestMock).toHaveBeenCalledWith("/api/locations/favorites?page=0"); + expect(page.items).toEqual([]); + expect(page.totalPages).toBe(0); + }); + + it("loads and deduplicates every favorites page for exact star state", async () => { + const firstLocation = { + id: "101", + sido: "경기도", + sigungu: "수원시 영통구", + dong: "이의동", + label: "경기도 수원시 영통구 이의동", + shortLabel: "수원시 영통구 이의동", + }; + const secondLocation = { + id: "202", + sido: "서울특별시", + sigungu: "강남구", + dong: "역삼동", + label: "서울특별시 강남구 역삼동", + shortLabel: "강남구 역삼동", + }; + apiRequestMock + .mockResolvedValueOnce({ + items: [firstLocation], + page: 0, + size: 5, + totalElements: 2, + totalPages: 2, + hasPrevious: false, + hasNext: true, + }) + .mockResolvedValueOnce({ + items: [firstLocation, secondLocation], + page: 1, + size: 5, + totalElements: 2, + totalPages: 2, + hasPrevious: true, + hasNext: false, + }); + + const favorites = await locationApi.favoriteCatalog(); + + expect(apiRequestMock).toHaveBeenNthCalledWith(1, "/api/locations/favorites?page=0"); + expect(apiRequestMock).toHaveBeenNthCalledWith(2, "/api/locations/favorites?page=1"); + expect(favorites.map((location) => location.id)).toEqual(["101", "202"]); + }); + + it("normalizes the atomic popular snapshot without query parameters", async () => { + apiRequestMock.mockResolvedValueOnce({ + snapshotId: "31", + calculatedAt: "2026-07-30T08:30:00Z", + windowStartedAt: "2026-07-23T08:30:00Z", + windowEndedAt: "2026-07-30T08:30:00Z", + algorithmVersion: "UNIQUE_REPORTERS_V1", + pageSize: 5, + totalElements: 1, + totalPages: 1, + items: [{ + rank: 1, + previousRank: 3, + rankChange: 2, + movement: "UP", + uniqueReporterCount: 4, + reportCount: 7, + latestReportAt: "2026-07-30T08:20:00Z", + location: { + id: "101", + sido: "경기도", + sigungu: "수원시 영통구", + dong: "이의동", + label: "경기도 수원시 영통구 이의동", + shortLabel: "수원시 영통구 이의동", + }, + }], + }); + + const snapshot = await locationApi.popular(); + + expect(apiRequestMock).toHaveBeenCalledWith("/api/locations/popular"); + expect(snapshot.calculatedAt).toBe("2026-07-30T08:30:00Z"); + expect(snapshot.items[0]).toMatchObject({ + rank: 1, + movement: "UP", + rankChange: 2, + location: { + id: "101", + shortName: "수원시 영통구 이의동", + }, + }); + }); }); diff --git a/lib/api/location-api.ts b/lib/api/location-api.ts index c6d8c65..f03f89a 100644 --- a/lib/api/location-api.ts +++ b/lib/api/location-api.ts @@ -10,6 +10,59 @@ export interface LocationResponse { shortLabel: string; } +export interface LocationPageResponse { + items: LocationResponse[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + hasPrevious: boolean; + hasNext: boolean; +} + +export interface LocationPage { + items: Location[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + hasPrevious: boolean; + hasNext: boolean; +} + +export type PopularLocationMovement = "UP" | "DOWN" | "SAME" | "NEW" | "UNKNOWN"; + +export interface PopularLocationItemResponse { + rank: number; + previousRank: number | null; + rankChange: number | null; + movement: PopularLocationMovement; + uniqueReporterCount: number; + reportCount: number; + latestReportAt: string; + location: LocationResponse; +} + +export interface PopularLocationItem extends Omit { + location: Location; +} + +export interface PopularLocationSnapshotResponse { + snapshotId: string; + calculatedAt: string; + windowStartedAt: string; + windowEndedAt: string; + algorithmVersion: string; + pageSize: number; + totalElements: number; + totalPages: number; + items: PopularLocationItemResponse[]; +} + +export interface PopularLocationSnapshot extends Omit { + items: PopularLocationItem[]; +} + export function normalizeLocation(location: LocationResponse): Location { const fullName = location.label.trim(); const shortName = location.shortLabel.trim(); @@ -21,14 +74,62 @@ export function normalizeLocation(location: LocationResponse): Location { }; } -const normalizeLocations = (locations: LocationResponse[]) => locations.map(normalizeLocation); +function normalizeLocationPage(page: LocationPageResponse): LocationPage { + return { + ...page, + items: page.items.map(normalizeLocation), + }; +} + +function normalizePopularLocationSnapshot( + snapshot: PopularLocationSnapshotResponse, +): PopularLocationSnapshot { + return { + ...snapshot, + items: snapshot.items.map((item) => ({ + ...item, + location: normalizeLocation(item.location), + })), + }; +} + +async function getFavoritePage(page: number) { + return normalizeLocationPage( + await apiRequest( + `/api/locations/favorites?page=${encodeURIComponent(page)}`, + ), + ); +} + +async function getFavoriteCatalog() { + const firstPage = await getFavoritePage(0); + const remainingPages = await Promise.all( + Array.from( + { length: Math.max(0, firstPage.totalPages - 1) }, + (_, index) => getFavoritePage(index + 1), + ), + ); + const locations = [firstPage, ...remainingPages].flatMap((page) => page.items); + return Array.from( + new Map( + locations.map((location) => [location.id ?? location.label, location]), + ).values(), + ); +} export const locationApi = { - search: async (keyword: string) => normalizeLocations(await apiRequest(`/api/locations?keyword=${encodeURIComponent(keyword)}`)), + search: async (keyword: string, page = 0) => normalizeLocationPage( + await apiRequest( + `/api/locations?keyword=${encodeURIComponent(keyword)}&page=${encodeURIComponent(page)}`, + ), + ), get: async (id: string) => normalizeLocation(await apiRequest(`/api/locations/${encodeURIComponent(id)}`)), reverseGeocode: async (latitude: number, longitude: number) => normalizeLocation(await apiRequest(`/api/locations/reverse-geocode?lat=${latitude}&lng=${longitude}`)), - popular: async () => normalizeLocations(await apiRequest("/api/locations/popular")), - favorites: async () => normalizeLocations(await apiRequest("/api/locations/favorites")), + popular: async () => normalizePopularLocationSnapshot( + await apiRequest("/api/locations/popular"), + ), + favorites: getFavoritePage, + favoriteCatalog: getFavoriteCatalog, addFavorite: (locationId: string) => jsonRequest("/api/locations/favorites", "POST", { locationId }), removeFavorite: (locationId: string) => jsonRequest(`/api/locations/favorites/${encodeURIComponent(locationId)}`, "DELETE"), }; diff --git a/lib/api/member-api.ts b/lib/api/member-api.ts index 40fd688..fbf6884 100644 --- a/lib/api/member-api.ts +++ b/lib/api/member-api.ts @@ -68,7 +68,7 @@ async function uploadAvatarFile(file: File) { } export const memberApi = { - getMe: () => apiRequest("/api/members/me"), + getMe: () => apiRequest("/api/members/me", { cache: "no-store" }), updateName: (name: string) => jsonRequest("/api/members/me/name", "PATCH", { name }), updateNickname: (nickname: string) => jsonRequest("/api/members/me/nickname", "PATCH", { nickname }), updateAvatar: (type: AvatarType, value: string | null) => diff --git a/lib/image.ts b/lib/image.ts index 8eb3d42..d32cc03 100644 --- a/lib/image.ts +++ b/lib/image.ts @@ -1,7 +1,8 @@ -const MAX_IMAGE_EDGE = 2048; +const MAX_IMAGE_EDGE = 1600; const OUTPUT_QUALITY = 0.86; const AVATAR_EDGE = 512; const MAX_AVATAR_SIZE = 2 * 1024 * 1024; +const JPEG_HEADER_SCAN_SIZE = 1024 * 1024; const AVATAR_SOURCE_TYPES = new Set([ "image/jpeg", "image/png", @@ -24,6 +25,7 @@ interface DecodedImage { source: CanvasImageSource; width: number; height: number; + downsampled: boolean; dispose: () => void; } @@ -47,14 +49,86 @@ export function isSupportedAvatarSource(file: File) { return AVATAR_SOURCE_TYPES.has(file.type.trim().toLowerCase()); } -async function decodeImage(file: File): Promise { +function isJpegStartOfFrame(marker: number) { + return ( + marker === 0xc0 || + marker === 0xc1 || + marker === 0xc2 || + marker === 0xc3 || + marker === 0xc5 || + marker === 0xc6 || + marker === 0xc7 || + marker === 0xc9 || + marker === 0xca || + marker === 0xcb || + marker === 0xcd || + marker === 0xce || + marker === 0xcf + ); +} + +async function readJpegDimensions(file: File) { + if (file.type !== "image/jpeg") return null; + + const bytes = new DataView( + await file.slice(0, Math.min(file.size, JPEG_HEADER_SCAN_SIZE)).arrayBuffer(), + ); + if (bytes.byteLength < 4 || bytes.getUint16(0) !== 0xffd8) return null; + + let offset = 2; + while (offset + 8 < bytes.byteLength) { + if (bytes.getUint8(offset) !== 0xff) { + offset += 1; + continue; + } + + const marker = bytes.getUint8(offset + 1); + if (marker === 0xd9 || marker === 0xda) break; + if (marker === 0x00 || marker === 0xff) { + offset += 1; + continue; + } + if (marker >= 0xd0 && marker <= 0xd8) { + offset += 2; + continue; + } + if (offset + 3 >= bytes.byteLength) break; + + const segmentLength = bytes.getUint16(offset + 2); + if (segmentLength < 2 || offset + segmentLength + 2 > bytes.byteLength) break; + if (isJpegStartOfFrame(marker)) { + const height = bytes.getUint16(offset + 5); + const width = bytes.getUint16(offset + 7); + return width > 0 && height > 0 ? { width, height } : null; + } + offset += segmentLength + 2; + } + + return null; +} + +async function decodeImage(file: File, maxEdge?: number): Promise { if (typeof createImageBitmap === "function") { try { - const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" }); + const dimensions = maxEdge ? await readJpegDimensions(file) : null; + const shouldDownsample = Boolean( + dimensions && Math.max(dimensions.width, dimensions.height) > maxEdge!, + ); + const resizeOptions = shouldDownsample && dimensions + ? dimensions.width >= dimensions.height + ? { resizeWidth: maxEdge } + : { resizeHeight: maxEdge } + : {}; + const bitmap = await createImageBitmap(file, { + imageOrientation: "from-image", + resizeQuality: "high", + ...resizeOptions, + }); return { source: bitmap, width: bitmap.width, height: bitmap.height, + downsampled: shouldDownsample, dispose: () => bitmap.close(), }; } catch { @@ -75,7 +149,11 @@ async function decodeImage(file: File): Promise { source: image, width: image.naturalWidth, height: image.naturalHeight, - dispose: () => URL.revokeObjectURL(objectUrl), + downsampled: false, + dispose: () => { + image.src = ""; + URL.revokeObjectURL(objectUrl); + }, }; } catch (error) { URL.revokeObjectURL(objectUrl); @@ -99,14 +177,15 @@ function canvasToBlob(canvas: HTMLCanvasElement, type: string) { } export async function optimizeReportImage(file: File) { - const image = await decodeImage(file); + const image = await decodeImage(file, MAX_IMAGE_EDGE); + let canvas: HTMLCanvasElement | null = null; try { const scale = Math.min(1, MAX_IMAGE_EDGE / Math.max(image.width, image.height)); const width = Math.max(1, Math.round(image.width * scale)); const height = Math.max(1, Math.round(image.height * scale)); - if (scale === 1 && file.size <= 2 * 1024 * 1024) return file; + if (!image.downsampled && scale === 1 && file.size <= 2 * 1024 * 1024) return file; - const canvas = document.createElement("canvas"); + canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d"); @@ -120,17 +199,22 @@ export async function optimizeReportImage(file: File) { lastModified: file.lastModified, }); } finally { + if (canvas) { + canvas.width = 0; + canvas.height = 0; + } image.dispose(); } } export async function optimizeAvatarImage(file: File) { const image = await decodeImage(file); + let canvas: HTMLCanvasElement | null = null; try { const cropSize = Math.min(image.width, image.height); const sourceX = Math.round((image.width - cropSize) / 2); const sourceY = Math.round((image.height - cropSize) / 2); - const canvas = document.createElement("canvas"); + canvas = document.createElement("canvas"); canvas.width = AVATAR_EDGE; canvas.height = AVATAR_EDGE; const context = canvas.getContext("2d"); @@ -157,6 +241,10 @@ export async function optimizeAvatarImage(file: File) { lastModified: file.lastModified, }); } finally { + if (canvas) { + canvas.width = 0; + canvas.height = 0; + } image.dispose(); } } diff --git a/lib/location-auto-refresh.test.ts b/lib/location-auto-refresh.test.ts new file mode 100644 index 0000000..ff62bc9 --- /dev/null +++ b/lib/location-auto-refresh.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + LOCATION_AUTO_REFRESH_COOLDOWN_MS, + LOCATION_RESUME_MIN_HIDDEN_MS, + shouldRefreshLocationAfterResume, +} from "@/lib/location-auto-refresh"; + +describe("shouldRefreshLocationAfterResume", () => { + it("ignores short visibility round trips", () => { + expect(shouldRefreshLocationAfterResume({ + hiddenAt: 1_000, + now: 1_000 + LOCATION_RESUME_MIN_HIDDEN_MS - 1, + lastRefreshAt: 0, + })).toBe(false); + }); + + it("refreshes after the minimum background duration", () => { + const now = LOCATION_RESUME_MIN_HIDDEN_MS + LOCATION_AUTO_REFRESH_COOLDOWN_MS; + expect(shouldRefreshLocationAfterResume({ + hiddenAt: now - LOCATION_RESUME_MIN_HIDDEN_MS, + now, + lastRefreshAt: 0, + })).toBe(true); + }); + + it("deduplicates pageshow and visibility resume events", () => { + const now = 1_000_000; + expect(shouldRefreshLocationAfterResume({ + hiddenAt: now - LOCATION_RESUME_MIN_HIDDEN_MS, + now, + lastRefreshAt: now - LOCATION_AUTO_REFRESH_COOLDOWN_MS + 1, + })).toBe(false); + }); +}); diff --git a/lib/location-auto-refresh.ts b/lib/location-auto-refresh.ts new file mode 100644 index 0000000..1effd42 --- /dev/null +++ b/lib/location-auto-refresh.ts @@ -0,0 +1,16 @@ +export const LOCATION_RESUME_MIN_HIDDEN_MS = 2 * 60 * 1000; +export const LOCATION_AUTO_REFRESH_COOLDOWN_MS = 5 * 60 * 1000; + +export function shouldRefreshLocationAfterResume({ + hiddenAt, + now, + lastRefreshAt, +}: { + hiddenAt: number | null; + now: number; + lastRefreshAt: number; +}) { + if (hiddenAt === null) return false; + if (now - hiddenAt < LOCATION_RESUME_MIN_HIDDEN_MS) return false; + return now - lastRefreshAt >= LOCATION_AUTO_REFRESH_COOLDOWN_MS; +} diff --git a/lib/location-pagination.test.ts b/lib/location-pagination.test.ts new file mode 100644 index 0000000..3b72bcd --- /dev/null +++ b/lib/location-pagination.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + clampLocationPage, + getLocationPageCount, + getLocationPageItems, +} from "@/lib/location-pagination"; + +describe("location pagination", () => { + it("splits location results into five-item pages", () => { + const locations = Array.from({ length: 12 }, (_, index) => index + 1); + + expect(getLocationPageCount(locations.length)).toBe(3); + expect(getLocationPageItems(locations, 1)).toEqual([1, 2, 3, 4, 5]); + expect(getLocationPageItems(locations, 3)).toEqual([11, 12]); + }); + + it("clamps a page when items are removed", () => { + expect(clampLocationPage(3, 10)).toBe(2); + expect(getLocationPageItems([1, 2, 3], 4)).toEqual([1, 2, 3]); + }); + + it("keeps empty results on page one", () => { + expect(getLocationPageCount(0)).toBe(1); + expect(clampLocationPage(2, 0)).toBe(1); + expect(getLocationPageItems([], 1)).toEqual([]); + }); +}); diff --git a/lib/location-pagination.ts b/lib/location-pagination.ts new file mode 100644 index 0000000..d02ad90 --- /dev/null +++ b/lib/location-pagination.ts @@ -0,0 +1,30 @@ +export const LOCATION_PAGE_SIZE = 5; + +export function getLocationPageCount( + itemCount: number, + pageSize = LOCATION_PAGE_SIZE, +) { + if (pageSize <= 0) return 1; + return Math.max(1, Math.ceil(Math.max(0, itemCount) / pageSize)); +} + +export function clampLocationPage( + page: number, + itemCount: number, + pageSize = LOCATION_PAGE_SIZE, +) { + return Math.min( + Math.max(1, Math.trunc(page) || 1), + getLocationPageCount(itemCount, pageSize), + ); +} + +export function getLocationPageItems( + items: ReadonlyArray, + page: number, + pageSize = LOCATION_PAGE_SIZE, +) { + const safePage = clampLocationPage(page, items.length, pageSize); + const startIndex = (safePage - 1) * pageSize; + return items.slice(startIndex, startIndex + pageSize); +} diff --git a/lib/types.ts b/lib/types.ts index 80054ca..5bd33f7 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,6 +1,7 @@ export type TemperatureStatus = "COLD" | "FRESH" | "HOT"; export type PrecipitationStatus = "NONE" | "LIGHT" | "HEAVY"; export type SunlightStatus = "LOW" | "MODERATE" | "STRONG"; +export type WeatherStatus = TemperatureStatus | PrecipitationStatus | SunlightStatus; export type WeatherAuthor = | { type: "ANONYMOUS"; nickname?: string } diff --git a/lib/weather-status-tone.test.ts b/lib/weather-status-tone.test.ts new file mode 100644 index 0000000..711ea46 --- /dev/null +++ b/lib/weather-status-tone.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { + getWeatherStatusTone, + weatherStatusToneClasses, +} from "@/lib/weather-status-tone"; + +describe("weather status tones", () => { + it("maps low-intensity statuses to blue", () => { + for (const status of ["COLD", "NONE", "LOW"] as const) { + expect(getWeatherStatusTone(status)).toBe(weatherStatusToneClasses.BLUE); + } + }); + + it("maps middle-intensity statuses to green", () => { + for (const status of ["FRESH", "LIGHT", "MODERATE"] as const) { + expect(getWeatherStatusTone(status)).toBe(weatherStatusToneClasses.GREEN); + } + }); + + it("maps high-intensity statuses to orange", () => { + for (const status of ["HOT", "HEAVY", "STRONG"] as const) { + expect(getWeatherStatusTone(status)).toBe(weatherStatusToneClasses.ORANGE); + } + }); +}); diff --git a/lib/weather-status-tone.ts b/lib/weather-status-tone.ts new file mode 100644 index 0000000..2458a1e --- /dev/null +++ b/lib/weather-status-tone.ts @@ -0,0 +1,41 @@ +import type { WeatherStatus } from "@/lib/types"; + +export type WeatherStatusTone = "BLUE" | "GREEN" | "ORANGE"; + +const toneByStatus: Record = { + COLD: "BLUE", + NONE: "BLUE", + LOW: "BLUE", + FRESH: "GREEN", + LIGHT: "GREEN", + MODERATE: "GREEN", + HOT: "ORANGE", + HEAVY: "ORANGE", + STRONG: "ORANGE", +}; + +export const weatherStatusToneClasses: Record = { + BLUE: { + badge: "bg-[#e8f3ff] text-[#397bb5]", + selected: "border-[#9fcbea] bg-[#e8f3ff] text-[#397bb5] ring-2 ring-[#72b2e4]/15", + detail: "border-[#9fcbea] text-[#397bb5]", + }, + GREEN: { + badge: "bg-[#e4f7ee] text-[#318561]", + selected: "border-[#a5d9bf] bg-[#e4f7ee] text-[#318561] ring-2 ring-[#5fb98b]/15", + detail: "border-[#a5d9bf] text-[#318561]", + }, + ORANGE: { + badge: "bg-[#fff0e8] text-[#c86638]", + selected: "border-[#efbda4] bg-[#fff0e8] text-[#c86638] ring-2 ring-[#df8a5f]/15", + detail: "border-[#efbda4] text-[#c86638]", + }, +}; + +export function getWeatherStatusTone(status: WeatherStatus) { + return weatherStatusToneClasses[toneByStatus[status]]; +} diff --git a/package-lock.json b/package-lock.json index 4f7c1ba..c63bfdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nalssilog-web", - "version": "0.1.0", + "version": "0.1.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nalssilog-web", - "version": "0.1.0", + "version": "0.1.12", "dependencies": { "@hookform/resolvers": "latest", "@sentry/nextjs": "^10.67.0", diff --git a/package.json b/package.json index 2c0f8e4..14d981d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nalssilog-web", - "version": "0.1.0", + "version": "0.1.12", "private": true, "scripts": { "config:init": "git -c submodule.env-config.update=checkout submodule update --init --checkout env-config && git -C env-config switch main && git -C env-config pull --ff-only origin main && npm run config:sync", diff --git a/store/location-store.ts b/store/location-store.ts index 1be75da..9ad9ec5 100644 --- a/store/location-store.ts +++ b/store/location-store.ts @@ -8,8 +8,13 @@ import type { Location } from "@/lib/types"; interface LocationState { location: Location | null; hasAttemptedDetection: boolean; + isDetecting: boolean; + detectionError: string; setLocation: (location: Location) => void; markDetectionAttempted: () => void; + startDetection: () => void; + stopDetection: () => void; + finishDetection: (error?: string) => void; } export const useLocationStore = create()( @@ -17,8 +22,13 @@ export const useLocationStore = create()( (set) => ({ location: null, hasAttemptedDetection: false, + isDetecting: false, + detectionError: "", setLocation: (location) => set({ location }), markDetectionAttempted: () => set({ hasAttemptedDetection: true }), + startDetection: () => set({ isDetecting: true, detectionError: "" }), + stopDetection: () => set({ isDetecting: false }), + finishDetection: (error = "") => set({ isDetecting: false, detectionError: error }), }), { name: "nalssilog-location",