Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
- 접근성: 버튼/아이콘에 `aria-label`, 텍스트 대체를 제공한다.
- 배포 용어: Cloudflare Pages 배포는 production, GitHub Pages 배포는 beta로 칭한다.

## 인코딩/텍스트 품질
- 모든 소스 파일은 UTF-8 (BOM 없음)으로 저장한다.
- 커밋 전 텍스트 깨짐 검사: `rg -n "�" src`, `rg -n "[\u00C0-\u00FF]" src`
- UI 문자열에 한글 외의 CJK 문자가 섞여 있으면 원인을 확인하고 교체한다.

## 작업 플로우
- 작업 시작 전: `develop` 최신화 → 새 feature 브랜치 생성.
- 새로운 작업은 항상 `develop` 최신화 후 `feature/{기능-이름}` 브랜치에서 시작한다.
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.MD
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,8 @@
- PR 본문은 마크다운이 깨지지 않도록 멀티라인(heredoc) 방식으로 작성한다.
- PR 제목/본문의 한글 인코딩이 깨지지 않도록 UTF-8로 작성·확인한다.
- PR URL은 코드 블록 없이 클릭 가능한 일반 텍스트로 제공한다.

## 인코딩/텍스트 품질
- 모든 소스 파일은 UTF-8 (BOM 없음)으로 저장한다.
- 커밋 전 텍스트 깨짐 검사: `rg -n "�" src`, `rg -n "[\u00C0-\u00FF]" src`
- UI 문자열에 한글 외의 CJK 문자가 섞여 있으면 원인을 확인하고 교체한다.
78 changes: 77 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
<!doctype html>
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/src/ui/assets/textodon-icon-blue.png" type="image/png" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' https: data: blob:; connect-src https: wss:; style-src 'self' 'unsafe-inline'; script-src 'self';"
content="default-src 'self'; img-src 'self' https: data: blob:; connect-src https: wss:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; script-src 'self';"
/>
<meta property="og:title" content="Deck" />
<meta property="og:description" content="오픈소스 페디버스 클라이언트" />
<meta property="og:description" content="오픈소스 페디버스 클라이언트" />
<meta property="og:image" content="/src/ui/assets/textodon-icon-blue.png" />
<meta property="og:type" content="website" />
<title>Deck</title>
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.0"
"vite": "^5.4.0",
"vite-plugin-svgr": "^4.5.0"
}
}
4 changes: 2 additions & 2 deletions src/infra/MastodonHttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export class MastodonHttpClient implements MastodonApi {
headers: buildHeaders(account)
});
if (!response.ok) {
throw new Error("?„로???•ë³´ë¥?불러?¤ì? 못했?µë‹ˆ??");
throw new Error("프로필 정보를 불러오지 못했습니다.");
}
const data = (await response.json()) as unknown;
return mapAccountProfile(data);
Expand Down Expand Up @@ -249,7 +249,7 @@ export class MastodonHttpClient implements MastodonApi {
headers: buildHeaders(account)
});
if (!response.ok) {
throw new Error("?„로???글?„ë¥?불러?¤ì? 못했?µë‹ˆ??");
throw new Error("게시글을 불러오지 못했습니다.");
}
const data = (await response.json()) as unknown[];
return data.map(mapStatus);
Expand Down
4 changes: 2 additions & 2 deletions src/infra/MisskeyHttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ export class MisskeyHttpClient implements MastodonApi {
body: JSON.stringify(buildBody(account, { userId: accountId }))
});
if (!response.ok) {
throw new Error("?„로???•ë³´ë¥?불러?¤ì? 못했?µë‹ˆ??");
throw new Error("프로필 정보를 불러오지 못했습니다.");
}
const data = (await response.json()) as unknown;
return mapMisskeyUserProfile(data, account.instanceUrl);
Expand Down Expand Up @@ -289,7 +289,7 @@ export class MisskeyHttpClient implements MastodonApi {
)
});
if (!response.ok) {
throw new Error("?„로???글?„ë¥?불러?¤ì? 못했?µë‹ˆ??");
throw new Error("게시글을 불러오지 못했습니다.");
}
const data = (await response.json()) as unknown[];
return data.map((item) => mapMisskeyStatusWithInstance(item, account.instanceUrl));
Expand Down
27 changes: 26 additions & 1 deletion src/ui/components/ProfileModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { MastodonApi } from "../../services/MastodonApi";
import { sanitizeHtml } from "../utils/htmlSanitizer";
import { formatHandle } from "../utils/account";
import { isPlainUrl, renderTextWithLinks } from "../utils/linkify";
import { renderMarkdown } from "../utils/markdown";
import { useClickOutside } from "../hooks/useClickOutside";
import { TimelineItem } from "./TimelineItem";

