From 7be5d07ed6f01f85bc60e18a8548c82e242fbcd8 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Tue, 6 Jan 2026 21:17:04 +0900 Subject: [PATCH 01/16] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EB=B0=B0=EA=B2=BD=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EC=8B=9C=20=EB=8B=AB=EA=B8=B0=20(#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/components/ComposeBox.tsx | 18 +++++++++++++++++- src/ui/components/TimelineItem.tsx | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/ui/components/ComposeBox.tsx b/src/ui/components/ComposeBox.tsx index baeaa3f..49d77a3 100644 --- a/src/ui/components/ComposeBox.tsx +++ b/src/ui/components/ComposeBox.tsx @@ -615,9 +615,25 @@ export const ComposeBox = ({ role="dialog" aria-modal="true" onWheel={(event) => event.preventDefault()} + onMouseDown={(event) => { + if (!(event.target instanceof Element)) { + return; + } + if (!event.target.closest(".image-modal-content")) { + setActiveImageId(null); + } + }} >
setActiveImageId(null)} /> -
+
{ + if (event.target === event.currentTarget) { + setActiveImageId(null); + } + }} + >
) : null} + + {selectedStatus ? ( + { + if (composeAccount) { + handleReply(status, composeAccount); + } + }} + onToggleFavourite={async (status) => { + if (!composeAccount) { + setActionError("계정을 선택해주세요."); + return; + } + setActionError(null); + try { + const updated = status.favourited + ? await services.api.unfavourite(composeAccount, status.id) + : await services.api.favourite(composeAccount, status.id); + // Update the status in modal + setSelectedStatus(updated); + } catch (err) { + setActionError(err instanceof Error ? err.message : "좋아요 처리에 실패했습니다."); + } + }} + onToggleReblog={async (status) => { + if (!composeAccount) { + setActionError("계정을 선택해주세요."); + return; + } + setActionError(null); + try { + const updated = status.reblogged + ? await services.api.unreblog(composeAccount, status.id) + : await services.api.reblog(composeAccount, status.id); + setSelectedStatus(updated); + } catch (err) { + setActionError(err instanceof Error ? err.message : "부스트 처리에 실패했습니다."); + } + }} + onDelete={async (status) => { + if (!composeAccount) { + return; + } + setActionError(null); + try { + await services.api.deleteStatus(composeAccount, status.id); + setSelectedStatus(null); + } catch (err) { + setActionError(err instanceof Error ? err.message : "게시글 삭제에 실패했습니다."); + } + }} + activeHandle={ + composeAccount?.handle ? formatHandle(composeAccount.handle, composeAccount.instanceUrl) : composeAccount?.instanceUrl ?? "" + } + activeAccountHandle={composeAccount?.handle ?? ""} + activeAccountUrl={composeAccount?.url ?? null} + showProfileImage={showProfileImages} + showCustomEmojis={showCustomEmojis} + showReactions={showMisskeyReactions} + /> + ) : null}
); }; diff --git a/src/ui/components/StatusModal.tsx b/src/ui/components/StatusModal.tsx new file mode 100644 index 0000000..e150c11 --- /dev/null +++ b/src/ui/components/StatusModal.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import type { Account, Status } from "../../domain/types"; +import { TimelineItem } from "./TimelineItem"; +import boostIconUrl from "../assets/boost-icon.svg"; + +export const StatusModal = ({ + status, + account, + onClose, + onReply, + onToggleFavourite, + onToggleReblog, + onDelete, + activeHandle, + activeAccountHandle, + activeAccountUrl, + showProfileImage, + showCustomEmojis, + showReactions +}: { + status: Status; + account: Account | null; + onClose: () => void; + onReply: (status: Status) => void; + onToggleFavourite: (status: Status) => void; + onToggleReblog: (status: Status) => void; + onDelete?: (status: Status) => void; + activeHandle: string; + activeAccountHandle: string; + activeAccountUrl: string | null; + showProfileImage: boolean; + showCustomEmojis: boolean; + showReactions: boolean; +}) => { + const displayStatus = status.reblog ?? status; + const boostedBy = status.reblog ? status.boostedBy : null; + + return ( +
+
+
+
+

게시글

+ +
+ +
+ {boostedBy ? ( +
+ + {boostedBy.name || boostedBy.handle} 님이 부스트함 +
+ ) : null} + + {})} + activeHandle={activeHandle} + activeAccountHandle={activeAccountHandle} + activeAccountUrl={activeAccountUrl} + showProfileImage={showProfileImage} + showCustomEmojis={showCustomEmojis} + showReactions={showReactions} + disableActions={!account} + /> +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx index 2b14ea1..aee7139 100644 --- a/src/ui/components/TimelineItem.tsx +++ b/src/ui/components/TimelineItem.tsx @@ -11,6 +11,7 @@ export const TimelineItem = ({ onToggleFavourite, onToggleReblog, onDelete, + onStatusClick, activeHandle, activeAccountHandle, activeAccountUrl, @@ -24,6 +25,7 @@ export const TimelineItem = ({ onToggleFavourite: (status: Status) => void; onToggleReblog: (status: Status) => void; onDelete: (status: Status) => void; + onStatusClick?: (status: Status) => void; activeHandle: string; activeAccountHandle: string; activeAccountUrl: string | null; @@ -166,6 +168,19 @@ export const TimelineItem = ({ }, [displayStatus.accountUrl] ); + + const handleStatusClick = useCallback( + (event: React.MouseEvent) => { + if (!onStatusClick) { + return; + } + if (event.target instanceof Element && event.target.closest("a")) { + return; + } + onStatusClick(displayStatus); + }, + [displayStatus, onStatusClick] + ); const visibilityIcon = useMemo(() => { switch (displayStatus.visibility) { case "public": @@ -596,16 +611,17 @@ export const TimelineItem = ({ ) : null} ) : null} -
+
{visibilityIcon} {visibilityIcon ? : null} - {displayStatus.url ? ( - - - - ) : ( - - )} +
{shouldShowReactions ? (
diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 6d8ed09..719194a 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -1079,6 +1079,14 @@ button.ghost { gap: 6px; } +.status-time[role="button"] { + cursor: pointer; +} + +.status-time[role="button"]:hover { + color: #3b5fa8; +} + .status-time a { color: inherit; text-decoration: none; @@ -1411,6 +1419,68 @@ button.ghost { color: #fffaf2; } +.status-modal { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; +} + +.status-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(15, 13, 11, 0.7); +} + +.status-modal-content { + position: relative; + z-index: 1; + width: min(92vw, 600px); + max-height: 80vh; + overflow: hidden; + background: #fffdfa; + border: 1px solid #e2ddd5; + border-radius: 16px; + display: flex; + flex-direction: column; +} + +.status-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 16px 12px; + border-bottom: 1px solid #e2ddd5; +} + +.status-modal-title { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.status-modal-close { + background: none; + color: #3b5fa8; + border: none; + padding: 6px 12px; + font-size: 14px; + cursor: pointer; + border-radius: 8px; +} + +.status-modal-close:hover { + background: #f3efe7; +} + +.status-modal-body { + padding: 0; + overflow-y: auto; + max-height: calc(80vh - 60px); +} + .boosted-by { display: flex; align-items: center; From 35cabde61b6a07a452cce84444df6c7392f829f8 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Wed, 7 Jan 2026 11:51:30 +0900 Subject: [PATCH 03/16] =?UTF-8?q?fix:=20StatusModal=20=ED=85=8C=EB=A7=88?= =?UTF-8?q?=20=EB=B0=8F=20=EC=83=89=EC=83=81=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 라이트 테마별 StatusModal 스타일 추가 - christmas, sky-pink, monochrome 테마 지원 - 배경, 내용, 헤더, 버튼 색상 조정 - 하드코딩된 색상 모드 StatusModal 스타일 추가 - 다크 모드 기본 스타일 - 각 테마별 다크 모드 스타일 조정 - StatusModal 전체 테마 일관성 확보 - backdrop 틴트 색상 테마 맞춤 - content 배경색 테마 맞춤 - header, title, close 버튼 색상 테마 맞춤 --- src/ui/styles/theme.css | 325 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 324 insertions(+), 1 deletion(-) diff --git a/src/ui/styles/theme.css b/src/ui/styles/theme.css index 1d04698..76ca526 100644 --- a/src/ui/styles/theme.css +++ b/src/ui/styles/theme.css @@ -1,4 +1,4 @@ -:root[data-theme="christmas"] .sidebar-description { +:root[data-theme="christmas"] .sidebar-description { color: #7b3f3f; } @@ -22,6 +22,31 @@ background: rgba(32, 16, 15, 0.65); } +:root[data-theme="christmas"] .status-modal-backdrop { + background: rgba(32, 16, 15, 0.7); +} + +:root[data-theme="christmas"] .status-modal-content { + background: #fff7f2; + border: 1px solid #ead7cc; +} + +:root[data-theme="christmas"] .status-modal-header { + border-bottom: 1px solid #ead7cc; +} + +:root[data-theme="christmas"] .status-modal-title { + color: #5b1f1f; +} + +:root[data-theme="christmas"] .status-modal-close { + color: #8f2f2f; +} + +:root[data-theme="christmas"] .status-modal-close:hover { + background: #f3e4dc; +} + :root[data-theme="christmas"] .overlay-backdrop, body[data-theme="christmas"] .overlay-backdrop { background: rgba(91, 31, 31, 0.12); @@ -294,6 +319,31 @@ body[data-theme="christmas"] .compose-icon-button { background: rgba(29, 51, 74, 0.6); } +:root[data-theme="sky-pink"] .status-modal-backdrop { + background: rgba(29, 51, 74, 0.7); +} + +:root[data-theme="sky-pink"] .status-modal-content { + background: #f7fbff; + border: 1px solid #d6e5f2; +} + +:root[data-theme="sky-pink"] .status-modal-header { + border-bottom: 1px solid #d6e5f2; +} + +:root[data-theme="sky-pink"] .status-modal-title { + color: #2b4f7d; +} + +:root[data-theme="sky-pink"] .status-modal-close { + color: #4a6b86; +} + +:root[data-theme="sky-pink"] .status-modal-close:hover { + background: #e9f2fb; +} + :root[data-theme="sky-pink"] .overlay-backdrop, body[data-theme="sky-pink"] .overlay-backdrop { background: rgba(74, 139, 216, 0.14); @@ -562,6 +612,31 @@ body[data-theme="sky-pink"] .compose-icon-button { background: rgba(10, 10, 10, 0.65); } +:root[data-theme="monochrome"] .status-modal-backdrop { + background: rgba(10, 10, 10, 0.7); +} + +:root[data-theme="monochrome"] .status-modal-content { + background: #ffffff; + border: 1px solid #1d1d1d; +} + +:root[data-theme="monochrome"] .status-modal-header { + border-bottom: 1px solid #1d1d1d; +} + +:root[data-theme="monochrome"] .status-modal-title { + color: #111111; +} + +:root[data-theme="monochrome"] .status-modal-close { + color: #111111; +} + +:root[data-theme="monochrome"] .status-modal-close:hover { + background: #f1f1f1; +} + :root[data-theme="monochrome"] .overlay-backdrop, body[data-theme="monochrome"] .overlay-backdrop { background: rgba(17, 17, 17, 0.16); @@ -1672,6 +1747,37 @@ body[data-theme="monochrome"] .compose-icon-button { background: rgba(8, 7, 6, 0.7); } + :root:not([data-color-scheme="light"]) .status-modal-backdrop, + body:not([data-color-scheme="light"]) .status-modal-backdrop { + background: rgba(8, 7, 6, 0.7); + } + + :root:not([data-color-scheme="light"]) .status-modal-content, + body:not([data-color-scheme="light"]) .status-modal-content { + background: #171512; + border: 1px solid #3b342d; + } + + :root:not([data-color-scheme="light"]) .status-modal-header, + body:not([data-color-scheme="light"]) .status-modal-header { + border-bottom: 1px solid #3b342d; + } + + :root:not([data-color-scheme="light"]) .status-modal-title, + body:not([data-color-scheme="light"]) .status-modal-title { + color: #f0e7dc; + } + + :root:not([data-color-scheme="light"]) .status-modal-close, + body:not([data-color-scheme="light"]) .status-modal-close { + color: #8fa6cf; + } + + :root:not([data-color-scheme="light"]) .status-modal-close:hover, + body:not([data-color-scheme="light"]) .status-modal-close:hover { + background: #2c3b59; + } + :root:not([data-color-scheme="light"]) .back-link, body:not([data-color-scheme="light"]) .back-link { color: #d7cbbd; @@ -1691,6 +1797,37 @@ body[data-theme="monochrome"] .compose-icon-button { color: #9cc7ab; } + :root:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-backdrop, + body:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-backdrop { + background: rgba(32, 16, 15, 0.45); + } + + :root:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-content, + body:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-content { + background: #201412; + border: 1px solid #4a2f2c; + } + + :root:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-header, + body:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-header { + border-bottom: 1px solid #4a2f2c; + } + + :root:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-title, + body:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-title { + color: #f7e7df; + } + + :root:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-close, + body:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-close { + color: #cf8c8c; + } + + :root:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-close:hover, + body:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-close:hover { + background: #3b2624; + } + :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .boosted-by, body:not([data-color-scheme="light"])[data-theme="sky-pink"] .boosted-by, :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .notification-actor, @@ -1698,6 +1835,37 @@ body[data-theme="monochrome"] .compose-icon-button { color: #9dbef4; } + :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-backdrop, + body:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-backdrop { + background: rgba(29, 51, 74, 0.7); + } + + :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-content, + body:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-content { + background: #1a202dcc; + border: 1px solid #2e3b50; + } + + :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-header, + body:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-header { + border-bottom: 1px solid #2e3b50; + } + + :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-title, + body:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-title { + color: #eff1f7; + } + + :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-close, + body:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-close { + color: #aeb7c6; + } + + :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-close:hover, + body:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-close:hover { + background: #223047; + } + :root:not([data-color-scheme="light"])[data-theme="monochrome"] .boosted-by, body:not([data-color-scheme="light"])[data-theme="monochrome"] .boosted-by, :root:not([data-color-scheme="light"])[data-theme="monochrome"] .notification-actor, @@ -1705,6 +1873,37 @@ body[data-theme="monochrome"] .compose-icon-button { color: #bdbdbd; } + :root:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-backdrop, + body:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-backdrop { + background: rgba(10, 10, 10, 0.5); + } + + :root:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-content, + body:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-content { + background: #121212; + border: 1px solid #3a3a3a; + } + + :root:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-header, + body:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-header { + border-bottom: 1px solid #3a3a3a; + } + + :root:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-title, + body:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-title { + color: #f5f5f5; + } + + :root:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-close, + body:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-close { + color: #f0f0f0; + } + + :root:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-close:hover, + body:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-close:hover { + background: #2a2a2a; + } + :root:not([data-color-scheme="light"]) .reply-info, body:not([data-color-scheme="light"]) .reply-info { color: #c4b6a6; @@ -3114,6 +3313,37 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { background: rgba(8, 7, 6, 0.7); } + :root[data-color-scheme="dark"] .status-modal-backdrop, + body[data-color-scheme="dark"] .status-modal-backdrop { + background: rgba(8, 7, 6, 0.7); + } + + :root[data-color-scheme="dark"] .status-modal-content, + body[data-color-scheme="dark"] .status-modal-content { + background: #171512; + border: 1px solid #3b342d; + } + + :root[data-color-scheme="dark"] .status-modal-header, + body[data-color-scheme="dark"] .status-modal-header { + border-bottom: 1px solid #3b342d; + } + + :root[data-color-scheme="dark"] .status-modal-title, + body[data-color-scheme="dark"] .status-modal-title { + color: #f0e7dc; + } + + :root[data-color-scheme="dark"] .status-modal-close, + body[data-color-scheme="dark"] .status-modal-close { + color: #8fa6cf; + } + + :root[data-color-scheme="dark"] .status-modal-close:hover, + body[data-color-scheme="dark"] .status-modal-close:hover { + background: #2c3b59; + } + :root[data-color-scheme="dark"] .back-link, body[data-color-scheme="dark"] .back-link { color: #d7cbbd; @@ -3133,6 +3363,37 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { color: #9cc7ab; } + :root[data-color-scheme="dark"][data-theme="christmas"] .status-modal-backdrop, + body[data-color-scheme="dark"][data-theme="christmas"] .status-modal-backdrop { + background: rgba(32, 16, 15, 0.7); + } + + :root[data-color-scheme="dark"][data-theme="christmas"] .status-modal-content, + body[data-color-scheme="dark"][data-theme="christmas"] .status-modal-content { + background: #201412; + border: 1px solid #4a2f2c; + } + + :root[data-color-scheme="dark"][data-theme="christmas"] .status-modal-header, + body[data-color-scheme="dark"][data-theme="christmas"] .status-modal-header { + border-bottom: 1px solid #4a2f2c; + } + + :root[data-color-scheme="dark"][data-theme="christmas"] .status-modal-title, + body[data-color-scheme="dark"][data-theme="christmas"] .status-modal-title { + color: #f7e7df; + } + + :root[data-color-scheme="dark"][data-theme="christmas"] .status-modal-close, + body[data-color-scheme="dark"][data-theme="christmas"] .status-modal-close { + color: #cf8c8c; + } + + :root[data-color-scheme="dark"][data-theme="christmas"] .status-modal-close:hover, + body[data-color-scheme="dark"][data-theme="christmas"] .status-modal-close:hover { + background: #3b2624; + } + :root[data-color-scheme="dark"][data-theme="sky-pink"] .boosted-by, body[data-color-scheme="dark"][data-theme="sky-pink"] .boosted-by, :root[data-color-scheme="dark"][data-theme="sky-pink"] .notification-actor, @@ -3140,6 +3401,37 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { color: #9dbef4; } + :root[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-backdrop, + body[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-backdrop { + background: rgba(29, 51, 74, 0.7); + } + + :root[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-content, + body[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-content { + background: #1a202d; + border: 1px solid #2e3b50; + } + + :root[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-header, + body[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-header { + border-bottom: 1px solid #2e3b50; + } + + :root[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-title, + body[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-title { + color: #eff1f7; + } + + :root[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close, + body[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close { + color: #aeb7c6; + } + + :root[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close:hover, + body[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close:hover { + background: #223047; + } + :root[data-color-scheme="dark"][data-theme="monochrome"] .boosted-by, body[data-color-scheme="dark"][data-theme="monochrome"] .boosted-by, :root[data-color-scheme="dark"][data-theme="monochrome"] .notification-actor, @@ -3147,6 +3439,37 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { color: #bdbdbd; } + :root[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-backdrop, + body[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-backdrop { + background: rgba(10, 10, 10, 0.5); + } + + :root[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-content, + body[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-content { + background: #121212; + border: 1px solid #3a3a3a; + } + + :root[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-header, + body[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-header { + border-bottom: 1px solid #3a3a3a; + } + + :root[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-title, + body[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-title { + color: #f5f5f5; + } + + :root[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close, + body[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close { + color: #f0f0f0; + } + + :root[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close:hover, + body[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close:hover { + background: #2a2a2a; + } + :root[data-color-scheme="dark"] .reply-info, body[data-color-scheme="dark"] .reply-info { color: #c4b6a6; From 82b0894031844cdc3adf560bbebf02bf43b77d6a Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Wed, 7 Jan 2026 11:59:07 +0900 Subject: [PATCH 04/16] =?UTF-8?q?refine:=20StatusModal=20UI=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 닫기 버튼 텍스트를 '×' 아이콘으로 변경 - 테마와 색상 모드 일관성 확보 - 커서 포인터와 hover 효과 유지 - StatusModal 마진과 그림자 효과 추가 - content에 20px 마진 추가 - 테마별로 맞춤 그림자 효과 적용 - body에 16px 패딩으로 여백 확보 - 전체적인 레이아웃 개선 - 모달과 화면 경계에 적당한 간격 확보 - 내용이 너무 붙어 보이는 문제 해결 - 라이트/다크 모드 모두 지원 --- src/ui/components/StatusModal.tsx | 2 +- src/ui/styles/components.css | 12 ++++++++---- src/ui/styles/theme.css | 31 +++++++++++++++++++++++++++++-- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/ui/components/StatusModal.tsx b/src/ui/components/StatusModal.tsx index e150c11..a48b412 100644 --- a/src/ui/components/StatusModal.tsx +++ b/src/ui/components/StatusModal.tsx @@ -52,7 +52,7 @@ export const StatusModal = ({ onClick={onClose} aria-label="닫기" > - 닫기 + ×
diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 719194a..b409495 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -1445,6 +1445,8 @@ button.ghost { border-radius: 16px; display: flex; flex-direction: column; + margin: 20px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); } .status-modal-header { @@ -1465,10 +1467,12 @@ button.ghost { background: none; color: #3b5fa8; border: none; - padding: 6px 12px; - font-size: 14px; + padding: 4px 8px; + font-size: 18px; cursor: pointer; border-radius: 8px; + line-height: 1; + font-weight: 300; } .status-modal-close:hover { @@ -1476,9 +1480,9 @@ button.ghost { } .status-modal-body { - padding: 0; + padding: 16px; overflow-y: auto; - max-height: calc(80vh - 60px); + max-height: calc(80vh - 100px); } .boosted-by { diff --git a/src/ui/styles/theme.css b/src/ui/styles/theme.css index 76ca526..d977a0c 100644 --- a/src/ui/styles/theme.css +++ b/src/ui/styles/theme.css @@ -29,6 +29,7 @@ :root[data-theme="christmas"] .status-modal-content { background: #fff7f2; border: 1px solid #ead7cc; + box-shadow: 0 20px 60px rgba(134, 40, 40, 0.15); } :root[data-theme="christmas"] .status-modal-header { @@ -326,6 +327,7 @@ body[data-theme="christmas"] .compose-icon-button { :root[data-theme="sky-pink"] .status-modal-content { background: #f7fbff; border: 1px solid #d6e5f2; + box-shadow: 0 20px 60px rgba(58, 118, 185, 0.15); } :root[data-theme="sky-pink"] .status-modal-header { @@ -619,6 +621,7 @@ body[data-theme="sky-pink"] .compose-icon-button { :root[data-theme="monochrome"] .status-modal-content { background: #ffffff; border: 1px solid #1d1d1d; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); } :root[data-theme="monochrome"] .status-modal-header { @@ -1756,6 +1759,7 @@ body[data-theme="monochrome"] .compose-icon-button { body:not([data-color-scheme="light"]) .status-modal-content { background: #171512; border: 1px solid #3b342d; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); } :root:not([data-color-scheme="light"]) .status-modal-header, @@ -1806,6 +1810,7 @@ body[data-theme="monochrome"] .compose-icon-button { body:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-content { background: #201412; border: 1px solid #4a2f2c; + box-shadow: 0 20px 60px rgba(64, 20, 20, 0.15); } :root:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-header, @@ -1840,10 +1845,11 @@ body[data-theme="monochrome"] .compose-icon-button { background: rgba(29, 51, 74, 0.7); } - :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-content, +:root:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-content, body:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-content { - background: #1a202dcc; + background: #1a202d; border: 1px solid #2e3b50; + box-shadow: 0 20px 60px rgba(12, 18, 28, 0.15); } :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-header, @@ -1882,6 +1888,7 @@ body[data-theme="monochrome"] .compose-icon-button { body:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-content { background: #121212; border: 1px solid #3a3a3a; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); } :root:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-header, @@ -3344,6 +3351,11 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { background: #2c3b59; } + :root[data-color-scheme="dark"] .status-modal-close:hover, + body[data-color-scheme="dark"] .status-modal-close:hover { + background: #2c3b59; + } + :root[data-color-scheme="dark"] .back-link, body[data-color-scheme="dark"] .back-link { color: #d7cbbd; @@ -3394,6 +3406,11 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { background: #3b2624; } + :root[data-color-scheme="dark"][data-theme="christmas"] .status-modal-close:hover, + body[data-color-scheme="dark"][data-theme="christmas"] .status-modal-close:hover { + background: #3b2624; + } + :root[data-color-scheme="dark"][data-theme="sky-pink"] .boosted-by, body[data-color-scheme="dark"][data-theme="sky-pink"] .boosted-by, :root[data-color-scheme="dark"][data-theme="sky-pink"] .notification-actor, @@ -3432,6 +3449,11 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { background: #223047; } + :root[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close:hover, + body[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close:hover { + background: #223047; + } + :root[data-color-scheme="dark"][data-theme="monochrome"] .boosted-by, body[data-color-scheme="dark"][data-theme="monochrome"] .boosted-by, :root[data-color-scheme="dark"][data-theme="monochrome"] .notification-actor, @@ -3470,6 +3492,11 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { background: #2a2a2a; } + :root[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close:hover, + body[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close:hover { + background: #2a2a2a; + } + :root[data-color-scheme="dark"] .reply-info, body[data-color-scheme="dark"] .reply-info { color: #c4b6a6; From 2d48fe49647c2da8834f8976949d642b153fe7ff Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Wed, 7 Jan 2026 12:07:37 +0900 Subject: [PATCH 05/16] =?UTF-8?q?fix:=20StatusModal=20=EB=8B=AB=EA=B8=B0?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hover 효과 제거로 깔끔한 디자인 적용 - 배경색 변경 없음 - 커서 포인터만 유지 - 닫기 버튼 텍스트 색상 테마별 조정 - 기본 테마: #666 (연한 회색) - Christmas: #7b3f3f (따뜻한 회갈색) - Sky-Pink: #4a6b86 (차가운 회갈색) - Monochrome: #666 (중간 회색) - 다크 모드: #999 (연한 회색) - 일관성 있는 색상 체계 적용 - 모든 테마와 색상 모드에서 자연스러움 - 시각적 방해 없는 수수한 디자인 --- src/ui/styles/components.css | 7 +--- src/ui/styles/theme.css | 64 ++++-------------------------------- 2 files changed, 7 insertions(+), 64 deletions(-) diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index b409495..8e71ae2 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -1465,20 +1465,15 @@ button.ghost { .status-modal-close { background: none; - color: #3b5fa8; + color: #666; border: none; padding: 4px 8px; font-size: 18px; cursor: pointer; - border-radius: 8px; line-height: 1; font-weight: 300; } -.status-modal-close:hover { - background: #f3efe7; -} - .status-modal-body { padding: 16px; overflow-y: auto; diff --git a/src/ui/styles/theme.css b/src/ui/styles/theme.css index d977a0c..003f40e 100644 --- a/src/ui/styles/theme.css +++ b/src/ui/styles/theme.css @@ -41,11 +41,7 @@ } :root[data-theme="christmas"] .status-modal-close { - color: #8f2f2f; -} - -:root[data-theme="christmas"] .status-modal-close:hover { - background: #f3e4dc; + color: #7b3f3f; } :root[data-theme="christmas"] .overlay-backdrop, @@ -342,10 +338,6 @@ body[data-theme="christmas"] .compose-icon-button { color: #4a6b86; } -:root[data-theme="sky-pink"] .status-modal-close:hover { - background: #e9f2fb; -} - :root[data-theme="sky-pink"] .overlay-backdrop, body[data-theme="sky-pink"] .overlay-backdrop { background: rgba(74, 139, 216, 0.14); @@ -633,11 +625,7 @@ body[data-theme="sky-pink"] .compose-icon-button { } :root[data-theme="monochrome"] .status-modal-close { - color: #111111; -} - -:root[data-theme="monochrome"] .status-modal-close:hover { - background: #f1f1f1; + color: #666; } :root[data-theme="monochrome"] .overlay-backdrop, @@ -1774,12 +1762,7 @@ body[data-theme="monochrome"] .compose-icon-button { :root:not([data-color-scheme="light"]) .status-modal-close, body:not([data-color-scheme="light"]) .status-modal-close { - color: #8fa6cf; - } - - :root:not([data-color-scheme="light"]) .status-modal-close:hover, - body:not([data-color-scheme="light"]) .status-modal-close:hover { - background: #2c3b59; + color: #999; } :root:not([data-color-scheme="light"]) .back-link, @@ -1828,11 +1811,6 @@ body[data-theme="monochrome"] .compose-icon-button { color: #cf8c8c; } - :root:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-close:hover, - body:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-close:hover { - background: #3b2624; - } - :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .boosted-by, body:not([data-color-scheme="light"])[data-theme="sky-pink"] .boosted-by, :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .notification-actor, @@ -1867,11 +1845,6 @@ body[data-theme="monochrome"] .compose-icon-button { color: #aeb7c6; } - :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-close:hover, - body:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-close:hover { - background: #223047; - } - :root:not([data-color-scheme="light"])[data-theme="monochrome"] .boosted-by, body:not([data-color-scheme="light"])[data-theme="monochrome"] .boosted-by, :root:not([data-color-scheme="light"])[data-theme="monochrome"] .notification-actor, @@ -1903,12 +1876,7 @@ body[data-theme="monochrome"] .compose-icon-button { :root:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-close, body:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-close { - color: #f0f0f0; - } - - :root:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-close:hover, - body:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-close:hover { - background: #2a2a2a; + color: #999; } :root:not([data-color-scheme="light"]) .reply-info, @@ -3343,12 +3311,7 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { :root[data-color-scheme="dark"] .status-modal-close, body[data-color-scheme="dark"] .status-modal-close { - color: #8fa6cf; - } - - :root[data-color-scheme="dark"] .status-modal-close:hover, - body[data-color-scheme="dark"] .status-modal-close:hover { - background: #2c3b59; + color: #999; } :root[data-color-scheme="dark"] .status-modal-close:hover, @@ -3406,11 +3369,6 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { background: #3b2624; } - :root[data-color-scheme="dark"][data-theme="christmas"] .status-modal-close:hover, - body[data-color-scheme="dark"][data-theme="christmas"] .status-modal-close:hover { - background: #3b2624; - } - :root[data-color-scheme="dark"][data-theme="sky-pink"] .boosted-by, body[data-color-scheme="dark"][data-theme="sky-pink"] .boosted-by, :root[data-color-scheme="dark"][data-theme="sky-pink"] .notification-actor, @@ -3449,11 +3407,6 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { background: #223047; } - :root[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close:hover, - body[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close:hover { - background: #223047; - } - :root[data-color-scheme="dark"][data-theme="monochrome"] .boosted-by, body[data-color-scheme="dark"][data-theme="monochrome"] .boosted-by, :root[data-color-scheme="dark"][data-theme="monochrome"] .notification-actor, @@ -3484,12 +3437,7 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { :root[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close, body[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close { - color: #f0f0f0; - } - - :root[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close:hover, - body[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close:hover { - background: #2a2a2a; + color: #999; } :root[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close:hover, From 6add41f979dc1d6e53e1523f3579eb787dc21c64 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Wed, 7 Jan 2026 12:10:56 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix:=20StatusModal=20=EB=8B=AB=EA=B8=B0?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EB=8B=A4=ED=81=AC=20=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=20=EC=83=89=EC=83=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다른 버튼들의 다크 모드 색상과 일관성 확보 - 기본 다크: #8fa6cf (파스텔 블루) - Christmas 다크: #cf8c8c (따뜻한 핑크) - Sky-Pink 다크: #f0a6c8 (밝은 핑크) - Monochrome 다크: #f0f0f0 (밝은 흰색) - button.ghost 스타일과 색상 일치 적용 - 모든 테마와 색상 모드에서 가시성 확보 - 앱 전체 디자인 시스템과 일관성 유지 --- src/ui/styles/theme.css | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ui/styles/theme.css b/src/ui/styles/theme.css index 003f40e..d6b248b 100644 --- a/src/ui/styles/theme.css +++ b/src/ui/styles/theme.css @@ -1762,7 +1762,7 @@ body[data-theme="monochrome"] .compose-icon-button { :root:not([data-color-scheme="light"]) .status-modal-close, body:not([data-color-scheme="light"]) .status-modal-close { - color: #999; + color: #8fa6cf; } :root:not([data-color-scheme="light"]) .back-link, @@ -1842,7 +1842,7 @@ body[data-theme="monochrome"] .compose-icon-button { :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-close, body:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-close { - color: #aeb7c6; + color: #f0a6c8; } :root:not([data-color-scheme="light"])[data-theme="monochrome"] .boosted-by, @@ -1876,7 +1876,7 @@ body[data-theme="monochrome"] .compose-icon-button { :root:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-close, body:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-close { - color: #999; + color: #f0f0f0; } :root:not([data-color-scheme="light"]) .reply-info, @@ -3311,7 +3311,7 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { :root[data-color-scheme="dark"] .status-modal-close, body[data-color-scheme="dark"] .status-modal-close { - color: #999; + color: #8fa6cf; } :root[data-color-scheme="dark"] .status-modal-close:hover, @@ -3399,7 +3399,7 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { :root[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close, body[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close { - color: #aeb7c6; + color: #f0a6c8; } :root[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close:hover, @@ -3437,7 +3437,7 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { :root[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close, body[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close { - color: #999; + color: #f0f0f0; } :root[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close:hover, From efb873d082ca9289a69801cdc317250d6073234b Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Wed, 7 Jan 2026 12:29:21 +0900 Subject: [PATCH 07/16] =?UTF-8?q?fix:=20StatusModal=20=EB=8B=AB=EA=B8=B0?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=EC=9D=84=20=EC=9D=BC=EB=B0=98=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EB=B2=84=ED=8A=BC=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - status-modal-close를 icon-button 클래스로 변경하여 다른 아이콘 버튼과 스타일 일치 - 텍스트 대신 SVG X 아이콘으로 변경하고 viewBox 추가하여 중앙 정렬 - 모든 테마별 색상 오버라이드 및 hover 효과 제거 - 컴포넌트 스타일 단순화 --- src/ui/components/StatusModal.tsx | 7 ++- src/ui/styles/components.css | 11 +---- src/ui/styles/theme.css | 72 +++++++------------------------ 3 files changed, 21 insertions(+), 69 deletions(-) diff --git a/src/ui/components/StatusModal.tsx b/src/ui/components/StatusModal.tsx index a48b412..0a013c4 100644 --- a/src/ui/components/StatusModal.tsx +++ b/src/ui/components/StatusModal.tsx @@ -48,11 +48,14 @@ export const StatusModal = ({

게시글

diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 8e71ae2..a980858 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -1463,16 +1463,7 @@ button.ghost { font-weight: 600; } -.status-modal-close { - background: none; - color: #666; - border: none; - padding: 4px 8px; - font-size: 18px; - cursor: pointer; - line-height: 1; - font-weight: 300; -} + .status-modal-body { padding: 16px; diff --git a/src/ui/styles/theme.css b/src/ui/styles/theme.css index d6b248b..e56fea0 100644 --- a/src/ui/styles/theme.css +++ b/src/ui/styles/theme.css @@ -40,9 +40,7 @@ color: #5b1f1f; } -:root[data-theme="christmas"] .status-modal-close { - color: #7b3f3f; -} + :root[data-theme="christmas"] .overlay-backdrop, body[data-theme="christmas"] .overlay-backdrop { @@ -334,9 +332,7 @@ body[data-theme="christmas"] .compose-icon-button { color: #2b4f7d; } -:root[data-theme="sky-pink"] .status-modal-close { - color: #4a6b86; -} + :root[data-theme="sky-pink"] .overlay-backdrop, body[data-theme="sky-pink"] .overlay-backdrop { @@ -624,9 +620,7 @@ body[data-theme="sky-pink"] .compose-icon-button { color: #111111; } -:root[data-theme="monochrome"] .status-modal-close { - color: #666; -} + :root[data-theme="monochrome"] .overlay-backdrop, body[data-theme="monochrome"] .overlay-backdrop { @@ -1760,10 +1754,7 @@ body[data-theme="monochrome"] .compose-icon-button { color: #f0e7dc; } - :root:not([data-color-scheme="light"]) .status-modal-close, - body:not([data-color-scheme="light"]) .status-modal-close { - color: #8fa6cf; - } + :root:not([data-color-scheme="light"]) .back-link, body:not([data-color-scheme="light"]) .back-link { @@ -1806,10 +1797,7 @@ body[data-theme="monochrome"] .compose-icon-button { color: #f7e7df; } - :root:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-close, - body:not([data-color-scheme="light"])[data-theme="christmas"] .status-modal-close { - color: #cf8c8c; - } + :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .boosted-by, body:not([data-color-scheme="light"])[data-theme="sky-pink"] .boosted-by, @@ -1840,10 +1828,7 @@ body[data-theme="monochrome"] .compose-icon-button { color: #eff1f7; } - :root:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-close, - body:not([data-color-scheme="light"])[data-theme="sky-pink"] .status-modal-close { - color: #f0a6c8; - } + :root:not([data-color-scheme="light"])[data-theme="monochrome"] .boosted-by, body:not([data-color-scheme="light"])[data-theme="monochrome"] .boosted-by, @@ -1874,10 +1859,7 @@ body[data-theme="monochrome"] .compose-icon-button { color: #f5f5f5; } - :root:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-close, - body:not([data-color-scheme="light"])[data-theme="monochrome"] .status-modal-close { - color: #f0f0f0; - } + :root:not([data-color-scheme="light"]) .reply-info, body:not([data-color-scheme="light"]) .reply-info { @@ -3309,15 +3291,9 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { color: #f0e7dc; } - :root[data-color-scheme="dark"] .status-modal-close, - body[data-color-scheme="dark"] .status-modal-close { - color: #8fa6cf; - } + - :root[data-color-scheme="dark"] .status-modal-close:hover, - body[data-color-scheme="dark"] .status-modal-close:hover { - background: #2c3b59; - } + :root[data-color-scheme="dark"] .back-link, body[data-color-scheme="dark"] .back-link { @@ -3359,15 +3335,9 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { color: #f7e7df; } - :root[data-color-scheme="dark"][data-theme="christmas"] .status-modal-close, - body[data-color-scheme="dark"][data-theme="christmas"] .status-modal-close { - color: #cf8c8c; - } + - :root[data-color-scheme="dark"][data-theme="christmas"] .status-modal-close:hover, - body[data-color-scheme="dark"][data-theme="christmas"] .status-modal-close:hover { - background: #3b2624; - } + :root[data-color-scheme="dark"][data-theme="sky-pink"] .boosted-by, body[data-color-scheme="dark"][data-theme="sky-pink"] .boosted-by, @@ -3397,15 +3367,9 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { color: #eff1f7; } - :root[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close, - body[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close { - color: #f0a6c8; - } + - :root[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close:hover, - body[data-color-scheme="dark"][data-theme="sky-pink"] .status-modal-close:hover { - background: #223047; - } + :root[data-color-scheme="dark"][data-theme="monochrome"] .boosted-by, body[data-color-scheme="dark"][data-theme="monochrome"] .boosted-by, @@ -3435,15 +3399,9 @@ body[data-color-scheme="dark"][data-theme="monochrome"] { color: #f5f5f5; } - :root[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close, - body[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close { - color: #f0f0f0; - } + - :root[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close:hover, - body[data-color-scheme="dark"][data-theme="monochrome"] .status-modal-close:hover { - background: #2a2a2a; - } + :root[data-color-scheme="dark"] .reply-info, body[data-color-scheme="dark"] .reply-info { From dc7b2032ef077ef0689cf35faedc9e37b973bdf5 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Wed, 7 Jan 2026 16:34:43 +0900 Subject: [PATCH 08/16] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=EC=97=90=EC=84=9C=20=EC=8A=A4=EB=A0=88?= =?UTF-8?q?=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마스토돈(/api/v1/statuses/:id/context)과 미스키(/notes/conversation) API 지원 - 조상/후손 게시물을 시각적 계층으로 표시 - UnifiedApiClient에 통합 스레드 API 메서드 추가 - StatusModal에서 자동 스레드 로딩 및 렌더링 - 미묘한 로딩 인디케이터와 에러 처리 - 깔끔한 들여쓰기 기반 스레드 디자인 - 최적화된 여백 처리로 일관된 레이아웃 제공 --- src/App.tsx | 1 + src/domain/types.ts | 6 ++ src/infra/MastodonHttpClient.ts | 26 ++++++- src/infra/MisskeyHttpClient.ts | 34 +++++++- src/infra/UnifiedApiClient.ts | 14 +++- src/ui/components/StatusModal.tsx | 124 ++++++++++++++++++++++++++---- src/ui/styles/components.css | 119 ++++++++++++++++++++++++++++ 7 files changed, 308 insertions(+), 16 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 67da28a..d891a44 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1671,6 +1671,7 @@ onAccountChange={setSectionAccount} { if (composeAccount) { diff --git a/src/domain/types.ts b/src/domain/types.ts index 6ee853d..cdf1c2f 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -96,6 +96,12 @@ export type Status = { accountEmojis: CustomEmoji[]; }; +export type ThreadContext = { + ancestors: Status[]; + descendants: Status[]; + conversation?: Status[]; // Misskey 전체 대화용 (시간순 정렬) +}; + export type TimelineItem = { status: Status; }; diff --git a/src/infra/MastodonHttpClient.ts b/src/infra/MastodonHttpClient.ts index 3f1d014..5b4626f 100644 --- a/src/infra/MastodonHttpClient.ts +++ b/src/infra/MastodonHttpClient.ts @@ -1,4 +1,4 @@ -import type { Account, CustomEmoji, Status, TimelineType } from "../domain/types"; +import type { Account, CustomEmoji, Status, ThreadContext, TimelineType } from "../domain/types"; import type { CreateStatusInput, MastodonApi } from "../services/MastodonApi"; import { mapNotificationToStatus, mapStatus } from "./mastodonMapper"; @@ -132,6 +132,30 @@ export class MastodonHttpClient implements MastodonApi { return id; } + async fetchContext(account: Account, statusId: string): Promise { + const response = await fetch(`${account.instanceUrl}/api/v1/statuses/${statusId}/context`, { + headers: buildHeaders(account) + }); + if (!response.ok) { + throw new Error("스레드 컨텍스트를 불러오지 못했습니다."); + } + const data = (await response.json()) as Record; + + // 마스토돈 API 응답: { ancestors: Status[], descendants: Status[] } + const ancestors = Array.isArray(data.ancestors) + ? data.ancestors.map(mapStatus).filter((status): status is Status => status !== null) + : []; + + const descendants = Array.isArray(data.descendants) + ? data.descendants.map(mapStatus).filter((status): status is Status => status !== null) + : []; + + return { + ancestors, + descendants + }; + } + async createStatus(account: Account, input: CreateStatusInput): Promise { const response = await fetch(`${account.instanceUrl}/api/v1/statuses`, { method: "POST", diff --git a/src/infra/MisskeyHttpClient.ts b/src/infra/MisskeyHttpClient.ts index 0397ef4..ff9f964 100644 --- a/src/infra/MisskeyHttpClient.ts +++ b/src/infra/MisskeyHttpClient.ts @@ -1,4 +1,4 @@ -import type { Account, CustomEmoji, Status, TimelineType } from "../domain/types"; +import type { Account, CustomEmoji, Status, ThreadContext, TimelineType } from "../domain/types"; import type { CreateStatusInput, MastodonApi } from "../services/MastodonApi"; import { mapMisskeyNotification, mapMisskeyStatusWithInstance } from "./misskeyMapper"; @@ -158,6 +158,38 @@ export class MisskeyHttpClient implements MastodonApi { return id; } + async fetchConversation(account: Account, noteId: string): Promise { + const response = await fetch(`${normalizeInstanceUrl(account.instanceUrl)}/api/notes/conversation`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(buildBody(account, { noteId, limit: 100 })) + }); + if (!response.ok) { + throw new Error("대화를 불러오지 못했습니다."); + } + const data = (await response.json()) as unknown[]; + + // 미스키는 시간순으로 정렬된 전체 대화를 반환 + const conversation = data + .map((item) => mapMisskeyStatusWithInstance(item, account.instanceUrl)) + .filter((status): status is Status => status !== null); + + // 전체 대화에서 현재 노트를 찾아서 ancestors/descendants로 분리 + const currentIndex = conversation.findIndex(status => status.id === noteId); + const ancestors = currentIndex > 0 ? conversation.slice(0, currentIndex) : []; + const descendants = currentIndex >= 0 && currentIndex < conversation.length - 1 + ? conversation.slice(currentIndex + 1) + : []; + + return { + ancestors, + descendants, + conversation // 미스키 전용: 전체 대화 보존 + }; + } + async createStatus(account: Account, input: CreateStatusInput): Promise { const payload: Record = { text: input.status, diff --git a/src/infra/UnifiedApiClient.ts b/src/infra/UnifiedApiClient.ts index c387e5e..c9da4f5 100644 --- a/src/infra/UnifiedApiClient.ts +++ b/src/infra/UnifiedApiClient.ts @@ -1,4 +1,4 @@ -import type { Account, TimelineType } from "../domain/types"; +import type { Account, ThreadContext, TimelineType } from "../domain/types"; import type { CustomEmoji } from "../domain/types"; import type { CreateStatusInput, MastodonApi } from "../services/MastodonApi"; @@ -55,4 +55,16 @@ export class UnifiedApiClient implements MastodonApi { unreblog(account: Account, statusId: string) { return this.getClient(account).unreblog(account, statusId); } + + async fetchThreadContext(account: Account, statusId: string): Promise { + if (account.platform === "misskey") { + // MisskeyHttpClient에는 fetchConversation 메서드가 있음 + const misskeyClient = this.getClient(account) as any; + return misskeyClient.fetchConversation(account, statusId); + } else { + // MastodonHttpClient에는 fetchContext 메서드가 있음 + const mastodonClient = this.getClient(account) as any; + return mastodonClient.fetchContext(account, statusId); + } + } } diff --git a/src/ui/components/StatusModal.tsx b/src/ui/components/StatusModal.tsx index 0a013c4..ea65a1c 100644 --- a/src/ui/components/StatusModal.tsx +++ b/src/ui/components/StatusModal.tsx @@ -1,11 +1,12 @@ -import React from "react"; -import type { Account, Status } from "../../domain/types"; +import React, { useEffect, useState } from "react"; +import type { Account, Status, ThreadContext } from "../../domain/types"; import { TimelineItem } from "./TimelineItem"; import boostIconUrl from "../assets/boost-icon.svg"; export const StatusModal = ({ status, account, + api, onClose, onReply, onToggleFavourite, @@ -20,6 +21,7 @@ export const StatusModal = ({ }: { status: Status; account: Account | null; + api: any; // UnifiedApiClient onClose: () => void; onReply: (status: Status) => void; onToggleFavourite: (status: Status) => void; @@ -34,6 +36,33 @@ export const StatusModal = ({ }) => { const displayStatus = status.reblog ?? status; const boostedBy = status.reblog ? status.boostedBy : null; + + // 스레드 컨텍스트 상태 + const [threadContext, setThreadContext] = useState(null); + const [isLoadingThread, setIsLoadingThread] = useState(false); + const [threadError, setThreadError] = useState(null); + + // 스레드 컨텍스트 가져오기 + useEffect(() => { + if (!account || !api) return; + + const fetchThreadContext = async () => { + setIsLoadingThread(true); + setThreadError(null); + + try { + const context = await api.fetchThreadContext(account, displayStatus.id); + setThreadContext(context); + } catch (error) { + console.error("스레드 컨텍스트 로딩 실패:", error); + setThreadError("스레드를 불러오지 못했습니다."); + } finally { + setIsLoadingThread(false); + } + }; + + fetchThreadContext(); + }, [account, api, displayStatus.id]); return (

게시글

- +
+ {isLoadingThread && ( +
+
+ 스레드 불러오는 중 +
+ )} + +
+ {/* 스레드 컨텍스트 렌더링 */} + {threadContext && threadContext.ancestors.length > 0 && ( +
+ {threadContext.ancestors.map((ancestorStatus) => ( +
+ {})} + activeHandle={activeHandle} + activeAccountHandle={activeAccountHandle} + activeAccountUrl={activeAccountUrl} + showProfileImage={showProfileImage} + showCustomEmojis={showCustomEmojis} + showReactions={showReactions} + disableActions={!account} + /> +
+ ))} +
+ )} + {boostedBy ? (
@@ -81,6 +142,43 @@ export const StatusModal = ({ showReactions={showReactions} disableActions={!account} /> + + {/* 스레드 컨텍스트 - 후손 게시물들 */} + {threadContext && threadContext.descendants.length > 0 && ( +
+ {/* 현재 게시물 이후에 연결선 */} +
+ + {/* 후손 게시물들 */} + {threadContext.descendants.map((descendantStatus) => ( +
+
+ {})} + activeHandle={activeHandle} + activeAccountHandle={activeAccountHandle} + activeAccountUrl={activeAccountUrl} + showProfileImage={showProfileImage} + showCustomEmojis={showCustomEmojis} + showReactions={showReactions} + disableActions={!account} + /> +
+ ))} +
+ )} + + {/* 로딩 상태는 헤더에서 처리 */} + + {threadError && ( +
+ {threadError} +
+ )}
diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index a980858..8e981ef 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -1463,6 +1463,34 @@ button.ghost { font-weight: 600; } +.status-modal-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.thread-loading-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #6b5c4f; +} + +.thread-loading-spinner { + width: 12px; + height: 12px; + border: 1.5px solid #d8d1c7; + border-top-color: #3b5fa8; + border-radius: 50%; + animation: thread-loading-spin 0.8s linear infinite; +} + +.thread-loading-text { + font-size: 11px; + white-space: nowrap; +} + .status-modal-body { @@ -1638,6 +1666,97 @@ button.ghost { display: none; } +/* 스레드 컨텍스트 스타일 */ +.thread-context.thread-ancestors { + margin-bottom: 16px; /* 본문 상단 스레드: 하단 마진 */ +} + +.thread-context.thread-descendants { + margin-top: 16px; /* 본문 하단 스레드: 상단 마진 */ +} + +/* 스레드가 없을 때는 상단 여백 제거 */ +.thread-context:empty { + display: none; +} + +.thread-item { + margin-bottom: 12px; + margin-left: 24px; /* leading margin으로 계층 표현 */ +} + +/* 마지막 스레드 아이템에는 하단 여백 제거 */ +.thread-item:last-child { + margin-bottom: 0; +} + +.thread-item.thread-ancestor { + opacity: 0.85; +} + +.thread-item.thread-descendant { + opacity: 0.9; +} + +.thread-item.thread-ancestor .status, +.thread-item.thread-descendant .status { + padding: 10px; + border-radius: 10px; + border: 1px solid #f0ebe3; + background: #faf9f6; +} + +/* 스레드 커넥터 불필요하여 제거 */ + +.thread-error { + padding: 12px; + text-align: center; + font-size: 13px; + border-radius: 10px; + margin: 8px 0; /* 위아래 여백 축소 */ +} + +/* 스레드 로딩은 헤더에서 처리하여 컨텐츠 영역에 영향 주지 않음 */ + +@keyframes thread-loading-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.thread-error { + color: #9a2e1f; + background: #fdf2f0; + border: 1px solid #f5d6d0; +} + +/* 스레드 아이템 내부의 액션 버튼들은 작게 표시 */ +.thread-item .status-actions button { + height: 28px; + padding: 0 8px; + font-size: 12px; +} + +.thread-item .icon-button { + width: 32px; + height: 32px; +} + +.thread-item .status-text { + font-size: 14px; +} + +.thread-item .rich-content { + font-size: 14px; +} + +.thread-item .status-time { + font-size: 11px; +} + @media (max-width: 900px) { .timeline-column-header { flex-direction: row; From e7d206078c2218038450c08cf6b5658c21a38b1f Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Wed, 7 Jan 2026 17:57:12 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix:=20=EC=8A=A4=EB=A0=88=EB=93=9C=20API?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=20=EC=8B=9C=20=EC=98=AC=EB=B0=94=EB=A5=B8?= =?UTF-8?q?=20=EA=B3=84=EC=A0=95=20=EC=A0=95=EB=B3=B4=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatusModal에서 해당 게시글이 속한 컬럼의 계정으로 스레드 API 호출 - Misskey에서 "NO_SUCH_NOTE" 에러 발생하는 문제 해결 - TimelineItem에서 onStatusClick 시 columnAccount 정보 전달 - StatusModal에 threadAccount prop 추가하여 올바른 계정 정보 사용 - 디버그 로그 제거로 코드 깔끔하게 정리 --- src/App.tsx | 23 ++++++++++++++--------- src/ui/components/StatusModal.tsx | 6 +++++- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d891a44..5355e2a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -173,8 +173,9 @@ const TimelineSection = ({ onAddSectionRight: (sectionId: string) => void; onRemoveSection: (sectionId: string) => void; onReply: (status: Status, account: Account | null) => void; - onStatusClick: (status: Status) => void; + onStatusClick: (status: Status, columnAccount: Account | null) => void; onError: (message: string | null) => void; + columnAccount: Account | null; onMoveSection: (sectionId: string, direction: "left" | "right") => void; onCloseStatusModal: () => void; canMoveLeft: boolean; @@ -569,9 +570,9 @@ const TimelineSection = ({ onReply(item, account)} - onStatusClick={onStatusClick} - onToggleFavourite={handleToggleFavourite} + onReply={(item) => onReply(item, account)} + onStatusClick={(status) => onStatusClick(status, account)} + onToggleFavourite={handleToggleFavourite} onToggleReblog={handleToggleReblog} onDelete={handleDeleteStatus} activeHandle={ @@ -695,9 +696,9 @@ const TimelineSection = ({ onReply(item, account)} - onStatusClick={onStatusClick} - onToggleFavourite={handleToggleFavourite} + onReply={(item) => onReply(item, account)} + onStatusClick={(status) => onStatusClick(status, account)} + onToggleFavourite={handleToggleFavourite} onToggleReblog={handleToggleReblog} onDelete={handleDeleteStatus} activeHandle={ @@ -1200,8 +1201,10 @@ export const App = () => { setSelectedStatus(null); }; - const handleStatusClick = (status: Status) => { + const handleStatusClick = (status: Status, columnAccount: Account | null) => { setSelectedStatus(status); + // Status에 columnAccount 정보를 임시 저장 + (status as any).__columnAccount = columnAccount; }; const handleCloseStatusModal = () => { @@ -1437,7 +1440,8 @@ onAccountChange={setSectionAccount} onAddSectionRight={(id) => addSectionNear(id, "right")} onRemoveSection={removeSection} onReply={handleReply} - onStatusClick={handleStatusClick} + onStatusClick={handleStatusClick} + columnAccount={sectionAccount} onCloseStatusModal={handleCloseStatusModal} onError={(message) => setActionError(message || null)} onMoveSection={moveSection} @@ -1671,6 +1675,7 @@ onAccountChange={setSectionAccount} { diff --git a/src/ui/components/StatusModal.tsx b/src/ui/components/StatusModal.tsx index ea65a1c..8849f2a 100644 --- a/src/ui/components/StatusModal.tsx +++ b/src/ui/components/StatusModal.tsx @@ -6,6 +6,7 @@ import boostIconUrl from "../assets/boost-icon.svg"; export const StatusModal = ({ status, account, + threadAccount, api, onClose, onReply, @@ -21,6 +22,7 @@ export const StatusModal = ({ }: { status: Status; account: Account | null; + threadAccount: Account | null; api: any; // UnifiedApiClient onClose: () => void; onReply: (status: Status) => void; @@ -51,7 +53,9 @@ export const StatusModal = ({ setThreadError(null); try { - const context = await api.fetchThreadContext(account, displayStatus.id); + // 스레드를 가져올 때는 해당 게시글이 속한 컬럼의 계정 사용 + const targetAccount = threadAccount || account; + const context = await api.fetchThreadContext(targetAccount, displayStatus.id); setThreadContext(context); } catch (error) { console.error("스레드 컨텍스트 로딩 실패:", error); From d167a89ecedaf81a634db3561d8a6fbaa774ec26 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Thu, 8 Jan 2026 05:33:40 +0900 Subject: [PATCH 10/16] =?UTF-8?q?feat:=20=EC=9B=90=EB=B3=B8=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=97=90=EC=84=9C=20=EB=B3=B4=EA=B8=B0=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/components/TimelineItem.tsx | 177 ++++++++++++++++++++++------- src/ui/styles/components.css | 44 +++++++ 2 files changed, 177 insertions(+), 44 deletions(-) diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx index aee7139..02631a2 100644 --- a/src/ui/components/TimelineItem.tsx +++ b/src/ui/components/TimelineItem.tsx @@ -43,8 +43,10 @@ export const TimelineItem = ({ const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 }); const [baseSize, setBaseSize] = useState<{ width: number; height: number } | null>(null); const [isDragging, setIsDragging] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); const imageContainerRef = useRef(null); const imageRef = useRef(null); + const menuRef = useRef(null); const dragStateRef = useRef<{ startX: number; startY: number; originX: number; originY: number } | null>( null ); @@ -137,6 +139,28 @@ export const TimelineItem = ({ () => new Date(displayStatus.createdAt).toLocaleString(), [displayStatus.createdAt] ); + const originUrl = useMemo(() => { + if (displayStatus.url) { + return displayStatus.url; + } + const hostFromAccount = activeAccountUrl + ? (() => { + try { + return new URL(activeAccountUrl).hostname; + } catch { + return ""; + } + })() + : ""; + const hostFromHandle = activeHandle.includes("@") + ? activeHandle.split("@").pop() ?? "" + : ""; + const host = hostFromAccount || hostFromHandle; + if (!host || !displayStatus.id) { + return null; + } + return `https://${host}/notes/${displayStatus.id}`; + }, [activeAccountUrl, activeHandle, displayStatus.id, displayStatus.url]); const handleHeaderClick = useCallback( (event: React.MouseEvent) => { if (!displayStatus.accountUrl) { @@ -169,6 +193,39 @@ export const TimelineItem = ({ [displayStatus.accountUrl] ); + useEffect(() => { + if (!menuOpen) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setMenuOpen(false); + } + }; + const handleClick = (event: MouseEvent) => { + if (!menuRef.current || !(event.target instanceof Node)) { + return; + } + if (!menuRef.current.contains(event.target)) { + setMenuOpen(false); + } + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("click", handleClick); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("click", handleClick); + }; + }, [menuOpen]); + + const handleOpenOrigin = useCallback(() => { + if (!originUrl) { + return; + } + window.open(originUrl, "_blank", "noopener,noreferrer"); + setMenuOpen(false); + }, [originUrl]); + const handleStatusClick = useCallback( (event: React.MouseEvent) => { if (!onStatusClick) { @@ -523,9 +580,34 @@ export const TimelineItem = ({
) : null}
- {showProfileImage ? ( - + {showProfileImage ? ( + + {displayStatus.accountAvatarUrl ? ( + {`${displayStatus.accountName + ) : ( + + ) : null} +
- {displayStatus.accountAvatarUrl ? ( - {`${displayStatus.accountName + {displayStatus.accountUrl ? ( + + {accountNameNode} + + ) : ( + accountNameNode + )} + + + {displayStatus.accountUrl ? ( + + @{displayHandle} + + ) : ( + `@${displayHandle}` + )} + +
+
+
+ + {menuOpen ? ( + <> +
setMenuOpen(false)} + aria-hidden="true" /> - ) : ( - - ) : null} -
- - {displayStatus.accountUrl ? ( - - {accountNameNode} - - ) : ( - accountNameNode - )} - - - {displayStatus.accountUrl ? ( - - @{displayHandle} - - ) : ( - `@${displayHandle}` - )} - +
+ +
+ + ) : null}
{displayStatus.spoilerText ? ( diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 8e981ef..d9e4427 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -972,6 +972,25 @@ button.ghost { color: #6b5c4f; } +.status-header-main { + justify-content: space-between; + align-items: flex-start; + width: 100%; +} + +.status-header-info { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + flex-direction: row; +} + +.status-menu { + margin-left: auto; +} + .status-avatar, .status-account { cursor: pointer; @@ -983,6 +1002,11 @@ button.ghost { gap: 4px; } +.status header .status-header-info { + flex-direction: row; + gap: 8px; +} + .custom-emoji { width: auto; height: 1.5em; @@ -1037,11 +1061,31 @@ button.ghost { gap: 4px; } +.status header .status-header-info { + flex-direction: row; + align-items: center; +} + +.status-menu-panel { + min-width: 180px; +} + +.status-menu .icon-button { + width: 32px; + height: 32px; +} + .status header a { color: inherit; text-decoration: none; } +.status-account strong { + display: block; + line-height: 1.2; + max-height: 1.4em; +} + .time-link { color: inherit; text-decoration: none; From ae85de20ffc6dfc1fe1da2d457f9d7ec152aa257 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Thu, 8 Jan 2026 09:27:26 +0900 Subject: [PATCH 11/16] =?UTF-8?q?=EA=B8=80=20=EC=9E=91=EC=84=B1=EC=8B=9C?= =?UTF-8?q?=20=EA=B8=80=EC=9E=90=20=EC=88=98=20=EC=B9=B4=EC=9A=B4=ED=8C=85?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도메인 타입에 InstanceInfo와 CharacterCountStatus 추가 - 플랫폼별 문자 수 계산 유틸리티 구현 (Mastodon: URL 23자 계산, Misskey: 순수 텍스트) - MastodonHttpClient와 MisskeyHttpClient에 fetchInstanceInfo 메서드 추가 - UnifiedApiClient에 인스턴스 정보 조회 기능 통합 - ComposeBox 컴포넌트에 실시간 문자 수 표시 UI 추가 - 문자 수 제한 초과 시 제출 방지 및 알림 기능 - 라이트/다크 테마별 문자 수 색상 스타일링 - 인스턴스별 동적 문자 수 제한 적용 (Mastodon: 500자, Misskey: 3000자 기본값) Fixes #94 --- src/domain/types.ts | 18 +++++++++ src/infra/MastodonHttpClient.ts | 19 ++++++++- src/infra/MisskeyHttpClient.ts | 23 ++++++++++- src/infra/UnifiedApiClient.ts | 7 +++- src/services/MastodonApi.ts | 3 +- src/ui/components/ComposeBox.tsx | 69 +++++++++++++++++++++++++++++++- src/ui/styles/components.css | 20 +++++++++ src/ui/styles/theme.css | 31 ++++++++++++++ src/ui/utils/characterCount.ts | 60 +++++++++++++++++++++++++++ 9 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 src/ui/utils/characterCount.ts diff --git a/src/domain/types.ts b/src/domain/types.ts index cdf1c2f..8b7529d 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -105,3 +105,21 @@ export type ThreadContext = { export type TimelineItem = { status: Status; }; + +export type InstanceInfo = { + // 공통 필드 + uri: string; + title: string; + description?: string; + + // Mastodon 전용 + max_toot_chars?: number; + + // Misskey 전용 + maxNoteLength?: number; + + // 플랫폼 식별 + platform: AccountPlatform; +}; + +export type CharacterCountStatus = "normal" | "warning" | "limit"; diff --git a/src/infra/MastodonHttpClient.ts b/src/infra/MastodonHttpClient.ts index 5b4626f..5cd3321 100644 --- a/src/infra/MastodonHttpClient.ts +++ b/src/infra/MastodonHttpClient.ts @@ -1,4 +1,4 @@ -import type { Account, CustomEmoji, Status, ThreadContext, TimelineType } from "../domain/types"; +import type { Account, CustomEmoji, Status, ThreadContext, TimelineType, InstanceInfo } from "../domain/types"; import type { CreateStatusInput, MastodonApi } from "../services/MastodonApi"; import { mapNotificationToStatus, mapStatus } from "./mastodonMapper"; @@ -111,6 +111,23 @@ export class MastodonHttpClient implements MastodonApi { return mapCustomEmojis(data); } + async fetchInstanceInfo(account: Account): Promise { + const response = await fetch(`${account.instanceUrl}/api/v1/instance`, { + headers: buildHeaders(account) + }); + if (!response.ok) { + throw new Error("인스턴스 정보를 불러오지 못했습니다."); + } + const data = (await response.json()) as Record; + return { + uri: String(data.uri || data.domain || ""), + title: String(data.title || ""), + description: data.description ? String(data.description) : undefined, + max_toot_chars: typeof data.max_toot_chars === "number" ? data.max_toot_chars : 500, + platform: "mastodon" + }; + } + async uploadMedia(account: Account, file: File): Promise { const formData = new FormData(); formData.append("file", file); diff --git a/src/infra/MisskeyHttpClient.ts b/src/infra/MisskeyHttpClient.ts index ff9f964..6316536 100644 --- a/src/infra/MisskeyHttpClient.ts +++ b/src/infra/MisskeyHttpClient.ts @@ -1,4 +1,4 @@ -import type { Account, CustomEmoji, Status, ThreadContext, TimelineType } from "../domain/types"; +import type { Account, CustomEmoji, Status, ThreadContext, TimelineType, InstanceInfo } from "../domain/types"; import type { CreateStatusInput, MastodonApi } from "../services/MastodonApi"; import { mapMisskeyNotification, mapMisskeyStatusWithInstance } from "./misskeyMapper"; @@ -136,6 +136,27 @@ export class MisskeyHttpClient implements MastodonApi { return mapMisskeyEmojis(data); } + async fetchInstanceInfo(account: Account): Promise { + const response = await fetch(`${normalizeInstanceUrl(account.instanceUrl)}/api/meta`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(buildBody(account, {})) + }); + if (!response.ok) { + throw new Error("인스턴스 정보를 불러오지 못했습니다."); + } + const data = (await response.json()) as Record; + return { + uri: String(data.uri || ""), + title: String(data.name || ""), + description: data.description ? String(data.description) : undefined, + maxNoteLength: typeof data.maxNoteLength === "number" ? data.maxNoteLength : 3000, + platform: "misskey" + }; + } + async uploadMedia(account: Account, file: File): Promise { const formData = new FormData(); formData.append("i", account.accessToken); diff --git a/src/infra/UnifiedApiClient.ts b/src/infra/UnifiedApiClient.ts index c9da4f5..a7eb1c0 100644 --- a/src/infra/UnifiedApiClient.ts +++ b/src/infra/UnifiedApiClient.ts @@ -1,4 +1,4 @@ -import type { Account, ThreadContext, TimelineType } from "../domain/types"; +import type { Account, ThreadContext, TimelineType, InstanceInfo } from "../domain/types"; import type { CustomEmoji } from "../domain/types"; import type { CreateStatusInput, MastodonApi } from "../services/MastodonApi"; @@ -28,6 +28,11 @@ export class UnifiedApiClient implements MastodonApi { return this.getClient(account).fetchCustomEmojis(account); } + fetchInstanceInfo(account: Account): Promise { + const client = this.getClient(account) as any; + return client.fetchInstanceInfo(account); + } + uploadMedia(account: Account, file: File) { return this.getClient(account).uploadMedia(account, file); } diff --git a/src/services/MastodonApi.ts b/src/services/MastodonApi.ts index b2cc7e3..702a00c 100644 --- a/src/services/MastodonApi.ts +++ b/src/services/MastodonApi.ts @@ -1,4 +1,4 @@ -import type { Account, Status, TimelineType, Visibility } from "../domain/types"; +import type { Account, Status, TimelineType, Visibility, InstanceInfo } from "../domain/types"; import type { CustomEmoji } from "../domain/types"; export type CreateStatusInput = { @@ -21,4 +21,5 @@ export interface MastodonApi { unfavourite(account: Account, statusId: string): Promise; reblog(account: Account, statusId: string): Promise; unreblog(account: Account, statusId: string): Promise; + fetchInstanceInfo(account: Account): Promise; } diff --git a/src/ui/components/ComposeBox.tsx b/src/ui/components/ComposeBox.tsx index 49d77a3..13fabb5 100644 --- a/src/ui/components/ComposeBox.tsx +++ b/src/ui/components/ComposeBox.tsx @@ -1,6 +1,13 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Account, CustomEmoji, Visibility } from "../../domain/types"; import type { MastodonApi } from "../../services/MastodonApi"; +import { + calculateCharacterCount, + getCharacterLimit, + getCharacterCountStatus, + getCharacterCountClassName, + getDefaultCharacterLimit +} from "../utils/characterCount"; const VISIBILITY_KEY = "textodon.compose.visibility"; @@ -100,6 +107,10 @@ export const ComposeBox = ({ const [recentByInstance, setRecentByInstance] = useState>({}); const [expandedByInstance, setExpandedByInstance] = useState>>({}); const [recentOpen, setRecentOpen] = useState(true); + + // 문자 수 관련 상태 + const [characterLimit, setCharacterLimit] = useState(null); + const [instanceLoading, setInstanceLoading] = useState(false); const activeImage = useMemo( () => attachments.find((item) => item.id === activeImageId) ?? null, [attachments, activeImageId] @@ -200,10 +211,56 @@ export const ComposeBox = ({ localStorage.setItem(VISIBILITY_KEY, visibility); }, [visibility]); + // 계정 변경 시 인스턴스 정보 로드 + useEffect(() => { + if (!account) { + setCharacterLimit(null); + return; + } + + const loadInstanceInfo = async () => { + try { + setInstanceLoading(true); + const instanceInfo = await api.fetchInstanceInfo(account); + const limit = getCharacterLimit(instanceInfo); + setCharacterLimit(limit); + } catch (error) { + console.error("인스턴스 정보 로드 실패:", error); + // fallback: 기본값 사용 + const fallbackLimit = getDefaultCharacterLimit(account.platform); + setCharacterLimit(fallbackLimit); + } finally { + setInstanceLoading(false); + } + }; + + loadInstanceInfo(); + }, [account, api]); + + // 현재 문자 수 계산 + const currentCharCount = useMemo(() => { + if (!account) return 0; + const fullText = (cwEnabled ? cwText + "\n" : "") + text; + return calculateCharacterCount(fullText, account.platform); + }, [text, cwText, cwEnabled, account]); + + // 문자 수 상태 계산 + const charCountStatus = useMemo(() => { + if (!characterLimit) return "normal"; + return getCharacterCountStatus(currentCharCount, characterLimit); + }, [currentCharCount, characterLimit]); + const submitPost = async () => { if (!text.trim() || isSubmitting) { return; } + + // 문자 수 제한 검사 + if (characterLimit && currentCharCount > characterLimit) { + alert(`글자 수 제한(${characterLimit.toLocaleString()}자)을 초과했습니다.`); + return; + } + setIsSubmitting(true); try { const ok = await onSubmit({ @@ -484,6 +541,16 @@ export const ComposeBox = ({ ))} + + {/* 문자 수 표시 */} + {characterLimit && ( +
+ + {currentCharCount.toLocaleString()} / {characterLimit.toLocaleString()} + +
+ )} +
) : null} -