Skip to content

Commit 7145c70

Browse files
authored
Merge pull request #354 from devpalsPlus/feat/#347
관리자 배너 페이지 구현( Feat/#347 )
2 parents 9d7a5f1 + 6df7066 commit 7145c70

28 files changed

+1575
-1
lines changed

src/api/admin/banner.api.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { ApiBannerList } from '../../models/admin/banner';
2+
import { httpClient } from '../http.api';
3+
4+
export const getBannerList = async () => {
5+
try {
6+
const response = await httpClient.get<ApiBannerList>('/banner');
7+
console.log(response.data);
8+
return response.data;
9+
} catch (error) {
10+
console.error(error);
11+
throw error;
12+
}
13+
};
14+
15+
export const postBanner = async (formData: FormData) => {
16+
try {
17+
const response = await httpClient.post('/banner', formData);
18+
return response.data;
19+
} catch (error) {
20+
console.error(error);
21+
throw error;
22+
}
23+
};
24+
25+
export const patchBanner = async (formData: FormData, bannerId?: number) => {
26+
try {
27+
if (!bannerId) {
28+
throw new Error('bannerId is required');
29+
}
30+
31+
for (const [key, value] of formData.entries()) {
32+
if (value instanceof File) {
33+
console.log(`${key}:`, {
34+
name: value.name,
35+
size: value.size,
36+
type: value.type,
37+
lastModified: value.lastModified,
38+
});
39+
} else {
40+
console.log(`${key}:`, value);
41+
}
42+
}
43+
const response = await httpClient.patch(`/banner/${bannerId}`, formData);
44+
return response.data;
45+
} catch (error) {
46+
console.error(error);
47+
throw error;
48+
}
49+
};
50+
51+
export const deleteBanner = async (bannerId?: number) => {
52+
try {
53+
if (!bannerId) {
54+
throw new Error('bannerId is required');
55+
}
56+
57+
const response = await httpClient.delete(`/banner/${bannerId}`);
58+
return response.data;
59+
} catch (error) {
60+
console.error(error);
61+
throw error;
62+
}
63+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import styled from 'styled-components';
2+
import Button from '../../common/Button/Button';
3+
4+
export const Container = styled.table<{ $header?: boolean }>`
5+
border-collapse: collapse;
6+
table-layout: fixed;
7+
border-radius: 8px;
8+
overflow: hidden;
9+
width: 100%;
10+
margin-top: 10rem;
11+
`;
12+
13+
export const TableBody = styled.tbody``;
14+
15+
export const ButtonContainer = styled.div`
16+
display: flex;
17+
justify-content: center;
18+
margin-top: 20px;
19+
`;
20+
21+
export const BannerButton = styled(Button)``;
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { useState, useCallback } from 'react';
2+
import * as S from './BannerList.styled';
3+
import ScrollPreventor from '../../common/modal/ScrollPreventor';
4+
import { useGetAllBannerList } from '../../../hooks/admin/useGetAllBannerList';
5+
import { useBannerMutations } from '../../../hooks/admin/useBannerMutations';
6+
import { useModal } from '../../../hooks/useModal';
7+
import TableHeader from './tableHeader/TableHeader';
8+
import BannerRow from './bannerRow/BannerRow';
9+
import NewBannerRow from './newBannerRow/NewBannerRow';
10+
import Modal from '../../common/modal/Modal';
11+
import { Spinner } from '../../common/loadingSpinner/LoadingSpinner.styled';
12+
13+
const TABLE_COLUMNS = [
14+
{ width: '50px' },
15+
{ width: '280px' },
16+
{ width: '100px' },
17+
{ width: '160px' },
18+
{ width: '240px' },
19+
{ width: '140px' },
20+
];
21+
22+
export default function BannerList() {
23+
const { isOpen, message, handleModalOpen, handleModalClose } = useModal();
24+
const { allBannersData, isLoading } = useGetAllBannerList();
25+
const { deleteBannerMutate } = useBannerMutations({ handleModalOpen });
26+
const [isCreating, setIsCreating] = useState(false);
27+
28+
const handleDelete = useCallback(
29+
async (id: number) => {
30+
if (window.confirm('정말 삭제하시겠습니까?')) {
31+
try {
32+
await deleteBannerMutate.mutateAsync(id);
33+
} catch (error) {
34+
console.error('삭제 실패:', error);
35+
}
36+
}
37+
},
38+
[deleteBannerMutate]
39+
);
40+
41+
const toggleCreating = useCallback(() => {
42+
setIsCreating((prev) => !prev);
43+
}, []);
44+
45+
if (isLoading) {
46+
return <Spinner />;
47+
}
48+
49+
return (
50+
<>
51+
<ScrollPreventor />
52+
<S.Container>
53+
<colgroup>
54+
{TABLE_COLUMNS.map((col, index) => (
55+
<col key={index} style={{ width: col.width }} />
56+
))}
57+
</colgroup>
58+
59+
<TableHeader />
60+
61+
<S.TableBody>
62+
{allBannersData?.data.map((banner, index) => (
63+
<BannerRow
64+
key={banner.id}
65+
banner={banner}
66+
index={index}
67+
onDelete={handleDelete}
68+
/>
69+
))}
70+
71+
{isCreating && <NewBannerRow />}
72+
</S.TableBody>
73+
</S.Container>
74+
75+
<S.ButtonContainer>
76+
{!isCreating ? (
77+
<S.BannerButton
78+
radius='primary'
79+
schema='primary'
80+
size='primary'
81+
onClick={toggleCreating}
82+
>
83+
배너 생성하기
84+
</S.BannerButton>
85+
) : (
86+
<S.BannerButton
87+
radius='primary'
88+
schema='primary'
89+
size='primary'
90+
onClick={toggleCreating}
91+
>
92+
취소하기
93+
</S.BannerButton>
94+
)}
95+
</S.ButtonContainer>
96+
97+
<Modal isOpen={isOpen} onClose={handleModalClose}>
98+
{message}
99+
</Modal>
100+
</>
101+
);
102+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import styled from 'styled-components';
2+
3+
export const TableRow = styled.tr`
4+
border-bottom: 1px solid #e9ecef;
5+
6+
&:nth-child(even) {
7+
background-color: ${({ theme }) => theme.color.lightgrey};
8+
}
9+
`;
10+
11+
export const TableCell = styled.td`
12+
padding: 14px;
13+
text-align: center;
14+
vertical-align: middle;
15+
font-size: 14px;
16+
color: #333;
17+
`;
18+
19+
export const ImageCell = styled.td`
20+
position: relative;
21+
height: 120px;
22+
padding: 0;
23+
width: 280px;
24+
text-align: center;
25+
vertical-align: middle;
26+
display: flex;
27+
align-items: center;
28+
justify-content: center;
29+
`;
30+
31+
export const EditButton = styled.button`
32+
padding: 6px 12px;
33+
margin-right: 8px;
34+
border: none;
35+
border-radius: 4px;
36+
background-color: ${({ theme }) => theme.buttonScheme.primary.bg};
37+
color: white;
38+
font-size: 12px;
39+
cursor: pointer;
40+
transition: background-color 0.2s ease;
41+
42+
&:hover:not(:disabled) {
43+
opacity: 0.8;
44+
}
45+
46+
&:disabled {
47+
background-color: ${({ theme }) => theme.color.deepGrey};
48+
cursor: not-allowed;
49+
opacity: 0.8;
50+
}
51+
`;
52+
53+
export const DeleteButton = styled.button`
54+
padding: 6px 12px;
55+
border: none;
56+
border-radius: 4px;
57+
background-color: ${({ theme }) => theme.color.red};
58+
color: white;
59+
font-size: 12px;
60+
cursor: pointer;
61+
transition: background-color 0.2s ease;
62+
63+
&:hover {
64+
opacity: 0.8;
65+
}
66+
`;
67+
68+
export const DateDisplay = styled.span`
69+
font-size: 14px;
70+
color: #495057;
71+
`;
72+
73+
export const Placeholder = styled.span`
74+
font-size: 14px;
75+
color: #6c757d;
76+
`;
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { useState, useCallback } from 'react';
2+
import * as S from './BannerRow.styled';
3+
import type { BannerItem } from '../../../../models/admin/banner';
4+
import { useBannerRow } from './useBannerRow';
5+
import ImageUploadArea from '../imageUploadArea/ImageUploadArea';
6+
import ToggleSwitch from '../toggleSwitch/ToggleSwitch';
7+
import RadioGroup from '../radioGroup/RadioGroup';
8+
import DateRange from '../dateRange/DateRange';
9+
import { formatDate } from '../../../../util/formatDate';
10+
11+
interface BannerRowProps {
12+
banner: BannerItem;
13+
index: number;
14+
onDelete: (id: number) => void;
15+
}
16+
17+
const BannerRow = ({ banner, index, onDelete }: BannerRowProps) => {
18+
const {
19+
currentValues,
20+
hasChanges,
21+
handleToggle,
22+
handleChangeType,
23+
handleDateChange,
24+
handleImageChange,
25+
handleUpdate,
26+
} = useBannerRow(banner);
27+
28+
const [hoveredImageId, setHoveredImageId] = useState<number | null>(null);
29+
const showImageOverlay = hoveredImageId === banner.id;
30+
31+
// 이미지 관련 핸들러
32+
const handleImageClick = useCallback(() => {
33+
const input = document.createElement('input');
34+
input.type = 'file';
35+
input.accept = 'image/*';
36+
input.style.display = 'none';
37+
38+
input.onchange = (event) => {
39+
const file = (event.target as HTMLInputElement).files?.[0];
40+
if (file) {
41+
handleImageChange(file);
42+
}
43+
};
44+
45+
document.body.appendChild(input);
46+
input.click();
47+
document.body.removeChild(input);
48+
}, [handleImageChange]);
49+
50+
// 날짜 표시 렌더링
51+
const renderDateDisplay = () => {
52+
if (currentValues.always) {
53+
return <S.Placeholder>-</S.Placeholder>;
54+
}
55+
56+
if (currentValues.startDate && currentValues.endDate) {
57+
return (
58+
<S.DateDisplay>
59+
{formatDate(currentValues.startDate)} ~{' '}
60+
{formatDate(currentValues.endDate)}
61+
</S.DateDisplay>
62+
);
63+
}
64+
65+
return (
66+
<DateRange
67+
startDate={currentValues.startDate}
68+
endDate={currentValues.endDate}
69+
onStartDateChange={(date) =>
70+
handleDateChange(date, currentValues.endDate)
71+
}
72+
onEndDateChange={(date) =>
73+
handleDateChange(currentValues.startDate, date)
74+
}
75+
placeholder={{ start: '시작일', end: '종료일' }}
76+
/>
77+
);
78+
};
79+
80+
return (
81+
<S.TableRow>
82+
<S.TableCell>{index + 1}</S.TableCell>
83+
84+
<S.ImageCell>
85+
<ImageUploadArea
86+
imageUrl={currentValues.imageUrl}
87+
onImageClick={handleImageClick}
88+
onImageHover={() => setHoveredImageId(banner.id)}
89+
onImageLeave={() => setHoveredImageId(null)}
90+
showOverlay={showImageOverlay}
91+
/>
92+
</S.ImageCell>
93+
94+
<S.TableCell>
95+
<ToggleSwitch
96+
id={`toggle-${banner.id}`}
97+
checked={currentValues.visible}
98+
onChange={handleToggle}
99+
/>
100+
</S.TableCell>
101+
102+
<S.TableCell>
103+
<RadioGroup
104+
name={banner.id.toString()}
105+
value={currentValues.always}
106+
onChange={handleChangeType}
107+
/>
108+
</S.TableCell>
109+
110+
<S.TableCell>{renderDateDisplay()}</S.TableCell>
111+
112+
<S.TableCell>
113+
<S.EditButton onClick={handleUpdate} disabled={!hasChanges}>
114+
수정
115+
</S.EditButton>
116+
<S.DeleteButton onClick={() => onDelete(banner.id)}>
117+
삭제
118+
</S.DeleteButton>
119+
</S.TableCell>
120+
</S.TableRow>
121+
);
122+
};
123+
124+
export default BannerRow;

0 commit comments

Comments
 (0)