From 10c7fdb6cfe14ecb22fa0fa53c26b1a43b2f82d2 Mon Sep 17 00:00:00 2001 From: inaemin Date: Thu, 5 Feb 2026 21:40:04 +0900 Subject: [PATCH 01/43] =?UTF-8?q?=E2=9C=A8=20feat(files):=20InlineFileInpu?= =?UTF-8?q?t=20=EC=B4=88=EA=B8=B0=EA=B0=92=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파일 이름 변경 기능을 위해 InlineFileInput에 초기값 전달 기능을 추가하고 레이아웃을 개선함 - initialValue prop 추가 및 value 상태 초기화 로직 반영 - 에러 메시지 노출 시 하단 여백(mb-5) 처리 방식 개선 - 불필요한 마진(my-0.5) 제거 --- .../src/widgets/files/components/InlineFileInput.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/files/components/InlineFileInput.tsx b/apps/client/src/widgets/files/components/InlineFileInput.tsx index 2eb011ec..055750b1 100644 --- a/apps/client/src/widgets/files/components/InlineFileInput.tsx +++ b/apps/client/src/widgets/files/components/InlineFileInput.tsx @@ -4,6 +4,7 @@ import { filenameSchema } from '@codejam/common'; import { toast } from '@codejam/ui'; interface InlineFileInputProps { + initialValue?: string; onSubmit: (filename: string) => void; onCancel: () => void; } @@ -22,10 +23,11 @@ const validateFilename = (value: string) => { }; export const InlineFileInput = ({ + initialValue = '', onSubmit, onCancel, }: InlineFileInputProps) => { - const [value, setValue] = useState(''); + const [value, setValue] = useState(initialValue); const [error, setError] = useState(null); const inputRef = useRef(null); @@ -87,12 +89,12 @@ export const InlineFileInput = ({ }; return ( -
+
From 9041d5f6779a03ba760c104e37c66b58d7ec974e Mon Sep 17 00:00:00 2001 From: inaemin Date: Thu, 5 Feb 2026 21:40:17 +0900 Subject: [PATCH 02/43] =?UTF-8?q?=E2=9C=A8=20feat(files):=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=EB=A5=BC=20=EC=9D=B8?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 다이얼로그 방식의 이름 변경 인터페이스를 InlineFileInput을 사용하는 인라인 방식으로 개편하여 UX 개선 - RenameDialog 제거 및 isRenaming 상태 기반 InlineFileInput 렌더링 추가 - handleRenameSubmit 구현: 변경사항이 없을 경우 조기 종료 및 이름 변경 로직 통합 - 에러 발생 시 토스트 알림 추가 - 파일 목록 아이템의 너비를 100%로 보장하도록 수정 (w-full 추가) --- .../src/widgets/files/components/File.tsx | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/apps/client/src/widgets/files/components/File.tsx b/apps/client/src/widgets/files/components/File.tsx index a5fe591f..79ef7332 100644 --- a/apps/client/src/widgets/files/components/File.tsx +++ b/apps/client/src/widgets/files/components/File.tsx @@ -1,9 +1,9 @@ -import { cn } from '@codejam/ui'; +import { cn, toast } from '@codejam/ui'; import { useFileStore } from '@/stores/file'; import { memo, useState, type DragEvent, type MouseEvent } from 'react'; -import { RenameDialog } from '@/widgets/dialog/RenameDialog'; import { DeleteDialog } from '@/widgets/dialog/DeleteDialog'; import { FileMoreMenu } from './FileMoreMenu'; +import { InlineFileInput } from './InlineFileInput'; type DialogType = 'RENAME' | 'DELETE' | undefined; type FileProps = { @@ -19,12 +19,14 @@ const INACTIVE_FILE_HOVER = export const File = memo(({ fileId, fileName, hasPermission }: FileProps) => { const setActiveFile = useFileStore((state) => state.setActiveFile); const activeFileId = useFileStore((state) => state.activeFileId); + const renameFile = useFileStore((state) => state.renameFile); const [x, setX] = useState(0); const [y, setY] = useState(0); const [open, setOpen] = useState(false); const [dialogType, setDialogType] = useState(undefined); + const [isRenaming, setIsRenaming] = useState(false); const isActive = activeFileId === fileId; @@ -33,10 +35,28 @@ export const File = memo(({ fileId, fileName, hasPermission }: FileProps) => { }; const handleActionClick = (type: DialogType) => { + if (type === 'RENAME') { + setIsRenaming(true); + return; + } setDialogType(type); setOpen(true); }; + const handleRenameSubmit = async (newFileName: string) => { + if (fileName === newFileName) { + setIsRenaming(false); + return; + } + + try { + await renameFile(fileId, newFileName); + setIsRenaming(false); + } catch { + toast.error('파일 이름 변경에 실패했습니다.'); + } + }; + const handleDragStart = (ev: DragEvent) => { ev.dataTransfer.setData( 'application/json', @@ -58,6 +78,16 @@ export const File = memo(({ fileId, fileName, hasPermission }: FileProps) => { } }; + if (isRenaming) { + return ( + setIsRenaming(false)} + /> + ); + } + return ( <>
{ onMouseUp={onMouseUp} onDragStart={handleDragStart} > -
+

{fileName}

@@ -87,13 +117,6 @@ export const File = memo(({ fileId, fileName, hasPermission }: FileProps) => { )}
- - { ); }); + File.displayName = 'FileList'; From 2d542e71c6ac3d49413d9aceddaa37f94a3faab2 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 00:15:03 +0900 Subject: [PATCH 03/43] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20Progress=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B3=A0=EB=8F=84?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20CapacityGauge=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 디자인 시스템의 일관성을 위해 Progress 컴포넌트의 유연성을 확장하고 저장 용량 표시기에 적용함 - Progress: Label, Value, Track, Indicator 하위 컴포넌트 추가 및 커스텀 클래스 지원 - CapacityGauge: 새로운 Progress 컴포넌트를 사용하여 레이아웃 및 시각적 피드백 개선 - UI 패키지 export 정리 --- .../widgets/capacity-gauge/CapacityGauge.tsx | 43 ++++++++++--------- packages/ui/src/components/base/index.ts | 8 +++- packages/ui/src/components/base/progress.tsx | 11 +++-- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/apps/client/src/widgets/capacity-gauge/CapacityGauge.tsx b/apps/client/src/widgets/capacity-gauge/CapacityGauge.tsx index ad3d18d5..d45970eb 100644 --- a/apps/client/src/widgets/capacity-gauge/CapacityGauge.tsx +++ b/apps/client/src/widgets/capacity-gauge/CapacityGauge.tsx @@ -1,4 +1,4 @@ -import { RadixProgress as Progress, cn } from '@codejam/ui'; +import { Progress, ProgressLabel, ProgressValue, cn } from '@codejam/ui'; import { useFileStore } from '@/stores/file'; function formatBytes(bytes: number): string { @@ -19,32 +19,33 @@ export function CapacityGauge() { }; return ( -
-
-
- 저장 용량 - - {capacityPercentage.toFixed(1)}% - -
- - {formatBytes(capacityBytes)} / 1 MB - -
- +
+ > +
+
+ + 저장 용량 + + +
+ + {formatBytes(capacityBytes)} / 1 MB + +
+
); } diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts index d42924d5..4798a15a 100644 --- a/packages/ui/src/components/base/index.ts +++ b/packages/ui/src/components/base/index.ts @@ -63,7 +63,13 @@ export { } from './input-group'; export { Input } from './input'; export { Popover, PopoverContent, PopoverTrigger } from './popover'; -export { Progress } from './progress'; +export { + Progress, + ProgressTrack, + ProgressIndicator, + ProgressLabel, + ProgressValue, +} from './progress'; export { Select, SelectContent, diff --git a/packages/ui/src/components/base/progress.tsx b/packages/ui/src/components/base/progress.tsx index 935ba440..610b8e84 100644 --- a/packages/ui/src/components/base/progress.tsx +++ b/packages/ui/src/components/base/progress.tsx @@ -4,10 +4,15 @@ import { cn } from '../../lib/utils'; function Progress({ className, + trackClassName, + indicatorClassName, children, value, ...props -}: ProgressPrimitive.Root.Props) { +}: ProgressPrimitive.Root.Props & { + trackClassName?: string; + indicatorClassName?: string; +}) { return ( {children} - - + + ); From b118db4439840bad181b9282c8c102afae7079e9 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 00:15:31 +0900 Subject: [PATCH 04/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(dialog):=20?= =?UTF-8?q?DuplicateDialog=EB=A5=BC=20AlertDialog=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B0=8F=20=EA=B8=B0=EB=8A=A5=20=EA=B3=A0=EB=8F=84?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파일 중복 처리 경험을 개선하기 위해 다이얼로그 구조와 자동 이름 생성 로직을 전면 개편함 - RadixDialog에서 AlertDialog 시스템으로 마이그레이션 (중요도 강조) - generateAutoName: 스마트한 사본 이름 생성 로직 구현 (예: "file (1).txt", "file (2).txt") - 툴팁을 통해 변경될 사본 이름을 미리 확인할 수 있는 기능 추가 - 덮어쓰기/자동이름변경 콜백 props 추가로 유연성 확보 - 중복된 레거시 파일(DuplicateDialog_new.tsx) 삭제 --- .../src/widgets/dialog/DuplicateDialog.tsx | 194 +++++++++++------- .../widgets/dialog/DuplicateDialog_new.tsx | 123 ----------- 2 files changed, 116 insertions(+), 201 deletions(-) delete mode 100644 apps/client/src/widgets/dialog/DuplicateDialog_new.tsx diff --git a/apps/client/src/widgets/dialog/DuplicateDialog.tsx b/apps/client/src/widgets/dialog/DuplicateDialog.tsx index 398ddc2e..13ec7f40 100644 --- a/apps/client/src/widgets/dialog/DuplicateDialog.tsx +++ b/apps/client/src/widgets/dialog/DuplicateDialog.tsx @@ -1,12 +1,18 @@ import { - RadixDialog as Dialog, - RadixDialogContent as DialogContent, - RadixDialogDescription as DialogDescription, - RadixDialogFooter as DialogFooter, - RadixDialogHeader as DialogHeader, - RadixDialogTitle as DialogTitle, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, + Tooltip, + TooltipContent, + TooltipTrigger, } from '@codejam/ui'; -import { Button } from '@codejam/ui'; +import { AlertCircle } from 'lucide-react'; import { useFileStore } from '@/stores/file'; import { extname, purename } from '@/shared/lib/file'; import { uploadFile } from '@/shared/lib/file'; @@ -14,113 +20,145 @@ import { uploadFile } from '@/shared/lib/file'; interface DuplicateDialogProps { open: boolean; onOpenChange: (open: boolean) => void; + filename: string; + onClick: () => void; + file?: File; + onOverwrite?: () => void; + onAutoRename?: (newName: string) => void; } -export function DuplicateDialog({ open, onOpenChange }: DuplicateDialogProps) { +export function DuplicateDialog({ + open, + onOpenChange, + filename, + onClick, + file, + onOverwrite, + onAutoRename, +}: DuplicateDialogProps) { const overwriteFile = useFileStore((state) => state.overwriteFile); const createFile = useFileStore((state) => state.createFile); const getFileId = useFileStore((state) => state.getFileId); const getFileNamesMap = useFileStore((state) => state.getFileNamesMap); - const tempFiles = useFileStore((state) => state.tempFiles); - const shiftTempFile = useFileStore((state) => state.shiftTempFile); - const fileName = tempFiles && tempFiles[0] ? tempFiles[0].name : ''; + const generateAutoName = (): string => { + const fileNamesMap = getFileNamesMap(); + if (!fileNamesMap) { + return filename; + } - const checkRepeat = () => { - shiftTempFile(); - if (tempFiles.length === 0) { - onOpenChange(false); + const ext = extname(filename); + const baseName = purename(filename).replace(/ \(\d+\)$/, ''); + + let maxNum = 0; + for (const name of Object.keys(fileNamesMap.toJSON())) { + if (extname(name) !== ext) continue; + + const pure = purename(name); + if (pure === baseName) { + // baseName 자체가 존재 → 최소 (1)이 필요 + maxNum = Math.max(maxNum, 0); + continue; + } + + const escaped = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = pure.match(new RegExp(`^${escaped} \\((\\d+)\\)$`)); + if (match) { + maxNum = Math.max(maxNum, parseInt(match[1])); + } } + + return `${baseName} (${maxNum + 1}).${ext}`; }; const handleOverwrite = async () => { - const fileId = getFileId(tempFiles[0].name); - if (!fileId) { + if (onOverwrite) { + onOverwrite(); + onOpenChange(false); + onClick(); return; } + const fileId = getFileId(filename); + if (!fileId || !file) return; + try { - const { content, type } = await uploadFile(tempFiles[0]); + const { content, type } = await uploadFile(file); overwriteFile(fileId, content, type); - checkRepeat(); + onOpenChange(false); + onClick(); } catch (error) { console.error('Failed to overwrite file:', error); } }; const handleRename = async () => { - const fileId = getFileId(fileName); + const name = generateAutoName(); - if (!fileId) { + if (onAutoRename) { + onAutoRename(name); + onOpenChange(false); + onClick(); return; } - const newName = (): string => { - const fileNamesMap = getFileNamesMap(); - if (!fileNamesMap) { - return fileName; - } - - const entries: [string, string][] = Object.entries( - fileNamesMap.toJSON(), - ).filter(([name]) => { - const pure = purename(fileName); - const size = pure.length; - if (name.length < size) { - return false; - } - return name.substring(0, size).trim() === pure.trim(); - }); - - const top = entries.sort((a, b) => b[1].localeCompare(a[1]))[0]; - const ext = extname(top[0]); - const pure = purename(top[0]); - const fileMatch = pure.match(/.+\((\d+)\)$/i); - - if (!fileMatch) { - return `${pure} (1).${ext}`; - } + const fileId = getFileId(filename); + if (!fileId) return; - return `${pure.replace(/\((\d+)\)$/i, `(${(parseInt(fileMatch[1]) + 1).toString()})`)}.${ext}`; - }; + if (!file) return; try { - const { content, type } = await uploadFile(tempFiles[0]); - createFile(newName(), content, type); - checkRepeat(); + const { content, type } = await uploadFile(file); + createFile(name, content, type); + onOpenChange(false); + onClick(); } catch (error) { - console.error('Failed to rename file:', error); + console.error('Failed to create file with new name:', error); } }; - const handleCancel = () => { - checkRepeat(); - }; + const expectedName = generateAutoName(); return ( - - e.preventDefault()} - > - - 파일 중복 - - {fileName}파일은 이미 존재합니다. 하시겠습니까? - - - - - - - - - + + + ( + + 사본으로 저장 + + )} + /> + +

+ 새로운 파일명:{' '} + {expectedName} +

+
+
+ + + ); } diff --git a/apps/client/src/widgets/dialog/DuplicateDialog_new.tsx b/apps/client/src/widgets/dialog/DuplicateDialog_new.tsx deleted file mode 100644 index 0d31c656..00000000 --- a/apps/client/src/widgets/dialog/DuplicateDialog_new.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { - RadixDialog as Dialog, - RadixDialogClose as DialogClose, - RadixDialogContent as DialogContent, - RadixDialogDescription as DialogDescription, - RadixDialogFooter as DialogFooter, - RadixDialogHeader as DialogHeader, - RadixDialogTitle as DialogTitle, -} from '@codejam/ui'; -import { Button } from '@codejam/ui'; -import { useFileStore } from '@/stores/file'; -import { extname, purename } from '@/shared/lib/file'; -import { uploadFile } from '@/shared/lib/file'; - -interface DuplicateDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - filename: string; - onClick: () => void; - file?: File; -} - -export function DuplicateDialog({ - open, - onOpenChange, - filename, - onClick, - file, -}: DuplicateDialogProps) { - const overwriteFile = useFileStore((state) => state.overwriteFile); - const createFile = useFileStore((state) => state.createFile); - const getFileId = useFileStore((state) => state.getFileId); - const getFileNamesMap = useFileStore((state) => state.getFileNamesMap); - - const handleOverwrite = async () => { - const fileId = getFileId(filename); - if (!fileId || !file) return; - - try { - const { content, type } = await uploadFile(file); - overwriteFile(fileId, content, type); - onOpenChange(false); - onClick(); - } catch (error) { - console.error('Failed to overwrite file:', error); - } - }; - - const handleRename = async () => { - const fileId = getFileId(filename); - if (!fileId) return; - - const newName = (): string => { - const fileNamesMap = getFileNamesMap(); - if (!fileNamesMap) { - return filename; - } - - const entries: [string, string][] = Object.entries( - fileNamesMap.toJSON(), - ).filter(([name]) => { - const pure = purename(filename); - const size = pure.length; - if (name.length < size) { - return false; - } - return name.substring(0, size).trim() === pure.trim(); - }); - - const top = entries.sort((a, b) => b[1].localeCompare(a[1]))[0]; - const ext = extname(top[0]); - const pure = purename(top[0]); - const fileMatch = pure.match(/.+\((\d+)\)$/i); - - if (!fileMatch) { - return `${pure} (1).${ext}`; - } - - return `${pure.replace(/\((\d+)\)$/i, `(${(parseInt(fileMatch[1]) + 1).toString()})`)}.${ext}`; - }; - - if (!file) return; - - try { - const { content, type } = await uploadFile(file); - const name = newName(); - createFile(name, content, type); - onOpenChange(false); - onClick(); - } catch (error) { - console.error('Failed to create file with new name:', error); - } - }; - - return ( - - - - 파일 중복 - - {filename} 파일이 이미 - 존재합니다. -
- 어떻게 하시겠습니까? -
-
- - - - - - - -
-
- ); -} From e4bdd8a51b0c611d800349683392c38be3602616 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 00:15:51 +0900 Subject: [PATCH 05/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(dialog):=20?= =?UTF-8?q?ImageUploadDialog=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 복잡하게 나뉘어 있던 하위 컴포넌트들을 통합하고 디자인 시스템의 기본 컴포넌트들을 적용하여 유지보수성을 높임 - @codejam/ui의 Dialog, Button, Input, Label 컴포넌트 적용 - 폼 구조 단순화 및 스타일링 개선 (Grid 레이아웃 적용) - 불필요한 내부 함수 및 컴포넌트 선언 제거 --- .../src/widgets/dialog/ImageUploadDialog.tsx | 206 +++++++----------- 1 file changed, 80 insertions(+), 126 deletions(-) diff --git a/apps/client/src/widgets/dialog/ImageUploadDialog.tsx b/apps/client/src/widgets/dialog/ImageUploadDialog.tsx index 18094f92..ce7af5e3 100644 --- a/apps/client/src/widgets/dialog/ImageUploadDialog.tsx +++ b/apps/client/src/widgets/dialog/ImageUploadDialog.tsx @@ -1,12 +1,13 @@ import { - RadixDialog as Dialog, - RadixDialogContent as DialogContent, - RadixDialogFooter as DialogFooter, - RadixDialogHeader as DialogHeader, - RadixDialogTitle as DialogTitle, - RadixDialogDescription as DialogDescription, Button, - RadixDialogClose as DialogClose, + Input, + Label, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from '@codejam/ui'; import { useState } from 'react'; @@ -74,127 +75,80 @@ export function ImageUploadDialog({ return ( - -
- -
- - - - - -
+ + + + 이미지 공유 + + 이미지 URL을 사용해 파일을 공유하면 파일 목록에서 이미지를 확인할 + 수 있습니다. + + + +
+
+ + setName(e.target.value)} + placeholder="example.png" + className="col-span-3" + autoFocus + /> +
+
+ + setUrl(e.target.value)} + placeholder="https://example.com/image" + className="col-span-3" + /> +
+ + + + {error && ( +

{error}

+ )} + + {previewUrl && ( + preview + )} +
+ + + + +
); } - -function Header() { - return ( - <> - - 이미지 공유 - - - 이미지 URL을 사용해 파일을 공유하면 파일 목록에서 이미지를 확인할 수 - 있습니다. - - - ); -} - -function FileNameInput({ - value, - onChange, -}: { - value: string; - onChange: (value: string) => void; -}) { - return ( -
- - onChange(e.target.value)} - placeholder="example.png" - className="border-input flex h-9 w-full rounded-md border bg-transparent px-3 text-sm" - autoFocus - /> -
- ); -} - -function ImageUrlInput({ - value, - onChange, -}: { - value: string; - onChange: (value: string) => void; -}) { - return ( -
- - onChange(e.target.value)} - placeholder="https://example.com/image" - className="border-input flex h-9 w-full rounded-md border bg-transparent px-3 text-sm" - /> -
- ); -} - -function CheckImageButton({ - isChecking, - onClick, -}: { - isChecking: boolean; - onClick: () => void; -}) { - return ( - - ); -} - -function ErrorMessage({ error }: { error: string | null }) { - if (!error) return null; - return

{error}

; -} - -function Preview({ url }: { url: string | null }) { - if (!url) return null; - - return ( - preview - ); -} - -function Footer({ disabled }: { disabled: boolean }) { - return ( - - - - - - - ); -} From 0e4423e2c60d7dc87d038210db4535fd0c9e8b91 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 00:16:16 +0900 Subject: [PATCH 06/43] =?UTF-8?q?=E2=9C=A8=20feat(files):=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20UX=20?= =?UTF-8?q?=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 별도의 다이얼로그 없이 파일 목록 내에서 생성, 이름 변경, 중복 처리가 가능하도록 인라인 워크플로우를 완성함 - File: 인라인 이름 변경 연동 및 이름 충돌 시 DuplicateDialog 연동 - FileList: 인라인 생성 과정에서의 중복 처리(덮어쓰기/사본생성) 로직 통합 - FileHeaderActions: NewFileDialog 제거 및 인라인 생성 트리거로 전환 - RoomPage: 전역 DuplicateDialog 제거 (개별 위젯 내에서 상황에 맞게 처리) - 덮어쓰기 시 기존 파일 내용 유지 및 사본 생성 시 자동 넘버링 지원 --- apps/client/src/pages/room/RoomPage.tsx | 9 +--- .../src/widgets/files/components/File.tsx | 50 ++++++++++++++++--- .../files/components/FileHeaderActions.tsx | 48 ++++-------------- apps/client/src/widgets/files/index.tsx | 23 ++++++--- 4 files changed, 71 insertions(+), 59 deletions(-) diff --git a/apps/client/src/pages/room/RoomPage.tsx b/apps/client/src/pages/room/RoomPage.tsx index 361fa4a9..5e67b56d 100644 --- a/apps/client/src/pages/room/RoomPage.tsx +++ b/apps/client/src/pages/room/RoomPage.tsx @@ -13,7 +13,6 @@ import { PrepareStage } from './PrepareStage'; import { useAwarenessSync } from '@/shared/lib/hooks/useAwarenessSync'; import { useInitialFileSelection } from '@/shared/lib/hooks/useInitialFileSelection'; import { useFileRename } from '@/shared/lib/hooks/useFileRename'; -import { DuplicateDialog } from '@/widgets/dialog/DuplicateDialog'; import { ConsolePanel as Output } from '@/widgets/console'; import { useDarkMode } from '@/shared/lib/hooks/useDarkMode'; import { Chat } from '@/widgets/chat'; @@ -37,9 +36,7 @@ function RoomPage() { handlePasswordConfirm, } = useRoomJoin(); - const { setIsDuplicated, isDuplicated, handleFileChange } = useFileRename( - paramCode!, - ); + const { handleFileChange } = useFileRename(paramCode!); useAwarenessSync(); useInitialFileSelection(); @@ -141,10 +138,6 @@ function RoomPage() { /> )} -
diff --git a/apps/client/src/widgets/files/components/File.tsx b/apps/client/src/widgets/files/components/File.tsx index 79ef7332..9d0d6492 100644 --- a/apps/client/src/widgets/files/components/File.tsx +++ b/apps/client/src/widgets/files/components/File.tsx @@ -1,7 +1,8 @@ -import { cn, toast } from '@codejam/ui'; +import { cn } from '@codejam/ui'; import { useFileStore } from '@/stores/file'; import { memo, useState, type DragEvent, type MouseEvent } from 'react'; import { DeleteDialog } from '@/widgets/dialog/DeleteDialog'; +import { DuplicateDialog } from '@/widgets/dialog/DuplicateDialog'; import { FileMoreMenu } from './FileMoreMenu'; import { InlineFileInput } from './InlineFileInput'; @@ -20,6 +21,11 @@ export const File = memo(({ fileId, fileName, hasPermission }: FileProps) => { const setActiveFile = useFileStore((state) => state.setActiveFile); const activeFileId = useFileStore((state) => state.activeFileId); const renameFile = useFileStore((state) => state.renameFile); + const getFileId = useFileStore((state) => state.getFileId); + const overwriteFile = useFileStore((state) => state.overwriteFile); + const deleteFile = useFileStore((state) => state.deleteFile); + const getFileContent = useFileStore((state) => state.getFileContent); + const getFileType = useFileStore((state) => state.getFileType); const [x, setX] = useState(0); const [y, setY] = useState(0); @@ -27,6 +33,7 @@ export const File = memo(({ fileId, fileName, hasPermission }: FileProps) => { const [open, setOpen] = useState(false); const [dialogType, setDialogType] = useState(undefined); const [isRenaming, setIsRenaming] = useState(false); + const [duplicateTarget, setDuplicateTarget] = useState(null); const isActive = activeFileId === fileId; @@ -43,18 +50,38 @@ export const File = memo(({ fileId, fileName, hasPermission }: FileProps) => { setOpen(true); }; - const handleRenameSubmit = async (newFileName: string) => { + const handleRenameSubmit = (newFileName: string) => { if (fileName === newFileName) { setIsRenaming(false); return; } - try { - await renameFile(fileId, newFileName); + if (getFileId(newFileName)) { + setDuplicateTarget(newFileName); setIsRenaming(false); - } catch { - toast.error('파일 이름 변경에 실패했습니다.'); + return; } + + renameFile(fileId, newFileName); + }; + + // 중복 모달에서 "덮어쓰기" 선택: 충돌 파일의 내용을 현재 파일 내용으로 덮어쓰고 현재 파일 삭제 + const handleOverwrite = () => { + if (!duplicateTarget) return; + const targetId = getFileId(duplicateTarget); + if (!targetId) return; + + const content = getFileContent(fileId) ?? ''; + const type = getFileType(fileId); + overwriteFile(targetId, content, type ?? undefined); + deleteFile(fileId); + setDuplicateTarget(null); + }; + + // 중복 모달에서 "사본으로 저장" 선택: 자동 생성된 이름으로 현재 파일 rename + const handleAutoRename = (newName: string) => { + renameFile(fileId, newName); + setDuplicateTarget(null); }; const handleDragStart = (ev: DragEvent) => { @@ -123,6 +150,17 @@ export const File = memo(({ fileId, fileName, hasPermission }: FileProps) => { open={open && dialogType === 'DELETE'} onOpenChange={setOpen} /> + + {duplicateTarget && ( + !isOpen && setDuplicateTarget(null)} + filename={duplicateTarget} + onClick={() => {}} + onOverwrite={handleOverwrite} + onAutoRename={handleAutoRename} + /> + )} ); }); diff --git a/apps/client/src/widgets/files/components/FileHeaderActions.tsx b/apps/client/src/widgets/files/components/FileHeaderActions.tsx index 53f22cd1..64b3745b 100644 --- a/apps/client/src/widgets/files/components/FileHeaderActions.tsx +++ b/apps/client/src/widgets/files/components/FileHeaderActions.tsx @@ -1,11 +1,9 @@ import { useCallback, useRef, useState } from 'react'; import { Plus, Upload, Image } from 'lucide-react'; import { Button } from '@codejam/ui'; -import { NewFileDialog } from '@/widgets/dialog/NewFileDialog'; -import { DuplicateDialog } from '@/widgets/dialog/DuplicateDialog_new'; +import { DuplicateDialog } from '@/widgets/dialog/DuplicateDialog'; import { ImageUploadDialog } from '@/widgets/dialog/ImageUploadDialog'; import { useFileStore } from '@/stores/file'; -import { useFileRename } from '@/shared/lib/hooks/useFileRename'; import { uploadFile } from '@/shared/lib/file'; import { EXT_TYPES } from '@codejam/common'; @@ -15,15 +13,12 @@ const getAcceptedExtensions = () => { }; export function FileHeaderActions({ - roomCode, onCreateClick, }: { - roomCode: string; onCreateClick?: () => void; }) { const uploadRef = useRef(null); - const { getFileId, createFile, setActiveFile } = useFileStore(); - const { handleCheckRename } = useFileRename(roomCode); + const { getFileId, createFile } = useFileStore(); const [isUrlDialogOpen, setIsUrlDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -65,21 +60,6 @@ export function FileHeaderActions({ [createFile, getFileId], ); - // 새 파일 생성 - const handleNewFile = async (name: string) => { - const fullNames = name; - if (getFileId(fullNames)) { - setCurrentDuplicate({ name: fullNames }); - setIsDialogOpen(true); - } else { - const result = await handleCheckRename(fullNames); - if (result) { - const fileId = createFile(fullNames, ''); - setActiveFile(fileId); - } - } - }; - // 업로드 const handleUploadFile = async (ev: React.ChangeEvent) => { const files = ev.target.files; @@ -102,22 +82,14 @@ export function FileHeaderActions({ return (
- {onCreateClick ? ( - - ) : ( - - - - )} +
@@ -128,12 +142,7 @@ function HeaderSection({ count={count} action={ roomCode && - hasPermission && ( - - ) + hasPermission && } /> ); From f44f0a68e50ceb6c7f447de964b750f10c29adf8 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 00:16:31 +0900 Subject: [PATCH 07/43] =?UTF-8?q?=F0=9F=94=A5=20remove:=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=B2=84=ED=8A=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파일 관리 기능이 파일 탭 위젯으로 통합됨에 따라, 헤더 영역의 미사용 버튼 컴포넌트들을 정리함 - FileUploadButton.tsx 삭제 - NewFileButton.tsx 삭제 --- .../header/ui/components/FileUploadButton.tsx | 57 ------------------- .../header/ui/components/NewFileButton.tsx | 46 --------------- 2 files changed, 103 deletions(-) delete mode 100644 apps/client/src/widgets/header/ui/components/FileUploadButton.tsx delete mode 100644 apps/client/src/widgets/header/ui/components/NewFileButton.tsx diff --git a/apps/client/src/widgets/header/ui/components/FileUploadButton.tsx b/apps/client/src/widgets/header/ui/components/FileUploadButton.tsx deleted file mode 100644 index 92ca8dd6..00000000 --- a/apps/client/src/widgets/header/ui/components/FileUploadButton.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/** - * [!NOTE] - * 현재 사용하지 않음 (이동됨) - * files의 FileHeaderActions를 이용할 것 - */ -import { useRef, type ChangeEvent } from 'react'; -import { Upload } from 'lucide-react'; -import { toast } from '@codejam/ui'; -import { DuplicateDialog } from '@/widgets/dialog/DuplicateDialog'; -import { useFileRename } from '@/shared/lib/hooks/useFileRename'; -import { HeaderActionButton } from './HeaderActionButton'; - -interface FileUploadButtonProps { - roomCode: string; -} - -export function FileUploadButton({ roomCode }: FileUploadButtonProps) { - const uploadRef = useRef(null); - - const { setIsDuplicated, isDuplicated, handleFileChange } = - useFileRename(roomCode); - - const handleUploadButton = () => { - if (!uploadRef.current) { - toast.error('오류가 발생했습니다. 새로고침을 해주세요.'); - return; - } - - uploadRef.current.click(); - }; - - const handleUploadFile = (ev: ChangeEvent) => { - const files = ev.target.files; - handleFileChange(files); - if (uploadRef.current) { - uploadRef.current.value = ''; - } - }; - - return ( - <> - - - - Upload - - - - ); -} diff --git a/apps/client/src/widgets/header/ui/components/NewFileButton.tsx b/apps/client/src/widgets/header/ui/components/NewFileButton.tsx deleted file mode 100644 index fa346ae2..00000000 --- a/apps/client/src/widgets/header/ui/components/NewFileButton.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * [!NOTE] - * 현재 사용하지 않음 (이동됨) - * files의 FileHeaderActions를 이용할 것 - */ -import { Plus } from 'lucide-react'; -import { NewFileDialog } from '@/widgets/dialog/NewFileDialog'; -import { DuplicateDialog } from '@/widgets/dialog/DuplicateDialog'; -import { useFileStore } from '@/stores/file'; -import { useFileRename } from '@/shared/lib/hooks/useFileRename'; -import { HeaderActionButton } from './HeaderActionButton'; - -interface NewFileButtonProps { - roomCode: string; -} - -export function NewFileButton({ roomCode }: NewFileButtonProps) { - const { getFileId, createFile, setActiveFile } = useFileStore(); - const { setIsDuplicated, isDuplicated, handleCheckRename } = - useFileRename(roomCode); - const handleNewFile = async (name: string) => { - const newFilename = name; - if (getFileId(newFilename)) { - setIsDuplicated(true); - } else { - const result = await handleCheckRename(newFilename); - if (result) { - const fileId = createFile(newFilename, ''); - setActiveFile(fileId); - } - } - }; - - return ( - <> - - - - New File - - - - - - ); -} From 70fcf4d277f5f8847a83b1a72695c371b9890dd4 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 01:27:09 +0900 Subject: [PATCH 08/43] =?UTF-8?q?=E2=9C=A8=20feat(sidebar):=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=B0=94=20=EA=B3=A0=EC=A0=95(Pin)=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사이드바의 고정 상태를 유지하기 위해 Zustand persist 미들웨어를 적용하고 관련 로직 구현 - isPinned 상태 및 togglePin 액션 추가 - localStorage를 통한 isPinned 상태 유지 (zustand/middleware/persist) - 고정 상태일 때 탭 토글 로직 보완 (고정 시 닫히지 않도록 처리) --- apps/client/src/stores/sidebar.ts | 45 +++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/apps/client/src/stores/sidebar.ts b/apps/client/src/stores/sidebar.ts index fbb390d8..90e82019 100644 --- a/apps/client/src/stores/sidebar.ts +++ b/apps/client/src/stores/sidebar.ts @@ -1,22 +1,45 @@ import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; import { type SidebarTab } from '@/widgets/room-sidebar/lib/types'; import { useThemeStore } from '@/shared/lib/hooks/useDarkMode'; interface SidebarState { activeSidebarTab: SidebarTab | null; + isPinned: boolean; setActiveSidebarTab: (tab: SidebarTab | null) => void; toggleSidebarTab: (tab: SidebarTab) => void; + togglePin: () => void; } -export const useSidebarStore = create((set) => ({ - activeSidebarTab: null, - setActiveSidebarTab: (tab) => set({ activeSidebarTab: tab }), - toggleSidebarTab: (tab) => { - // 이스터 에그 카운터 증가 - useThemeStore.getState().incrementSidebarToggle(); +export const useSidebarStore = create()( + persist( + (set) => ({ + activeSidebarTab: null, + isPinned: false, + setActiveSidebarTab: (tab) => set({ activeSidebarTab: tab }), + toggleSidebarTab: (tab) => { + // 이스터 에그 카운터 증가 + useThemeStore.getState().incrementSidebarToggle(); - set((state) => ({ - activeSidebarTab: state.activeSidebarTab === tab ? null : tab, - })); - }, -})); + set((state) => ({ + activeSidebarTab: + state.activeSidebarTab === tab && !state.isPinned ? null : tab, + })); + }, + togglePin: () => + set((state) => ({ + isPinned: !state.isPinned, + // 고정 해제 시 현재 탭을 activeSidebarTab로 잡아두어 즉시 닫히지 않도록 + activeSidebarTab: + state.isPinned && !state.activeSidebarTab + ? 'FILES' + : state.activeSidebarTab, + })), + }), + { + name: 'sidebar-store', + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ isPinned: state.isPinned }), + }, + ), +); From 829871f7b73639fc65dd88c3cb506b21a88ee8a4 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 01:27:33 +0900 Subject: [PATCH 09/43] =?UTF-8?q?=E2=9C=A8=20feat(sidebar):=20PinButton=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B0=8F=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EC=97=90=EC=85=8B=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사이드바 고정 기능을 시각적으로 제어하기 위한 버튼 컴포넌트와 전용 SVG 아이콘 추가 - PinButton: 고정 상태(isPinned)에 따라 아이콘 및 툴팁 전환 - poly_pin.svg, pin-outline.svg 신규 아이콘 추가 --- apps/client/src/assets/pin-outline.svg | 3 +++ apps/client/src/assets/poly_pin.svg | 5 ++++ .../room-sidebar/components/PinButton.tsx | 24 +++++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 apps/client/src/assets/pin-outline.svg create mode 100644 apps/client/src/assets/poly_pin.svg create mode 100644 apps/client/src/widgets/room-sidebar/components/PinButton.tsx diff --git a/apps/client/src/assets/pin-outline.svg b/apps/client/src/assets/pin-outline.svg new file mode 100644 index 00000000..74e72030 --- /dev/null +++ b/apps/client/src/assets/pin-outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/client/src/assets/poly_pin.svg b/apps/client/src/assets/poly_pin.svg new file mode 100644 index 00000000..a73a0d1b --- /dev/null +++ b/apps/client/src/assets/poly_pin.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/client/src/widgets/room-sidebar/components/PinButton.tsx b/apps/client/src/widgets/room-sidebar/components/PinButton.tsx new file mode 100644 index 00000000..432c98ae --- /dev/null +++ b/apps/client/src/widgets/room-sidebar/components/PinButton.tsx @@ -0,0 +1,24 @@ +import { Button } from '@codejam/ui'; +import { useSidebarStore } from '@/stores/sidebar'; +import PolyPin from '@/assets/poly_pin.svg?react'; +import PinOutline from '@/assets/pin-outline.svg?react'; + +export function PinButton() { + const isPinned = useSidebarStore((state) => state.isPinned); + const togglePin = useSidebarStore((state) => state.togglePin); + + return ( + + ); +} From bdcfee85f85802db74a82838ed0af72acbf45257 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 01:27:48 +0900 Subject: [PATCH 10/43] =?UTF-8?q?=E2=9C=A8=20feat(sidebar):=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=B0=94=20=EA=B3=A0=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20=ED=83=AD=EB=B3=84?= =?UTF-8?q?=20PinButton=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 고정 상태(isPinned)에 따라 사이드바 패널의 개폐 로직을 변경하고, 각 탭 헤더에 PinButton을 배치함 - RoomSidebar: isPinned가 true일 때 패널을 항상 열어두도록 로직 수정 - visibleTab 계산 로직 도입: 탭이 닫혀있어도 고정 상태라면 기본적으로 FILES 탭 노출 - FileList, Participants, More, Settings 모든 탭 헤더에 PinButton 통합 --- apps/client/src/widgets/files/index.tsx | 9 +++++++-- apps/client/src/widgets/participants/index.tsx | 3 ++- .../room-sidebar/components/MoreTabContent.tsx | 3 ++- .../components/SettingsTabContent.tsx | 3 ++- apps/client/src/widgets/room-sidebar/index.tsx | 17 +++++++++-------- 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/apps/client/src/widgets/files/index.tsx b/apps/client/src/widgets/files/index.tsx index 5ce19e1f..cf0af127 100644 --- a/apps/client/src/widgets/files/index.tsx +++ b/apps/client/src/widgets/files/index.tsx @@ -15,6 +15,7 @@ import type { FileMetadata } from '@/shared/lib/collaboration'; import { InlineFileInput } from './components/InlineFileInput'; import { useFileRename } from '@/shared/lib/hooks/useFileRename'; import { DuplicateDialog } from '@/widgets/dialog/DuplicateDialog'; +import { PinButton } from '@/widgets/room-sidebar/components/PinButton'; export function FileList() { const files = useFileStore((state) => state.files); @@ -141,8 +142,12 @@ function HeaderSection({ title="파일" count={count} action={ - roomCode && - hasPermission && + <> + {roomCode && hasPermission && ( + + )} + + } /> ); diff --git a/apps/client/src/widgets/participants/index.tsx b/apps/client/src/widgets/participants/index.tsx index b83ba17a..ba5c060c 100644 --- a/apps/client/src/widgets/participants/index.tsx +++ b/apps/client/src/widgets/participants/index.tsx @@ -12,6 +12,7 @@ import type { SortKey } from './lib/types'; import type { FilterOption } from './types'; import { filterParticipants, sortParticipants } from './types'; import { usePermission } from '@/shared/lib/hooks/usePermission'; +import { PinButton } from '@/widgets/room-sidebar/components/PinButton'; /** * 참가자 목록 위젯 메인 컴포넌트 @@ -90,7 +91,7 @@ export function Participants() { return (
- + } /> (null); @@ -13,7 +14,7 @@ export function MoreTabContent() { return (
- + } />
{MORE_MENU_ITEMS.map((item) => { diff --git a/apps/client/src/widgets/room-sidebar/components/SettingsTabContent.tsx b/apps/client/src/widgets/room-sidebar/components/SettingsTabContent.tsx index d4894ebc..5ef296b0 100644 --- a/apps/client/src/widgets/room-sidebar/components/SettingsTabContent.tsx +++ b/apps/client/src/widgets/room-sidebar/components/SettingsTabContent.tsx @@ -14,6 +14,7 @@ import { Activity, } from 'lucide-react'; import { useSettings } from '@/shared/lib/hooks/useSettings'; +import { PinButton } from './PinButton'; export function SettingsTabContent() { const { @@ -37,7 +38,7 @@ export function SettingsTabContent() { return (
- + } />
diff --git a/apps/client/src/widgets/room-sidebar/index.tsx b/apps/client/src/widgets/room-sidebar/index.tsx index e3ac7566..b3fe7662 100644 --- a/apps/client/src/widgets/room-sidebar/index.tsx +++ b/apps/client/src/widgets/room-sidebar/index.tsx @@ -15,10 +15,11 @@ import { Logo } from './components/Logo'; import { useSidebarStore } from '@/stores/sidebar'; export function RoomSidebar({ className }: { className?: string }) { - const { activeSidebarTab, toggleSidebarTab } = useSidebarStore(); + const { activeSidebarTab, isPinned, toggleSidebarTab } = useSidebarStore(); const [isLeaveDialogOpen, setIsLeaveDialogOpen] = useState(false); - const isOpen = activeSidebarTab !== null; + const isOpen = activeSidebarTab !== null || isPinned; + const visibleTab = activeSidebarTab ?? (isPinned ? 'FILES' : null); return (
@@ -28,7 +29,7 @@ export function RoomSidebar({ className }: { className?: string }) { {SIDEBAR_TABS.map((tab) => ( toggleSidebarTab(tab.id)} icon={tab.icon} label={tab.label} @@ -38,7 +39,7 @@ export function RoomSidebar({ className }: { className?: string }) {
toggleSidebarTab(SETTINGS_TAB.id)} icon={SETTINGS_TAB.icon} label={SETTINGS_TAB.label} @@ -55,10 +56,10 @@ export function RoomSidebar({ className }: { className?: string }) {
- {activeSidebarTab === 'PARTICIPANTS' && } - {activeSidebarTab === 'FILES' && } - {activeSidebarTab === 'MORE' && } - {activeSidebarTab === 'SETTINGS' && } + {visibleTab === 'PARTICIPANTS' && } + {visibleTab === 'FILES' && } + {visibleTab === 'MORE' && } + {visibleTab === 'SETTINGS' && } Date: Fri, 6 Feb 2026 01:37:47 +0900 Subject: [PATCH 11/43] =?UTF-8?q?=F0=9F=92=84=20style(sidebar):=20SidebarH?= =?UTF-8?q?eader=20=EB=86=92=EC=9D=B4=20=EB=B0=8F=20=EC=95=A1=EC=85=98=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=EC=A0=95=EB=A0=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사이드바 고정 버튼 등 액션 아이템들이 추가됨에 따라 레이아웃 안정성을 위해 스타일 조정 - 헤더 높이를 h-5에서 h-6으로 변경 - 액션 영역에 flex-center 정렬 적용 --- packages/ui/src/components/primitives/sidebar-header.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/primitives/sidebar-header.tsx b/packages/ui/src/components/primitives/sidebar-header.tsx index f0be7c14..ebf07691 100644 --- a/packages/ui/src/components/primitives/sidebar-header.tsx +++ b/packages/ui/src/components/primitives/sidebar-header.tsx @@ -16,7 +16,7 @@ export function SidebarHeader({ className, }: SidebarHeaderProps) { return ( -
+

{title}

{count !== undefined && ( @@ -29,7 +29,7 @@ export function SidebarHeader({ )}
- {action &&
{action}
} + {action &&
{action}
}
); } From e3888226ffd2d7a21eea84e8f48a56c72b9dada7 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 04:20:45 +0900 Subject: [PATCH 12/43] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20Command=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20Dialog=20=EA=B8=B0=EB=8A=A5=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검색 및 명령 팔레트 기능을 위한 cmdk 기반 Command 컴포넌트 도입 - Command, CommandDialog, CommandInput 등 하위 컴포넌트 구현 - DialogContent에 onInteractOutside, onEscapeKeyDown 핸들러 추가 - 스토리북 예제 추가 및 패키지 의존성(cmdk) 설치 --- .../stories/third-party/Command.stories.tsx | 152 ++++++++++++++ packages/ui/package.json | 1 + packages/ui/src/components/base/dialog.tsx | 2 + .../ui/src/components/third-party/command.tsx | 190 ++++++++++++++++++ .../ui/src/components/third-party/index.ts | 11 + pnpm-lock.yaml | 21 ++ 6 files changed, 377 insertions(+) create mode 100644 apps/storybook/src/stories/third-party/Command.stories.tsx create mode 100644 packages/ui/src/components/third-party/command.tsx diff --git a/apps/storybook/src/stories/third-party/Command.stories.tsx b/apps/storybook/src/stories/third-party/Command.stories.tsx new file mode 100644 index 00000000..a0ee3426 --- /dev/null +++ b/apps/storybook/src/stories/third-party/Command.stories.tsx @@ -0,0 +1,152 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from '@codejam/ui'; +import { + Calendar, + Smile, + Calculator, + User, + CreditCard, + Settings, +} from 'lucide-react'; +import { useState, useEffect } from 'react'; + +const meta = { + title: 'Third-Party/Command', + component: Command, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + render: () => ( + + + + No results found. + + + + Calendar + + + + Search Emoji + + + + Calculator + + + + + + + Profile + ⌘P + + + + Billing + ⌘B + + + + Settings + ⌘S + + + + + ), +}; + +export const Dialog: Story = { + render: () => { + const [open, setOpen] = useState(false); + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === 'j' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen((open) => !open); + } + }; + + document.addEventListener('keydown', down); + return () => document.removeEventListener('keydown', down); + }, []); + + return ( + <> +
+

+ Press{' '} + + J + {' '} + to open the command menu +

+ +
+ + + + No results found. + + + + Calendar + + + + Search Emoji + + + + Calculator + + + + + + + Profile + ⌘P + + + + Billing + ⌘B + + + + Settings + ⌘S + + + + + + ); + }, +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 98299416..dda9a5e5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -47,6 +47,7 @@ "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "input-otp": "^1.4.2", "lucide-react": "^0.561.0", "next-themes": "^0.4.6", diff --git a/packages/ui/src/components/base/dialog.tsx b/packages/ui/src/components/base/dialog.tsx index 736ae9b4..fdf197a9 100644 --- a/packages/ui/src/components/base/dialog.tsx +++ b/packages/ui/src/components/base/dialog.tsx @@ -43,6 +43,8 @@ function DialogContent({ ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean; + onInteractOutside?: (event: Event) => void; + onEscapeKeyDown?: (event: KeyboardEvent) => void; }) { return ( diff --git a/packages/ui/src/components/third-party/command.tsx b/packages/ui/src/components/third-party/command.tsx new file mode 100644 index 00000000..2fd7cfc2 --- /dev/null +++ b/packages/ui/src/components/third-party/command.tsx @@ -0,0 +1,190 @@ +import { Command as CommandPrimitive } from 'cmdk'; + +import { cn } from '@/lib/utils'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/base/dialog'; +import { InputGroup, InputGroupAddon } from '@/components/base/input-group'; +import { SearchIcon, CheckIcon } from 'lucide-react'; + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandDialog({ + title = 'Command Palette', + description = 'Search for a command to run...', + children, + className, + showCloseButton = false, + ...props +}: Omit, 'children'> & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; + children: React.ReactNode; +}) { + return ( + + + {title} + {description} + + + {children} + + + ); +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + + + + + +
+ ); +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + {children} + + + ); +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<'span'>) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/packages/ui/src/components/third-party/index.ts b/packages/ui/src/components/third-party/index.ts index 249a9d95..4b46ef3a 100644 --- a/packages/ui/src/components/third-party/index.ts +++ b/packages/ui/src/components/third-party/index.ts @@ -1 +1,12 @@ export * from './input-otp'; +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} from './command'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e300108..9b5a0aa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -547,6 +547,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -3622,6 +3625,12 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -10327,6 +10336,18 @@ snapshots: cluster-key-slot@1.1.2: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + co@4.6.0: {} codemirror@6.0.2: From 83618ad2b65bdd3816b19c1a48dba844115f9574 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 04:21:01 +0900 Subject: [PATCH 13/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EB=B0=A9=20=EC=9E=85=EC=9E=A5=20=EA=B0=80=EB=8A=A5=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=EC=B2=B4=ED=81=AC=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROOM_JOIN_STATUS.FULL 상태일 때 에러를 던지는 대신 상태를 반환하도록 변경하여 페이지 진입 후 처리가 가능하도록 함 --- apps/client/src/shared/api/room.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/shared/api/room.ts b/apps/client/src/shared/api/room.ts index 19d8905c..0f9c47dc 100644 --- a/apps/client/src/shared/api/room.ts +++ b/apps/client/src/shared/api/room.ts @@ -22,7 +22,7 @@ export async function checkRoomJoinable( case ROOM_JOIN_STATUS.NOT_FOUND: throw new Error(MESSAGE.ERROR.ROOM_NOT_FOUND); case ROOM_JOIN_STATUS.FULL: - throw new Error(MESSAGE.ERROR.ROOM_FULL); + return status; case ROOM_JOIN_STATUS.JOINABLE: return status; default: From ccabdac7ef88e4a1ee8b38cdd0a102a657d67ede Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 04:21:22 +0900 Subject: [PATCH 14/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20ErrorDia?= =?UTF-8?q?log=20=EC=9C=84=EC=B9=98=20=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20UX?= =?UTF-8?q?=20=EB=AC=B8=EA=B5=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ErrorDialog를 widgets/dialog로 이동하고, 방이 가득 찼을 때의 안내 문구와 버튼 레이블을 더 친절하게 수정함 --- apps/client/src/pages/room/RoomPage.tsx | 8 ++++---- .../{error-dialog => dialog}/ErrorDialog.tsx | 18 +++++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) rename apps/client/src/widgets/{error-dialog => dialog}/ErrorDialog.tsx (67%) diff --git a/apps/client/src/pages/room/RoomPage.tsx b/apps/client/src/pages/room/RoomPage.tsx index 5e67b56d..7374e1b2 100644 --- a/apps/client/src/pages/room/RoomPage.tsx +++ b/apps/client/src/pages/room/RoomPage.tsx @@ -5,7 +5,7 @@ import { useRoomJoin } from '@/shared/lib/hooks/useRoomJoin'; import { RadixToaster as Toaster } from '@codejam/ui'; import { useFileStore } from '@/stores/file'; import { useLoaderData } from 'react-router-dom'; -import { ErrorDialog } from '@/widgets/error-dialog/ErrorDialog'; +import { ErrorDialog } from '@/widgets/dialog/ErrorDialog'; import { HostClaimRequestDialog } from '@/widgets/dialog/HostClaimRequestDialog'; import { PERMISSION, type RoomJoinStatus } from '@codejam/common'; import { usePermission } from '@/shared/lib/hooks/usePermission'; @@ -119,9 +119,9 @@ function RoomPage() {
{loader === 'FULL' ? ( { window.location.href = '/'; }} diff --git a/apps/client/src/widgets/error-dialog/ErrorDialog.tsx b/apps/client/src/widgets/dialog/ErrorDialog.tsx similarity index 67% rename from apps/client/src/widgets/error-dialog/ErrorDialog.tsx rename to apps/client/src/widgets/dialog/ErrorDialog.tsx index e251c16c..24741321 100644 --- a/apps/client/src/widgets/error-dialog/ErrorDialog.tsx +++ b/apps/client/src/widgets/dialog/ErrorDialog.tsx @@ -1,12 +1,12 @@ import { - RadixDialog as Dialog, - RadixDialogContent as DialogContent, - RadixDialogDescription as DialogDescription, - RadixDialogFooter as DialogFooter, - RadixDialogHeader as DialogHeader, - RadixDialogTitle as DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Button, } from '@codejam/ui'; -import { Button } from '@codejam/ui'; import type { FormEvent } from 'react'; interface ErrorDialogProps { @@ -32,11 +32,11 @@ export function ErrorDialog({ return ( -
+ {title} - {description} + {description} From f67b29e619a2c20266ee8afd689a058d2c2468e5 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 04:21:35 +0900 Subject: [PATCH 15/43] =?UTF-8?q?=E2=9C=A8=20feat:=20CustomRoomForm=20?= =?UTF-8?q?=EC=8A=A4=ED=85=8C=ED=8D=BC=20=EB=A1=B1=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8A=A4=20=EC=97=B0=EC=86=8D=20=EC=9E=85=EB=A0=A5=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 방 생성 폼의 인원 수 조절 버튼을 길게 누르고 있으면 값이 연속적으로 변화하도록 개선하고, useCallback과 ref를 활용하여 성능 및 로직을 최적화함 --- .../pages/home/components/CustomRoomForm.tsx | 149 ++++++++++++++++-- 1 file changed, 134 insertions(+), 15 deletions(-) diff --git a/apps/client/src/pages/home/components/CustomRoomForm.tsx b/apps/client/src/pages/home/components/CustomRoomForm.tsx index b9207229..999100a6 100644 --- a/apps/client/src/pages/home/components/CustomRoomForm.tsx +++ b/apps/client/src/pages/home/components/CustomRoomForm.tsx @@ -17,7 +17,13 @@ import { InputGroupButton, } from '@codejam/ui'; import { ArrowLeft, Eye, EyeOff, Loader2, Minus, Plus } from 'lucide-react'; -import { type ReactNode, useState } from 'react'; +import { + type ReactNode, + useState, + useRef, + useCallback, + useEffect, +} from 'react'; import { ErrorMessage } from './ErrorMessage'; interface FormFieldProps { @@ -68,6 +74,103 @@ function StepperField({ isInvalid, errorMessage, }: StepperFieldProps) { + const intervalRef = useRef(null); + const timeoutRef = useRef(null); + const decreaseButtonRef = useRef(null); + const increaseButtonRef = useRef(null); + const valueRef = useRef(value); + const minRef = useRef(min); + const maxRef = useRef(max); + const onDecreaseRef = useRef(onDecrease); + const onIncreaseRef = useRef(onIncrease); + + // Keep refs in sync + useEffect(() => { + valueRef.current = value; + minRef.current = min; + maxRef.current = max; + onDecreaseRef.current = onDecrease; + onIncreaseRef.current = onIncrease; + }, [value, min, max, onDecrease, onIncrease]); + + const startRepeating = useCallback((action: () => void) => { + action(); + timeoutRef.current = window.setTimeout(() => { + intervalRef.current = window.setInterval(action, 100); + }, 500); + }, []); + + const stopRepeating = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + useEffect(() => { + const decreaseBtn = decreaseButtonRef.current; + + if (decreaseBtn) { + const handleDecreaseStart = (e: PointerEvent) => { + e.preventDefault(); + startRepeating(() => { + if (valueRef.current !== '' && valueRef.current > minRef.current) { + onDecreaseRef.current(); + } else { + stopRepeating(); + } + }); + }; + const handleDecreaseEnd = () => { + stopRepeating(); + }; + + decreaseBtn.addEventListener('pointerdown', handleDecreaseStart); + decreaseBtn.addEventListener('pointerup', handleDecreaseEnd); + decreaseBtn.addEventListener('pointercancel', handleDecreaseEnd); + + return () => { + decreaseBtn.removeEventListener('pointerdown', handleDecreaseStart); + decreaseBtn.removeEventListener('pointerup', handleDecreaseEnd); + decreaseBtn.removeEventListener('pointercancel', handleDecreaseEnd); + }; + } + }, [startRepeating, stopRepeating]); + + useEffect(() => { + const increaseBtn = increaseButtonRef.current; + + if (increaseBtn) { + const handleIncreaseStart = (e: PointerEvent) => { + e.preventDefault(); + startRepeating(() => { + if (valueRef.current === '' || valueRef.current < maxRef.current) { + onIncreaseRef.current(); + } else { + stopRepeating(); + } + }); + }; + const handleIncreaseEnd = () => { + stopRepeating(); + }; + + increaseBtn.addEventListener('pointerdown', handleIncreaseStart); + increaseBtn.addEventListener('pointerup', handleIncreaseEnd); + increaseBtn.addEventListener('pointercancel', handleIncreaseEnd); + + return () => { + increaseBtn.removeEventListener('pointerdown', handleIncreaseStart); + increaseBtn.removeEventListener('pointerup', handleIncreaseEnd); + increaseBtn.removeEventListener('pointercancel', handleIncreaseEnd); + }; + } + }, [startRepeating, stopRepeating]); + return (
@@ -81,20 +184,22 @@ function StepperField({ className="w-14 [appearance:textfield] text-center [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" /> @@ -263,19 +368,33 @@ export function CustomRoomForm({ } }; - const decreaseMaxPts = () => { - const current = maxPtsInput === '' ? LIMITS.MIN_PTS : maxPtsInput; - const newVal = Math.max(current - 1, LIMITS.MIN_PTS); - setMaxPtsInput(newVal); - handleChange('maxPts', newVal); - }; + const decreaseMaxPts = useCallback(() => { + setMaxPtsInput((prev) => { + const current = prev === '' ? LIMITS.MIN_PTS : prev; + if (current <= LIMITS.MIN_PTS) { + return current; + } + const newVal = Math.max(current - 1, LIMITS.MIN_PTS); + queueMicrotask(() => { + setCustomRoomConfig((c) => ({ ...c, maxPts: newVal })); + }); + return newVal; + }); + }, []); - const increaseMaxPts = () => { - const current = maxPtsInput === '' ? LIMITS.MIN_PTS : maxPtsInput; - const newVal = Math.min(current + 1, LIMITS.MAX_PTS); - setMaxPtsInput(newVal); - handleChange('maxPts', newVal); - }; + const increaseMaxPts = useCallback(() => { + setMaxPtsInput((prev) => { + const current = prev === '' ? LIMITS.MIN_PTS : prev; + if (current >= LIMITS.MAX_PTS) { + return current; + } + const newVal = Math.min(current + 1, LIMITS.MAX_PTS); + queueMicrotask(() => { + setCustomRoomConfig((c) => ({ ...c, maxPts: newVal })); + }); + return newVal; + }); + }, []); const handleRoomPasswordChange = (e: React.ChangeEvent) => { handleChange('roomPassword', e.target.value); From 3138e3daec5f928dabd1eae7c859c452b37fc92f Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 04:21:46 +0900 Subject: [PATCH 16/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20FileSele?= =?UTF-8?q?ctDialog=EB=A5=BC=20CommandDialog=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 다이얼로그 방식 대신 Command 컴포넌트를 사용하여 파일 검색 및 선택 UX를 개선하고 코드를 단순화함 --- .../src/widgets/dialog/FileSelectDialog.tsx | 175 +++--------------- 1 file changed, 30 insertions(+), 145 deletions(-) diff --git a/apps/client/src/widgets/dialog/FileSelectDialog.tsx b/apps/client/src/widgets/dialog/FileSelectDialog.tsx index 75a4aab2..9b781696 100644 --- a/apps/client/src/widgets/dialog/FileSelectDialog.tsx +++ b/apps/client/src/widgets/dialog/FileSelectDialog.tsx @@ -1,18 +1,15 @@ -import { Button, cn } from '@codejam/ui'; import { - RadixDialog as Dialog, - RadixDialogClose as DialogClose, - RadixDialogContent as DialogContent, - RadixDialogDescription as DialogDescription, - RadixDialogFooter as DialogFooter, - RadixDialogHeader as DialogHeader, - RadixDialogTitle as DialogTitle, + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, } from '@codejam/ui'; import { useFileStore } from '@/stores/file'; -import { useState, useMemo, useEffect, useRef } from 'react'; import type { FileMetadata } from '@/shared/lib/collaboration'; -import { RadixInput as Input } from '@codejam/ui'; -import { Search } from 'lucide-react'; +import { FileIcon } from 'lucide-react'; type FileSelectDialogProps = { open: boolean; @@ -26,144 +23,32 @@ export function FileSelectDialog({ onSelectFile, }: FileSelectDialogProps) { const files = useFileStore((state) => state.files); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedIndex, setSelectedIndex] = useState(0); - const listRef = useRef(null); - const filteredFiles = useMemo(() => { - if (!searchQuery) return files; - return files.filter((file) => - file.name.toLowerCase().includes(searchQuery.toLowerCase()), - ); - }, [files, searchQuery]); - - // 선택된 항목이 보이도록 스크롤 - useEffect(() => { - if (listRef.current && filteredFiles.length > 0) { - const selectedElement = listRef.current.children[ - selectedIndex - ] as HTMLElement; - if (selectedElement) { - selectedElement.scrollIntoView({ - block: 'nearest', - behavior: 'smooth', - }); - } - } - }, [selectedIndex, filteredFiles.length]); - - const handleConfirm = () => { - if ( - filteredFiles.length > 0 && - selectedIndex >= 0 && - selectedIndex < filteredFiles.length - ) { - const selectedFile = filteredFiles[selectedIndex]; - onSelectFile(selectedFile.id); - handleClose(); - } - }; - - const handleClose = () => { - setSearchQuery(''); - setSelectedIndex(0); + const handleSelect = (fileId: string) => { + onSelectFile(fileId); onOpenChange(false); }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedIndex((prev) => Math.min(prev + 1, filteredFiles.length - 1)); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedIndex((prev) => Math.max(prev - 1, 0)); - } else if (e.key === 'Enter') { - e.preventDefault(); - handleConfirm(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleClose(); - } - }; - - const handleSearchChange = (e: React.ChangeEvent) => { - setSearchQuery(e.target.value); - setSelectedIndex(0); - }; - return ( - - - - 파일 열기 - 열고 싶은 파일을 선택하세요 - - -
-
- - -
-
- -
- {filteredFiles.length > 0 ? ( -
- {filteredFiles.map((file: FileMetadata, index: number) => ( -
{ - setSelectedIndex(index); - handleConfirm(); - }} - onMouseEnter={() => setSelectedIndex(index)} - className={cn( - 'cursor-pointer rounded px-3 py-2 text-sm transition-colors select-none', - selectedIndex === index - ? 'bg-primary/10 text-primary font-semibold' - : 'hover:bg-muted/60 text-muted-foreground hover:text-foreground', - )} - > - {file.name} -
- ))} -
- ) : ( -
-

- {searchQuery ? '검색 결과가 없습니다.' : '파일이 없습니다.'} -

-
- )} -
- - - - - - - -
-
+ + + + + 검색 결과가 없습니다. + + {files.map((file: FileMetadata) => ( + handleSelect(file.id)} + className="hover:data-[selected=false]:bg-muted/50 data-[selected=false]:bg-transparent" + > + + {file.name} + + ))} + + + + ); } From be441060252bd8975b98d4659b31b4fb7bee0157 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 04:22:02 +0900 Subject: [PATCH 17/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20Nickname?= =?UTF-8?q?InputDialog=EB=A5=BC=20AlertDialog=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B0=8F=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 닉네임 입력 다이얼로그를 AlertDialog 시스템으로 전환하고, nicknameSchema를 활용한 실시간 유효성 검사 및 에러 메시지 피드백을 추가함 --- .../nickname-input/NicknameInputDialog.tsx | 128 ++++++++++-------- 1 file changed, 69 insertions(+), 59 deletions(-) diff --git a/apps/client/src/widgets/nickname-input/NicknameInputDialog.tsx b/apps/client/src/widgets/nickname-input/NicknameInputDialog.tsx index 23287395..f834ba38 100644 --- a/apps/client/src/widgets/nickname-input/NicknameInputDialog.tsx +++ b/apps/client/src/widgets/nickname-input/NicknameInputDialog.tsx @@ -1,16 +1,16 @@ import { useState } from 'react'; -import { nicknameSchema } from '@codejam/common'; +import { LIMITS, nicknameSchema } from '@codejam/common'; import { - RadixDialog as Dialog, - RadixDialogContent as DialogContent, - RadixDialogDescription as DialogDescription, - RadixDialogFooter as DialogFooter, - RadixDialogHeader as DialogHeader, - RadixDialogTitle as DialogTitle, + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Label, + Input, } from '@codejam/ui'; -import { Button } from '@codejam/ui'; -import { RadixInput as Input } from '@codejam/ui'; -import { RadixLabel as Label } from '@codejam/ui'; interface NicknameInputDialogProps { open: boolean; @@ -25,72 +25,82 @@ export function NicknameInputDialog({ }: NicknameInputDialogProps) { const [nickname, setNickname] = useState(''); const [error, setError] = useState(''); + const [isValid, setIsValid] = useState(false); const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); - const trimmedNickname = nickname.trim(); + if (!isValid) return; - // Zod 검증 - const result = nicknameSchema.safeParse(trimmedNickname); - - if (!result.success) { - const firstError = result.error.issues[0]; - setError(firstError?.message || '닉네임을 확인해주세요.'); - return; - } - - setError(''); - onConfirm(result.data); + onConfirm(nickname); setNickname(''); + setIsValid(false); }; const handleChange = (e: React.ChangeEvent) => { const value = e.target.value; - // 최대 6자로 제한 - if (value.length <= 6) { - setNickname(value); - setError(''); // 입력 중에는 에러 메시지 제거 + + setNickname(value); + + if (!value.trim()) { + setError(''); + setIsValid(false); + return; + } + + const result = nicknameSchema.safeParse(value); + + if (!result.success) { + const firstError = result.error.issues[0]; + setError(firstError?.message || '닉네임 형식이 올바르지 않습니다.'); + setIsValid(false); + } else { + setError(''); + setIsValid(true); } }; return ( - - e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()} - > - - - 닉네임 입력 - - 방에 입장하기 위한 닉네임을 입력해주세요. (1-6자) - - -
-
- - - {error &&

{error}

} + + + + + 닉네임 입력 + + 방에 입장하기 위한 닉네임을 입력해주세요. + + + +
+
+ +
+ +
+
+
+

{error}

- - - - - -
+ + + + ); } From 45019bd1d3c3d69ccf117a6d449e87e44ee71699 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 04:22:16 +0900 Subject: [PATCH 18/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20Password?= =?UTF-8?q?InputDialog=EB=A5=BC=20AlertDialog=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B0=8F=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비밀번호 입력 다이얼로그를 AlertDialog로 전환하고, 비밀번호 보이기/숨기기 토글 기능 및 실시간 유효성 검사 로직을 추가함 --- .../password-input/PasswordInputDialog.tsx | 157 ++++++++++-------- 1 file changed, 92 insertions(+), 65 deletions(-) diff --git a/apps/client/src/widgets/password-input/PasswordInputDialog.tsx b/apps/client/src/widgets/password-input/PasswordInputDialog.tsx index 54d59f21..cde05e5e 100644 --- a/apps/client/src/widgets/password-input/PasswordInputDialog.tsx +++ b/apps/client/src/widgets/password-input/PasswordInputDialog.tsx @@ -1,16 +1,20 @@ import { useState } from 'react'; -import { passwordSchema } from '@codejam/common'; +import { LIMITS, passwordSchema } from '@codejam/common'; import { - RadixDialog as Dialog, - RadixDialogContent as DialogContent, - RadixDialogDescription as DialogDescription, - RadixDialogFooter as DialogFooter, - RadixDialogHeader as DialogHeader, - RadixDialogTitle as DialogTitle, + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Label, + InputGroup, + InputGroupInput, + InputGroupAddon, + InputGroupButton, } from '@codejam/ui'; -import { Button } from '@codejam/ui'; -import { RadixInput as Input } from '@codejam/ui'; -import { RadixLabel as Label } from '@codejam/ui'; +import { Eye, EyeOff } from 'lucide-react'; interface PasswordDialogProps { open: boolean; @@ -27,79 +31,102 @@ export function PasswordDialogProps({ }: PasswordDialogProps) { const [password, setPassword] = useState(''); const [error, setError] = useState(''); + const [isValid, setIsValid] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + const displayError = error || passwordError; const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); - const input = password.trim(); - - // Zod 검증 - const result = passwordSchema.safeParse(input); + if (!isValid) return; - if (!result.success) { - const firstError = result.error.issues[0]; - setError(firstError?.message || '비밀번호를 확인해주세요.'); - return; - } - - setError(''); - onConfirm(result.data); + onConfirm(password); setPassword(''); + setIsValid(false); }; const handleChange = (e: React.ChangeEvent) => { const value = e.target.value; - // 최대 16자로 제한 - if (value.length <= 16) { - setPassword(value); - setError(''); // 입력 중에는 에러 메시지 제거 + + setPassword(value); + + if (!value.trim()) { + setError(''); + setIsValid(false); + return; + } + + const result = passwordSchema.safeParse(value); + + if (!result.success) { + const firstError = result.error.issues[0]; + setError(firstError?.message || '비밀번호 형식이 올바르지 않습니다.'); + setIsValid(false); + } else { + setError(''); + setIsValid(true); } }; return ( - - e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()} - > -
- - 비밀번호 입력 - + + + + + 비밀번호 입력 + 방에 입장하기 위한 비밀번호를 입력해주세요. - - -
-
- - - {error && ( -

{error}

- )} - {passwordError && ( -

- {passwordError} -

- )} + + + +
+
+ +
+ + + + setShowPassword(!showPassword)} + type="button" + > + {showPassword ? ( + + ) : ( + + )} + + + +
+
+
+

+ {displayError} +

- - - - - -
+ + + + ); } From f47c9bf59c0955f11bd23cb98e3d3fa3cdb523d5 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 04:22:29 +0900 Subject: [PATCH 19/43] =?UTF-8?q?=F0=9F=92=84=20style:=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=AA=A9=EB=A1=9D=20UI=20=EA=B0=84=EA=B2=A9=20?= =?UTF-8?q?=EB=AF=B8=EC=84=B8=20=EC=A1=B0=EC=A0=95=20=EB=B0=8F=20ShareDial?= =?UTF-8?q?og=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파일 목록의 상단 여백을 조정하여 시각적 일관성을 높이고, ShareDialogTrigger의 렌더링 방식을 수정하여 불필요한 DOM 요소를 제거함 --- apps/client/src/widgets/dialog/ShareDialog/index.tsx | 2 +- apps/client/src/widgets/files/components/File.tsx | 2 +- apps/client/src/widgets/files/components/InlineFileInput.tsx | 2 +- apps/client/src/widgets/files/index.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/dialog/ShareDialog/index.tsx b/apps/client/src/widgets/dialog/ShareDialog/index.tsx index a9d0b543..4afe0b2e 100644 --- a/apps/client/src/widgets/dialog/ShareDialog/index.tsx +++ b/apps/client/src/widgets/dialog/ShareDialog/index.tsx @@ -24,7 +24,7 @@ export function ShareDialog({ children, roomCode }: ShareDialogProps) { return ( - {children} + 참여자 초대하기 diff --git a/apps/client/src/widgets/files/components/File.tsx b/apps/client/src/widgets/files/components/File.tsx index 9d0d6492..87c0575e 100644 --- a/apps/client/src/widgets/files/components/File.tsx +++ b/apps/client/src/widgets/files/components/File.tsx @@ -120,7 +120,7 @@ export const File = memo(({ fileId, fileName, hasPermission }: FileProps) => {
+
-
+
{isCreatingNewFile && ( Date: Fri, 6 Feb 2026 04:22:56 +0900 Subject: [PATCH 20/43] =?UTF-8?q?=F0=9F=94=A5=20remove:=20=EB=AF=B8?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EB=A0=88=EA=B1=B0?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인라인 편집 및 사이드바 통합으로 더 이상 사용하지 않는 다이얼로그들을 제거하고 백업을 위해 deprecated 폴더로 이동함 --- apps/client/src/widgets/dialog/{ => deprecated}/NewFileDialog.tsx | 0 apps/client/src/widgets/dialog/{ => deprecated}/RenameDialog.tsx | 0 .../client/src/widgets/dialog/{ => deprecated}/SettingsDialog.tsx | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename apps/client/src/widgets/dialog/{ => deprecated}/NewFileDialog.tsx (100%) rename apps/client/src/widgets/dialog/{ => deprecated}/RenameDialog.tsx (100%) rename apps/client/src/widgets/dialog/{ => deprecated}/SettingsDialog.tsx (100%) diff --git a/apps/client/src/widgets/dialog/NewFileDialog.tsx b/apps/client/src/widgets/dialog/deprecated/NewFileDialog.tsx similarity index 100% rename from apps/client/src/widgets/dialog/NewFileDialog.tsx rename to apps/client/src/widgets/dialog/deprecated/NewFileDialog.tsx diff --git a/apps/client/src/widgets/dialog/RenameDialog.tsx b/apps/client/src/widgets/dialog/deprecated/RenameDialog.tsx similarity index 100% rename from apps/client/src/widgets/dialog/RenameDialog.tsx rename to apps/client/src/widgets/dialog/deprecated/RenameDialog.tsx diff --git a/apps/client/src/widgets/dialog/SettingsDialog.tsx b/apps/client/src/widgets/dialog/deprecated/SettingsDialog.tsx similarity index 100% rename from apps/client/src/widgets/dialog/SettingsDialog.tsx rename to apps/client/src/widgets/dialog/deprecated/SettingsDialog.tsx From eec0c8b4181f04c49a467b45ce4087f2e07b7124 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 04:39:38 +0900 Subject: [PATCH 21/43] =?UTF-8?q?=E2=9C=A8=20feat(fe):=20=ED=83=AD=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EC=95=A1?= =?UTF-8?q?=EC=85=98=20=EB=B2=84=ED=8A=BC(=EC=8B=A4=ED=96=89,=20=EB=8B=AB?= =?UTF-8?q?=EA=B8=B0)=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20UX=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자가 탭에서 직접 코드를 실행하거나 탭을 닫을 수 있도록 직관적인 UI 제공 - 컨텍스트 메뉴(ContextMenu)를 제거하고 인라인 버튼(Play, X)으로 교체 - 실행 가능 파일(isFileExecutable) 여부에 따른 플레이 버튼 노출 제어 - handleExecuteCode: 개별 파일 탭 단위의 코드 실행 로직 구현 - 탭 트리거 내 레이아웃 조정 및 호버/활성 스타일 적용 --- apps/client/src/pages/room/TabViewer.tsx | 108 ++++++++++++++++++----- 1 file changed, 84 insertions(+), 24 deletions(-) diff --git a/apps/client/src/pages/room/TabViewer.tsx b/apps/client/src/pages/room/TabViewer.tsx index ddba263f..2caa9ceb 100644 --- a/apps/client/src/pages/room/TabViewer.tsx +++ b/apps/client/src/pages/room/TabViewer.tsx @@ -3,19 +3,21 @@ import { useFileStore } from '@/stores/file'; import { lazy, Suspense, useContext, useEffect, type MouseEvent } from 'react'; import { EmptyView } from './EmptyView'; import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, + Button, ScrollArea, ScrollBar, Tabs, TabsContent, TabsList, TabsTrigger, + toast, } from '@codejam/ui'; -import { Trash2 } from 'lucide-react'; +import { X, Play } from 'lucide-react'; import { ActiveTabContext } from '@/contexts/TabProvider'; +import { useCodeExecutionStore } from '@/stores/code-execution'; +import { emitExecuteCode } from '@/stores/socket-events'; +import { extname, getPistonLanguage } from '@/shared/lib/file'; +import { useSettings } from '@/shared/lib/hooks/useSettings'; type TabViewerProps = { tabKey: number; @@ -36,6 +38,8 @@ export default function TabViewer({ tabKey, readOnly }: TabViewerProps) { const { activeTab, setActiveTab } = useContext(ActiveTabContext); const setActiveFileId = useFileStore((state) => state.setActiveFile); const getFileName = useFileStore((state) => state.getFileName); + const isExecuting = useCodeExecutionStore((state) => state.isExecuting); + const { streamCodeExecutionOutput } = useSettings(); useFileStore((state) => state.files); @@ -65,10 +69,49 @@ export default function TabViewer({ tabKey, readOnly }: TabViewerProps) { } }; + const handleExecuteCode = (fileId: string) => { + const fileName = getFileName(fileId); + + if (!fileName) { + toast.error('파일 이름을 가져올 수 없습니다.'); + return; + } + + const extension = extname(fileName); + if (!extension) { + toast.error('파일 확장자를 찾을 수 없습니다.'); + return; + } + + const language = getPistonLanguage(extension); + if (!language) { + toast.error('코드 실행을 지원하지 않는 파일입니다.'); + return; + } + + if (isExecuting) { + toast.warning('코드가 이미 실행 중입니다.'); + return; + } + + emitExecuteCode(fileId, language, streamCodeExecutionOutput); + }; + const handleValueChange = (fileId: string) => { setActiveTab(tabKey, fileId); }; + const isFileExecutable = (fileId: string): boolean => { + const fileName = getFileName(fileId); + if (!fileName) return false; + + const extension = extname(fileName); + if (!extension) return false; + + const language = getPistonLanguage(extension); + return !!language; + }; + const myTabs = Object.keys(fileTab); const isSplitActive = activeTab.active === tabKey; @@ -83,30 +126,47 @@ export default function TabViewer({ tabKey, readOnly }: TabViewerProps) { {myTabs.map((fileId) => ( - - - - {getFileName(fileId) ? ( - getFileName(fileId) - ) : ( - - {fileTab[fileId].fileName} - - )} - - - - + + {getFileName(fileId) ? ( + getFileName(fileId) + ) : ( + + {fileTab[fileId].fileName} + + )} + +
+ {isFileExecutable(fileId) && ( + + )} + +
+ ))}
From e495626c7c19191aca1d9c48fb0fa7ece00432f2 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 04:39:52 +0900 Subject: [PATCH 22/43] =?UTF-8?q?=F0=9F=94=A7=20chore(fe):=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=EC=97=90=EC=84=9C=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=8B=A4=ED=96=89=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=A3=BC=EC=84=9D=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탭 내부로 코드 실행 기능이 이동함에 따라 헤더의 코드 실행 버튼 임포트 구문을 주석 처리함 --- apps/client/src/widgets/header/ui/Header.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/client/src/widgets/header/ui/Header.tsx b/apps/client/src/widgets/header/ui/Header.tsx index 6930a04c..95c2d6f4 100644 --- a/apps/client/src/widgets/header/ui/Header.tsx +++ b/apps/client/src/widgets/header/ui/Header.tsx @@ -5,6 +5,7 @@ import { DestroyRoomButton } from './components/DestroyRoomButton'; import { ThemeToggleButton } from './components/ThemeToggleButton'; import { RoleBadge } from './components/RoleBadge'; import { usePermission } from '@/shared/lib/hooks/usePermission'; +// import { CodeExecutionButton } from './components/CodeExecutionButton'; type HeaderProps = { roomCode: string; From 028fc5ccf3c32b9188e13bc2f6c0c9a980b4c5f6 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 04:44:44 +0900 Subject: [PATCH 23/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(fe):=20?= =?UTF-8?q?=ED=83=AD=20=EB=82=B4=EB=B6=80=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EA=B5=AC=ED=98=84=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(render=20prop=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EB=8C=80=EC=9D=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TabsTrigger에서 지원하지 않는 render prop 관련 제약 및 버튼 중첩 문제를 해결하기 위해 span(role=button)으로 교체 - TabsTrigger 내부에서 Button 컴포넌트 사용 시 발생하는 스타일 및 이벤트 간섭 이슈 해결 - span에 role="button" 및 tabIndex를 부여하여 기능적 버튼 역할 수행 - 키보드 접근성을 위한 onKeyDown 핸들러 추가 --- apps/client/src/pages/room/TabViewer.tsx | 40 +++++++++++++++++------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/apps/client/src/pages/room/TabViewer.tsx b/apps/client/src/pages/room/TabViewer.tsx index 2caa9ceb..210cf9b4 100644 --- a/apps/client/src/pages/room/TabViewer.tsx +++ b/apps/client/src/pages/room/TabViewer.tsx @@ -3,7 +3,6 @@ import { useFileStore } from '@/stores/file'; import { lazy, Suspense, useContext, useEffect, type MouseEvent } from 'react'; import { EmptyView } from './EmptyView'; import { - Button, ScrollArea, ScrollBar, Tabs, @@ -11,6 +10,7 @@ import { TabsList, TabsTrigger, toast, + cn, } from '@codejam/ui'; import { X, Play } from 'lucide-react'; import { ActiveTabContext } from '@/contexts/TabProvider'; @@ -142,29 +142,47 @@ export default function TabViewer({ tabKey, readOnly }: TabViewerProps) {
{isFileExecutable(fileId) && ( - + )} - +
))} From 4150c7d3f3fbbda8fdea587c9caaacf08ca3367b Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 08:23:27 +0900 Subject: [PATCH 24/43] =?UTF-8?q?=E2=9C=A8=20feat:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=EC=9E=90=20=EC=95=84=EC=9D=B4=EC=BD=98=20SVG?= =?UTF-8?q?=20=EC=97=90=EC=85=8B=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로그래밍 언어별 시각적 구분을 위해 주요 확장자(C, C++, Java, JS, TS, Python) 아이콘 추가 --- apps/client/src/assets/exts/c.svg | 1 + apps/client/src/assets/exts/cpp.svg | 1 + apps/client/src/assets/exts/java.svg | 1 + apps/client/src/assets/exts/javascript.svg | 1 + apps/client/src/assets/exts/python.svg | 1 + apps/client/src/assets/exts/typescript.svg | 1 + 6 files changed, 6 insertions(+) create mode 100644 apps/client/src/assets/exts/c.svg create mode 100644 apps/client/src/assets/exts/cpp.svg create mode 100644 apps/client/src/assets/exts/java.svg create mode 100644 apps/client/src/assets/exts/javascript.svg create mode 100644 apps/client/src/assets/exts/python.svg create mode 100644 apps/client/src/assets/exts/typescript.svg diff --git a/apps/client/src/assets/exts/c.svg b/apps/client/src/assets/exts/c.svg new file mode 100644 index 00000000..5bb84b6a --- /dev/null +++ b/apps/client/src/assets/exts/c.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/client/src/assets/exts/cpp.svg b/apps/client/src/assets/exts/cpp.svg new file mode 100644 index 00000000..16534aca --- /dev/null +++ b/apps/client/src/assets/exts/cpp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/client/src/assets/exts/java.svg b/apps/client/src/assets/exts/java.svg new file mode 100644 index 00000000..0950bc40 --- /dev/null +++ b/apps/client/src/assets/exts/java.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/client/src/assets/exts/javascript.svg b/apps/client/src/assets/exts/javascript.svg new file mode 100644 index 00000000..254704ab --- /dev/null +++ b/apps/client/src/assets/exts/javascript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/client/src/assets/exts/python.svg b/apps/client/src/assets/exts/python.svg new file mode 100644 index 00000000..20c2508a --- /dev/null +++ b/apps/client/src/assets/exts/python.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/client/src/assets/exts/typescript.svg b/apps/client/src/assets/exts/typescript.svg new file mode 100644 index 00000000..acaf0ddb --- /dev/null +++ b/apps/client/src/assets/exts/typescript.svg @@ -0,0 +1 @@ + \ No newline at end of file From 16d13cb04f990506bd806878ffd999a0d0e9fe11 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 08:23:32 +0900 Subject: [PATCH 25/43] =?UTF-8?q?=E2=9C=A8=20feat:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=B0=8F=20=ED=83=AD=EC=97=90=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=EC=9E=90=EB=B3=84=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파일 이름에 따라 해당하는 언어 아이콘을 표시하여 가독성 향상 - TabViewer: 탭 제목 옆에 아이콘 추가 및 간격 조정 - File (목록 아이템): 파일명 옆에 아이콘 추가 및 레이아웃 수정 - FileSelectDialog: 검색 결과 목록에 아이콘 추가 - C, CPP, Java, JS, TS, Python 확장자 지원 --- apps/client/src/pages/room/TabViewer.tsx | 38 +++++++++++++++++- .../src/widgets/dialog/FileSelectDialog.tsx | 30 +++++++++++++- .../src/widgets/files/components/File.tsx | 40 +++++++++++++++++-- 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/apps/client/src/pages/room/TabViewer.tsx b/apps/client/src/pages/room/TabViewer.tsx index 210cf9b4..02f751e3 100644 --- a/apps/client/src/pages/room/TabViewer.tsx +++ b/apps/client/src/pages/room/TabViewer.tsx @@ -12,13 +12,20 @@ import { toast, cn, } from '@codejam/ui'; -import { X, Play } from 'lucide-react'; +import { X, Play, FileIcon } from 'lucide-react'; import { ActiveTabContext } from '@/contexts/TabProvider'; import { useCodeExecutionStore } from '@/stores/code-execution'; import { emitExecuteCode } from '@/stores/socket-events'; import { extname, getPistonLanguage } from '@/shared/lib/file'; import { useSettings } from '@/shared/lib/hooks/useSettings'; +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'; + type TabViewerProps = { tabKey: number; readOnly: boolean; @@ -32,6 +39,32 @@ type FileViewerTab = { const FileContentViewer = lazy(() => import('./FileContentViewer')); +const iconMap: Record< + string, + React.ComponentType> +> = { + c: CIcon, + cpp: CppIcon, + java: JavaIcon, + js: JavaScriptIcon, + ts: TypeScriptIcon, + py: PythonIcon, +}; + +const getFileIcon = (fileName: string | null) => { + if (!fileName) return ; + + const extension = extname(fileName); + if (!extension) return ; + + const Icon = iconMap[extension.toLowerCase()]; + return Icon ? ( + + ) : ( + + ); +}; + export default function TabViewer({ tabKey, readOnly }: TabViewerProps) { const { takeTab, removeLinear, deleteLinearTab, tabKeys } = useContext(LinearTabApiContext); @@ -129,8 +162,9 @@ export default function TabViewer({ tabKey, readOnly }: TabViewerProps) { + {getFileIcon(getFileName(fileId) || fileTab[fileId].fileName)} {getFileName(fileId) ? ( getFileName(fileId) diff --git a/apps/client/src/widgets/dialog/FileSelectDialog.tsx b/apps/client/src/widgets/dialog/FileSelectDialog.tsx index 9b781696..67eb88e8 100644 --- a/apps/client/src/widgets/dialog/FileSelectDialog.tsx +++ b/apps/client/src/widgets/dialog/FileSelectDialog.tsx @@ -10,6 +10,14 @@ import { import { useFileStore } from '@/stores/file'; import type { FileMetadata } from '@/shared/lib/collaboration'; 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'; type FileSelectDialogProps = { open: boolean; @@ -17,6 +25,18 @@ type FileSelectDialogProps = { onSelectFile: (fileId: string) => void; }; +const iconMap: Record< + string, + React.ComponentType> +> = { + c: CIcon, + cpp: CppIcon, + java: JavaIcon, + js: JavaScriptIcon, + ts: TypeScriptIcon, + py: PythonIcon, +}; + export function FileSelectDialog({ open, onOpenChange, @@ -29,6 +49,14 @@ export function FileSelectDialog({ onOpenChange(false); }; + const getFileIcon = (fileName: string) => { + const extension = extname(fileName); + if (!extension) return ; + + const Icon = iconMap[extension.toLowerCase()]; + return Icon ? : ; + }; + return ( @@ -42,7 +70,7 @@ export function FileSelectDialog({ onSelect={() => handleSelect(file.id)} className="hover:data-[selected=false]:bg-muted/50 data-[selected=false]:bg-transparent" > - + {getFileIcon(file.name)} {file.name} ))} diff --git a/apps/client/src/widgets/files/components/File.tsx b/apps/client/src/widgets/files/components/File.tsx index 87c0575e..94bdc0d0 100644 --- a/apps/client/src/widgets/files/components/File.tsx +++ b/apps/client/src/widgets/files/components/File.tsx @@ -5,6 +5,15 @@ import { DeleteDialog } from '@/widgets/dialog/DeleteDialog'; import { DuplicateDialog } from '@/widgets/dialog/DuplicateDialog'; import { FileMoreMenu } from './FileMoreMenu'; import { InlineFileInput } from './InlineFileInput'; +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'; type DialogType = 'RENAME' | 'DELETE' | undefined; type FileProps = { @@ -17,6 +26,30 @@ const ACTIVE_FILE_BG = 'bg-accent/80 text-primary rounded-sm'; const INACTIVE_FILE_HOVER = 'hover:bg-muted/60 text-muted-foreground hover:text-foreground rounded-lg'; +const iconMap: Record< + string, + React.ComponentType> +> = { + c: CIcon, + cpp: CppIcon, + java: JavaIcon, + js: JavaScriptIcon, + ts: TypeScriptIcon, + py: PythonIcon, +}; + +const getFileIcon = (fileName: string) => { + const extension = extname(fileName); + if (!extension) return ; + + const Icon = iconMap[extension.toLowerCase()]; + return Icon ? ( + + ) : ( + + ); +}; + export const File = memo(({ fileId, fileName, hasPermission }: FileProps) => { const setActiveFile = useFileStore((state) => state.setActiveFile); const activeFileId = useFileStore((state) => state.activeFileId); @@ -127,10 +160,11 @@ export const File = memo(({ fileId, fileName, hasPermission }: FileProps) => { onMouseUp={onMouseUp} onDragStart={handleDragStart} > -
-

+

+ {getFileIcon(fileName)} + {fileName} -

+
{/* 더보기 액션 버튼 */} From 863d6935fcc8877aac710c9cab3bd8055473675b Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 09:15:16 +0900 Subject: [PATCH 26/43] =?UTF-8?q?=E2=9C=A8=20feat(sidebar):=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EC=9C=84=ED=95=9C=20useNicknameEdit=20?= =?UTF-8?q?=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사이드바 프로필 카드에서 닉네임을 직접 수정할 수 있도록 유효성 검사 및 소켓 통신 로직을 포함한 커스텀 훅 구현 --- .../room-sidebar/lib/hooks/useNicknameEdit.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 apps/client/src/widgets/room-sidebar/lib/hooks/useNicknameEdit.ts diff --git a/apps/client/src/widgets/room-sidebar/lib/hooks/useNicknameEdit.ts b/apps/client/src/widgets/room-sidebar/lib/hooks/useNicknameEdit.ts new file mode 100644 index 00000000..242755d0 --- /dev/null +++ b/apps/client/src/widgets/room-sidebar/lib/hooks/useNicknameEdit.ts @@ -0,0 +1,90 @@ +import { useState, type ChangeEvent, type KeyboardEvent } from 'react'; +import { LIMITS } from '@codejam/common'; +import { usePtsStore } from '@/stores/pts'; +import { socket } from '@/shared/api/socket'; +import { SOCKET_EVENTS } from '@codejam/common'; +import type { Pt } from '@codejam/common'; + +export function useNicknameEdit(me: Pt) { + const [isEditing, setIsEditing] = useState(false); + const [value, setValue] = useState(me.nickname || ''); + const [error, setError] = useState(null); + + const setPt = usePtsStore((state) => state.setPt); + + const handleClick = () => { + setIsEditing(true); + setValue(me.nickname || ''); + setError(null); + }; + + const handleChange = (e: ChangeEvent) => { + const newValue = e.target.value; + setValue(newValue); + + // 실시간 유효성 검사 + if (newValue.trim().length < LIMITS.NICKNAME_MIN) { + setError(`최소 ${LIMITS.NICKNAME_MIN}글자 이상 입력하세요`); + } else if (newValue.length > LIMITS.NICKNAME_MAX) { + setError(`최대 ${LIMITS.NICKNAME_MAX}글자까지 입력 가능합니다`); + } else { + setError(null); + } + }; + + const handleSubmit = () => { + const currentNickname = me.nickname || ''; + const trimmedValue = value.trim(); + + // 유효성 검사 + if (trimmedValue.length < LIMITS.NICKNAME_MIN) { + setValue(currentNickname); + setError(null); + setIsEditing(false); + return; + } + + if (trimmedValue.length > LIMITS.NICKNAME_MAX) { + setValue(currentNickname); + setError(null); + setIsEditing(false); + return; + } + + // 변경사항이 있을 때만 업데이트 + if (trimmedValue !== currentNickname) { + setPt(me.ptId, { ...me, nickname: trimmedValue }); + + socket.emit(SOCKET_EVENTS.UPDATE_NICKNAME_PT, { + ptId: me.ptId, + nickname: trimmedValue, + }); + + setValue(trimmedValue); + } + + setError(null); + setIsEditing(false); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + handleSubmit(); + } + if (e.key === 'Escape') { + setValue(me.nickname || ''); + setError(null); + setIsEditing(false); + } + }; + + return { + isEditing, + value, + error, + handleClick, + handleChange, + handleSubmit, + handleKeyDown, + }; +} From 9a5310154b45f544df0edb1686858d3d3332a3c3 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 09:15:24 +0900 Subject: [PATCH 27/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=B9=B4=EB=93=9C=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사이드바 하단 프로필 카드의 레이아웃을 다듬고 닉네임 인라인 수정 기능을 통합함 - ProfileCardContent: useNicknameEdit 적용, 아바타 및 배너 레이아웃 최적화 - SidebarProfile: PopoverTrigger 렌더링 방식 변경 및 호버 애니메이션 추가 - 닉네임 수정 시 실시간 에러 메시지 노출 처리 --- .../components/ProfileCardContent.tsx | 159 +++++++----------- .../components/SidebarProfile.tsx | 36 ++-- 2 files changed, 77 insertions(+), 118 deletions(-) diff --git a/apps/client/src/widgets/room-sidebar/components/ProfileCardContent.tsx b/apps/client/src/widgets/room-sidebar/components/ProfileCardContent.tsx index 9c561814..459abe67 100644 --- a/apps/client/src/widgets/room-sidebar/components/ProfileCardContent.tsx +++ b/apps/client/src/widgets/room-sidebar/components/ProfileCardContent.tsx @@ -1,17 +1,12 @@ -import { useState, type ChangeEvent, type KeyboardEvent } from 'react'; -import { - createAvatarGenerator, - AvvvatarsProvider, - RadixInput as Input, -} from '@codejam/ui'; +import { useRef } from 'react'; +import { createAvatarGenerator, AvvvatarsProvider, cn } from '@codejam/ui'; import { Pencil } from 'lucide-react'; import { adjustColor } from '@/shared/lib/utils/color'; -import { usePtsStore } from '@/stores/pts'; -import { socket } from '@/shared/api/socket'; -import { SOCKET_EVENTS, PRESENCE } from '@codejam/common'; +import { PRESENCE } from '@codejam/common'; import type { Pt } from '@codejam/common'; import { ProfileBannerAnimation } from './ProfileBannerAnimation'; +import { useNicknameEdit } from '../lib/hooks/useNicknameEdit'; const provider = new AvvvatarsProvider({ variant: 'shape' }); const { Avatar } = createAvatarGenerator(provider); @@ -21,134 +16,100 @@ interface ProfileCardContentProps { } export function ProfileCardContent({ me }: ProfileCardContentProps) { - const [isEditing, setIsEditing] = useState(false); - const [rename, setRename] = useState(me.nickname || ''); - - const setPt = usePtsStore((state) => state.setPt); + const nickname = useNicknameEdit(me); + const inputRef = useRef(null); const bannerBaseColor = adjustColor(me.color, -30); const bannerEndColor = adjustColor(me.color, -50); const bannerStyle = `linear-gradient(135deg, ${bannerBaseColor}, ${bannerEndColor})`; - const handleChange = (e: ChangeEvent) => { - setRename(e.target.value); - }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - handleSubmit(); - } - if (e.key === 'Escape') { - setRename(me.nickname || ''); - setIsEditing(false); - } - }; - - const handleSubmit = () => { - const currentNickname = me.nickname || ''; - const trimmedRename = rename.trim(); - - const naming = - trimmedRename.length < 1 || trimmedRename.length > 6 - ? currentNickname - : trimmedRename; - - if (naming !== currentNickname) { - setPt(me.ptId, { ...me, nickname: naming }); - - socket.emit(SOCKET_EVENTS.UPDATE_NICKNAME_PT, { - ptId: me.ptId, - nickname: naming, - }); - - setRename(naming); - } else { - setRename(currentNickname); - } - - setIsEditing(false); + const handlePencilClick = (e: React.MouseEvent) => { + e.stopPropagation(); + nickname.handleClick(); + setTimeout(() => inputRef.current?.focus(), 0); }; return ( -
+
-
-
-
+
+
+
-
+
{me.ptHash && ( - + #{me.ptHash} )}
-
-

- 사용자 이름 -

-
- {isEditing ? ( - +

사용자 이름

+
+
+ - ) : ( -
- setIsEditing(true)} - title="클릭하여 이름 변경" - > - {me.nickname} + + {nickname.error && ( + + {nickname.error} - -
- )} + )} +
- {isEditing && ( -

- Enter를 눌러 저장 (최대 6글자) -

- )} - -
- THEME COLOR +
+ THEME COLOR
- {me.color} + {me.color}
diff --git a/apps/client/src/widgets/room-sidebar/components/SidebarProfile.tsx b/apps/client/src/widgets/room-sidebar/components/SidebarProfile.tsx index 08742855..754bd65b 100644 --- a/apps/client/src/widgets/room-sidebar/components/SidebarProfile.tsx +++ b/apps/client/src/widgets/room-sidebar/components/SidebarProfile.tsx @@ -1,8 +1,4 @@ -import { - RadixPopover as Popover, - RadixPopoverTrigger as PopoverTrigger, - RadixPopoverContent as PopoverContent, -} from '@codejam/ui'; +import { Popover, PopoverTrigger, PopoverContent } from '@codejam/ui'; import { usePt } from '@/stores/pts'; import { useRoomStore } from '@/stores/room'; import { createAvatarGenerator, AvvvatarsProvider } from '@codejam/ui'; @@ -22,20 +18,22 @@ export function SidebarProfile() { return ( - - - + +
+ +
+ + } + /> Date: Fri, 6 Feb 2026 09:15:34 +0900 Subject: [PATCH 28/43] =?UTF-8?q?=F0=9F=92=84=20style(sidebar):=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EB=B0=B0=EB=84=88=20=EC=95=A0?= =?UTF-8?q?=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EB=94=94=ED=85=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배너 위를 지나가는 아이콘들의 위치와 가독성을 개선함 - 아이콘 크기 확대(24->28) 및 선 두께(1.5->2) 조정 - 불투명도 조절로 시각적 균형 확보 (white/20 -> white/50) - 각 애니메이션 변동 위치(yOffset) 미세 조정 --- .../room-sidebar/components/ProfileBannerAnimation.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/room-sidebar/components/ProfileBannerAnimation.tsx b/apps/client/src/widgets/room-sidebar/components/ProfileBannerAnimation.tsx index 6e15d1e7..3ca256f1 100644 --- a/apps/client/src/widgets/room-sidebar/components/ProfileBannerAnimation.tsx +++ b/apps/client/src/widgets/room-sidebar/components/ProfileBannerAnimation.tsx @@ -88,7 +88,7 @@ const VARIANTS = [ animationCss: ANIMATIONS.swim, animationName: 'move-swim', duration: '8s', - yOffset: '-12px', + yOffset: '0px', }, { id: 'rabbit', @@ -96,7 +96,7 @@ const VARIANTS = [ animationCss: ANIMATIONS.hop, animationName: 'move-hop', duration: '7s', - yOffset: '-8px', + yOffset: '2px', }, { id: 'penguin', @@ -104,7 +104,7 @@ const VARIANTS = [ animationCss: ANIMATIONS.slide, animationName: 'move-slide', duration: '7s', - yOffset: '-6px', + yOffset: '4px', }, ]; @@ -122,14 +122,14 @@ export function ProfileBannerAnimation() { <>
- +
); From 9db0472f5fa5735edb722dda6df912d6be407d4a Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 09:24:27 +0900 Subject: [PATCH 29/43] =?UTF-8?q?=F0=9F=92=84=20ui:=20=EB=B1=83=EC=A7=80?= =?UTF-8?q?=20=EB=B0=8F=20=EB=9D=BC=EB=B2=A8=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 헤더의 역할 뱃지와 참가자 목록의 본인 표시 라벨의 레이아웃 및 여백 조정 - RoleBadge: 불필요한 wrapper div 제거 및 padding 조정으로 간결화 - Participant: 'YOU' 라벨 여백 수정 및 leading-0 적용으로 수직 정렬 개선 --- .../src/widgets/header/ui/components/RoleBadge.tsx | 10 ++++------ .../widgets/participants/components/Participant.tsx | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/client/src/widgets/header/ui/components/RoleBadge.tsx b/apps/client/src/widgets/header/ui/components/RoleBadge.tsx index a770e1e5..654eb898 100644 --- a/apps/client/src/widgets/header/ui/components/RoleBadge.tsx +++ b/apps/client/src/widgets/header/ui/components/RoleBadge.tsx @@ -12,7 +12,7 @@ export function RoleBadge({ role }: RoleBadgeProps) { return (
@@ -20,11 +20,9 @@ export function RoleBadge({ role }: RoleBadgeProps) { -
- - {role.toUpperCase()} - -
+ + {role.toUpperCase()} +
); } diff --git a/apps/client/src/widgets/participants/components/Participant.tsx b/apps/client/src/widgets/participants/components/Participant.tsx index 93148a27..45020e73 100644 --- a/apps/client/src/widgets/participants/components/Participant.tsx +++ b/apps/client/src/widgets/participants/components/Participant.tsx @@ -98,9 +98,9 @@ export const Participant = memo(({ ptId }: ParticipantProps) => { )} {isMe && ( -
+
- + YOU
From ea7224e0c3f19e6b0027bac99cdf8c2260341f35 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 10:57:37 +0900 Subject: [PATCH 30/43] =?UTF-8?q?=F0=9F=92=84=20ui:=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=ED=8F=AC=EC=BB=A4=EC=8A=A4=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=ED=86=B5=EC=9D=BC=20=EB=B0=8F=20=EC=A0=95?= =?UTF-8?q?=EC=A0=81=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=8B=A4=ED=81=AC=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브랜드 아이덴티티 강화를 위해 포커스 링 색상을 brand-blue로 변경하고, 로딩/에러 페이지의 다이얼로그 가독성을 개선함 - Input, InputGroup: focus-visible 시 brand-blue 색상 적용 - JoinPage, NotFoundPage: 다크 모드 대응 배경색 및 텍스트 색상 추가 --- apps/client/src/pages/join/JoinPage.tsx | 24 +++++++++++++++---- .../src/pages/not-found/NotFoundPage.tsx | 8 ++++--- .../ui/src/components/base/input-group.tsx | 2 +- packages/ui/src/components/base/input.tsx | 2 +- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/apps/client/src/pages/join/JoinPage.tsx b/apps/client/src/pages/join/JoinPage.tsx index f2df0a4a..2784484e 100644 --- a/apps/client/src/pages/join/JoinPage.tsx +++ b/apps/client/src/pages/join/JoinPage.tsx @@ -1,7 +1,8 @@ import { useEffect } from 'react'; import { useLoaderData, useNavigate } from 'react-router-dom'; -import { ROUTES } from '@codejam/common'; +import { ROUTES, ROOM_CONFIG } from '@codejam/common'; import { Loader2 } from 'lucide-react'; +import Cookies from 'js-cookie'; export default function JoinPage() { const { roomCode, token } = useLoaderData() as { @@ -11,19 +12,32 @@ export default function JoinPage() { const navigate = useNavigate(); useEffect(() => { - // Navigate to room immediately + // Save token to cookie + const cookieName = `auth_${roomCode.toUpperCase()}`; + const isProduction = import.meta.env.PROD; + + Cookies.set(cookieName, token, { + expires: ROOM_CONFIG.COOKIE_MAX_AGE / (1000 * 60 * 60 * 24), // Convert ms to days + path: '/', + secure: isProduction, + sameSite: isProduction ? 'none' : 'lax', + }); + + // Navigate to room const url = ROUTES.ROOM(roomCode); navigate(url, { replace: true }); }, [roomCode, token, navigate]); return ( -
+
-

+

방 참가 중

-

잠시만 기다려 주세요...

+

+ 잠시만 기다려 주세요... +

); diff --git a/apps/client/src/pages/not-found/NotFoundPage.tsx b/apps/client/src/pages/not-found/NotFoundPage.tsx index a6d807bb..c10cbb32 100644 --- a/apps/client/src/pages/not-found/NotFoundPage.tsx +++ b/apps/client/src/pages/not-found/NotFoundPage.tsx @@ -26,13 +26,15 @@ function NotFoundPage() { } return ( -
+
{icon} -

{title}

-

{message}

+

+ {title} +

+

{message}

diff --git a/apps/client/src/widgets/password-input/PasswordInputDialog.tsx b/apps/client/src/widgets/password-input/PasswordInputDialog.tsx index cde05e5e..4e7d40b9 100644 --- a/apps/client/src/widgets/password-input/PasswordInputDialog.tsx +++ b/apps/client/src/widgets/password-input/PasswordInputDialog.tsx @@ -69,6 +69,13 @@ export function PasswordDialogProps({ } }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && isValid) { + e.preventDefault(); + handleSubmit(); + } + }; + return ( @@ -92,6 +99,7 @@ export function PasswordDialogProps({ type={showPassword ? 'text' : 'password'} value={password} onChange={handleChange} + onKeyDown={handleKeyDown} placeholder="비밀번호를 입력해주세요." autoFocus maxLength={LIMITS.PASSWORD_MAX} From a57cb0765ed1f536a3bb635ae8d563b4ee491c74 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 10:57:52 +0900 Subject: [PATCH 32/43] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=B0=A9=20=EC=B0=B8?= =?UTF-8?q?=EA=B0=80=20=ED=86=A0=ED=81=B0=20=EC=BF=A0=ED=82=A4=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=B0=8F=20=EC=9E=85=EC=9E=A5=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 참가 토큰을 쿠키에 저장하여 세션 유지를 보장하고, 방이 가득 찬 상태를 페이지 내에서 우아하게 처리함 - js-cookie 라이브러리 추가 - JoinPage: URL 파라미터로 받은 토큰을 쿠키(auth_ROOMCODE)에 저장 - RoomPage: FULL 상태일 때 전용 에러 다이얼로그 노출 - API: checkRoomJoinable에서 FULL 상태를 예외 대신 상태값으로 반환 --- apps/client/package.json | 2 ++ pnpm-lock.yaml | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/apps/client/package.json b/apps/client/package.json index 04954d3b..8727168f 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -31,6 +31,7 @@ "@vercel/speed-insights": "^1.3.1", "codemirror": "^6.0.2", "highlight.js": "^11.11.1", + "js-cookie": "^3.0.5", "lib0": "^0.2.115", "lucide-react": "^0.561.0", "qrcode.react": "^4.2.0", @@ -56,6 +57,7 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@testing-library/react": "^16.3.1", + "@types/js-cookie": "^3.0.6", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b5a0aa6..8a6b252c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: highlight.js: specifier: ^11.11.1 version: 11.11.1 + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 lib0: specifier: ^0.2.115 version: 0.2.115 @@ -132,6 +135,9 @@ importers: '@testing-library/react': specifier: ^16.3.1 version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 '@types/node': specifier: ^24.10.1 version: 24.10.4 @@ -2694,6 +2700,9 @@ packages: '@types/jest@30.0.0': resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/js-cookie@3.0.6': + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -4791,6 +4800,10 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -9336,6 +9349,8 @@ snapshots: expect: 30.2.0 pretty-format: 30.2.0 + '@types/js-cookie@3.0.6': {} + '@types/json-schema@7.0.15': {} '@types/jsonwebtoken@9.0.10': @@ -11739,6 +11754,8 @@ snapshots: joycon@3.1.1: {} + js-cookie@3.0.5: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} From 36b9e8873c3c3885c320bf33be9b1bc72a429df2 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 10:57:58 +0900 Subject: [PATCH 33/43] =?UTF-8?q?=E2=9C=A8=20feat(cli):=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=EB=B0=A9=20=EC=83=9D=EC=84=B1=20=EC=8B=9C?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EC=9C=A0=EC=A7=80=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI를 통해 방을 생성할 때 서버에서 내려주는 토큰을 파싱하여 세션을 유지하고, 서버 에러 발생 시 상세 메시지를 노출함 - ApiClient: 서버의 Set-Cookie 헤더에서 인증 토큰 추출 로직 추가 - start 커맨드: 커스텀 방 생성 시 JOIN 라우트로 접속하여 토큰 주입 처리 - API 에러 발생 시 JSON 응답의 에러 메시지 우선 노출 --- packages/cli/src/api/client.ts | 43 +++++++++++++++++++++++++++--- packages/cli/src/commands/start.ts | 13 +++++---- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/api/client.ts b/packages/cli/src/api/client.ts index 214fa39a..09d0d788 100644 --- a/packages/cli/src/api/client.ts +++ b/packages/cli/src/api/client.ts @@ -19,7 +19,14 @@ export class ApiClient { }); if (!response.ok) { - throw new Error(`Request failed: ${response.statusText}`); + const errorData = await response.json().catch(() => null); + + const message = + errorData?.error?.message || + errorData?.message || + `Request failed: ${response.statusText}`; + + throw new Error(message); } return response.json(); @@ -37,13 +44,43 @@ export class ApiClient { async createCustomRoom( request: CreateCustomRoomRequest, ): Promise { - return this.request( - API_ENDPOINTS.ROOM.CREATE_CUSTOM, + const response = await fetch( + `${this.baseUrl}${API_ENDPOINTS.ROOM.CREATE_CUSTOM}`, { method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, body: JSON.stringify(request), }, ); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + + const message = + errorData?.error?.message || + errorData?.message || + `Request failed: ${response.statusText}`; + + throw new Error(message); + } + + const data = await response.json(); + + // Extract token from Set-Cookie header + const setCookie = response.headers.get('set-cookie'); + let token: string | undefined; + + if (setCookie) { + // Parse: auth_ROOMCODE=TOKEN; ... + const match = setCookie.match(/auth_[^=]+=([^;]+)/); + if (match) { + token = match[1]; + } + } + + return { ...data, token }; } async checkJoinable(roomCode: string): Promise { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 178ad99c..c090bd18 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -121,13 +121,16 @@ export const startCommand = new Command('start') const client = new ApiClient(config.serverUrl); const { roomCode, token } = await createRoom(client, options); displayRoomInfo(roomCode, options); - if (!options.custom) { - const roomUrl = `${config.clientUrl}${ROUTES.ROOM(roomCode)}`; - await openRoomInBrowser(roomUrl, options.browser !== false); + + let roomUrl: string; + if (options.custom && token) { + // Custom room: use JOIN route with token to save cookie + roomUrl = `${config.clientUrl}${ROUTES.JOIN(roomCode)}?token=${token}`; } else { - const roomUrl = `${config.clientUrl}${ROUTES.JOIN(roomCode)}`; - await openRoomInBrowser(roomUrl, options.browser !== false); + // Quick room: direct to ROOM + roomUrl = `${config.clientUrl}${ROUTES.ROOM(roomCode)}`; } + await openRoomInBrowser(roomUrl, options.browser !== false); } catch (error) { handleError('Failed to create room', error); } From d95276bd5ef76ecb69df4316d6b2cc22c1eddbee Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 11:21:18 +0900 Subject: [PATCH 34/43] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20setting:=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20=EC=B0=B8=EA=B0=80=EC=9E=90=20=EC=88=98=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EB=B3=80=EA=B2=BD=20(1=20->=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/common/src/constants/limits.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/constants/limits.ts b/packages/common/src/constants/limits.ts index 3b943944..f3d513c4 100644 --- a/packages/common/src/constants/limits.ts +++ b/packages/common/src/constants/limits.ts @@ -2,7 +2,7 @@ export const LIMITS = { // Participant limits MAX_CAN_EDIT: 6, MAX_PTS: 150, - MIN_PTS: 1, + MIN_PTS: 2, PT_HASH_LENGTH: 4, // Room code From 2916166fd930b58adee6b1c3430b484e4e2499d3 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 11:46:07 +0900 Subject: [PATCH 35/43] =?UTF-8?q?=F0=9F=93=9D=20docs(cli):=20README=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=9D=B4=EB=A6=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm 배포 패키지 이름과 일치하도록 문서 업데이트 - @codejam/cli → codejam-cli로 변경 - 설치 명령어 3곳 수정 --- packages/cli/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 7d398aa2..1f258cad 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,11 +1,11 @@ -# @codejam/cli +# codejam-cli 터미널에서 CodeJam 협업 코딩 룸을 빠르게 생성하고 관리하는 CLI 도구입니다. ## 설치 ```bash -npm install -g @codejam/cli +npm install -g codejam-cli ``` ## 명령어 @@ -182,7 +182,7 @@ echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc source ~/.zshrc # 다시 설치 -npm install -g @codejam/cli +npm install -g codejam-cli ``` --- From 1fda6457048d688f67e1bdcbc3a71b188b64aef6 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 11:47:21 +0900 Subject: [PATCH 36/43] =?UTF-8?q?=F0=9F=94=96=20chore(cli):=20v1.0.1=20?= =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=A6=88=20=EC=A4=80=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 패키지 문서 및 버전 업데이트 - CONTRIBUTING.md 패키지 이름 수정 (@codejam/cli → codejam-cli) - package.json 버전 업데이트 (1.0.0 → 1.0.1) --- packages/cli/CONTRIBUTING.md | 2 +- packages/cli/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/CONTRIBUTING.md b/packages/cli/CONTRIBUTING.md index c53c9bf7..400c3684 100644 --- a/packages/cli/CONTRIBUTING.md +++ b/packages/cli/CONTRIBUTING.md @@ -1,6 +1,6 @@ # CLI 개발 가이드 -@codejam/cli 패키지 개발 및 배포 가이드입니다. +codejam-cli 패키지 개발 및 배포 가이드입니다. ## 개발 환경 설정 diff --git a/packages/cli/package.json b/packages/cli/package.json index 4ec1f205..2aaf419c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "codejam-cli", - "version": "1.0.0", + "version": "1.0.1", "description": "CodeJam CLI for creating and managing collaborative coding rooms", "type": "module", "bin": { From ee5a0e4a8c2014e1a26ac674337e7c03354b7579 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 11:53:30 +0900 Subject: [PATCH 37/43] =?UTF-8?q?=F0=9F=94=96=20chore(cli):=20v1.0.2=20-?= =?UTF-8?q?=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=B2=88=EB=93=A4=EB=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @codejam/common을 번들에 포함하여 npm 배포 시 의존성 문제 해결 - @codejam/common을 devDependencies로 이동 - tsup.config.ts 추가하여 noExternal 설정 - 빌드 시 @codejam/common을 번들에 포함 - 버전 1.0.1 → 1.0.2 --- packages/cli/package.json | 4 ++-- packages/cli/tsup.config.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 packages/cli/tsup.config.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 2aaf419c..4e80ec22 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "codejam-cli", - "version": "1.0.1", + "version": "1.0.2", "description": "CodeJam CLI for creating and managing collaborative coding rooms", "type": "module", "bin": { @@ -23,7 +23,6 @@ "license": "ISC", "packageManager": "pnpm@10.26.0", "dependencies": { - "@codejam/common": "workspace:*", "chalk": "^5.4.1", "cli-table3": "^0.6.5", "commander": "^12.1.0", @@ -31,6 +30,7 @@ "ora": "^8.1.1" }, "devDependencies": { + "@codejam/common": "workspace:*", "@types/node": "^22.10.5", "tsup": "^8.3.5", "typescript": "^5.9.3" diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 00000000..fb75d47a --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + clean: true, + shims: true, + noExternal: [/@codejam\/common/], +}); From b984aca816b1dc68c7d8a8e811ac7d56d3b017f8 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 11:55:48 +0900 Subject: [PATCH 38/43] =?UTF-8?q?=F0=9F=94=A7=20chore:=20pnpm-lock.yaml=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI 의존성 변경 반영 --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a6b252c..941fb08b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -455,9 +455,6 @@ importers: packages/cli: dependencies: - '@codejam/common': - specifier: workspace:* - version: link:../common chalk: specifier: ^5.4.1 version: 5.6.2 @@ -474,6 +471,9 @@ importers: specifier: ^8.1.1 version: 8.2.0 devDependencies: + '@codejam/common': + specifier: workspace:* + version: link:../common '@types/node': specifier: ^22.10.5 version: 22.19.3 From 4daa5b6763560350ea3009ca3c6071aa3228dc46 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 12:00:27 +0900 Subject: [PATCH 39/43] =?UTF-8?q?=F0=9F=94=96=20chore(cli):=20v1.0.3=20-?= =?UTF-8?q?=20CLI=20=ED=8C=A8=ED=82=A4=EC=A7=80=EB=AA=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update 명령어에서 사용하는 패키지명을 codejam-cli로 수정 - common/config.ts: CLI_PACKAGE_NAME 수정 - 버전 1.0.2 → 1.0.3 --- packages/cli/package.json | 2 +- packages/common/src/constants/config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 4e80ec22..ee5cd8fe 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "codejam-cli", - "version": "1.0.2", + "version": "1.0.3", "description": "CodeJam CLI for creating and managing collaborative coding rooms", "type": "module", "bin": { diff --git a/packages/common/src/constants/config.ts b/packages/common/src/constants/config.ts index 151023e3..c5854be6 100644 --- a/packages/common/src/constants/config.ts +++ b/packages/common/src/constants/config.ts @@ -1,3 +1,3 @@ export const PROJECT_NAME = 'CodeJam'; -export const CLI_PACKAGE_NAME = '@codejam/cli'; +export const CLI_PACKAGE_NAME = 'codejam-cli'; export const JWT_ROOM_TOKEN_DEFAULT_EXPIRES = '24h'; From c71537706b60b43bbf3f836cff2fa6fcadbd5aac Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 12:06:11 +0900 Subject: [PATCH 40/43] =?UTF-8?q?=F0=9F=94=96=20chore(cli):=20v1.0.4=20-?= =?UTF-8?q?=20=EB=B2=84=EC=A0=84=20=EC=B2=B4=ED=81=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getCurrentVersion이 항상 1.0.0을 반환하는 문제 수정 - package.json을 JSON import로 직접 불러오도록 변경 - 파일 시스템 읽기 대신 번들링된 버전 사용 - 버전 1.0.3 → 1.0.4 --- packages/cli/package.json | 2 +- packages/cli/src/commands/update.ts | 16 ++-------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index ee5cd8fe..9f7485a4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "codejam-cli", - "version": "1.0.3", + "version": "1.0.4", "description": "CodeJam CLI for creating and managing collaborative coding rooms", "type": "module", "bin": { diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index bf74a416..4c2ae69f 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -3,26 +3,14 @@ import chalk from 'chalk'; import ora from 'ora'; import { exec } from 'child_process'; import { promisify } from 'util'; -import { readFileSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; import { CLI_PACKAGE_NAME, PROJECT_NAME } from '@codejam/common'; import { handleError } from '../utils/index.js'; +import packageJson from '../../package.json' with { type: 'json' }; const execAsync = promisify(exec); -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - function getCurrentVersion(): string { - try { - const packageJson = JSON.parse( - readFileSync(join(__dirname, '../../package.json'), 'utf-8'), - ); - return packageJson.version; - } catch { - return '1.0.0'; - } + return packageJson.version; } async function getLatestVersion(): Promise { From 2dc696224fb10a72c7cace5967a6c24823361970 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 12:26:40 +0900 Subject: [PATCH 41/43] =?UTF-8?q?=F0=9F=90=9B=20fix(cli):=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=EB=B0=A9=20=EC=83=9D=EC=84=B1=20=EC=8B=9C?= =?UTF-8?q?=20=ED=98=B8=EC=8A=A4=ED=8A=B8=20=EC=9D=B8=EC=A6=9D=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=A0=84=EB=8B=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI로 커스텀 방을 생성할 때 사용자가 참가자 대신 호스트로 인증되도록 수정 - CLI가 Set-Cookie 헤더에서 추출한 토큰을 브라우저 URL에 포함 - JoinPage가 URL의 토큰을 쿠키에 저장하여 호스트 권한 유지 - ApiClient의 토큰 추출 로직 정리 (디버그 로그 제거) --- packages/cli/package.json | 2 +- packages/cli/src/commands/start.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 9f7485a4..df6157bd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "codejam-cli", - "version": "1.0.4", + "version": "1.0.5", "description": "CodeJam CLI for creating and managing collaborative coding rooms", "type": "module", "bin": { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index c090bd18..c274b516 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -49,7 +49,9 @@ async function createRoom( }); spinner.succeed(chalk.green('Custom room created!')); - return { roomCode: response.roomCode }; + // response는 서버에서 { roomCode }만 반환 + // 하지만 ApiClient가 Set-Cookie 헤더에서 token을 추출하여 { roomCode, token? }로 확장 + return { roomCode: response.roomCode, token: (response as any).token }; } else { const response = await client.createQuickRoom(); From ca501a27706a2d7e9a13bba0c365789091be4833 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 12:50:40 +0900 Subject: [PATCH 42/43] =?UTF-8?q?=F0=9F=90=9B=20fix:=20IME=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EC=A4=91=20Enter=20=ED=82=A4=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=A0=9C=EC=B6=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한글 입력 시 Enter 키가 두 번 입력되는 문제 수정 - NicknameInputDialog: isComposing 체크 추가 - PasswordInputDialog: isComposing 체크 추가 - IME 조합 완료 Enter와 실제 Enter 키 입력 구분 --- apps/client/src/widgets/nickname-input/NicknameInputDialog.tsx | 2 +- apps/client/src/widgets/password-input/PasswordInputDialog.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/nickname-input/NicknameInputDialog.tsx b/apps/client/src/widgets/nickname-input/NicknameInputDialog.tsx index 9d85356a..44fa2c8a 100644 --- a/apps/client/src/widgets/nickname-input/NicknameInputDialog.tsx +++ b/apps/client/src/widgets/nickname-input/NicknameInputDialog.tsx @@ -61,7 +61,7 @@ export function NicknameInputDialog({ }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && isValid) { + if (e.key === 'Enter' && isValid && !e.nativeEvent.isComposing) { e.preventDefault(); handleSubmit(); } diff --git a/apps/client/src/widgets/password-input/PasswordInputDialog.tsx b/apps/client/src/widgets/password-input/PasswordInputDialog.tsx index 4e7d40b9..a645ede5 100644 --- a/apps/client/src/widgets/password-input/PasswordInputDialog.tsx +++ b/apps/client/src/widgets/password-input/PasswordInputDialog.tsx @@ -70,7 +70,7 @@ export function PasswordDialogProps({ }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && isValid) { + if (e.key === 'Enter' && isValid && !e.nativeEvent.isComposing) { e.preventDefault(); handleSubmit(); } From 2513e083cb1584bbc09278803802ffa803c47fb4 Mon Sep 17 00:00:00 2001 From: inaemin Date: Fri, 6 Feb 2026 13:04:07 +0900 Subject: [PATCH 43/43] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=8B=A4=ED=96=89=20=EA=B6=8C=ED=95=9C=20=EA=B2=80=EC=82=AC?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EXECUTE_CODE 이벤트에서 PermissionGuard 제거 --- apps/server/src/modules/collaboration/collaboration.gateway.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/modules/collaboration/collaboration.gateway.ts b/apps/server/src/modules/collaboration/collaboration.gateway.ts index 8241a11f..5c54646d 100644 --- a/apps/server/src/modules/collaboration/collaboration.gateway.ts +++ b/apps/server/src/modules/collaboration/collaboration.gateway.ts @@ -240,7 +240,7 @@ export class CollaborationGateway } /** C -> S 코드 실행 요청 */ - @UseGuards(PermissionGuard, WsThrottlerGuard) + @UseGuards(WsThrottlerGuard) @Throttle({ default: { limit: EXECUTE_CODE_LIMIT, ttl: 60000 } }) @SubscribeMessage(SOCKET_EVENTS.EXECUTE_CODE) async handleExecuteCode(