Skip to content

Commit dd16f6b

Browse files
committed
fix: support image editing in archive forms
1 parent eb2c3f8 commit dd16f6b

3 files changed

Lines changed: 206 additions & 53 deletions

File tree

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

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import { useState } from "react";
44

55
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6-
import Image from "next/image";
76
import { useParams, useRouter } from "next/navigation";
87

98
import DatePicker from "@/components/archive-form/DayPicker";
109
import Dropdown from "@/components/archive-form/Dropdown";
10+
import EditableImageUploader, {
11+
type EditableArchiveImage,
12+
} from "@/components/archive-form/EditableImageUploader";
1113
import Input from "@/components/archive-form/Input";
1214
import Label from "@/components/archive-form/Label";
1315
import RegionSelect from "@/components/archive-form/RegionSelect";
@@ -16,6 +18,7 @@ import Textarea from "@/components/archive-form/Textarea";
1618
import ToggleButton from "@/components/archive-form/ToggleButton";
1719
import Header from "@/components/common/Header";
1820
import { ART_TYPES } from "@/constants/art";
21+
import { useUploadImage } from "@/hooks/useImageUploader";
1922
import { useRequireAuth } from "@/hooks/useRequireAuth";
2023
import { getMyArtworkDetail, updateArtwork } from "@/services/artworks";
2124
import type { ArtworkDetail } from "@/types/archiveDetail";
@@ -46,9 +49,30 @@ function getErrorMessage(error: unknown) {
4649
return error instanceof Error ? error.message : "작품 수정에 실패했습니다.";
4750
}
4851

