jongsultest 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/index.d.mts +339 -0
  2. package/dist/index.d.ts +339 -0
  3. package/dist/index.js +1280 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/index.mjs +1234 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/package.json +59 -0
  8. package/src/atoms/Badge.stories.tsx +136 -0
  9. package/src/atoms/Badge.tsx +46 -0
  10. package/src/atoms/Button.stories.tsx +142 -0
  11. package/src/atoms/Button.tsx +50 -0
  12. package/src/atoms/ComboboxAutocomplete.stories.tsx +43 -0
  13. package/src/atoms/ComboboxAutocomplete.tsx +6 -0
  14. package/src/atoms/ComboboxSelect.stories.tsx +56 -0
  15. package/src/atoms/ComboboxSelect.tsx +6 -0
  16. package/src/atoms/DateTimePicker.stories.tsx +42 -0
  17. package/src/atoms/DateTimePicker.tsx +6 -0
  18. package/src/atoms/Icon.stories.tsx +86 -0
  19. package/src/atoms/Icon.tsx +81 -0
  20. package/src/atoms/IconButton.stories.tsx +59 -0
  21. package/src/atoms/IconButton.tsx +19 -0
  22. package/src/atoms/InputColor.stories.tsx +42 -0
  23. package/src/atoms/InputColor.tsx +6 -0
  24. package/src/atoms/InputDatePicker.stories.tsx +55 -0
  25. package/src/atoms/InputDatePicker.tsx +6 -0
  26. package/src/atoms/InputFile.stories.tsx +57 -0
  27. package/src/atoms/InputFile.tsx +15 -0
  28. package/src/atoms/InputNumber.stories.tsx +58 -0
  29. package/src/atoms/InputNumber.tsx +15 -0
  30. package/src/atoms/InputPassword.stories.tsx +42 -0
  31. package/src/atoms/InputPassword.tsx +24 -0
  32. package/src/atoms/InputRadio.stories.tsx +65 -0
  33. package/src/atoms/InputRadio.tsx +19 -0
  34. package/src/atoms/InputSegmentedControl.stories.tsx +44 -0
  35. package/src/atoms/InputSegmentedControl.tsx +15 -0
  36. package/src/atoms/InputSwitch.stories.tsx +45 -0
  37. package/src/atoms/InputSwitch.tsx +23 -0
  38. package/src/atoms/InputSwitchInTable.stories.tsx +43 -0
  39. package/src/atoms/InputSwitchInTable.tsx +35 -0
  40. package/src/atoms/InputText.stories.tsx +42 -0
  41. package/src/atoms/InputText.tsx +7 -0
  42. package/src/atoms/InputTextarea.stories.tsx +50 -0
  43. package/src/atoms/InputTextarea.tsx +6 -0
  44. package/src/atoms/index.ts +20 -0
  45. package/src/atoms/inputClassNames.ts +20 -0
  46. package/src/blocks/Accordion.stories.tsx +102 -0
  47. package/src/blocks/Accordion.tsx +124 -0
  48. package/src/blocks/AccordionDraggable.stories.tsx +169 -0
  49. package/src/blocks/AccordionDraggable.tsx +200 -0
  50. package/src/blocks/BoxContainer.stories.tsx +34 -0
  51. package/src/blocks/BoxContainer.tsx +16 -0
  52. package/src/blocks/DataTable.tsx +127 -0
  53. package/src/blocks/DescriptionRow.stories.tsx +34 -0
  54. package/src/blocks/DescriptionRow.tsx +22 -0
  55. package/src/blocks/EditorLayout.stories.tsx +79 -0
  56. package/src/blocks/EditorLayout.tsx +43 -0
  57. package/src/blocks/ImageUpload.tsx +292 -0
  58. package/src/blocks/LabeledField.stories.tsx +34 -0
  59. package/src/blocks/LabeledField.tsx +31 -0
  60. package/src/blocks/MediDrawer.stories.tsx +58 -0
  61. package/src/blocks/MediDrawer.tsx +34 -0
  62. package/src/blocks/Modal.stories.tsx +42 -0
  63. package/src/blocks/Modal.tsx +31 -0
  64. package/src/blocks/ModalDeleteConfirm.stories.tsx +31 -0
  65. package/src/blocks/ModalDeleteConfirm.tsx +56 -0
  66. package/src/blocks/ModalTokenExpired.tsx +52 -0
  67. package/src/blocks/NavigationBanner.tsx +100 -0
  68. package/src/blocks/PageHeader.tsx +24 -0
  69. package/src/blocks/PageLoading.stories.tsx +22 -0
  70. package/src/blocks/PageLoading.tsx +19 -0
  71. package/src/blocks/PageTitle.stories.tsx +39 -0
  72. package/src/blocks/PageTitle.tsx +25 -0
  73. package/src/blocks/Pagination.tsx +49 -0
  74. package/src/blocks/Panel.stories.tsx +84 -0
  75. package/src/blocks/Panel.tsx +97 -0
  76. package/src/blocks/SectionGroupWithTitle.stories.tsx +24 -0
  77. package/src/blocks/SectionGroupWithTitle.tsx +21 -0
  78. package/src/blocks/Table.tsx +114 -0
  79. package/src/blocks/TagInput.stories.tsx +74 -0
  80. package/src/blocks/TagInput.tsx +93 -0
  81. package/src/blocks/index.ts +25 -0
  82. package/src/index.ts +51 -0
  83. package/src/utils/file.ts +7 -0
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ import React, { useState, useMemo, useEffect } from "react";
4
+ import { Paper, Table, Pagination, Box, CSSProperties } from "@mantine/core";
5
+
6
+ export interface Column<T> {
7
+ id: string;
8
+ label: string;
9
+ minWidth?: number;
10
+ align?: "left" | "right" | "center";
11
+ format?: (value: any, row: T) => React.ReactNode;
12
+ style?: CSSProperties;
13
+ headerStyle?: CSSProperties;
14
+ }
15
+
16
+ interface DataTableProps<T> {
17
+ columns: Column<T>[];
18
+ data: T[];
19
+ getRowId: (row: T) => string | number;
20
+ onRowClick?: (row: T) => void;
21
+ rowsPerPageOptions?: number[];
22
+ defaultRowsPerPage?: number;
23
+ style?: CSSProperties;
24
+ containerStyle?: CSSProperties;
25
+ rowStyle?: CSSProperties;
26
+ emptyMessage?: string;
27
+ }
28
+
29
+ export default function DataTable<T extends Record<string, any>>({
30
+ columns,
31
+ data,
32
+ getRowId,
33
+ onRowClick,
34
+ rowsPerPageOptions = [5, 10, 25, 50],
35
+ defaultRowsPerPage = 10,
36
+ style,
37
+ containerStyle,
38
+ rowStyle,
39
+ emptyMessage = "데이터가 없습니다.",
40
+ }: DataTableProps<T>) {
41
+ const [page, setPage] = useState(1);
42
+ const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage);
43
+ const [mounted, setMounted] = useState(false);
44
+
45
+ useEffect(() => {
46
+ setMounted(true);
47
+ }, []);
48
+
49
+ // 페이지네이션된 데이터
50
+ const paginatedData = useMemo(() => {
51
+ const startIndex = (page - 1) * rowsPerPage;
52
+ const endIndex = startIndex + rowsPerPage;
53
+ return data.slice(startIndex, endIndex);
54
+ }, [data, page, rowsPerPage]);
55
+
56
+ const totalPages = Math.ceil(data.length / rowsPerPage);
57
+
58
+ const handleRowClick = (row: T) => {
59
+ if (onRowClick) {
60
+ onRowClick(row);
61
+ }
62
+ };
63
+
64
+ return (
65
+ <Paper shadow="xs" p="md" mt="md" style={containerStyle}>
66
+ <Table style={style} highlightOnHover={!!onRowClick}>
67
+ <Table.Thead>
68
+ <Table.Tr>
69
+ {columns.map((column) => (
70
+ <Table.Th
71
+ key={column.id}
72
+ style={{
73
+ minWidth: column.minWidth,
74
+ fontWeight: "bold",
75
+ textAlign: column.align || "left",
76
+ ...column.headerStyle,
77
+ }}
78
+ >
79
+ {column.label}
80
+ </Table.Th>
81
+ ))}
82
+ </Table.Tr>
83
+ </Table.Thead>
84
+ <Table.Tbody>
85
+ {paginatedData.length === 0 ? (
86
+ <Table.Tr>
87
+ <Table.Td colSpan={columns.length} style={{ textAlign: "center", padding: "32px" }}>
88
+ {emptyMessage}
89
+ </Table.Td>
90
+ </Table.Tr>
91
+ ) : (
92
+ paginatedData.map((row, index) => (
93
+ <Table.Tr
94
+ key={getRowId(row)}
95
+ onClick={() => handleRowClick(row)}
96
+ style={{
97
+ cursor: onRowClick ? "pointer" : "default",
98
+ ...rowStyle,
99
+ }}
100
+ >
101
+ {columns.map((column) => {
102
+ const value = row[column.id];
103
+ return (
104
+ <Table.Td
105
+ suppressHydrationWarning
106
+ key={column.id}
107
+ style={{
108
+ textAlign: column.align || "left",
109
+ ...column.style,
110
+ }}
111
+ >
112
+ {column.id === "no" ? (mounted ? (page - 1) * rowsPerPage + index + 1 : null) : null}
113
+ {column.id !== "no" && (column.format ? column.format(value, row) : value)}
114
+ </Table.Td>
115
+ );
116
+ })}
117
+ </Table.Tr>
118
+ ))
119
+ )}
120
+ </Table.Tbody>
121
+ </Table>
122
+ <Box mt="md" className="flex justify-end">
123
+ <Pagination value={page} onChange={setPage} total={totalPages} />
124
+ </Box>
125
+ </Paper>
126
+ );
127
+ }
@@ -0,0 +1,34 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import DescriptionRow from "./DescriptionRow";
3
+ import { Text } from "@mantine/core";
4
+
5
+ const meta: Meta<typeof DescriptionRow> = {
6
+ title: "Blocks/DescriptionRow",
7
+ component: DescriptionRow,
8
+ tags: ["autodocs"],
9
+ args: {
10
+ label: "신청일시",
11
+ value: <Text>2026-02-26 10:59</Text>,
12
+ },
13
+ };
14
+
15
+ export default meta;
16
+ type Story = StoryObj<typeof DescriptionRow>;
17
+
18
+ export const Default: Story = {
19
+ render: (args) => (
20
+ <div className="m-auto w-[300px]">
21
+ <DescriptionRow {...args} />
22
+ </div>
23
+ ),
24
+ };
25
+
26
+ export const ListWithDescription: Story = {
27
+ render: () => (
28
+ <div className="m-auto flex w-[300px] flex-col gap-1">
29
+ <DescriptionRow label="신청일시" value={<Text>2026-02-26 10:59</Text>} />
30
+ <DescriptionRow label="신청자" value={<Text>홍길동</Text>} />
31
+ <DescriptionRow label="연락처" value={<Text>010-1234-5678</Text>} />
32
+ </div>
33
+ ),
34
+ };
@@ -0,0 +1,22 @@
1
+ import { Box, Group, Text } from "@mantine/core";
2
+ import { ReactNode } from "react";
3
+
4
+ interface Props {
5
+ label: string;
6
+ value: ReactNode;
7
+ }
8
+
9
+ const DescriptionRow = ({ label, value }: Props) => {
10
+ return (
11
+ <Group justify="space-between" py="xs">
12
+ <Text size="sm" c="dimmed">
13
+ {label}
14
+ </Text>
15
+ <Box fz="sm" fw={500}>
16
+ {value}
17
+ </Box>
18
+ </Group>
19
+ );
20
+ };
21
+
22
+ export default DescriptionRow;
@@ -0,0 +1,79 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { EditorLayout } from "./EditorLayout";
3
+ import { Box, NavLink, Paper, Stack, Text, TextInput } from "@mantine/core";
4
+
5
+ const meta: Meta<typeof EditorLayout> = {
6
+ title: "Blocks/EditorLayout",
7
+ component: EditorLayout,
8
+ tags: ["autodocs"],
9
+ parameters: {
10
+ layout: "fullscreen",
11
+ },
12
+ };
13
+
14
+ export default meta;
15
+ type Story = StoryObj<typeof EditorLayout>;
16
+
17
+ const LeftPanel = (
18
+ <Paper p="md" withBorder>
19
+ <Text fw={700} mb="sm">
20
+ 컴포넌트
21
+ </Text>
22
+ <Stack gap="xs">
23
+ <NavLink label="배너" active />
24
+ <NavLink label="텍스트" />
25
+ <NavLink label="이미지" />
26
+ </Stack>
27
+ </Paper>
28
+ );
29
+
30
+ const CenterPanel = (
31
+ <Paper p="md" withBorder h={400}>
32
+ <Text fw={700} mb="sm">
33
+ 프리뷰
34
+ </Text>
35
+ <Box
36
+ style={{
37
+ border: "1px dashed #dee2e6",
38
+ borderRadius: 8,
39
+ height: 300,
40
+ display: "flex",
41
+ alignItems: "center",
42
+ justifyContent: "center",
43
+ }}
44
+ >
45
+ <Text c="dimmed">미리보기 영역</Text>
46
+ </Box>
47
+ </Paper>
48
+ );
49
+
50
+ const RightPanel = (
51
+ <Paper p="md" withBorder>
52
+ <Text fw={700} mb="sm">
53
+ 설정
54
+ </Text>
55
+ <Stack gap="sm">
56
+ <TextInput label="제목" placeholder="제목을 입력하세요" />
57
+ <TextInput label="설명" placeholder="설명을 입력하세요" />
58
+ </Stack>
59
+ </Paper>
60
+ );
61
+
62
+ export const Default: Story = {
63
+ args: {
64
+ leftPanel: LeftPanel,
65
+ centerPanel: CenterPanel,
66
+ rightPanel: RightPanel,
67
+ },
68
+ };
69
+
70
+ export const CustomSpan: Story = {
71
+ args: {
72
+ leftPanel: LeftPanel,
73
+ centerPanel: CenterPanel,
74
+ rightPanel: RightPanel,
75
+ leftSpan: 3,
76
+ centerSpan: 4,
77
+ rightSpan: 5,
78
+ },
79
+ };
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { ReactNode } from "react";
4
+ import { Grid } from "@mantine/core";
5
+
6
+ // ============================================
7
+ // Types
8
+ // ============================================
9
+
10
+ interface EditorLayoutProps {
11
+ leftPanel: ReactNode;
12
+ centerPanel: ReactNode;
13
+ rightPanel: ReactNode;
14
+ leftSpan?: number;
15
+ centerSpan?: number;
16
+ rightSpan?: number;
17
+ }
18
+
19
+ // ============================================
20
+ // Component
21
+ // ============================================
22
+
23
+ export function EditorLayout({
24
+ leftPanel,
25
+ centerPanel,
26
+ rightPanel,
27
+ leftSpan = 2,
28
+ centerSpan = 5,
29
+ rightSpan = 5,
30
+ }: EditorLayoutProps) {
31
+ return (
32
+ <Grid gutter="md">
33
+ {/* 왼쪽: 컴포넌트 선택 영역 */}
34
+ <Grid.Col span={leftSpan}>{leftPanel}</Grid.Col>
35
+
36
+ {/* 중앙: 프리뷰 영역 */}
37
+ <Grid.Col span={centerSpan}>{centerPanel}</Grid.Col>
38
+
39
+ {/* 오른쪽: 설정/등록 영역 */}
40
+ <Grid.Col span={rightSpan}>{rightPanel}</Grid.Col>
41
+ </Grid>
42
+ );
43
+ }
@@ -0,0 +1,292 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Box, Text, Image, FileButton, ActionIcon } from "@mantine/core";
5
+ import { IconX, IconPlus, IconUpload } from "@tabler/icons-react";
6
+ import { readFileAsDataURL } from "../utils/file";
7
+ import { InputFile, Button } from "../atoms";
8
+ import { notifications } from "@mantine/notifications";
9
+
10
+ interface ImageUploadProps {
11
+ value: string[];
12
+ onChange: (images: string[]) => void;
13
+ maxCount?: number;
14
+ variant?: "gallery" | "area" | "input";
15
+ previewWidth?: number;
16
+ previewHeight?: number;
17
+ previewFit?: "cover" | "contain";
18
+ label?: string;
19
+ buttonText?: string;
20
+ placeholder?: string;
21
+ description?: string;
22
+ accept?: string;
23
+ showCount?: boolean;
24
+ /** 업로드 허용 최대 가로 크기(px) */
25
+ maxWidth?: number;
26
+ /** 업로드 허용 최대 세로 크기(px) */
27
+ maxHeight?: number;
28
+ }
29
+
30
+ function DeleteIndicator({ onClick }: { onClick: () => void }) {
31
+ return (
32
+ <ActionIcon
33
+ size={18}
34
+ radius="xl"
35
+ color="red"
36
+ variant="filled"
37
+ style={{ position: "absolute", top: -6, right: -6, zIndex: 10 }}
38
+ className="shadow-sm"
39
+ onClick={(e) => {
40
+ e.stopPropagation();
41
+ onClick();
42
+ }}
43
+ >
44
+ <IconX size={10} stroke={3} />
45
+ </ActionIcon>
46
+ );
47
+ }
48
+
49
+ function validateImageDimensions(file: File, maxW?: number, maxH?: number): Promise<boolean> {
50
+ if (!maxW && !maxH) return Promise.resolve(true);
51
+ return new Promise((resolve) => {
52
+ const img = new window.Image();
53
+ img.onload = () => {
54
+ URL.revokeObjectURL(img.src);
55
+ const isWidthOk = !maxW || img.naturalWidth <= maxW;
56
+ const isHeightOk = !maxH || img.naturalHeight <= maxH;
57
+ resolve(isWidthOk && isHeightOk);
58
+ };
59
+ img.onerror = () => {
60
+ URL.revokeObjectURL(img.src);
61
+ resolve(false);
62
+ };
63
+ img.src = URL.createObjectURL(file);
64
+ });
65
+ }
66
+
67
+ export function ImageUpload({
68
+ value,
69
+ onChange,
70
+ maxCount,
71
+ variant = "gallery",
72
+ previewWidth = 80,
73
+ previewHeight,
74
+ previewFit = "cover",
75
+ label,
76
+ buttonText = "이미지 업로드",
77
+ placeholder = "No Image",
78
+ description,
79
+ accept = "image/png,image/jpeg,image/gif",
80
+ showCount = false,
81
+ maxWidth,
82
+ maxHeight,
83
+ }: ImageUploadProps) {
84
+ const effectiveHeight = previewHeight ?? previewWidth;
85
+ const [dimensionError, setDimensionError] = useState<string | null>(null);
86
+
87
+ const buildDimensionErrorMessage = () => {
88
+ if (maxWidth && maxHeight) return `이미지 크기는 최대 ${maxWidth}x${maxHeight}px 이하여야 합니다.`;
89
+ if (maxWidth) return `이미지 가로 크기는 최대 ${maxWidth}px 이하여야 합니다.`;
90
+ return `이미지 세로 크기는 최대 ${maxHeight}px 이하여야 합니다.`;
91
+ };
92
+
93
+ const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
94
+
95
+ const handleFileSelect = async (file: File | null) => {
96
+ if (!file) return;
97
+ if (file.size > MAX_FILE_SIZE) {
98
+ notifications.show({ title: "업로드 실패", message: "파일 크기는 20MB 이하여야 합니다.", color: "red" });
99
+ return;
100
+ }
101
+ setDimensionError(null);
102
+ const isValid = await validateImageDimensions(file, maxWidth, maxHeight);
103
+ if (!isValid) {
104
+ const msg = buildDimensionErrorMessage();
105
+ setDimensionError(msg);
106
+ notifications.show({ title: "업로드 실패", message: msg, color: "red" });
107
+ return;
108
+ }
109
+ const src = await readFileAsDataURL(file);
110
+ if (maxCount === 1) {
111
+ onChange([src]);
112
+ } else {
113
+ onChange([...value, src]);
114
+ }
115
+ };
116
+
117
+ const handleMultipleFileSelect = async (files: File[]) => {
118
+ if (!files || files.length === 0) return;
119
+ setDimensionError(null);
120
+ const sizeOverCount = files.filter((f) => f.size > MAX_FILE_SIZE).length;
121
+ const sizeValidFiles = files.filter((f) => f.size <= MAX_FILE_SIZE);
122
+ if (sizeOverCount > 0) {
123
+ notifications.show({
124
+ title: "업로드 실패",
125
+ message: `${sizeOverCount}개의 파일이 20MB를 초과하여 제외되었습니다.`,
126
+ color: "red",
127
+ });
128
+ }
129
+ if (sizeValidFiles.length === 0) return;
130
+ const validFiles: File[] = [];
131
+ for (const file of sizeValidFiles) {
132
+ const isValid = await validateImageDimensions(file, maxWidth, maxHeight);
133
+ if (isValid) {
134
+ validFiles.push(file);
135
+ }
136
+ }
137
+ if (validFiles.length < sizeValidFiles.length) {
138
+ const msg = buildDimensionErrorMessage();
139
+ setDimensionError(msg);
140
+ notifications.show({
141
+ title: "업로드 실패",
142
+ message: `${sizeValidFiles.length - validFiles.length}개의 이미지가 크기 제한을 초과하여 제외되었습니다. ${msg}`,
143
+ color: "red",
144
+ });
145
+ }
146
+ if (validFiles.length === 0) return;
147
+ const results = await Promise.all(validFiles.map(readFileAsDataURL));
148
+ onChange([...value, ...results]);
149
+ };
150
+
151
+ const handleRemove = (index: number) => {
152
+ onChange(value.filter((_, i) => i !== index));
153
+ };
154
+
155
+ const isAdd = maxCount === undefined || value.length < maxCount;
156
+
157
+ if (variant === "gallery") {
158
+ return (
159
+ <Box>
160
+ <Box style={{ display: "flex", flexWrap: "wrap", gap: 12, paddingTop: 6, paddingRight: 6 }}>
161
+ {value.map((src, index) => (
162
+ <Box key={index} style={{ position: "relative", width: previewWidth, height: effectiveHeight }}>
163
+ <Image src={src} alt="" w={previewWidth} h={effectiveHeight} fit={previewFit} radius="sm" />
164
+ <DeleteIndicator onClick={() => handleRemove(index)} />
165
+ </Box>
166
+ ))}
167
+ {isAdd && (
168
+ <FileButton onChange={handleFileSelect} accept={accept}>
169
+ {(props) => (
170
+ <Box
171
+ {...props}
172
+ className="flex cursor-pointer items-center justify-center rounded border-2 border-dashed border-gray-300 hover:border-blue-400"
173
+ style={{ width: previewWidth, height: effectiveHeight }}
174
+ >
175
+ <IconPlus size={24} className="text-gray-400" />
176
+ </Box>
177
+ )}
178
+ </FileButton>
179
+ )}
180
+ </Box>
181
+ {showCount && (
182
+ <Text size="xs" c="dimmed" mt="xs">
183
+ 등록된 사진: {value.length}장
184
+ </Text>
185
+ )}
186
+ {description && (
187
+ <Text size="xs" c="dimmed" mt="xs">
188
+ {description}
189
+ </Text>
190
+ )}
191
+ {dimensionError && (
192
+ <Text size="xs" c="red" mt="xs">
193
+ {dimensionError}
194
+ </Text>
195
+ )}
196
+ </Box>
197
+ );
198
+ }
199
+
200
+ if (variant === "area") {
201
+ return (
202
+ <Box className="flex flex-col items-center">
203
+ <FileButton onChange={handleFileSelect} accept={accept}>
204
+ {(props) => (
205
+ <Box
206
+ style={{ position: "relative", width: previewWidth, height: effectiveHeight }}
207
+ {...props}
208
+ >
209
+ <Box
210
+ className="flex h-full w-full cursor-pointer items-center justify-center overflow-hidden rounded-md border border-gray-200 bg-gray-50 transition-colors hover:border-blue-400 hover:bg-gray-100"
211
+ >
212
+ {value[0] ? (
213
+ <Image src={value[0]} alt="" fit={previewFit} className="p-4" />
214
+ ) : (
215
+ <Box className="flex flex-col items-center gap-1">
216
+ <IconUpload size={20} className="text-gray-400" />
217
+ <Text size="xs" c="dimmed" className="font-medium">
218
+ {placeholder}
219
+ </Text>
220
+ </Box>
221
+ )}
222
+ </Box>
223
+ {value[0] && <DeleteIndicator onClick={() => onChange([])} />}
224
+ </Box>
225
+ )}
226
+ </FileButton>
227
+ {description && (
228
+ <Text size="xs" c="dimmed" mt="xs">
229
+ {description}
230
+ </Text>
231
+ )}
232
+ {dimensionError && (
233
+ <Text size="xs" c="red" mt="xs">
234
+ {dimensionError}
235
+ </Text>
236
+ )}
237
+ </Box>
238
+ );
239
+ }
240
+
241
+ // variant === "input"
242
+ const isMultiple = maxCount === undefined || maxCount > 1;
243
+
244
+ return (
245
+ <Box>
246
+ {isMultiple ? (
247
+ <InputFile
248
+ label={label}
249
+ placeholder="이미지를 선택하세요"
250
+ accept={accept}
251
+ leftSection={<IconUpload size={16} />}
252
+ onChange={handleMultipleFileSelect}
253
+ multiple
254
+ value={[]}
255
+ />
256
+ ) : (
257
+ <InputFile
258
+ label={label}
259
+ placeholder="이미지를 선택하세요"
260
+ accept={accept}
261
+ leftSection={<IconUpload size={16} />}
262
+ onChange={handleFileSelect}
263
+ />
264
+ )}
265
+ {value.length > 0 && (
266
+ <Box style={{ display: "flex", flexWrap: "wrap", gap: 12, paddingTop: 6, paddingRight: 6, marginTop: 8 }}>
267
+ {value.map((src, index) => (
268
+ <Box key={index} style={{ position: "relative", width: previewWidth, height: effectiveHeight }}>
269
+ <Image src={src} alt="" w={previewWidth} h={effectiveHeight} fit={previewFit} radius="sm" />
270
+ <DeleteIndicator onClick={() => handleRemove(index)} />
271
+ </Box>
272
+ ))}
273
+ </Box>
274
+ )}
275
+ {showCount && (
276
+ <Text size="xs" c="dimmed" mt="xs">
277
+ {value.length}개의 이미지가 등록되었습니다.
278
+ </Text>
279
+ )}
280
+ {description && (
281
+ <Text size="xs" c="dimmed" mt="xs">
282
+ {description}
283
+ </Text>
284
+ )}
285
+ {dimensionError && (
286
+ <Text size="xs" c="red" mt="xs">
287
+ {dimensionError}
288
+ </Text>
289
+ )}
290
+ </Box>
291
+ );
292
+ }
@@ -0,0 +1,34 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import LabeledField from "./LabeledField";
3
+ import { InputText } from "../atoms/InputText";
4
+
5
+ const meta: Meta<typeof LabeledField> = {
6
+ title: "Blocks/LabeledField",
7
+ component: LabeledField,
8
+ tags: ["autodocs"],
9
+ args: {
10
+ label: "플로팅 제목",
11
+ children: <InputText value="" onChange={() => {}} placeholder="플로팅 제목을 입력하세요" style={{ width: 300 }} />,
12
+ },
13
+ };
14
+
15
+ export default meta;
16
+ type Story = StoryObj<typeof LabeledField>;
17
+
18
+ export const Default: Story = {};
19
+
20
+ export const Description: Story = {
21
+ args: {
22
+ label: "플로팅 제목",
23
+ children: <InputText value="" onChange={() => {}} placeholder="플로팅 제목을 입력하세요" style={{ width: 300 }} />,
24
+ description: "이제 플로팅 제목을 입력할 수 있습니다.",
25
+ },
26
+ };
27
+
28
+ export const BottomDescription: Story = {
29
+ args: {
30
+ label: "플로팅 제목",
31
+ children: <InputText value="" onChange={() => {}} placeholder="플로팅 제목을 입력하세요" style={{ width: 300 }} />,
32
+ bottomDescription: "이제 플로팅 제목을 입력할 수 있습니다.",
33
+ },
34
+ };
@@ -0,0 +1,31 @@
1
+ import { ReactNode } from "react";
2
+ import { Box, Text } from "@mantine/core";
3
+
4
+ interface Props {
5
+ label: string;
6
+ description?: string;
7
+ bottomDescription?: string;
8
+ children: ReactNode;
9
+ }
10
+ const LabeledField = ({ label, description, bottomDescription, children }: Props) => {
11
+ return (
12
+ <Box>
13
+ <Text size="sm" fw={500} mb="xs">
14
+ {label}
15
+ </Text>
16
+ {description && (
17
+ <Text size="xs" c="dimmed" mt="xs" mb="xs">
18
+ {description}
19
+ </Text>
20
+ )}
21
+ {children}
22
+ {bottomDescription && (
23
+ <Text size="xs" c="dimmed" mt="xs">
24
+ {bottomDescription}
25
+ </Text>
26
+ )}
27
+ </Box>
28
+ );
29
+ };
30
+
31
+ export default LabeledField;