@farmzone/fz-template-react 1.0.6 → 1.0.7

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 (64) hide show
  1. package/README.md +102 -102
  2. package/bin/create.js +108 -108
  3. package/package.json +24 -24
  4. package/template/.env.example +5 -5
  5. package/template/.prettierrc +9 -9
  6. package/template/eslint.config.js +26 -26
  7. package/template/index.css +32 -32
  8. package/template/index.html +19 -19
  9. package/template/package.json +54 -54
  10. package/template/pnpm-lock.yaml +4214 -4214
  11. package/template/public/mockServiceWorker.js +349 -349
  12. package/template/src/app/App.tsx +26 -26
  13. package/template/src/app/api/api.ts +178 -178
  14. package/template/src/app/api/queries.ts +335 -335
  15. package/template/src/app/api/queryKey.ts +7 -7
  16. package/template/src/app/api/token.ts +8 -7
  17. package/template/src/app/layout/Layout.tsx +33 -33
  18. package/template/src/app/layout/ListContents.tsx +9 -9
  19. package/template/src/app/layout/ListHeader.tsx +41 -41
  20. package/template/src/app/layout/MultiTabNav.tsx +106 -101
  21. package/template/src/app/layout/Sidebar.tsx +33 -33
  22. package/template/src/app/layout/UserInfo.tsx +95 -94
  23. package/template/src/app/layout/menu.ts +79 -55
  24. package/template/src/app/layout/tabSwitchStore.ts +11 -11
  25. package/template/src/app/router/Router.tsx +56 -56
  26. package/template/src/app/store/index.ts +26 -26
  27. package/template/src/index.tsx +21 -21
  28. package/template/src/mocks/browser.ts +17 -17
  29. package/template/src/mocks/handlers.ts +43 -43
  30. package/template/src/mocks/scenarios.ts +57 -57
  31. package/template/src/pages/dashboard/index.tsx +541 -541
  32. package/template/src/pages/error/Error.tsx +29 -29
  33. package/template/src/pages/error/NotFound.tsx +27 -27
  34. package/template/src/pages/login/index.tsx +317 -317
  35. package/template/src/pages/post/PostFormModal.tsx +128 -128
  36. package/template/src/pages/post/detail/index.tsx +545 -545
  37. package/template/src/pages/post/index.tsx +266 -266
  38. package/template/src/pages/sample/SampleFormModal.tsx +188 -188
  39. package/template/src/pages/sample/detail/index.tsx +551 -517
  40. package/template/src/pages/sample/index.tsx +298 -298
  41. package/template/src/pages/sample/modal/index.tsx +308 -308
  42. package/template/src/pages/system/log/index.tsx +173 -173
  43. package/template/src/pages/user/config/columns.tsx +102 -102
  44. package/template/src/pages/user/config/schema.ts +54 -54
  45. package/template/src/pages/user/index.tsx +704 -650
  46. package/template/src/shared/components/CommentInput.tsx +243 -243
  47. package/template/src/shared/components/FilePreviewCard.tsx +71 -71
  48. package/template/src/shared/config/text.ts +27 -27
  49. package/template/src/shared/config/type.ts +40 -40
  50. package/template/src/shared/utils/format.ts +11 -11
  51. package/template/src/types/auth.ts +10 -10
  52. package/template/src/types/comment.ts +33 -33
  53. package/template/src/types/common.ts +19 -19
  54. package/template/src/types/dashboard.ts +53 -53
  55. package/template/src/types/index.ts +16 -16
  56. package/template/src/types/log.ts +21 -21
  57. package/template/src/types/post.ts +32 -32
  58. package/template/src/types/sample.ts +33 -33
  59. package/template/src/types/user.ts +51 -51
  60. package/template/src/vite-env.d.ts +10 -10
  61. package/template/tsconfig.app.json +32 -32
  62. package/template/tsconfig.json +7 -7
  63. package/template/tsconfig.node.json +26 -26
  64. package/template/vite.config.ts +13 -13
