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/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 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/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); 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}

{loader === 'FULL' ? ( { window.location.href = '/'; }} @@ -141,10 +138,6 @@ function RoomPage() { /> )} - diff --git a/apps/client/src/pages/room/TabViewer.tsx b/apps/client/src/pages/room/TabViewer.tsx index ddba263f..02f751e3 100644 --- a/apps/client/src/pages/room/TabViewer.tsx +++ b/apps/client/src/pages/room/TabViewer.tsx @@ -3,19 +3,28 @@ import { useFileStore } from '@/stores/file'; import { lazy, Suspense, useContext, useEffect, type MouseEvent } from 'react'; import { EmptyView } from './EmptyView'; import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, ScrollArea, ScrollBar, Tabs, TabsContent, TabsList, TabsTrigger, + toast, + cn, } from '@codejam/ui'; -import { Trash2 } 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; @@ -30,12 +39,40 @@ 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); 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 +102,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 +159,66 @@ export default function TabViewer({ tabKey, readOnly }: TabViewerProps) { {myTabs.map((fileId) => ( - - - - {getFileName(fileId) ? ( - getFileName(fileId) - ) : ( - - {fileTab[fileId].fileName} - + + {getFileIcon(getFileName(fileId) || fileTab[fileId].fileName)} + + {getFileName(fileId) ? ( + getFileName(fileId) + ) : ( + + {fileTab[fileId].fileName} + + )} + +
+ {isFileExecutable(fileId) && ( + { + e.stopPropagation(); + handleExecuteCode(fileId); + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + handleExecuteCode(fileId); + } + }} + > + + + )} + - - - { e.stopPropagation(); handleDeleteTab(fileId); }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + handleDeleteTab(fileId); + } + }} > - - 삭제하기 - - - + + +
+
))}
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: 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 }), + }, + ), +); 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/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} 파일이 이미 - 존재합니다. -
- 어떻게 하시겠습니까? -
-
- - - - - - - -
-
- ); -} 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} diff --git a/apps/client/src/widgets/dialog/FileSelectDialog.tsx b/apps/client/src/widgets/dialog/FileSelectDialog.tsx index 75a4aab2..67eb88e8 100644 --- a/apps/client/src/widgets/dialog/FileSelectDialog.tsx +++ b/apps/client/src/widgets/dialog/FileSelectDialog.tsx @@ -1,18 +1,23 @@ -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'; +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; @@ -20,150 +25,58 @@ 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, 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 getFileIcon = (fileName: string) => { + const extension = extname(fileName); + if (!extension) return ; - const handleSearchChange = (e: React.ChangeEvent) => { - setSearchQuery(e.target.value); - setSelectedIndex(0); + const Icon = iconMap[extension.toLowerCase()]; + return Icon ? : ; }; 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" + > + {getFileIcon(file.name)} + {file.name} + + ))} + + + + ); } 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 ( - - - - - - - ); -} 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/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 diff --git a/apps/client/src/widgets/files/components/File.tsx b/apps/client/src/widgets/files/components/File.tsx index a5fe591f..94bdc0d0 100644 --- a/apps/client/src/widgets/files/components/File.tsx +++ b/apps/client/src/widgets/files/components/File.tsx @@ -1,9 +1,19 @@ import { cn } 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 { 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 = { @@ -16,15 +26,47 @@ 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); + 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); const [open, setOpen] = useState(false); const [dialogType, setDialogType] = useState(undefined); + const [isRenaming, setIsRenaming] = useState(false); + const [duplicateTarget, setDuplicateTarget] = useState(null); const isActive = activeFileId === fileId; @@ -33,10 +75,48 @@ export const File = memo(({ fileId, fileName, hasPermission }: FileProps) => { }; const handleActionClick = (type: DialogType) => { + if (type === 'RENAME') { + setIsRenaming(true); + return; + } setDialogType(type); setOpen(true); }; + const handleRenameSubmit = (newFileName: string) => { + if (fileName === newFileName) { + setIsRenaming(false); + return; + } + + if (getFileId(newFileName)) { + setDuplicateTarget(newFileName); + setIsRenaming(false); + 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) => { ev.dataTransfer.setData( 'application/json', @@ -58,22 +138,33 @@ export const File = memo(({ fileId, fileName, hasPermission }: FileProps) => { } }; + if (isRenaming) { + return ( + setIsRenaming(false)} + /> + ); + } + return ( <>
-
-

+

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

