Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ee4e67b
docs: AGENTS.md에서 CLAUDE.MD 생성
deholic Jan 9, 2026
5c86f36
Merge pull request #106 from deholic/feature/add-claude-md
deholic Jan 9, 2026
f46b130
refactor: 팝오버/메뉴 외부 클릭 로직을 useClickOutside 훅으로 통합
deholic Jan 9, 2026
15d4727
Merge pull request #107 from deholic/refactor/use-click-outside-hook
deholic Jan 9, 2026
b74190d
refactor: 이모지 관리 로직을 useEmojiManager 훅으로 통합
deholic Jan 9, 2026
0f27110
Merge pull request #108 from deholic/refactor/use-click-outside-hook
deholic Jan 9, 2026
0ca6885
refactor: 이미지 줌/드래그 로직 useImageZoom 훅으로 추출
deholic Jan 9, 2026
e789145
Merge pull request #109 from deholic/refactor/image-zoom-modal
deholic Jan 9, 2026
b60f9cf
refactor: 계정 표시 UI를 AccountLabel 컴포넌트로 통합
deholic Jan 9, 2026
e0de60e
refactor: TimelineItem 삭제 확인 모달에 AccountLabel 적용
deholic Jan 9, 2026
febb2c0
fix: AccountLabel 이름 굵기 선택 가능하도록 개선
deholic Jan 9, 2026
a7e3e8c
첨부 이미지 추가 버튼 보더 두께 조정
deholic Jan 9, 2026
7e69403
Merge pull request #111 from deholic/feature/attachment-thumb-border
deholic Jan 9, 2026
7aeaf9f
Merge pull request #110 from deholic/refactor/account-label-component
deholic Jan 9, 2026
4e752ea
feat: add profile modal
deholic Jan 10, 2026
29c75c4
fix: repair mention label
deholic Jan 10, 2026
9dfe7df
fix: theme profile handle color
deholic Jan 10, 2026
2f6314e
Merge pull request #113 from deholic/feature/profile-modal
deholic Jan 10, 2026
1187ad9
Add profile follow controls and modal layering
deholic Jan 10, 2026
e8fe250
Merge pull request #114 from deholic/feature/profile-follow-button
deholic Jan 10, 2026
2d12126
Fix mention handle profile link
deholic Jan 10, 2026
1882f1c
Merge pull request #115 from deholic/feature/full-handle-link
deholic Jan 10, 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
27 changes: 27 additions & 0 deletions CLAUDE.MD
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# CLAUDE.MD

## 기본 원칙
- SOLID 원칙을 준수한다.
- 패키지 매니저는 Bun으로 통일하고, `bun.lock`을 항상 커밋한다.
- tsconfig는 `strict` 중심으로 유지하고, `any`/`as` 남용을 피한다.
- React 컴포넌트는 props 타입을 명확히 하고, 상태는 최소화한다.
- 비동기 상태 갱신은 낙관적 업데이트와 실패 롤백을 고려한다.
- 스타일은 목적별 파일로 분리하고, 전역 스타일은 최소화한다.
- UI의 색상 변경 시 모든 테마의 라이트/다크 모드를 모두 고려하여 변경한다.
- 사용하는 텍스트는 한국어를 기본으로 사용하고, UTF-8 인코딩을 적용한다.
- 다른 컨텐츠 위에 뜨는 메뉴나 팝오버, 팝업들은 자신 이외의 영역을 클릭했을 때 닫혀야 하며 배경색도 틴트 처리가 되어야 한다.
- 접근성: 버튼/아이콘에 `aria-label`, 텍스트 대체를 제공한다.
- 배포 용어: Cloudflare Pages 배포는 production, GitHub Pages 배포는 beta로 칭한다.

## 작업 플로우
- 작업 시작 전: `develop` 최신화 → 새 feature 브랜치 생성.
- 새로운 작업은 항상 `develop` 최신화 후 `feature/{기능-이름}` 브랜치에서 시작한다.
- 브랜치 이름은 작업 내용에 맞게 스스로 정한다.
- 브랜치 변경 시 미커밋 변경사항은 스태시 후 새 브랜치에 다시 적용하는 흐름을 우선한다.
- 작업 종료(릴리즈 준비 요청): 커밋 → 푸시 → PR 생성까지 진행한다.
- 브랜치 전략: `develop`은 beta 배포 기준 브랜치, `main`은 production 배포 기준 브랜치로 사용한다.

## PR 작성 규칙
- PR 본문은 마크다운이 깨지지 않도록 멀티라인(heredoc) 방식으로 작성한다.
- PR 제목/본문의 한글 인코딩이 깨지지 않도록 UTF-8로 작성·확인한다.
- PR URL은 코드 블록 없이 클릭 가능한 일반 텍스트로 제공한다.
156 changes: 63 additions & 93 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import type { Account, Reaction, ReactionInput, Status, TimelineType } from "./d
import { AccountAdd } from "./ui/components/AccountAdd";
import { AccountSelector } from "./ui/components/AccountSelector";
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 { 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 } from "./ui/utils/account";
Expand All @@ -16,6 +18,7 @@ import licenseText from "../LICENSE?raw";

