2026-03-10 18:30:18 +09:00
|
|
|
|
"use client";
|
2025-10-13 18:39:37 +09:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 날씨 위젯 컴포넌트
|
|
|
|
|
|
* - 실시간 날씨 정보를 표시
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
|
import React, { useEffect, useState } from "react";
|
|
|
|
|
|
import { getWeather, WeatherData } from "@/lib/api/openApi";
|
2025-10-13 18:39:37 +09:00
|
|
|
|
import {
|
|
|
|
|
|
Cloud,
|
|
|
|
|
|
CloudRain,
|
|
|
|
|
|
Sun,
|
|
|
|
|
|
CloudSnow,
|
|
|
|
|
|
Wind,
|
|
|
|
|
|
Droplets,
|
|
|
|
|
|
Gauge,
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
Check,
|
|
|
|
|
|
ChevronsUpDown,
|
2025-10-14 10:05:40 +09:00
|
|
|
|
Settings,
|
2026-03-10 18:30:18 +09:00
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
|
|
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
import { DashboardElement } from "@/components/admin/dashboard/types";
|
2025-10-13 18:39:37 +09:00
|
|
|
|
|
|
|
|
|
|
interface WeatherWidgetProps {
|
2025-10-15 18:25:16 +09:00
|
|
|
|
element?: DashboardElement;
|
2025-10-13 18:39:37 +09:00
|
|
|
|
city?: string;
|
|
|
|
|
|
refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
|
export default function WeatherWidget({ element, city = "서울", refreshInterval = 600000 }: WeatherWidgetProps) {
|
2025-10-13 18:39:37 +09:00
|
|
|
|
const [open, setOpen] = useState(false);
|
2025-10-14 10:05:40 +09:00
|
|
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
2025-10-13 18:39:37 +09:00
|
|
|
|
const [selectedCity, setSelectedCity] = useState(city);
|
|
|
|
|
|
const [weather, setWeather] = useState<WeatherData | null>(null);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
2026-03-10 18:30:18 +09:00
|
|
|
|
|
2025-10-14 10:05:40 +09:00
|
|
|
|
// 표시할 날씨 정보 선택
|
|
|
|
|
|
const [selectedItems, setSelectedItems] = useState<string[]>([
|
2026-03-10 18:30:18 +09:00
|
|
|
|
"temperature",
|
|
|
|
|
|
"feelsLike",
|
|
|
|
|
|
"humidity",
|
|
|
|
|
|
"windSpeed",
|
|
|
|
|
|
"pressure",
|
2025-10-14 10:05:40 +09:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// 날씨 항목 정의
|
|
|
|
|
|
const weatherItems = [
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ id: "temperature", label: "기온", icon: Sun },
|
|
|
|
|
|
{ id: "feelsLike", label: "체감온도", icon: Sun },
|
|
|
|
|
|
{ id: "humidity", label: "습도", icon: Droplets },
|
|
|
|
|
|
{ id: "windSpeed", label: "풍속", icon: Wind },
|
|
|
|
|
|
{ id: "pressure", label: "기압", icon: Gauge },
|
2025-10-14 10:05:40 +09:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// 항목 토글
|
|
|
|
|
|
const toggleItem = (itemId: string) => {
|
2026-03-10 18:30:18 +09:00
|
|
|
|
setSelectedItems((prev) => (prev.includes(itemId) ? prev.filter((id) => id !== itemId) : [...prev, itemId]));
|
2025-10-14 10:05:40 +09:00
|
|
|
|
};
|
2025-10-13 18:39:37 +09:00
|
|
|
|
|
|
|
|
|
|
// 도시 목록 (전국 시/군/구 단위)
|
|
|
|
|
|
const cities = [
|
|
|
|
|
|
// 서울특별시 (25개 구)
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "서울", label: "서울" },
|
|
|
|
|
|
{ value: "종로구", label: "서울 종로구" },
|
|
|
|
|
|
{ value: "중구", label: "서울 중구" },
|
|
|
|
|
|
{ value: "용산구", label: "서울 용산구" },
|
|
|
|
|
|
{ value: "성동구", label: "서울 성동구" },
|
|
|
|
|
|
{ value: "광진구", label: "서울 광진구" },
|
|
|
|
|
|
{ value: "동대문구", label: "서울 동대문구" },
|
|
|
|
|
|
{ value: "중랑구", label: "서울 중랑구" },
|
|
|
|
|
|
{ value: "성북구", label: "서울 성북구" },
|
|
|
|
|
|
{ value: "강북구", label: "서울 강북구" },
|
|
|
|
|
|
{ value: "도봉구", label: "서울 도봉구" },
|
|
|
|
|
|
{ value: "노원구", label: "서울 노원구" },
|
|
|
|
|
|
{ value: "은평구", label: "서울 은평구" },
|
|
|
|
|
|
{ value: "서대문구", label: "서울 서대문구" },
|
|
|
|
|
|
{ value: "마포구", label: "서울 마포구" },
|
|
|
|
|
|
{ value: "양천구", label: "서울 양천구" },
|
|
|
|
|
|
{ value: "강서구", label: "서울 강서구" },
|
|
|
|
|
|
{ value: "구로구", label: "서울 구로구" },
|
|
|
|
|
|
{ value: "금천구", label: "서울 금천구" },
|
|
|
|
|
|
{ value: "영등포구", label: "서울 영등포구" },
|
|
|
|
|
|
{ value: "동작구", label: "서울 동작구" },
|
|
|
|
|
|
{ value: "관악구", label: "서울 관악구" },
|
|
|
|
|
|
{ value: "서초구", label: "서울 서초구" },
|
|
|
|
|
|
{ value: "강남구", label: "서울 강남구" },
|
|
|
|
|
|
{ value: "송파구", label: "서울 송파구" },
|
|
|
|
|
|
{ value: "강동구", label: "서울 강동구" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 부산광역시
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "부산", label: "부산" },
|
|
|
|
|
|
{ value: "해운대구", label: "부산 해운대구" },
|
|
|
|
|
|
{ value: "부산진구", label: "부산 부산진구" },
|
|
|
|
|
|
{ value: "동래구", label: "부산 동래구" },
|
|
|
|
|
|
{ value: "사하구", label: "부산 사하구" },
|
|
|
|
|
|
{ value: "금정구", label: "부산 금정구" },
|
|
|
|
|
|
{ value: "사상구", label: "부산 사상구" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 인천광역시
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "인천", label: "인천" },
|
|
|
|
|
|
{ value: "부평구", label: "인천 부평구" },
|
|
|
|
|
|
{ value: "계양구", label: "인천 계양구" },
|
|
|
|
|
|
{ value: "남동구", label: "인천 남동구" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 대구광역시
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "대구", label: "대구" },
|
|
|
|
|
|
{ value: "수성구", label: "대구 수성구" },
|
|
|
|
|
|
{ value: "달서구", label: "대구 달서구" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 광주광역시
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "광주", label: "광주" },
|
|
|
|
|
|
{ value: "광산구", label: "광주 광산구" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 대전광역시
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "대전", label: "대전" },
|
|
|
|
|
|
{ value: "유성구", label: "대전 유성구" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 울산광역시
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "울산", label: "울산" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 세종특별자치시
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "세종", label: "세종" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 경기도 (주요 도시)
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "수원", label: "수원" },
|
|
|
|
|
|
{ value: "성남", label: "성남" },
|
|
|
|
|
|
{ value: "고양", label: "고양" },
|
|
|
|
|
|
{ value: "용인", label: "용인" },
|
|
|
|
|
|
{ value: "부천", label: "부천" },
|
|
|
|
|
|
{ value: "안산", label: "안산" },
|
|
|
|
|
|
{ value: "안양", label: "안양" },
|
|
|
|
|
|
{ value: "남양주", label: "남양주" },
|
|
|
|
|
|
{ value: "화성", label: "화성" },
|
|
|
|
|
|
{ value: "평택", label: "평택" },
|
|
|
|
|
|
{ value: "의정부", label: "의정부" },
|
|
|
|
|
|
{ value: "시흥", label: "시흥" },
|
|
|
|
|
|
{ value: "파주", label: "파주" },
|
|
|
|
|
|
{ value: "김포", label: "김포" },
|
|
|
|
|
|
{ value: "광명", label: "광명" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 강원도
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "춘천", label: "춘천" },
|
|
|
|
|
|
{ value: "원주", label: "원주" },
|
|
|
|
|
|
{ value: "강릉", label: "강릉" },
|
|
|
|
|
|
{ value: "속초", label: "속초" },
|
|
|
|
|
|
{ value: "동해", label: "동해" },
|
|
|
|
|
|
{ value: "태백", label: "태백" },
|
|
|
|
|
|
{ value: "삼척", label: "삼척" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 충청북도
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "청주", label: "청주" },
|
|
|
|
|
|
{ value: "충주", label: "충주" },
|
|
|
|
|
|
{ value: "제천", label: "제천" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 충청남도
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "천안", label: "천안" },
|
|
|
|
|
|
{ value: "공주", label: "공주" },
|
|
|
|
|
|
{ value: "보령", label: "보령" },
|
|
|
|
|
|
{ value: "아산", label: "아산" },
|
|
|
|
|
|
{ value: "서산", label: "서산" },
|
|
|
|
|
|
{ value: "논산", label: "논산" },
|
|
|
|
|
|
{ value: "당진", label: "당진" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 전라북도
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "전주", label: "전주" },
|
|
|
|
|
|
{ value: "군산", label: "군산" },
|
|
|
|
|
|
{ value: "익산", label: "익산" },
|
|
|
|
|
|
{ value: "정읍", label: "정읍" },
|
|
|
|
|
|
{ value: "남원", label: "남원" },
|
|
|
|
|
|
{ value: "김제", label: "김제" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 전라남도
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "목포", label: "목포" },
|
|
|
|
|
|
{ value: "여수", label: "여수" },
|
|
|
|
|
|
{ value: "순천", label: "순천" },
|
|
|
|
|
|
{ value: "나주", label: "나주" },
|
|
|
|
|
|
{ value: "광양", label: "광양" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 경상북도
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "포항", label: "포항" },
|
|
|
|
|
|
{ value: "경주", label: "경주" },
|
|
|
|
|
|
{ value: "김천", label: "김천" },
|
|
|
|
|
|
{ value: "안동", label: "안동" },
|
|
|
|
|
|
{ value: "구미", label: "구미" },
|
|
|
|
|
|
{ value: "영주", label: "영주" },
|
|
|
|
|
|
{ value: "영천", label: "영천" },
|
|
|
|
|
|
{ value: "상주", label: "상주" },
|
|
|
|
|
|
{ value: "문경", label: "문경" },
|
|
|
|
|
|
{ value: "경산", label: "경산" },
|
|
|
|
|
|
{ value: "울릉도", label: "울릉도" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 경상남도
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "창원", label: "창원" },
|
|
|
|
|
|
{ value: "진주", label: "진주" },
|
|
|
|
|
|
{ value: "통영", label: "통영" },
|
|
|
|
|
|
{ value: "사천", label: "사천" },
|
|
|
|
|
|
{ value: "김해", label: "김해" },
|
|
|
|
|
|
{ value: "밀양", label: "밀양" },
|
|
|
|
|
|
{ value: "거제", label: "거제" },
|
|
|
|
|
|
{ value: "양산", label: "양산" },
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 제주특별자치도
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{ value: "제주", label: "제주" },
|
|
|
|
|
|
{ value: "서귀포", label: "서귀포" },
|
2025-10-13 18:39:37 +09:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// 날씨 정보 가져오기
|
|
|
|
|
|
const fetchWeather = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setError(null);
|
2026-03-10 18:30:18 +09:00
|
|
|
|
const data = await getWeather(selectedCity, "metric", "kr");
|
2025-10-13 18:39:37 +09:00
|
|
|
|
setWeather(data);
|
|
|
|
|
|
setLastUpdated(new Date());
|
|
|
|
|
|
} catch (err: any) {
|
2026-03-10 18:30:18 +09:00
|
|
|
|
console.error("날씨 조회 실패:", err);
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
// 에러 메시지 추출
|
2026-03-10 18:30:18 +09:00
|
|
|
|
let errorMessage = "날씨 정보를 가져오는 중 오류가 발생했습니다.";
|
|
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
if (err.response?.status === 503) {
|
2026-03-10 18:30:18 +09:00
|
|
|
|
errorMessage = "API 키가 설정되지 않았습니다. 관리자에게 문의하세요.";
|
2025-10-13 18:39:37 +09:00
|
|
|
|
} else if (err.response?.status === 401) {
|
2026-03-10 18:30:18 +09:00
|
|
|
|
errorMessage = "API 키가 유효하지 않습니다.";
|
2025-10-13 18:39:37 +09:00
|
|
|
|
} else if (err.response?.status === 404) {
|
|
|
|
|
|
errorMessage = `도시를 찾을 수 없습니다: ${city}`;
|
|
|
|
|
|
} else if (err.response?.data?.message) {
|
|
|
|
|
|
errorMessage = err.response.data.message;
|
|
|
|
|
|
}
|
2026-03-10 18:30:18 +09:00
|
|
|
|
|
2025-10-13 18:39:37 +09:00
|
|
|
|
setError(errorMessage);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 초기 로딩 및 자동 새로고침
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchWeather();
|
|
|
|
|
|
const interval = setInterval(fetchWeather, refreshInterval);
|
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
|
}, [selectedCity, refreshInterval]);
|
|
|
|
|
|
|
|
|
|
|
|
// 도시 변경 핸들러
|
|
|
|
|
|
const handleCityChange = (newCity: string) => {
|
|
|
|
|
|
setSelectedCity(newCity);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 날씨 아이콘 선택
|
|
|
|
|
|
const getWeatherIcon = (weatherMain: string) => {
|
|
|
|
|
|
switch (weatherMain.toLowerCase()) {
|
2026-03-10 18:30:18 +09:00
|
|
|
|
case "clear":
|
|
|
|
|
|
return <Sun className="text-warning h-12 w-12" />;
|
|
|
|
|
|
case "clouds":
|
|
|
|
|
|
return <Cloud className="text-muted-foreground h-12 w-12" />;
|
|
|
|
|
|
case "rain":
|
|
|
|
|
|
case "drizzle":
|
|
|
|
|
|
return <CloudRain className="text-primary h-12 w-12" />;
|
|
|
|
|
|
case "snow":
|
|
|
|
|
|
return <CloudSnow className="text-primary/70 h-12 w-12" />;
|
2025-10-13 18:39:37 +09:00
|
|
|
|
default:
|
2026-03-10 18:30:18 +09:00
|
|
|
|
return <Cloud className="text-muted-foreground h-12 w-12" />;
|
2025-10-13 18:39:37 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 로딩 상태
|
|
|
|
|
|
if (loading && !weather) {
|
|
|
|
|
|
return (
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<div className="bg-background flex h-full items-center justify-center rounded-lg border p-6">
|
2025-10-17 14:52:08 +09:00
|
|
|
|
<div className="flex flex-col items-center gap-3">
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<RefreshCw className="text-primary h-8 w-8 animate-spin" />
|
2025-10-17 14:52:08 +09:00
|
|
|
|
<div className="text-center">
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<p className="text-foreground mb-1 text-sm font-semibold">실제 기상청 API 연결 중...</p>
|
|
|
|
|
|
<p className="text-muted-foreground text-xs">실시간 관측 데이터를 가져오고 있습니다</p>
|
2025-10-17 14:52:08 +09:00
|
|
|
|
</div>
|
2025-10-13 18:39:37 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 에러 상태
|
|
|
|
|
|
if (error || !weather) {
|
2026-03-10 18:30:18 +09:00
|
|
|
|
const isTestMode = error?.includes("API 키가 설정되지 않았습니다");
|
2025-10-13 18:39:37 +09:00
|
|
|
|
return (
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<div className="bg-background flex h-full flex-col items-center justify-center rounded-lg border p-6">
|
|
|
|
|
|
<Cloud className="text-muted-foreground mb-2 h-12 w-12" />
|
|
|
|
|
|
<div className="mb-3 text-center">
|
|
|
|
|
|
<p className="text-foreground mb-1 text-sm font-semibold">{isTestMode ? "⚠️ 테스트 모드" : "❌ 연결 실패"}</p>
|
|
|
|
|
|
<p className="text-foreground text-xs">{error || "날씨 정보를 불러올 수 없습니다."}</p>
|
|
|
|
|
|
{isTestMode && <p className="text-warning mt-2 text-xs">임시 데이터가 표시됩니다</p>}
|
2025-10-17 14:52:08 +09:00
|
|
|
|
</div>
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<Button variant="outline" size="sm" onClick={fetchWeather} className="gap-1">
|
2025-10-13 18:39:37 +09:00
|
|
|
|
<RefreshCw className="h-3 w-3" />
|
|
|
|
|
|
다시 시도
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<div className="bg-background h-full rounded-lg border p-4">
|
2025-10-13 18:39:37 +09:00
|
|
|
|
{/* 헤더 */}
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<div className="mb-3 flex items-center justify-between">
|
2025-10-13 18:39:37 +09:00
|
|
|
|
<div className="flex-1">
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<h3 className="text-foreground mb-1 text-lg font-semibold">🌤️ {element?.customTitle || "날씨"}</h3>
|
|
|
|
|
|
<div className="mb-1 flex items-center gap-2">
|
2025-10-13 18:39:37 +09:00
|
|
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
role="combobox"
|
|
|
|
|
|
aria-expanded={open}
|
2026-03-10 18:30:18 +09:00
|
|
|
|
className="text-foreground hover:bg-muted/80 h-auto justify-between px-2 py-0.5 text-sm"
|
2025-10-13 18:39:37 +09:00
|
|
|
|
>
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{cities.find((city) => city.value === selectedCity)?.label || "도시 선택"}
|
2025-10-15 18:25:16 +09:00
|
|
|
|
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
2025-10-13 18:39:37 +09:00
|
|
|
|
</Button>
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent className="w-[200px] p-0" align="start">
|
|
|
|
|
|
<Command>
|
|
|
|
|
|
<CommandInput placeholder="도시 검색..." />
|
|
|
|
|
|
<CommandList>
|
|
|
|
|
|
<CommandEmpty>도시를 찾을 수 없습니다.</CommandEmpty>
|
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
|
{cities.map((city) => (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={city.value}
|
|
|
|
|
|
value={city.value}
|
|
|
|
|
|
onSelect={(currentValue) => {
|
|
|
|
|
|
handleCityChange(currentValue === selectedCity ? selectedCity : currentValue);
|
|
|
|
|
|
setOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check
|
2026-03-10 18:30:18 +09:00
|
|
|
|
className={cn("mr-2 h-4 w-4", selectedCity === city.value ? "opacity-100" : "opacity-0")}
|
2025-10-13 18:39:37 +09:00
|
|
|
|
/>
|
|
|
|
|
|
{city.label}
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
</div>
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<p className="text-muted-foreground pl-2 text-xs">
|
2025-10-13 18:39:37 +09:00
|
|
|
|
{lastUpdated
|
2026-03-10 18:30:18 +09:00
|
|
|
|
? `업데이트: ${lastUpdated.toLocaleTimeString("ko-KR", {
|
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
|
minute: "2-digit",
|
2025-10-13 18:39:37 +09:00
|
|
|
|
})}`
|
2026-03-10 18:30:18 +09:00
|
|
|
|
: ""}
|
2025-10-13 18:39:37 +09:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2025-10-14 10:05:40 +09:00
|
|
|
|
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
2025-10-14 10:05:40 +09:00
|
|
|
|
<Settings className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent className="w-[200px] p-3" align="end">
|
|
|
|
|
|
<div className="space-y-2">
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<h4 className="text-foreground mb-3 text-sm font-semibold">표시 항목</h4>
|
2025-10-14 10:05:40 +09:00
|
|
|
|
{weatherItems.map((item) => {
|
|
|
|
|
|
const Icon = item.icon;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
onClick={() => toggleItem(item.id)}
|
|
|
|
|
|
className={cn(
|
2026-03-10 18:30:18 +09:00
|
|
|
|
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs transition-colors",
|
|
|
|
|
|
selectedItems.includes(item.id) ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted",
|
2025-10-14 10:05:40 +09:00
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check
|
2026-03-10 18:30:18 +09:00
|
|
|
|
className={cn("h-3.5 w-3.5", selectedItems.includes(item.id) ? "opacity-100" : "opacity-0")}
|
2025-10-14 10:05:40 +09:00
|
|
|
|
/>
|
|
|
|
|
|
<Icon className="h-3.5 w-3.5" />
|
|
|
|
|
|
<span>{item.label}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<Button variant="ghost" size="sm" onClick={fetchWeather} disabled={loading} className="h-8 w-8 p-0">
|
|
|
|
|
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
2025-10-13 18:39:37 +09:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-14 10:05:40 +09:00
|
|
|
|
{/* 반응형 그리드 레이아웃 - 자동 조정 */}
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<div className="grid gap-2" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))" }}>
|
2025-10-14 10:05:40 +09:00
|
|
|
|
{/* 날씨 아이콘 및 온도 */}
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="bg-muted/80 rounded-lg p-3">
|
2025-10-14 10:05:40 +09:00
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
|
<div className="flex-shrink-0">
|
|
|
|
|
|
{(() => {
|
|
|
|
|
|
const iconClass = "h-5 w-5";
|
|
|
|
|
|
switch (weather.weatherMain.toLowerCase()) {
|
2026-03-10 18:30:18 +09:00
|
|
|
|
case "clear":
|
2025-10-29 17:53:03 +09:00
|
|
|
|
return <Sun className={`${iconClass} text-warning`} />;
|
2026-03-10 18:30:18 +09:00
|
|
|
|
case "clouds":
|
2025-10-29 17:53:03 +09:00
|
|
|
|
return <Cloud className={`${iconClass} text-muted-foreground`} />;
|
2026-03-10 18:30:18 +09:00
|
|
|
|
case "rain":
|
|
|
|
|
|
case "drizzle":
|
2025-10-29 17:53:03 +09:00
|
|
|
|
return <CloudRain className={`${iconClass} text-primary`} />;
|
2026-03-10 18:30:18 +09:00
|
|
|
|
case "snow":
|
2025-10-29 17:53:03 +09:00
|
|
|
|
return <CloudSnow className={`${iconClass} text-primary/70`} />;
|
2025-10-14 10:05:40 +09:00
|
|
|
|
default:
|
2025-10-29 17:53:03 +09:00
|
|
|
|
return <Cloud className={`${iconClass} text-muted-foreground`} />;
|
2025-10-14 10:05:40 +09:00
|
|
|
|
}
|
|
|
|
|
|
})()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<div className="text-foreground truncate text-sm leading-tight font-bold">{weather.temperature}°C</div>
|
|
|
|
|
|
<p className="text-muted-foreground truncate text-xs leading-tight capitalize">
|
2025-10-14 10:05:40 +09:00
|
|
|
|
{weather.weatherDescription}
|
|
|
|
|
|
</p>
|
2025-10-13 18:39:37 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-14 10:05:40 +09:00
|
|
|
|
{/* 기온 - 선택 가능 */}
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{selectedItems.includes("temperature") && (
|
|
|
|
|
|
<div className="bg-muted/80 flex items-center gap-1.5 rounded-lg p-3">
|
|
|
|
|
|
<Sun className="text-warning h-3.5 w-3.5 flex-shrink-0" />
|
2025-10-14 10:05:40 +09:00
|
|
|
|
<div className="min-w-0 flex-1">
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<p className="text-muted-foreground truncate text-xs leading-tight">기온</p>
|
|
|
|
|
|
<p className="text-foreground truncate text-sm leading-tight font-semibold">{weather.temperature}°C</p>
|
2025-10-14 10:05:40 +09:00
|
|
|
|
</div>
|
2025-10-13 18:39:37 +09:00
|
|
|
|
</div>
|
2025-10-14 10:05:40 +09:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 체감 온도 */}
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{selectedItems.includes("feelsLike") && (
|
|
|
|
|
|
<div className="bg-muted/80 flex items-center gap-1.5 rounded-lg p-3">
|
|
|
|
|
|
<Wind className="text-primary h-3.5 w-3.5 flex-shrink-0" />
|
2025-10-14 10:05:40 +09:00
|
|
|
|
<div className="min-w-0 flex-1">
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<p className="text-muted-foreground truncate text-xs leading-tight">체감온도</p>
|
|
|
|
|
|
<p className="text-foreground truncate text-sm leading-tight font-semibold">{weather.feelsLike}°C</p>
|
2025-10-14 10:05:40 +09:00
|
|
|
|
</div>
|
2025-10-13 18:39:37 +09:00
|
|
|
|
</div>
|
2025-10-14 10:05:40 +09:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 습도 */}
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{selectedItems.includes("humidity") && (
|
|
|
|
|
|
<div className="bg-muted/80 flex items-center gap-1.5 rounded-lg p-3">
|
|
|
|
|
|
<Droplets className="text-primary h-3.5 w-3.5 flex-shrink-0" />
|
2025-10-14 10:05:40 +09:00
|
|
|
|
<div className="min-w-0 flex-1">
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<p className="text-muted-foreground truncate text-xs leading-tight">습도</p>
|
|
|
|
|
|
<p className="text-foreground truncate text-sm leading-tight font-semibold">{weather.humidity}%</p>
|
2025-10-14 10:05:40 +09:00
|
|
|
|
</div>
|
2025-10-13 18:39:37 +09:00
|
|
|
|
</div>
|
2025-10-14 10:05:40 +09:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 풍속 */}
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{selectedItems.includes("windSpeed") && (
|
|
|
|
|
|
<div className="bg-muted/80 flex items-center gap-1.5 rounded-lg p-3">
|
|
|
|
|
|
<Wind className="text-success h-3.5 w-3.5 flex-shrink-0" />
|
2025-10-14 10:05:40 +09:00
|
|
|
|
<div className="min-w-0 flex-1">
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<p className="text-muted-foreground truncate text-xs leading-tight">풍속</p>
|
|
|
|
|
|
<p className="text-foreground truncate text-sm leading-tight font-semibold">{weather.windSpeed} m/s</p>
|
2025-10-14 10:05:40 +09:00
|
|
|
|
</div>
|
2025-10-13 18:39:37 +09:00
|
|
|
|
</div>
|
2025-10-14 10:05:40 +09:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 기압 */}
|
2026-03-10 18:30:18 +09:00
|
|
|
|
{selectedItems.includes("pressure") && (
|
|
|
|
|
|
<div className="bg-muted/80 flex items-center gap-1.5 rounded-lg p-3">
|
|
|
|
|
|
<Gauge className="text-primary h-3.5 w-3.5 flex-shrink-0" />
|
2025-10-14 10:05:40 +09:00
|
|
|
|
<div className="min-w-0 flex-1">
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<p className="text-muted-foreground truncate text-xs leading-tight">기압</p>
|
|
|
|
|
|
<p className="text-foreground truncate text-sm leading-tight font-semibold">{weather.pressure} hPa</p>
|
2025-10-14 10:05:40 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-10-13 18:39:37 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|