시계 위젯 구현
This commit is contained in:
parent
4813da827e
commit
ce65e6106d
|
|
@ -466,10 +466,12 @@ const themes = {
|
|||
|
||||
### Step 6: 설정 모달
|
||||
|
||||
- [ ] `ClockConfigModal.tsx` 생성 (향후 추가 예정)
|
||||
- [ ] 스타일 선택 UI (향후 추가 예정)
|
||||
- [ ] 타임존 선택 UI (향후 추가 예정)
|
||||
- [ ] 옵션 토글 UI (향후 추가 예정)
|
||||
- [x] `ClockConfigModal.tsx` 생성 ✨
|
||||
- [x] 스타일 선택 UI (아날로그/디지털/둘다) ✨
|
||||
- [x] 타임존 선택 UI (8개 주요 도시) ✨
|
||||
- [x] 옵션 토글 UI (날짜/초/24시간) ✨
|
||||
- [x] 테마 선택 UI (light/dark/blue/gradient) ✨
|
||||
- [x] ElementConfigModal 통합 ✨
|
||||
|
||||
### Step 7: 통합
|
||||
|
||||
|
|
@ -547,7 +549,7 @@ const TIMEZONES = [
|
|||
- [x] 시계가 실시간으로 정확하게 업데이트됨 (1초마다 업데이트)
|
||||
- [x] 아날로그/디지털 스타일 모두 정상 작동 (코드 구현 완료)
|
||||
- [x] 타임존 변경이 즉시 반영됨 (Intl.DateTimeFormat 사용)
|
||||
- [ ] 설정 모달에서 모든 옵션 변경 가능 (향후 추가)
|
||||
- [x] 설정 모달에서 모든 옵션 변경 가능 ✨ (ClockConfigModal 완성!)
|
||||
- [x] 테마 전환이 자연스러움 (4가지 테마 구현)
|
||||
- [x] 메모리 누수 없음 (컴포넌트 unmount 시 타이머 정리 - useEffect cleanup)
|
||||
- [x] 크기 조절 시 레이아웃이 깨지지 않음 (그리드 스냅 적용)
|
||||
|
|
@ -603,13 +605,31 @@ console.log(formatter.format(new Date())); // "05:30"
|
|||
5. **통합** - CanvasElement, DashboardDesigner, Sidebar 연동
|
||||
6. **테마** - light, dark, blue, gradient 4가지 테마
|
||||
|
||||
### ✅ 최종 완료 기능
|
||||
|
||||
1. **시계 위젯 컴포넌트** - 아날로그/디지털/둘다
|
||||
2. **실시간 업데이트** - 1초마다 정확한 시간
|
||||
3. **타임존 지원** - 8개 주요 도시
|
||||
4. **4가지 테마** - light, dark, blue, gradient
|
||||
5. **설정 모달** - 모든 옵션 UI로 변경 가능 ✨
|
||||
|
||||
### 🔜 향후 추가 예정
|
||||
|
||||
- 설정 모달 (스타일, 타임존, 옵션 변경 UI)
|
||||
- 세계 시계 (여러 타임존 동시 표시)
|
||||
- 알람 기능
|
||||
- 타이머/스톱워치
|
||||
- 커스텀 색상 선택
|
||||
|
||||
---
|
||||
|
||||
이제 대시보드에서 시계 위젯을 드래그해서 사용할 수 있습니다! 🚀⏰
|
||||
## 🎯 사용 방법
|
||||
|
||||
1. **시계 추가**: 우측 사이드바에서 "⏰ 시계 위젯" 드래그
|
||||
2. **설정 변경**: 시계 위에 마우스 올리고 ⚙️ 버튼 클릭
|
||||
3. **옵션 선택**:
|
||||
- 스타일 (디지털/아날로그/둘다)
|
||||
- 타임존 (서울, 뉴욕, 런던 등)
|
||||
- 테마 (4가지)
|
||||
- 날짜/초/24시간 형식
|
||||
|
||||
이제 완벽하게 작동하는 시계 위젯을 사용할 수 있습니다! 🚀⏰
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from './types';
|
||||
import { QueryEditor } from './QueryEditor';
|
||||
import { ChartConfigPanel } from './ChartConfigPanel';
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult, ClockConfig } from "./types";
|
||||
import { QueryEditor } from "./QueryEditor";
|
||||
import { ChartConfigPanel } from "./ChartConfigPanel";
|
||||
import { ClockConfigModal } from "./widgets/ClockConfigModal";
|
||||
|
||||
interface ElementConfigModalProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -20,13 +21,11 @@ interface ElementConfigModalProps {
|
|||
*/
|
||||
export function ElementConfigModal({ element, isOpen, onClose, onSave }: ElementConfigModalProps) {
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: 'database', refreshInterval: 30000 }
|
||||
);
|
||||
const [chartConfig, setChartConfig] = useState<ChartConfig>(
|
||||
element.chartConfig || {}
|
||||
element.dataSource || { type: "database", refreshInterval: 30000 },
|
||||
);
|
||||
const [chartConfig, setChartConfig] = useState<ChartConfig>(element.chartConfig || {});
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'query' | 'chart'>('query');
|
||||
const [activeTab, setActiveTab] = useState<"query" | "chart">("query");
|
||||
|
||||
// 데이터 소스 변경 처리
|
||||
const handleDataSourceChange = useCallback((newDataSource: ChartDataSource) => {
|
||||
|
|
@ -43,7 +42,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
setQueryResult(result);
|
||||
// 쿼리 결과가 나오면 자동으로 차트 설정 탭으로 이동
|
||||
if (result.rows.length > 0) {
|
||||
setActiveTab('chart');
|
||||
setActiveTab("chart");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -58,26 +57,51 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
onClose();
|
||||
}, [element, dataSource, chartConfig, onSave, onClose]);
|
||||
|
||||
// 시계 위젯 설정 저장
|
||||
const handleClockConfigSave = useCallback(
|
||||
(clockConfig: ClockConfig) => {
|
||||
const updatedElement: DashboardElement = {
|
||||
...element,
|
||||
clockConfig,
|
||||
};
|
||||
onSave(updatedElement);
|
||||
},
|
||||
[element, onSave],
|
||||
);
|
||||
|
||||
// 모달이 열려있지 않으면 렌더링하지 않음
|
||||
if (!isOpen) return null;
|
||||
|
||||
// 시계 위젯인 경우 시계 설정 모달 표시
|
||||
if (element.type === "widget" && element.subtype === "clock") {
|
||||
return (
|
||||
<ClockConfigModal
|
||||
config={
|
||||
element.clockConfig || {
|
||||
style: "digital",
|
||||
timezone: "Asia/Seoul",
|
||||
showDate: true,
|
||||
showSeconds: true,
|
||||
format24h: true,
|
||||
theme: "light",
|
||||
}
|
||||
}
|
||||
onSave={handleClockConfigSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl h-[80vh] flex flex-col">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-lg bg-white shadow-xl">
|
||||
{/* 모달 헤더 */}
|
||||
<div className="flex justify-between items-center p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
{element.title} 설정
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
데이터 소스와 차트 설정을 구성하세요
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold text-gray-800">{element.title} 설정</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">데이터 소스와 차트 설정을 구성하세요</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-muted-foreground text-2xl"
|
||||
>
|
||||
<button onClick={onClose} className="hover:text-muted-foreground text-2xl text-gray-400">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -85,28 +109,26 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
{/* 탭 네비게이션 */}
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('query')}
|
||||
className={`
|
||||
px-6 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
${activeTab === 'query'
|
||||
? 'border-primary text-primary bg-accent'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'}
|
||||
`}
|
||||
onClick={() => setActiveTab("query")}
|
||||
className={`border-b-2 px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === "query"
|
||||
? "border-primary text-primary bg-accent"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
} `}
|
||||
>
|
||||
📝 쿼리 & 데이터
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('chart')}
|
||||
className={`
|
||||
px-6 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
${activeTab === 'chart'
|
||||
? 'border-primary text-primary bg-accent'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'}
|
||||
`}
|
||||
onClick={() => setActiveTab("chart")}
|
||||
className={`border-b-2 px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === "chart"
|
||||
? "border-primary text-primary bg-accent"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
} `}
|
||||
>
|
||||
📊 차트 설정
|
||||
{queryResult && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
<span className="ml-2 rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800">
|
||||
{queryResult.rows.length}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -115,7 +137,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
|
||||
{/* 탭 내용 */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{activeTab === 'query' && (
|
||||
{activeTab === "query" && (
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceChange}
|
||||
|
|
@ -123,41 +145,32 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'chart' && (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
{activeTab === "chart" && (
|
||||
<ChartConfigPanel config={chartConfig} queryResult={queryResult} onConfigChange={handleChartConfigChange} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="flex justify-between items-center p-6 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between border-t border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">
|
||||
{dataSource.query && (
|
||||
<>
|
||||
💾 쿼리: {dataSource.query.length > 50
|
||||
? `${dataSource.query.substring(0, 50)}...`
|
||||
: dataSource.query}
|
||||
💾 쿼리: {dataSource.query.length > 50 ? `${dataSource.query.substring(0, 50)}...` : dataSource.query}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-muted-foreground border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
className="text-muted-foreground rounded-lg border border-gray-300 px-4 py-2 hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dataSource.query || (!chartConfig.xAxis || !chartConfig.yAxis)}
|
||||
className="
|
||||
px-4 py-2 bg-accent0 text-white rounded-lg
|
||||
hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed
|
||||
"
|
||||
disabled={!dataSource.query || !chartConfig.xAxis || !chartConfig.yAxis}
|
||||
className="bg-accent0 rounded-lg px-4 py-2 text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -83,5 +83,6 @@ export interface ClockConfig {
|
|||
showDate: boolean; // 날짜 표시 여부
|
||||
showSeconds: boolean; // 초 표시 여부 (디지털)
|
||||
format24h: boolean; // 24시간 형식 (true) vs 12시간 형식 (false)
|
||||
theme: "light" | "dark" | "blue" | "gradient"; // 테마
|
||||
theme: "light" | "dark" | "custom"; // 테마
|
||||
customColor?: string; // 사용자 지정 색상 (custom 테마일 때)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
interface AnalogClockProps {
|
||||
time: Date;
|
||||
theme: "light" | "dark" | "blue" | "gradient";
|
||||
theme: "light" | "dark" | "custom";
|
||||
timezone?: string;
|
||||
customColor?: string; // 사용자 지정 색상
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -10,8 +12,9 @@ interface AnalogClockProps {
|
|||
* - SVG 기반 아날로그 시계
|
||||
* - 시침, 분침, 초침 애니메이션
|
||||
* - 테마별 색상 지원
|
||||
* - 타임존 표시
|
||||
*/
|
||||
export function AnalogClock({ time, theme }: AnalogClockProps) {
|
||||
export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockProps) {
|
||||
const hours = time.getHours() % 12;
|
||||
const minutes = time.getMinutes();
|
||||
const seconds = time.getSeconds();
|
||||
|
|
@ -22,11 +25,14 @@ export function AnalogClock({ time, theme }: AnalogClockProps) {
|
|||
const hourAngle = hours * 30 + minutes * 0.5 - 90; // 30도씩 + 분당 0.5도
|
||||
|
||||
// 테마별 색상
|
||||
const colors = getThemeColors(theme);
|
||||
const colors = getThemeColors(theme, customColor);
|
||||
|
||||
// 타임존 라벨
|
||||
const timezoneLabel = timezone ? getTimezoneLabel(timezone) : "";
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<svg viewBox="0 0 200 200" className="h-full max-h-[250px] w-full max-w-[250px]">
|
||||
<div className="flex h-full flex-col items-center justify-center p-2">
|
||||
<svg viewBox="0 0 200 200" className="h-full max-h-[200px] w-full max-w-[200px]">
|
||||
{/* 시계판 배경 */}
|
||||
<circle cx="100" cy="100" r="98" fill={colors.background} stroke={colors.border} strokeWidth="2" />
|
||||
|
||||
|
|
@ -110,14 +116,56 @@ export function AnalogClock({ time, theme }: AnalogClockProps) {
|
|||
<circle cx="100" cy="100" r="6" fill={colors.center} />
|
||||
<circle cx="100" cy="100" r="3" fill={colors.background} />
|
||||
</svg>
|
||||
|
||||
{/* 타임존 표시 */}
|
||||
{timezoneLabel && (
|
||||
<div className="mt-1 text-center text-xs font-medium" style={{ color: colors.number }}>
|
||||
{timezoneLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임존 라벨 반환
|
||||
*/
|
||||
function getTimezoneLabel(timezone: string): string {
|
||||
const timezoneLabels: Record<string, string> = {
|
||||
"Asia/Seoul": "서울 (KST)",
|
||||
"Asia/Tokyo": "도쿄 (JST)",
|
||||
"Asia/Shanghai": "베이징 (CST)",
|
||||
"America/New_York": "뉴욕 (EST)",
|
||||
"America/Los_Angeles": "LA (PST)",
|
||||
"Europe/London": "런던 (GMT)",
|
||||
"Europe/Paris": "파리 (CET)",
|
||||
"Australia/Sydney": "시드니 (AEDT)",
|
||||
};
|
||||
|
||||
return timezoneLabels[timezone] || timezone.split("/")[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 테마별 색상 반환
|
||||
*/
|
||||
function getThemeColors(theme: string) {
|
||||
function getThemeColors(theme: string, customColor?: string) {
|
||||
if (theme === "custom" && customColor) {
|
||||
// 사용자 지정 색상 사용 (약간 밝게/어둡게 조정)
|
||||
const lighterColor = adjustColor(customColor, 40);
|
||||
const darkerColor = adjustColor(customColor, -40);
|
||||
|
||||
return {
|
||||
background: lighterColor,
|
||||
border: customColor,
|
||||
tick: customColor,
|
||||
number: darkerColor,
|
||||
hourHand: darkerColor,
|
||||
minuteHand: customColor,
|
||||
secondHand: "#ef4444",
|
||||
center: darkerColor,
|
||||
};
|
||||
}
|
||||
|
||||
const themes = {
|
||||
light: {
|
||||
background: "#ffffff",
|
||||
|
|
@ -139,27 +187,35 @@ function getThemeColors(theme: string) {
|
|||
secondHand: "#ef4444",
|
||||
center: "#f9fafb",
|
||||
},
|
||||
blue: {
|
||||
background: "#dbeafe",
|
||||
border: "#3b82f6",
|
||||
tick: "#60a5fa",
|
||||
number: "#1e40af",
|
||||
hourHand: "#1e3a8a",
|
||||
minuteHand: "#2563eb",
|
||||
custom: {
|
||||
background: "#e0e7ff",
|
||||
border: "#6366f1",
|
||||
tick: "#818cf8",
|
||||
number: "#4338ca",
|
||||
hourHand: "#4338ca",
|
||||
minuteHand: "#6366f1",
|
||||
secondHand: "#ef4444",
|
||||
center: "#1e3a8a",
|
||||
},
|
||||
gradient: {
|
||||
background: "#fce7f3",
|
||||
border: "#ec4899",
|
||||
tick: "#f472b6",
|
||||
number: "#9333ea",
|
||||
hourHand: "#7c3aed",
|
||||
minuteHand: "#a855f7",
|
||||
secondHand: "#ef4444",
|
||||
center: "#7c3aed",
|
||||
center: "#4338ca",
|
||||
},
|
||||
};
|
||||
|
||||
return themes[theme as keyof typeof themes] || themes.light;
|
||||
}
|
||||
|
||||
/**
|
||||
* 색상 밝기 조정
|
||||
*/
|
||||
function adjustColor(color: string, amount: number): string {
|
||||
const clamp = (num: number) => Math.min(255, Math.max(0, num));
|
||||
|
||||
const hex = color.replace("#", "");
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
const newR = clamp(r + amount);
|
||||
const newG = clamp(g + amount);
|
||||
const newB = clamp(b + amount);
|
||||
|
||||
return `#${newR.toString(16).padStart(2, "0")}${newG.toString(16).padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ClockConfig } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface ClockConfigModalProps {
|
||||
config: ClockConfig;
|
||||
onSave: (config: ClockConfig) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시계 위젯 설정 모달
|
||||
* - 스타일 선택 (아날로그/디지털/둘다)
|
||||
* - 타임존 선택
|
||||
* - 테마 선택
|
||||
* - 옵션 토글 (날짜, 초, 24시간)
|
||||
*/
|
||||
export function ClockConfigModal({ config, onSave, onClose }: ClockConfigModalProps) {
|
||||
const [localConfig, setLocalConfig] = useState<ClockConfig>(config);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(localConfig);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden p-0">
|
||||
<DialogHeader className="border-b p-6">
|
||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||
<span>⏰</span>
|
||||
시계 위젯 설정
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 내용 - 스크롤 가능 */}
|
||||
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-4">
|
||||
{/* 스타일 선택 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">시계 스타일</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ value: "digital", label: "디지털", icon: "🔢" },
|
||||
{ value: "analog", label: "아날로그", icon: "🕐" },
|
||||
{ value: "both", label: "둘 다", icon: "⏰" },
|
||||
].map((style) => (
|
||||
<Button
|
||||
key={style.value}
|
||||
type="button"
|
||||
variant={localConfig.style === style.value ? "default" : "outline"}
|
||||
onClick={() => setLocalConfig({ ...localConfig, style: style.value as any })}
|
||||
className="flex h-auto flex-col items-center gap-2 p-4"
|
||||
>
|
||||
<span className="text-3xl">{style.icon}</span>
|
||||
<span className="text-sm font-medium">{style.label}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타임존 선택 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">타임존</Label>
|
||||
<Select
|
||||
value={localConfig.timezone}
|
||||
onValueChange={(value) => setLocalConfig({ ...localConfig, timezone: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Asia/Seoul">🇰🇷 서울 (KST)</SelectItem>
|
||||
<SelectItem value="Asia/Tokyo">🇯🇵 도쿄 (JST)</SelectItem>
|
||||
<SelectItem value="Asia/Shanghai">🇨🇳 베이징 (CST)</SelectItem>
|
||||
<SelectItem value="America/New_York">🇺🇸 뉴욕 (EST)</SelectItem>
|
||||
<SelectItem value="America/Los_Angeles">🇺🇸 LA (PST)</SelectItem>
|
||||
<SelectItem value="Europe/London">🇬🇧 런던 (GMT)</SelectItem>
|
||||
<SelectItem value="Europe/Paris">🇫🇷 파리 (CET)</SelectItem>
|
||||
<SelectItem value="Australia/Sydney">🇦🇺 시드니 (AEDT)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테마 선택 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">테마</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
gradient: "bg-gradient-to-br from-white to-gray-100",
|
||||
text: "text-gray-900",
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
gradient: "bg-gradient-to-br from-gray-800 to-gray-900",
|
||||
text: "text-white",
|
||||
},
|
||||
{
|
||||
value: "custom",
|
||||
label: "사용자 지정",
|
||||
gradient: "bg-gradient-to-br from-blue-400 to-purple-600",
|
||||
text: "text-white",
|
||||
},
|
||||
].map((theme) => (
|
||||
<Button
|
||||
key={theme.value}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setLocalConfig({ ...localConfig, theme: theme.value as any })}
|
||||
className={`relative h-auto overflow-hidden p-0 ${
|
||||
localConfig.theme === theme.value ? "ring-primary ring-2 ring-offset-2" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`${theme.gradient} ${theme.text} w-full rounded p-3 text-center text-xs font-medium`}>
|
||||
{theme.label}
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 사용자 지정 색상 선택 */}
|
||||
{localConfig.theme === "custom" && (
|
||||
<Card className="border p-4">
|
||||
<Label className="mb-2 block text-sm font-medium">배경 색상 선택</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="color"
|
||||
value={localConfig.customColor || "#3b82f6"}
|
||||
onChange={(e) => setLocalConfig({ ...localConfig, customColor: e.target.value })}
|
||||
className="h-12 w-20 cursor-pointer"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={localConfig.customColor || "#3b82f6"}
|
||||
onChange={(e) => setLocalConfig({ ...localConfig, customColor: e.target.value })}
|
||||
placeholder="#3b82f6"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-xs">시계의 배경색이나 강조색으로 사용됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 옵션 토글 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">표시 옵션</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{/* 날짜 표시 */}
|
||||
<Card className="hover:bg-accent flex cursor-pointer flex-col items-center justify-center border p-4 text-center transition-colors">
|
||||
<span className="mb-2 text-2xl">📅</span>
|
||||
<Label className="mb-1 cursor-pointer text-sm font-medium">날짜 표시</Label>
|
||||
<Switch
|
||||
checked={localConfig.showDate}
|
||||
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, showDate: checked })}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 초 표시 */}
|
||||
<Card className="hover:bg-accent flex cursor-pointer flex-col items-center justify-center border p-4 text-center transition-colors">
|
||||
<span className="mb-2 text-2xl">⏱️</span>
|
||||
<Label className="mb-1 cursor-pointer text-sm font-medium">초 표시</Label>
|
||||
<Switch
|
||||
checked={localConfig.showSeconds}
|
||||
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, showSeconds: checked })}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 24시간 형식 */}
|
||||
<Card className="hover:bg-accent flex cursor-pointer flex-col items-center justify-center border p-4 text-center transition-colors">
|
||||
<span className="mb-2 text-2xl">🕐</span>
|
||||
<Label className="mb-1 cursor-pointer text-sm font-medium">24시간 형식</Label>
|
||||
<Switch
|
||||
checked={localConfig.format24h}
|
||||
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, format24h: checked })}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t p-6">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>저장</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -42,7 +42,12 @@ export function ClockWidget({ element }: ClockWidgetProps) {
|
|||
if (config.style === "analog") {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<AnalogClock time={currentTime} theme={config.theme} />
|
||||
<AnalogClock
|
||||
time={currentTime}
|
||||
theme={config.theme}
|
||||
timezone={config.timezone}
|
||||
customColor={config.customColor}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -57,28 +62,36 @@ export function ClockWidget({ element }: ClockWidgetProps) {
|
|||
showSeconds={config.showSeconds}
|
||||
format24h={config.format24h}
|
||||
theme={config.theme}
|
||||
customColor={config.customColor}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 'both' - 아날로그 + 디지털
|
||||
// 'both' - 아날로그 + 디지털 (작은 크기에 최적화)
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* 아날로그 시계 (상단 60%) */}
|
||||
<div className="flex-[3]">
|
||||
<AnalogClock time={currentTime} theme={config.theme} />
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
{/* 아날로그 시계 (상단 55%) */}
|
||||
<div className="flex-[55] overflow-hidden">
|
||||
<AnalogClock
|
||||
time={currentTime}
|
||||
theme={config.theme}
|
||||
timezone={config.timezone}
|
||||
customColor={config.customColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 디지털 시계 (하단 40%) */}
|
||||
<div className="flex-[2]">
|
||||
{/* 디지털 시계 (하단 45%) - 컴팩트 버전 */}
|
||||
<div className="flex-[45] overflow-hidden">
|
||||
<DigitalClock
|
||||
time={currentTime}
|
||||
timezone={config.timezone}
|
||||
showDate={config.showDate}
|
||||
showDate={false}
|
||||
showSeconds={config.showSeconds}
|
||||
format24h={config.format24h}
|
||||
theme={config.theme}
|
||||
customColor={config.customColor}
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ interface DigitalClockProps {
|
|||
showDate: boolean;
|
||||
showSeconds: boolean;
|
||||
format24h: boolean;
|
||||
theme: "light" | "dark" | "blue" | "gradient";
|
||||
theme: "light" | "dark" | "custom";
|
||||
compact?: boolean; // 작은 크기에서 사용
|
||||
customColor?: string; // 사용자 지정 색상
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -16,7 +18,16 @@ interface DigitalClockProps {
|
|||
* - 날짜/초 표시 옵션
|
||||
* - 12/24시간 형식 지원
|
||||
*/
|
||||
export function DigitalClock({ time, timezone, showDate, showSeconds, format24h, theme }: DigitalClockProps) {
|
||||
export function DigitalClock({
|
||||
time,
|
||||
timezone,
|
||||
showDate,
|
||||
showSeconds,
|
||||
format24h,
|
||||
theme,
|
||||
compact = false,
|
||||
customColor,
|
||||
}: DigitalClockProps) {
|
||||
// 시간 포맷팅 (타임존 적용)
|
||||
const timeString = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: timezone,
|
||||
|
|
@ -41,18 +52,27 @@ export function DigitalClock({ time, timezone, showDate, showSeconds, format24h,
|
|||
const timezoneLabel = getTimezoneLabel(timezone);
|
||||
|
||||
// 테마별 스타일
|
||||
const themeClasses = getThemeClasses(theme);
|
||||
const themeClasses = getThemeClasses(theme, customColor);
|
||||
|
||||
return (
|
||||
<div className={`flex h-full flex-col items-center justify-center p-4 text-center ${themeClasses.container}`}>
|
||||
{/* 날짜 표시 */}
|
||||
{showDate && dateString && <div className={`mb-3 text-sm font-medium ${themeClasses.date}`}>{dateString}</div>}
|
||||
<div
|
||||
className={`flex h-full flex-col items-center justify-center ${compact ? "p-1" : "p-4"} text-center ${themeClasses.container}`}
|
||||
style={themeClasses.style}
|
||||
>
|
||||
{/* 날짜 표시 (compact 모드에서는 숨김) */}
|
||||
{!compact && showDate && dateString && (
|
||||
<div className={`mb-3 text-sm font-medium ${themeClasses.date}`}>{dateString}</div>
|
||||
)}
|
||||
|
||||
{/* 시간 표시 */}
|
||||
<div className={`text-5xl font-bold tabular-nums ${themeClasses.time}`}>{timeString}</div>
|
||||
<div className={`font-bold tabular-nums ${themeClasses.time} ${compact ? "text-xl" : "text-5xl"}`}>
|
||||
{timeString}
|
||||
</div>
|
||||
|
||||
{/* 타임존 표시 */}
|
||||
<div className={`mt-3 text-xs font-medium ${themeClasses.timezone}`}>{timezoneLabel}</div>
|
||||
<div className={`${compact ? "mt-0.5" : "mt-3"} text-xs font-medium ${themeClasses.timezone}`}>
|
||||
{timezoneLabel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -78,7 +98,18 @@ function getTimezoneLabel(timezone: string): string {
|
|||
/**
|
||||
* 테마별 클래스 반환
|
||||
*/
|
||||
function getThemeClasses(theme: string) {
|
||||
function getThemeClasses(theme: string, customColor?: string) {
|
||||
if (theme === "custom" && customColor) {
|
||||
// 사용자 지정 색상 사용
|
||||
return {
|
||||
container: "text-white",
|
||||
date: "text-white/80",
|
||||
time: "text-white",
|
||||
timezone: "text-white/70",
|
||||
style: { backgroundColor: customColor },
|
||||
};
|
||||
}
|
||||
|
||||
const themes = {
|
||||
light: {
|
||||
container: "bg-white text-gray-900",
|
||||
|
|
@ -92,18 +123,12 @@ function getThemeClasses(theme: string) {
|
|||
time: "text-white",
|
||||
timezone: "text-gray-400",
|
||||
},
|
||||
blue: {
|
||||
container: "bg-gradient-to-br from-blue-400 to-blue-600 text-white",
|
||||
custom: {
|
||||
container: "bg-gradient-to-br from-blue-400 to-purple-600 text-white",
|
||||
date: "text-blue-100",
|
||||
time: "text-white",
|
||||
timezone: "text-blue-200",
|
||||
},
|
||||
gradient: {
|
||||
container: "bg-gradient-to-br from-purple-400 via-pink-500 to-red-500 text-white",
|
||||
date: "text-purple-100",
|
||||
time: "text-white",
|
||||
timezone: "text-pink-200",
|
||||
},
|
||||
};
|
||||
|
||||
return themes[theme as keyof typeof themes] || themes.light;
|
||||
|
|
|
|||
Loading…
Reference in New Issue