From 13b5dd71fe96eb98a3bf2ecdf9f7bf5f8723dc6a Mon Sep 17 00:00:00 2001 From: Seongbin Kim Date: Mon, 13 Jul 2026 19:18:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(web):=20=ED=82=A4=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=A4=91=EC=8B=AC=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EB=8D=B1=20?= =?UTF-8?q?=EC=8B=9C=EC=95=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/components.json | 16 ++ apps/web/index.html | 2 +- apps/web/src/App.css | 165 ++++++++++++++ apps/web/src/App.tsx | 62 +++++- .../CommandPalette/CommandPalette.tsx | 100 +++++---- .../charts/AvailableRestTimeChart/chart.tsx | 70 +++++- .../charts/AvailableRestTimeChart/points.tsx | 37 ++-- .../web/src/components/charts/DayRatioBar.tsx | 6 +- .../components/charts/ProductivePaceChart.tsx | 95 ++++++-- .../dataManagement/DataManagementButton.tsx | 18 +- .../src/components/texts/ProductiveToggle.tsx | 12 +- .../src/components/texts/TextLogContainer.tsx | 208 ++++++++++++------ apps/web/src/components/texts/TimeSummary.tsx | 83 ++++--- apps/web/src/components/ui/badge.tsx | 31 +++ apps/web/src/components/ui/button.tsx | 52 +++++ apps/web/src/components/ui/card.tsx | 45 ++++ apps/web/src/components/ui/input.tsx | 19 ++ apps/web/src/components/ui/textarea.tsx | 18 ++ .../features/AvailableRestTimeChartArea.tsx | 43 +++- .../src/features/ProductivePaceChartArea.tsx | 60 +++-- apps/web/src/hooks/useCommandPalette.ts | 13 +- apps/web/src/pages/LogWriterPage.tsx | 194 +++++++++++++--- apps/web/tailwind.config.js | 12 +- apps/web/vite.config.ts | 4 +- 24 files changed, 1087 insertions(+), 278 deletions(-) create mode 100644 apps/web/components.json create mode 100644 apps/web/src/components/ui/badge.tsx create mode 100644 apps/web/src/components/ui/button.tsx create mode 100644 apps/web/src/components/ui/card.tsx create mode 100644 apps/web/src/components/ui/input.tsx create mode 100644 apps/web/src/components/ui/textarea.tsx diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..efebb42 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/App.css", + "baseColor": "zinc", + "cssVariables": false + }, + "aliases": { + "components": "./src/components", + "ui": "./src/components/ui" + } +} diff --git a/apps/web/index.html b/apps/web/index.html index 9fdda00..7c7beac 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -7,7 +7,7 @@ - + * { + animation: deck-panel-in 420ms cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +.focus-command-deck main > :nth-child(2) { + animation-delay: 60ms; +} + +.focus-command-deck main > :nth-child(3) { + animation-delay: 120ms; +} + +@keyframes deck-panel-in { + from { + opacity: 0; + transform: translateY(7px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + @keyframes shake { 0% { transform: translateX(0); @@ -32,3 +187,13 @@ .shake-animation { animation: shake 600ms ease-in-out; } + +@media (prefers-reduced-motion: reduce) { + .focus-command-deck main > * { + animation: none; + } + + .shake-animation { + animation-duration: 1ms; + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d4300d3..595978b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -6,7 +6,22 @@ import { useEffect, useMemo } from 'react'; import { Command, CommandPalette } from './components/CommandPalette'; import { useCommandPalette } from './hooks/useCommandPalette'; import { Routes } from './Routes'; -import { focusActivityInput } from './utils/commandEvents'; +import { + dispatchAddConsumptionStart, + dispatchAddProductionStart, + focusActivityInput, +} from './utils/commandEvents'; + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + + return ( + target.isContentEditable || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement + ); +} export const App = () => { const { isOpen, close } = useCommandPalette(); @@ -15,6 +30,43 @@ export const App = () => { console.log(`[build version] ${__BUILD_VERSION__}`); }, []); + useEffect(() => { + const handleOperationalShortcuts = (event: KeyboardEvent) => { + const hasOpenDialog = document.querySelector('.modal-open') !== null; + if (isOpen || hasOpenDialog || event.isComposing) { + return; + } + + if ( + event.key === '/' && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !isEditableTarget(event.target) + ) { + event.preventDefault(); + focusActivityInput(); + return; + } + + if (event.altKey && !event.ctrlKey && !event.metaKey) { + if (event.code === 'Digit1') { + event.preventDefault(); + dispatchAddProductionStart(); + } + + if (event.code === 'Digit2') { + event.preventDefault(); + dispatchAddConsumptionStart(); + } + } + }; + + window.addEventListener('keydown', handleOperationalShortcuts); + return () => + window.removeEventListener('keydown', handleOperationalShortcuts); + }, [isOpen]); + const commands: Command[] = useMemo( () => [ { @@ -31,9 +83,7 @@ export const App = () => { description: '현재 시각으로 생산 활동을 시작합니다', icon: , action: () => { - import('./utils/commandEvents').then((module) => { - module.dispatchAddProductionStart(); - }); + dispatchAddProductionStart(); }, keywords: ['생산', 'production', 'start', '시작'], }, @@ -43,9 +93,7 @@ export const App = () => { description: '현재 시각으로 소비 활동을 시작합니다', icon: , action: () => { - import('./utils/commandEvents').then((module) => { - module.dispatchAddConsumptionStart(); - }); + dispatchAddConsumptionStart(); }, keywords: ['소비', 'consumption', 'start', '시작'], }, diff --git a/apps/web/src/components/CommandPalette/CommandPalette.tsx b/apps/web/src/components/CommandPalette/CommandPalette.tsx index e66c53b..9dce090 100644 --- a/apps/web/src/components/CommandPalette/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette/CommandPalette.tsx @@ -2,6 +2,11 @@ import clsx from 'clsx'; import { MinusCircle, PlusCircle, Search } from 'lucide-react'; import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + dispatchAddConsumptionStart, + dispatchAddProductionStart, +} from '../../utils/commandEvents'; + export interface Command { id: string; label: string; @@ -38,12 +43,10 @@ export const CommandPalette: React.FC = ({ { id: 'dynamic-add-production', label: content ? `생산 기록 추가: ${content}` : '생산 기록 추가', - description: '입력한 내용으 생산 기록을 추가합니다', + description: '입력한 내용으로 생산 기록을 추가합니다', icon: , action: () => { - import('../../utils/commandEvents').then((module) => { - module.dispatchAddProductionStart(content); - }); + dispatchAddProductionStart(content); }, }, ]; @@ -59,9 +62,7 @@ export const CommandPalette: React.FC = ({ description: '입력한 내용으로 소비 기록을 추가합니다', icon: , action: () => { - import('../../utils/commandEvents').then((module) => { - module.dispatchAddConsumptionStart(content); - }); + dispatchAddConsumptionStart(content); }, }, ]; @@ -85,11 +86,13 @@ export const CommandPalette: React.FC = ({ // 팔레트가 열릴 때 상태 초기화 및 포커스 useEffect(() => { if (isOpen) { - setSearchQuery(''); - setSelectedIndex(0); - setTimeout(() => { + const focusTimer = setTimeout(() => { + setSearchQuery(''); + setSelectedIndex(0); inputRef.current?.focus(); - }, 50); + }, 0); + + return () => clearTimeout(focusTimer); } }, [isOpen]); @@ -141,37 +144,47 @@ export const CommandPalette: React.FC = ({ } }, [selectedIndex, filteredCommands.length]); - // 검색어 변경 시 선택 인덱스 초기화 - useEffect(() => { - setSelectedIndex(0); - }, [searchQuery]); - if (!isOpen) return null; return ( -
- {/* 배경 클릭 시 닫기 */} -
- -
+
+ )) )}
{/* 도움말 */} -
-
+
+
- - 이동 + ↑↓ 이동 - 실행 + 실행
- ESC 닫기 + ESC 닫기
diff --git a/apps/web/src/components/charts/AvailableRestTimeChart/chart.tsx b/apps/web/src/components/charts/AvailableRestTimeChart/chart.tsx index bf4e2dd..5fcb4ee 100644 --- a/apps/web/src/components/charts/AvailableRestTimeChart/chart.tsx +++ b/apps/web/src/components/charts/AvailableRestTimeChart/chart.tsx @@ -23,6 +23,25 @@ export const AvailableRestTimeChart = ({ logs, }: AvailableRestTimeChartProps) => { const data = getChartDataPoints(logs); + + if (data.length < 2) { + return ( +
+
+
+

+ Waiting for timeline +

+

+ 두 개 이상의 시각 기록이 생기면 +
+ 휴식 편차가 표시됩니다. +

+
+
+ ); + } + const gradientOffset = calculateGradientOffset(data); const yAxisConfig = getNormalizedYAxisTicks(data); @@ -32,45 +51,72 @@ export const AvailableRestTimeChart = ({ const gradientId = 'availableRestTimeSplitColor'; return ( - + - + `${value}`} /> [`${value}분`, '초과 휴식']} + formatter={(value: number | string) => [`${value} min`, '휴식 편차']} + contentStyle={{ + background: '#080b09', + border: '1px solid #3a423b', + borderRadius: 0, + color: '#e7ede8', + fontFamily: 'monospace', + fontSize: 10, + }} + labelStyle={{ color: '#c9ff3d', marginBottom: 4 }} + itemStyle={{ color: '#d7ded8' }} /> - - + + {getPoints(data)} diff --git a/apps/web/src/components/charts/AvailableRestTimeChart/points.tsx b/apps/web/src/components/charts/AvailableRestTimeChart/points.tsx index 0016284..b122199 100644 --- a/apps/web/src/components/charts/AvailableRestTimeChart/points.tsx +++ b/apps/web/src/components/charts/AvailableRestTimeChart/points.tsx @@ -11,22 +11,23 @@ export const getPoints = (data: ChartDataPoint[]) => { const points: ReactElement[] = []; if (highPoint) { - const color = highPoint.need >= 0 ? 'red' : 'green'; + const color = highPoint.need >= 0 ? '#ff6b4a' : '#c9ff3d'; points.push( , @@ -34,22 +35,23 @@ export const getPoints = (data: ChartDataPoint[]) => { } if (lowPoint) { - const color = lowPoint.need >= 0 ? 'red' : 'green'; + const color = lowPoint.need >= 0 ? '#ff6b4a' : '#c9ff3d'; points.push( , @@ -62,16 +64,17 @@ export const getPoints = (data: ChartDataPoint[]) => { key="current" x={currentPointConfig.point.offset} y={currentPointConfig.point.need} - r={6} + r={4} fill={currentPointConfig.color} - stroke="white" - strokeWidth={2} + stroke="#080b09" + strokeWidth={1.5} >