Expand All @@ -29,6 +30,21 @@ const buildFallbackProfile = (status: Status): UserProfile => ({
});

const hasHtmlTags = (value: string): boolean => /<[^>]+>/.test(value);
const hasMarkdownSyntax = (value: string): boolean => {
if (!value.trim()) {
return false;
}
const patterns = [
/^#{1,3}\s/m,
/^-\s+/m,
/```/,
/\*\*[^*]+\*\*/,
/`[^`]+`/,
/\[[^\]]+\]\([^)]+\)/,
/!\[[^\]]*\]\([^)]+\)/
];
return patterns.some((pattern) => pattern.test(value));
};

const buildEmojiMap = (emojis: CustomEmoji[]): Map<string, string> =>
new Map(emojis.map((emoji) => [emoji.shortcode, emoji.url]));
Expand Down Expand Up @@ -407,13 +423,22 @@ export const ProfileModal = ({
if (hasHtmlTags(value)) {
return <span dangerouslySetInnerHTML={{ __html: sanitizeHtml(value) }} />;
}
if (hasMarkdownSyntax(value)) {
const markdownEmojiMap = showCustomEmojis ? emojiMap : undefined;
return (
<div
className="profile-field-markdown"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(renderMarkdown(value, markdownEmojiMap)) }}
/>
);
}
const nodes = renderTextWithEmojis(value, `profile-field-${index}`, false);
if (isPlainUrl(value)) {
return <span className="profile-field-link">{nodes}</span>;
}
return <span>{nodes}</span>;
},
[renderTextWithEmojis]
[emojiMap, renderTextWithEmojis, showCustomEmojis]
);

