Skip to content

Commit f7a65f6

Browse files
authored
feat: 사이드바 하단 내 프로필 메뉴 추가
1 parent cf8e7aa commit f7a65f6

1 file changed

Lines changed: 223 additions & 3 deletions

File tree

src/app/pages/ChatPage.tsx

Lines changed: 223 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Hash, Users, GitPullRequest, Home, CheckSquare, ChevronDown, ChevronRight, GitBranch, Code2, Database, BookOpen, Maximize2, Minimize2, Plus, Pencil, Trash2, MoreVertical, X, LayoutGrid, type LucideIcon } from "lucide-react";
1+
import { Hash, Users, GitPullRequest, Home, CheckSquare, ChevronDown, ChevronRight, GitBranch, Code2, Database, BookOpen, Maximize2, Minimize2, Plus, Pencil, Trash2, MoreVertical, X, LayoutGrid, Bell, BellOff, Check, Clock3, MessageCircle, Settings, UserRound, type LucideIcon } from "lucide-react";
22
import { WorkBoardPanel } from "../components/WorkBoardPanel";
33
import { ChatPanel } from "../components/ChatPanel";
44
import { PRReviewPanel } from "../components/PRReviewPanel";
@@ -25,6 +25,8 @@ const CHAT_THREAD_REPLY_COUNTS_KEY = "codedock-chat-thread-reply-counts-v1";
2525
const CHAT_REACTIONS_KEY = "codedock-chat-reactions-v1";
2626

2727
type SidebarGroupId = 'documentation';
28+
type UserPresence = 'active' | 'away' | 'busy' | 'offline';
29+
type NotificationMode = 'all' | 'mentions' | 'muted';
2830

