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
35 changes: 30 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,13 @@ const TimelineSection = ({
const actionsDisabled = timelineType === "notifications";
const emptyMessage = timelineType === "notifications" ? "표시할 알림이 없습니다." : "표시할 글이 없습니다.";

useEffect(() => {
if (!timeline.error) {
return;
}
showToast(timeline.error, { tone: "error" });
}, [showToast, timeline.error]);

useEffect(() => {
const el = scrollRef.current;
if (!el) {
Expand Down Expand Up @@ -475,6 +482,13 @@ const TimelineSection = ({
refreshNotifications();
}, [notificationsOpen, refreshNotifications]);

useEffect(() => {
if (!notificationsError) {
return;
}
showToast(notificationsError, { tone: "error" });
}, [notificationsError, showToast]);

const handleToggleFavourite = async (status: Status) => {
if (!account) {
onError("계정을 선택해주세요.");
Expand Down Expand Up @@ -649,7 +663,6 @@ const TimelineSection = ({
<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-body" ref={notificationScrollRef}>
{notificationsError ? <p className="error">{notificationsError}</p> : null}
{notificationItems.length === 0 && !notificationsLoading ? (
<p className="empty">표시할 알림이 없습니다.</p>
) : null}
Expand Down Expand Up @@ -785,7 +798,6 @@ const TimelineSection = ({
</div>
<div className="timeline-column-body" ref={scrollRef}>
{!account ? <p className="empty">계정을 선택하면 타임라인을 불러옵니다.</p> : null}
{account && timeline.error ? <p className="error">{timeline.error}</p> : null}
{account && timeline.items.length === 0 && !timeline.loading ? (
<p className="empty">{emptyMessage}</p>
) : null}
Expand Down Expand Up @@ -836,10 +848,14 @@ const TimelineSection = ({
);
};

type ThemeMode = "default" | "christmas" | "sky-pink" | "monochrome";
type ThemeMode = "default" | "christmas" | "sky-pink" | "monochrome" | "matcha-core";

const isThemeMode = (value: string): value is ThemeMode =>
value === "default" || value === "christmas" || value === "sky-pink" || value === "monochrome";
value === "default" ||
value === "christmas" ||
value === "sky-pink" ||
value === "monochrome" ||
value === "matcha-core";

const getStoredTheme = (): ThemeMode => {
const storedTheme = localStorage.getItem("textodon.theme");
Expand Down Expand Up @@ -888,6 +904,7 @@ export const App = () => {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [mobileComposeOpen, setMobileComposeOpen] = useState(false);
const { services, accountsState } = useAppContext();
const { showToast } = useToast();
const [sections, setSections] = useState<TimelineSectionConfig[]>(() => {
try {
const raw = localStorage.getItem(SECTION_STORAGE_KEY);
Expand Down Expand Up @@ -956,6 +973,14 @@ export const App = () => {
const previousAccountIds = useRef<Set<string>>(new Set());
const hasAccounts = accountsState.accounts.length > 0;

useEffect(() => {
if (!actionError) {
return;
}
showToast(actionError, { tone: "error" });
setActionError(null);
}, [actionError, showToast]);

const registerTimelineListener = useCallback((accountId: string, listener: (status: Status) => void) => {
const next = new Map(timelineListeners.current);
const existing = next.get(accountId) ?? new Set();
Expand Down Expand Up @@ -1669,7 +1694,6 @@ export const App = () => {
{hasAccounts ? (
<section className="main-column">
{oauthLoading ? <p className="empty">OAuth 인증 중...</p> : null}
{actionError ? <p className="error">{actionError}</p> : null}
{route === "home" ? (
<section className="panel">
{sections.length > 0 ? (
Expand Down Expand Up @@ -1881,6 +1905,7 @@ onAccountChange={setSectionAccount}
<option value="christmas">크리스마스</option>
<option value="sky-pink">하늘핑크</option>
<option value="monochrome">모노톤</option>
<option value="matcha-core">말차코어</option>
</select>
</div>
<div className="settings-item">
Expand Down
9 changes: 4 additions & 5 deletions src/ui/components/AccountAdd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@ import React, { useState } from "react";
import type { OAuthClient } from "../../services/OAuthClient";
import { normalizeInstanceUrl } from "../utils/account";
import { createOauthState, loadRegisteredApp, saveRegisteredApp, storePendingOAuth } from "../utils/oauth";
import { useToast } from "../state/ToastContext";

export const AccountAdd = ({
oauth
}: {
oauth: OAuthClient;
}) => {
const [instanceUrl, setInstanceUrl] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showForm, setShowForm] = useState(false);
const { showToast } = useToast();
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);

const normalizedUrl = normalizeInstanceUrl(instanceUrl);
if (!normalizedUrl) {
setError("서버 주소를 입력해주세요.");
showToast("서버 주소를 입력해주세요.", { tone: "error" });
return;
}

Expand All @@ -42,7 +42,7 @@ export const AccountAdd = ({
const authorizeUrl = oauth.buildAuthorizeUrl(registered, state);
window.location.assign(authorizeUrl);
} catch (err) {
setError(err instanceof Error ? err.message : "OAuth 연결에 실패했습니다.");
showToast(err instanceof Error ? err.message : "OAuth 연결에 실패했습니다.", { tone: "error" });
} finally {
setLoading(false);
}
Expand Down Expand Up @@ -83,7 +83,6 @@ export const AccountAdd = ({
onChange={(event) => setInstanceUrl(event.target.value)}
/>
</label>
{error ? <p className="error">{error}</p> : null}
<button type="submit" disabled={loading}>
{loading ? "연결 중..." : "OAuth로 연결"}
</button>
Expand Down
18 changes: 17 additions & 1 deletion src/ui/components/ComposeBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Account, Visibility } from "../../domain/types";
import type { MastodonApi } from "../../services/MastodonApi";
import { useEmojiManager, type EmojiItem } from "../hooks/useEmojiManager";
import { useImageZoom } from "../hooks/useImageZoom";
import { useToast } from "../state/ToastContext";
import {
calculateCharacterCount,
getCharacterLimit,
Expand Down Expand Up @@ -88,6 +89,8 @@ export const ComposeBox = ({
} = useImageZoom(imageContainerRef, imageRef);
const [emojiPanelOpen, setEmojiPanelOpen] = useState(false);
const [recentOpen, setRecentOpen] = useState(true);
const { showToast } = useToast();
const lastEmojiErrorRef = useRef<string | null>(null);

// 문자 수 관련 상태
const [characterLimit, setCharacterLimit] = useState<number | null>(null);
Expand All @@ -108,6 +111,19 @@ export const ComposeBox = ({
searchEmojis
} = useEmojiManager(account, api, false);

useEffect(() => {
if (emojiStatus !== "error") {
lastEmojiErrorRef.current = null;
return;
}
const message = emojiError ?? "이모지를 불러오지 못했습니다.";
if (message === lastEmojiErrorRef.current) {
return;
}
lastEmojiErrorRef.current = message;
showToast(message, { tone: "error" });
}, [emojiError, emojiStatus, showToast]);

const activeImage = useMemo(
() => attachments.find((item) => item.id === activeImageId) ?? null,
[attachments, activeImageId]
Expand Down Expand Up @@ -200,7 +216,7 @@ export const ComposeBox = ({

// 문자 수 제한 검사
if (characterLimit && currentCharCount > characterLimit) {
alert(`글자 수 제한(${characterLimit.toLocaleString()}자)을 초과했습니다.`);
showToast(`글자 수 제한(${characterLimit.toLocaleString()}자)을 초과했습니다.`, { tone: "error" });
return;
}

Expand Down
28 changes: 24 additions & 4 deletions src/ui/components/ProfileModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,30 @@ export const ProfileModal = ({
};
}, [account, api, targetAccountId]);

useEffect(() => {
if (!profileError) {
return;
}
showToast(profileError, { tone: "error" });
setProfileError(null);
}, [profileError, showToast]);

useEffect(() => {
if (!followError) {
return;
}
showToast(followError, { tone: "error" });
setFollowError(null);
}, [followError, showToast]);

useEffect(() => {
if (!itemsError) {
return;
}
showToast(itemsError, { tone: "error" });
setItemsError(null);
}, [itemsError, showToast]);

const updateItem = useCallback((next: Status) => {
setItems((current) => current.map((item) => (item.id === next.id ? next : item)));
}, []);
Expand Down Expand Up @@ -605,7 +629,6 @@ export const ProfileModal = ({
const message = error instanceof Error ? error.message : fallbackMessage;
setRelationship(previous);
setFollowError(message);
showToast(message, { tone: "error" });
} finally {
setFollowLoading(false);
}
Expand Down Expand Up @@ -908,9 +931,7 @@ export const ProfileModal = ({
) : null}
</div>
</div>
{followError ? <p className="error">{followError}</p> : null}
{profileLoading ? <p className="empty">프로필을 불러오는 중...</p> : null}
{profileError ? <p className="error">{profileError}</p> : null}
{bioContent
? bioContent.type === "html"
? <div className="profile-bio" dangerouslySetInnerHTML={{ __html: bioContent.value }} />
Expand All @@ -929,7 +950,6 @@ export const ProfileModal = ({
</section>
<section className="profile-posts">
<h4>작성한 글</h4>
{itemsError ? <p className="error">{itemsError}</p> : null}
{itemsLoading && items.length === 0 ? <p className="empty">게시글을 불러오는 중...</p> : null}
{!itemsLoading && items.length === 0 ? <p className="empty">표시할 글이 없습니다.</p> : null}
{items.length > 0 ? (
Expand Down
16 changes: 16 additions & 0 deletions src/ui/components/ReactionPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Account, ReactionInput } from "../../domain/types";
import type { MastodonApi } from "../../services/MastodonApi";
import { useClickOutside } from "../hooks/useClickOutside";
import { useEmojiManager, type EmojiItem } from "../hooks/useEmojiManager";
import { useToast } from "../state/ToastContext";

export const ReactionPicker = ({
account,
Expand All @@ -21,6 +22,8 @@ export const ReactionPicker = ({
const [emojiSearchQuery, setEmojiSearchQuery] = useState("");
const buttonRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const { showToast } = useToast();
const lastEmojiErrorRef = useRef<string | null>(null);

// useEmojiManager 훅 사용
const {
Expand All @@ -37,6 +40,19 @@ export const ReactionPicker = ({
searchEmojis
} = useEmojiManager(account, api, false);

useEffect(() => {
if (emojiStatus !== "error") {
lastEmojiErrorRef.current = null;
return;
}
const message = emojiError ?? "이모지를 불러오지 못했습니다.";
if (message === lastEmojiErrorRef.current) {
return;
}
lastEmojiErrorRef.current = message;
showToast(message, { tone: "error" });
}, [emojiError, emojiStatus, showToast]);

const emojiSearchResults = useMemo(() => {
if (!emojiSearchQuery.trim()) {
return [];
Expand Down
15 changes: 10 additions & 5 deletions src/ui/components/StatusModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Account, CustomEmoji, Status, ThreadContext } from "../../domain/t
import type { MastodonApi } from "../../services/MastodonApi";
import { TimelineItem } from "./TimelineItem";
import BoostIcon from "../assets/boost-icon.svg?react";
import { useToast } from "../state/ToastContext";

export const StatusModal = ({
status,
Expand Down Expand Up @@ -120,6 +121,7 @@ export const StatusModal = ({
const [threadContext, setThreadContext] = useState<ThreadContext | null>(null);
const [isLoadingThread, setIsLoadingThread] = useState(false);
const [threadError, setThreadError] = useState<string | null>(null);
const { showToast } = useToast();

// 스레드 컨텍스트 가져오기
useEffect(() => {
Expand All @@ -145,6 +147,14 @@ export const StatusModal = ({
fetchThreadContext();
}, [account, api, displayStatus.id]);

useEffect(() => {
if (!threadError) {
return;
}
showToast(threadError, { tone: "error" });
setThreadError(null);
}, [showToast, threadError]);

return (
<div
className="status-modal"
Expand Down Expand Up @@ -268,11 +278,6 @@ export const StatusModal = ({

{/* 로딩 상태는 헤더에서 처리 */}

{threadError && (
<div className="thread-error">
<span>{threadError}</span>
</div>
)}
</div>
</div>
</div>
Expand Down
Loading
Loading