const renderFieldLabel = useCallback(
Expand Down
4 changes: 2 additions & 2 deletions src/ui/components/StatusModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from "react";
import type { Account, Status, ThreadContext } from "../../domain/types";
import type { MastodonApi } from "../../services/MastodonApi";
import { TimelineItem } from "./TimelineItem";
import boostIconUrl from "../assets/boost-icon.svg";
import BoostIcon from "../assets/boost-icon.svg?react";

export const StatusModal = ({
status,
Expand Down Expand Up @@ -146,7 +146,7 @@ export const StatusModal = ({

{boostedBy ? (
<div className="boosted-by">
<img src={boostIconUrl} alt="" aria-hidden="true" />
<BoostIcon aria-hidden="true" focusable="false" />
<span>{boostedBy.name || boostedBy.handle} 님이 부스트함</span>
</div>
) : null}
Expand Down
28 changes: 14 additions & 14 deletions src/ui/components/TimelineItem.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Account, CustomEmoji, Mention, ReactionInput, Status } from "../../domain/types";
import type { MastodonApi } from "../../services/MastodonApi";
import { sanitizeHtml } from "../utils/htmlSanitizer";
import { renderTextWithLinks } from "../utils/linkify";
import boostIconUrl from "../assets/boost-icon.svg";
import replyIconUrl from "../assets/reply-icon.svg";
import trashIconUrl from "../assets/trash-icon.svg";
import BoostIcon from "../assets/boost-icon.svg?react";
import ReplyIcon from "../assets/reply-icon.svg?react";
import TrashIcon from "../assets/trash-icon.svg?react";
import { ReactionPicker } from "./ReactionPicker";
import { useClickOutside } from "../hooks/useClickOutside";
import { useImageZoom } from "../hooks/useImageZoom";
Expand Down Expand Up @@ -74,7 +74,7 @@ export const TimelineItem = ({
const menuRef = useRef<HTMLDivElement | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);

// useImageZoom ???ъ슜
// useImageZoom 사용
const {
zoom: imageZoom,
offset: imageOffset,
Expand Down Expand Up @@ -203,8 +203,8 @@ export const TimelineItem = ({
return null;
}
const actorName =
notification.actor.name || notificationActorHandle || notification.actor.handle || "?????놁쓬";
return `${actorName} ?섏씠 ${notification.label}`;
notification.actor.name || notificationActorHandle || notification.actor.handle || "알 수 없는 사용자";
return `${actorName} 님이 ${notification.label}`;
}, [notification, notificationActorHandle]);
const timestamp = useMemo(
() => new Date(displayStatus.createdAt).toLocaleString(),
Expand Down Expand Up @@ -682,13 +682,13 @@ export const TimelineItem = ({
) : null}
{boostedLabel ? (
<div className="boosted-by">
<img src={boostIconUrl} alt="" aria-hidden="true" />
<BoostIcon aria-hidden="true" focusable="false" />
<span>{boostedLabel}</span>
</div>
) : null}
{mentionNames ? (
<div className="reply-info">
<img src={replyIconUrl} alt="" aria-hidden="true" />
<ReplyIcon aria-hidden="true" focusable="false" />
<span>{mentionNames}에게 보낸 답글</span>
</div>
) : null}
Expand Down Expand Up @@ -909,7 +909,7 @@ export const TimelineItem = ({
</div>
{actionsEnabled && canDelete ? (
<button type="button" className="delete-button" onClick={() => setShowDeleteConfirm(true)}>
<img src={trashIconUrl} alt="" aria-hidden="true" />
<TrashIcon aria-hidden="true" focusable="false" />
</button>
) : null}
</footer>
Expand Down Expand Up @@ -988,14 +988,14 @@ export const TimelineItem = ({
className="image-modal-close"
onClick={() => setActiveImageIndex(null)}
>
?リ린
닫기
</button>
{attachments.length > 1 ? (
<button
type="button"
className="image-modal-nav image-modal-nav-prev"
onClick={goToPrevImage}
aria-label="?댁쟾 ?대?吏€"
aria-label="이전 이미지"
>
<svg viewBox="0 0 24 24" width="24" height="24">
<polyline points="15 18 9 12 15 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
Expand All @@ -1007,7 +1007,7 @@ export const TimelineItem = ({
type="button"
className="image-modal-nav image-modal-nav-next"
onClick={goToNextImage}
aria-label="?ㅼ쓬 ?대?吏€"
aria-label="다음 이미지"
>
<svg viewBox="0 0 24 24" width="24" height="24">
<polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
Expand All @@ -1016,7 +1016,7 @@ export const TimelineItem = ({
) : null}
<img
src={activeImageUrl}
alt="泥⑤? ?대?吏€ ?먮낯"
alt="선택한 이미지 원본"
ref={imageRef}
draggable={false}
className={isDragging ? "is-dragging" : undefined}
Expand Down
6 changes: 4 additions & 2 deletions src/ui/styles/base.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
:root {
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css");

:root {
color-scheme: light dark;
font-family: system-ui, -apple-system, "Segoe UI", "Noto Sans", "Helvetica Neue", Arial, sans-serif;
font-family: "Pretendard", system-ui, -apple-system, "Segoe UI", "Noto Sans", "Helvetica Neue", Arial, sans-serif;
background: var(--page-background);
color: var(--color-text-primary);
--page-background: radial-gradient(circle at top left, #f0efe9, #f9f7f2 60%, #ffffff);
Expand Down
45 changes: 42 additions & 3 deletions src/ui/styles/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -1396,7 +1396,8 @@ button.ghost {
background: var(--color-delete-button-bg);
}

.delete-button img {
.delete-button img,
.delete-button svg {
width: 18px;
height: 18px;
}
Expand Down Expand Up @@ -1992,6 +1993,42 @@ button.ghost {
color: var(--color-action-bg);
}

.profile-field-markdown p {
margin: 0;
}

.profile-field-markdown ul,
.profile-field-markdown ol {
margin: 0;
padding-left: 18px;
}

.profile-field-markdown li {
margin: 2px 0;
}

.profile-field-markdown code {
padding: 2px 4px;
border-radius: 4px;
background: var(--color-readme-code-bg);
}

.profile-field-markdown pre {
margin: 6px 0;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--color-readme-code-border);
background: var(--color-readme-code-bg);
overflow-x: auto;
}

.profile-field-markdown img {
max-width: 100%;
height: auto;
border-radius: 8px;
border: 1px solid var(--color-readme-code-border);
}

.profile-posts {
padding: 0 16px 20px;
display: flex;
Expand Down Expand Up @@ -2020,7 +2057,8 @@ button.ghost {
margin-bottom: 8px;
}

.boosted-by img {
.boosted-by img,
.boosted-by svg {
width: 18px;
height: 18px;
}
Expand Down Expand Up @@ -2091,7 +2129,8 @@ button.ghost {
transform: translateX(20px);
}

.reply-info img {
.reply-info img,
.reply-info svg {
width: 18px;
height: 18px;
}
Expand Down
10 changes: 10 additions & 0 deletions src/ui/utils/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,14 @@ describe("renderMarkdown", () => {
'<p><a href="https://example.com/a%28b" target="_blank" rel="noreferrer">go</a>)</p>'
);
});

it("renders custom emojis outside inline code", () => {
const input = "hi :wave: `:wave:`";
const emojiMap = new Map([["wave", "https://example.com/wave.png"]]);
const output = renderMarkdown(input, emojiMap);

expect(output).toBe(
'<p>hi <img src="https://example.com/wave.png" alt=":wave:" class="custom-emoji" loading="lazy" /> <code>:wave:</code></p>'
);
});
});
Loading
Loading