Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ssh/src/components/Layout/Header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,16 @@ export default function Header() {
{NAV.map((item) => {
const href = toAbs(item.seg);
const active = isActive(item.seg);

// ✅ 라벨/seg에 따라 id 부여 (HIGHLIGHT_ELEMENT에서 사용할 고정 셀렉터)
let id: string | undefined;
if (item.seg === ROUTING_PATH.atmmap) id = 'header-atm-button';
if (item.seg === ROUTING_PATH.voicephishing) id = 'header-phishing-button';

return (
<Link
key={item.label}
id={id} // ✅ 여기!
to={href}
aria-current={active ? 'page' : undefined}
className={[basePill, active ? activePill : inactivePill].join(' ')}
Expand Down
62 changes: 62 additions & 0 deletions ssh/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,65 @@ body {
@apply h-7 w-7;
}
}

/* ===== Kakao-like chat bubbles ===== */
.kakao-bubble {
position: relative;
max-width: min(78%, 640px);
word-break: break-word;
}

.kakao-bubble.user {
background: #a8bfe4; /* 카카오 노랑 */
color: #111827; /* text-gray-900 */
}

.kakao-bubble.assistant {
background: #ffffff;
color: #111827;
border: 1px solid hsl(0 0% 90% / 1);
}

/* 꼬리 (좌) */
.kakao-bubble.assistant.tail::after {
content: '';
position: absolute;
left: -6px; /* 좌측 밖으로 */
bottom: 10px;
border-width: 6px;
border-style: solid;
border-color: transparent #ffffff transparent transparent;
filter: drop-shadow(0 0 0 hsl(0 0% 90% / 1)); /* 경계선 자연스러움 */
}

/* 꼬리 (우) */
.kakao-bubble.user.tail::after {
content: '';
position: absolute;
right: -6px; /* 우측 밖으로 */
bottom: 10px;
border-width: 6px;
border-style: solid;
border-color: transparent transparent transparent #a8bfe4;
}

/* 연속 말풍선은 모서리/꼬리 처리 */
.kakao-bubble.stack-top {
border-bottom-left-radius: 0.5rem !important;
border-bottom-right-radius: 0.5rem !important;
}
.kakao-bubble.stack-mid {
border-radius: 0.5rem !important;
}
.kakao-bubble.stack-bottom {
border-top-left-radius: 0.5rem !important;
border-top-right-radius: 0.5rem !important;
}

/* 시간 뱃지 */
.kakao-time {
font-size: 12px;
line-height: 1;
color: hsl(215 16% 47% / 1); /* text-muted-foreground 느낌 */
white-space: nowrap;
}
119 changes: 96 additions & 23 deletions ssh/src/pages/Chatbot/components/appsidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,61 @@ import {
} from '@/components/ui/sidebar';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { MessageSquare, History, Users, Settings, PanelLeftClose } from 'lucide-react';
import {
MessageSquare,
History,
Users,
Settings,
PanelLeftClose,
Volume2,
Play,
} from 'lucide-react';
import logo2 from '@/assets/logo2.png';

export default function AppSidebar({
className = '',
onClose,
fontPx,
setFontPx,
volume,
setVolume,
}: {
className?: string;
onClose?: () => void;
fontPx: number;
setFontPx: (v: number | ((prev: number) => number)) => void;
volume: number; // 0 ~ 1
setVolume: (v: number) => void;
}) {
// 현재 볼륨으로 0.4초 테스트 사운드
const playTestSound = async () => {
try {
const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext;
if (!Ctx) return;
const ctx = new Ctx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.value = Math.max(0, Math.min(1, volume));
osc.connect(gain).connect(ctx.destination);
osc.start();
setTimeout(() => {
osc.stop();
ctx.close();
}, 400);
} catch (e) {
console.warn('Test sound failed', e);
}
};

return (
<Sidebar className={className}>
<SidebarHeader className="bg-background">
<div className="flex items-center justify-between px-2 py-1.5">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<div className="grid h-7 w-7 place-items-center rounded-full bg-primary/10 overflow-hidden">
<img src={logo2} alt="Logo" className="h-5 w-5 object-contain" />
</div>
<span className="font-semibold">마음 채팅</span>
<div className="grid h-7 w-7 place-items-center rounded-full bg-primary/10 overflow-hidden">
<img src={logo2} alt="Logo" className="h-5 w-5 object-contain" />
</div>
<span className="font-semibold">마음 채팅</span>
</div>
Expand Down Expand Up @@ -58,52 +94,89 @@ export default function AppSidebar({
<MessageSquare /> 새 대화
</SidebarMenuButton>
</SidebarMenuItem>

<SidebarMenuItem>
<SidebarMenuButton className="hover:bg-accent hover:text-accent-foreground">
<History /> 과거 대화
</SidebarMenuButton>
</SidebarMenuItem>

<SidebarMenuItem>
<SidebarMenuButton className="hover:bg-accent hover:text-accent-foreground">
<Users /> 관련 부서 연결
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>

{/* 수정 예정 */}
<div className="mt-4 border-t border-border pt-4">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold">
<Settings className="h-4 w-4" />
설정
<span className="chat-chip">준비중</span>
</div>

{/* 글자 크기 */}
<div className="mb-4 space-y-2">
<div className="chat-subtitle">글자 크기</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="btn-outline-brand" disabled>
<Button
variant="outline"
size="sm"
className="btn-outline-brand"
onClick={() => setFontPx((v) => Math.max(12, Number(v) - 1))}
aria-label="글자 작게"
>
작게
</Button>
<div className="text-sm text-muted-foreground">14px</div>
<Button variant="outline" size="sm" className="btn-outline-brand" disabled>
<div className="text-sm text-muted-foreground min-w-12 text-center">
{fontPx}px
</div>
<Button
variant="outline"
size="sm"
className="btn-outline-brand"
onClick={() => setFontPx((v) => Math.min(24, Number(v) + 1))}
aria-label="글자 크게"
>
크게
</Button>
</div>
</div>

<div className="space-y-2">
<div className="chat-subtitle">음성 출력 볼륨</div>
<input
type="range"
min={0}
max={100}
defaultValue={80}
className="w-full opacity-60"
disabled
min={12}
max={24}
step={1}
value={fontPx}
onChange={(e) => setFontPx(Number(e.target.value))}
className="w-full"
aria-label="글자 크기 슬라이더"
/>
<div className="text-xs text-muted-foreground">80% (미구현)</div>
</div>

{/* 음성 출력 볼륨 */}
<div className="space-y-2">
<div className="chat-subtitle flex items-center gap-2">
<Volume2 className="h-4 w-4" />
음성 출력 볼륨
</div>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={100}
value={Math.round(volume * 100)}
onChange={(e) => setVolume(Number(e.target.value) / 100)}
className="w-full"
aria-label="음성 볼륨 슬라이더"
/>
<Button
variant="outline"
size="icon"
aria-label="테스트 사운드"
title="현재 볼륨으로 테스트 사운드 재생"
onClick={playTestSound}
>
<Play className="h-4 w-4" />
</Button>
</div>
<div className="text-xs text-muted-foreground">{Math.round(volume * 100)}%</div>
</div>
</div>
</SidebarGroupContent>
Expand Down
125 changes: 125 additions & 0 deletions ssh/src/pages/Chatbot/components/chat/ChatBubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { memo } from 'react';
import { cn } from '@/lib/utils'; // shadcn 유틸(없으면 className join 함수로 대체)
import logo2 from '@/assets/logo2.png';
import { CircleUser } from 'lucide-react';

export type ChatRole = 'user' | 'assistant';

export type ChatBubbleProps = {
role: ChatRole;
text: string;
time?: string; // "오후 3:21" 같은 표시용
showAvatar?: boolean; // 첫 말풍선에만 아바타
showTail?: boolean; // 묶음의 마지막 말풍선에만 꼬리
stackPosition?: 'single' | 'top' | 'mid' | 'bottom';
audioUrl?: string;
videoUrl?: string;
fontPx?: number; // 글자 크기 동기화
volume?: number;
};

function formatText(t: string) {
return t.replace(/\n/g, '\n');
}

export const ChatBubble = memo(function ChatBubble({
role,
text,
time,
showAvatar = false,
showTail = true,
stackPosition = 'single',
audioUrl,
videoUrl,
fontPx = 16,
volume = 0.8,
}: ChatBubbleProps) {
const isUser = role === 'user';

return (
<div
className={cn('flex w-full items-end gap-2', isUser ? 'justify-end' : 'justify-start')}
style={{ ['--chat-font-size' as any]: `${fontPx}px` }}
>
{/* 왼쪽 아바타 (assistant) */}
{!isUser && (
<div className="w-7 shrink-0 self-end">
{showAvatar ? (
<div className="h-7 w-7 overflow-hidden rounded-full border border-border bg-primary/10 grid place-items-center">
<img src={logo2} alt="bot" className="h-full w-full object-contain" />
</div>
) : (
<div className="h-7 w-7" />
)}
</div>
)}

{/* 말풍선 + 시간 */}
<div className={cn('flex max-w-[90%] items-end', isUser ? 'flex-row-reverse' : 'flex-row')}>
<div
className={cn(
'kakao-bubble whitespace-pre-wrap px-3.5 py-2.5 text-[length:var(--chat-font-size)]',
'rounded-2xl',
isUser ? 'user' : 'assistant',
showTail && 'tail',
stackPosition === 'single' && 'rounded-2xl',
stackPosition === 'top' && 'stack-top',
stackPosition === 'mid' && 'stack-mid',
stackPosition === 'bottom' && 'stack-bottom',
)}
>
{formatText(text)}

{videoUrl && (
<div className="mt-2">
<div className="aspect-video w-[72vw] max-w-[520px] overflow-hidden rounded-lg border">
<iframe
className="h-full w-full"
src={
videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')
? videoUrl.replace('watch?v=', 'embed/')
: videoUrl
}
title="안내 영상"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
</div>
</div>
)}

{audioUrl && (
<audio
className="mt-2 w-[72vw] max-w-[520px]"
controls
src={audioUrl}
autoPlay={false}
onLoadedMetadata={(e) => {
const el = e.currentTarget as HTMLAudioElement;
el.volume = Math.max(0, Math.min(1, volume));
}}
/>
)}
</div>

{/* 시간 */}
{time && (
<div className={cn('kakao-time mx-2', isUser ? 'order-0' : 'order-2')}>{time}</div>
)}
</div>

{/* 오른쪽 아바타 (user) */}
{isUser && (
<div className="w-7 shrink-0 self-end">
{showAvatar ? (
<div className="h-7 w-7 grid place-items-center rounded-full border border-border bg-muted">
<CircleUser className="h-4 w-4" />
</div>
) : (
<div className="h-7 w-7" />
)}
</div>
)}
</div>
);
});
9 changes: 9 additions & 0 deletions ssh/src/pages/Chatbot/components/chat/ChatDateDivider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default function ChatDateDivider({ date }: { date: string }) {
return (
<div className="my-4 flex items-center justify-center">
<span className="rounded-full border border-border bg-background px-3 py-1 text-xs text-muted-foreground">
{date}
</span>
</div>
);
}
Loading