Skip to content

Commit

Permalink
feature: user page api (#36)
Browse files Browse the repository at this point in the history
* chore: image remote pattern 설정

* feat: Film 추가 API 연동

* fix: userId type error 해결

* feat: response type 정의

* feat: 유저 페이지 API 연동

* feat: onEditProfile 인자 제거

* feat: 필름제목 수정 API 연동

* fix: 모달 내부 인풋 state가 초기화되지 않는 현상 수정

* feat: 카메라 롤 컷 클릭시 라우팅

* feat: 포토컷 올리기 모달 데이터 연동 및 submit 시 라우팅 적용

* feat: 로그인, 비로그인뷰 구분

* feat: useIsomorphicLayoutEffect 추가

* fix: 비로그인뷰가 잠시보이는 현상 임시 수정

* feat: empty view 추가
  • Loading branch information
asdf99245 authored Aug 15, 2023
1 parent b54cc55 commit 75c46f0
Show file tree
Hide file tree
Showing 22 changed files with 492 additions and 156 deletions.
9 changes: 9 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ const nextConfig = {
use: ["@svgr/webpack"]
});
return config;
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'grafi-test-bucket.s3.ap-northeast-2.amazonaws.com',
port: '',
},
],
}
}

Expand Down
Binary file added public/images/film.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 36 additions & 8 deletions src/components/shared/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Image from 'next/image';
import type { ComponentProps, MouseEventHandler } from 'react';
import { Profile } from '@/pages/user/[id]';
import clsx from 'clsx';
import { Icon } from '@/components/shared/Icon';
import { cn } from '@/utils/cn';

const PLACEHOLDER_SRC = '/images/avatar-placeholder.png';
Expand All @@ -13,7 +13,8 @@ interface AvatarProps {
displayMeta?: boolean;
description?: string;
viewCount?: number;
onEditProfile?: (info: Profile) => void;
isLogin?: boolean;
onEditProfile?: () => void;
}

