Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] 무한 스크롤, 로딩 인디케이터, 엠티 뷰, 오류 alert #279

Merged
merged 24 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
815ff90
feat: Tab 공통 컴포넌트에서 value와 label을 분리하여 사용하도록 수정
simeunseo Nov 25, 2024
4d51092
feat: 변경된 Tab 공통 컴포넌트에 맞춰서 DashboardTab 사용 방식 변경
simeunseo Nov 25, 2024
d5e90bf
feat: Tab story 수정
simeunseo Nov 25, 2024
473f406
feat: 티클 리스트에서 진행예정/종료 탭 추가
simeunseo Nov 25, 2024
dbc3e4c
feat: Loading 컴포넌트 수정
simeunseo Nov 25, 2024
46d16e8
feat: Loading 컴포넌트 story 추가
simeunseo Nov 25, 2024
735c37a
feat: 티클 목록 조회시 기존 useQuery를 useInfiniteQuery로 변경
simeunseo Nov 25, 2024
d705e30
feat: 무한 스크롤 커스텀훅 구현
simeunseo Nov 25, 2024
9b6e32c
feat: 티클 리스트 조회시 무한스크롤과 로딩 인디케이터 추가
simeunseo Nov 25, 2024
888e6d8
feat: 기존 대시보드 조회 쿼리를 useInfiniteQuery로 변경
simeunseo Nov 25, 2024
b204788
design: 대시보드 카드에서 개설자 명이 길때 레이아웃이 깨지지 않도록 고정된 width 추가
simeunseo Nov 25, 2024
533a455
feat: 신청한 티클, 개설한 티클에 대해 10개씩 무한 스크롤 적용
simeunseo Nov 25, 2024
aafaba9
feat: 대시보드에서 티클 카드 클릭시 해당 티클 상세페이지로 이동
simeunseo Nov 25, 2024
2a841f3
feat: Link태그 내 Link태그가 중첩되는 문제 해결
simeunseo Nov 25, 2024
04c2fe3
feat: DashboardTab에서 state가 아닌 url에 따라서 tab을 전환하도록 변경
simeunseo Nov 25, 2024
e965652
feat: Header를 root에 조건부 렌더링하는 것이 아닌 필요한 곳에 직접 삽입하여 리렌더링 이슈 해결
simeunseo Nov 25, 2024
d003f97
feat: 메인 페이지에서 탭 전환에 따른 리렌더링 개선
simeunseo Nov 25, 2024
4b890d5
feat: 티클 리스트, 대시보드에 대해 Empty 뷰 추가
simeunseo Nov 25, 2024
b0eaf1b
feat: 티클 신청시 alert
simeunseo Nov 25, 2024
53dd259
design: Empty 뷰에 점선 border 적용
simeunseo Nov 26, 2024
fd62076
feat: 개설한 티클 목록에서 참여자 목록 모달 열 때 link 태그 동작 없애기
simeunseo Nov 26, 2024
2799faf
feat: 참여자 목록 모달에 Empty 뷰 추가
simeunseo Nov 26, 2024
e6f49ce
feat: 티클 생성시 대시보드 조회 invalidateQueries
simeunseo Nov 26, 2024
2e550e7
design: 태그의 최대 크기에 맞춰서 티클 카드 레이아웃 조정
simeunseo Nov 26, 2024
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
24 changes: 24 additions & 0 deletions apps/web/src/components/common/Empty/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import TicleCharacter from '@/assets/images/ticle-character.png';
import cn from '@/utils/cn';

interface EmptyProps {
title?: string;
className?: string;
}

function Empty({ title = '항목이 비어있어요!', className }: EmptyProps) {
return (
<div className={cn('flex h-96 w-full flex-col items-center justify-center gap-8', className)}>
<img
src={TicleCharacter}
alt="흑백 티클 캐릭터"
className="grayscale"
width={180}
height={180}
/>
<h1 className="text-head2 text-weak">{title}</h1>
</div>
);
}

export default Empty;
5 changes: 0 additions & 5 deletions apps/web/src/components/common/Loading/Loading.tsx

This file was deleted.

32 changes: 32 additions & 0 deletions apps/web/src/components/common/Loading/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { cva } from 'class-variance-authority';