type Route = "home" | "terms" | "license" | "oss";
type TimelineSectionConfig = { id: string; accountId: string | null; timelineType: TimelineType };
type ProfileTarget = { status: Status; account: Account | null; zIndex: number };

const SECTION_STORAGE_KEY = "textodon.sections";
const COMPOSE_ACCOUNT_KEY = "textodon.compose.accountId";
Expand Down Expand Up @@ -231,6 +234,7 @@ const TimelineSection = ({
onStatusClick,
onCloseStatusModal,
onReact,
onProfileClick,
onError,
onMoveSection,
canMoveLeft,
Expand All @@ -255,6 +259,7 @@ const TimelineSection = ({
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;
columnAccount: Account | null;
onMoveSection: (sectionId: string, direction: "left" | "right") => void;
Expand Down Expand Up @@ -349,80 +354,11 @@ const TimelineSection = ({
};
}, [account, registerTimelineListener, timeline.updateItem, timelineType, unregisterTimelineListener]);

useEffect(() => {
if (!menuOpen) {
return;
}
const handleClick = (event: MouseEvent) => {
if (!menuRef.current || !(event.target instanceof Node)) {
return;
}
if (
event.target instanceof Element &&
event.target.closest(".overlay-backdrop")
) {
setMenuOpen(false);
return;
}
if (!menuRef.current.contains(event.target)) {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick);
};
}, [menuOpen]);
useClickOutside(menuRef, menuOpen, () => setMenuOpen(false));

useEffect(() => {
if (!timelineMenuOpen) {
return;
}
const handleClick = (event: MouseEvent) => {
if (!timelineMenuRef.current || !(event.target instanceof Node)) {
return;
}
if (
event.target instanceof Element &&
event.target.closest(".overlay-backdrop")
) {
setTimelineMenuOpen(false);
return;
}
if (!timelineMenuRef.current.contains(event.target)) {
setTimelineMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick);
};
}, [timelineMenuOpen]);
useClickOutside(timelineMenuRef, timelineMenuOpen, () => setTimelineMenuOpen(false));

useEffect(() => {
if (!notificationsOpen) {
return;
}
const handleClick = (event: MouseEvent) => {
if (!notificationMenuRef.current || !(event.target instanceof Node)) {
return;
}
if (
event.target instanceof Element &&
event.target.closest(".overlay-backdrop")
) {
setNotificationsOpen(false);
return;
}
if (!notificationMenuRef.current.contains(event.target)) {
setNotificationsOpen(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick);
};
}, [notificationsOpen]);
useClickOutside(notificationMenuRef, notificationsOpen, () => setNotificationsOpen(false));

useEffect(() => {
if (!notificationsOpen) {
Expand Down Expand Up @@ -542,7 +478,7 @@ const TimelineSection = ({
variant="inline"
/>
<div className="timeline-column-actions" role="group" aria-label="타임라인 작업">
<div className="timeline-selector" ref={timelineMenuRef}>
<div className="timeline-selector">
<button
type="button"
className="timeline-selector-button"
Expand All @@ -566,12 +502,9 @@ const TimelineSection = ({
</button>
{timelineMenuOpen ? (
<>
<div className="overlay-backdrop" aria-hidden="true" />
<div
className="overlay-backdrop"
onClick={() => setTimelineMenuOpen(false)}
aria-hidden="true"
/>
<div
ref={timelineMenuRef}
className="section-menu-panel timeline-selector-panel"
role="menu"
aria-label="타임라인 선택"
Expand All @@ -598,7 +531,7 @@ const TimelineSection = ({
</>
) : null}
</div>
<div className="notification-menu" ref={notificationMenuRef}>
<div className="notification-menu">
<button
type="button"
className={`icon-button${notificationsOpen ? " is-active" : ""}`}
Expand Down Expand Up @@ -628,12 +561,8 @@ const TimelineSection = ({
</button>
{notificationsOpen ? (
<>
<div
className="overlay-backdrop"
onClick={() => setNotificationsOpen(false)}
aria-hidden="true"
/>
<div className="notification-popover panel" role="dialog" aria-modal="true" aria-label="알림">
<div className="overlay-backdrop" aria-hidden="true" />
<div ref={notificationMenuRef} className="notification-popover panel" role="dialog" aria-modal="true" aria-label="알림">
<div className="notification-popover-header">
<button
type="button"
Expand Down Expand Up @@ -664,6 +593,7 @@ const TimelineSection = ({
onToggleReblog={handleToggleReblog}
onDelete={handleDeleteStatus}
onReact={handleReact}
onProfileClick={(item) => onProfileClick(item, account)}
activeHandle={
account?.handle ? formatHandle(account.handle, account.instanceUrl) : account?.instanceUrl ?? ""
}
Expand All @@ -685,7 +615,7 @@ const TimelineSection = ({
</>
) : null}
</div>
<div className="section-menu" ref={menuRef}>
<div className="section-menu">
<button
type="button"
className="icon-button menu-button"
Expand All @@ -704,12 +634,8 @@ const TimelineSection = ({
</button>
{menuOpen ? (
<>
<div
className="overlay-backdrop"
onClick={() => setMenuOpen(false)}
aria-hidden="true"
/>
<div className="section-menu-panel" role="menu">
<div className="overlay-backdrop" aria-hidden="true" />
<div ref={menuRef} className="section-menu-panel" role="menu">
<button
type="button"
onClick={() => {
Expand Down Expand Up @@ -793,6 +719,7 @@ const TimelineSection = ({
onToggleReblog={handleToggleReblog}
onDelete={handleDeleteStatus}
onReact={handleReact}
onProfileClick={(item) => onProfileClick(item, account)}
activeHandle={
account.handle ? formatHandle(account.handle, account.instanceUrl) : account.instanceUrl
}
Expand Down Expand Up @@ -926,6 +853,9 @@ export const App = () => {
);
const [replyTarget, setReplyTarget] = useState<Status | null>(null);
const [selectedStatus, setSelectedStatus] = useState<Status | null>(null);
const [profileTargets, setProfileTargets] = useState<ProfileTarget[]>([]);
const [statusModalZIndex, setStatusModalZIndex] = useState<number | null>(null);
const nextModalZIndexRef = useRef(70);
const [actionError, setActionError] = useState<string | null>(null);
const [oauthLoading, setOauthLoading] = useState(false);
const [mentionSeed, setMentionSeed] = useState<string | null>(null);
Expand Down Expand Up @@ -1305,12 +1235,31 @@ export const App = () => {

const handleStatusClick = (status: Status, columnAccount: Account | null) => {
setSelectedStatus(status);
setStatusModalZIndex(nextModalZIndexRef.current++);
// Status에 columnAccount 정보를 임시 저장
(status as any).__columnAccount = columnAccount;
};

const handleProfileOpen = useCallback((target: Status, columnAccount: Account | null) => {
const zIndex = nextModalZIndexRef.current++;
setProfileTargets((current) => [...current, { status: target, account: columnAccount, zIndex }]);
}, []);

const handleCloseProfileModal = useCallback((index?: number) => {
setProfileTargets((current) => {
if (current.length === 0) {
return current;
}
if (typeof index !== "number") {
return current.slice(0, -1);
}
return current.filter((_, currentIndex) => currentIndex !== index);
});
}, []);

const handleCloseStatusModal = () => {
setSelectedStatus(null);
setStatusModalZIndex(null);
};

const handleReaction = useCallback(
Expand Down Expand Up @@ -1575,8 +1524,9 @@ onAccountChange={setSectionAccount}
onAddSectionRight={(id) => addSectionNear(id, "right")}
onRemoveSection={removeSection}
onReply={handleReply}
onStatusClick={handleStatusClick}
onStatusClick={handleStatusClick}
onReact={handleReaction}
onProfileClick={handleProfileOpen}
columnAccount={sectionAccount}
onCloseStatusModal={handleCloseStatusModal}
onError={(message) => setActionError(message || null)}
Expand Down Expand Up @@ -1807,13 +1757,33 @@ onAccountChange={setSectionAccount}
</div>
) : null}

{profileTargets.map((target, index) => (
<ProfileModal
key={`${target.status.id}-${index}`}
status={target.status}
account={target.account}
api={services.api}
zIndex={target.zIndex}
isTopmost={index === profileTargets.length - 1}
onClose={() => handleCloseProfileModal(index)}
onReply={handleReply}
onStatusClick={(status) => handleStatusClick(status, target.account)}
onProfileClick={handleProfileOpen}
showProfileImage={showProfileImages}
showCustomEmojis={showCustomEmojis}
showReactions={showMisskeyReactions}
/>
))}

{selectedStatus ? (
<StatusModal
status={selectedStatus}
account={composeAccount}
threadAccount={(selectedStatus as any).__columnAccount || null}
api={services.api}
zIndex={statusModalZIndex ?? undefined}
onClose={handleCloseStatusModal}
onProfileClick={handleProfileOpen}
onReply={(status) => {
if (composeAccount) {
handleReply(status, composeAccount);
Expand Down
23 changes: 23 additions & 0 deletions src/domain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export type LinkCard = {
export type Status = {
id: string;
createdAt: string;
accountId: string | null;
accountName: string;
accountHandle: string;
accountUrl: string | null;
Expand Down Expand Up @@ -103,6 +104,28 @@ export type Status = {
accountEmojis: CustomEmoji[];
};

export type ProfileField = {
label: string;
value: string;
};

export type UserProfile = {
id: string;
name: string;
handle: string;
url: string | null;
avatarUrl: string | null;
headerUrl: string | null;
locked: boolean;
bio: string;
fields: ProfileField[];
};

export type AccountRelationship = {
following: boolean;
requested: boolean;
};

export type ThreadContext = {
ancestors: Status[];
descendants: Status[];
Expand Down
Loading
Loading