52+
function toEditableImages(artwork: ArtworkDetail): EditableArchiveImage[] {
53+
return artwork.imageIds.map((id, index) => ({
54+
kind: "existing",
55+
id,
56+
url: normalizeImageUrl(artwork.imageUrls[index]),
57+
}));
58+
}
59+
60+
function resolveThumbnailIndex(
61+
images: EditableArchiveImage[],
62+
originalImageIds: number[],
63+
originalThumbnailIndex: number | null
64+
) {
65+
const thumbnailImageId = originalImageIds[originalThumbnailIndex ?? 0];
66+
const currentThumbnailIndex = images.findIndex(
67+
image => image.kind === "existing" && image.id === thumbnailImageId
68+
);
69+
return currentThumbnailIndex >= 0 ? currentThumbnailIndex : 0;
70+
}
71+
4972
function ArtEditForm({ artwork, artworkId }: { artwork: ArtworkDetail; artworkId: string }) {
5073
const router = useRouter();
5174
const queryClient = useQueryClient();
75+
const { mutateAsync: uploadImage } = useUploadImage();
5276
const [artType, setArtType] = useState(artwork.artworkType ?? "");
5377
const [title, setTitle] = useState(artwork.title ?? "");
5478
const [description, setDescription] = useState(artwork.description ?? "");
@@ -59,11 +83,23 @@ function ArtEditForm({ artwork, artworkId }: { artwork: ArtworkDetail; artworkId
5983
const [height, setHeight] = useState(artwork.heightCm ? String(artwork.heightCm) : "");
6084
const [notes, setNotes] = useState(artwork.caution ?? "");
6185
const [isPublic, setIsPublic] = useState(artwork.status === "PUBLISHED");
86+
const [images, setImages] = useState<EditableArchiveImage[]>(() => toEditableImages(artwork));
6287
const [submitErrorMessage, setSubmitErrorMessage] = useState<string | null>(null);
6388

6489
const updateMutation = useMutation({
65-
mutationFn: () =>
66-
updateArtwork(artworkId, {
90+
mutationFn: async () => {
91+
const uploadedImages = await Promise.all(
92+
images.filter(image => image.kind === "new").map(image => uploadImage(image.file))
93+
);
94+
let uploadedImageIndex = 0;
95+
const imageIds = images.map(image => {
96+
if (image.kind === "existing") {
97+
return image.id;
98+
}
99+
return uploadedImages[uploadedImageIndex++].imageId;
100+
});
101+
102+
return updateArtwork(artworkId, {
67103
title: title.trim(),
68104
artworkType: artType,
69105
description: description.trim(),
@@ -74,10 +110,11 @@ function ArtEditForm({ artwork, artworkId }: { artwork: ArtworkDetail; artworkId
74110
depthCm: toNullableNumber(depth),
75111
createdDate: toDateString(date),
76112
isPublic,
77-
imageIds: artwork.imageIds,
78-
thumbnailIndex: artwork.thumbnailIndex ?? 0,
113+
imageIds,
114+
thumbnailIndex: resolveThumbnailIndex(images, artwork.imageIds, artwork.thumbnailIndex),
79115
availableRegions: selectedRegions,
80-
}),
116+
});
117+
},
81118
onSuccess: () => {
82119
void queryClient.invalidateQueries({ queryKey: ["mypage", "feed"] });
83120
void queryClient.invalidateQueries({ queryKey: ["artwork-detail", artworkId] });
@@ -90,6 +127,7 @@ function ArtEditForm({ artwork, artworkId }: { artwork: ArtworkDetail; artworkId
90127
});
91128

92129
const isFormValid =
130+
images.length > 0 &&
93131
title.trim() !== "" &&
94132
artType !== "" &&
95133
description.trim() !== "" &&
@@ -101,27 +139,7 @@ function ArtEditForm({ artwork, artworkId }: { artwork: ArtworkDetail; artworkId
101139
<section className="flex flex-col gap-6 px-5 py-6 pb-32">
102140
<FieldWrapper>
103141
<Label required>사진</Label>
104-
<div className="flex flex-wrap gap-3">
105-
{artwork.imageUrls.map((url, index) => {
106-
const imageUrl = normalizeImageUrl(url);
107-
if (!imageUrl) return null;
108-
return (
109-
<div
110-
key={`${imageUrl}-${index}`}
111-
className="border-border-primary relative h-18 w-18 overflow-hidden rounded-sm border"
112-
>
113-
<Image
114-
src={imageUrl}
115-
alt={`작품 이미지 ${index + 1}`}
116-
fill
117-
sizes="72px"
118-
unoptimized
119-
className="object-cover"
120-
/>
121-
</div>
122-
);
123-
})}
124-
</div>
142+
<EditableImageUploader images={images} onChange={setImages} />
125143
</FieldWrapper>
126144

127145
<FieldWrapper>

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

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@
33
import { useState } from "react";
44

55
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6-
import Image from "next/image";
76
import { useParams, useRouter } from "next/navigation";
87

98
import AddressSearch from "@/components/archive-form/AddressSearch";
109
import Dropdown from "@/components/archive-form/Dropdown";
10+
import EditableImageUploader, {
11+
type EditableArchiveImage,
12+
} from "@/components/archive-form/EditableImageUploader";
1113
import Input from "@/components/archive-form/Input";
1214
import Label from "@/components/archive-form/Label";
1315
import SizeInput from "@/components/archive-form/SizeInput";
1416
import Textarea from "@/components/archive-form/Textarea";
1517
import ToggleButton from "@/components/archive-form/ToggleButton";
1618
import Header from "@/components/common/Header";
1719
import { ART_TYPES } from "@/constants/art";
20+
import { useUploadImage } from "@/hooks/useImageUploader";
1821
import { useRequireAuth } from "@/hooks/useRequireAuth";
1922
import { getMySpaceDetail, updateSpace } from "@/services/spaces";
2023
import type { SpaceDetail } from "@/types/archiveDetail";
@@ -33,9 +36,18 @@ function getErrorMessage(error: unknown) {
3336
return error instanceof Error ? error.message : "공간 수정에 실패했습니다.";
3437
}
3538

39+
function toEditableImages(space: SpaceDetail): EditableArchiveImage[] {
40+
return space.imageIds.map((id, index) => ({
41+
kind: "existing",
42+
id,
43+
url: normalizeImageUrl(space.imageUrls[index]),
44+
}));
45+
}
46+
3647
function SpaceEditForm({ space, spaceId }: { space: SpaceDetail; spaceId: string }) {
3748
const router = useRouter();
3849
const queryClient = useQueryClient();
50+
const { mutateAsync: uploadImage } = useUploadImage();
3951
const [spaceType, setSpaceType] = useState(space.spaceType ?? "");
4052
const [title, setTitle] = useState(space.title ?? "");
4153
const [description, setDescription] = useState(space.description ?? "");
@@ -45,11 +57,23 @@ function SpaceEditForm({ space, spaceId }: { space: SpaceDetail; spaceId: string
4557
const [height, setHeight] = useState(space.heightCm ? String(space.heightCm) : "");
4658
const [notes, setNotes] = useState(space.caution ?? "");
4759
const [isPublic, setIsPublic] = useState(space.isPublic);
60+
const [images, setImages] = useState<EditableArchiveImage[]>(() => toEditableImages(space));
4861
const [submitErrorMessage, setSubmitErrorMessage] = useState<string | null>(null);
4962

5063
const updateMutation = useMutation({
51-
mutationFn: () =>
52-
updateSpace(spaceId, {
64+
mutationFn: async () => {
65+
const uploadedImages = await Promise.all(
66+
images.filter(image => image.kind === "new").map(image => uploadImage(image.file))
67+
);
68+
let uploadedImageIndex = 0;
69+
const imageIds = images.map(image => {
70+
if (image.kind === "existing") {
71+
return image.id;
72+
}
73+
return uploadedImages[uploadedImageIndex++].imageId;
74+
});
75+
76+
return updateSpace(spaceId, {
5377
title: title.trim(),
5478
spaceType,
5579
address: address.trim(),
@@ -59,8 +83,9 @@ function SpaceEditForm({ space, spaceId }: { space: SpaceDetail; spaceId: string
5983
heightCm: toNullableNumber(height),
6084
depthCm: toNullableNumber(depth),
6185
isPublic,
62-
imageIds: space.imageIds,
63-
}),
86+
imageIds,
87+
});
88+
},
6489
onSuccess: () => {
6590
void queryClient.invalidateQueries({ queryKey: ["mypage", "feed"] });
6691
void queryClient.invalidateQueries({ queryKey: ["space-detail", spaceId] });
@@ -73,6 +98,7 @@ function SpaceEditForm({ space, spaceId }: { space: SpaceDetail; spaceId: string
7398
});
7499

75100
const isFormValid =
101+
images.length > 0 &&
76102
title.trim() !== "" &&
77103
spaceType !== "" &&
78104
address.trim() !== "" &&
@@ -85,27 +111,7 @@ function SpaceEditForm({ space, spaceId }: { space: SpaceDetail; spaceId: string
85111
<section className="flex flex-col gap-6 px-5 py-6 pb-32">
86112
<FieldWrapper>
87113
<Label required>사진</Label>
88-
<div className="flex flex-wrap gap-3">
89-
{space.imageUrls.map((url, index) => {
90-
const imageUrl = normalizeImageUrl(url);
91-
if (!imageUrl) return null;
92-
return (
93-
<div
94-
key={`${imageUrl}-${index}`}
95-
className="border-border-primary relative h-18 w-18 overflow-hidden rounded-sm border"
96-
>
97-
<Image
98-
src={imageUrl}
99-
alt={`공간 이미지 ${index + 1}`}
100-
fill
101-
sizes="72px"
102-
unoptimized
103-
className="object-cover"
104-
/>
105-
</div>
106-
);
107-
})}
108-
</div>
114+
<EditableImageUploader images={images} onChange={setImages} />
109115
</FieldWrapper>
110116

111117
<FieldWrapper>
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"use client";
2+
3+
import { useEffect, useRef, useState } from "react";
4+
5+
import Image from "next/image";
6+
7+
export type EditableArchiveImage =
8+
| {
9+
kind: "existing";
10+
id: number;
11+
url: string | null;
12+
}
13+
| {
14+
kind: "new";
15+
file: File;
16+
url: string;
17+
};
18+
19+
interface EditableImageUploaderProps {
20+
images: EditableArchiveImage[];
21+
onChange: (images: EditableArchiveImage[]) => void;
22+
}
23+
24+
export default function EditableImageUploader({ images, onChange }: EditableImageUploaderProps) {
25+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
26+
const imagesRef = useRef(images);
27+
28+
useEffect(() => {
29+
imagesRef.current = images;
30+
}, [images]);
31+
32+
useEffect(
33+
() => () => {
34+
imagesRef.current.forEach(image => {
35+
if (image.kind === "new") {
36+
URL.revokeObjectURL(image.url);
37+
}
38+
});
39+
},
40+
[]
41+
);
42+
43+
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
44+
const files = Array.from(event.target.files || []);
45+
if (files.length === 0) return;
46+
47+
if (images.length + files.length > 10) {
48+
setErrorMessage("사진은 최대 10장까지만 업로드할 수 있습니다.");
49+
event.target.value = "";
50+
return;
51+
}
52+
53+
const nextImages: EditableArchiveImage[] = files.map(file => ({
54+
kind: "new",
55+
file,
56+
url: URL.createObjectURL(file),
57+
}));
58+
onChange([...images, ...nextImages]);
59+
setErrorMessage(null);
60+
event.target.value = "";
61+
};
62+
63+
const handleRemove = (indexToRemove: number) => {
64+
const imageToRemove = images[indexToRemove];
65+
if (imageToRemove?.kind === "new") {
66+
URL.revokeObjectURL(imageToRemove.url);
67+
}
68+
onChange(images.filter((_, index) => index !== indexToRemove));
69+
setErrorMessage(null);
70+
};
71+
72+
return (
73+
<div className="mt-2 flex flex-col gap-2">
74+
<div className="flex flex-wrap items-center gap-3">
75+
<label className="bg-bg-primary-darker flex h-18 w-18 shrink-0 flex-col items-center justify-center rounded-sm">
76+
<input
77+
type="file"
78+
accept="image/*"
79+
multiple
80+
onChange={handleFileChange}
81+
className="hidden"
82+
/>
83+
<Image src="/camera-icon.svg" alt="camera" width={24} height={24} />
84+
<div className="text-caption font-regular text-text-secondary">{images.length}/10</div>
85+
</label>
86+
87+
{images.map((image, index) => {
88+
const imageUrl = image.kind === "existing" ? image.url : image.url;
89+
90+
return (
91+
<div
92+
key={image.kind === "existing" ? `existing-${image.id}` : image.url}
93+
className="bg-bg-primary-darker relative h-18 w-18 shrink-0 overflow-hidden rounded-sm"
94+
>
95+
{imageUrl ? (
96+
<Image
97+
src={imageUrl}
98+
alt="미리보기"
99+
fill
100+
unoptimized
101+
className="object-cover"
102+
sizes="72px"
103+
/>
104+
) : (
105+
<div className="text-caption text-text-secondary flex h-full w-full items-center justify-center">
106+
이미지
107+
</div>
108+
)}
109+
<button
110+
type="button"
111+
onClick={() => handleRemove(index)}
112+
className="absolute top-2 right-2 h-4 w-4"
113+
aria-label="이미지 삭제"
114+
>
115+
<Image src="/cancel-icon.svg" alt="" width={16} height={16} />
116+
</button>
117+
</div>
118+
);
119+
})}
120+
</div>
121+
122+
{errorMessage && (
123+
<p role="alert" className="text-caption text-error-default">
124+
{errorMessage}
125+
</p>
126+
)}
127+
</div>
128+
);
129+
}

0 commit comments

Comments
 (0)