Skip to content
20 changes: 20 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.3",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.11"
},
"devDependencies": {
Expand Down
8 changes: 6 additions & 2 deletions src/components/admin/assignments/AssignmentFormLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ const AssignmentFormLayout = ({

{/* 하단 버튼 */}
<div className='mt-auto flex justify-end gap-[21px]'>
<Button theme='primaryWhite' text='취소' onClick={onCancel} />
<Button theme='primaryPurple' text='확인' onClick={onConfirm} />
<Button color='outlinePurple' onClick={onCancel}>
취소
</Button>
<Button color='primary' onClick={onConfirm}>
저장
</Button>
</div>
</div>
</div>
Expand Down
13 changes: 6 additions & 7 deletions src/components/admin/assignments/AssignmentPageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,14 @@ const AssignmentPageLayout = ({
<div className='px-14'>
<AssignmentList courses={courses} selectMode={selectMode} />
{!selectMode && (
<Button
theme='secondaryPurpleStroke'
text='문제 추가'
icon={<AddIcon width={12} height={12} />}
/>
<Button color='tonal' size='compact' content='mixed'>
<AddIcon width={12} height={12} />
문제 추가
</Button>
)}
<div className='flex justify-end gap-5 mt-3'>
<Button theme='primaryWhite' text='취소' />
<Button theme='primaryPurple' text='저장' />
<Button color='outlinePurple'>취소</Button>
<Button color='primary'>등록</Button>
</div>
</div>
</div>
Expand Down
74 changes: 54 additions & 20 deletions src/components/common/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,61 @@
interface ButtonProps {
theme: string;
text: string;
icon?: React.ReactElement;
onClick?: () => void;
}
import {tv, type VariantProps} from 'tailwind-variants/lite';

interface ButtonTheme {
[key: string]: string;
}
const button = tv({
base: 'cursor-pointer rounded-[10px] border',
variants: {
color: {
primary: 'bg-primary text-white border-primary',
secondary:
'bg-white text-primary-black border-purple-stroke hover:bg-hover hover:text-white',
outlinePurple: 'bg-white text-primary border-primary',
outlineWhite: 'bg-transparent text-white border-white',
tonal: 'bg-purple-stroke text-secondary-black border-purple-stroke',
ghost: 'bg-transparent text-black border-none',
ghostWhite: 'bg-white text-secondary-black border-none',
},
size: {
default: 'w-24 h-10 px-3 py-1.5',
compact: 'w-fit px-3 py-1.5 leading-5',
wide: 'w-40 py-[15px]',
none: 'w-fit p-0',
icon: 'w-16 h-16 p-0 rounded-full', // 아이콘 버튼 rounded 속성 적용
},
content: {
text: 'text-center text-base font-medium whitespace-nowrap',
icon: 'flex-center',
mixed:
'flex-center gap-2 text-center text-base font-medium whitespace-nowrap',
},
},
defaultVariants: {
color: 'primary',
size: 'default',
content: 'text',
},
});

const buttonTheme: ButtonTheme = {
primaryPurple: 'primary-btn bg-primary text-white',
primaryWhite: 'primary-btn bg-white text-primary border',
primaryTransparent: 'primary-btn text-white border border-white',
secondaryPurpleStroke: 'secondary-btn bg-purple-stroke text-secondary-black',
};
type ButtonVariants = VariantProps<typeof button>;

interface ButtonProps extends ButtonVariants {
children: React.ReactNode;
className?: string;
type?: 'button' | 'submit';
disabled?: boolean;
onClick?: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
}

const Button = ({theme, text, icon}: ButtonProps) => {
const Button = ({
children,
onClick,
type = 'button',
disabled = false,
...props
}: ButtonProps) => {
return (
<button
className={`py-1.5 px-3 rounded-[10px] cursor-pointer ${buttonTheme[theme]}`}>
{icon}
<span className='text-btn'>{text}</span>
<button onClick={onClick} disabled={disabled} className={button(props)}>
{children}
</button>
);
Comment on lines +49 to 60
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Missing type, onMouseEnter, and onMouseLeave attributes on the button element.

The type prop is destructured but never applied to the <button>. Additionally, onMouseEnter and onMouseLeave are accepted as props but remain in the ...props spread—they're passed to the button() tv function (which ignores them) instead of being wired to the actual element. This breaks hover interactions in consumers like LandingPage.

🔎 Proposed fix
 const Button = ({
   children,
   onClick,
   type = 'button',
   disabled = false,
+  onMouseEnter,
+  onMouseLeave,
+  className,
   ...props
 }: ButtonProps) => {
   return (
-    <button onClick={onClick} disabled={disabled} className={button(props)}>
+    <button
+      type={type}
+      onClick={onClick}
+      disabled={disabled}
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+      className={button({...props, className})}>
       {children}
     </button>
   );
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const Button = ({
children,
onClick,
type = 'button',
disabled = false,
...props
}: ButtonProps) => {
return (
<button
className={`py-1.5 px-3 rounded-[10px] cursor-pointer ${buttonTheme[theme]}`}>
{icon}
<span className='text-btn'>{text}</span>
<button onClick={onClick} disabled={disabled} className={button(props)}>
{children}
</button>
);
const Button = ({
children,
onClick,
type = 'button',
disabled = false,
onMouseEnter,
onMouseLeave,
className,
...props
}: ButtonProps) => {
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={button({...props, className})}>
{children}
</button>
);
};
🤖 Prompt for AI Agents
In src/components/common/Button.tsx around lines 49 to 60, the destructured type
prop is not applied to the <button> and the onMouseEnter/onMouseLeave props are
swallowed by the props spread (passed only to the style function), so hover
callbacks never reach the DOM element; fix by adding type={type} on the
<button>, explicitly pass onMouseEnter={onMouseEnter} and
onMouseLeave={onMouseLeave} to the element (keep the remaining ...props spread
for other attributes), and ensure the button() tv function only receives the
props it needs (or pass a copy that excludes event handlers).

};
Expand Down
10 changes: 2 additions & 8 deletions src/components/common/CourseOverview/CourseActionsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,8 @@ import Button from '../Button';
const CourseActionsBar = ({isActive}: {isActive: boolean}) => {
return (
<div className='flex gap-5'>
<Button
theme={`${isActive ? 'primaryTransparent' : 'primaryWhite'}`}
text='학생 목록'
/>
<Button
theme={`${isActive ? 'primaryWhite' : 'primaryPurple'}`}
text='단원 추가'
/>
<Button color='outlineWhite'>학생 목록</Button>
<Button color='outlinePurple'>단원 추가</Button>
</div>
);
};
Expand Down
10 changes: 7 additions & 3 deletions src/components/common/Dashboard/CourseList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import logo from '../../../assets/images/snowCode_logo_mini.svg';
import CourseCard from './CourseCard';
import {AddIcon} from '../../../assets/svg';
import type {Course, UserType} from './types';
import Button from '../Button';

const courses: Course[] = [
{
Expand Down Expand Up @@ -45,10 +46,13 @@ const CourseList = ({userType}: CourseListProps) => {
강의 목록
</span>
{userType === 'admin' && (
<button className='bg-white border-0 px-3 py-1 rounded-[10px] flex-center gap-[6px] text-base font-medium text-secondary-black cursor-pointer hover:bg-gray'>
<Button
color='ghostWhite'
size='compact'
className='hover:opacity-70'>
<AddIcon width={12} height={12} />
<span>추가</span>
</button>
추가
</Button>
Comment on lines +49 to +55
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add content='mixed' variant for icon + text combination.

This Button contains both an icon and text but doesn't specify content='mixed'. This is inconsistent with similar usage in AssignmentCreatePage.tsx (line 66) and AssignmentPageLayout.tsx (line 35).

🔎 Proposed fix
          <Button
            color='ghostWhite'
            size='compact'
+           content='mixed'
            className='hover:opacity-70'>
            <AddIcon width={12} height={12} />
            추가
          </Button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Button
color='ghostWhite'
size='compact'
className='hover:opacity-70'>
<AddIcon width={12} height={12} />
<span>추가</span>
</button>
추가
</Button>
<Button
color='ghostWhite'
size='compact'
content='mixed'
className='hover:opacity-70'>
<AddIcon width={12} height={12} />
추가
</Button>
🤖 Prompt for AI Agents
In src/components/common/Dashboard/CourseList.tsx around lines 49 to 55, the
Button contains both an icon and text but is missing the content='mixed' prop;
update the Button declaration to include content='mixed' so it matches similar
usages (e.g., AssignmentCreatePage.tsx line 66 and AssignmentPageLayout.tsx line
35) to ensure consistent styling/behavior for icon+text buttons.

)}
</div>
<div>
Expand Down
10 changes: 8 additions & 2 deletions src/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import {useLocation, Outlet} from 'react-router-dom';
import BaseHeader from '../components/common/BaseHeader';
import {NotificationIcon, SignoutIcon, UserIcon, ChatIcon} from '../assets/svg';
import IconButton from '../components/common/IconButton';
import Button from '@/components/common/Button';

const Layout = () => {
const location = useLocation();
Expand Down Expand Up @@ -42,7 +42,13 @@ const Layout = () => {
<nav
className={`flex-1 bg-gradient-to-r from-light-purple to-purple ${width} h-full px-3 py-[8px] rounded-full flex items-center justify-between gap-9`}>
{buttons.map((button, index) => (
<IconButton key={index} icon={button.icon} aria-label={button.label} />
<Button
key={index}
content='icon'
size='icon'
aria-label={button.label}>
{button.icon}
</Button>
))}
</nav>
);
Expand Down
13 changes: 9 additions & 4 deletions src/pages/admin/assignments/AssignmentCreatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import LabeledInput from '@/components/admin/form/LabeledInput';
import FileUpload from '@/components/admin/form/FileUpload';
import {useState} from 'react';
import LabeledDropdown from '@/components/admin/form/LabeledDropdown';
import Button from '@/components/common/Button';
import {AddIcon} from '@/assets/svg';

const AssignmentCreatePage = () => {
const [examples, setExamples] = useState([{input: '', output: '', 공개: ''}]);
Expand Down Expand Up @@ -58,11 +60,14 @@ const AssignmentCreatePage = () => {
))}
</div>

<button
className='w-fit self-start px-3 py-1.5 bg-purple-stroke rounded-[10px] cursor-pointer'
<Button
color='tonal'
size='compact'
content='mixed'
onClick={handleAddExample}>
+ 추가
</button>
<AddIcon width={12} height={12} />
추가
</Button>
<FileUpload
label='테스트 케이스'
onFileChange={() => {}}
Expand Down
32 changes: 18 additions & 14 deletions src/pages/common/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import snowCodeStudent from '/src/assets/images/snowCode_student.svg';
import snowCodeAdmin from '/src/assets/images/snowCode_admin.svg';
import googleLogo from '/src/assets/images/google_logo.svg';
import {ArrowrightIcon} from '../../assets/svg';
import ActionButton from '../../components/common/ActionButton';
import Button from '@/components/common/Button';

type HoverState = 'none' | 'student' | 'admin';

Expand Down Expand Up @@ -39,11 +39,13 @@ export default function LandingPage() {
{/* 상단 오른쪽 "다음으로" 버튼 */}
<div className='absolute top-[43px] right-[60px] flex items-center gap-4'>
<ArrowrightIcon className='w-[18px] h-[24px]' />
<button
<Button
color='ghost'
onClick={handleNextClick}
className='text-black text-[18px] font-medium'>
size='none'
className='leading-7 text-lg'>
다음으로
</button>
</Button>
</div>

{/* 로고 이미지 (선택/호버에 따라 이미지 변경) */}
Expand All @@ -68,20 +70,22 @@ export default function LandingPage() {
</span>

<div className='flex gap-11 justify-center'>
<ActionButton
label='학생'
<Button
color={selected === 'student' ? 'primary' : 'secondary'}
size='wide'
onClick={() => setSelected('student')}
onMouseEnter={() => setHover('student')}
onMouseLeave={() => selected === 'none' && setHover('none')}
selected={selected === 'student'}
/>
<ActionButton
label='관리자'
onMouseLeave={() => selected === 'none' && setHover('none')}>
학생
</Button>
<Button
color={selected === 'admin' ? 'primary' : 'secondary'}
size='wide'
onClick={() => setSelected('admin')}
onMouseEnter={() => setHover('admin')}
onMouseLeave={() => selected === 'none' && setHover('none')}
selected={selected === 'admin'}
/>
onMouseLeave={() => selected === 'none' && setHover('none')}>
관리자
</Button>
</div>
</div>

Expand Down
22 changes: 12 additions & 10 deletions src/pages/common/UserIdInputPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {useState, useRef, useEffect} from 'react';
import {useNavigate, useLocation} from 'react-router-dom';
import ActionButton from '../../components/common/ActionButton';
import SnowCodeEntryMini from '../../assets/images/snowCode_entry_mini.svg';
import {ArrowleftIcon} from '../../assets/svg';
import Button from '@/components/common/Button';

export default function UserIdInputPage() {
const navigate = useNavigate();
Expand Down Expand Up @@ -78,13 +78,14 @@ export default function UserIdInputPage() {
<div className='relative flex flex-col items-center min-h-[calc(100vh-120px)] text-center'>
<div className='absolute top-[43px] left-[60px] flex items-center gap-4'>
<ArrowleftIcon className='w-[18px] h-[24px]' />
<button
<Button
color='ghost'
onClick={handleBeforeClick}
className='text-black text-[18px] font-medium'>
size='none'
className='leading-7 text-lg'>
이전으로
</button>
</Button>
</div>

<div className='w-[216px] h-[216px] mt-19'>
<img src={SnowCodeEntryMini} alt='SnowCode Entry Mini' />
</div>
Expand Down Expand Up @@ -121,13 +122,14 @@ export default function UserIdInputPage() {
/>
))}
</div>

<ActionButton
label='확인'
<Button
color='secondary'
size='wide'
disabled={!isComplete}
onClick={handleSubmit}
className={!isComplete ? 'cursor-not-allowed' : ''}
/>
className='disabled:cursor-not-allowed'>
확인
</Button>
</div>
</div>
);
Expand Down