@conscience-technology/ctds 0.1.9 → 0.1.10
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/assets/components/AIMessage/AIMessage.module.css +117 -0
- package/assets/components/AIMessage/AIMessage.tsx +90 -0
- package/assets/components/AIMessage/index.ts +2 -0
- package/assets/components/AttachmentChip/AttachmentChip.module.css +3 -1
- package/assets/components/AttachmentThumb/AttachmentThumb.module.css +13 -7
- package/assets/components/AttachmentThumb/AttachmentThumb.tsx +4 -1
- package/assets/components/Bubble/Bubble.module.css +65 -0
- package/assets/components/Bubble/Bubble.tsx +63 -0
- package/assets/components/Bubble/index.ts +2 -0
- package/assets/components/Button/Button.module.css +15 -2
- package/assets/components/Button/Button.tsx +13 -0
- package/assets/components/Calendar/Calendar.module.css +163 -0
- package/assets/components/Calendar/Calendar.tsx +323 -0
- package/assets/components/Calendar/index.ts +2 -0
- package/assets/components/ChatInput/ChatInput.module.css +82 -45
- package/assets/components/ChatInput/ChatInput.tsx +46 -34
- package/assets/components/ChatInput/ModelSelector.tsx +23 -27
- package/assets/components/ChatInput/index.ts +0 -1
- package/assets/components/CodeBlock/CodeBlock.module.css +79 -0
- package/assets/components/CodeBlock/CodeBlock.tsx +71 -0
- package/assets/components/CodeBlock/index.ts +2 -0
- package/assets/components/ContextMenu/ContextMenu.tsx +10 -1
- package/assets/components/DropdownMenu/DropdownMenu.module.css +26 -0
- package/assets/components/DropdownMenu/DropdownMenu.tsx +367 -0
- package/assets/components/DropdownMenu/index.ts +9 -0
- package/assets/components/EventChip/EventChip.module.css +57 -0
- package/assets/components/EventChip/EventChip.tsx +41 -0
- package/assets/components/EventChip/index.ts +2 -0
- package/assets/components/Icon/Icon.tsx +3 -0
- package/assets/components/IdentityButton/IdentityButton.module.css +119 -0
- package/assets/components/IdentityButton/IdentityButton.tsx +113 -0
- package/assets/components/IdentityButton/index.ts +6 -0
- package/assets/components/MenuItem/MenuItem.module.css +67 -2
- package/assets/components/MenuItem/MenuItem.tsx +22 -2
- package/assets/components/MessageActions/MessageActions.module.css +43 -0
- package/assets/components/MessageActions/MessageActions.tsx +94 -0
- package/assets/components/MessageActions/index.ts +6 -0
- package/assets/components/MessageQuote/MessageQuote.module.css +18 -0
- package/assets/components/MessageQuote/MessageQuote.tsx +33 -0
- package/assets/components/MessageQuote/index.ts +2 -0
- package/assets/components/MessageSources/MessageSources.module.css +63 -0
- package/assets/components/MessageSources/MessageSources.tsx +50 -0
- package/assets/components/MessageSources/index.ts +2 -0
- package/assets/components/MessageTable/MessageTable.module.css +46 -0
- package/assets/components/MessageTable/MessageTable.tsx +21 -0
- package/assets/components/MessageTable/index.ts +2 -0
- package/assets/components/QuotationBar/QuotationBar.module.css +2 -2
- package/assets/components/Sidebar/Sidebar.module.css +3 -57
- package/assets/components/Sidebar/Sidebar.tsx +18 -21
- package/assets/components/Spinner/LottiePlayer.tsx +75 -0
- package/assets/components/Spinner/Spinner.module.css +167 -0
- package/assets/components/Spinner/Spinner.tsx +119 -0
- package/assets/components/Spinner/index.ts +10 -0
- package/assets/components/Spinner/lottieColor.ts +76 -0
- package/assets/components/Spinner/lottieManifest.ts +99 -0
- package/assets/components/Tabs/Tabs.module.css +3 -3
- package/assets/components/UserMessage/UserMessage.module.css +41 -0
- package/assets/components/UserMessage/UserMessage.tsx +66 -0
- package/assets/components/UserMessage/index.ts +2 -0
- package/assets/components/_data.ts +18 -4
- package/assets/components/tokens.css +20 -0
- package/assets/prompts/components/ai-message.md +43 -0
- package/assets/prompts/components/bubble.md +56 -0
- package/assets/prompts/components/calendar.md +123 -0
- package/assets/prompts/components/code-block.md +35 -0
- package/assets/prompts/components/dropdown-menu.md +145 -0
- package/assets/prompts/components/identity-button.md +168 -0
- package/assets/prompts/components/message-actions.md +49 -0
- package/assets/prompts/components/message-quote.md +26 -0
- package/assets/prompts/components/message-sources.md +26 -0
- package/assets/prompts/components/message-table.md +27 -0
- package/assets/prompts/components/sidebar.md +48 -3
- package/assets/prompts/components/spinner.md +109 -0
- package/assets/prompts/components/user-message.md +45 -0
- package/assets/prompts/primitives/scroll.md +22 -11
- package/package.json +1 -1
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
memo,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type HTMLAttributes,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from "react";
|
|
13
|
+
|
|
14
|
+
import { Badge } from "../Badge";
|
|
15
|
+
import { Button } from "../Button";
|
|
16
|
+
import { EventChip, type EventChipTone } from "../EventChip";
|
|
17
|
+
import { Icon } from "../Icon";
|
|
18
|
+
import { IconButton } from "../IconButton";
|
|
19
|
+
import {
|
|
20
|
+
addMonths,
|
|
21
|
+
buildMonthGrid,
|
|
22
|
+
isSameDay,
|
|
23
|
+
isSameMonth,
|
|
24
|
+
formatISODate,
|
|
25
|
+
MONTH_LABELS_KO,
|
|
26
|
+
WEEKDAY_LABELS_KO,
|
|
27
|
+
startOfMonth,
|
|
28
|
+
} from "../DatePicker/utils";
|
|
29
|
+
import styles from "./Calendar.module.css";
|
|
30
|
+
|
|
31
|
+
export type CalendarEventTone = EventChipTone;
|
|
32
|
+
|
|
33
|
+
export type CalendarEvent = {
|
|
34
|
+
id: string;
|
|
35
|
+
/** ISO yyyy-MM-dd */
|
|
36
|
+
date: string;
|
|
37
|
+
title: string;
|
|
38
|
+
tone?: CalendarEventTone;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type CalendarProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
|
|
42
|
+
/** 현재 보여줄 달. controlled. */
|
|
43
|
+
month: Date;
|
|
44
|
+
/** 오늘 날짜. 테스트/스토리북에서 고정하려면 넘김. 기본 new Date(). */
|
|
45
|
+
today?: Date;
|
|
46
|
+
/** 선택된 날짜. 셀 하이라이트. */
|
|
47
|
+
selectedDate?: Date | null;
|
|
48
|
+
events?: CalendarEvent[];
|
|
49
|
+
/** 요일 시작. 0=일, 1=월. 기본 1 (업무 캘린더 관행). */
|
|
50
|
+
weekStartsOn?: 0 | 1;
|
|
51
|
+
showWeekend?: boolean;
|
|
52
|
+
/** 상단바 왼쪽 커스텀 슬롯 (예: Google 연결 상태). */
|
|
53
|
+
leadingSlot?: ReactNode;
|
|
54
|
+
/** 상단바 오른쪽 "오늘" 버튼 표시 여부. 기본 true. */
|
|
55
|
+
showTodayButton?: boolean;
|
|
56
|
+
onMonthChange?: (next: Date) => void;
|
|
57
|
+
onSelectedDateChange?: (next: Date) => void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const Calendar = forwardRef<HTMLDivElement, CalendarProps>(function Calendar(
|
|
61
|
+
{
|
|
62
|
+
className,
|
|
63
|
+
month,
|
|
64
|
+
today: todayProp,
|
|
65
|
+
selectedDate = null,
|
|
66
|
+
events = [],
|
|
67
|
+
weekStartsOn = 1,
|
|
68
|
+
showWeekend = true,
|
|
69
|
+
leadingSlot,
|
|
70
|
+
showTodayButton = true,
|
|
71
|
+
onMonthChange,
|
|
72
|
+
onSelectedDateChange,
|
|
73
|
+
...rest
|
|
74
|
+
},
|
|
75
|
+
ref,
|
|
76
|
+
) {
|
|
77
|
+
const today = useMemo(() => todayProp ?? new Date(), [todayProp]);
|
|
78
|
+
const monthStart = useMemo(() => startOfMonth(month), [month]);
|
|
79
|
+
const isTodayMonth = isSameMonth(monthStart, today);
|
|
80
|
+
|
|
81
|
+
const allDays = useMemo(
|
|
82
|
+
() => buildMonthGrid(monthStart, weekStartsOn),
|
|
83
|
+
[monthStart, weekStartsOn],
|
|
84
|
+
);
|
|
85
|
+
const weeks = allDays.length / 7;
|
|
86
|
+
|
|
87
|
+
const weekdays = useMemo(() => {
|
|
88
|
+
const list = [...WEEKDAY_LABELS_KO];
|
|
89
|
+
for (let i = 0; i < weekStartsOn; i += 1) list.push(list.shift() as string);
|
|
90
|
+
return list;
|
|
91
|
+
}, [weekStartsOn]);
|
|
92
|
+
|
|
93
|
+
const visibleWeekdays = showWeekend ? weekdays : weekdays.filter((_, i) => !isWeekendIndex(i, weekStartsOn));
|
|
94
|
+
const visibleDays = showWeekend
|
|
95
|
+
? allDays
|
|
96
|
+
: allDays.filter((d) => d.getDay() !== 0 && d.getDay() !== 6);
|
|
97
|
+
|
|
98
|
+
const eventsByDate = useMemo(() => groupEventsByDate(events), [events]);
|
|
99
|
+
|
|
100
|
+
const shiftMonth = (delta: number) => onMonthChange?.(addMonths(monthStart, delta));
|
|
101
|
+
const shiftYear = (delta: number) => {
|
|
102
|
+
const next = new Date(monthStart);
|
|
103
|
+
next.setFullYear(next.getFullYear() + delta);
|
|
104
|
+
onMonthChange?.(next);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const columns = showWeekend ? 7 : 5;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
ref={ref}
|
|
112
|
+
className={[styles.root, className].filter(Boolean).join(" ")}
|
|
113
|
+
{...rest}
|
|
114
|
+
>
|
|
115
|
+
<div className={styles.toolbar}>
|
|
116
|
+
<div className={styles.toolbarSide}>{leadingSlot}</div>
|
|
117
|
+
|
|
118
|
+
<div className={styles.monthNav}>
|
|
119
|
+
<IconButton
|
|
120
|
+
variant="ghost"
|
|
121
|
+
size="sm"
|
|
122
|
+
aria-label="이전 해"
|
|
123
|
+
icon={<Icon name="caret-double-left" />}
|
|
124
|
+
onClick={() => shiftYear(-1)}
|
|
125
|
+
/>
|
|
126
|
+
<IconButton
|
|
127
|
+
variant="ghost"
|
|
128
|
+
size="sm"
|
|
129
|
+
aria-label="이전 달"
|
|
130
|
+
icon={<Icon name="caret-left" />}
|
|
131
|
+
onClick={() => shiftMonth(-1)}
|
|
132
|
+
/>
|
|
133
|
+
<span className={styles.monthTitle}>
|
|
134
|
+
{monthStart.getFullYear()}년 {MONTH_LABELS_KO[monthStart.getMonth()]}
|
|
135
|
+
</span>
|
|
136
|
+
<IconButton
|
|
137
|
+
variant="ghost"
|
|
138
|
+
size="sm"
|
|
139
|
+
aria-label="다음 달"
|
|
140
|
+
icon={<Icon name="caret-right" />}
|
|
141
|
+
onClick={() => shiftMonth(1)}
|
|
142
|
+
/>
|
|
143
|
+
<IconButton
|
|
144
|
+
variant="ghost"
|
|
145
|
+
size="sm"
|
|
146
|
+
aria-label="다음 해"
|
|
147
|
+
icon={<Icon name="caret-double-right" />}
|
|
148
|
+
onClick={() => shiftYear(1)}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div className={styles.toolbarSide} data-align="end">
|
|
153
|
+
{showTodayButton && (
|
|
154
|
+
<div data-visible={(!isTodayMonth) || undefined} className={styles.todaySlot}>
|
|
155
|
+
<Button
|
|
156
|
+
variant="outline"
|
|
157
|
+
size="sm"
|
|
158
|
+
onClick={() => {
|
|
159
|
+
onMonthChange?.(startOfMonth(today));
|
|
160
|
+
onSelectedDateChange?.(today);
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
오늘
|
|
164
|
+
</Button>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div
|
|
171
|
+
className={styles.weekdays}
|
|
172
|
+
style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
|
|
173
|
+
>
|
|
174
|
+
{visibleWeekdays.map((w) => (
|
|
175
|
+
<div
|
|
176
|
+
key={w}
|
|
177
|
+
className={styles.weekday}
|
|
178
|
+
data-tone={weekdayTone(w)}
|
|
179
|
+
>
|
|
180
|
+
{w}
|
|
181
|
+
</div>
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div
|
|
186
|
+
className={styles.grid}
|
|
187
|
+
style={{
|
|
188
|
+
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
|
189
|
+
gridTemplateRows: `repeat(${weeks}, minmax(0, 1fr))`,
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
{visibleDays.map((day) => {
|
|
193
|
+
const key = formatISODate(day);
|
|
194
|
+
return (
|
|
195
|
+
<DayCell
|
|
196
|
+
key={key}
|
|
197
|
+
day={day}
|
|
198
|
+
month={monthStart}
|
|
199
|
+
today={today}
|
|
200
|
+
events={eventsByDate.get(key) ?? []}
|
|
201
|
+
isSelected={!!selectedDate && isSameDay(day, selectedDate)}
|
|
202
|
+
onSelect={() => onSelectedDateChange?.(day)}
|
|
203
|
+
/>
|
|
204
|
+
);
|
|
205
|
+
})}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
export { Calendar };
|
|
212
|
+
|
|
213
|
+
/* ───────────────────────── DayCell ───────────────────────── */
|
|
214
|
+
|
|
215
|
+
type DayCellProps = {
|
|
216
|
+
day: Date;
|
|
217
|
+
month: Date;
|
|
218
|
+
today: Date;
|
|
219
|
+
events: CalendarEvent[];
|
|
220
|
+
isSelected: boolean;
|
|
221
|
+
onSelect: () => void;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const DayCell = memo(function DayCell({
|
|
225
|
+
day,
|
|
226
|
+
month,
|
|
227
|
+
today,
|
|
228
|
+
events,
|
|
229
|
+
isSelected,
|
|
230
|
+
onSelect,
|
|
231
|
+
}: DayCellProps) {
|
|
232
|
+
const outside = !isSameMonth(day, month);
|
|
233
|
+
const isToday = isSameDay(day, today);
|
|
234
|
+
const isPast = day.getTime() < new Date(today).setHours(0, 0, 0, 0);
|
|
235
|
+
const dow = day.getDay();
|
|
236
|
+
const tone = outside || isPast ? "muted" : dow === 0 ? "sun" : dow === 6 ? "sat" : "default";
|
|
237
|
+
|
|
238
|
+
const eventsRef = useRef<HTMLDivElement>(null);
|
|
239
|
+
const [hiddenCount, setHiddenCount] = useState(0);
|
|
240
|
+
|
|
241
|
+
useLayoutEffect(() => {
|
|
242
|
+
const container = eventsRef.current;
|
|
243
|
+
if (!container) return;
|
|
244
|
+
const recalc = () => {
|
|
245
|
+
const containerBottom = container.getBoundingClientRect().bottom;
|
|
246
|
+
const children = Array.from(container.children) as HTMLElement[];
|
|
247
|
+
let visible = 0;
|
|
248
|
+
for (const child of children) {
|
|
249
|
+
if (child.getBoundingClientRect().bottom <= containerBottom + 1) {
|
|
250
|
+
visible += 1;
|
|
251
|
+
} else {
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
setHiddenCount(Math.max(0, events.length - visible));
|
|
256
|
+
};
|
|
257
|
+
recalc();
|
|
258
|
+
const ro = new ResizeObserver(recalc);
|
|
259
|
+
ro.observe(container);
|
|
260
|
+
return () => ro.disconnect();
|
|
261
|
+
}, [events.length]);
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div
|
|
265
|
+
className={styles.cell}
|
|
266
|
+
data-outside={outside || undefined}
|
|
267
|
+
data-today={isToday || undefined}
|
|
268
|
+
data-selected={isSelected || undefined}
|
|
269
|
+
role="button"
|
|
270
|
+
tabIndex={0}
|
|
271
|
+
onClick={onSelect}
|
|
272
|
+
onKeyDown={(e) => {
|
|
273
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
274
|
+
e.preventDefault();
|
|
275
|
+
onSelect();
|
|
276
|
+
}
|
|
277
|
+
}}
|
|
278
|
+
>
|
|
279
|
+
<div className={styles.cellHead}>
|
|
280
|
+
<span className={styles.dayNum} data-tone={tone} data-selected={isSelected || undefined}>
|
|
281
|
+
{day.getDate()}
|
|
282
|
+
</span>
|
|
283
|
+
<span className={styles.cellBadges}>
|
|
284
|
+
{hiddenCount > 0 && <Badge size="sm" variant="soft">+{hiddenCount}</Badge>}
|
|
285
|
+
{isToday && <Badge size="sm" variant="soft">오늘</Badge>}
|
|
286
|
+
</span>
|
|
287
|
+
</div>
|
|
288
|
+
<div ref={eventsRef} className={styles.cellEvents}>
|
|
289
|
+
{events.map((ev) => (
|
|
290
|
+
<EventChip
|
|
291
|
+
key={ev.id}
|
|
292
|
+
title={ev.title}
|
|
293
|
+
tone={ev.tone}
|
|
294
|
+
faded={outside}
|
|
295
|
+
/>
|
|
296
|
+
))}
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
/* ───────────────────────── helpers ───────────────────────── */
|
|
303
|
+
|
|
304
|
+
function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
|
|
305
|
+
const map = new Map<string, CalendarEvent[]>();
|
|
306
|
+
for (const ev of events) {
|
|
307
|
+
const list = map.get(ev.date) ?? [];
|
|
308
|
+
list.push(ev);
|
|
309
|
+
map.set(ev.date, list);
|
|
310
|
+
}
|
|
311
|
+
return map;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function isWeekendIndex(i: number, weekStartsOn: 0 | 1): boolean {
|
|
315
|
+
if (weekStartsOn === 0) return i === 0 || i === 6;
|
|
316
|
+
return i === 5 || i === 6;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function weekdayTone(label: string): "sat" | "sun" | "default" {
|
|
320
|
+
if (label === "토") return "sat";
|
|
321
|
+
if (label === "일") return "sun";
|
|
322
|
+
return "default";
|
|
323
|
+
}
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
gap: 0;
|
|
9
9
|
background: var(--bg);
|
|
10
10
|
border: var(--hairline) solid var(--border);
|
|
11
|
-
border-radius: var(--radius-
|
|
11
|
+
border-radius: var(--radius-xl);
|
|
12
|
+
box-shadow: var(--shadow-md);
|
|
12
13
|
transition: var(--transition-colors), var(--transition-ring);
|
|
13
14
|
overflow: hidden; /* root border radius로 자식 clip (scroll 끝이 root border에서) */
|
|
14
15
|
}
|
|
@@ -20,44 +21,43 @@
|
|
|
20
21
|
padding: 0;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
/* radius variant — Shape 축에 반응 */
|
|
24
|
-
.radius-square { border-radius: var(--radius-xs); }
|
|
25
|
-
.radius-rounded { border-radius: var(--radius-sm); }
|
|
26
|
-
.radius-circular { border-radius: var(--radius-md); }
|
|
27
|
-
|
|
28
24
|
.root:focus-within {
|
|
29
|
-
box-shadow: var(--ring);
|
|
25
|
+
box-shadow: var(--ring), var(--shadow-md);
|
|
30
26
|
}
|
|
31
27
|
.disabled {
|
|
32
28
|
opacity: 0.5;
|
|
33
29
|
pointer-events: none;
|
|
34
30
|
}
|
|
35
31
|
|
|
36
|
-
/* 첨부물 바 —
|
|
32
|
+
/* 첨부물 바 — 가로 wrap 스택.
|
|
37
33
|
gap space-1 (feedback-small-component-gap). */
|
|
38
34
|
.attachments {
|
|
39
|
-
display:
|
|
40
|
-
|
|
35
|
+
display: flex;
|
|
36
|
+
flex-wrap: wrap;
|
|
41
37
|
gap: var(--space-1);
|
|
42
|
-
padding: var(--space-
|
|
38
|
+
padding: var(--space-2);
|
|
39
|
+
}
|
|
40
|
+
/* SingleLine은 아래 body footer가 자체 상단 padding을 가지므로 bottom 압축. */
|
|
41
|
+
.shape-single .attachments {
|
|
42
|
+
padding-bottom: var(--space-0_5);
|
|
43
43
|
}
|
|
44
|
-
/* Thumb은 셀 안에서 자체 폭 유지(좌측 정렬), chip은 셀 폭 채움. */
|
|
45
44
|
.attachmentCell {
|
|
46
45
|
display: flex;
|
|
47
46
|
min-width: 0;
|
|
48
47
|
}
|
|
49
|
-
.attachmentCell[data-kind="image"] >
|
|
50
|
-
flex: 0 0 auto;
|
|
51
|
-
}
|
|
48
|
+
.attachmentCell[data-kind="image"] > *,
|
|
52
49
|
.attachmentCell[data-kind="file"] > * {
|
|
53
|
-
|
|
54
|
-
max-width: 100%;
|
|
50
|
+
flex: 0 0 auto;
|
|
55
51
|
}
|
|
56
52
|
|
|
57
53
|
/* Quotation */
|
|
58
54
|
.quotation {
|
|
59
55
|
display: block;
|
|
60
|
-
padding: var(--space-
|
|
56
|
+
padding: var(--space-2);
|
|
57
|
+
}
|
|
58
|
+
/* SingleLine은 아래 body footer가 자체 상단 padding을 가지므로 bottom 압축. */
|
|
59
|
+
.shape-single .quotation {
|
|
60
|
+
padding-bottom: var(--space-0_5);
|
|
61
61
|
}
|
|
62
62
|
/* attachments 아래에 quotation이 오면 상단 패딩 제거 (attachments bottom이 책임) */
|
|
63
63
|
.attachments + .quotation {
|
|
@@ -81,30 +81,25 @@
|
|
|
81
81
|
width: 100%;
|
|
82
82
|
min-width: 0;
|
|
83
83
|
}
|
|
84
|
-
/* multiScroll — 세로 스크롤 컨테이너.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
- 긴 글: wrapper scroll 발생 → 글이 상·하 padding 영역을 통과해 root border에서 자연스레 잘림. */
|
|
84
|
+
/* multiScroll — 세로 스크롤 컨테이너.
|
|
85
|
+
**스크롤 룰**: wrapper에 padding 0, textarea에 상·하 padding + 좌·우 padding 둘 다.
|
|
86
|
+
textarea가 자신의 padding을 content의 일부로 품으므로 scroll 시 글이 상·하 padding 영역을
|
|
87
|
+
통과해 root border에서 clip. wrapper에 padding을 두면 scroll 시 그 영역이 "빈 띠"로 남음. */
|
|
89
88
|
.multiScroll {
|
|
90
89
|
overflow-y: auto;
|
|
91
90
|
max-height: calc(var(--text-sm) * 1.5 * 8); /* ~8 lines */
|
|
92
|
-
padding:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
.attachments + .body .multiScroll,
|
|
96
|
-
.quotation + .body .multiScroll {
|
|
97
|
-
padding-top: var(--space-1);
|
|
91
|
+
padding: 0;
|
|
92
|
+
scroll-padding-bottom: var(--space-3);
|
|
93
|
+
scroll-padding-top: var(--space-3);
|
|
98
94
|
}
|
|
99
95
|
|
|
100
|
-
/* textarea
|
|
101
|
-
스크롤 끝에서 글이 wrapper(=root) border까지 흘러감. */
|
|
96
|
+
/* textarea에 상·하·좌·우 padding 전부 — 스크롤되면 함께 올라감. */
|
|
102
97
|
.multiField {
|
|
103
98
|
display: block;
|
|
104
99
|
width: 100%;
|
|
105
100
|
min-width: 0;
|
|
106
101
|
min-height: calc(var(--text-sm) * 1.5);
|
|
107
|
-
padding:
|
|
102
|
+
padding: var(--space-3) var(--space-4);
|
|
108
103
|
background: transparent;
|
|
109
104
|
border: 0;
|
|
110
105
|
outline: none;
|
|
@@ -114,10 +109,16 @@
|
|
|
114
109
|
font-size: var(--text-sm);
|
|
115
110
|
line-height: 1.5;
|
|
116
111
|
overflow: hidden;
|
|
112
|
+
box-sizing: border-box;
|
|
117
113
|
}
|
|
118
114
|
.multiField::placeholder {
|
|
119
115
|
color: var(--fg-muted);
|
|
120
116
|
}
|
|
117
|
+
/* attachments/quotation 아래에선 상단 padding 압축 (이미 attachments/quotation이 상단 책임). */
|
|
118
|
+
.attachments + .body .multiField,
|
|
119
|
+
.quotation + .body .multiField {
|
|
120
|
+
padding-top: var(--space-1);
|
|
121
|
+
}
|
|
121
122
|
|
|
122
123
|
.footer {
|
|
123
124
|
display: flex;
|
|
@@ -127,32 +128,63 @@
|
|
|
127
128
|
padding: 0 var(--space-2) var(--space-2);
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
/* SingleLine —
|
|
131
|
-
|
|
131
|
+
/* SingleLine — body는 relative + padding 0, 버튼은 absolute로 좌·우 하단 고정.
|
|
132
|
+
singleScroll이 body 전체 높이·폭 차지하되 좌·우 padding으로 버튼 자리 확보.
|
|
133
|
+
이러면 body 높이가 전적으로 textarea(singleScroll) 크기에 따라감 — padding 늘리면 input도 커짐. */
|
|
132
134
|
.shape-single .body {
|
|
133
135
|
flex-direction: row;
|
|
134
|
-
align-items:
|
|
135
|
-
gap:
|
|
136
|
-
padding:
|
|
136
|
+
align-items: stretch;
|
|
137
|
+
gap: 0;
|
|
138
|
+
padding: 0;
|
|
139
|
+
position: relative;
|
|
140
|
+
}
|
|
141
|
+
/* 버튼 bottom inset은 textarea 상·하 padding과 같은 토큰으로 — 1줄 상태에서
|
|
142
|
+
textarea 수직 중앙 = 버튼 수직 중앙 (density scale 바뀌어도 비례해서 유지됨). */
|
|
143
|
+
.shape-single .attachBtn {
|
|
144
|
+
position: absolute;
|
|
145
|
+
left: var(--space-2);
|
|
146
|
+
bottom: calc((var(--space-3) * 2 + var(--text-sm) * 1.5 - var(--space-8)) / 2);
|
|
147
|
+
z-index: var(--z-raise);
|
|
148
|
+
}
|
|
149
|
+
.shape-single .actions {
|
|
150
|
+
position: absolute;
|
|
151
|
+
right: var(--space-2);
|
|
152
|
+
bottom: calc((var(--space-3) * 2 + var(--text-sm) * 1.5 - var(--space-8)) / 2);
|
|
153
|
+
z-index: var(--z-raise);
|
|
154
|
+
}
|
|
155
|
+
/* singleScroll — body 전체 차지. 좌·우 padding으로 버튼 자리 확보.
|
|
156
|
+
왼쪽: attachBtn 32 + margin 8 = 40 (gap 없음).
|
|
157
|
+
오른쪽: actions(model+gap+send) 가변 + margin 8. */
|
|
158
|
+
.shape-single .singleScroll {
|
|
159
|
+
padding-left: var(--space-10);
|
|
160
|
+
padding-right: calc(var(--space-20) + var(--space-8));
|
|
161
|
+
}
|
|
162
|
+
.singleScroll {
|
|
163
|
+
flex: 1 1 auto;
|
|
164
|
+
min-width: 0;
|
|
165
|
+
overflow-y: auto;
|
|
166
|
+
max-height: calc(var(--text-sm) * 1.5 * 8);
|
|
167
|
+
padding: 0;
|
|
168
|
+
/* 타이핑 중 caret이 하단에 올 때 scroll이 자동으로 textarea padding-bottom을 지나
|
|
169
|
+
root border까지 밀어주도록 scroll-padding 확보. */
|
|
170
|
+
scroll-padding-bottom: var(--space-3);
|
|
171
|
+
scroll-padding-top: var(--space-3);
|
|
137
172
|
}
|
|
138
173
|
.singleField {
|
|
139
|
-
|
|
174
|
+
display: block;
|
|
175
|
+
width: 100%;
|
|
140
176
|
min-width: 0;
|
|
141
|
-
padding:
|
|
177
|
+
padding: var(--space-3) 0;
|
|
142
178
|
background: transparent;
|
|
143
179
|
border: 0;
|
|
144
180
|
outline: none;
|
|
145
181
|
resize: none;
|
|
182
|
+
overflow: hidden;
|
|
146
183
|
color: var(--fg-strong);
|
|
147
184
|
font: inherit;
|
|
148
185
|
font-size: var(--text-sm);
|
|
149
186
|
line-height: 1.5;
|
|
150
|
-
|
|
151
|
-
max-height: calc(var(--text-sm) * 1.5 * 8);
|
|
152
|
-
overflow-y: auto;
|
|
153
|
-
}
|
|
154
|
-
.singleField::placeholder {
|
|
155
|
-
color: var(--fg-muted);
|
|
187
|
+
box-sizing: border-box;
|
|
156
188
|
}
|
|
157
189
|
.singleField::placeholder {
|
|
158
190
|
color: var(--fg-muted);
|
|
@@ -272,6 +304,11 @@
|
|
|
272
304
|
.sendBtn:disabled {
|
|
273
305
|
cursor: not-allowed;
|
|
274
306
|
}
|
|
307
|
+
/* 비활성(= 입력 비어있어 보낼 수 없음) 상태 아이콘을 연하게.
|
|
308
|
+
data-active일 땐 primary 배경이라 색 유지. */
|
|
309
|
+
.sendBtn:not([data-active]) {
|
|
310
|
+
color: var(--fg-disabled);
|
|
311
|
+
}
|
|
275
312
|
.sendIcon {
|
|
276
313
|
width: 44%;
|
|
277
314
|
height: 44%;
|
|
@@ -9,17 +9,18 @@ import {
|
|
|
9
9
|
type CSSProperties,
|
|
10
10
|
type FormEvent,
|
|
11
11
|
type KeyboardEvent,
|
|
12
|
+
type ReactNode,
|
|
12
13
|
} from "react";
|
|
13
14
|
import { Paperclip as PhPaperclip } from "@phosphor-icons/react";
|
|
14
15
|
import { AttachmentChip } from "@/components/ctds/AttachmentChip";
|
|
15
16
|
import { AttachmentThumb } from "@/components/ctds/AttachmentThumb";
|
|
17
|
+
import { DropdownMenu } from "@/components/ctds/DropdownMenu";
|
|
16
18
|
import { QuotationBar } from "@/components/ctds/QuotationBar";
|
|
17
19
|
import { ModelSelector } from "./ModelSelector";
|
|
18
20
|
import { SendButton } from "./SendButton";
|
|
19
21
|
import styles from "./ChatInput.module.css";
|
|
20
22
|
|
|
21
23
|
export type ChatInputShape = "multi" | "single";
|
|
22
|
-
export type ChatInputRadius = "square" | "rounded" | "circular";
|
|
23
24
|
|
|
24
25
|
/** 첨부 공용 타입 — ChatInput·Message 양쪽에서 재사용. */
|
|
25
26
|
export interface ChatAttachmentImage {
|
|
@@ -39,11 +40,14 @@ export type ChatAttachment = ChatAttachmentImage | ChatAttachmentFile;
|
|
|
39
40
|
export interface ChatInputModel {
|
|
40
41
|
id: string;
|
|
41
42
|
label: string;
|
|
43
|
+
/** 메뉴 아이템 leading 슬롯. 모델별 대표 아이콘 (CTDS Icon 노드 등). */
|
|
44
|
+
icon?: ReactNode;
|
|
45
|
+
/** 2줄 아이템용 보조 문구. 넘기면 메뉴 아이템이 2줄로 열림. */
|
|
46
|
+
description?: ReactNode;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
export interface ChatInputProps {
|
|
45
50
|
shape?: ChatInputShape;
|
|
46
|
-
radius?: ChatInputRadius;
|
|
47
51
|
|
|
48
52
|
value: string;
|
|
49
53
|
onChange: (v: string) => void;
|
|
@@ -81,7 +85,6 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
|
|
|
81
85
|
function ChatInput(props, ref) {
|
|
82
86
|
const {
|
|
83
87
|
shape = "multi",
|
|
84
|
-
radius = "rounded",
|
|
85
88
|
value,
|
|
86
89
|
onChange,
|
|
87
90
|
onSend,
|
|
@@ -108,16 +111,14 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
|
|
|
108
111
|
const canSend = !disabled && (value.trim().length > 0 || attachments.length > 0);
|
|
109
112
|
const showStop = isStreaming && !!onStop;
|
|
110
113
|
|
|
111
|
-
/* autoResize — multi/single 둘 다. textarea는 content에 맞춰 자람.
|
|
112
|
-
|
|
114
|
+
/* autoResize — multi/single 둘 다. textarea는 content + padding에 맞춰 자람.
|
|
115
|
+
scrollHeight는 content + padding(border-box). height를 auto로 reset해야 축소도 가능. */
|
|
113
116
|
useLayoutEffect(() => {
|
|
114
117
|
const el = taRef.current;
|
|
115
118
|
if (!el) return;
|
|
116
119
|
el.style.height = "auto";
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
el.style.height = `${nextH}px`;
|
|
120
|
-
}, [shape, value]);
|
|
120
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
121
|
+
});
|
|
121
122
|
|
|
122
123
|
const handleKeyDown = useCallback(
|
|
123
124
|
(e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
|
@@ -166,7 +167,6 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
|
|
|
166
167
|
const rootCls = [
|
|
167
168
|
styles.root,
|
|
168
169
|
styles[`shape-${shape}`],
|
|
169
|
-
styles[`radius-${radius}`],
|
|
170
170
|
disabled ? styles.disabled : "",
|
|
171
171
|
className,
|
|
172
172
|
]
|
|
@@ -199,19 +199,29 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
|
|
|
199
199
|
|
|
200
200
|
const ModelAndSend = (
|
|
201
201
|
<div className={styles.actions}>
|
|
202
|
-
{modelLabel ? (
|
|
203
|
-
<
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
202
|
+
{modelLabel && models ? (
|
|
203
|
+
<DropdownMenu>
|
|
204
|
+
<DropdownMenu.Trigger asChild>
|
|
205
|
+
<ModelSelector
|
|
206
|
+
label={modelLabel}
|
|
207
|
+
compact={modelCompact}
|
|
208
|
+
disabled={disabled}
|
|
209
|
+
/>
|
|
210
|
+
</DropdownMenu.Trigger>
|
|
211
|
+
<DropdownMenu.Content align="end">
|
|
212
|
+
<DropdownMenu.Label>모델 변경</DropdownMenu.Label>
|
|
213
|
+
{models.map((m) => (
|
|
214
|
+
<DropdownMenu.Item
|
|
215
|
+
key={m.id}
|
|
216
|
+
leadingActions={m.icon ? [{ node: m.icon, key: m.id }] : undefined}
|
|
217
|
+
description={m.description}
|
|
218
|
+
onSelect={() => onSelectModel?.(m.id)}
|
|
219
|
+
>
|
|
220
|
+
{m.label}
|
|
221
|
+
</DropdownMenu.Item>
|
|
222
|
+
))}
|
|
223
|
+
</DropdownMenu.Content>
|
|
224
|
+
</DropdownMenu>
|
|
215
225
|
) : null}
|
|
216
226
|
<SendButton
|
|
217
227
|
active={canSend}
|
|
@@ -277,17 +287,19 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
|
|
|
277
287
|
{shape === "single" ? (
|
|
278
288
|
<>
|
|
279
289
|
{AttachButton}
|
|
280
|
-
<
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
290
|
+
<div className={styles.singleScroll}>
|
|
291
|
+
<textarea
|
|
292
|
+
ref={taRef}
|
|
293
|
+
className={styles.singleField}
|
|
294
|
+
value={value}
|
|
295
|
+
onChange={(e) => onChange(e.target.value)}
|
|
296
|
+
onKeyDown={handleKeyDown}
|
|
297
|
+
placeholder={placeholder}
|
|
298
|
+
disabled={disabled}
|
|
299
|
+
rows={1}
|
|
300
|
+
aria-label={placeholder}
|
|
301
|
+
/>
|
|
302
|
+
</div>
|
|
291
303
|
{ModelAndSend}
|
|
292
304
|
</>
|
|
293
305
|
) : (
|