2931
interface RepositoryItem {
3032
id: string;
@@ -100,6 +102,26 @@ const ALL_SIDEBAR_CHANNELS = [
100102
...DOCUMENTATION_CHANNELS
101103
];
102104

105+
const myProfile = {
106+
name: "김준우",
107+
role: "Frontend Developer",
108+
email: "junwoo@codedock.dev",
109+
initials: "JW"
110+
};
111+
112+
const presenceOptions: Array<{ id: UserPresence; label: string; description: string; color: string }> = [
113+
{ id: 'active', label: '활동중', description: '바로 응답 가능', color: '#39FF88' },
114+
{ id: 'away', label: '자리비움', description: '잠시 후 확인', color: '#FFD166' },
115+
{ id: 'busy', label: '방해금지', description: '멘션만 확인', color: '#FF6B6B' },
116+
{ id: 'offline', label: '오프라인', description: '상태 숨김', color: '#8B94A7' }
117+
];
118+
119+
const notificationOptions: Array<{ id: NotificationMode; label: string; description: string; icon: LucideIcon }> = [
120+
{ id: 'all', label: '모든 알림', description: '채널, PR, 이슈 알림 받기', icon: Bell },
121+
{ id: 'mentions', label: '멘션만', description: '@멘션과 배정 알림만 받기', icon: MessageCircle },
122+
{ id: 'muted', label: '알림 끄기', description: '새 알림을 조용히 보관', icon: BellOff }
123+
];
124+
103125
function getRepositoryImportPreference() {
104126
if (typeof window === "undefined" || typeof window.localStorage === "undefined") {
105127
return false;
@@ -528,6 +550,9 @@ export function ChatPage() {
528550
'backend-chat': 1,
529551
'review-room': 2,
530552
});
553+
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
554+
const [userPresence, setUserPresence] = useState<UserPresence>('active');
555+
const [notificationMode, setNotificationMode] = useState<NotificationMode>('mentions');
531556

532557
const [selectedWorkspace, setSelectedWorkspace] = useState<string>(DEFAULT_WORKSPACES[0].id);
533558

@@ -579,6 +604,10 @@ export function ChatPage() {
579604
?? selectedChannel.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
580605
const selectedRepositoryName = repositories.find((repo) => repo.id === selectedRepository)?.name ?? '전체 리포지토리';
581606

607+
const currentPresence = presenceOptions.find((option) => option.id === userPresence) ?? presenceOptions[0];
608+
const currentNotificationMode = notificationOptions.find((option) => option.id === notificationMode) ?? notificationOptions[0];
609+
const CurrentNotificationIcon = currentNotificationMode.icon;
610+
582611
useEffect(() => {
583612
if (!isMainExpanded) return;
584613

@@ -901,6 +930,196 @@ export function ChatPage() {
901930
);
902931
};
903932

933+
const renderProfileDock = () => (
934+
<div className="relative">
935+
<AnimatePresence initial={false}>
936+
{profileMenuOpen && (
937+
<motion.div
938+
className="absolute bottom-full left-0 right-0 mb-3 overflow-hidden rounded-2xl px-3 py-3"
939+
style={{
940+
background: 'rgba(5, 11, 20, 0.98)',
941+
border: '1px solid rgba(32, 227, 255, 0.22)',
942+
boxShadow: '0 20px 56px rgba(0, 0, 0, 0.48), 0 0 30px rgba(32, 227, 255, 0.12)',
943+
backdropFilter: 'blur(18px) saturate(180%)',
944+
zIndex: 30
945+
}}
946+
initial={{ opacity: 0, y: 10, scale: 0.98 }}
947+
animate={{ opacity: 1, y: 0, scale: 1 }}
948+
exit={{ opacity: 0, y: 10, scale: 0.98 }}
949+
transition={{ type: 'spring', stiffness: 420, damping: 34 }}
950+
>
951+
<div className="mb-3 px-1">
952+
<p className="m-0 tracking-tight" style={{ color: 'var(--white)', fontSize: '13px', fontWeight: 950 }}>
953+
내 상태
954+
</p>
955+
<p className="m-0 mt-1 tracking-tight" style={{ color: 'var(--muted)', fontSize: '11px', fontWeight: 800 }}>
956+
팀원에게 표시되는 상태를 바꿉니다
957+
</p>
958+
</div>
959+
960+
<div className="grid gap-1.5">
961+
{presenceOptions.map((option) => {
962+
const selected = option.id === userPresence;
963+
return (
964+
<button
965+
key={option.id}
966+
type="button"
967+
onClick={() => setUserPresence(option.id)}
968+
className="flex w-full items-center gap-3 rounded-xl border-0 px-3 py-2.5 text-left tracking-tight"
969+
style={{
970+
background: selected ? 'rgba(32, 227, 255, 0.12)' : 'transparent',
971+
border: selected ? '1px solid rgba(32, 227, 255, 0.20)' : '1px solid transparent',
972+
cursor: 'pointer'
973+
}}
974+
>
975+
<span className="h-2.5 w-2.5 flex-shrink-0 rounded-full" style={{ background: option.color }} />
976+
<span className="min-w-0 flex-1">
977+
<span className="block truncate" style={{ color: 'var(--white)', fontSize: '12px', fontWeight: 950 }}>
978+
{option.label}
979+
</span>
980+
<span className="block truncate" style={{ color: 'var(--muted)', fontSize: '10px', fontWeight: 800 }}>
981+
{option.description}
982+
</span>
983+
</span>
984+
{selected && <Check size={14} style={{ color: 'var(--neon-cyan)', flexShrink: 0 }} />}
985+
</button>
986+
);
987+
})}
988+
</div>
989+
990+
<div className="my-3" style={{ borderTop: '1px solid rgba(32, 227, 255, 0.14)' }} />
991+
992+
<div className="mb-2 px-1">
993+
<p className="m-0 tracking-tight" style={{ color: 'var(--white)', fontSize: '13px', fontWeight: 950 }}>
994+
알림 설정
995+
</p>
996+
</div>
997+
998+
<div className="grid gap-1.5">
999+
{notificationOptions.map((option) => {
1000+
const selected = option.id === notificationMode;
1001+
const Icon = option.icon;
1002+
return (
1003+
<button
1004+
key={option.id}
1005+
type="button"
1006+
onClick={() => setNotificationMode(option.id)}
1007+
className="flex w-full items-center gap-3 rounded-xl border-0 px-3 py-2.5 text-left tracking-tight"
1008+
style={{
1009+
background: selected ? 'rgba(57, 255, 136, 0.10)' : 'transparent',
1010+
border: selected ? '1px solid rgba(57, 255, 136, 0.18)' : '1px solid transparent',
1011+
cursor: 'pointer'
1012+
}}
1013+
>
1014+
<Icon size={15} style={{ color: selected ? 'var(--matrix-green)' : 'var(--muted)', flexShrink: 0 }} />
1015+
<span className="min-w-0 flex-1">
1016+
<span className="block truncate" style={{ color: 'var(--white)', fontSize: '12px', fontWeight: 950 }}>
1017+
{option.label}
1018+
</span>
1019+
<span className="block truncate" style={{ color: 'var(--muted)', fontSize: '10px', fontWeight: 800 }}>
1020+
{option.description}
1021+
</span>
1022+
</span>
1023+
{selected && <Check size={14} style={{ color: 'var(--matrix-green)', flexShrink: 0 }} />}
1024+
</button>
1025+
);
1026+
})}
1027+
</div>
1028+
1029+
<div className="my-3" style={{ borderTop: '1px solid rgba(32, 227, 255, 0.14)' }} />
1030+
1031+
<div className="grid grid-cols-2 gap-2">
1032+
<button
1033+
type="button"
1034+
onClick={() => {
1035+
setProfileMenuOpen(false);
1036+
navigate('/profile');
1037+
}}
1038+
className="flex items-center justify-center gap-2 rounded-xl border-0 px-3 py-2.5 tracking-tight"
1039+
style={{
1040+
background: 'rgba(234, 247, 255, 0.07)',
1041+
border: '1px solid rgba(32, 227, 255, 0.14)',
1042+
color: 'var(--white)',
1043+
cursor: 'pointer',
1044+
fontSize: '12px',
1045+
fontWeight: 900
1046+
}}
1047+
>
1048+
<UserRound size={14} />
1049+
프로필
1050+
</button>
1051+
<button
1052+
type="button"
1053+
onClick={() => {
1054+
setProfileMenuOpen(false);
1055+
navigate('/settings');
1056+
}}
1057+
className="flex items-center justify-center gap-2 rounded-xl border-0 px-3 py-2.5 tracking-tight"
1058+
style={{
1059+
background: 'rgba(234, 247, 255, 0.07)',
1060+
border: '1px solid rgba(32, 227, 255, 0.14)',
1061+
color: 'var(--white)',
1062+
cursor: 'pointer',
1063+
fontSize: '12px',
1064+
fontWeight: 900
1065+
}}
1066+
>
1067+
<Settings size={14} />
1068+
설정
1069+
</button>
1070+
</div>
1071+
</motion.div>
1072+
)}
1073+
</AnimatePresence>
1074+
1075+
<button
1076+
type="button"
1077+
onClick={() => setProfileMenuOpen((open) => !open)}
1078+
className="flex w-full items-center gap-3 rounded-2xl border-0 px-3 py-3 text-left tracking-tight transition-all"
1079+
style={{
1080+
background: profileMenuOpen
1081+
? 'linear-gradient(135deg, rgba(32, 227, 255, 0.16), rgba(57, 255, 136, 0.08)), rgba(11, 22, 40, 0.88)'
1082+
: 'rgba(5, 11, 20, 0.72)',
1083+
border: profileMenuOpen ? '1px solid rgba(32, 227, 255, 0.34)' : '1px solid rgba(32, 227, 255, 0.18)',
1084+
boxShadow: profileMenuOpen ? '0 0 28px rgba(32, 227, 255, 0.14)' : 'inset 0 1px 0 rgba(255, 255, 255, 0.06)',
1085+
cursor: 'pointer'
1086+
}}
1087+
aria-expanded={profileMenuOpen}
1088+
aria-label="내 프로필 메뉴 열기"
1089+
>
1090+
<span className="relative grid h-10 w-10 flex-shrink-0 place-items-center rounded-full" style={{
1091+
background: 'linear-gradient(135deg, var(--neon-cyan), var(--matrix-green))',
1092+
color: '#021014',
1093+
fontSize: '13px',
1094+
fontWeight: 950
1095+
}}>
1096+
{myProfile.initials}
1097+
<span className="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full" style={{
1098+
background: currentPresence.color,
1099+
border: '2px solid #07111f'
1100+
}} />
1101+
</span>
1102+
<span className="min-w-0 flex-1">
1103+
<span className="block truncate" style={{ color: 'var(--white)', fontSize: '13px', fontWeight: 950 }}>
1104+
{myProfile.name}
1105+
</span>
1106+
<span className="mt-0.5 flex min-w-0 items-center gap-1.5">
1107+
<Clock3 size={11} style={{ color: currentPresence.color, flexShrink: 0 }} />
1108+
<span className="truncate" style={{ color: 'var(--muted)', fontSize: '11px', fontWeight: 850 }}>
1109+
{currentPresence.label}
1110+
</span>
1111+
</span>
1112+
</span>
1113+
<span className="grid h-8 w-8 flex-shrink-0 place-items-center rounded-full" style={{
1114+
background: notificationMode === 'muted' ? 'rgba(255, 107, 107, 0.10)' : 'rgba(32, 227, 255, 0.10)',
1115+
border: notificationMode === 'muted' ? '1px solid rgba(255, 107, 107, 0.22)' : '1px solid rgba(32, 227, 255, 0.16)'
1116+
}}>
1117+
<CurrentNotificationIcon size={14} style={{ color: notificationMode === 'muted' ? '#FF8FA3' : 'var(--neon-cyan)' }} />
1118+
</span>
1119+
</button>
1120+
</div>
1121+
);
1122+
9041123
const handleMergePR = (messageId: number) => {
9051124
setMessages(prevMessages => {
9061125
const newMessages = { ...prevMessages };
@@ -1213,8 +1432,8 @@ export function ChatPage() {
12131432
)}
12141433

12151434
{visibleRepositories.length > 0 ? (
1216-
<div className="flex flex-1 flex-col overflow-y-auto">
1217-
<div className="grid gap-2 min-w-0">
1435+
<div className="flex flex-1 flex-col overflow-hidden">
1436+
<div className="grid min-w-0 flex-1 content-start gap-2 overflow-y-auto pr-1">
12181437
{renderSidebarChannel({ id: 'overview', label: '통합 개요', icon: Home })}
12191438

12201439
<div className="my-1" style={{ borderTop: '1px solid rgba(32, 227, 255, 0.14)' }} />
@@ -1753,6 +1972,7 @@ export function ChatPage() {
17531972
<div className="mb-2" style={{ borderTop: '1px solid rgba(32, 227, 255, 0.14)' }}></div>
17541973

17551974
{renderSidebarChannel({ id: 'team', label: '팀', icon: Users })}
1975+
{renderProfileDock()}
17561976
</div>
17571977
</div>
17581978
) : (

0 commit comments

Comments
 (0)