Skip to content

Commit 50cc703

Browse files
authored
Merge pull request #75 from JECT-Study/hotfix/74-Various-fixes
Hotfix/74 various fixes
2 parents 3fd17c1 + b742da3 commit 50cc703

15 files changed

Lines changed: 834 additions & 37 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public/workbox-*.js
4747

4848
# 작업 산출물·하네스·IDE (커밋 제외)
4949
/_workspace/
50+
/docs/
5051
/.claude/
5152
/.ai-context/
5253
/.idea/

public/icon-private-lock.svg

Lines changed: 3 additions & 0 deletions
Loading

src/app/art/[id]/edit/page.tsx

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
5+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6+
import Image from "next/image";
7+
import { useParams, useRouter } from "next/navigation";
8+
9+
import DatePicker from "@/components/archive-form/DayPicker";
10+
import Dropdown from "@/components/archive-form/Dropdown";
11+
import Input from "@/components/archive-form/Input";
12+
import Label from "@/components/archive-form/Label";
13+
import RegionSelect from "@/components/archive-form/RegionSelect";
14+
import SizeInput from "@/components/archive-form/SizeInput";
15+
import Textarea from "@/components/archive-form/Textarea";
16+
import ToggleButton from "@/components/archive-form/ToggleButton";
17+
import Header from "@/components/common/Header";
18+
import { ART_TYPES } from "@/constants/art";
19+
import { useRequireAuth } from "@/hooks/useRequireAuth";
20+
import { getMyArtworkDetail, updateArtwork } from "@/services/artworks";
21+
import type { ArtworkDetail } from "@/types/archiveDetail";
22+
import { normalizeImageUrl } from "@/utils/normalizeImageUrl";
23+
24+
function FieldWrapper({ children }: { children: React.ReactNode }) {
25+
return <div className="flex flex-col gap-2">{children}</div>;
26+
}
27+
28+
function toOptionalNumber(value: string) {
29+
return value.trim() === "" ? undefined : Number(value);
30+
}
31+
32+
function toDate(value: string | null) {
33+
return value ? new Date(`${value}T00:00:00`) : undefined;
34+
}
35+
36+
function toDateString(value: Date | undefined) {
37+
if (!value) return undefined;
38+
const year = value.getFullYear();
39+
const month = String(value.getMonth() + 1).padStart(2, "0");
40+
const day = String(value.getDate()).padStart(2, "0");
41+
return `${year}-${month}-${day}`;
42+
}
43+
44+
function getErrorMessage(error: unknown) {
45+
return error instanceof Error ? error.message : "작품 수정에 실패했습니다.";
46+
}
47+
48+
function ArtEditForm({ artwork, artworkId }: { artwork: ArtworkDetail; artworkId: string }) {
49+
const router = useRouter();
50+
const queryClient = useQueryClient();
51+
const [artType, setArtType] = useState(artwork.artworkType ?? "");
52+
const [title, setTitle] = useState(artwork.title ?? "");
53+
const [description, setDescription] = useState(artwork.description ?? "");
54+
const [date, setDate] = useState<Date | undefined>(() => toDate(artwork.createdDate));
55+
const [selectedRegions, setSelectedRegions] = useState<string[]>(artwork.availableRegions ?? []);
56+
const [width, setWidth] = useState(artwork.widthCm ? String(artwork.widthCm) : "");
57+
const [depth, setDepth] = useState(artwork.depthCm ? String(artwork.depthCm) : "");
58+
const [height, setHeight] = useState(artwork.heightCm ? String(artwork.heightCm) : "");
59+
const [notes, setNotes] = useState(artwork.caution ?? "");
60+
const [isPublic, setIsPublic] = useState(artwork.status === "PUBLISHED");
61+
const [submitErrorMessage, setSubmitErrorMessage] = useState<string | null>(null);
62+
63+
const updateMutation = useMutation({
64+
mutationFn: () =>
65+
updateArtwork(artworkId, {
66+
title: title.trim(),
67+
artworkType: artType,
68+
description: description.trim(),
69+
caution: notes.trim(),
70+
sizeType: artwork.sizeType ?? "STANDARD",
71+
widthCm: toOptionalNumber(width),
72+
heightCm: toOptionalNumber(height),
73+
depthCm: toOptionalNumber(depth),
74+
createdDate: toDateString(date),
75+
isPublic,
76+
availableRegions: selectedRegions,
77+
}),
78+
onSuccess: () => {
79+
void queryClient.invalidateQueries({ queryKey: ["mypage", "feed"] });
80+
void queryClient.invalidateQueries({ queryKey: ["artwork-detail", artworkId] });
81+
void queryClient.invalidateQueries({ queryKey: ["mypage", "artwork", artworkId] });
82+
router.replace("/mypage/feed");
83+
},
84+
onError: error => {
85+
setSubmitErrorMessage(getErrorMessage(error));
86+
},
87+
});
88+
89+
const isFormValid =
90+
title.trim() !== "" &&
91+
artType !== "" &&
92+
description.trim() !== "" &&
93+
description.length <= 500 &&
94+
notes.length <= 500;
95+
96+
return (
97+
<>
98+
<section className="flex flex-col gap-6 px-5 py-6 pb-32">
99+
<FieldWrapper>
100+
<Label required>사진</Label>
101+
<div className="flex flex-wrap gap-3">
102+
{artwork.imageUrls.map((url, index) => {
103+
const imageUrl = normalizeImageUrl(url);
104+
if (!imageUrl) return null;
105+
return (
106+
<div
107+
key={`${imageUrl}-${index}`}
108+
className="border-border-primary relative h-18 w-18 overflow-hidden rounded-sm border"
109+
>
110+
<Image
111+
src={imageUrl}
112+
alt={`작품 이미지 ${index + 1}`}
113+
fill
114+
sizes="72px"
115+
unoptimized
116+
className="object-cover"
117+
/>
118+
</div>
119+
);
120+
})}
121+
</div>
122+
</FieldWrapper>
123+
124+
<FieldWrapper>
125+
<Label required>작품 유형</Label>
126+
<Dropdown
127+
required
128+
placeholder="작품 유형을 선택해주세요."
129+
options={ART_TYPES}
130+
value={artType}
131+
onChange={setArtType}
132+
/>
133+
</FieldWrapper>
134+
135+
<FieldWrapper>
136+
<Label required>작품명 (최대 10자)</Label>
137+
<Input
138+
required
139+
placeholder="작품명을 작성해주세요."
140+
deleteButton
141+
maxLength={10}
142+
value={title}
143+
onChange={setTitle}
144+
/>
145+
</FieldWrapper>
146+
147+
<FieldWrapper>
148+
<Label required>작품 설명</Label>
149+
<Textarea
150+
placeholder="작품 설명을 작성해주세요."
151+
maxLength={500}
152+
value={description}
153+
onChange={setDescription}
154+
/>
155+
</FieldWrapper>
156+
157+
<FieldWrapper>
158+
<Label>작품 제작일</Label>
159+
<DatePicker value={date} onChange={setDate} />
160+
</FieldWrapper>
161+
162+
<FieldWrapper>
163+
<Label>희망 전시 지역</Label>
164+
<RegionSelect value={selectedRegions} onChange={setSelectedRegions} />
165+
</FieldWrapper>
166+
167+
<FieldWrapper>
168+
<Label>전시 필요 공간 크기 (cm)</Label>
169+
<SizeInput
170+
width={width}
171+
depth={depth}
172+
height={height}
173+
onWidthChange={setWidth}
174+
onDepthChange={setDepth}
175+
onHeightChange={setHeight}
176+
/>
177+
</FieldWrapper>
178+
179+
<FieldWrapper>
180+
<Label>주의 사항</Label>
181+
<Textarea
182+
placeholder="주의 사항 설명"
183+
maxLength={500}
184+
value={notes}
185+
onChange={setNotes}
186+
/>
187+
</FieldWrapper>
188+
189+
<FieldWrapper>
190+
<div className="flex items-center justify-between py-3">
191+
<Label>피드 내 공개</Label>
192+
<ToggleButton value={isPublic} onChange={setIsPublic} />
193+
</div>
194+
</FieldWrapper>
195+
</section>
196+
197+
<div className="border-border-primary fixed right-0 bottom-4 left-0 h-24.5 border-t bg-white px-5 py-4">
198+
{submitErrorMessage && (
199+
<p role="alert" className="text-caption text-error-default mb-2">
200+
{submitErrorMessage}
201+
</p>
202+
)}
203+
<button
204+
type="button"
205+
disabled={!isFormValid || updateMutation.isPending}
206+
onClick={() => {
207+
setSubmitErrorMessage(null);
208+
updateMutation.mutate();
209+
}}
210+
className={`text-body-1 h-12.5 w-full rounded-lg font-medium transition-colors ${
211+
isFormValid && !updateMutation.isPending
212+
? "bg-object-primary text-text-invert cursor-pointer"
213+
: "bg-object-disabled text-text-disabled cursor-not-allowed"
214+
}`}
215+
>
216+
{updateMutation.isPending ? "수정 중..." : "수정하기"}
217+
</button>
218+
</div>
219+
</>
220+
);
221+
}
222+
223+
export default function ArtEditPage() {
224+
const params = useParams<{ id: string }>();
225+
const artworkId = params.id;
226+
const { isAuthReady, isAuthenticated } = useRequireAuth();
227+
const canFetch = isAuthReady && isAuthenticated && Boolean(artworkId);
228+
229+
const query = useQuery({
230+
queryKey: ["mypage", "artwork", artworkId],
231+
queryFn: ({ signal }) => getMyArtworkDetail(artworkId, signal),
232+
enabled: canFetch,
233+
});
234+
235+
if (!isAuthReady || !isAuthenticated) return null;
236+
237+
return (
238+
<main className="min-h-screen bg-white">
239+
<Header title="작품 수정" showBack />
240+
241+
{query.isLoading ? (
242+
<section className="px-5 py-6">
243+
<p className="text-body-2 text-text-secondary">작품 정보를 불러오는 중입니다.</p>
244+
</section>
245+
) : query.isError || !query.data ? (
246+
<section className="px-5 py-6">
247+
<p className="text-body-2 text-error-default">작품 정보를 불러오지 못했습니다.</p>
248+
<button
249+
type="button"
250+
onClick={() => void query.refetch()}
251+
className="border-border-primary text-body-2 text-text-primary mt-3 h-9 rounded-lg border px-4 font-medium"
252+
>
253+
다시 불러오기
254+
</button>
255+
</section>
256+
) : (
257+
<ArtEditForm key={query.data.id} artwork={query.data} artworkId={artworkId} />
258+
)}
259+
</main>
260+
);
261+
}

