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
1 change: 1 addition & 0 deletions .github/workflows/cd-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ jobs:
id: changes
uses: dorny/paths-filter@v3
with:
base: ${{ github.event.before }}
filters: |
client:
- 'apps/client/**'
Expand Down
7 changes: 3 additions & 4 deletions apps/client/src/widgets/chat/components/ChatIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,12 @@ export function ChatIcon({ unreadCount, onClick }: ChatIconProps) {
<div className="fixed right-5 bottom-5 z-50">
<Button
onClick={onClick}
size="icon-lg"
className="relative h-14 w-14 rounded-full shadow-lg"
className="relative h-16 w-16 rounded-full shadow-lg transition-transform duration-150 ease-out hover:scale-[1.08] active:scale-[0.96]"
aria-label="채팅 열기"
>
<MessageCircle className="h-6 w-6" />
<MessageCircle className="size-6" />
{unreadCount > 0 && (
<span className="bg-destructive absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[10px] font-semibold text-white">
<span className="bg-destructive absolute top-1 -right-1.5 flex h-5 min-w-5 items-center rounded-full px-1.5 text-xs font-semibold text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
Expand Down
83 changes: 26 additions & 57 deletions apps/client/src/widgets/chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
type ChangeEvent,
} from 'react';
import { Button, Textarea } from '@codejam/ui';
import { Send, FileText } from 'lucide-react';
import { Send } from 'lucide-react';
import { ChatMentionPopover } from './ChatMentionPopover';
import { LIMITS } from '@codejam/common';
import { emitChatMessage } from '@/stores/socket-events/chat';
import { useFileStore } from '@/stores/file';
Expand Down Expand Up @@ -43,8 +44,10 @@ export function ChatInput() {

// 선택 인덱스 리셋 (setState는 다음 틱으로 지연해 cascading render 방지)
useEffect(() => {
queueMicrotask(() => setSelectedIndex(0));
}, [mentionState.query]);
if (mentionState.isOpen) {
queueMicrotask(() => setSelectedIndex(0));
}
}, [mentionState.isOpen, mentionState.query]);

const trimmedContent = content.trim();
const isValid =
Expand Down Expand Up @@ -134,67 +137,33 @@ export function ChatInput() {

return (
<div className="border-border relative flex items-end gap-2 border-t px-3 py-2 select-none">
{/* 파일 선택 Popover */}
{mentionState.isOpen && filteredFiles.length > 0 && (
<div className="border-border bg-popover/95 animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2 absolute bottom-full left-3 mb-2 w-72 origin-bottom rounded-xl border p-1.5 shadow-xl backdrop-blur-sm duration-150">
{/* 헤더 */}
<div className="text-muted-foreground mb-1 px-2 py-1 text-[10px] font-medium tracking-wider uppercase">
파일 선택
</div>

{/* 파일 목록 */}
<div className="max-h-48 overflow-y-auto">
{filteredFiles.slice(0, 8).map(({ name: fileName }, index) => (
<button
key={fileName}
onClick={() => handleSelectFile(fileName)}
className={`group flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm transition-all duration-150 ${
index === selectedIndex
? 'bg-primary/15 text-primary'
: 'text-foreground hover:bg-accent/60'
}`}
>
<div
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors ${
index === selectedIndex
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground group-hover:bg-accent'
}`}
>
<FileText className="h-3.5 w-3.5" />
</div>
<span className="truncate font-medium">{fileName}</span>
</button>
))}
</div>

{/* 힌트 */}
<div className="text-muted-foreground/70 border-border/50 mt-1 border-t px-2 pt-1.5 text-[10px]">
↑↓ 이동 · Enter 선택 · Esc 닫기
</div>
</div>
)}

<Textarea
ref={textareaRef}
value={content}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="메시지를 입력하세요... (@로 파일 언급)"
maxLength={LIMITS.CHAT_MESSAGE_MAX}
rows={1}
className="max-h-[100px] min-h-[36px] flex-1 resize-none"
/>
<div className="relative flex-1">
<ChatMentionPopover
isOpen={mentionState.isOpen}
files={filteredFiles}
selectedIndex={selectedIndex}
onSelectFile={handleSelectFile}
/>
<Textarea
ref={textareaRef}
value={content}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="메시지를 입력하세요... (@로 파일 언급)"
maxLength={LIMITS.CHAT_MESSAGE_MAX}
rows={1}
className="max-h-25 min-h-9 w-full resize-none"
/>
</div>

<Button
variant="ghost"
size="icon"
size="icon-lg"
onClick={handleSend}
disabled={!isValid}
className="h-9 w-9 shrink-0"
title="전송 (Enter)"
>
<Send className="h-4 w-4" />
<Send />
</Button>
</div>
);
Expand Down
100 changes: 100 additions & 0 deletions apps/client/src/widgets/chat/components/ChatMentionPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useEffect, useRef } from 'react';
import { FileIcon } from 'lucide-react';
import { extname } from '@/shared/lib/file';

import CIcon from '@/assets/exts/c.svg?react';
import CppIcon from '@/assets/exts/cpp.svg?react';
import JavaIcon from '@/assets/exts/java.svg?react';
import JavaScriptIcon from '@/assets/exts/javascript.svg?react';
import TypeScriptIcon from '@/assets/exts/typescript.svg?react';
import PythonIcon from '@/assets/exts/python.svg?react';

const iconMap: Record<
string,
React.ComponentType<React.SVGProps<SVGSVGElement>>
> = {
c: CIcon,
cpp: CppIcon,
java: JavaIcon,
js: JavaScriptIcon,
ts: TypeScriptIcon,
py: PythonIcon,
};

const getFileIcon = (fileName: string) => {
const extension = extname(fileName);
if (!extension) return <FileIcon className="h-4 w-4 shrink-0" />;

const Icon = iconMap[extension.toLowerCase()];
return Icon ? (
<Icon className="h-4 w-4 shrink-0" />
) : (
<FileIcon className="h-4 w-4 shrink-0" />
);
};

interface ChatMentionPopoverProps {
isOpen: boolean;
files: Array<{ name: string }>;
selectedIndex: number;
onSelectFile: (fileName: string) => void;
}

/**
* 파일 멘션 자동완성 Popover
* - @ 입력 시 파일 목록 표시
* - 키보드 네비게이션 지원 (↑↓, Enter, Esc)
*/
export function ChatMentionPopover({
isOpen,
files,
selectedIndex,
onSelectFile,
}: ChatMentionPopoverProps) {
const selectedRef = useRef<HTMLButtonElement>(null);

// 선택된 항목으로 스크롤
useEffect(() => {
selectedRef.current?.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
}, [selectedIndex]);

if (!isOpen || files.length === 0) return null;

return (
<div className="bg-popover text-popover-foreground absolute bottom-full left-0 z-50 mb-2 w-full rounded-lg border p-2.5 shadow-md">
{/* 헤더 */}
<div className="text-primary mb-2 text-xs font-medium">파일 선택</div>

{/* 파일 목록 */}
<div className="max-h-48 space-y-1 overflow-y-auto">
{files.map(({ name: fileName }, index) => (
<button
key={fileName}
ref={index === selectedIndex ? selectedRef : null}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelectFile(fileName)}
className={`flex h-10 w-full cursor-pointer items-center gap-1.5 overflow-hidden px-2 text-left transition-all duration-200 ${
index === selectedIndex
? 'bg-accent/80 text-primary rounded-sm'
: 'hover:bg-muted/60 text-muted-foreground hover:text-foreground'
}`}
>
{getFileIcon(fileName)}
<span className="truncate text-sm" title={fileName}>
{fileName}
</span>
</button>
))}
</div>

{/* 힌트 */}
<div className="text-muted-foreground border-t pt-1 text-[11px] tracking-tight">
↑↓ 이동 · Enter 선택 · Esc 닫기
</div>
</div>
);
}
2 changes: 1 addition & 1 deletion apps/client/src/widgets/chat/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const ChatMessages = memo(function ChatMessages() {
const messages = useChatStore((state) => state.messages);

return (
<div className="min-h-0 flex-1 select-none">
<div className="min-h-0 flex-1">
<ChatWindow messages={messages} />
</div>
);
Expand Down
12 changes: 6 additions & 6 deletions apps/client/src/widgets/participants/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { usePtsStore } from '@/stores/pts';
import { useRoomStore } from '@/stores/room';
import { useSocketStore } from '@/stores/socket';
import { SOCKET_EVENTS, PERMISSION, ROLE, type Pt } from '@codejam/common';
import { SidebarHeader, toast } from '@codejam/ui';
import { SidebarHeader, toast, ScrollArea } from '@codejam/ui';
import type { SortKey } from './lib/types';
import type { FilterOption } from './types';
import { filterParticipants, sortParticipants } from './types';
Expand Down Expand Up @@ -109,7 +109,7 @@ export function Participants() {
/>
}
/>
<div>
<div className="min-h-0 flex-1">
<ParticipantsSection count={totalCount} me={me} others={others} />
</div>
</div>
Expand All @@ -134,12 +134,12 @@ function ParticipantsSection({
}

return (
<>
<div className="flex h-full flex-col">
{me && <Divider />}
<Me me={me} />
{others.length > 0 && <Divider />}
<ParticipantList others={others} />
</>
</div>
);
}

Expand All @@ -159,11 +159,11 @@ function Me({ me }: { me?: Pt }) {

function ParticipantList({ others }: { others: Pt[] }) {
return (
<div className="flex-1 overflow-y-auto">
<ScrollArea className="min-h-0 flex-1">
{others.map((p) => (
<Participant key={p.ptId} ptId={p.ptId} />
))}
</div>
</ScrollArea>
);
}

Expand Down
Binary file added docs/week7-demo-final.pdf
Binary file not shown.
9 changes: 8 additions & 1 deletion packages/ui/src/components/base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ export {
InputGroupTextarea,
} from './input-group';
export { Input } from './input';
export { Popover, PopoverContent, PopoverTrigger } from './popover';
export {
Progress,
ProgressTrack,
Expand Down Expand Up @@ -132,3 +131,11 @@ export {
EmptyContent,
EmptyMedia,
} from './empty';
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from './popover';
46 changes: 17 additions & 29 deletions packages/ui/src/components/primitives/avatar/avvvatars-avatar.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import Avvvatars from './avvvatars/avvvatars.js';
import { getAvatarColors } from '@codejam/common/avvvatars';
import { SHAPE_PATHS } from './avvvatars/shape-paths.js';
import { type AvatarProvider } from './avatar-generator.js';

export type AvvvatarsVariant = 'shape' | 'character';
Expand All @@ -28,51 +28,39 @@ export class AvvvatarsProvider implements AvatarProvider {
}

toSvgString(id: string, size: number): string {
const html = renderToStaticMarkup(
createElement(Avvvatars, {
value: id,
size,
variant: this.variant,
}),
);

// 색상 정보 가져오기
const colors = getAvatarColors(id);
const bgColor = colors.background;
const fgColor = this.variant === 'character' ? colors.text : colors.shape;

if (this.variant === 'character') {
// Character 모드: 배경 원 + 텍스트
const name = String(id).substring(0, 2).toUpperCase();
const fgColor = colors.text;
const fontSize = Math.round((size / 100) * 37);

const svg = `<svg viewBox="0 0 32 32" fill="none" width="${size}" height="${size}">
return `<svg viewBox="0 0 32 32" fill="none" width="${size}" height="${size}">
<circle cx="16" cy="16" r="16" fill="${bgColor}"/>
<text x="16" y="16" text-anchor="middle" dominant-baseline="central" fill="${fgColor}" font-family="-apple-system, BlinkMacSystemFont, Inter, Segoe UI, Roboto, sans-serif" font-size="${fontSize}" font-weight="500" style="text-transform: uppercase;">${name}</text>
</svg>`;

return svg;
} else {
// Shape 모드: 배경 원 + 아이콘
// 아이콘 path 추출 (원본 viewBox는 항상 32x32)
const pathMatch = html.match(/<path[^>]*d="([^"]+)"[^>]*>/);
const pathD = pathMatch ? pathMatch[1] : '';
const pathData = SHAPE_PATHS[colors.shapeIndex];
const fgColor = colors.shape;

// clipPath 추출 (일부 shape에서 사용)
const clipPathMatch = html.match(/<clipPath[^>]*>([\s\S]*?)<\/clipPath>/);
const hasClipPath = !!clipPathMatch;
const clipDefs = pathData.clipPathId
? `<defs><clipPath id="${pathData.clipPathId}"><rect width="32" height="32" fill="white"/></clipPath></defs>`
: '';
const clipOpen = pathData.clipPathId
? `<g clip-path="url(#${pathData.clipPathId})">`
: '';
const clipClose = pathData.clipPathId ? '</g>' : '';

const svg = `<svg viewBox="0 0 32 32" fill="none" width="${size}" height="${size}">
return `<svg viewBox="0 0 32 32" fill="none" width="${size}" height="${size}">
<circle cx="16" cy="16" r="16" fill="${bgColor}"/>
<g transform="translate(6.4, 6.4) scale(0.6)">
${hasClipPath ? `<defs>${clipPathMatch[0]}</defs>` : ''}
${hasClipPath ? `<g clip-path="url(#clip0_1_4196)">` : ''}
<path d="${pathD}" fill="${fgColor}"/>
${hasClipPath ? '</g>' : ''}
${clipDefs}
${clipOpen}
<path d="${pathData.d}" fill="${fgColor}"/>
${clipClose}
</g>
</svg>`;

return svg;
}
}
}
Loading