diff --git a/AGENTS.md b/AGENTS.md index ed7145c..993c6b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# AGENTS.md +# AGENTS.md ## 기본 원칙 - SOLID 원칙을 준수한다. @@ -17,6 +17,7 @@ - 모든 소스 파일은 UTF-8 (BOM 없음)으로 저장한다. - 커밋 전 텍스트 깨짐 검사: `rg -n "�" src`, `rg -n "[\u00C0-\u00FF]" src` - UI 문자열에 한글 외의 CJK 문자가 섞여 있으면 원인을 확인하고 교체한다. +- 분석을 위하여 파일을 읽을 때는 CJK 문자가 깨지지 않도록 해야 한다. ## 작업 플로우 - 작업 시작 전: `develop` 최신화 → 새 feature 브랜치 생성. @@ -30,3 +31,5 @@ - PR 본문은 마크다운이 깨지지 않도록 멀티라인(heredoc) 방식으로 작성한다. - PR 제목/본문의 한글 인코딩이 깨지지 않도록 UTF-8로 작성·확인한다. - PR URL은 코드 블록 없이 클릭 가능한 일반 텍스트로 제공한다. +- PR 생성 시 feature/* 브랜치는 반드시 develop 브랜치를 베이스로 삼는다. +- PR 생성 시 release/* 브랜치는 반드시 main 브랜치를 베이스로 삼는다. diff --git a/CLAUDE.MD b/CLAUDE.MD index 7e27b1b..e57c56b 100644 --- a/CLAUDE.MD +++ b/CLAUDE.MD @@ -25,8 +25,11 @@ - PR 본문은 마크다운이 깨지지 않도록 멀티라인(heredoc) 방식으로 작성한다. - PR 제목/본문의 한글 인코딩이 깨지지 않도록 UTF-8로 작성·확인한다. - PR URL은 코드 블록 없이 클릭 가능한 일반 텍스트로 제공한다. +- PR 생성 시 feature/* 브랜치는 반드시 develop 브랜치를 베이스로 삼는다. +- PR 생성 시 release/* 브랜치는 반드시 main 브랜치를 베이스로 삼는다. ## 인코딩/텍스트 품질 - 모든 소스 파일은 UTF-8 (BOM 없음)으로 저장한다. - 커밋 전 텍스트 깨짐 검사: `rg -n "�" src`, `rg -n "[\u00C0-\u00FF]" src` -- UI 문자열에 한글 외의 CJK 문자가 섞여 있으면 원인을 확인하고 교체한다. \ No newline at end of file +- UI 문자열에 한글 외의 CJK 문자가 섞여 있으면 원인을 확인하고 교체한다. +- 분석을 위하여 파일을 읽을 때는 CJK 문자가 깨지지 않도록 해야 한다. \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index fa7b957..9f6ff0c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,11 +6,12 @@ import { ComposeBox } from "./ui/components/ComposeBox"; import { ProfileModal } from "./ui/components/ProfileModal"; import { StatusModal } from "./ui/components/StatusModal"; import { TimelineItem } from "./ui/components/TimelineItem"; +import { PomodoroTimer } from "./ui/components/PomodoroTimer"; import { useTimeline } from "./ui/hooks/useTimeline"; import { useClickOutside } from "./ui/hooks/useClickOutside"; import { useAppContext } from "./ui/state/AppContext"; import type { AccountsState, AppServices } from "./ui/state/AppContext"; -import { createAccountId, formatHandle, normalizeInstanceUrl } from "./ui/utils/account"; +import { createAccountId, formatHandle, formatReplyHandle, normalizeInstanceUrl } from "./ui/utils/account"; import { clearPendingOAuth, createOauthState, loadPendingOAuth, loadRegisteredApp, saveRegisteredApp, storePendingOAuth } from "./ui/utils/oauth"; import { getTimelineLabel, getTimelineOptions, normalizeTimelineType } from "./ui/utils/timeline"; import { sanitizeHtml } from "./ui/utils/htmlSanitizer"; @@ -132,8 +133,10 @@ const buildOptimisticReactionStatus = ( }; }; -const TimelineIcon = ({ timeline }: { timeline: TimelineType }) => { +const TimelineIcon = ({ timeline }: { timeline: TimelineType | string }) => { switch (timeline) { + case "divider-before-bookmarks": + return null; case "home": return ( ); + case "bookmarks": + return ( + + ); default: return null; } @@ -400,8 +409,12 @@ const TimelineSection = ({ timelineType, onNotification: handleNotification }); - const actionsDisabled = timelineType === "notifications"; - const emptyMessage = timelineType === "notifications" ? "표시할 알림이 없습니다." : "표시할 글이 없습니다."; + const actionsDisabled = timelineType === "notifications" || timelineType === "bookmarks"; + const emptyMessage = timelineType === "notifications" + ? "표시할 알림이 없습니다." + : timelineType === "bookmarks" + ? "북마크한 글이 없습니다." + : "표시할 글이 없습니다."; useEffect(() => { if (!timeline.error) { @@ -529,6 +542,34 @@ const TimelineSection = ({ } }; + 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("북마크했습니다."); + } else { + showToast("북마크를 취소했습니다."); + } + } catch (err) { + onError(err instanceof Error ? err.message : "북마크 처리에 실패했습니다."); + timeline.updateItem(status); + } + }; + const handleReact = useCallback( (status: Status, reaction: ReactionInput) => { onReact(account, status, reaction); @@ -609,7 +650,13 @@ const TimelineSection = ({ aria-label="타임라인 선택" > {timelineOptions.map((option) => { - const isSelected = timelineType === option.id; + if (option.isDivider) { + return ( +
+ ); + } + + const isSelected = !option.isDivider && timelineType === option.id; return ( ); @@ -678,8 +728,9 @@ const TimelineSection = ({ onReply={(item) => onReply(item, account)} onStatusClick={(status) => onStatusClick(status, account)} onToggleFavourite={handleToggleFavourite} - onToggleReblog={handleToggleReblog} - onDelete={handleDeleteStatus} + onToggleReblog={handleToggleReblog} + onToggleBookmark={handleToggleBookmark} + onDelete={handleDeleteStatus} onReact={handleReact} onProfileClick={(item) => onProfileClick(item, account)} activeHandle={ @@ -810,8 +861,9 @@ const TimelineSection = ({ onReply={(item) => onReply(item, account)} onStatusClick={(status) => onStatusClick(status, account)} onToggleFavourite={handleToggleFavourite} - onToggleReblog={handleToggleReblog} - onDelete={handleDeleteStatus} + onToggleReblog={handleToggleReblog} + onToggleBookmark={handleToggleBookmark} + onDelete={handleDeleteStatus} onReact={handleReact} onProfileClick={(item) => onProfileClick(item, account)} activeHandle={ @@ -897,6 +949,23 @@ export const App = () => { const [showMisskeyReactions, setShowMisskeyReactions] = useState(() => { return localStorage.getItem("textodon.reactions") !== "off"; }); + const [showPomodoro, setShowPomodoro] = useState(() => { + return localStorage.getItem("textodon.pomodoro") === "on"; + }); + const [pomodoroFocus, setPomodoroFocus] = useState(() => { + const stored = localStorage.getItem("textodon.pomodoro.focus"); + return stored ? Number(stored) : 25; + }); + const [pomodoroBreak, setPomodoroBreak] = useState(() => { + const stored = localStorage.getItem("textodon.pomodoro.break"); + return stored ? Number(stored) : 5; + }); + const [pomodoroLongBreak, setPomodoroLongBreak] = useState(() => { + const stored = localStorage.getItem("textodon.pomodoro.longBreak"); + return stored ? Number(stored) : 30; + }); + const [pomodoroSessionType, setPomodoroSessionType] = useState<"focus" | "break" | "longBreak">("focus"); + const [pomodoroIsRunning, setPomodoroIsRunning] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsAccountId, setSettingsAccountId] = useState