Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
422db55
Merge pull request #166 from deholic/feature/compose-submit-button-im…
deholic Jan 15, 2026
435b9b2
feat: 마스토돈 타임라인에 북마크 기능 추가
deholic Jan 15, 2026
ad16e70
Merge pull request #168 from deholic/feature/fix-visibility-save-issue
deholic Jan 15, 2026
c542b50
feat: 북마크 타임라인 구분선 추가 및 UI 개선
deholic Jan 15, 2026
37eea9b
Merge pull request #169 from deholic/feature/bookmark-favorites
deholic Jan 15, 2026
66a1656
Merge branch 'main' into develop
deholic Jan 15, 2026
684b9f2
docs: PR 생성 규칙 및 CJK 문자 처리 가이드라인 추가
deholic Jan 15, 2026
b10ebd8
Merge pull request #171 from deholic/feature/docs-enhancement
deholic Jan 15, 2026
29be98c
feat: 뽀모도로 타이머 기능 추가
deholic Jan 15, 2026
de4fee6
chore: 뽀모도로 타이머 기본값을 숨김으로 변경
deholic Jan 15, 2026
d3bcd75
Merge pull request #172 from deholic/feature/pomodoro-timer
deholic Jan 15, 2026
196fa5d
feat: 뽀모도로 집중 세션 중 타임라인 흐림효과 개선
Jan 15, 2026
462a9b7
Merge pull request #173 from deholic/feature/pomodoro-focus-blur-impr…
deholic Jan 15, 2026
7abb59b
fix: 뽀모도로 집중 중앙 메시지 z-index 조정 (rebased)
Jan 15, 2026
3ed1bfd
fix: CSS에서 Git 충돌 기호 제거
Jan 15, 2026
9bcc062
Merge pull request #174 from deholic/fix/pomodoro-zindex-modal-conflict
deholic Jan 15, 2026
b51ed77
feat: 답글 작성 시 외부 서버 사용자에게 풀 핸들 사용
Jan 16, 2026
673b329
Merge pull request #175 from deholic/feature/reply-full-handle-for-ex…
deholic Jan 16, 2026
9d570ba
fix: 이미지 추가 버튼 보더 색상을 input-border와 통일
Jan 16, 2026
9882c30
Merge pull request #176 from deholic/feature/reply-full-handle-for-ex…
deholic Jan 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# AGENTS.md
# AGENTS.md

## 기본 원칙
- SOLID 원칙을 준수한다.
Expand All @@ -17,6 +17,7 @@
- 모든 소스 파일은 UTF-8 (BOM 없음)으로 저장한다.
- 커밋 전 텍스트 깨짐 검사: `rg -n "�" src`, `rg -n "[\u00C0-\u00FF]" src`
- UI 문자열에 한글 외의 CJK 문자가 섞여 있으면 원인을 확인하고 교체한다.
- 분석을 위하여 파일을 읽을 때는 CJK 문자가 깨지지 않도록 해야 한다.