type Props = AvatarProps &
Expand All @@ -25,12 +26,15 @@ export function Avatar({
displayMeta = false,
description = '',
viewCount = 0,
isLogin = false,
className,
onEditProfile,
...restProps
}: Props) {
const handleEditProfile: MouseEventHandler<HTMLElement> = () => {
if (onEditProfile) onEditProfile({ profileImage: src, nickname, description });
if (!isLogin) return;

if (onEditProfile) onEditProfile();
};

return (
Expand All @@ -41,19 +45,43 @@ export function Avatar({
)}
>
<div
className='tw-relative tw-h-20 tw-w-20 tw-cursor-pointer tw-overflow-hidden tw-rounded-full'
className={cn(
'tw-relative tw-h-20 tw-w-20 tw-overflow-hidden tw-rounded-full',
// isLogin && 'tw-cursor-pointer',
)}
onClick={handleEditProfile}
>
<Image src={src} alt={nickname} fill className='tw-object-contain tw-object-center' {...restProps} />
<Image
src={src}
alt={nickname}
fill
className='tw-object-contain tw-object-center'
{...restProps}
/>
</div>
{displayMeta && (
<div className='tw-flex tw-flex-col tw-gap-3'>
<div className='tw-flex tw-items-center tw-gap-2.5' onClick={handleEditProfile}>
<strong className='tw-text-accent-eng'>{nickname}</strong>
<div className='tw-flex tw-items-center tw-gap-2.5'>
<div
className={cn(
'tw-flex tw-items-center tw-gap-0.5',
isLogin && 'tw-cursor-pointer',
)}
onClick={handleEditProfile}
>
<strong className='tw-text-accent-eng'>{nickname}</strong>
{isLogin && (
<Icon iconType='RightChevron' width={18} height={18} />
)}
</div>
<span className='tw-text-caption-eng tw-text-grayscale-500'>{`Total ${viewCount}`}</span>
</div>
<p
className={clsx('tw-text-body2-accent', !description && 'tw-text-grayscale-300')}
className={clsx(
'tw-text-body2-accent',
!description && 'tw-text-grayscale-300',
isLogin && 'tw-cursor-pointer',
)}
onClick={handleEditProfile}
>
{description.length === 0 && HINT_TEXT}
Expand Down
27 changes: 18 additions & 9 deletions src/components/shared/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,24 @@ import type { MouseEventHandler, PropsWithChildren } from 'react';
import { createContext, useState } from 'react';
import { useSafeContext } from '@/hooks';
import { Icon } from '@/components/shared/Icon';
import { SelectedFilm } from '@/components/user';
import { cn } from '@/utils/cn';

interface SelectProps {
isExpanded?: boolean;
selected: string;
onSelect: (option: string) => void;
onSelect: (film: SelectedFilm) => void;
}

interface SelectItemProps {
children: string;
filmId: number;
}

interface SelectContextType {
selected: string;
closeSelect: () => void;
onSelect: (option: string) => void;
onSelect: (film: SelectedFilm) => void;
}
const SelectContext = createContext<SelectContextType | null>(null);

Expand All @@ -40,29 +42,36 @@ export function Select({
return (
<div className='rounded tw-flex tw-w-full tw-flex-col tw-overflow-hidden tw-border tw-border-grayscale-300'>
<div className='tw-flex tw-h-12 tw-w-full tw-items-center tw-justify-between tw-pl-2.5 tw-pr-3'>
<span className='tw-text-body2-accent tw-text-grayscale-700'>{selected}</span>
<span className='tw-text-body2-accent tw-text-grayscale-700'>
{selected}
</span>
<Icon
iconType='ArrowDown'
className={cn('tw-top-1/2 tw-cursor-pointer', isExpanded && '-tw-rotate-180')}
className={cn(
'tw-top-1/2 tw-cursor-pointer',
isExpanded && '-tw-rotate-180',
)}
onClick={handleToggleSelect}
/>
</div>
<div className={cn('tw-h-0 tw-w-full', isExpanded && 'tw-h-fit')}>
<ul className='tw-w-full'>
<SelectContext.Provider value={{ selected, closeSelect, onSelect }}>{children}</SelectContext.Provider>
<SelectContext.Provider value={{ selected, closeSelect, onSelect }}>
{children}
</SelectContext.Provider>
</ul>
</div>
</div>
);
}

function SelectItem({ children }: SelectItemProps) {
function SelectItem({ children, filmId }: SelectItemProps) {
const { selected, closeSelect, onSelect } = useSafeContext(SelectContext);

const handleSelect =
(option: string): MouseEventHandler<HTMLLIElement> =>
(film: SelectedFilm): MouseEventHandler<HTMLLIElement> =>
() => {
onSelect(option);
onSelect(film);
closeSelect();
};

Expand All @@ -72,7 +81,7 @@ function SelectItem({ children }: SelectItemProps) {
'tw-flex-justify-between tw-text-body2 tw-flex tw-h-12 tw-w-full tw-cursor-pointer tw-items-center tw-border-t tw-border-grayscale-300 tw-bg-grayscale-100 tw-p-2.5 tw-text-grayscale-400',
selected === children && 'tw-hidden',
)}
onClick={handleSelect(children)}
onClick={handleSelect({ title: children, filmId })}
>
{children}
</li>
Expand Down
67 changes: 53 additions & 14 deletions src/components/user/CameraRoll.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,41 @@
import { type HTMLAttributes } from 'react';
import { Icon } from '@/components/shared';
import { useRouter } from 'next/router';
import { type HTMLAttributes, type MouseEventHandler } from 'react';
import { PhotoCut } from '@/types';
import { Icon, ImageFrame } from '@/components/shared';
import { cn } from '@/utils/cn';

const FILM_HOLE_COUNT = 11;
const FILM_MAX_COUNT = 10;

interface Props extends HTMLAttributes<HTMLDivElement> {
userId: string;
filmId: number;
title: string;
photos?: string[];
onEditTitle: (title: string) => void;
photos?: PhotoCut[];
isLogin?: boolean;
onEditTitle: () => void;
}

export function CameraRoll({ title, photos = [], onEditTitle, className, ...restProps }: Props) {
const srcs = Array.from({ length: 10 }, (_, i) => photos[i] ?? '');
export function CameraRoll({
userId,
filmId,
title,
photos = [],
onEditTitle,
isLogin = false,
className,
...restProps
}: Props) {
const router = useRouter();

const handleClickPhoto: MouseEventHandler<HTMLDivElement> = () => {
router.push(`/user/${userId}/${filmId}/item`);
};

const handleClickEmptyCut: MouseEventHandler<HTMLDivElement> = () => {
if (!isLogin) return;
router.push(`/user/${userId}/${filmId}/item/add`);
};

return (
<div
Expand All @@ -21,19 +45,34 @@ export function CameraRoll({ title, photos = [], onEditTitle, className, ...rest
<div className='tw-flex tw-items-center tw-justify-between tw-py-2 tw-pl-3.5 tw-pr-5'>
<div className='tw-flex tw-items-center tw-gap-1'>
<h2 className='tw-text-body1 tw-text-grayscale-200'>{title}</h2>
<Icon
iconType='Edit'
className='tw-cursor-pointer tw-fill-grayscale-400'
onClick={() => onEditTitle(title)}
/>
{isLogin && (
<Icon
iconType='Edit'
className='tw-cursor-pointer tw-fill-grayscale-400'
onClick={onEditTitle}
/>
)}
</div>
<span className='tw-text-caption-eng tw-text-grayscale-100'>{`${photos.length} Cuts`}</span>
</div>
<div className='tw-flex tw-gap-2.5 tw-overflow-x-scroll tw-scrollbar-hide'>
{srcs.map((photo, idx) => (
{photos.map(({ photo_cut_id, title, image }) => (
<div
key={idx}
className='tw-aspect-[3/4] tw-h-[250px] tw-bg-grayscale-400'
key={photo_cut_id}
className='tw-aspect-[3/4] tw-h-[250px] tw-cursor-pointer tw-bg-grayscale-400'
onClick={handleClickPhoto}
>
<ImageFrame src={image} alt={title} />
</div>
))}
{Array.from({ length: FILM_MAX_COUNT - photos.length }, (_, idx) => (
<div
key={`empty_${idx}`}
className={cn(
'tw-aspect-[3/4] tw-h-[250px] tw-bg-grayscale-400',
isLogin && 'tw-cursor-pointer',
)}
onClick={handleClickEmptyCut}
/>
))}
</div>
Expand Down
31 changes: 31 additions & 0 deletions src/components/user/EmptyView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Image from 'next/image';
import { useRouter } from 'next/router';
import { Button } from '@/components/shared';

interface Props {
isLogin?: boolean;
}

export function EmptyView({ isLogin }: Props) {
const router = useRouter();

return (
<div className='tw-flex tw-flex-col tw-items-center tw-gap-9'>
<div className='tw-flex tw-flex-col tw-items-center tw-gap-2.5'>
<Image
src='/images/film.png'
alt='empty view'
width={200}
height={186}
priority
/>
<p className='tw-text-body1 tw-text-grayscale-300'>
아직 등록된 사진이 없어요
</p>
</div>
{!isLogin && (
<Button onClick={() => router.push('/signin')}>내 그라피 만들기</Button>
)}
</div>
);
}
27 changes: 24 additions & 3 deletions src/components/user/FilmAddModal.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,44 @@
import { useRouter } from 'next/router';
import { type ComponentProps, type MouseEventHandler, useState } from 'react';
import { useCreateFilms } from '@/query-hooks/useFilms';
import { Input, Modal } from '@/components/shared';

type Props = Omit<ComponentProps<typeof Modal>, 'title' | 'children'>;

export function FilmAddModal({ onCancel, ...restProps }: Props) {
const router = useRouter();
const { mutate: createFilms } = useCreateFilms();
const [input, setInput] = useState('');

const handleValueChange = (value: string) => {
setInput(value);
};

const handleSave: MouseEventHandler<HTMLButtonElement> = (e) => {
// TODO: 필름 추가 API 연결
const userId = localStorage.getItem('userId');
if (!userId) {
router.push('/signin');
return;
}

createFilms({ title: input, userId });

if (onCancel) onCancel(e);
};

return (
<Modal title='새로운 필름 추가' onCancel={onCancel} onSave={handleSave} {...restProps}>
<Input label='필름 제목' placeholder='필름 제목을 입력하세요' value={input} onValueChange={handleValueChange} />
<Modal
title='새로운 필름 추가'
onCancel={onCancel}
onSave={handleSave}
{...restProps}
>
<Input
label='필름 제목'
placeholder='필름 제목을 입력하세요'
value={input}
onValueChange={handleValueChange}
/>
</Modal>
);
}
Loading

0 comments on commit 75c46f0

Please sign in to comment.