+
{/* 더보기 액션 버튼 */} @@ -87,20 +178,25 @@ export const File = memo(({ fileId, fileName, hasPermission }: FileProps) => { )}
- - + + {duplicateTarget && ( + !isOpen && setDuplicateTarget(null)} + filename={duplicateTarget} + onClick={() => {}} + onOverwrite={handleOverwrite} + onAutoRename={handleAutoRename} + /> + )} ); }); + File.displayName = 'FileList'; 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 ? ( - - ) : ( - - - - )} + - - - -
+ + + + ); } 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
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 (
- + } /> { e?.preventDefault(); - const input = password.trim(); + if (!isValid) return; - // Zod 검증 - const result = passwordSchema.safeParse(input); + onConfirm(password); + setPassword(''); + setIsValid(false); + }; - if (!result.success) { - const firstError = result.error.issues[0]; - setError(firstError?.message || '비밀번호를 확인해주세요.'); + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + + setPassword(value); + + if (!value.trim()) { + setError(''); + setIsValid(false); return; } - setError(''); - onConfirm(result.data); - setPassword(''); + const result = passwordSchema.safeParse(value); + + if (!result.success) { + const firstError = result.error.issues[0]; + setError(firstError?.message || '비밀번호 형식이 올바르지 않습니다.'); + setIsValid(false); + } else { + setError(''); + setIsValid(true); + } }; - const handleChange = (e: React.ChangeEvent) => { - const value = e.target.value; - // 최대 16자로 제한 - if (value.length <= 16) { - setPassword(value); - setError(''); // 입력 중에는 에러 메시지 제거 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && isValid) { + e.preventDefault(); + handleSubmit(); } }; return ( - - e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()} - > -
- - 비밀번호 입력 - + + + + + 비밀번호 입력 + 방에 입장하기 위한 비밀번호를 입력해주세요. - - -
-
- - - {error && ( -

{error}

- )} - {passwordError && ( -

- {passwordError} -

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

+ {displayError} +

- - - - - -
+ + + + ); } diff --git a/apps/client/src/widgets/room-sidebar/components/MoreTabContent.tsx b/apps/client/src/widgets/room-sidebar/components/MoreTabContent.tsx index efc1e4b7..10c497e2 100644 --- a/apps/client/src/widgets/room-sidebar/components/MoreTabContent.tsx +++ b/apps/client/src/widgets/room-sidebar/components/MoreTabContent.tsx @@ -3,6 +3,7 @@ import { cn, SidebarHeader } from '@codejam/ui'; import { MORE_MENU_ITEMS } from '../lib/sidebar-data'; import { ExternalLink } from 'lucide-react'; import { ShortcutList } from './ShortcutList'; +import { PinButton } from './PinButton'; export function MoreTabContent() { const [activeAction, setActiveAction] = useState(null); @@ -13,7 +14,7 @@ export function MoreTabContent() { return (
- + } />
{MORE_MENU_ITEMS.map((item) => { 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 ( + + ); +} 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() { <>
- +
); 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/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/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 ( - - - + +
+ +
+ + } + /> @@ -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' && } (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, + }; +} 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/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); } 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 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/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/input-group.tsx b/packages/ui/src/components/base/input-group.tsx index 51e245fb..20cee3a7 100644 --- a/packages/ui/src/components/base/input-group.tsx +++ b/packages/ui/src/components/base/input-group.tsx @@ -11,7 +11,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) { data-slot="input-group" role="group" className={cn( - 'border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5', + 'border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-brand-blue has-[[data-slot=input-group-control]:focus-visible]:ring-brand-blue/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5', className, )} {...props} diff --git a/packages/ui/src/components/base/input.tsx b/packages/ui/src/components/base/input.tsx index 7066487f..fb10dde7 100644 --- a/packages/ui/src/components/base/input.tsx +++ b/packages/ui/src/components/base/input.tsx @@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) { type={type} data-slot="input" className={cn( - 'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-[3px] md:text-sm', + 'dark:bg-input/30 border-input focus-visible:border-brand-blue focus-visible:ring-brand-blue/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-[3px] md:text-sm', className, )} {...props} 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} - - + + ); 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}
}
); } 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..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 @@ -547,6 +553,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) @@ -2691,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==} @@ -3622,6 +3634,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'} @@ -4782,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==} @@ -9327,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': @@ -10327,6 +10351,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: @@ -11718,6 +11754,8 @@ snapshots: joycon@3.1.1: {} + js-cookie@3.0.5: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {}