|
| 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 | +} |
0 commit comments