Skip to content

Commit

Permalink
Merge pull request #279 from boostcampwm-2024/feat/#30/pagination
Browse files Browse the repository at this point in the history
[Feat] 무한 스크롤, 로딩 인디케이터, 엠티 뷰, 오류 alert
  • Loading branch information
simeunseo authored Nov 26, 2024
2 parents 8a5c3ab + 2e550e7 commit 55c402f
Show file tree
Hide file tree
Showing 29 changed files with 577 additions and 225 deletions.
30 changes: 30 additions & 0 deletions apps/web/src/components/common/Empty/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import TicleCharacter from '@/assets/images/ticle-character.png';
import cn from '@/utils/cn';

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

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

export default Empty;
File renamed without changes.
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>) {
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
19 changes: 12 additions & 7 deletions apps/web/src/components/dashboard/open/ApplicantsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DashboardApplicantsResponse } from '@repo/types';

import Avatar from '@/components/common/Avatar';
import { Dialog } from '@/components/common/Dialog';
import Empty from '@/components/common/Empty';

interface ApplicantsDialogProps {
isOpen: boolean;
Expand All @@ -15,13 +16,17 @@ function ApplicantsDialog({ isOpen, onClose, applicants }: ApplicantsDialogProps
<Dialog.Title align="center">신청자 목록</Dialog.Title>
<Dialog.Close onClose={onClose} />
<Dialog.Content className="custom-scrollbar h-56 overflow-y-scroll">
<ul className="flex flex-col gap-4">
{applicants.map((applicant) => (
<li key={applicant.id} className="flex items-center gap-2.5">
<Avatar src={applicant.user.profileImageUrl} size="sm" />
<span className="text-body1 text-alt">{applicant.user.nickname}</span>
</li>
))}
<ul className="flex h-full flex-col gap-4">
{applicants.length === 0 ? (
<Empty imageSize={80} className="h-full" />
) : (
applicants.map((applicant) => (
<li key={applicant.id} className="flex items-center gap-2.5">
<Avatar src={applicant.user.profileImageUrl} size="sm" />
<span className="text-body1 text-alt">{applicant.user.nickname}</span>
</li>
))
)}
</ul>
</Dialog.Content>
</Dialog.Root>
Expand Down
Loading

0 comments on commit 55c402f

Please sign in to comment.