@@ -1,243 +1,243 @@
1
- import { useCallback, useRef, useState } from "react";
2
- import { useQuery } from "@tanstack/react-query";
3
-
4
- import { apiInstance } from "@/app/api/api";
5
- import { USER_QUERY_KEY } from "@/app/api/queryKey";
6
- import { usePostComment } from "@/app/api/queries";
7
- import type { PageResponse, User } from "@/types";
8
- import { Button } from "@farmzone/fz-react-ui";
9
-
10
- interface CommentInputProps {
11
- // create mode
12
- targetType?: string;
13
- targetId?: number;
14
- parentCommentId?: number | null;
15
- // edit mode — when onSave is provided, calls it instead of POST API
16
- initialContent?: string;
17
- initialMentionUserIds?: Array<number>;
18
- onSave?: (content: string, mentionUserIds: Array<number>) => Promise<void>;
19
- isSaving?: boolean;
20
- saveLabel?: string;
21
- onCancel?: () => void;
22
- // common
23
- placeholder?: string;
24
- onSuccess?: () => void;
25
- }
26
-
27
- interface ActiveMention {
28
- query: string;
29
- atIndex: number;
30
- }
31
-
32
- // Find the @mention word the cursor is currently inside
33
- function resolveMention(text: string, cursor: number): ActiveMention | null {
34
- let i = cursor - 1;
35
- while (i >= 0 && !/\s/.test(text[i])) i--;
36
- const wordStart = i + 1;
37
- if (wordStart >= cursor || text[wordStart] !== "@") return null;
38
- return { query: text.slice(wordStart + 1, cursor), atIndex: wordStart };
39
- }
40
-
41
- export default function CommentInput({
42
- targetType,
43
- targetId,
44
- parentCommentId,
45
- initialContent = "",
46
- initialMentionUserIds = [],
47
- onSave,
48
- isSaving = false,
49
- saveLabel,
50
- onCancel,
51
- placeholder,
52
- onSuccess,
53
- }: CommentInputProps) {
54
- const textareaRef = useRef<HTMLTextAreaElement>(null);
55
- const [content, setContent] = useState(initialContent);
56
- const [mentionUserIds, setMentionUserIds] = useState<Array<number>>(initialMentionUserIds);
57
- const [activeMention, setActiveMention] = useState<ActiveMention | null>(null);
58
- const [highlightedIdx, setHighlightedIdx] = useState(0);
59
-
60
- // Fetch users matching the current @query
61
- const { data } = useQuery({
62
- queryKey: [USER_QUERY_KEY, "mention", activeMention?.query],
63
- queryFn: () =>
64
- apiInstance
65
- .get<PageResponse<User>>("/users", {
66
- params: {
67
- page: 0,
68
- size: 8,
69
- ...(activeMention?.query ? { keyword: activeMention.query, keywordType: "name" } : {}),
70
- },
71
- })
72
- .then((r) => r.data),
73
- enabled: activeMention !== null,
74
- staleTime: 30_000,
75
- });
76
-
77
- const mentionUsers = data?.content ?? [];
78
- const { mutateAsync: postComment, isPending } = usePostComment();
79
-
80
- const syncMention = useCallback((text: string, cursor: number) => {
81
- const next = resolveMention(text, cursor);
82
- setActiveMention(next);
83
- if (next) setHighlightedIdx(0);
84
- }, []);
85
-
86
- const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
87
- const { value, selectionStart } = e.target;
88
- setContent(value);
89
- syncMention(value, selectionStart ?? value.length);
90
- };
91
-
92
- // Also update on cursor position change (arrow keys, mouse click inside textarea)
93
- const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
94
- const { value, selectionStart } = e.currentTarget;
95
- syncMention(value, selectionStart ?? value.length);
96
- };
97
-
98
- const selectUser = useCallback(
99
- (user: User) => {
100
- if (!activeMention) return;
101
- const cursor = textareaRef.current?.selectionStart ?? content.length;
102
- const insertText = `@${user.name} `;
103
- const newContent = content.slice(0, activeMention.atIndex) + insertText + content.slice(cursor);
104
-
105
- setContent(newContent);
106
- setMentionUserIds((ids) => (ids.includes(user.id) ? ids : [...ids, user.id]));
107
- setActiveMention(null);
108
-
109
- requestAnimationFrame(() => {
110
- if (!textareaRef.current) return;
111
- const newPos = activeMention.atIndex + insertText.length;
112
- textareaRef.current.focus();
113
- textareaRef.current.setSelectionRange(newPos, newPos);
114
- });
115
- },
116
- [activeMention, content],
117
- );
118
-
119
- const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
120
- if (!activeMention || mentionUsers.length === 0) return;
121
-
122
- if (e.key === "ArrowDown") {
123
- e.preventDefault();
124
- setHighlightedIdx((i) => (i + 1) % mentionUsers.length);
125
- } else if (e.key === "ArrowUp") {
126
- e.preventDefault();
127
- setHighlightedIdx((i) => (i - 1 + mentionUsers.length) % mentionUsers.length);
128
- } else if (e.key === "Enter") {
129
- e.preventDefault();
130
- selectUser(mentionUsers[highlightedIdx]);
131
- } else if (e.key === "Escape") {
132
- e.preventDefault();
133
- setActiveMention(null);
134
- }
135
- };
136
-
137
- const handleSubmit = async () => {
138
- const trimmed = content.trim();
139
- if (!trimmed) return;
140
- if (onSave) {
141
- await onSave(trimmed, mentionUserIds);
142
- } else {
143
- await postComment({
144
- targetType: targetType!,
145
- targetId: targetId!,
146
- parentCommentId: parentCommentId ?? null,
147
- content: trimmed,
148
- mentionUserIds: mentionUserIds.length > 0 ? mentionUserIds : undefined,
149
- });
150
- setContent("");
151
- setMentionUserIds([]);
152
- }
153
- onSuccess?.();
154
- };
155
-
156
- const isPopoverOpen = activeMention !== null && mentionUsers.length > 0;
157
-
158
- return (
159
- <div className="relative">
160
- {/* @멘션 팝오버 */}
161
- {isPopoverOpen && (
162
- <div className="absolute bottom-full left-0 mb-1.5 w-64 bg-white border border-gray-200 rounded-lg shadow-xl z-50 overflow-hidden">
163
- <div className="px-3 py-1.5 border-b border-gray-100 bg-gray-50">
164
- <span className="text-[10px] font-semibold text-gray-400 tracking-wide uppercase">
165
- 사용자 멘션
166
- </span>
167
- </div>
168
-
169
- <ul className="max-h-52 overflow-y-auto">
170
- {mentionUsers.map((user, idx) => (
171
- <li key={user.id}>
172
- <button
173
- type="button"
174
- className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
175
- idx === highlightedIdx ? "bg-blue-50" : "hover:bg-gray-50"
176
- }`}
177
- onMouseDown={(e) => e.preventDefault()} // textarea 포커스 유지
178
- onClick={() => selectUser(user)}
179
- onMouseEnter={() => setHighlightedIdx(idx)}
180
- >
181
- <div className="w-7 h-7 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 text-xs font-bold shrink-0">
182
- {user.name.slice(0, 1)}
183
- </div>
184
- <div className="min-w-0 text-left">
185
- <div
186
- className={`font-medium truncate leading-snug ${
187
- idx === highlightedIdx ? "text-blue-700" : "text-gray-800"
188
- }`}
189
- >
190
- {user.name}
191
- </div>
192
- <div className="text-[11px] text-gray-400 truncate leading-snug">{user.userId}</div>
193
- </div>
194
- </button>
195
- </li>
196
- ))}
197
- </ul>
198
-
199
- <div className="px-3 py-1.5 border-t border-gray-100 bg-gray-50">
200
- <p className="text-[10px] text-gray-400">↑↓ 이동 · Enter 선택 · Esc 닫기</p>
201
- </div>
202
- </div>
203
- )}
204
-
205
- {/* 입력 영역 */}
206
- <div className="border border-gray-200 rounded-lg overflow-hidden focus-within:border-[var(--color-main)] transition-colors">
207
- <textarea
208
- ref={textareaRef}
209
- value={content}
210
- onChange={handleChange}
211
- onSelect={handleSelect}
212
- onKeyDown={handleKeyDown}
213
- placeholder={placeholder ?? "댓글을 입력하세요. @를 입력하면 사용자를 멘션할 수 있습니다."}
214
- rows={3}
215
- className="w-full px-3 py-2.5 text-sm resize-none focus:outline-none bg-white"
216
- />
217
- <div className="flex items-center justify-between px-3 py-2 border-t border-gray-100 bg-gray-50">
218
- <span className="text-[11px] text-gray-400">
219
- {mentionUserIds.length > 0
220
- ? `${mentionUserIds.length}명 멘션됨`
221
- : "@를 입력해 사용자를 멘션하세요"}
222
- </span>
223
- <div className="flex items-center gap-2">
224
- {onCancel && (
225
- <Button type="button" variant="outline" size="sm" onClick={onCancel}>
226
- 취소
227
- </Button>
228
- )}
229
- <Button
230
- type="button"
231
- variant="save"
232
- size="sm"
233
- onClick={() => void handleSubmit()}
234
- disabled={!content.trim() || isPending || isSaving}
235
- >
236
- {isPending || isSaving ? `${saveLabel ?? "등록"} 중...` : (saveLabel ?? "등록")}
237
- </Button>
238
- </div>
239
- </div>
240
- </div>
241
- </div>
242
- );
243
- }
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+
4
+ import { apiInstance } from "@/app/api/api";
5
+ import { USER_QUERY_KEY } from "@/app/api/queryKey";
6
+ import { usePostComment } from "@/app/api/queries";
7
+ import type { PageResponse, User } from "@/types";
8
+ import { Button } from "@farmzone/fz-react-ui";
9
+
10
+ interface CommentInputProps {
11
+ // create mode
12
+ targetType?: string;
13
+ targetId?: number;
14
+ parentCommentId?: number | null;
15
+ // edit mode — when onSave is provided, calls it instead of POST API
16
+ initialContent?: string;
17
+ initialMentionUserIds?: Array<number>;
18
+ onSave?: (content: string, mentionUserIds: Array<number>) => Promise<void>;
19
+ isSaving?: boolean;
20
+ saveLabel?: string;
21
+ onCancel?: () => void;
22
+ // common
23
+ placeholder?: string;
24
+ onSuccess?: () => void;
25
+ }
26
+
27
+ interface ActiveMention {
28
+ query: string;
29
+ atIndex: number;
30
+ }
31
+
32
+ // Find the @mention word the cursor is currently inside
33
+ function resolveMention(text: string, cursor: number): ActiveMention | null {
34
+ let i = cursor - 1;
35
+ while (i >= 0 && !/\s/.test(text[i])) i--;
36
+ const wordStart = i + 1;
37
+ if (wordStart >= cursor || text[wordStart] !== "@") return null;
38
+ return { query: text.slice(wordStart + 1, cursor), atIndex: wordStart };
39
+ }
40
+
41
+ export default function CommentInput({
42
+ targetType,
43
+ targetId,
44
+ parentCommentId,
45
+ initialContent = "",
46
+ initialMentionUserIds = [],
47
+ onSave,
48
+ isSaving = false,
49
+ saveLabel,
50
+ onCancel,
51
+ placeholder,
52
+ onSuccess,
53
+ }: CommentInputProps) {
54
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
55
+ const [content, setContent] = useState(initialContent);
56
+ const [mentionUserIds, setMentionUserIds] = useState<Array<number>>(initialMentionUserIds);
57
+ const [activeMention, setActiveMention] = useState<ActiveMention | null>(null);
58
+ const [highlightedIdx, setHighlightedIdx] = useState(0);
59
+
60
+ // Fetch users matching the current @query
61
+ const { data } = useQuery({
62
+ queryKey: [USER_QUERY_KEY, "mention", activeMention?.query],
63
+ queryFn: () =>
64
+ apiInstance
65
+ .get<PageResponse<User>>("/users", {
66
+ params: {
67
+ page: 0,
68
+ size: 8,
69
+ ...(activeMention?.query ? { keyword: activeMention.query, keywordType: "name" } : {}),
70
+ },
71
+ })
72
+ .then((r) => r.data),
73
+ enabled: activeMention !== null,
74
+ staleTime: 30_000,
75
+ });
76
+
77
+ const mentionUsers = data?.content ?? [];
78
+ const { mutateAsync: postComment, isPending } = usePostComment();
79
+
80
+ const syncMention = useCallback((text: string, cursor: number) => {
81
+ const next = resolveMention(text, cursor);
82
+ setActiveMention(next);
83
+ if (next) setHighlightedIdx(0);
84
+ }, []);
85
+
86
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
87
+ const { value, selectionStart } = e.target;
88
+ setContent(value);
89
+ syncMention(value, selectionStart ?? value.length);
90
+ };
91
+
92
+ // Also update on cursor position change (arrow keys, mouse click inside textarea)
93
+ const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
94
+ const { value, selectionStart } = e.currentTarget;
95
+ syncMention(value, selectionStart ?? value.length);
96
+ };
97
+
98
+ const selectUser = useCallback(
99
+ (user: User) => {
100
+ if (!activeMention) return;
101
+ const cursor = textareaRef.current?.selectionStart ?? content.length;
102
+ const insertText = `@${user.name} `;
103
+ const newContent = content.slice(0, activeMention.atIndex) + insertText + content.slice(cursor);
104
+
105
+ setContent(newContent);
106
+ setMentionUserIds((ids) => (ids.includes(user.id) ? ids : [...ids, user.id]));
107
+ setActiveMention(null);
108
+
109
+ requestAnimationFrame(() => {
110
+ if (!textareaRef.current) return;
111
+ const newPos = activeMention.atIndex + insertText.length;
112
+ textareaRef.current.focus();
113
+ textareaRef.current.setSelectionRange(newPos, newPos);
114
+ });
115
+ },
116
+ [activeMention, content],
117
+ );
118
+
119
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
120
+ if (!activeMention || mentionUsers.length === 0) return;
121
+
122
+ if (e.key === "ArrowDown") {
123
+ e.preventDefault();
124
+ setHighlightedIdx((i) => (i + 1) % mentionUsers.length);
125
+ } else if (e.key === "ArrowUp") {
126
+ e.preventDefault();
127
+ setHighlightedIdx((i) => (i - 1 + mentionUsers.length) % mentionUsers.length);
128
+ } else if (e.key === "Enter") {
129
+ e.preventDefault();
130
+ selectUser(mentionUsers[highlightedIdx]);
131
+ } else if (e.key === "Escape") {
132
+ e.preventDefault();
133
+ setActiveMention(null);
134
+ }
135
+ };
136
+
137
+ const handleSubmit = async () => {
138
+ const trimmed = content.trim();
139
+ if (!trimmed) return;
140
+ if (onSave) {
141
+ await onSave(trimmed, mentionUserIds);
142
+ } else {
143
+ await postComment({
144
+ targetType: targetType!,
145
+ targetId: targetId!,
146
+ parentCommentId: parentCommentId ?? null,
147
+ content: trimmed,
148
+ mentionUserIds: mentionUserIds.length > 0 ? mentionUserIds : undefined,
149
+ });
150
+ setContent("");
151
+ setMentionUserIds([]);
152
+ }
153
+ onSuccess?.();
154
+ };
155
+
156
+ const isPopoverOpen = activeMention !== null && mentionUsers.length > 0;
157
+
158
+ return (
159
+ <div className="relative">
160
+ {/* @멘션 팝오버 */}
161
+ {isPopoverOpen && (
162
+ <div className="absolute bottom-full left-0 mb-1.5 w-64 bg-white border border-gray-200 rounded-lg shadow-xl z-50 overflow-hidden">
163
+ <div className="px-3 py-1.5 border-b border-gray-100 bg-gray-50">
164
+ <span className="text-[10px] font-semibold text-gray-400 tracking-wide uppercase">
165
+ 사용자 멘션
166
+ </span>
167
+ </div>
168
+
169
+ <ul className="max-h-52 overflow-y-auto">
170
+ {mentionUsers.map((user, idx) => (
171
+ <li key={user.id}>
172
+ <button
173
+ type="button"
174
+ className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
175
+ idx === highlightedIdx ? "bg-blue-50" : "hover:bg-gray-50"
176
+ }`}
177
+ onMouseDown={(e) => e.preventDefault()} // textarea 포커스 유지
178
+ onClick={() => selectUser(user)}
179
+ onMouseEnter={() => setHighlightedIdx(idx)}
180
+ >
181
+ <div className="w-7 h-7 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 text-xs font-bold shrink-0">
182
+ {user.name.slice(0, 1)}
183
+ </div>
184
+ <div className="min-w-0 text-left">
185
+ <div
186
+ className={`font-medium truncate leading-snug ${
187
+ idx === highlightedIdx ? "text-blue-700" : "text-gray-800"
188
+ }`}
189
+ >
190
+ {user.name}
191
+ </div>
192
+ <div className="text-[11px] text-gray-400 truncate leading-snug">{user.userId}</div>
193
+ </div>
194
+ </button>
195
+ </li>
196
+ ))}
197
+ </ul>
198
+
199
+ <div className="px-3 py-1.5 border-t border-gray-100 bg-gray-50">
200
+ <p className="text-[10px] text-gray-400">↑↓ 이동 · Enter 선택 · Esc 닫기</p>
201
+ </div>
202
+ </div>
203
+ )}
204
+
205
+ {/* 입력 영역 */}
206
+ <div className="border border-gray-200 rounded-lg overflow-hidden focus-within:border-[var(--color-main)] transition-colors">
207
+ <textarea
208
+ ref={textareaRef}
209
+ value={content}
210
+ onChange={handleChange}
211
+ onSelect={handleSelect}
212
+ onKeyDown={handleKeyDown}
213
+ placeholder={placeholder ?? "댓글을 입력하세요. @를 입력하면 사용자를 멘션할 수 있습니다."}
214
+ rows={3}
215
+ className="w-full px-3 py-2.5 text-sm resize-none focus:outline-none bg-white"
216
+ />
217
+ <div className="flex items-center justify-between px-3 py-2 border-t border-gray-100 bg-gray-50">
218
+ <span className="text-[11px] text-gray-400">
219
+ {mentionUserIds.length > 0
220
+ ? `${mentionUserIds.length}명 멘션됨`
221
+ : "@를 입력해 사용자를 멘션하세요"}
222
+ </span>
223
+ <div className="flex items-center gap-2">
224
+ {onCancel && (
225
+ <Button type="button" variant="outline" size="sm" onClick={onCancel}>
226
+ 취소
227
+ </Button>
228
+ )}
229
+ <Button
230
+ type="button"
231
+ variant="save"
232
+ size="sm"
233
+ onClick={() => void handleSubmit()}
234
+ disabled={!content.trim() || isPending || isSaving}
235
+ >
236
+ {isPending || isSaving ? `${saveLabel ?? "등록"} 중...` : (saveLabel ?? "등록")}
237
+ </Button>
238
+ </div>
239
+ </div>
240
+ </div>
241
+ </div>
242
+ );
243
+ }
@@ -1,71 +1,71 @@
1
- import { FileText } from "lucide-react";
2
-
3
- import { cn, getPreviewFileType, useStableImageSrc } from "@farmzone/fz-react-ui";
4
-
5
- interface FilePreviewCardProps {
6
- src: string;
7
- fileName: string;
8
- fileSize?: string;
9
- onClick?: () => void;
10
- className?: string;
11
- fileType?: "image" | "pdf" | "unsupported";
12
- }
13
-
14
- /** 예시용 파일 카드 UI — FilePreviewViewer와 분리, 사용처에서 자유롭게 커스터마이징 */
15
- export default function FilePreviewCard(props: FilePreviewCardProps) {
16
- const { src, fileName, fileSize, onClick, className, fileType: fileTypeProp } = props;
17
-
18
- const fileType = fileTypeProp ?? getPreviewFileType(src);
19
- const isImage = fileType === "image";
20
- const imageSrc = useStableImageSrc(isImage ? src : undefined);
21
-
22
- const shortName = fileName.length > 12 ? `${fileName.slice(0, 10)}...` : fileName;
23
-
24
- if (isImage) {
25
- return (
26
- <button
27
- type="button"
28
- onClick={onClick}
29
- className={cn(
30
- "group flex flex-col w-full h-30 rounded-lg border border-gray-200 overflow-hidden bg-white hover:border-gray-300 hover:shadow-md transition-all duration-200 text-left cursor-pointer",
31
- className,
32
- )}
33
- >
34
- <div className="flex-1 min-h-0 bg-gray-50 overflow-hidden">
35
- <img
36
- src={imageSrc}
37
- alt={fileName}
38
- className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
39
- />
40
- </div>
41
- <div className="flex items-center justify-between gap-2 border-t border-gray-200 px-3 py-2 text-xs">
42
- <span className="truncate text-gray-800">{fileName}</span>
43
- {fileSize ? <span className="shrink-0 text-gray-400">{fileSize}</span> : null}
44
- </div>
45
- </button>
46
- );
47
- }
48
-
49
- return (
50
- <button
51
- type="button"
52
- onClick={onClick}
53
- className={cn(
54
- "group flex flex-col w-full h-30 rounded-lg border border-gray-200 overflow-hidden bg-white hover:border-gray-300 hover:shadow-md transition-all duration-200 text-left cursor-pointer",
55
- className,
56
- )}
57
- >
58
- <div className="flex flex-1 flex-col items-center justify-center gap-2 px-3 min-h-0 bg-gray-50">
59
- <FileText
60
- className="size-10 text-gray-400 shrink-0 transition-transform duration-200 group-hover:scale-110 group-hover:text-gray-500"
61
- strokeWidth={1.5}
62
- />
63
- <span className="text-sm text-gray-700 truncate max-w-full text-center">{shortName}</span>
64
- </div>
65
- <div className="flex items-center justify-between gap-2 border-t border-gray-200 px-3 py-2 text-xs">
66
- <span className="truncate text-gray-800">{fileName}</span>
67
- {fileSize ? <span className="shrink-0 text-gray-400">{fileSize}</span> : null}
68
- </div>
69
- </button>
70
- );
71
- }
1
+ import { FileText } from "lucide-react";
2
+
3
+ import { cn, getPreviewFileType, useStableImageSrc } from "@farmzone/fz-react-ui";
4
+
5
+ interface FilePreviewCardProps {
6
+ src: string;
7
+ fileName: string;
8
+ fileSize?: string;
9
+ onClick?: () => void;
10
+ className?: string;
11
+ fileType?: "image" | "pdf" | "unsupported";
12
+ }
13
+
14
+ /** 예시용 파일 카드 UI — FilePreviewViewer와 분리, 사용처에서 자유롭게 커스터마이징 */
15
+ export default function FilePreviewCard(props: FilePreviewCardProps) {
16
+ const { src, fileName, fileSize, onClick, className, fileType: fileTypeProp } = props;
17
+
18
+ const fileType = fileTypeProp ?? getPreviewFileType(src);
19
+ const isImage = fileType === "image";
20
+ const imageSrc = useStableImageSrc(isImage ? src : undefined);
21
+
22
+ const shortName = fileName.length > 12 ? `${fileName.slice(0, 10)}...` : fileName;
23
+
24
+ if (isImage) {
25
+ return (
26
+ <button
27
+ type="button"
28
+ onClick={onClick}
29
+ className={cn(
30
+ "group flex flex-col w-full h-30 rounded-lg border border-gray-200 overflow-hidden bg-white hover:border-gray-300 hover:shadow-md transition-all duration-200 text-left cursor-pointer",
31
+ className,
32
+ )}
33
+ >
34
+ <div className="flex-1 min-h-0 bg-gray-50 overflow-hidden">
35
+ <img
36
+ src={imageSrc}
37
+ alt={fileName}
38
+ className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
39
+ />
40
+ </div>
41
+ <div className="flex items-center justify-between gap-2 border-t border-gray-200 px-3 py-2 text-xs">
42
+ <span className="truncate text-gray-800">{fileName}</span>
43
+ {fileSize ? <span className="shrink-0 text-gray-400">{fileSize}</span> : null}
44
+ </div>
45
+ </button>
46
+ );
47
+ }
48
+
49
+ return (
50
+ <button
51
+ type="button"
52
+ onClick={onClick}
53
+ className={cn(
54
+ "group flex flex-col w-full h-30 rounded-lg border border-gray-200 overflow-hidden bg-white hover:border-gray-300 hover:shadow-md transition-all duration-200 text-left cursor-pointer",
55
+ className,
56
+ )}
57
+ >
58
+ <div className="flex flex-1 flex-col items-center justify-center gap-2 px-3 min-h-0 bg-gray-50">
59
+ <FileText
60
+ className="size-10 text-gray-400 shrink-0 transition-transform duration-200 group-hover:scale-110 group-hover:text-gray-500"
61
+ strokeWidth={1.5}
62
+ />
63
+ <span className="text-sm text-gray-700 truncate max-w-full text-center">{shortName}</span>
64
+ </div>
65
+ <div className="flex items-center justify-between gap-2 border-t border-gray-200 px-3 py-2 text-xs">
66
+ <span className="truncate text-gray-800">{fileName}</span>
67
+ {fileSize ? <span className="shrink-0 text-gray-400">{fileSize}</span> : null}
68
+ </div>
69
+ </button>
70
+ );
71
+ }