## 작업 플로우
- 작업 시작 전: `develop` 최신화 → 새 feature 브랜치 생성.
Expand All @@ -30,3 +31,5 @@
- PR 본문은 마크다운이 깨지지 않도록 멀티라인(heredoc) 방식으로 작성한다.
- PR 제목/본문의 한글 인코딩이 깨지지 않도록 UTF-8로 작성·확인한다.
- PR URL은 코드 블록 없이 클릭 가능한 일반 텍스트로 제공한다.
- PR 생성 시 feature/* 브랜치는 반드시 develop 브랜치를 베이스로 삼는다.
- PR 생성 시 release/* 브랜치는 반드시 main 브랜치를 베이스로 삼는다.
5 changes: 4 additions & 1 deletion CLAUDE.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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 문자가 섞여 있으면 원인을 확인하고 교체한다.
- UI 문자열에 한글 외의 CJK 문자가 섞여 있으면 원인을 확인하고 교체한다.
- 분석을 위하여 파일을 읽을 때는 CJK 문자가 깨지지 않도록 해야 한다.
210 changes: 195 additions & 15 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<svg viewBox="0 0 24 24" aria-hidden="true">
Expand Down Expand Up @@ -183,6 +186,12 @@ const TimelineIcon = ({ timeline }: { timeline: TimelineType }) => {
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
);
case "bookmarks":
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
);
default:
return null;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -609,19 +650,28 @@ const TimelineSection = ({
aria-label="타임라인 선택"
>
{timelineOptions.map((option) => {
const isSelected = timelineType === option.id;
if (option.isDivider) {
return (
<div key={option.id} className="timeline-selector-divider" role="separator" />
);
}

const isSelected = !option.isDivider && timelineType === option.id;
return (
<button
key={option.id}
type="button"
className={isSelected ? "is-active" : ""}
aria-pressed={isSelected}
onClick={() => {
onTimelineChange(section.id, option.id);
setTimelineMenuOpen(false);
if (!option.isDivider) {
onTimelineChange(section.id, option.id as TimelineType);
setTimelineMenuOpen(false);
}
}}
disabled={option.isDivider}
>
<TimelineIcon timeline={option.id} />
{!option.isDivider && <TimelineIcon timeline={option.id as TimelineType} />}
<span>{option.label}</span>
</button>
);
Expand Down Expand Up @@ -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={
Expand Down Expand Up @@ -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={
Expand Down Expand Up @@ -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<string | null>(null);
const [reauthLoading, setReauthLoading] = useState(false);
Expand Down Expand Up @@ -966,7 +1035,7 @@ export const App = () => {
const dragStateRef = useRef<{ startX: number; scrollLeft: number; pointerId: number } | null>(null);
const [isBoardDragging, setIsBoardDragging] = useState(false);
const replySummary = replyTarget
? `@${replyTarget.accountHandle} · ${replyTarget.content.slice(0, 80)}`
? `@${formatReplyHandle(replyTarget.accountHandle, replyTarget.accountUrl, composeAccount?.instanceUrl ?? "")} · ${replyTarget.content.slice(0, 80)}`
: null;
const [route, setRoute] = useState<Route>(() => parseRoute());
const timelineListeners = useRef<Map<string, Set<(status: Status) => void>>>(new Map());
Expand Down Expand Up @@ -1195,6 +1264,22 @@ export const App = () => {
localStorage.setItem("textodon.reactions", showMisskeyReactions ? "on" : "off");
}, [showMisskeyReactions]);

useEffect(() => {
localStorage.setItem("textodon.pomodoro", showPomodoro ? "on" : "off");
}, [showPomodoro]);

useEffect(() => {
localStorage.setItem("textodon.pomodoro.focus", String(pomodoroFocus));
}, [pomodoroFocus]);

useEffect(() => {
localStorage.setItem("textodon.pomodoro.break", String(pomodoroBreak));
}, [pomodoroBreak]);

useEffect(() => {
localStorage.setItem("textodon.pomodoro.longBreak", String(pomodoroLongBreak));
}, [pomodoroLongBreak]);

const closeMobileMenu = useCallback(() => {
setMobileMenuOpen(false);
setMobileComposeOpen(false);
Expand Down Expand Up @@ -1408,7 +1493,8 @@ export const App = () => {
}
setComposeAccountId(account.id);
setReplyTarget(status);
setMentionSeed(`@${status.accountHandle}`);
const formattedHandle = formatReplyHandle(status.accountHandle, status.accountUrl, account.instanceUrl);
setMentionSeed(`@${formattedHandle}`);
setSelectedStatus(null);
};

Expand Down Expand Up @@ -1622,6 +1708,15 @@ export const App = () => {
/>
) : null}
</div>
{route === "home" && showPomodoro ? (
<PomodoroTimer
focusMinutes={pomodoroFocus}
breakMinutes={pomodoroBreak}
longBreakMinutes={pomodoroLongBreak}
onSessionTypeChange={setPomodoroSessionType}
onRunningChange={setPomodoroIsRunning}
/>
) : null}
{route === "home" ? (
<section className="panel sidebar-panel">
<div className="brand">
Expand Down Expand Up @@ -1696,7 +1791,16 @@ export const App = () => {
{oauthLoading ? <p className="empty">OAuth 인증 중...</p> : null}
{route === "home" ? (
<section className="panel">
{sections.length > 0 ? (
{showPomodoro && pomodoroSessionType === "focus" && pomodoroIsRunning ? (
<div className="pomodoro-focus-message">
<div className="pomodoro-focus-message-content">
<h2>🎯 집중 세션 진행 중</h2>
<p>뽀모도로 타이머가 동작 중입니다.<br />타임라인은 집중이 끝날 때까지 숨겨집니다.</p>
</div>
</div>
) : null}
<div className={`panel-content${showPomodoro && pomodoroSessionType === "focus" && pomodoroIsRunning ? " pomodoro-focus-blur" : ""}`}>
{sections.length > 0 ? (
<div
className={`timeline-board${isBoardDragging ? " is-dragging" : ""}`}
ref={timelineBoardRef}
Expand Down Expand Up @@ -1754,6 +1858,7 @@ onAccountChange={setSectionAccount}
})}
</div>
) : null}
</div>
</section>
) : null}
{route === "terms" ? <TermsPage /> : null}
Expand Down Expand Up @@ -1986,6 +2091,60 @@ onAccountChange={setSectionAccount}
<option value="large">대</option>
</select>
</div>
<div className="settings-item">
<div>
<strong>뽀모도로 타이머</strong>
<p>사이드바에 뽀모도로 타이머를 표시합니다.</p>
</div>
<label className="switch">
<input
type="checkbox"
checked={showPomodoro}
onChange={(event) => setShowPomodoro(event.target.checked)}
/>
<span className="slider" aria-hidden="true" />
</label>
</div>
{showPomodoro ? (
<div className="settings-item settings-item-pomodoro">
<div>
<strong>뽀모도로 시간 설정</strong>
<p>집중, 휴식, 긴 휴식 시간을 분 단위로 설정합니다.</p>
</div>
<div className="pomodoro-time-inputs">
<label>
집중
<input
type="number"
min="1"
max="60"
value={pomodoroFocus}
onChange={(event) => setPomodoroFocus(Number(event.target.value))}
/>
</label>
<label>
휴식
<input
type="number"
min="1"
max="30"
value={pomodoroBreak}
onChange={(event) => setPomodoroBreak(Number(event.target.value))}
/>
</label>
<label>
긴 휴식
<input
type="number"
min="1"
max="60"
value={pomodoroLongBreak}
onChange={(event) => setPomodoroLongBreak(Number(event.target.value))}
/>
</label>
</div>
</div>
) : null}
<div className="settings-item">
<div>
<strong>로컬 저장소 초기화</strong>
Expand Down Expand Up @@ -2067,6 +2226,27 @@ onAccountChange={setSectionAccount}
setActionError(err instanceof Error ? err.message : "부스트 처리에 실패했습니다.");
}
}}
onToggleBookmark={async (status) => {
if (!composeAccount) {
setActionError("계정을 선택해주세요.");
return;
}
setActionError(null);
const isBookmarking = !status.bookmarked;
try {
const updated = status.bookmarked
? await services.api.unbookmark(composeAccount, status.id)
: await services.api.bookmark(composeAccount, status.id);
setSelectedStatus(updated);
if (isBookmarking) {
showToast("북마크했습니다.");
} else {
showToast("북마크를 취소했습니다.");
}
} catch (err) {
setActionError(err instanceof Error ? err.message : "북마크 처리에 실패했습니다.");
}
}}
onDelete={async (status) => {
if (!composeAccount) {
return;
Expand Down
3 changes: 2 additions & 1 deletion src/domain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export type Visibility = "public" | "unlisted" | "private" | "direct";

export type AccountPlatform = "mastodon" | "misskey";

export type TimelineType = "home" | "local" | "federated" | "social" | "global" | "notifications";
export type TimelineType = "home" | "local" | "federated" | "social" | "global" | "notifications" | "bookmarks";

export type Account = {
id: string;
Expand Down Expand Up @@ -94,6 +94,7 @@ export type Status = {
reactions: Reaction[];
reblogged: boolean;
favourited: boolean;
bookmarked: boolean;
inReplyToId: string | null;
mentions: Mention[];
mediaAttachments: MediaAttachment[];
Expand Down
Loading
Loading