@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.
- package/README.md +102 -102
- package/bin/create.js +108 -108
- package/package.json +24 -24
- package/template/.env.example +5 -5
- package/template/.prettierrc +9 -9
- package/template/eslint.config.js +26 -26
- package/template/index.css +32 -32
- package/template/index.html +19 -19
- package/template/package.json +54 -54
- package/template/pnpm-lock.yaml +4214 -4214
- package/template/public/mockServiceWorker.js +349 -349
- package/template/src/app/App.tsx +26 -26
- package/template/src/app/api/api.ts +178 -178
- package/template/src/app/api/queries.ts +335 -335
- package/template/src/app/api/queryKey.ts +7 -7
- package/template/src/app/api/token.ts +8 -7
- package/template/src/app/layout/Layout.tsx +33 -33
- package/template/src/app/layout/ListContents.tsx +9 -9
- package/template/src/app/layout/ListHeader.tsx +41 -41
- package/template/src/app/layout/MultiTabNav.tsx +106 -101
- package/template/src/app/layout/Sidebar.tsx +33 -33
- package/template/src/app/layout/UserInfo.tsx +95 -94
- package/template/src/app/layout/menu.ts +79 -55
- package/template/src/app/layout/tabSwitchStore.ts +11 -11
- package/template/src/app/router/Router.tsx +56 -56
- package/template/src/app/store/index.ts +26 -26
- package/template/src/index.tsx +21 -21
- package/template/src/mocks/browser.ts +17 -17
- package/template/src/mocks/handlers.ts +43 -43
- package/template/src/mocks/scenarios.ts +57 -57
- package/template/src/pages/dashboard/index.tsx +541 -541
- package/template/src/pages/error/Error.tsx +29 -29
- package/template/src/pages/error/NotFound.tsx +27 -27
- package/template/src/pages/login/index.tsx +317 -317
- package/template/src/pages/post/PostFormModal.tsx +128 -128
- package/template/src/pages/post/detail/index.tsx +545 -545
- package/template/src/pages/post/index.tsx +266 -266
- package/template/src/pages/sample/SampleFormModal.tsx +188 -188
- package/template/src/pages/sample/detail/index.tsx +551 -517
- package/template/src/pages/sample/index.tsx +298 -298
- package/template/src/pages/sample/modal/index.tsx +308 -308
- package/template/src/pages/system/log/index.tsx +173 -173
- package/template/src/pages/user/config/columns.tsx +102 -102
- package/template/src/pages/user/config/schema.ts +54 -54
- package/template/src/pages/user/index.tsx +704 -650
- package/template/src/shared/components/CommentInput.tsx +243 -243
- package/template/src/shared/components/FilePreviewCard.tsx +71 -71
- package/template/src/shared/config/text.ts +27 -27
- package/template/src/shared/config/type.ts +40 -40
- package/template/src/shared/utils/format.ts +11 -11
- package/template/src/types/auth.ts +10 -10
- package/template/src/types/comment.ts +33 -33
- package/template/src/types/common.ts +19 -19
- package/template/src/types/dashboard.ts +53 -53
- package/template/src/types/index.ts +16 -16
- package/template/src/types/log.ts +21 -21
- package/template/src/types/post.ts +32 -32
- package/template/src/types/sample.ts +33 -33
- package/template/src/types/user.ts +51 -51
- package/template/src/vite-env.d.ts +10 -10
- package/template/tsconfig.app.json +32 -32
- package/template/tsconfig.json +7 -7
- package/template/tsconfig.node.json +26 -26
- 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
|
+
}
|