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
7 changes: 2 additions & 5 deletions src/api/service/group-service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
GetMyGroupsPayload,
GetMyGroupsResponse,
GroupIdPayload,
PreUploadGroupImagePayload,
PreUploadGroupImageResponse,
} from '@/types/service/group';

Expand Down Expand Up @@ -39,10 +38,8 @@ export const groupServiceRemote = () => ({
},

// 모임 이미지 사전 업로드 (POST /groups/images/upload) - multipart/form-data
uploadGroupImages: (payload: PreUploadGroupImagePayload) => {
return api.post<PreUploadGroupImageResponse>('/groups/images/upload', payload, {
headers: { 'Content-Type': 'multipart/form-data' },
});
uploadGroupImages: (payload: FormData) => {
return api.post<PreUploadGroupImageResponse>('/groups/images/upload', payload);
},

createGroup: (payload: CreateGroupPayload) => {
Expand Down
6 changes: 5 additions & 1 deletion src/app/post-meetup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useRouter } from 'next/navigation';

import { useForm } from '@tanstack/react-form';
import { useForm, useStore } from '@tanstack/react-form';

import {
MeetupCapField,
Expand Down Expand Up @@ -40,6 +40,10 @@ const PostMeetupPage = () => {
},
});

const values = useStore(form.store, (state) => state.values);

console.log(values);

return (
<div>
<form>
Expand Down
6 changes: 2 additions & 4 deletions src/components/pages/meetup/meetup-banner-images/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import './index.css';

import Image from 'next/image';

import { DEFAULT_GROUP_IMAGE } from 'constants/default-images';
import { Pagination } from 'swiper/modules';
import { Swiper, SwiperSlide } from 'swiper/react';

Expand All @@ -19,9 +20,6 @@ interface Props {
export const MeetupBannerImages = ({ images }: Props) => {
const hasImages = Boolean(images.length);

const defaultImageUrl =
'https://images.unsplash.com/photo-1705599359461-f99dc9e80efa?q=80&w=1170&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D';

return (
<section className='select-none'>
{hasImages ? (
Expand All @@ -34,7 +32,7 @@ export const MeetupBannerImages = ({ images }: Props) => {
</Swiper>
) : (
<div className='relative h-60'>
<Image alt='image' draggable={false} fill objectFit='cover' src={defaultImageUrl} />
<Image alt='image' draggable={false} fill objectFit='cover' src={DEFAULT_GROUP_IMAGE} />
</div>
)}
</section>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import Image from 'next/image';
import Link from 'next/link';

import { DEFAULT_PROFILE_IMAGE } from 'constants/default-images';

import { ImageWithFallback } from '@/components/ui';
import { GetGroupDetailsResponse } from '@/types/service/group';

interface Props {
Expand All @@ -13,14 +11,13 @@ export const DescriptionProfile = ({ hostInfo: { nickName, profileImage, userId
return (
<div className='w-full select-none'>
<Link href={`/profile/${userId}`} className='flex gap-3'>
<Image
<ImageWithFallback
width={40}
className='h-10 w-10 shrink-0 rounded-full'
className='object-fit h-10 w-10 shrink-0 rounded-full'
alt='프로필 사진'
draggable={false}
height={40}
objectFit='cover'
src={profileImage ?? DEFAULT_PROFILE_IMAGE}
src={profileImage}
/>

<div className='*:line-clamp-1'>
Expand Down
13 changes: 4 additions & 9 deletions src/components/pages/meetup/meetup-members/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
'use client';

import Image from 'next/image';
import Link from 'next/link';

import { useState } from 'react';
Expand All @@ -9,7 +8,7 @@ import clsx from 'clsx';

import { Icon } from '@/components/icon';
import { AnimateDynamicHeight } from '@/components/shared';
import { Button } from '@/components/ui';
import { Button, ImageWithFallback } from '@/components/ui';
import { GetGroupDetailsResponse } from '@/types/service/group';

interface Props {
Expand All @@ -22,9 +21,6 @@ export const MeetupMembers = ({ members }: Props) => {

const hasMoreMember = 2 < Math.ceil(members.length / 3);

const defaultProfileImageUrl =
'https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?q=80&w=717&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D';

const onExpandClick = () => {
setExpand((prev) => !prev);
setCoverMember((prev) => !prev);
Expand All @@ -46,14 +42,13 @@ export const MeetupMembers = ({ members }: Props) => {
>
<div className='flex-col-center gap-1.5'>
<Link href={`/profile/${userId}`}>
<Image
<ImageWithFallback
width={64}
className='h-16 w-16 rounded-full'
className='object-fit h-16 w-16 rounded-full'
alt='프로필 사진'
draggable={false}
height={64}
objectFit='cover'
src={profileImage ?? defaultProfileImageUrl}
src={profileImage}
/>
</Link>
<p
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const MeetupDetailField = ({ field }: Props) => {
className='bg-mono-white focus:border-mint-500 text-text-md-medium h-40 w-full resize-none rounded-2xl border border-gray-300 px-5 py-4 text-gray-800 focus:outline-none'
maxLength={300}
placeholder='모임에 대해 설명해주세요'
required
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
Expand Down
144 changes: 89 additions & 55 deletions src/components/pages/post-meetup/fields/images-field/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,76 +2,110 @@

import Image from 'next/image';

import { useRef } from 'react';

import { AnyFieldApi } from '@tanstack/react-form';

import { Icon } from '@/components/icon';
import { ImageInput, ImageInputProps } from '@/components/ui';
import { useUploadGroupImages } from '@/hooks/use-group/use-group-upload-images';
import { cn } from '@/lib/utils';
import { ALLOWED_IMAGE_TYPES } from '@/types/service/common';
import { PreUploadGroupImageResponse } from '@/types/service/group';

interface Props {
field: AnyFieldApi;
initialImages?: ImageInputProps['initialImages'];
}

export const MeetupImagesField = ({ field, initialImages }: Props) => {
export const MeetupImagesField = ({ field }: Props) => {
const { mutateAsync } = useUploadGroupImages();

const onUploadImage = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;

const fileArray = Array.from(files);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const invalidFile = fileArray.find((file) => !ALLOWED_IMAGE_TYPES.includes(file.type as any));

if (invalidFile) {
alert('jpg 또는 png 파일만 업로드 가능합니다.');
e.target.value = '';
return;
}

const response = await mutateAsync({
images: fileArray,
});

field.handleChange([...response.images]);
};

const onUploadImageButtonClick = () => {
if (!inputRef.current) {
return;
}

inputRef.current.click();
};

const inputRef = useRef<HTMLInputElement | null>(null);

return (
<ImageInput
initialImages={initialImages}
maxFiles={3}
mode='append'
multiple={true}
value={field.state.value}
onChange={field.handleChange}
>
{(images, onRemoveImageClick, onFileSelectClick) => (
<div className='space-y-1'>
<div className='mt-6 flex flex-row gap-2'>
<button
<div className='space-y-1'>
<div className='mt-6 flex flex-row gap-2'>
<button
className={cn(
'flex-center bg-mono-white group aspect-square w-full max-w-20 cursor-pointer rounded-2xl border-1 border-gray-300', // 기본 스타일
'hover:bg-gray-50', // hover 스타일
'transition-all duration-300', // animation 스타일
)}
aria-label='이미지 선택 버튼'
type='button'
onClick={onUploadImageButtonClick}
>
<Icon
id='plus'
className={cn(
'size-6 text-gray-600', // 기본 스타일
'group-hover:scale-120', // hover 스타일
'transition-all duration-300', // animation 스타일
)}
/>
<input
ref={inputRef}
className='hidden'
accept='image/*'
multiple
type='file'
onChange={(e) => onUploadImage(e)}
/>
</button>
{field.state.value.map(({ imageUrl100x100 }: PreUploadGroupImageResponse['images'][0]) => (
<div key={imageUrl100x100} className='relative aspect-square w-full max-w-20'>
<Image
className='border-mono-black/5 h-full w-full rounded-2xl border-1 object-cover'
alt='썸네일 이미지'
fill
src={imageUrl100x100}
/>

{/* <button
className={cn(
'flex-center bg-mono-white group aspect-square w-full max-w-20 cursor-pointer rounded-2xl border-1 border-gray-300', // 기본 스타일
'hover:bg-gray-50', // hover 스타일
'flex-center bg-mono-white/80 group absolute top-1.5 right-2 size-4 cursor-pointer rounded-full', // 기본 스타일
'hover:bg-mono-white hover:scale-110', // hover 스타일
'transition-all duration-300', // animation 스타일
)}
aria-label='이미지 선택 버튼'
aria-label='이미지 삭제 버튼'
type='button'
onClick={onFileSelectClick}
onClick={() => onRemoveImageClick(url)}
>
<Icon
id='plus'
className={cn(
'size-6 text-gray-600', // 기본 스타일
'group-hover:scale-120', // hover 스타일
'transition-all duration-300', // animation 스타일
)}
/>
</button>
{Object.entries(images).map(([url, _file]) => (
<div key={url} className='relative aspect-square w-full max-w-20'>
<Image
className='border-mono-black/5 h-full w-full rounded-2xl border-1 object-cover'
alt='팀 이미지'
fill
src={url}
unoptimized={url.startsWith('blob:')}
/>
<button
className={cn(
'flex-center bg-mono-white/80 group absolute top-1.5 right-2 size-4 cursor-pointer rounded-full', // 기본 스타일
'hover:bg-mono-white hover:scale-110', // hover 스타일
'transition-all duration-300', // animation 스타일
)}
aria-label='이미지 삭제 버튼'
type='button'
onClick={() => onRemoveImageClick(url)}
>
<Icon id='small-x-1' className='size-1.5 text-gray-700' />
</button>
</div>
))}
<Icon id='small-x-1' className='size-1.5 text-gray-700' />
</button> */}
</div>
<p className='text-text-sm-medium px-2 text-gray-500'>최대 3개까지 업로드할 수 있어요.</p>
</div>
)}
</ImageInput>
))}
</div>
<p className='text-text-sm-medium px-2 text-gray-500'>최대 3개까지 업로드할 수 있어요.</p>
</div>
);
};
4 changes: 3 additions & 1 deletion src/components/ui/image-with-fallback/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ interface ImageWithFallbackProps extends Omit<ImageProps, 'src' | 'onError'> {
fallbackSrc?: string;
}

import { DEFAULT_PROFILE_IMAGE } from 'constants/default-images';

export const ImageWithFallback = ({
src,
fallbackSrc = 'https://plus.unsplash.com/premium_photo-1738592736106-a17b897c0ab1?q=80&w=1934&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
fallbackSrc = DEFAULT_PROFILE_IMAGE,
...rest
}: ImageWithFallbackProps) => {
const [error, setError] = useState(false);
Expand Down
8 changes: 7 additions & 1 deletion src/hooks/use-group/use-group-upload-images/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import { PreUploadGroupImagePayload } from '@/types/service/group';
export const useUploadGroupImages = () => {
const query = useMutation({
mutationFn: (payload: PreUploadGroupImagePayload) => {
return API.groupService.uploadGroupImages(payload);
const formData = new FormData();

payload.images.forEach((file) => {
formData.append('images', file);
});

return API.groupService.uploadGroupImages(formData);
},
onSuccess: () => {
console.log('이미지 등록 성공');
Expand Down
2 changes: 2 additions & 0 deletions src/types/service/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ export interface CommonSuccessResponse<T> {
success: boolean;
data: T;
}

export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png'] as const;