const dotVariants = cva('h-4 w-4 rounded-full', {
variants: {
variant: {
white: ['animate-[flashWhite_1.5s_ease-out_infinite_alternate] bg-altWeak'],
primary: ['animate-[flashPrimary_1.5s_ease-out_infinite_alternate] bg-secondary'],
},
position: {
first: '',
second: '[animation-delay:0.5s]',
third: '[animation-delay:1s]',
},
},
defaultVariants: {
variant: 'white',
},
});

type LoadingProps = {
color?: 'white' | 'primary';
};

const Loading = ({ color = 'white' }: LoadingProps) => (
<div className="flex gap-5">
<div className={dotVariants({ variant: color, position: 'first' })} />
<div className={dotVariants({ variant: color, position: 'second' })} />
<div className={dotVariants({ variant: color, position: 'third' })} />
</div>
);

export default Loading;
21 changes: 11 additions & 10 deletions apps/web/src/components/common/Tab/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { KeyboardEvent } from 'react';

export interface TabData {
name: string;
export interface TabData<T extends string> {
value: T;
label: string;
onClick: () => void;
}

interface TabProps {
tabItems: TabData[];
selectedTab: string;
interface TabProps<T extends string> {
tabItems: TabData<T>[];
selectedTab: T;
}

function Tab({ tabItems, selectedTab }: TabProps) {
function Tab<T extends string>({ tabItems, selectedTab }: TabProps<T>) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q:
제네릭을 통해 props에 대한 타입을 정의한 이유가 있을까요? 그냥 string으로 할당하지 않은 이유가 궁금합니다!

p3:
컨벤션에 맞춰서 화살표 함수로 부탁드리겠습니다!

Copy link
Collaborator Author

@simeunseo simeunseo Nov 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

탭 정보에 대한 타입 안정성을 위해서입니다!
탭 정보는 위에 정의한 TabData interface에 맞게 value, label, onClick 속성으로 정의하게 되는데요!

예를 들어 탭 정보가 다음과 같이 정의되었을 때

[
    {
      value: 'APPLIED',
      label: '신청한 티클 관리',
      onClick: () => {
        // do something
      },
    },
    {
      value: 'OPENED',
      label: '개설한 티클 관리',
      onClick: () => {
        // do something
      },
    },
  ];

아래와 같이 selectedTab에 정의도지 않은 Tab value를 넣으면 타입에러가 나게 됩니다.

<Tab tabItems={DASHBOARD_TAB_DATA} selectedTab='APPLIEDD' />;

제너릭으로 정의하지 않으면 이 부분에 대해 캐치가 안됩니다!

const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>, onClick: () => void) => {
if (e.key !== 'Enter') return;
onClick();
Expand All @@ -20,15 +21,15 @@ function Tab({ tabItems, selectedTab }: TabProps) {
<div role="tablist" className="flex items-center gap-6">
{tabItems.map((tab) => (
<button
key={tab.name}
key={tab.value}
role="tab"
aria-selected={selectedTab === tab.name}
aria-selected={selectedTab === tab.value}
onClick={tab.onClick}
onKeyDown={(e) => handleKeyDown(e, tab.onClick)}
className="flex cursor-pointer flex-col gap-1.5 bg-transparent"
>
<span className="text-head1 text-main">{tab.name}</span>
<span className={`h-1 w-full ${selectedTab === tab.name ? 'bg-primary' : ''}`} />
<span className="text-head1 text-main">{tab.label}</span>
<span className={`h-1 w-full ${selectedTab === tab.value ? 'bg-primary' : ''}`} />
</button>
))}
</div>
Expand Down
16 changes: 8 additions & 8 deletions apps/web/src/components/dashboard/DashboardTab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { useMatch, useNavigate } from '@tanstack/react-router';

import Tab, { TabData } from '../common/Tab';

Expand All @@ -15,21 +14,22 @@ const DASHBOARD_ROUTES = {

function DashboardTab() {
const navigate = useNavigate();
const [selectedTab, setSelectedTab] = useState<string>(DASHBOARD_TAB.APPLIED);
const isOpenedMatch = useMatch({ from: '/dashboard/open', shouldThrow: false });
const selectedTab = isOpenedMatch ? 'OPENED' : 'APPLIED';

const DASHBOARD_TAB_DATA: TabData[] = [
const DASHBOARD_TAB_DATA: TabData<keyof typeof DASHBOARD_TAB>[] = [
{
name: DASHBOARD_TAB.APPLIED,
value: 'APPLIED',
label: DASHBOARD_TAB.APPLIED,
onClick: () => {
navigate({ to: DASHBOARD_ROUTES.APPLIED });
setSelectedTab(DASHBOARD_TAB.APPLIED);
},
},
{
name: DASHBOARD_TAB.OPENED,
value: 'OPENED',
label: DASHBOARD_TAB.OPENED,
onClick: () => {
navigate({ to: DASHBOARD_ROUTES.OPENED });
setSelectedTab(DASHBOARD_TAB.OPENED);
},
},
];
Expand Down
46 changes: 28 additions & 18 deletions apps/web/src/components/dashboard/apply/TicleInfoCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Link } from '@tanstack/react-router';
import { Link, useNavigate } from '@tanstack/react-router';
import { MouseEvent } from 'react';

import Button from '@/components/common/Button';
import { formatDateTimeRange } from '@/utils/date';
Expand All @@ -21,27 +22,36 @@ function TicleInfoCard({
status,
}: TicleInfoCardProps) {
const { dateStr, timeRangeStr } = formatDateTimeRange(startTime, endTime);
const navigate = useNavigate();

const handleTicleParticipate = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
navigate({ to: `/live/${ticleId}` });
};

return (
<div className="flex items-center justify-between rounded-lg border border-main bg-white p-6 shadow-normal">
<div className="flex gap-5">
<div className="flex items-center gap-3">
<h3 className="text-title2 text-main">개설자</h3>
<span className="text-body1 text-main">{speakerName}</span>
</div>
<div className="flex items-center gap-3">
<h3 className="text-title2 text-main">티클명</h3>
<span className="w-80 text-body1 text-main">{ticleTitle}</span>
</div>
<div className="flex items-center gap-3">
<h3 className="text-title2 text-main">진행 일시</h3>
<span className="text-body1 text-main">{`${dateStr} ${timeRangeStr}`}</span>
<Link to={`/ticle/${ticleId}`}>
<div className="flex items-center justify-between rounded-lg border border-main bg-white p-6 shadow-normal">
<div className="flex gap-5">
<div className="flex items-center gap-3">
<h3 className="text-title2 text-main">개설자</h3>
<span className="w-36 text-body1 text-main">{speakerName}</span>
</div>
<div className="flex items-center gap-3">
<h3 className="text-title2 text-main">티클명</h3>
<span className="w-80 text-body1 text-main">{ticleTitle}</span>
</div>
<div className="flex items-center gap-3">
<h3 className="text-title2 text-main">진행 일시</h3>
<span className="text-body1 text-main">{`${dateStr} ${timeRangeStr}`}</span>
</div>
</div>

<Button disabled={status === 'closed'} onClick={handleTicleParticipate}>
티클 참여하기
</Button>
</div>
<Link to={`/live/${ticleId}`}>
<Button disabled={status === 'closed'}>티클 참여하기</Button>
</Link>
</div>
</Link>
);
}

Expand Down
64 changes: 47 additions & 17 deletions apps/web/src/components/dashboard/apply/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { useState } from 'react';
import { Fragment, useState } from 'react';

import Empty from '@/components/common/Empty';
import Loading from '@/components/common/Loading';
import Select, { Option } from '@/components/common/Select';
import { useDashboardTicleList } from '@/hooks/api/dashboard';
import useIntersectionObserver from '@/hooks/useIntersectionObserver';

import TicleInfoCard from './TicleInfoCard';

Expand All @@ -26,28 +29,55 @@ function Apply() {
setSelectedOption(option);
};

const { data: { ticles, meta } = { ticles: [], meta: {} }, isLoading } = useDashboardTicleList({
isSpeaker: false,
page: 1,
pageSize: 10,
...(selectedOption.value && { status: selectedOption.value as 'open' | 'closed' }),
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useDashboardTicleList(
{
isSpeaker: false,
page: 1,
pageSize: 10,
...(selectedOption.value && { status: selectedOption.value as 'open' | 'closed' }),
}
);

const { ref: intersectionRef } = useIntersectionObserver({
hasNextPage,
fetchNextPage,
});

return (
<main className="mt-14 flex w-full flex-col gap-12">
<Select options={FILTER_OPTIONS} selectedOption={selectedOption} onChange={onOptionChange} />
<div className="flex flex-col gap-6">
{ticles.map((ticle) => (
<TicleInfoCard
key={ticle.id}
ticleId={ticle.id}
ticleTitle={ticle.title}
speakerName={ticle.speakerName as string}
startTime={ticle.startTime}
endTime={ticle.endTime}
status={ticle.ticleStatus}
/>
))}
{isLoading || !data ? (
<div className="flex h-80 w-full items-center justify-center">
<Loading color="primary" />
</div>
) : !data.pages[0]?.ticles?.length ? (
<Empty />
) : (
<div className="flex flex-col gap-6">
{data?.pages.map((page) => (
<Fragment key={page.meta.page}>
{page.ticles.map((ticle) => (
<TicleInfoCard
key={ticle.id}
ticleId={ticle.id}
ticleTitle={ticle.title}
speakerName={ticle.speakerName as string}
startTime={ticle.startTime}
endTime={ticle.endTime}
status={ticle.ticleStatus}
/>
))}
</Fragment>
))}
<div ref={intersectionRef} className="h-10 w-full" aria-hidden />
{isFetchingNextPage && (
<div className="flex w-full justify-center">
<Loading color="primary" />
</div>
)}
</div>
)}
</div>
</main>
);
Expand Down
66 changes: 39 additions & 27 deletions apps/web/src/components/dashboard/open/TicleInfoCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Link } from '@tanstack/react-router';
import { Link, useNavigate } from '@tanstack/react-router';
import { MouseEvent } from 'react';

import PersonFilledIc from '@/assets/icons/person-filled.svg?react';
import Button from '@/components/common/Button';
Expand All @@ -19,40 +20,51 @@ interface TicleInfoCardProps {
function TicleInfoCard({ ticleId, ticleTitle, startTime, endTime, status }: TicleInfoCardProps) {
const { isOpen, onOpen, onClose } = useModal();

const { data: applicantsData, isLoading } = useApplicantsTicle(ticleId.toString());
const { data: applicantsData } = useApplicantsTicle(ticleId.toString());
const { dateStr, timeRangeStr } = formatDateTimeRange(startTime, endTime);

const navigate = useNavigate();

if (!applicantsData) return;

const handleTicleParticipate = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
navigate({ to: `/live/${ticleId}` });
};

return (
<div className="flex items-center justify-between rounded-lg border border-main bg-white p-6 shadow-normal">
<div className="flex gap-5">
<div className="flex items-center gap-3">
<h3 className="text-title2 text-main">티클명</h3>
<span className="w-80 text-body1 text-main">{ticleTitle}</span>
<Link to={`/ticle/${ticleId}`}>
<div className="flex items-center justify-between rounded-lg border border-main bg-white p-6 shadow-normal">
<div className="flex gap-5">
<div className="flex items-center gap-3">
<h3 className="text-title2 text-main">티클명</h3>
<span className="w-80 text-body1 text-main">{ticleTitle}</span>
</div>
<div className="flex items-center gap-3">
<h3 className="text-title2 text-main">진행 일시</h3>
<span className="text-body1 text-main">{`${dateStr} ${timeRangeStr}`}</span>
</div>
</div>
<div className="flex items-center gap-3">
<h3 className="text-title2 text-main">진행 일시</h3>
<span className="text-body1 text-main">{`${dateStr} ${timeRangeStr}`}</span>
<div className="flex gap-9">
<button
className="flex items-center gap-2 rounded-md p-2.5 hover:bg-teritary"
onClick={onOpen}
>
<span className="text-title2 text-primary">신청자 목록</span>

<div>
<PersonFilledIc className="fill-primary" />
</div>
</button>
<Button disabled={status === 'closed'} onClick={handleTicleParticipate}>
티클 시작하기
</Button>
</div>
{isOpen && (
<ApplicantsDialog onClose={onClose} isOpen={isOpen} applicants={applicantsData} />
)}
</div>
<div className="flex gap-9">
<button
className="flex items-center gap-2 rounded-md p-2.5 hover:bg-teritary"
onClick={onOpen}
>
<span className="text-title2 text-primary">신청자 목록</span>

<div>
<PersonFilledIc className="fill-primary" />
</div>
</button>
<Link to={`/live/${ticleId}`}>
<Button disabled={status === 'closed'}>티클 시작하기</Button>
</Link>
</div>
{isOpen && <ApplicantsDialog onClose={onClose} isOpen={isOpen} applicants={applicantsData} />}
</div>
</Link>
);
}

Expand Down
Loading
Loading