Merge branch 'fe/fix/error-api' into 'fe/develop'

Fix: 리뷰 쿼리 수정, 리뷰 사진 모달 추가

See merge request s11-s-project/S11P21S002!293
This commit is contained in:
조현수 2024-10-04 17:03:41 +09:00
commit 2cd7cfea46
6 changed files with 197 additions and 38 deletions

View File

@ -0,0 +1,104 @@
import { useEffect, useState } from 'react';
import { Image, Layer, Stage, Line, Rect } from 'react-konva';
import useImage from 'use-image';
import { Label, Shape } from '@/types';
interface ImageSelectionWithLabelsProps {
imagePath: string;
labelData: string;
width: number;
height: number;
}
export default function ImageSelectionWithLabels({
imagePath,
labelData,
width,
height,
}: ImageSelectionWithLabelsProps) {
const [image] = useImage(imagePath, 'anonymous');
const [labels, setLabels] = useState<Label[]>([]);
const [stageDimensions, setStageDimensions] = useState<{ width: number; height: number }>({ width, height });
useEffect(() => {
if (image) {
const widthRatio = width / image.width;
const heightRatio = height / image.height;
const scale = Math.min(widthRatio, heightRatio);
setStageDimensions({
width: image.width * scale,
height: image.height * scale,
});
}
}, [image, width, height]);
useEffect(() => {
const fetchLabelData = async () => {
try {
const response = await fetch(labelData);
const json: { shapes: Shape[] } = await response.json();
const shapes = json.shapes.map((shape, index) => ({
id: index,
categoryId: shape.categoryId,
color: shape.color,
type: shape.shape_type,
coordinates: shape.points,
})) as Label[];
setLabels(shapes);
} catch (error) {
console.error('Failed to fetch label data:', error);
}
};
fetchLabelData();
}, [labelData]);
if (!stageDimensions || !image) return null;
return (
<Stage
width={stageDimensions.width}
height={stageDimensions.height}
className="overflow-hidden bg-gray-200"
>
<Layer>
<Image
image={image}
width={stageDimensions.width}
height={stageDimensions.height}
/>
</Layer>
<Layer>
{labels.map((label) =>
label.type === 'rectangle' ? (
<Rect
key={label.id}
x={label.coordinates[0][0] * (stageDimensions.width / image.width)}
y={label.coordinates[0][1] * (stageDimensions.height / image.height)}
width={(label.coordinates[1][0] - label.coordinates[0][0]) * (stageDimensions.width / image.width)}
height={(label.coordinates[1][1] - label.coordinates[0][1]) * (stageDimensions.height / image.height)}
stroke={label.color}
strokeWidth={2}
listening={false}
/>
) : (
<Line
key={label.id}
points={label.coordinates
.flat()
.map((point, index) =>
index % 2 === 0
? point * (stageDimensions.width / image.width)
: point * (stageDimensions.height / image.height)
)}
stroke={label.color}
strokeWidth={2}
closed
listening={false}
/>
)
)}
</Layer>
</Stage>
);
}

View File

