From b43be73f2b78da3fb5145878699ec551a9158aa8 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Mon, 19 Jan 2026 16:26:38 +0900 Subject: [PATCH 01/34] =?UTF-8?q?Fix:=20=EB=A7=88=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=8F=88=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MastodonHttpClient의 favourite/unfavourite 메서드가 미스키 전용 에러를 던지는 문제 수정 - postAction을 통해 /api/v1/statuses/:id/favourite/unfavourite 호출하도록 구현 - 마스토돈 계정에서 좋아요 버튼 정상 동작하도록 개선 --- src/infra/MastodonHttpClient.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/infra/MastodonHttpClient.ts b/src/infra/MastodonHttpClient.ts index 9280c78..02bf5c8 100644 --- a/src/infra/MastodonHttpClient.ts +++ b/src/infra/MastodonHttpClient.ts @@ -398,12 +398,12 @@ export class MastodonHttpClient implements MastodonApi { } } - async favourite(_account: Account, _statusId: string): Promise { - throw new Error("즐겨찾기는 미스키 계정에서만 사용할 수 있습니다."); + async favourite(account: Account, statusId: string): Promise { + return this.postAction(account, statusId, "favourite"); } - async unfavourite(_account: Account, _statusId: string): Promise { - throw new Error("즐겨찾기는 미스키 계정에서만 사용할 수 있습니다."); + async unfavourite(account: Account, statusId: string): Promise { + return this.postAction(account, statusId, "unfavourite"); } async bookmark(account: Account, statusId: string): Promise { From 0b7862d90b126cacb700fa10744ca3b67a5a6475 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 20 Jan 2026 11:26:59 +0900 Subject: [PATCH 02/34] =?UTF-8?q?Feat:=20=EB=BD=80=EB=AA=A8=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=90=20=ED=91=9C=EC=8B=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 목표 사이클 수 설정 기능 (설정에서 1-20사이클 지정 가능) - 세션 완료 시 점으로 시각적 표시 (집중: 빨강, 휴식: 초록, 긴휴식: 파랑) - 완료된 세션 localStorage 저장 및 관리 - 타이머 리셋 시 진행 상태 초기화 - 목표 사이클 변경 시 완료된 세션 초기화 - 점 표시 hover 효과 및 반응형 디자인 --- src/App.tsx | 105 ++++++++++++++++++---------- src/ui/components/PomodoroTimer.tsx | 75 ++++++++++++++++---- src/ui/styles/components.css | 41 +++++++++++ 3 files changed, 171 insertions(+), 50 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2cfcfda..7818f06 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -964,6 +964,10 @@ export const App = () => { const stored = localStorage.getItem("textodon.pomodoro.longBreak"); return stored ? Number(stored) : 30; }); + const [pomodoroTargetCycles, setPomodoroTargetCycles] = useState(() => { + const stored = localStorage.getItem("textodon.pomodoro.targetCycles"); + return stored ? Number(stored) : 4; + }); const [pomodoroSessionType, setPomodoroSessionType] = useState<"focus" | "break" | "longBreak">("focus"); const [pomodoroIsRunning, setPomodoroIsRunning] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); @@ -1280,6 +1284,12 @@ export const App = () => { localStorage.setItem("textodon.pomodoro.longBreak", String(pomodoroLongBreak)); }, [pomodoroLongBreak]); + useEffect(() => { + localStorage.setItem("textodon.pomodoro.targetCycles", String(pomodoroTargetCycles)); + // 목표 사이클 수가 변경되면 완료된 세션 초기화 + localStorage.removeItem("textodon.pomodoro.completedSessions"); + }, [pomodoroTargetCycles]); + const closeMobileMenu = useCallback(() => { setMobileMenuOpen(false); setMobileComposeOpen(false); @@ -1713,8 +1723,7 @@ export const App = () => { focusMinutes={pomodoroFocus} breakMinutes={pomodoroBreak} longBreakMinutes={pomodoroLongBreak} - onSessionTypeChange={setPomodoroSessionType} - onRunningChange={setPomodoroIsRunning} + targetCycles={pomodoroTargetCycles} /> ) : null} {route === "home" ? ( @@ -2106,44 +2115,64 @@ onAccountChange={setSectionAccount} {showPomodoro ? ( -
-
- 뽀모도로 시간 설정 -

집중, 휴식, 긴 휴식 시간을 분 단위로 설정합니다.

+ <> +
+
+ 뽀모도로 시간 설정 +

집중, 휴식, 긴 휴식 시간을 분 단위로 설정합니다.

+
+
+ + + +
-
- - - +
+
+ 목표 사이클 수 +

완료할 뽀모도로 사이클 수를 설정합니다 (고정: 4사이클).

+
+
+ +
-
+ ) : null}
diff --git a/src/ui/components/PomodoroTimer.tsx b/src/ui/components/PomodoroTimer.tsx index e35b090..0b3cb4b 100644 --- a/src/ui/components/PomodoroTimer.tsx +++ b/src/ui/components/PomodoroTimer.tsx @@ -6,8 +6,7 @@ type PomodoroTimerProps = { focusMinutes?: number; breakMinutes?: number; longBreakMinutes?: number; - onSessionTypeChange?: (type: SessionType) => void; - onRunningChange?: (isRunning: boolean) => void; + targetCycles?: number; }; const TOTAL_SESSIONS = 8; @@ -33,8 +32,7 @@ export const PomodoroTimer = ({ focusMinutes = 25, breakMinutes = 5, longBreakMinutes = 30, - onSessionTypeChange, - onRunningChange, + targetCycles = 4, }: PomodoroTimerProps) => { const focusDuration = focusMinutes * 60; const breakDuration = breakMinutes * 60; @@ -60,17 +58,22 @@ export const PomodoroTimer = ({ const intervalRef = useRef(null); const audioContextRef = useRef(null); - const sessionInfo = useMemo(() => getSessionInfo(session), [session, getSessionInfo]); + // 완료된 세션 추적 + const [completedSessions, setCompletedSessions] = useState>(() => { + try { + const stored = localStorage.getItem("textodon.pomodoro.completedSessions"); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } + }); - // 세션 타입 변경 시 부모 컴포넌트에 알림 - useEffect(() => { - onSessionTypeChange?.(sessionInfo.type); - }, [sessionInfo.type, onSessionTypeChange]); + const sessionInfo = useMemo(() => getSessionInfo(session), [session, getSessionInfo]); - // 실행 상태 변경 시 부모 컴포넌트에 알림 + // 완료된 세션 localStorage 저장 useEffect(() => { - onRunningChange?.(isRunning); - }, [isRunning, onRunningChange]); + localStorage.setItem("textodon.pomodoro.completedSessions", JSON.stringify(completedSessions)); + }, [completedSessions]); const playNotificationSound = useCallback(() => { try { @@ -119,6 +122,7 @@ export const PomodoroTimer = ({ setSession(1); setTimeLeft(focusDuration); setIsRunning(false); + setCompletedSessions([]); if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; @@ -143,6 +147,15 @@ export const PomodoroTimer = ({ if (prev <= 1) { playNotificationSound(); setIsBlinking(true); + + // 현재 세션을 완료된 세션 목록에 추가 + setCompletedSessions((prev) => { + const updated = [...prev, { session, type: sessionInfo.type }]; + // 최근 targetCycles * 2개 세션까지만 유지 (집중+휴식이 한 사이클) + const maxSessions = targetCycles * 2; + return updated.slice(-maxSessions); + }); + const nextSession = session >= TOTAL_SESSIONS ? 1 : session + 1; const nextInfo = getSessionInfo(nextSession); setSession(nextSession); @@ -170,6 +183,41 @@ export const PomodoroTimer = ({ const focusCount = Math.ceil(session / 2); + // 진행 상태 점 생성 + const renderProgressDots = useCallback(() => { + const totalSessions = targetCycles * 2; // 각 사이클은 집중+휴식 + const dots = []; + + for (let i = 1; i <= totalSessions; i++) { + const completedSession = completedSessions.find(cs => cs.session === i); + const isCompleted = !!completedSession; + const sessionType = completedSession?.type || getSessionInfo(i).type; + + let dotClass = "pomodoro-progress-dot"; + if (isCompleted) { + if (sessionType === "focus") { + dotClass += " completed-focus"; + } else if (sessionType === "break") { + dotClass += " completed-break"; + } else if (sessionType === "longBreak") { + dotClass += " completed-long-break"; + } + } else { + dotClass += " incomplete"; + } + + dots.push( +
+ ); + } + + return dots; + }, [completedSessions, targetCycles, getSessionInfo]); + const handlePanelClick = useCallback(() => { if (isBlinking) { setIsBlinking(false); @@ -210,6 +258,9 @@ export const PomodoroTimer = ({
+
+ {renderProgressDots()} +
); }; diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 7f313e5..b255344 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -2782,3 +2782,44 @@ button.ghost { display: none; } } + +.pomodoro-progress-dots { + display: flex; + gap: 6px; + justify-content: center; + margin-top: 12px; + flex-wrap: wrap; +} + +.pomodoro-progress-dot { + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid; + transition: all 0.3s ease; + flex-shrink: 0; +} + +.pomodoro-progress-dot.incomplete { + border-color: var(--color-text-secondary); + background: transparent; +} + +.pomodoro-progress-dot.completed-focus { + border-color: var(--color-pomodoro-focus); + background: var(--color-pomodoro-focus); +} + +.pomodoro-progress-dot.completed-break { + border-color: var(--color-pomodoro-break); + background: var(--color-pomodoro-break); +} + +.pomodoro-progress-dot.completed-long-break { + border-color: var(--color-pomodoro-long-break); + background: var(--color-pomodoro-long-break); +} + +.pomodoro-progress-dot:hover { + transform: scale(1.2); +} From f4691bbbef9c18f0a7dacce9072ed1f1923cf3ac Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 20 Jan 2026 11:32:42 +0900 Subject: [PATCH 03/34] =?UTF-8?q?Fix:=20=EB=AF=B8=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=A0=90=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=ED=95=98=EC=96=80=20?= =?UTF-8?q?=EC=84=A0=EC=97=90=EC=84=9C=20=ED=9A=8C=EC=83=89=20=EC=A0=90?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/styles/components.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index b255344..0814509 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -2802,7 +2802,8 @@ button.ghost { .pomodoro-progress-dot.incomplete { border-color: var(--color-text-secondary); - background: transparent; + background: var(--color-text-secondary); + opacity: 0.3; } .pomodoro-progress-dot.completed-focus { From aab47d087e7ba8dfc32febc4a976176ebec1ee9f Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 20 Jan 2026 11:34:07 +0900 Subject: [PATCH 04/34] =?UTF-8?q?Fix:=20=EB=BD=80=EB=AA=A8=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=EC=A7=84=ED=96=89=20=EC=83=81=ED=83=9C=20=EC=A0=90?= =?UTF-8?q?=20=ED=81=AC=EA=B8=B0=EB=A5=BC=2010px=EC=97=90=EC=84=9C=208px?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/styles/components.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 0814509..617f100 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -2792,8 +2792,8 @@ button.ghost { } .pomodoro-progress-dot { - width: 10px; - height: 10px; + width: 8px; + height: 8px; border-radius: 50%; border: 2px solid; transition: all 0.3s ease; From 71bd862ef31671a4904cf4cf64f96a9891a05503 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 20 Jan 2026 11:40:27 +0900 Subject: [PATCH 05/34] =?UTF-8?q?Fix:=20=EB=BD=80=EB=AA=A8=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=EC=A7=84=ED=96=89=20=EC=83=81=ED=83=9C=20=EC=A0=90?= =?UTF-8?q?=EC=9D=B4=20=EB=AA=A8=EB=91=90=20=EC=B1=84=EC=9B=8C=EC=A7=80?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=84=B8=EC=85=98=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TOTAL_SESSIONS 고정 값을 제거하고 targetCycles에 따라 동적 계산 - 완료된 세션 목록에서 최대 개수 제한 로직 제거 - 중복 세션 번호 처리 로직 개선 - 모든 세션이 정상적으로 채워지고 표시되도록 수정 --- src/ui/components/PomodoroTimer.tsx | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/ui/components/PomodoroTimer.tsx b/src/ui/components/PomodoroTimer.tsx index 0b3cb4b..0400b7e 100644 --- a/src/ui/components/PomodoroTimer.tsx +++ b/src/ui/components/PomodoroTimer.tsx @@ -9,7 +9,7 @@ type PomodoroTimerProps = { targetCycles?: number; }; -const TOTAL_SESSIONS = 8; +// TOTAL_SESSIONS을 targetCycles에 따라 동적으로 계산 const getSessionLabel = (type: SessionType): string => { switch (type) { @@ -40,7 +40,9 @@ export const PomodoroTimer = ({ const getSessionInfo = useCallback( (sess: number): { type: SessionType; duration: number } => { - if (sess === 8) { + const totalSessions = targetCycles * 2; + // 마지막 세션은 긴 휴식 + if (sess === totalSessions) { return { type: "longBreak", duration: longBreakDuration }; } if (sess % 2 === 0) { @@ -48,7 +50,7 @@ export const PomodoroTimer = ({ } return { type: "focus", duration: focusDuration }; }, - [focusDuration, breakDuration, longBreakDuration] + [focusDuration, breakDuration, longBreakDuration, targetCycles] ); const [session, setSession] = useState(1); @@ -100,7 +102,8 @@ export const PomodoroTimer = ({ }, []); const handleSessionToggle = useCallback(() => { - const nextSession = session >= TOTAL_SESSIONS ? 1 : session + 1; + const totalSessions = targetCycles * 2; + const nextSession = session >= totalSessions ? 1 : session + 1; const nextInfo = getSessionInfo(nextSession); setSession(nextSession); setTimeLeft(nextInfo.duration); @@ -110,7 +113,7 @@ export const PomodoroTimer = ({ clearInterval(intervalRef.current); intervalRef.current = null; } - }, [session, getSessionInfo]); + }, [session, getSessionInfo, targetCycles]); const handleStart = useCallback(() => { setIsBlinking(false); @@ -151,12 +154,15 @@ export const PomodoroTimer = ({ // 현재 세션을 완료된 세션 목록에 추가 setCompletedSessions((prev) => { const updated = [...prev, { session, type: sessionInfo.type }]; - // 최근 targetCycles * 2개 세션까지만 유지 (집중+휴식이 한 사이클) - const maxSessions = targetCycles * 2; - return updated.slice(-maxSessions); + // 중복 세션 제거 (동일 세션 번호가 있으면 기존 것 제거) + const filtered = updated.filter((cs, index) => + updated.findIndex(item => item.session === cs.session) === index + ); + return filtered; }); - const nextSession = session >= TOTAL_SESSIONS ? 1 : session + 1; + const totalSessions = targetCycles * 2; + const nextSession = session >= totalSessions ? 1 : session + 1; const nextInfo = getSessionInfo(nextSession); setSession(nextSession); setIsRunning(false); From 5b493769fa1539916d75457d790a96f980987214 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 20 Jan 2026 11:42:38 +0900 Subject: [PATCH 06/34] =?UTF-8?q?Feat:=20=EB=BD=80=EB=AA=A8=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=EC=82=AC=EC=9D=B4=ED=81=B4=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=8B=9C=20=EC=A7=84=ED=96=89=20=EC=83=81=ED=83=9C=20=EC=A0=90?= =?UTF-8?q?=20=EB=A6=AC=EC=85=8B=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 한 사이클(targetCycles * 2 세션)이 끝나면 점 전부 초기화 - 다시 첫 번째 집중 세션부터 시작 - 현재 진행 중인 사이클만 시각적으로 표시하여 직관성 향상 --- src/ui/components/PomodoroTimer.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ui/components/PomodoroTimer.tsx b/src/ui/components/PomodoroTimer.tsx index 0400b7e..30ea49d 100644 --- a/src/ui/components/PomodoroTimer.tsx +++ b/src/ui/components/PomodoroTimer.tsx @@ -151,6 +151,8 @@ export const PomodoroTimer = ({ playNotificationSound(); setIsBlinking(true); + const totalSessions = targetCycles * 2; + // 현재 세션을 완료된 세션 목록에 추가 setCompletedSessions((prev) => { const updated = [...prev, { session, type: sessionInfo.type }]; @@ -158,10 +160,15 @@ export const PomodoroTimer = ({ const filtered = updated.filter((cs, index) => updated.findIndex(item => item.session === cs.session) === index ); + + // 한 사이클이 끝나면 점 초기화 + if (session === totalSessions) { + return []; + } + return filtered; }); - const totalSessions = targetCycles * 2; const nextSession = session >= totalSessions ? 1 : session + 1; const nextInfo = getSessionInfo(nextSession); setSession(nextSession); From c8debd88fb05721ff619fb3b861bd5de137a450f Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 20 Jan 2026 11:44:29 +0900 Subject: [PATCH 07/34] =?UTF-8?q?Feat:=20=EB=BD=80=EB=AA=A8=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=A6=AC=EB=A1=9C=EB=93=9C=20=EC=8B=9C=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=83=81=ED=83=9C=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 현재 세션 번호, 남은 시간, 실행 상태를 localStorage에 저장 - 페이지 새로고침/브라우저 재시작 시 이전 상태 복원 - 설정 변경 시에는 타이머 자동 정지 및 시간 재설정 기존 로직 유지 - 사용자 경험 향상: 중단된 타이머 이어서 가능 --- src/ui/components/PomodoroTimer.tsx | 47 +++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/ui/components/PomodoroTimer.tsx b/src/ui/components/PomodoroTimer.tsx index 30ea49d..2737ca2 100644 --- a/src/ui/components/PomodoroTimer.tsx +++ b/src/ui/components/PomodoroTimer.tsx @@ -53,9 +53,37 @@ export const PomodoroTimer = ({ [focusDuration, breakDuration, longBreakDuration, targetCycles] ); - const [session, setSession] = useState(1); - const [timeLeft, setTimeLeft] = useState(focusDuration); - const [isRunning, setIsRunning] = useState(false); + const [session, setSession] = useState(() => { + try { + const stored = localStorage.getItem("textodon.pomodoro.currentSession"); + return stored ? Number(stored) : 1; + } catch { + return 1; + } + }); + + const [timeLeft, setTimeLeft] = useState(() => { + try { + const stored = localStorage.getItem("textodon.pomodoro.timeLeft"); + const savedSession = localStorage.getItem("textodon.pomodoro.currentSession"); + if (stored && savedSession) { + return Number(stored); + } + return focusDuration; + } catch { + return focusDuration; + } + }); + + const [isRunning, setIsRunning] = useState(() => { + try { + const stored = localStorage.getItem("textodon.pomodoro.isRunning"); + return stored === "true"; + } catch { + return false; + } + }); + const [isBlinking, setIsBlinking] = useState(false); const intervalRef = useRef(null); const audioContextRef = useRef(null); @@ -77,6 +105,19 @@ export const PomodoroTimer = ({ localStorage.setItem("textodon.pomodoro.completedSessions", JSON.stringify(completedSessions)); }, [completedSessions]); + // 현재 세션 상태 localStorage 저장 + useEffect(() => { + localStorage.setItem("textodon.pomodoro.currentSession", String(session)); + }, [session]); + + useEffect(() => { + localStorage.setItem("textodon.pomodoro.timeLeft", String(timeLeft)); + }, [timeLeft]); + + useEffect(() => { + localStorage.setItem("textodon.pomodoro.isRunning", String(isRunning)); + }, [isRunning]); + const playNotificationSound = useCallback(() => { try { if (!audioContextRef.current) { From b694b268bdbaf02cc85dc754951ce60d291debba Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 20 Jan 2026 11:47:00 +0900 Subject: [PATCH 08/34] =?UTF-8?q?Fix:=20=EB=BD=80=EB=AA=A8=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=EC=A7=84=ED=96=89=20=EC=83=81=ED=83=9C=20=EC=A0=90?= =?UTF-8?q?=EC=9D=84=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=EC=95=84=EB=9E=98=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 점 표시를 제어 버튼 아래에서 타이머 시간 표시 바로 아래로 이동 - margin-top: 8px, margin-bottom: 12px로 적절한 간격 조정 - 진행 상태를 더 직관적으로 확인 가능하도록 위치 최적화 --- src/ui/components/PomodoroTimer.tsx | 8 +++++--- src/ui/styles/components.css | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/ui/components/PomodoroTimer.tsx b/src/ui/components/PomodoroTimer.tsx index 2737ca2..ba50d32 100644 --- a/src/ui/components/PomodoroTimer.tsx +++ b/src/ui/components/PomodoroTimer.tsx @@ -295,6 +295,11 @@ export const PomodoroTimer = ({ {formatTime(timeLeft)} +
+
+ {renderProgressDots()} +
+
-
- {renderProgressDots()} -
); }; diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 617f100..51ef256 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -2787,7 +2787,8 @@ button.ghost { display: flex; gap: 6px; justify-content: center; - margin-top: 12px; + margin-top: 8px; + margin-bottom: 12px; flex-wrap: wrap; } From ae6a9c319f9d5d60814c2ca2be076d307b16f15c Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 20 Jan 2026 11:48:29 +0900 Subject: [PATCH 09/34] =?UTF-8?q?Fix:=20=EB=BD=80=EB=AA=A8=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=ED=83=80=EC=9D=B4=EB=A8=B8=EC=99=80=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=EC=83=81=ED=83=9C=20=EC=A0=90=EC=9D=84=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EB=AC=B6=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pomodoro-time과 pomodoro-progress-dots를 같은 pomodoro-row 안에 배치 - 두 요소가 시각적으로 함께 묶여 더 자연스러운 레이아웃 - margin-bottom 제거로 제어 버튼과의 적절한 간격 유지 --- src/ui/components/PomodoroTimer.tsx | 6 +++--- src/ui/styles/components.css | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/ui/components/PomodoroTimer.tsx b/src/ui/components/PomodoroTimer.tsx index ba50d32..4bc26ea 100644 --- a/src/ui/components/PomodoroTimer.tsx +++ b/src/ui/components/PomodoroTimer.tsx @@ -295,9 +295,9 @@ export const PomodoroTimer = ({ {formatTime(timeLeft)} -
-
- {renderProgressDots()} +
+ {renderProgressDots()} +
diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 51ef256..ba344b6 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -2788,7 +2788,6 @@ button.ghost { gap: 6px; justify-content: center; margin-top: 8px; - margin-bottom: 12px; flex-wrap: wrap; } From 82d2a59ad7ba0a1be80e7e77ab8bed8dd151d93c Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 20 Jan 2026 11:49:54 +0900 Subject: [PATCH 10/34] =?UTF-8?q?Fix:=20=EB=BD=80=EB=AA=A8=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=ED=83=80=EC=9D=B4=EB=A8=B8=EC=99=80=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=EC=83=81=ED=83=9C=20=EC=A0=90=EC=9D=84=20=EC=84=B8?= =?UTF-8?q?=EB=A1=9C=EB=A1=9C=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pomodoro-timer-section 클래스 추가하여 타이머와 점 세로 배치 - flex-direction: column으로 세로 정렬 - align-items: center로 중앙 정렬 - gap: 8px로 적절한 세로 간격 설정 --- src/ui/components/PomodoroTimer.tsx | 2 ++ src/ui/styles/components.css | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/src/ui/components/PomodoroTimer.tsx b/src/ui/components/PomodoroTimer.tsx index 4bc26ea..e53b20a 100644 --- a/src/ui/components/PomodoroTimer.tsx +++ b/src/ui/components/PomodoroTimer.tsx @@ -292,6 +292,8 @@ export const PomodoroTimer = ({ > {getSessionLabel(sessionInfo.type)} {sessionInfo.type === "focus" ? focusCount : ""} +
+
{formatTime(timeLeft)} diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index ba344b6..d02b961 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -2709,6 +2709,12 @@ button.ghost { gap: 12px; } +.pomodoro-timer-section { + flex-direction: column; + align-items: center; + gap: 8px; +} + .pomodoro-mode-toggle { padding: 6px 12px; border: none; From ce890a9bd18966cc0c0308dbd179c1e69293b3a6 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 20 Jan 2026 11:52:31 +0900 Subject: [PATCH 11/34] =?UTF-8?q?Fix:=20=EB=BD=80=EB=AA=A8=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=EC=9D=84=20?= =?UTF-8?q?=EC=A2=8C=EC=9A=B0=20=ED=95=9C=20=EC=A4=84=EB=A1=9C=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세션 버튼(왼쪽), 타이머/점(중앙), 제어 버튼(오른쪽) 한 줄 배치 - justify-content: space-between으로 균등한 분배 - pomodoro-timer-section에 flex: 1 추가로 중앙 영역 확보 - 세션 버튼과 제어 버튼은 각자 영역에 고정 --- src/ui/components/PomodoroTimer.tsx | 18 +++++++++--------- src/ui/styles/components.css | 3 +++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/ui/components/PomodoroTimer.tsx b/src/ui/components/PomodoroTimer.tsx index e53b20a..0e54211 100644 --- a/src/ui/components/PomodoroTimer.tsx +++ b/src/ui/components/PomodoroTimer.tsx @@ -292,16 +292,16 @@ export const PomodoroTimer = ({ > {getSessionLabel(sessionInfo.type)} {sessionInfo.type === "focus" ? focusCount : ""} -
-
- - {formatTime(timeLeft)} - -
- {renderProgressDots()} + +
+ + {formatTime(timeLeft)} + +
+ {renderProgressDots()} +
-
-
+
-
-
- 목표 사이클 수 -

완료할 뽀모도로 사이클 수를 설정합니다 (고정: 4사이클).

-
-
- -
-
) : null}
diff --git a/src/ui/components/PomodoroTimer.tsx b/src/ui/components/PomodoroTimer.tsx index 0e54211..4978af9 100644 --- a/src/ui/components/PomodoroTimer.tsx +++ b/src/ui/components/PomodoroTimer.tsx @@ -6,7 +6,6 @@ type PomodoroTimerProps = { focusMinutes?: number; breakMinutes?: number; longBreakMinutes?: number; - targetCycles?: number; }; // TOTAL_SESSIONS을 targetCycles에 따라 동적으로 계산 @@ -32,8 +31,8 @@ export const PomodoroTimer = ({ focusMinutes = 25, breakMinutes = 5, longBreakMinutes = 30, - targetCycles = 4, }: PomodoroTimerProps) => { + const targetCycles = 4; // 고정된 4사이클 const focusDuration = focusMinutes * 60; const breakDuration = breakMinutes * 60; const longBreakDuration = longBreakMinutes * 60; From bec0647758a6f79c7f351d64941e1fe63e600809 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 20 Jan 2026 12:04:00 +0900 Subject: [PATCH 14/34] =?UTF-8?q?Fix:=20=EB=BD=80=EB=AA=A8=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=EC=A7=91=EC=A4=91=20=EC=84=B8=EC=85=98=20=EC=8B=9C?= =?UTF-8?q?=20=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20=EA=B0=80=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onSessionTypeChange, onRunningChange props 재추가 - PomodoroTimer 세션 타입/실행 상태 변경 시 App.tsx 상태 동기화 - 집중 세션 실행 중 타임라인 블러 효과 및 안내 메시지 정상 동작 - props 제거로 인해 동작하지 않던 기능 복구 --- src/App.tsx | 2 ++ src/ui/components/PomodoroTimer.tsx | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 14e7c1a..dcfa6dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1715,6 +1715,8 @@ export const App = () => { focusMinutes={pomodoroFocus} breakMinutes={pomodoroBreak} longBreakMinutes={pomodoroLongBreak} + onSessionTypeChange={setPomodoroSessionType} + onRunningChange={setPomodoroIsRunning} /> ) : null} {route === "home" ? ( diff --git a/src/ui/components/PomodoroTimer.tsx b/src/ui/components/PomodoroTimer.tsx index 4978af9..3ba1b86 100644 --- a/src/ui/components/PomodoroTimer.tsx +++ b/src/ui/components/PomodoroTimer.tsx @@ -6,6 +6,8 @@ type PomodoroTimerProps = { focusMinutes?: number; breakMinutes?: number; longBreakMinutes?: number; + onSessionTypeChange?: (type: SessionType) => void; + onRunningChange?: (isRunning: boolean) => void; }; // TOTAL_SESSIONS을 targetCycles에 따라 동적으로 계산 @@ -31,6 +33,8 @@ export const PomodoroTimer = ({ focusMinutes = 25, breakMinutes = 5, longBreakMinutes = 30, + onSessionTypeChange, + onRunningChange, }: PomodoroTimerProps) => { const targetCycles = 4; // 고정된 4사이클 const focusDuration = focusMinutes * 60; @@ -99,6 +103,16 @@ export const PomodoroTimer = ({ const sessionInfo = useMemo(() => getSessionInfo(session), [session, getSessionInfo]); + // 세션 타입 변경 시 부모 컴포넌트에 알림 + useEffect(() => { + onSessionTypeChange?.(sessionInfo.type); + }, [sessionInfo.type, onSessionTypeChange]); + + // 실행 상태 변경 시 부모 컴포넌트에 알림 + useEffect(() => { + onRunningChange?.(isRunning); + }, [isRunning, onRunningChange]); + // 완료된 세션 localStorage 저장 useEffect(() => { localStorage.setItem("textodon.pomodoro.completedSessions", JSON.stringify(completedSessions)); From f6016fdc9b6171d4a53ff55f8babe009dcb00d8a Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 20 Jan 2026 12:54:29 +0900 Subject: [PATCH 15/34] =?UTF-8?q?feat:=20=EC=84=A4=EC=A0=95=20=ED=8C=9D?= =?UTF-8?q?=EC=97=85=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings-modal-body wrapper 추가로 설정 항목들만 스크롤 가능 - 헤더 고정 및 border 추가로 시각적 구분 개선 - 반응형 높이 적용 (80vh, max 600px) - 모든 테마에 스크롤바 색상 변수 추가 (라이트/다크 9개 테마) - 웹킷 스크롤바 스타일링으로 깔끔한 UI 유지 - 첫 항목 상단 패딩 추가로 여백 개선 --- src/App.tsx | 4 +++- src/ui/styles/base.css | 3 +++ src/ui/styles/components.css | 35 +++++++++++++++++++++++++++++++++- src/ui/styles/themes/dark.css | 27 ++++++++++++++++++++++++++ src/ui/styles/themes/light.css | 12 ++++++++++++ 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index dcfa6dc..7f7bc89 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1960,7 +1960,8 @@ onAccountChange={setSectionAccount} 닫기
-
+
+
계정 관리

계정을 선택하여 재인증하거나 삭제합니다.

@@ -2163,6 +2164,7 @@ onAccountChange={setSectionAccount} 모두 삭제
+
) : null} diff --git a/src/ui/styles/base.css b/src/ui/styles/base.css index f8f7a54..7562970 100644 --- a/src/ui/styles/base.css +++ b/src/ui/styles/base.css @@ -47,6 +47,9 @@ --color-settings-select-border: #d8d1c7; --color-settings-select-text: #1f1b16; --color-settings-select-placeholder: #6b5c4f; + --color-settings-scrollbar-track: rgba(0, 0, 0, 0.1); + --color-settings-scrollbar-thumb: rgba(0, 0, 0, 0.3); + --color-settings-scrollbar-thumb-hover: rgba(0, 0, 0, 0.5); --color-action-bg: #3b5fa8; --color-action-text: #f2f6ff; --color-action-active-bg: #9db6e3; diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index f77254c..840ea5a 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -200,10 +200,11 @@ position: relative; z-index: 1; width: min(92vw, 520px); + height: 80vh; + max-height: 600px; margin: 10vh auto 0; display: flex; flex-direction: column; - gap: 12px; } .settings-modal-header { @@ -211,6 +212,9 @@ justify-content: space-between; align-items: center; gap: 12px; + flex-shrink: 0; + padding-bottom: 12px; + border-bottom: 1px solid var(--color-settings-item-border); } .settings-modal-header h3 { @@ -222,6 +226,31 @@ padding: 0 12px; } +.settings-modal-body { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 12px 4px 0; +} + +.settings-modal-body::-webkit-scrollbar { + width: 6px; +} + +.settings-modal-body::-webkit-scrollbar-track { + background: var(--color-settings-scrollbar-track); + border-radius: 3px; +} + +.settings-modal-body::-webkit-scrollbar-thumb { + background: var(--color-settings-scrollbar-thumb); + border-radius: 3px; +} + +.settings-modal-body::-webkit-scrollbar-thumb:hover { + background: var(--color-settings-scrollbar-thumb-hover); +} + .settings-item { display: flex; justify-content: space-between; @@ -236,6 +265,10 @@ padding-top: 0; } +.settings-item:last-of-type { + margin-bottom: 0; +} + .settings-item p { margin: 4px 0 0; font-size: 12px; diff --git a/src/ui/styles/themes/dark.css b/src/ui/styles/themes/dark.css index a637fa5..e4f19b3 100644 --- a/src/ui/styles/themes/dark.css +++ b/src/ui/styles/themes/dark.css @@ -109,6 +109,9 @@ --color-settings-select-bg: #151310; --color-settings-select-border: #3a312a; --color-settings-select-text: #f0e7dc; + --color-settings-scrollbar-track: rgba(240, 231, 220, 0.1); + --color-settings-scrollbar-thumb: rgba(240, 231, 220, 0.3); + --color-settings-scrollbar-thumb-hover: rgba(240, 231, 220, 0.5); --color-compose-input-bg: #151310; --color-attachments-bg: #1a1814; --color-attachments-border: #3a312a; @@ -235,6 +238,9 @@ --color-settings-select-bg: #1a100f; --color-settings-select-border: #3b2624; --color-settings-select-text: #f7e7df; + --color-settings-scrollbar-track: rgba(247, 231, 223, 0.1); + --color-settings-scrollbar-thumb: rgba(247, 231, 223, 0.3); + --color-settings-scrollbar-thumb-hover: rgba(247, 231, 223, 0.5); --color-compose-input-bg: #1a100f; --color-attachments-bg: #2a1b19; --color-attachments-border: #3b2624; @@ -346,6 +352,9 @@ --color-settings-select-bg: #181e2a; --color-settings-select-border: #2e3b50; --color-settings-select-text: #eff1f7; + --color-settings-scrollbar-track: rgba(239, 241, 247, 0.1); + --color-settings-scrollbar-thumb: rgba(239, 241, 247, 0.3); + --color-settings-scrollbar-thumb-hover: rgba(239, 241, 247, 0.5); --color-compose-input-bg: #181e2a; --color-attachments-bg: #212a3a; --color-attachments-border: #2e3b50; @@ -463,6 +472,9 @@ --color-settings-select-bg: #101010; --color-settings-select-border: #3a3a3a; --color-settings-select-text: #f0f0f0; + --color-settings-scrollbar-track: rgba(240, 240, 240, 0.1); + --color-settings-scrollbar-thumb: rgba(240, 240, 240, 0.3); + --color-settings-scrollbar-thumb-hover: rgba(240, 240, 240, 0.5); --color-compose-input-bg: #101010; --color-attachments-bg: #181818; --color-attachments-border: #3a3a3a; @@ -583,6 +595,9 @@ --color-settings-select-bg: #0a120e; --color-settings-select-border: #1f3a2b; --color-settings-select-text: #c6f3d0; + --color-settings-scrollbar-track: rgba(198, 243, 208, 0.1); + --color-settings-scrollbar-thumb: rgba(198, 243, 208, 0.3); + --color-settings-scrollbar-thumb-hover: rgba(198, 243, 208, 0.5); --color-compose-input-bg: #0a120e; --color-attachments-bg: #101c15; --color-attachments-border: #1f3a2b; @@ -856,6 +871,9 @@ --color-settings-select-bg: #1a100f; --color-settings-select-border: #3b2624; --color-settings-select-text: #f7e7df; + --color-settings-scrollbar-track: rgba(247, 231, 223, 0.1); + --color-settings-scrollbar-thumb: rgba(247, 231, 223, 0.3); + --color-settings-scrollbar-thumb-hover: rgba(247, 231, 223, 0.5); --color-compose-input-bg: #1a100f; --color-attachments-bg: #2a1b19; --color-attachments-border: #3b2624; @@ -967,6 +985,9 @@ --color-settings-select-bg: #181e2a; --color-settings-select-border: #2e3b50; --color-settings-select-text: #eff1f7; + --color-settings-scrollbar-track: rgba(239, 241, 247, 0.1); + --color-settings-scrollbar-thumb: rgba(239, 241, 247, 0.3); + --color-settings-scrollbar-thumb-hover: rgba(239, 241, 247, 0.5); --color-compose-input-bg: #181e2a; --color-attachments-bg: #212a3a; --color-attachments-border: #2e3b50; @@ -1084,6 +1105,9 @@ --color-settings-select-bg: #101010; --color-settings-select-border: #3a3a3a; --color-settings-select-text: #f0f0f0; + --color-settings-scrollbar-track: rgba(240, 240, 240, 0.1); + --color-settings-scrollbar-thumb: rgba(240, 240, 240, 0.3); + --color-settings-scrollbar-thumb-hover: rgba(240, 240, 240, 0.5); --color-compose-input-bg: #101010; --color-attachments-bg: #181818; --color-attachments-border: #3a3a3a; @@ -1204,6 +1228,9 @@ --color-settings-select-bg: #0a120e; --color-settings-select-border: #1f3a2b; --color-settings-select-text: #c6f3d0; + --color-settings-scrollbar-track: rgba(198, 243, 208, 0.1); + --color-settings-scrollbar-thumb: rgba(198, 243, 208, 0.3); + --color-settings-scrollbar-thumb-hover: rgba(198, 243, 208, 0.5); --color-compose-input-bg: #0a120e; --color-attachments-bg: #101c15; --color-attachments-border: #1f3a2b; diff --git a/src/ui/styles/themes/light.css b/src/ui/styles/themes/light.css index 6f1062e..127bc7b 100644 --- a/src/ui/styles/themes/light.css +++ b/src/ui/styles/themes/light.css @@ -105,6 +105,9 @@ --color-settings-select-bg: #fff7f2; --color-settings-select-border: #ead7cc; --color-settings-select-text: #5b1f1f; + --color-settings-scrollbar-track: rgba(91, 31, 31, 0.1); + --color-settings-scrollbar-thumb: rgba(91, 31, 31, 0.3); + --color-settings-scrollbar-thumb-hover: rgba(91, 31, 31, 0.5); --color-compose-input-bg: #fff7f2; --color-attachments-bg: #f3e4dc; --color-attachments-border: #ead7cc; @@ -220,6 +223,9 @@ --color-settings-select-bg: #f7fbff; --color-settings-select-border: #d6e5f2; --color-settings-select-text: #2b4f7d; + --color-settings-scrollbar-track: rgba(43, 79, 125, 0.1); + --color-settings-scrollbar-thumb: rgba(43, 79, 125, 0.3); + --color-settings-scrollbar-thumb-hover: rgba(43, 79, 125, 0.5); --color-compose-input-bg: #f7fbff; --color-attachments-bg: #e9f2fb; --color-attachments-border: #d6e5f2; @@ -335,6 +341,9 @@ --color-settings-select-bg: #ffffff; --color-settings-select-border: #1d1d1d; --color-settings-select-text: #111111; + --color-settings-scrollbar-track: rgba(0, 0, 0, 0.1); + --color-settings-scrollbar-thumb: rgba(0, 0, 0, 0.3); + --color-settings-scrollbar-thumb-hover: rgba(0, 0, 0, 0.5); --color-compose-input-bg: #ffffff; --color-attachments-bg: #f1f1f1; --color-attachments-border: #1d1d1d; @@ -454,6 +463,9 @@ --color-settings-select-bg: #f6fff6; --color-settings-select-border: #cfe6d5; --color-settings-select-text: #0f3b22; + --color-settings-scrollbar-track: rgba(15, 59, 34, 0.1); + --color-settings-scrollbar-thumb: rgba(15, 59, 34, 0.3); + --color-settings-scrollbar-thumb-hover: rgba(15, 59, 34, 0.5); --color-compose-input-bg: #f6fff6; --color-attachments-bg: #e9f5ec; --color-attachments-border: #cfe6d5; From 5cc2d737b752b7b76d2421c116900905dea89e05 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 20 Jan 2026 14:59:52 +0900 Subject: [PATCH 16/34] =?UTF-8?q?feat:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=91=EC=86=8D=20=EC=B0=A8=EB=8B=A8=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 8 +++++++ src/ui/styles/base.css | 4 ++++ src/ui/styles/layout.css | 45 +++++++++++++++++++++++++++++++++++ src/ui/styles/themes/dark.css | 8 +++++++ 4 files changed, 65 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 7f7bc89..0d1085a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1691,6 +1691,14 @@ export const App = () => {
+
+
+

모바일 환경에서는 사용이 불가능합니다 🙇‍♂️

+

+ 멀티 컬럼 인터페이스 특성상 모바일 지원이 제한됩니다. 데스크톱 또는 태블릿에서 이용해 주세요. +

+
+
+
+
+ {displayedTodos.length > 0 ? ( +
+ {displayedTodos.map((item) => ( +
+ handleToggleTodo(item.id)} + aria-label={`할 일 완료: ${item.text}`} + /> + {item.text} + +
+ ))} +
+ ) : null} +
{ + event.preventDefault(); + handleAddTodo(); + }} + > + setTodoInput(event.target.value)} + placeholder="할 일 추가" + aria-label="뽀모도로 투두 입력" + /> + +
+
); }; diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 840ea5a..2980b97 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -2786,6 +2786,116 @@ button.ghost { gap: 6px; } +.pomodoro-divider { + margin: 12px 0; +} + +.pomodoro-todos { + display: flex; + flex-direction: column; + gap: 10px; +} + +.pomodoro-todo-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.pomodoro-todo-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 6px 10px; + border-radius: 8px; + background: var(--color-panel-bg); + border: 1px solid var(--color-panel-border); + color: var(--color-text); + font-size: 13px; +} + +.pomodoro-todo-item.is-completed .pomodoro-todo-text { + color: var(--color-text-secondary); + text-decoration: line-through; +} + +.pomodoro-todo-item.is-completed { + opacity: 0.6; +} + +.pomodoro-todo-checkbox { + width: 14px; + height: 14px; + accent-color: var(--color-action-bg); + cursor: pointer; +} + +.pomodoro-todo-text { + flex: 1; + word-break: break-word; +} + +.pomodoro-todo-remove { + width: 24px; + height: 24px; + padding: 0; + border-radius: 6px; + border: none; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.pomodoro-todo-remove:hover { + color: var(--color-danger, #d64545); +} + +.pomodoro-todo-remove svg { + width: 14px; + height: 14px; + stroke: currentColor; + stroke-width: 2; + fill: none; +} + +.pomodoro-todo-input { + display: flex; + align-items: center; + gap: 8px; +} + +.pomodoro-todo-input input { + flex: 1; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--color-input-border); + background: var(--color-input-bg); + color: var(--color-text); + font-size: 13px; +} + +.pomodoro-todo-input input:focus { + outline: 2px solid var(--color-action-bg); + outline-offset: 1px; +} + +.pomodoro-todo-input button { + padding: 8px 12px; + border-radius: 8px; + border: none; + background: var(--color-action-bg); + color: var(--color-action-text); + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + + .pomodoro-button { padding: 6px 10px; border: none; From 5916bb21b59ba27efcdc642d5c1c7019621d6c25 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Thu, 22 Jan 2026 10:57:49 +0900 Subject: [PATCH 18/34] feat: add compose box shortcut hints --- src/ui/components/AccountSelector.tsx | 2 +- src/ui/components/ComposeBox.tsx | 134 +++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/src/ui/components/AccountSelector.tsx b/src/ui/components/AccountSelector.tsx index 47766f1..04bf820 100644 --- a/src/ui/components/AccountSelector.tsx +++ b/src/ui/components/AccountSelector.tsx @@ -37,7 +37,7 @@ export const AccountSelector = ({ open={dropdownOpen} onToggle={(event) => setDropdownOpen(event.currentTarget.open)} > - + {activeAccount ? ( (null); const textareaRef = useRef(null); const cwInputRef = useRef(null); + const composeRef = useRef(null); + const visibilitySelectRef = useRef(null); + const fileInputRef = useRef(null); + const emojiToggleRef = useRef(null); + const cwToggleRef = useRef(null); // useImageZoom 훅 사용 const { @@ -350,6 +355,116 @@ export const ComposeBox = ({ }); }; + useEffect(() => { + const isEditableElement = (element: Element | null) => { + if (!element) { + return false; + } + return ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + (element as HTMLElement).isContentEditable + ); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + const activeElement = document.activeElement; + const isTextField = isEditableElement(activeElement); + const isInsideCompose = + !!composeRef.current && !!activeElement && composeRef.current.contains(activeElement); + + if (key === "escape" && isTextField && isInsideCompose) { + event.preventDefault(); + (activeElement as HTMLElement).blur(); + return; + } + + if (key === "n" && !event.ctrlKey && !event.metaKey && !event.shiftKey && !isTextField) { + event.preventDefault(); + textareaRef.current?.focus(); + return; + } + + if (key === "n" && event.ctrlKey && event.shiftKey && !event.metaKey && isTextField) { + event.preventDefault(); + textareaRef.current?.focus(); + return; + } + + if (!event.ctrlKey || event.metaKey || !event.shiftKey) { + return; + } + + if (key === "w") { + if (cwToggleRef.current && !cwToggleRef.current.disabled) { + event.preventDefault(); + toggleCw(); + if (cwEnabled) { + requestAnimationFrame(() => { + textareaRef.current?.focus(); + }); + } + } + return; + } + + if (!composeRef.current) { + return; + } + + if (key === "a") { + const summary = composeRef.current.querySelector( + ".account-selector-summary" + ); + if (summary) { + const details = summary.closest("details"); + if (details?.hasAttribute("open")) { + event.preventDefault(); + summary.focus(); + return; + } + event.preventDefault(); + summary.click(); + summary.focus(); + } + return; + } + + if (key === "o") { + const select = visibilitySelectRef.current; + if (select && !select.disabled) { + event.preventDefault(); + select.focus(); + select.click(); + } + return; + } + + if (key === "i") { + if (fileInputRef.current && !fileInputRef.current.disabled) { + event.preventDefault(); + fileInputRef.current.click(); + } + return; + } + + if (key === "e") { + if (emojiToggleRef.current && !emojiToggleRef.current.disabled) { + event.preventDefault(); + setEmojiPanelOpen((open) => !open); + emojiToggleRef.current.focus(); + } + return; + } + + + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [cwEnabled, setEmojiPanelOpen, toggleCw]); + const findEmojiQuery = useCallback( (value: string, cursor: number) => { if (cursor <= 0) { @@ -476,7 +591,7 @@ export const ComposeBox = ({ }, [attachments]); return ( -
+
{accountSelector ?
{accountSelector}
: null} {replyingTo ? (
@@ -510,6 +625,8 @@ export const ComposeBox = ({ updateEmojiQuery(nextValue, event.target.selectionStart ?? nextValue.length); }} placeholder="지금 무슨 생각을 하고 있나요?" + aria-label="글 작성" + title="글 작성 (N / Ctrl+Shift+N)" rows={4} onPaste={handlePaste} disabled={isSubmitting} @@ -606,13 +723,18 @@ export const ComposeBox = ({ ))} {/* 이미지 추가 버튼 */} -
-
-
- 색상 모드 -

시스템 설정을 따르거나 라이트/다크 모드를 고정합니다.

-
- -
-
-
- 프로필 이미지 표시 -

피드에서 사용자 프로필 이미지를 보여줍니다.

-
- -
-
-
- 커스텀 이모지 표시 -

사용자 이름과 본문에 커스텀 이모지를 표시합니다.

-
- -
-
-
- 리액션 표시 -

리액션 정보를 지원하는 서버에서 받은 리액션을 보여줍니다.

-
- -
-
-
- 섹션 폭 -

타임라인 섹션의 가로 폭을 조절합니다.

-
- -
-
-
- 뽀모도로 타이머 -

사이드바에 뽀모도로 타이머를 표시합니다.

-
- -
- {showPomodoro ? ( - <> -
-
- 뽀모도로 시간 설정 -

집중, 휴식, 긴 휴식 시간을 분 단위로 설정합니다.

-
-
- - - -
-
- - ) : null} -
-
- 로컬 저장소 초기화 -

계정과 설정을 포함한 모든 로컬 데이터를 삭제합니다.

-
- -
-
- - - ) : null} + setMobileComposeOpen(false)} + composeAccount={composeAccount} + composeAccountSelector={composeAccountSelector} + api={services.api} + onSubmit={handleSubmit} + replyingTo={replyTarget ? { id: replyTarget.id, summary: replySummary ?? "" } : null} + onCancelReply={() => { + setReplyTarget(null); + setMentionSeed(null); + }} + mentionText={mentionSeed} + /> + + setSettingsOpen(true)} + oauth={services.oauth} + /> + + setSettingsOpen(false)} + accountsState={accountsState} + settingsAccountId={settingsAccountId} + setSettingsAccountId={setSettingsAccountId} + reauthLoading={reauthLoading} + onReauth={handleSettingsReauth} + onRemove={handleSettingsRemove} + onClearLocalStorage={handleClearLocalStorage} + themeMode={themeMode} + onThemeChange={(value) => { + if (isThemeMode(value)) { + setThemeMode(value); + } + }} + colorScheme={colorScheme} + onColorSchemeChange={(value) => { + if (isColorScheme(value)) { + setColorScheme(value); + } + }} + showProfileImages={showProfileImages} + onToggleProfileImages={setShowProfileImages} + showCustomEmojis={showCustomEmojis} + onToggleCustomEmojis={setShowCustomEmojis} + showMisskeyReactions={showMisskeyReactions} + onToggleMisskeyReactions={setShowMisskeyReactions} + sectionSize={sectionSize} + onSectionSizeChange={setSectionSize} + showPomodoro={showPomodoro} + onTogglePomodoro={setShowPomodoro} + pomodoroFocus={pomodoroFocus} + pomodoroBreak={pomodoroBreak} + pomodoroLongBreak={pomodoroLongBreak} + onPomodoroFocusChange={setPomodoroFocus} + onPomodoroBreakChange={setPomodoroBreak} + onPomodoroLongBreakChange={setPomodoroLongBreak} + /> {profileTargets.map((target, index) => ( { + switch (type) { + case "terms": + return "이용약관"; + case "license": + return "라이선스"; + case "oss": + return "오픈소스 목록"; + case "shortcuts": + return "단축키"; + default: + return ""; + } +}; + +const InfoModalContent = ({ type }: { type: InfoModalType }) => { + switch (type) { + case "terms": + return ; + case "license": + return ; + case "oss": + return ; + case "shortcuts": + return ; + default: + return null; + } +}; + +export const InfoModal = ({ type, onClose }: { type: InfoModalType; onClose: () => void }) => { + const title = getInfoModalTitle(type); + return ( +
+
+
+
+

{title}

+ +
+
+ +
+
+
+ ); +}; diff --git a/src/ui/components/MobileMenus.tsx b/src/ui/components/MobileMenus.tsx new file mode 100644 index 0000000..81b3992 --- /dev/null +++ b/src/ui/components/MobileMenus.tsx @@ -0,0 +1,120 @@ +import type { ReactNode } from "react"; +import type { Account, Visibility } from "../../domain/types"; +import type { MastodonApi } from "../../services/MastodonApi"; +import type { OAuthClient } from "../../services/OAuthClient"; +import { AccountAdd } from "./AccountAdd"; +import { ComposeBox } from "./ComposeBox"; + +type MobileComposeMenuProps = { + open: boolean; + onClose: () => void; + composeAccount: Account | null; + composeAccountSelector: ReactNode; + api: MastodonApi; + onSubmit: (params: { + text: string; + visibility: Visibility; + inReplyToId?: string; + files: File[]; + spoilerText: string; + }) => Promise; + replyingTo: { id: string; summary: string } | null; + onCancelReply: () => void; + mentionText: string | null; +}; + +export const MobileComposeMenu = ({ + open, + onClose, + composeAccount, + composeAccountSelector, + api, + onSubmit, + replyingTo, + onCancelReply, + mentionText +}: MobileComposeMenuProps) => { + if (!open) { + return null; + } + + return ( +
+
+
+
+

글쓰기

+ +
+ {composeAccount ? ( + + ) : null} +
+
+ ); +}; + +type MobileMenuProps = { + open: boolean; + onClose: () => void; + onOpenSettings: () => void; + oauth: OAuthClient; +}; + +export const MobileMenu = ({ open, onClose, onOpenSettings, oauth }: MobileMenuProps) => { + if (!open) { + return null; + } + + return ( +
+
+
+
+

메뉴

+ +
+
+ +
+
+ +
+
+
+ ); +}; diff --git a/src/ui/components/SettingsModal.tsx b/src/ui/components/SettingsModal.tsx new file mode 100644 index 0000000..37f6395 --- /dev/null +++ b/src/ui/components/SettingsModal.tsx @@ -0,0 +1,288 @@ +import type { AccountsState } from "../state/AppContext"; +import type { ColorScheme, ThemeMode } from "../utils/theme"; +import { AccountSelector } from "./AccountSelector"; + +type SettingsModalProps = { + open: boolean; + onClose: () => void; + accountsState: AccountsState; + settingsAccountId: string | null; + setSettingsAccountId: (id: string | null) => void; + reauthLoading: boolean; + onReauth: () => void; + onRemove: () => void; + onClearLocalStorage: () => void; + themeMode: ThemeMode; + onThemeChange: (value: string) => void; + colorScheme: ColorScheme; + onColorSchemeChange: (value: string) => void; + showProfileImages: boolean; + onToggleProfileImages: (value: boolean) => void; + showCustomEmojis: boolean; + onToggleCustomEmojis: (value: boolean) => void; + showMisskeyReactions: boolean; + onToggleMisskeyReactions: (value: boolean) => void; + sectionSize: "small" | "medium" | "large"; + onSectionSizeChange: (value: "small" | "medium" | "large") => void; + showPomodoro: boolean; + onTogglePomodoro: (value: boolean) => void; + pomodoroFocus: number; + pomodoroBreak: number; + pomodoroLongBreak: number; + onPomodoroFocusChange: (value: number) => void; + onPomodoroBreakChange: (value: number) => void; + onPomodoroLongBreakChange: (value: number) => void; +}; + +export const SettingsModal = ({ + open, + onClose, + accountsState, + settingsAccountId, + setSettingsAccountId, + reauthLoading, + onReauth, + onRemove, + onClearLocalStorage, + themeMode, + onThemeChange, + colorScheme, + onColorSchemeChange, + showProfileImages, + onToggleProfileImages, + showCustomEmojis, + onToggleCustomEmojis, + showMisskeyReactions, + onToggleMisskeyReactions, + sectionSize, + onSectionSizeChange, + showPomodoro, + onTogglePomodoro, + pomodoroFocus, + pomodoroBreak, + pomodoroLongBreak, + onPomodoroFocusChange, + onPomodoroBreakChange, + onPomodoroLongBreakChange +}: SettingsModalProps) => { + if (!open) { + return null; + } + + return ( +
+
+
+
+

설정

+ +
+
+
+
+ 계정 관리 +

계정을 선택하여 재인증하거나 삭제합니다.

+
+
+ +
+ + +
+
+
+
+
+ 테마 +

기본, 크리스마스, 하늘핑크, 모노톤 테마를 선택합니다.

+
+ +
+
+
+ 색상 모드 +

시스템 설정을 따르거나 라이트/다크 모드를 고정합니다.

+
+ +
+
+
+ 프로필 이미지 표시 +

피드에서 사용자 프로필 이미지를 보여줍니다.

+
+ +
+
+
+ 커스텀 이모지 표시 +

사용자 이름과 본문에 커스텀 이모지를 표시합니다.

+
+ +
+
+
+ 리액션 표시 +

리액션 정보를 지원하는 서버에서 받은 리액션을 보여줍니다.

+
+ +
+
+
+ 섹션 폭 +

타임라인 섹션의 가로 폭을 조절합니다.

+
+ +
+
+
+ 뽀모도로 타이머 +

사이드바에 뽀모도로 타이머를 표시합니다.

+
+ +
+ {showPomodoro ? ( + <> +
+
+ 뽀모도로 시간 설정 +

집중, 휴식, 긴 휴식 시간을 분 단위로 설정합니다.

+
+
+ + + +
+
+ + ) : null} +
+
+ 로컬 저장소 초기화 +

계정과 설정을 포함한 모든 로컬 데이터를 삭제합니다.

+
+ +
+
+
+
+ ); +}; diff --git a/src/ui/components/TimelineSection.tsx b/src/ui/components/TimelineSection.tsx new file mode 100644 index 0000000..417fada --- /dev/null +++ b/src/ui/components/TimelineSection.tsx @@ -0,0 +1,1063 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { Account, ReactionInput, Status, TimelineType } from "../../domain/types"; +import type { AccountsState, AppServices } from "../state/AppContext"; +import { useTimeline } from "../hooks/useTimeline"; +import { useClickOutside } from "../hooks/useClickOutside"; +import { useToast } from "../state/ToastContext"; +import { AccountSelector } from "./AccountSelector"; +import { TimelineItem } from "./TimelineItem"; +import { formatHandle, normalizeInstanceUrl } from "../utils/account"; +import { getTimelineLabel, getTimelineOptions } from "../utils/timeline"; + +export type TimelineSectionConfig = { id: string; accountId: string | null; timelineType: TimelineType }; + +type TimelineSectionProps = { + section: TimelineSectionConfig; + account: Account | null; + services: AppServices; + accountsState: AccountsState; + onAccountChange: (sectionId: string, accountId: string | null) => void; + onTimelineChange: (sectionId: string, timelineType: TimelineType) => void; + onAddSectionLeft: (sectionId: string) => void; + onAddSectionRight: (sectionId: string) => void; + onRemoveSection: (sectionId: string) => void; + onReply: (status: Status, account: Account | null) => void; + onStatusClick: (status: Status, columnAccount: Account | null) => void; + onReact: (account: Account | null, status: Status, reaction: ReactionInput) => void; + onProfileClick: (status: Status, account: Account | null) => void; + onError: (message: string | null) => void; + onMoveSection: (sectionId: string, direction: "left" | "right") => void; + onScrollToSection: (sectionId: string) => void; + onCloseStatusModal: () => void; + onTimelineItemsChange: (sectionId: string, items: Status[]) => void; + onSelectStatus: (sectionId: string, statusId: string) => void; + canMoveLeft: boolean; + canMoveRight: boolean; + canRemoveSection: boolean; + timelineType: TimelineType; + showProfileImage: boolean; + showCustomEmojis: boolean; + showReactions: boolean; + registerTimelineListener: (accountId: string, listener: (status: Status) => void) => void; + unregisterTimelineListener: (accountId: string, listener: (status: Status) => void) => void; + registerTimelineShortcutHandler: (sectionId: string, handler: ((event: KeyboardEvent) => boolean) | null) => void; + columnRef?: React.Ref; + selectedStatusId: string | null; +}; + +const TimelineIcon = ({ timeline }: { timeline: TimelineType | string }) => { + switch (timeline) { + case "divider-before-bookmarks": + return null; + case "home": + return ( + + ); + case "local": + return ( + + ); + case "federated": + return ( + + ); + case "social": + return ( + + ); + case "global": + return ( + + ); + case "notifications": + return ( + + ); + case "bookmarks": + return ( + + ); + default: + return null; + } +}; + +export const TimelineSection = ({ + section, + account, + services, + accountsState, + onAccountChange, + onTimelineChange, + onAddSectionLeft, + onAddSectionRight, + onRemoveSection, + onReply, + onStatusClick, + onCloseStatusModal, + onTimelineItemsChange, + onSelectStatus, + onReact, + onProfileClick, + onError, + onMoveSection, + onScrollToSection, + canMoveLeft, + canMoveRight, + canRemoveSection, + timelineType, + showProfileImage, + showCustomEmojis, + showReactions, + registerTimelineListener, + unregisterTimelineListener, + registerTimelineShortcutHandler, + columnRef, + selectedStatusId +}: TimelineSectionProps) => { + const notificationsTimeline = useTimeline({ + account, + api: services.api, + streaming: services.streaming, + timelineType: "notifications", + enableStreaming: false + }); + const { + items: notificationItems, + loading: notificationsLoading, + loadingMore: notificationsLoadingMore, + error: notificationsError, + refresh: refreshNotifications, + loadMore: loadMoreNotifications + } = notificationsTimeline; + const menuRef = useRef(null); + const timelineMenuRef = useRef(null); + const notificationMenuRef = useRef(null); + const scrollRef = useRef(null); + const notificationScrollRef = useRef(null); + const accountSummaryRef = useRef(null); + const lastNotificationToastRef = useRef(0); + const [menuOpen, setMenuOpen] = useState(false); + const [timelineMenuOpen, setTimelineMenuOpen] = useState(false); + const [notificationsOpen, setNotificationsOpen] = useState(false); + const [notificationCount, setNotificationCount] = useState(0); + const [isAtTop, setIsAtTop] = useState(true); + const [highlightedTimelineIndex, setHighlightedTimelineIndex] = useState(null); + const [highlightedSectionMenuIndex, setHighlightedSectionMenuIndex] = useState(null); + const [highlightedNotificationIndex, setHighlightedNotificationIndex] = useState(null); + const { showToast } = useToast(); + const timelineOptions = useMemo(() => getTimelineOptions(account?.platform, false), [account?.platform]); + const actionableTimelineOptions = useMemo( + () => timelineOptions.filter((option) => !option.isDivider), + [timelineOptions] + ); + const timelineButtonLabel = `타임라인 선택: ${getTimelineLabel(timelineType)}`; + const hasNotificationBadge = notificationCount > 0; + const instanceOriginUrl = useMemo(() => { + if (!account) { + return null; + } + try { + return normalizeInstanceUrl(account.instanceUrl); + } catch { + return null; + } + }, [account]); + const notificationBadgeLabel = notificationsOpen + ? "알림 닫기" + : hasNotificationBadge + ? `알림 열기 (새 알림 ${notificationCount >= 99 ? "99개 이상" : `${notificationCount}개`})` + : "알림 열기"; + const notificationBadgeText = notificationCount >= 99 ? "99+" : String(notificationCount); + const handleNotification = useCallback(() => { + if (notificationsOpen) { + refreshNotifications(); + return; + } + setNotificationCount((count) => Math.min(count + 1, 99)); + if (timelineType === "notifications") { + return; + } + const now = Date.now(); + if (now - lastNotificationToastRef.current < 5000) { + return; + } + lastNotificationToastRef.current = now; + showToast("새 알림이 도착했습니다.", { + tone: "info", + actionLabel: "알림 받은 컬럼으로 이동", + actionAriaLabel: "알림이 도착한 컬럼으로 이동", + onAction: () => onScrollToSection(section.id) + }); + }, [notificationsOpen, refreshNotifications, timelineType, showToast, onScrollToSection, section.id]); + const timeline = useTimeline({ + account, + api: services.api, + streaming: services.streaming, + timelineType, + onNotification: handleNotification + }); + const actionsDisabled = timelineType === "notifications" || timelineType === "bookmarks"; + const emptyMessage = timelineType === "notifications" + ? "표시할 알림이 없습니다." + : timelineType === "bookmarks" + ? "북마크한 글이 없습니다." + : "표시할 글이 없습니다."; + + useEffect(() => { + onTimelineItemsChange(section.id, timeline.items); + }, [onTimelineItemsChange, section.id, timeline.items]); + + useEffect(() => { + if (!timeline.error) { + return; + } + showToast(timeline.error, { tone: "error" }); + }, [showToast, timeline.error]); + + useEffect(() => { + const el = scrollRef.current; + if (!el) { + return; + } + const onScroll = () => { + const threshold = el.scrollHeight - el.clientHeight - 200; + if (el.scrollTop >= threshold) { + timeline.loadMore(); + } + setIsAtTop(el.scrollTop <= 0); + }; + onScroll(); + el.addEventListener("scroll", onScroll, { passive: true }); + return () => { + el.removeEventListener("scroll", onScroll); + }; + }, [timeline.loadMore]); + + useEffect(() => { + if (!account || timelineType === "notifications") { + return; + } + registerTimelineListener(account.id, timeline.updateItem); + return () => { + unregisterTimelineListener(account.id, timeline.updateItem); + }; + }, [account, registerTimelineListener, timeline.updateItem, timelineType, unregisterTimelineListener]); + + useClickOutside(menuRef, menuOpen, () => setMenuOpen(false)); + + useClickOutside(timelineMenuRef, timelineMenuOpen, () => setTimelineMenuOpen(false)); + + useClickOutside(notificationMenuRef, notificationsOpen, () => setNotificationsOpen(false)); + + useEffect(() => { + if (!timelineMenuOpen) { + setHighlightedTimelineIndex(null); + return; + } + const selectedIndex = actionableTimelineOptions.findIndex((option) => option.id === timelineType); + const nextIndex = selectedIndex >= 0 ? selectedIndex : 0; + setHighlightedTimelineIndex(nextIndex); + }, [actionableTimelineOptions, timelineMenuOpen, timelineType]); + + useEffect(() => { + if (!menuOpen) { + setHighlightedSectionMenuIndex(null); + return; + } + setHighlightedSectionMenuIndex(0); + }, [menuOpen]); + + useEffect(() => { + if (!notificationsOpen) { + setHighlightedNotificationIndex(null); + return; + } + if (highlightedNotificationIndex !== null) { + return; + } + const hasNotifications = notificationItems.length > 0; + setHighlightedNotificationIndex(hasNotifications ? 0 : null); + }, [highlightedNotificationIndex, notificationItems.length, notificationsOpen]); + + useEffect(() => { + if (!notificationsOpen) { + return; + } + if (highlightedNotificationIndex === null) { + return; + } + const container = notificationScrollRef.current; + if (!container) { + return; + } + const items = container.querySelectorAll(".status"); + const target = items[highlightedNotificationIndex]; + if (!target) { + return; + } + target.scrollIntoView({ block: "nearest" }); + }, [highlightedNotificationIndex, notificationsOpen]); + + useEffect(() => { + if (!notificationsOpen) { + return; + } + const el = notificationScrollRef.current; + if (!el) { + return; + } + const onScroll = () => { + const threshold = el.scrollHeight - el.clientHeight - 120; + if (el.scrollTop >= threshold) { + loadMoreNotifications(); + } + }; + onScroll(); + el.addEventListener("scroll", onScroll, { passive: true }); + return () => { + el.removeEventListener("scroll", onScroll); + }; + }, [notificationsOpen, loadMoreNotifications]); + + useEffect(() => { + if (!account) { + setNotificationsOpen(false); + setTimelineMenuOpen(false); + } + setNotificationCount(0); + }, [account?.id]); + + useEffect(() => { + if (!notificationsOpen) { + return; + } + setNotificationCount(0); + refreshNotifications(); + }, [notificationsOpen, refreshNotifications]); + + useEffect(() => { + if (!notificationsError) { + return; + } + showToast(notificationsError, { tone: "error" }); + }, [notificationsError, showToast]); + + const handleToggleFavourite = async (status: Status) => { + if (!account) { + onError("계정을 선택해주세요."); + return; + } + onError(null); + try { + const updated = status.favourited + ? await services.api.unfavourite(account, status.id) + : await services.api.favourite(account, status.id); + timeline.updateItem(updated); + } catch (err) { + onError(err instanceof Error ? err.message : "좋아요 처리에 실패했습니다."); + } + }; + + const handleToggleReblog = async (status: Status) => { + if (!account) { + onError("계정을 선택해주세요."); + return; + } + onError(null); + const delta = status.reblogged ? -1 : 1; + const optimistic = { + ...status, + reblogged: !status.reblogged, + reblogsCount: Math.max(0, status.reblogsCount + delta) + }; + timeline.updateItem(optimistic); + try { + const updated = status.reblogged + ? await services.api.unreblog(account, status.id) + : await services.api.reblog(account, status.id); + timeline.updateItem(updated); + } catch (err) { + onError(err instanceof Error ? err.message : "부스트 처리에 실패했습니다."); + timeline.updateItem(status); + } + }; + + const handleToggleBookmark = async (status: Status) => { + if (!account) { + onError("계정을 선택해주세요."); + return; + } + onError(null); + const isBookmarking = !status.bookmarked; + const optimistic = { + ...status, + bookmarked: isBookmarking + }; + timeline.updateItem(optimistic); + try { + const updated = status.bookmarked + ? await services.api.unbookmark(account, status.id) + : await services.api.bookmark(account, status.id); + timeline.updateItem(updated); + if (isBookmarking) { + showToast("북마크했습니다.", { tone: "success" }); + } else { + showToast("북마크를 취소했습니다.", { tone: "success" }); + } + } catch (err) { + onError(err instanceof Error ? err.message : "북마크 처리에 실패했습니다."); + timeline.updateItem(status); + } + }; + + const handleReact = useCallback( + (status: Status, reaction: ReactionInput) => { + onReact(account, status, reaction); + }, + [account, onReact] + ); + + const handleDeleteStatus = async (status: Status) => { + if (!account) { + return; + } + onError(null); + try { + await services.api.deleteStatus(account, status.id); + timeline.removeItem(status.id); + onCloseStatusModal(); + } catch (err) { + onError(err instanceof Error ? err.message : "게시글 삭제에 실패했습니다."); + } + }; + + const scrollToTop = () => { + if (scrollRef.current) { + scrollRef.current.scrollTo({ top: 0, behavior: "smooth" }); + } + }; + + const handleOpenInstanceOrigin = useCallback(() => { + if (!instanceOriginUrl) { + return; + } + window.open(instanceOriginUrl, "_blank", "noopener,noreferrer"); + setMenuOpen(false); + }, [instanceOriginUrl]); + + const handleTimelineShortcuts = useCallback( + (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + const hasModifier = event.ctrlKey || event.metaKey || event.shiftKey || event.altKey; + if (key === "escape") { + if (timelineMenuOpen) { + event.preventDefault(); + setTimelineMenuOpen(false); + return true; + } + if (menuOpen) { + event.preventDefault(); + setMenuOpen(false); + return true; + } + if (notificationsOpen) { + event.preventDefault(); + setNotificationsOpen(false); + return true; + } + return false; + } + if (hasModifier) { + return false; + } + if (timelineMenuOpen && (key === "arrowup" || key === "arrowdown" || key === "enter")) { + if (!actionableTimelineOptions.length) { + return true; + } + if (key === "enter") { + const option = actionableTimelineOptions[ + highlightedTimelineIndex ?? 0 + ]; + if (option) { + onTimelineChange(section.id, option.id as TimelineType); + setTimelineMenuOpen(false); + } + event.preventDefault(); + return true; + } + event.preventDefault(); + setHighlightedTimelineIndex((current) => { + const currentIndex = current ?? 0; + const offset = key === "arrowdown" ? 1 : -1; + const nextIndex = + (currentIndex + offset + actionableTimelineOptions.length) % actionableTimelineOptions.length; + return nextIndex; + }); + return true; + } + if (menuOpen && (key === "arrowup" || key === "arrowdown" || key === "enter")) { + const menuButtons = menuRef.current?.querySelectorAll("button"); + if (!menuButtons || menuButtons.length === 0) { + return true; + } + if (key === "enter") { + const index = highlightedSectionMenuIndex ?? 0; + const targetButton = menuButtons[index]; + if (targetButton) { + targetButton.click(); + setMenuOpen(false); + } + event.preventDefault(); + return true; + } + event.preventDefault(); + setHighlightedSectionMenuIndex((current) => { + const currentIndex = current ?? 0; + const offset = key === "arrowdown" ? 1 : -1; + const nextIndex = (currentIndex + offset + menuButtons.length) % menuButtons.length; + return nextIndex; + }); + return true; + } + if (notificationsOpen && (key === "arrowup" || key === "arrowdown")) { + if (notificationItems.length === 0) { + return true; + } + event.preventDefault(); + setHighlightedNotificationIndex((current) => { + const currentIndex = current ?? 0; + if (key === "arrowup") { + return Math.max(0, currentIndex - 1); + } + return Math.min(notificationItems.length - 1, currentIndex + 1); + }); + return true; + } + if (notificationsOpen && key === "enter") { + if (highlightedNotificationIndex === null) { + return true; + } + const status = notificationItems[highlightedNotificationIndex]; + if (status) { + event.preventDefault(); + onStatusClick(status, account); + return true; + } + return true; + } + if (!selectedStatusId) { + return false; + } + const selectedStatus = timeline.items.find((item) => item.id === selectedStatusId); + if (!selectedStatus) { + return false; + } + const selectedStatusElement = scrollRef.current?.querySelector( + `[data-status-id="${selectedStatus.id}"]` + ); + const clickStatusAction = (action: string) => { + const button = selectedStatusElement?.querySelector( + `[data-action="${action}"]` + ); + if (!button || button.disabled) { + return false; + } + button.click(); + button.focus(); + return true; + }; + if (key === "r") { + if (actionsDisabled) { + return false; + } + const handled = clickStatusAction("reply"); + if (!handled) { + return false; + } + event.preventDefault(); + return true; + } + if (key === "b") { + if (actionsDisabled) { + return false; + } + const handled = clickStatusAction("reblog"); + if (!handled) { + return false; + } + event.preventDefault(); + return true; + } + if (key === "l") { + if (actionsDisabled) { + return false; + } + if (account?.platform === "mastodon") { + const handled = clickStatusAction("favourite"); + if (!handled) { + return false; + } + event.preventDefault(); + return true; + } + if (account?.platform === "misskey" && showReactions) { + event.preventDefault(); + onReact(account, selectedStatus, { + name: "❤️", + url: null, + isCustom: false, + host: null + }); + return true; + } + } + if (key === "c") { + if (account?.platform !== "misskey" || !showReactions) { + return false; + } + const handled = clickStatusAction("reaction-picker"); + if (!handled) { + return false; + } + event.preventDefault(); + return true; + } + if (key === "i") { + const handled = clickStatusAction("open-image"); + if (!handled) { + return false; + } + event.preventDefault(); + return true; + } + if (key === "enter") { + event.preventDefault(); + onStatusClick(selectedStatus, account); + return true; + } + if (key === "p") { + event.preventDefault(); + onProfileClick(selectedStatus, account); + return true; + } + if (key === "a") { + const summary = accountSummaryRef.current; + if (!summary) { + return false; + } + const details = summary.closest("details"); + if (details?.hasAttribute("open")) { + event.preventDefault(); + summary.focus(); + return true; + } + event.preventDefault(); + summary.click(); + summary.focus(); + return true; + } + if (key === "t") { + if (!account) { + onError("계정을 선택해주세요."); + return true; + } + event.preventDefault(); + setTimelineMenuOpen(true); + setMenuOpen(false); + setNotificationsOpen(false); + return true; + } + if (key === "g") { + if (!account) { + onError("계정을 선택해주세요."); + return true; + } + event.preventDefault(); + setNotificationsOpen((current) => !current); + setTimelineMenuOpen(false); + setMenuOpen(false); + return true; + } + if (key === "m") { + event.preventDefault(); + setMenuOpen(true); + setTimelineMenuOpen(false); + setNotificationsOpen(false); + return true; + } + return false; + }, + [ + account, + actionableTimelineOptions, + actionsDisabled, + highlightedNotificationIndex, + highlightedSectionMenuIndex, + highlightedTimelineIndex, + menuOpen, + notificationItems, + notificationItems.length, + notificationsOpen, + onError, + onProfileClick, + onReact, + onStatusClick, + onTimelineChange, + section.id, + selectedStatusId, + showReactions, + timeline.items, + timelineMenuOpen + ] + ); + + useEffect(() => { + registerTimelineShortcutHandler(section.id, handleTimelineShortcuts); + return () => registerTimelineShortcutHandler(section.id, null); + }, [handleTimelineShortcuts, registerTimelineShortcutHandler, section.id]); + + return ( +
+
+ { + onAccountChange(section.id, id); + accountsState.setActiveAccount(id); + }} + summaryRef={accountSummaryRef} + summaryTitle="계정 선택 (A)" + variant="inline" + /> +
+
+ + {timelineMenuOpen ? ( + <> + + ); +}; diff --git a/src/ui/content/shortcuts.ts b/src/ui/content/shortcuts.ts new file mode 100644 index 0000000..c1608b4 --- /dev/null +++ b/src/ui/content/shortcuts.ts @@ -0,0 +1,65 @@ +export const shortcutSections: Array<{ + title: string; + note?: string; + items: Array<{ keys: string; description: string }>; +}> = [ + { + title: "타임라인 이동", + items: [ + { keys: "M", description: "선택이 없을 때 왼쪽 첫 글을 선택" }, + { keys: "↑ / ↓", description: "선택된 글 위아래 이동" }, + { keys: "← / →", description: "이웃 컬럼으로 이동" }, + { keys: "ESC", description: "선택 해제" } + ] + }, + { + title: "선택된 글 컨트롤", + note: "글을 선택한 상태에서만 동작합니다.", + items: [ + { keys: "R", description: "답글 작성" }, + { keys: "B", description: "부스트" }, + { keys: "L", description: "좋아요 (마스토돈) / ❤️ 리액션 (미스키)" }, + { keys: "C", description: "리액션 팔레트 열기 (미스키)" }, + { keys: "I", description: "첨부 이미지 열기" }, + { keys: "Enter", description: "글 팝업 열기 (열린 메뉴에서는 항목 선택)" }, + { keys: "P", description: "작성자 프로필 팝업 열기" }, + { keys: "A", description: "계정 선택 열기" }, + { keys: "T", description: "타임라인 메뉴 열기" }, + { keys: "M", description: "컬럼 메뉴 열기" }, + { keys: "G", description: "알림 열기" }, + { keys: "↑ / ↓", description: "열린 메뉴에서 항목 이동" }, + { keys: "ESC", description: "열린 메뉴 닫기" } + ] + }, + { + title: "글쓰기", + note: "글쓰기 영역 기준으로 동작합니다.", + items: [ + { keys: "N", description: "글쓰기 입력으로 이동" }, + { keys: "Ctrl+Shift+N", description: "글쓰기 입력으로 이동 (포커스 중)" }, + { keys: "Ctrl+Shift+W", description: "내용 경고 토글" }, + { keys: "Ctrl+Shift+A", description: "계정 선택 열기" }, + { keys: "Ctrl+Shift+O", description: "공개 범위 선택" }, + { keys: "Ctrl+Shift+I", description: "미디어 첨부" }, + { keys: "Ctrl+Shift+E", description: "이모지 패널 토글" }, + { keys: "Ctrl/Command+Enter", description: "글 올리기" }, + { keys: "ESC", description: "글쓰기 입력 포커스 해제" } + ] + }, + { + title: "이모지 추천", + note: "추천 목록이 열려 있을 때만 동작합니다.", + items: [ + { keys: "↑ / ↓", description: "추천 항목 이동" }, + { keys: "Enter", description: "추천 이모지 입력" }, + { keys: "ESC", description: "추천 닫기" } + ] + }, + { + title: "이미지 뷰어", + items: [ + { keys: "← / →", description: "이미지 이동" }, + { keys: "ESC", description: "이미지 보기 닫기" } + ] + } +]; diff --git a/src/ui/pages/InfoPages.tsx b/src/ui/pages/InfoPages.tsx new file mode 100644 index 0000000..b2e5b37 --- /dev/null +++ b/src/ui/pages/InfoPages.tsx @@ -0,0 +1,78 @@ +import { sanitizeHtml } from "../utils/htmlSanitizer"; +import { renderMarkdown } from "../utils/markdown"; +import { shortcutSections } from "../content/shortcuts"; +import licenseText from "../../../LICENSE?raw"; +import ossMarkdown from "../content/oss.md?raw"; +import termsMarkdown from "../content/terms.md?raw"; + +const termsHtml = sanitizeHtml(renderMarkdown(termsMarkdown)); +const ossHtml = sanitizeHtml(renderMarkdown(ossMarkdown)); + +export const PageHeader = ({ title }: { title: string }) => ( + +); + +export const TermsContent = () => ( +
+); + +export const LicenseContent = () =>
{licenseText}
; + +export const OssContent = () => ( +
+); + +export const ShortcutsContent = () => ( +
+ {shortcutSections.map((section) => ( +
+

{section.title}

+ {section.note ?

{section.note}

: null} +
    + {section.items.map((item) => ( +
  • + {item.keys} + {item.description} +
  • + ))} +
+
+ ))} +
+); + +export const TermsPage = () => ( +
+ + +
+); + +export const LicensePage = () => ( +
+ + +
+); + +export const OssPage = () => ( +
+ + +
+); + +export const ShortcutsPage = () => ( +
+ + +
+); diff --git a/src/ui/types/info.ts b/src/ui/types/info.ts new file mode 100644 index 0000000..9977c5d --- /dev/null +++ b/src/ui/types/info.ts @@ -0,0 +1 @@ +export type InfoModalType = "terms" | "license" | "oss" | "shortcuts"; diff --git a/src/ui/utils/reactions.ts b/src/ui/utils/reactions.ts new file mode 100644 index 0000000..3008c19 --- /dev/null +++ b/src/ui/utils/reactions.ts @@ -0,0 +1,80 @@ +import type { Reaction, ReactionInput, Status } from "../../domain/types"; + +export const sortReactions = (reactions: Reaction[]) => + [...reactions].sort((a, b) => { + if (a.count === b.count) { + return a.name.localeCompare(b.name); + } + return b.count - a.count; + }); + +export const buildReactionSignature = (reactions: Reaction[]) => + sortReactions(reactions).map((reaction) => + [reaction.name, reaction.count, reaction.url ?? "", reaction.isCustom ? "1" : "0", reaction.host ?? ""].join("|") + ); + +export const hasSameReactions = (left: Status, right: Status) => { + if (left.myReaction !== right.myReaction) { + return false; + } + const leftSig = buildReactionSignature(left.reactions); + const rightSig = buildReactionSignature(right.reactions); + if (leftSig.length !== rightSig.length) { + return false; + } + return leftSig.every((value, index) => value === rightSig[index]); +}; + +export const adjustReactionCount = ( + reactions: Reaction[], + name: string, + delta: number, + fallback?: ReactionInput +) => { + let updated = false; + const next = reactions + .map((reaction) => { + if (reaction.name !== name) { + return reaction; + } + updated = true; + const count = reaction.count + delta; + if (count <= 0) { + return null; + } + return { ...reaction, count }; + }) + .filter((reaction): reaction is Reaction => reaction !== null); + + if (!updated && delta > 0 && fallback) { + next.push({ ...fallback, count: delta }); + } + + return next; +}; + +export const buildOptimisticReactionStatus = ( + status: Status, + reaction: ReactionInput, + remove: boolean +): Status => { + let nextReactions = status.reactions; + if (remove) { + nextReactions = adjustReactionCount(nextReactions, reaction.name, -1); + } else { + if (status.myReaction && status.myReaction !== reaction.name) { + nextReactions = adjustReactionCount(nextReactions, status.myReaction, -1); + } + nextReactions = adjustReactionCount(nextReactions, reaction.name, 1, reaction); + } + const sorted = sortReactions(nextReactions); + const favouritesCount = sorted.reduce((sum, item) => sum + item.count, 0); + const myReaction = remove ? null : reaction.name; + return { + ...status, + reactions: sorted, + myReaction, + favouritesCount, + favourited: Boolean(myReaction) + }; +}; diff --git a/src/ui/utils/theme.ts b/src/ui/utils/theme.ts new file mode 100644 index 0000000..6973828 --- /dev/null +++ b/src/ui/utils/theme.ts @@ -0,0 +1,29 @@ +export type ThemeMode = "default" | "christmas" | "sky-pink" | "monochrome" | "matcha-core"; + +export const isThemeMode = (value: string): value is ThemeMode => + value === "default" || + value === "christmas" || + value === "sky-pink" || + value === "monochrome" || + value === "matcha-core"; + +export const getStoredTheme = (): ThemeMode => { + const storedTheme = localStorage.getItem("textodon.theme"); + if (storedTheme && isThemeMode(storedTheme)) { + return storedTheme; + } + return localStorage.getItem("textodon.christmas") === "on" ? "christmas" : "default"; +}; + +export type ColorScheme = "system" | "light" | "dark"; + +export const isColorScheme = (value: string): value is ColorScheme => + value === "system" || value === "light" || value === "dark"; + +export const getStoredColorScheme = (): ColorScheme => { + const storedScheme = localStorage.getItem("textodon.colorScheme"); + if (storedScheme && isColorScheme(storedScheme)) { + return storedScheme; + } + return "system"; +};