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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<div align="center">
<img src="./public/avatars/sun.svg" width="88" alt="날씨로그" />
<h1>날씨로그</h1>
<p>
예보보다 가까운, 이웃이 직접 전하는 지금 날씨
Expand All @@ -14,11 +13,7 @@
날씨로그는 같은 동네의 이웃들이 직접 느낀 날씨를 사진과 함께 공유하는 모바일 웹 서비스입니다.
숫자로 표시된 예보만으로 알기 어려운 체감온도, 비, 햇빛 상태를 최근 제보로 빠르게 확인할 수 있습니다.

## 서비스 미리보기

<p align="center">
<img src="./public/weather/clear-sky.webp" alt="맑은 하늘 제보" />
</p>
## 핵심 기능

### 동네 날씨 피드

Expand Down
4 changes: 3 additions & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ html { background: #deeff8; }
body {
margin: 0;
min-height: 100vh;
min-height: 100dvh;
color: var(--ink);
background: linear-gradient(145deg, #deeff8 0%, #f4fbff 50%, #ddecf5 100%);
font-family: "Pretendard Variable", Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
Expand All @@ -27,12 +28,13 @@ button { cursor: pointer; }
position: relative;
width: min(100%, 480px);
min-height: 100vh;
min-height: 100dvh;
margin: 0 auto;
overflow-x: clip;
background: #eef9ff;
box-shadow: 0 0 48px rgba(48, 98, 126, .12);
}
.page { min-height: 100vh; padding: 0 20px 112px; }
.page { min-height: 100vh; min-height: 100dvh; padding: 0 20px 112px; }
.main-tab-page { padding-bottom: 80px; }
.safe-top { padding-top: max(8px, env(safe-area-inset-top)); }
.home-header-top { padding-top: max(9px, env(safe-area-inset-top)); }
Expand Down
4 changes: 3 additions & 1 deletion components/auth-callback-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { SocialIcon } from "@/components/social-icon";
import { authApi, type OAuthCallbackResult } from "@/lib/api/auth-api";
import { markAccountPanelReturn } from "@/lib/account-panel-return";
import { resolveApiUrl } from "@/lib/api/config";
import { logger } from "@/lib/logging";
import type { SocialProvider } from "@/lib/types";
Expand Down Expand Up @@ -55,6 +56,7 @@ export function AuthCallbackScreen() {
const code = searchParams.get("code");
if (result === "LINK_SUCCESS") {
authLogger.info("authentication_completed", { result });
markAccountPanelReturn();
showToast("소셜 계정을 연동했어요.", "SUCCESS");
router.replace("/mypage");
return;
Expand Down Expand Up @@ -128,7 +130,7 @@ export function AuthCallbackScreen() {

return (
<main className="flex min-h-[75dvh] items-center justify-center px-5">
<section className="w-full max-w-[340px] rounded-[24px] bg-[#eef9ff] p-5 shadow-xl">
<section className="w-full max-w-[340px] rounded-[24px] border-2 border-[#d2e3ec] bg-[#eef9ff] p-5">
<h1 className="text-center text-xl font-extrabold">계정 연동</h1>
<div className="mt-4 rounded-[20px] border-2 border-[#d2e3ec] px-4 py-5 text-center">
<div className="mx-auto w-fit"><SocialIcon provider={pendingLink.provider} /></div>
Expand Down
4 changes: 2 additions & 2 deletions components/home-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { getLocationName } from "@/lib/constants";
import { useToastStore } from "@/store/toast-store";

export function HomeScreen() {
const { location, setLocation, isDetecting, detectionError, needsManualInput, setNeedsManualInput, detectLocation } = useCurrentLocation({ refreshOnResume: true });
const { location, setLocation, isDetecting, detectionError, needsManualInput, setNeedsManualInput, detectLocation } = useCurrentLocation();
const showToast = useToastStore((state) => state.showToast);
const loadMoreRef = useRef<HTMLDivElement>(null);
const locationLabel = location ? getLocationName(location, "short") : "";
Expand Down Expand Up @@ -55,7 +55,7 @@ export function HomeScreen() {
const hasWeatherData = hasSummaryData || hasReportData;
const hasWeatherError = summary.isError || reports.isError;
const showLocationError = !location && !isDetecting && Boolean(detectionError);
const isInitialWeatherLoading = !locationLabel || (!hasWeatherData && (summary.isPending || reports.isPending));
const isInitialWeatherLoading = !isDetecting && Boolean(locationLabel) && !hasWeatherData && (summary.isPending || reports.isPending);
const showFullWeatherError = Boolean(locationLabel) && !hasWeatherData && hasWeatherError && !summary.isPending && !reports.isPending;
return (
<div className="page main-tab-page">
Expand Down
5 changes: 3 additions & 2 deletions components/home-weather-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { format } from "date-fns";
import { ko } from "date-fns/locale";
import { ChevronDown, MapPin, RotateCw } from "lucide-react";
import { useSyncExternalStore } from "react";
import { LocationDetectingIndicator } from "@/components/location-detecting-indicator";

let openedAt = 0;
const openedAtListeners = new Set<() => void>();
Expand Down Expand Up @@ -42,11 +43,11 @@ export function HomeWeatherControls({
: "현재 시각";

return (
<section className="mb-2 flex items-center justify-between gap-2">
<section className="mb-1 flex items-center justify-between gap-2">
<button type="button" onClick={onLocationClick} className="group flex w-fit min-w-0 max-w-[60%] items-center gap-2 py-2.5 pr-1 text-left">
<MapPin size={18} className="shrink-0 text-[#45ace4]" />
{isDetecting
? <span className="skeleton h-4 w-28 max-w-full rounded" aria-label="현재 위치 불러오는 중" />
? <LocationDetectingIndicator compact />
: <span className="min-w-0 truncate text-base font-extrabold">{location || "동네를 선택해 주세요"}</span>}
<ChevronDown size={16} className="shrink-0 text-[#8ba0ae] transition-transform group-hover:translate-y-0.5" />
</button>
Expand Down
30 changes: 30 additions & 0 deletions components/location-detecting-indicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { LoaderCircle } from "lucide-react";

export function LocationDetectingIndicator({
className = "",
compact = false,
iconPosition = "left",
}: {
className?: string;
compact?: boolean;
iconPosition?: "left" | "right";
}) {
const spinner = (
<LoaderCircle
size={compact ? 14 : 16}
className="shrink-0 animate-spin text-[#45ace4]"
/>
);

return (
<span
role="status"
aria-live="polite"
className={`inline-flex min-w-0 items-center gap-1.5 text-[#718594] ${compact ? "text-xs font-bold" : "text-base font-extrabold"} ${className}`}
>
{iconPosition === "left" && spinner}
<span className="truncate">위치를 찾는 중</span>
{iconPosition === "right" && spinner}
</span>
);
}
15 changes: 8 additions & 7 deletions components/location-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, Check, Flame, LockKeyhole, LocateFixed, MapPin, RefreshCw, 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 { getLocationName } from "@/lib/constants";
import { locationApi } from "@/lib/api/location-api";
import { truncateText } from "@/lib/text";
Expand Down Expand Up @@ -106,14 +107,14 @@ function LocationPickerDialog({ current, isDetecting, detectionError, required,
</div>
{!required && <button type="button" onClick={onClose} className="flex size-9 items-center justify-center rounded-xl border-2 border-[#d2e3ec]" aria-label="닫기"><X size={18} /></button>}
</div>
<div className="mb-5 flex items-center gap-3 rounded-[18px] border-2 border-[#d2e3ec] p-4">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-[#eef9ff] text-[#45ace4]"><MapPin size={19} /></span>
<div className="min-w-0 flex-1"><p className="text-[11px] font-bold text-[#718594]">현재 설정된 위치</p>{isDetecting ? <div className="skeleton mt-1.5 h-4 w-44 max-w-full rounded" aria-label="현재 위치 불러오는 중" /> : <p className="mt-0.5 break-keep text-sm font-extrabold leading-5">{selectedLocation ? getLocationName(selectedLocation, "full") : current ? getLocationName(current, "full") : "아직 설정되지 않았어요"}</p>}</div>
<button type="button" onClick={detectCandidateLocation} disabled={isDetecting} className="flex shrink-0 items-center gap-1 rounded-xl bg-[#eef9ff] px-2.5 py-2 text-[11px] font-extrabold text-[#268fc7] disabled:opacity-50" aria-label="GPS로 현재 위치 다시 찾기"><LocateFixed size={14} /> GPS</button>
<div className="mb-5 flex items-center gap-3 rounded-[18px] border-2 border-[#d2e3ec] p-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-[#eef9ff] text-[#45ace4]"><MapPin size={18} /></span>
<div className="min-w-0 flex-1">{isDetecting ? <LocationDetectingIndicator iconPosition="right" /> : <p className="break-keep text-base font-extrabold leading-6">{selectedLocation ? getLocationName(selectedLocation, "full") : current ? getLocationName(current, "full") : "아직 설정되지 않았어요"}</p>}</div>
<button type="button" onClick={detectCandidateLocation} disabled={isDetecting} className="flex shrink-0 items-center gap-1 rounded-xl border-2 border-[#d2e3ec] bg-[#eef9ff] px-2.5 py-1.5 text-[11px] font-extrabold text-[#268fc7] disabled:opacity-50" aria-label="GPS로 현재 위치 다시 찾기"><LocateFixed size={14} /> GPS</button>
</div>
{detectionError && <p className="-mt-3 mb-5 flex items-start gap-1.5 px-1 text-xs font-bold leading-5 text-[#b36b54]"><AlertCircle size={14} className="mt-0.5 shrink-0" />{detectionError}</p>}
<form onSubmit={submit}>
<div className="mb-4 grid grid-cols-3 rounded-2xl bg-[#f1f6f9] p-1">
<div className="mb-4 grid grid-cols-3 rounded-2xl p-1">
<TabButton active={activeTab === "SEARCH"} onClick={() => setActiveTab("SEARCH")} icon={<Search size={14} />} label="검색" />
<TabButton active={activeTab === "FAVORITES"} onClick={() => setActiveTab("FAVORITES")} icon={<Star size={14} />} label="즐겨찾기" />
<TabButton active={activeTab === "POPULAR"} onClick={() => setActiveTab("POPULAR")} icon={<Flame size={14} />} label="인기" />
Expand All @@ -130,8 +131,8 @@ function LocationPickerDialog({ current, isDetecting, detectionError, required,
{!serverSearch.isError && searchResults.map((location) => <LocationRow key={location.id} location={location} selected={selectedLocation?.id === location.id} favorite={isMember ? isFavorite(location) : undefined} onSelect={chooseLocation} onToggleFavorite={isMember ? toggleFavorite : undefined} />)}
{serverSearch.isFetching && <div className="space-y-2 px-3 py-3">{Array.from({ length: 3 }).map((_, index) => <div key={index} className="skeleton h-10 rounded-xl" />)}</div>}
{serverSearch.isError && <LocationLoadError onRetry={() => serverSearch.refetch()} />}
{value.trim() && !serverSearch.isFetching && !serverSearch.isError && searchResults.length === 0 && <p className="px-3 py-8 text-center text-sm font-semibold text-[#8ba0ae]">지원하는 지역을 찾지 못했어요.</p>}
{!value.trim() && <p className="px-3 py-8 text-center text-sm font-semibold leading-6 text-[#8ba0ae]">찾고 싶은 시·구 또는 동 이름을 입력해 주세요.</p>}
{value.trim() && !serverSearch.isFetching && !serverSearch.isError && searchResults.length === 0 && <p className="px-3 py-8 text-center text-sm font-semibold text-[#8ba0ae]">검색 결과가 없습니다.</p>}
{!value.trim() && <p className="px-3 py-8 text-center text-sm font-semibold leading-6 text-[#8ba0ae]">검색 결과가 없습니다.</p>}
</div>
</>}
{activeTab === "FAVORITES" && (isMember
Expand Down
4 changes: 3 additions & 1 deletion components/main-tab-navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export function MainTabNavigation() {
const profileActive = pathname === "/mypage";
const profileContent = (
<span
className="flex size-8 items-center justify-center rounded-full bg-white bg-cover bg-center"
className={`flex size-8 items-center justify-center rounded-full bg-cover bg-center ${
user.type === "MEMBER" ? "" : "border-2 border-[#b9dceb] bg-transparent"
}`}
style={user.type === "MEMBER" && user.avatarUrl ? { backgroundImage: `url(${user.avatarUrl})` } : undefined}
aria-hidden="true"
>
Expand Down
18 changes: 15 additions & 3 deletions components/my-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { MyReportList } from "@/components/my-report-list";
import { NicknameEditModal } from "@/components/nickname-edit-modal";
import { ProfileImageModal, type ProfileImageSelection } from "@/components/profile-image-modal";
import { ProfilePreviewModal } from "@/components/profile-preview-modal";
import { UserPanel } from "@/components/user-panel";
import { UserPanel, type UserPanelView } from "@/components/user-panel";
import { consumeAccountPanelReturn } from "@/lib/account-panel-return";
import { memberApi } from "@/lib/api/member-api";
import { resolveProfileImage } from "@/lib/constants";
import { optimizeAvatarImage } from "@/lib/image";
Expand All @@ -24,6 +25,7 @@ export function MyPage() {
const setNickname = useAuthStore((state) => state.setNickname);
const showToast = useToastStore((state) => state.showToast);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [userPanelInitialView, setUserPanelInitialView] = useState<UserPanelView>("MAIN");
const [isProfileImageOpen, setIsProfileImageOpen] = useState(false);
const [isProfilePreviewOpen, setIsProfilePreviewOpen] = useState(false);
const [isNicknameOpen, setIsNicknameOpen] = useState(false);
Expand Down Expand Up @@ -76,6 +78,16 @@ export function MyPage() {
if (hasCheckedServerSession && user.type !== "MEMBER") router.replace("/");
}, [hasCheckedServerSession, router, user.type]);

useEffect(() => {
if (!hasCheckedServerSession || user.type !== "MEMBER") return;
const timeout = window.setTimeout(() => {
if (!consumeAccountPanelReturn()) return;
setUserPanelInitialView("ACCOUNT");
setIsSettingsOpen(true);
}, 0);
return () => window.clearTimeout(timeout);
}, [hasCheckedServerSession, user.type]);

if (!hasCheckedServerSession) {
return <main className="min-h-[75dvh] pb-[72px]" aria-busy="true"><header className="safe-top spacious-page-header grid grid-cols-[36px_1fr_36px] items-center px-5 pb-2"><span /><div className="skeleton mx-auto h-5 w-20 rounded" /><span /></header><section className="flex items-center gap-5 px-5 pb-6 pt-0"><div className="skeleton size-18 rounded-full" /><div className="skeleton h-4 w-28 rounded" /></section><section className="px-5"><div className="skeleton mb-3 h-11 rounded" /><div className="grid grid-cols-3 gap-1.5">{Array.from({ length: 6 }).map((_, index) => <div key={index} className="skeleton aspect-square rounded-lg" />)}</div></section></main>;
}
Expand All @@ -89,7 +101,7 @@ export function MyPage() {
<header className="safe-top spacious-page-header sticky top-0 z-30 grid grid-cols-[36px_1fr_36px] items-center bg-[#eef9ff] px-5 pb-2">
<span aria-hidden="true" />
<h1 className="text-center text-lg font-extrabold">프로필</h1>
<button type="button" onClick={() => setIsSettingsOpen(true)} className="header-action-button justify-self-end" aria-label="설정 열기"><Settings size={18} /></button>
<button type="button" onClick={() => { setUserPanelInitialView("MAIN"); setIsSettingsOpen(true); }} className="header-action-button justify-self-end" aria-label="설정 열기"><Settings size={18} /></button>
</header>

<section className="px-5 pb-5 pt-0">
Expand All @@ -112,7 +124,7 @@ export function MyPage() {
<MyReportList memberId={user.id} columns={3} />
</section>

<UserPanel open={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
{isSettingsOpen && <UserPanel open initialView={userPanelInitialView} onClose={() => { setIsSettingsOpen(false); setUserPanelInitialView("MAIN"); }} />}
{isProfileImageOpen && <ProfileImageModal current={avatarUrl} onClose={() => setIsProfileImageOpen(false)} onChange={saveProfileImage} />}
{isProfilePreviewOpen && <ProfilePreviewModal avatarUrl={avatarUrl} onClose={() => setIsProfilePreviewOpen(false)} />}
{isNicknameOpen && <NicknameEditModal currentNickname={nickname} onClose={() => setIsNicknameOpen(false)} onSave={saveNickname} />}
Expand Down
1 change: 0 additions & 1 deletion components/name-edit-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ export function NameEditModal({
id="member-name"
value={name}
maxLength={30}
autoFocus
autoComplete="name"
onChange={(event) => {
const nextName = event.currentTarget.value;
Expand Down
Loading