@ -1,7 +1,11 @@
import { useState, useCallback, useMemo } from 'react';
import { Label } from '@/components/ui/label';
import useRecursiveSavedImages from '@/hooks/useRecursiveSavedImages';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '@/components/ui/dialog';
import ImageWithLabels from './ImageSelectionWithLabels';
import { FixedSizeList } from 'react-window';
import React from 'react';
interface ImageSelectionProps {
projectId: string;
@ -11,16 +15,22 @@ interface ImageSelectionProps {
export default function ImageSelection({ projectId, selectedImages, setSelectedImages }: ImageSelectionProps) {
const { allSavedImages } = useRecursiveSavedImages(projectId, 0);
const [selectedImagePath, setSelectedImagePath] = useState<string | null>(null);
const [selectedLabelData, setSelectedLabelData] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const handleImageSelect = (imageId: number) => {
const updatedImages = selectedImages.includes(imageId)
? selectedImages.filter((id) => id !== imageId)
: [...selectedImages, imageId];
const handleImageSelect = useCallback(
(imageId: number) => {
if (selectedImages.includes(imageId)) {
setSelectedImages(selectedImages.filter((id) => id !== imageId));
} else {
setSelectedImages([...selectedImages, imageId]);
}
},
[selectedImages, setSelectedImages]
);
setSelectedImages(updatedImages);
};
const handleSelectAll = () => {
const handleSelectAll = useCallback(() => {
if (allSavedImages) {
if (selectedImages.length === allSavedImages.length) {
setSelectedImages([]);
@ -28,8 +38,66 @@ export default function ImageSelection({ projectId, selectedImages, setSelectedI
setSelectedImages(allSavedImages.map((image) => image.id));
}
}
}, [allSavedImages, selectedImages, setSelectedImages]);
const handleOpenDialog = (imagePath: string, labelData: string) => {
setSelectedImagePath(imagePath);
setSelectedLabelData(labelData);
setIsDialogOpen(true);
};
const Row = useMemo(() => {
return React.memo(({ index, style }: { index: number; style: React.CSSProperties }) => {
const image = allSavedImages[index];
return (
<div
key={image.id}
style={style}
className={`relative flex items-center justify-between border p-2 ${
selectedImages.includes(image.id) ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
}`}
>
<Dialog
open={isDialogOpen}
onOpenChange={(open) => setIsDialogOpen(open)}
>
<DialogTrigger asChild>
<span
className="cursor-pointer truncate"
onClick={() => handleOpenDialog(image.imagePath, image.dataPath)}
>
{image.imageTitle}
</span>
</DialogTrigger>
{isDialogOpen && selectedImagePath && selectedLabelData && (
<DialogContent className="h-[600px] w-[1000px]">
<DialogHeader title={image.imageTitle} />
<ImageWithLabels
imagePath={selectedImagePath}
labelData={selectedLabelData}
width={400}
height={400}
/>
</DialogContent>
)}
</Dialog>
<div className="flex items-center space-x-2">
<Button
variant={selectedImages.includes(image.id) ? 'blue' : 'black'}
size="sm"
onClick={() => handleImageSelect(image.id)}
className="px-3 py-1"
type="button"
>
{selectedImages.includes(image.id) ? '해제' : '선택'}
</Button>
</div>
</div>
);
});
}, [allSavedImages, selectedImages, selectedImagePath, selectedLabelData, handleImageSelect, isDialogOpen]);
return (
<div className="mb-4">
<div className="mb-2 flex items-center justify-between">
@ -44,35 +112,18 @@ export default function ImageSelection({ projectId, selectedImages, setSelectedI
{allSavedImages && selectedImages.length === allSavedImages.length ? '전체 선택 해제' : '전체 선택'}
</Button>
</div>
<ScrollArea className="max-h-64 overflow-auto border p-2">
<ul className="space-y-2">
{allSavedImages && allSavedImages.length > 0 ? (
allSavedImages.map((image) => (
<li
key={image.id}
className={`relative flex items-center justify-between border p-2 ${
selectedImages.includes(image.id) ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
}`}
>
<span className="truncate">{image.imageTitle}</span>
<div className="flex items-center space-x-2">
<Button
variant={selectedImages.includes(image.id) ? 'blue' : 'black'}
size="sm"
onClick={() => handleImageSelect(image.id)}
className="px-3 py-1"
type="button"
>
{selectedImages.includes(image.id) ? '해제' : '선택'}
</Button>
</div>
</li>
))
) : (
<p className="text-gray-500"> .</p>
)}
</ul>
</ScrollArea>
{allSavedImages && allSavedImages.length > 0 ? (
<FixedSizeList
height={260}
itemCount={allSavedImages.length}
itemSize={80}
width="100%"
>
{Row}
</FixedSizeList>
) : (
<p className="text-gray-500"> .</p>
)}
</div>
);
}

View File

@ -14,6 +14,7 @@ export default function useApproveReviewQuery({ projectId, reviewId, memberId }:
mutationFn: () => approveReview(projectId, reviewId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reviewDetail', projectId, reviewId, memberId] });
queryClient.invalidateQueries({ queryKey: ['reviewByStatus', projectId, reviewId, memberId] });
},
});
}

View File

@ -18,6 +18,7 @@ export default function useCreateReviewQuery() {
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['reviewDetail', variables.projectId] });
queryClient.invalidateQueries({ queryKey: ['reviewByStatus', variables.projectId] });
},
});
}

View File

@ -9,6 +9,7 @@ export default function useDeleteReviewQuery() {
deleteReview(projectId, reviewId, memberId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['reviewDetail', variables.projectId, variables.reviewId] });
queryClient.invalidateQueries({ queryKey: ['reviewByStatus', variables.projectId, variables.reviewId] });
},
});
}

View File

@ -14,6 +14,7 @@ export default function useRejectReviewQuery({ projectId, reviewId, memberId }:
mutationFn: () => rejectReview(projectId, reviewId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reviewDetail', projectId, reviewId, memberId] });
queryClient.invalidateQueries({ queryKey: ['reviewByStatus', projectId, reviewId, memberId] });
},
});
}