src/app/art/[id]/page.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState } from "react";
44

55
import { useQuery } from "@tanstack/react-query";
6+
import { ChevronLeft } from "lucide-react";
67
import { useParams, useRouter } from "next/navigation";
78

89
import ExpandableText from "@/components/archive-detail/ExpandableText";
@@ -12,8 +13,8 @@ import RegionText from "@/components/archive-detail/RegionText";
1213
import SizeText from "@/components/archive-detail/SizeText";
1314
import { useCreateChatRoom } from "@/hooks/useCreateChatRoom";
1415
import { getArtworkDetail } from "@/services/artworks";
15-
import { normalizeImageUrl } from "@/utils/normalizeImageUrl";
1616
import { useAuthStore } from "@/stores/useAuthStore";
17+
import { normalizeImageUrl } from "@/utils/normalizeImageUrl";
1718

1819
function formatDate(date: string | null) {
1920
if (!date) return "-";
@@ -75,14 +76,14 @@ export default function ArtDetailPage() {
7576

7677
return (
7778
<div className="min-h-screen bg-white pb-32">
78-
<header className="fixed top-0 right-0 left-0 z-50 flex h-15 w-full min-w-[320px] px-4">
79-
<div className="flex items-center">
79+
<header className="pointer-events-none fixed top-0 right-0 left-0 z-50 h-15 min-w-[320px]">
80+
<div className="mx-auto flex h-full w-full max-w-97.5 items-center px-4">
8081
<button
8182
aria-label="뒤로가기"
8283
onClick={() => router.back()}
83-
className="flex cursor-pointer items-center font-bold text-white drop-shadow-md"
84+
className="pointer-events-auto -ml-2 flex h-11 w-11 cursor-pointer items-center justify-center rounded-full text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.55)]"
8485
>
85-
86+
<ChevronLeft size={28} strokeWidth={2.5} />
8687
</button>
8788
</div>
8889
</header>

src/app/chat/[roomId]/page.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,19 @@ interface ChatRoomPageProps {
1717
params: Promise<{ roomId: string }>;
1818
}
1919

20+
function getRoomAccessErrorMessage(error: unknown) {
21+
if (error instanceof Error && error.message) return error.message;
22+
return "채팅방을 볼 수 없습니다.";
23+
}
24+
2025
export default function ChatRoomPage({ params }: ChatRoomPageProps) {
2126
const { roomId } = use(params);
2227
const id = Number(roomId);
2328

2429
const { myUserId } = useSession();
2530

2631
// 방 정보: 딥링크/새로고침 대비 상세 조회(useChatRoom) 우선, 없으면 방 목록 캐시 폴백.
27-
const { data: roomDetail } = useChatRoom(id);
32+
const { data: roomDetail, isError: isRoomError, error: roomError } = useChatRoom(id);
2833
const { data: roomsData } = useChatRooms();
2934
const roomListItem = useMemo(
3035
() => roomsData?.pages.flatMap(page => page.items).find(r => r.id === id),
@@ -48,9 +53,13 @@ export default function ChatRoomPage({ params }: ChatRoomPageProps) {
4853
hasNextPage,
4954
isFetchingNextPage,
5055
fetchNextPage,
56+
isError: isMessagesError,
57+
error: messagesError,
58+
refetch: refetchMessages,
5159
} = useMessages(id);
5260

53-
const { sendMessage, markAsRead, lastError, isConnected } = useChatSocket(id);
61+
const canConnectSocket = Boolean(roomDetail || roomListItem);
62+
const { sendMessage, markAsRead, lastError, isConnected } = useChatSocket(id, canConnectSocket);
5463

5564
const messages = useMemo(
5665
() => messagesData?.pages.flatMap(page => page.items) ?? [],
@@ -64,6 +73,24 @@ export default function ChatRoomPage({ params }: ChatRoomPageProps) {
6473
// eslint-disable-next-line react-hooks/exhaustive-deps
6574
}, [id, isConnected]);
6675

76+
if (!Number.isFinite(id) || isRoomError) {
77+
return (
78+
<div className="bg-bg-primary flex h-screen flex-col">
79+
<ChatHeader title={null} />
80+
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-5 text-center">
81+
<p className="text-body-1 text-text-primary font-medium">
82+
{!Number.isFinite(id)
83+
? "올바르지 않은 채팅방입니다."
84+
: getRoomAccessErrorMessage(roomError)}
85+
</p>
86+
<p className="text-body-2 text-text-secondary">
87+
채팅방 참여자만 대화 내용을 볼 수 있습니다.
88+
</p>
89+
</div>
90+
</div>
91+
);
92+
}
93+
6794
return (
6895
<div className="bg-bg-primary flex h-screen flex-col">
6996
<ChatHeader title={counterpartyNickname} />
@@ -83,6 +110,19 @@ export default function ChatRoomPage({ params }: ChatRoomPageProps) {
83110
<div className="flex flex-1 items-center justify-center">
84111
<div className="text-body-1 text-text-secondary">메시지를 불러오는 중...</div>
85112
</div>
113+
) : isMessagesError ? (
114+
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-5 text-center">
115+
<p className="text-body-1 text-text-primary font-medium">
116+
{getRoomAccessErrorMessage(messagesError)}
117+
</p>
118+
<button
119+
type="button"
120+
onClick={() => void refetchMessages()}
121+
className="border-border-primary text-body-2 text-text-primary h-9 rounded-lg border px-4 font-medium"
122+
>
123+
다시 불러오기
124+
</button>
125+
</div>
86126
) : (
87127
<MessageList
88128
messages={messages}

0 commit comments

Comments
 (0)