diff --git a/.gitignore b/.gitignore index 43cbae0..71b8f68 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ app-example .agents/ skills-lock.json STRUCTURE_CHANGES.txt + +CLAUDE.md +Convention.txt diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index abc0f25..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,210 +0,0 @@ -# LinClean 프로젝트 컨벤션 - -본 문서는 LinClean 프로젝트의 Git / 코드 / 네이밍 컨벤션을 정리한 공식 가이드입니다. -모든 기여자와 AI 어시스턴트는 이 규칙을 준수해야 합니다. - ---- - -## 1. Git 브랜치 전략 (Git Flow) - -### 메인 브랜치 - -| 브랜치 | 역할 | -|---|---| -| `main` | 최종 배포 가능한 상태를 관리하는 브랜치 | -| `develop` | 배포 이전 개발용 코드를 통합 관리하는 브랜치 | - -### feature 브랜치 - -새로운 기능 개발 및 버그 수정이 필요할 때마다 `develop`에서 분기하여 관리한다. - -1. `develop` 브랜치에서 새로운 기능에 대한 feature 브랜치를 분기한다. -2. 새로운 기능에 대한 작업을 수행한다. -3. 작업이 끝나면 `develop` 브랜치로 PR을 통해 병합(merge)한다. -4. 더 이상 필요하지 않은 `feature` 브랜치는 삭제한다. -5. 작업 중인 `feature` 브랜치는 수시로 원격 저장소에 push 한다. - -### 개발 흐름 - -1. Issue 생성 후, Issue에 맞는 브랜치를 생성한다. -2. 해당 브랜치로 checkout 후 기능 이름의 branch에서 개발을 진행한다. -3. 개발이 끝나면 `develop`에 PR을 통해 Merge 한다. (한 명 이상 리뷰어가 승인 시 Merge 가능) -4. 배포 조건이 충족되면 `develop` → `main`으로 팀장이 Merge 후 배포를 진행한다. - ---- - -## 2. 브랜치 및 커밋 네이밍 타입 - -| 타입 | 설명 | -|---|---| -| `feat` | 새로운 기능 추가 | -| `fix` | 버그 수정 | -| `docs` | 문서 수정 | -| `style` | 코드 포맷팅, 세미콜론 누락, 코드 변경이 없는 경우 | -| `refactor` | 코드 리팩토링 | -| `design` | UI/UX 변경 (CSS, 디자인 변경, 이미지 추가 등) | -| `comment` | 필요한 주석 추가 및 변경 | -| `remove` | 파일 삭제 | -| `rename` | 파일 경로 변경 혹은 파일 이름 변경 | -| `test` | 테스트 코드 추가/수정 | -| `chore` | 빌드 업무 수정, 패키지 매니저 수정 | - ---- - -## 3. 브랜치 / 커밋 네이밍 규칙 - -### 브랜치 네이밍 - -형식: `타입/#이슈번호/간단설명` - -- `feat`는 `feature`가 아니라 **`feat`** 로 작성한다. (단, 문서 설명상 예시로 `feature/#이슈번호/간단설명` 형태가 등장할 수 있음) -- 나머지 타입은 커밋 컨벤션과 동일하게 적용한다. - -예시: -- 로그인 기능 개발: `feat/#20/login` -- 로그인 기능 수정: `fix/#21/login` -- 로그인 리팩토링: `refactor/#30/login` - -> 이슈번호는 issue 생성 시 자동 생성되는 번호를 기재한다. - -### 커밋 메시지 - -형식: `<타입>: <설명>` - -예시: -- 로그인 기능 API 연동 완료: `feat: 로그인 기능개발 완료` -- 로그인 버그 수정: `fix: 로그인시 팝업 오류 해결` - ---- - -## 4. 코드 네이밍 컨벤션 - -| 대상 | 규칙 | 예시 | -|---|---|---| -| 폴더명 / 파일명 | `kebab-case` | `payment-complete-view.tsx` | -| 타입 / 인터페이스 | `PascalCase` | `User`, `OrderItem`, `TipInfoProps` | -| 함수 / 변수명 | `camelCase` | `user`, `orderItem`, `emotionDtos` | -| 훅 파일명 | `use-*.ts` | `use-fcm.ts` | -| 훅 함수명 | `useXxx()` | `useFcm()` | -| 환경 파일 | `.env` | `.env`, `.env.local` | - -### 예시 - -**인터페이스 (PascalCase)** -```ts -export interface TipInfoProps { - title: React.ReactNode; - text?: React.ReactNode; - className?: string; -} -``` - -**변수 / 함수 (camelCase)** -```ts -const emotionDtos = useMemo(() => { - const arr = entryData?.emotions as unknown; - if (!Array.isArray(arr)) return []; - // ... -}, [entryData?.emotions]); -``` - -**훅 (useXxx)** -```ts -export default function useFcm({ - vapidKey, - swPath = '/firebase-messaging-sw.js', - autoRequest = false, -}: UseFcmOptions) { - // ... -} -``` - ---- - -## 5. Issue 템플릿 - -```markdown ---- -name: 'Issue: Feature request' -about: '해당 이슈 템플릿을 활용하여 이슈를 작성해 주세요.' -title: '' -labels: '' -assignees: '' ---- - -## 📝 기능 설명 - - - -## 📋 구현할 Task - -- [ ] -- [ ] -- [ ] - -## 📎 추가 내용 (선택) - - -``` - ---- - -## 6. Pull Request 템플릿 - -```markdown - - -Closes # - - - -## 🎯 개요 - -## 💡 해결한 이슈 목록 - -- [ ] 새로운 기능 추가 -- [ ] 버그 수정 -- [ ] CSS 등 사용자 UI 디자인 변경 -- [ ] 코드에 영향을 주지 않는 변경사항 (오타 수정, 탭 사이즈 변경, 변수명 변경) -- [ ] 코드 리팩토링 -- [ ] 주석 추가 및 수정 -- [ ] 문서 수정 -- [ ] 테스트 추가, 테스트 리팩토링 -- [ ] 빌드 부분 혹은 패키지 매니저 수정 -- [ ] 파일 혹은 폴더명 수정 -- [ ] 파일 혹은 폴더 삭제 - -## ✅ 체크 사항 - - - -- [ ] 커밋/코딩 컨벤션에 맞게 작성 -- [ ] 변경 사항에 대한 테스트 - - - -## 📷 Screenshots or Video - - -``` - ---- - -## 7. PR / Merge 규칙 - -- PR은 반드시 `develop` 브랜치를 대상으로 생성한다. -- **한 명 이상의 리뷰어 승인**을 받아야 Merge 가능하다. -- `develop` → `main` 병합 및 배포는 **팀장**이 담당한다. -- Merge 완료된 `feature` 브랜치는 삭제한다. - ---- - -## 8. 작업 체크리스트 (커밋 전 확인) - -- [ ] 브랜치명이 `타입/#이슈번호/간단설명` 형식에 맞는가? -- [ ] 커밋 메시지가 `<타입>: <설명>` 형식에 맞는가? -- [ ] 파일명이 `kebab-case`로 작성되었는가? -- [ ] 타입/인터페이스는 `PascalCase`인가? -- [ ] 함수/변수는 `camelCase`인가? -- [ ] 훅 파일은 `use-*.ts`, 함수는 `useXxx()`인가? -- [ ] 이슈 번호가 PR 본문에 연결(`Closes #`)되어 있는가? diff --git a/assets/images/folder_active.png b/assets/images/folder_active.png new file mode 100644 index 0000000..263baf1 Binary files /dev/null and b/assets/images/folder_active.png differ diff --git a/assets/images/folder_unactive.png b/assets/images/folder_unactive.png new file mode 100644 index 0000000..bb362fe Binary files /dev/null and b/assets/images/folder_unactive.png differ diff --git a/components/ui/action-icon-button.tsx b/components/ui/action-icon-button.tsx new file mode 100644 index 0000000..8964253 --- /dev/null +++ b/components/ui/action-icon-button.tsx @@ -0,0 +1,64 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { Colors, Typography } from '@/constants/theme'; +import { IconSymbol } from './icon-symbol'; + +type Variant = 'delete' | 'erase'; + +interface ActionIconButtonProps { + variant: Variant; + label?: string; + icon?: boolean; + disabled?: boolean; + onPress?: () => void; +} + +const ICON_NAME: Record = { + delete: 'trash', + erase: 'eraser', +}; + +export function ActionIconButton({ + variant, + label, + icon = true, + disabled = false, + onPress, +}: ActionIconButtonProps) { + const iconColor = disabled ? Colors.brand.textHint : Colors.brand.primary; + const labelColor = disabled ? Colors.brand.textHint : Colors.brand.textSecondary; + const showIcon = icon; + const showLabel = Boolean(label); + + return ( + [ + styles.container, + pressed && !disabled && styles.pressed, + ]} + accessibilityRole="button" + accessibilityState={{ disabled }} + > + {showIcon && ( + + )} + {showLabel && ( + {label} + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + pressed: { + opacity: 0.6, + }, + label: { + ...Typography.caption, + }, +}); diff --git a/components/ui/add-folder-button.tsx b/components/ui/add-folder-button.tsx new file mode 100644 index 0000000..3df8282 --- /dev/null +++ b/components/ui/add-folder-button.tsx @@ -0,0 +1,66 @@ +import { StyleSheet, Text, TouchableOpacity, type TouchableOpacityProps } from 'react-native'; + +import { Colors, Typography } from '@/constants/theme'; + +export interface AddFolderButtonProps extends Omit { + label?: string; + icon?: boolean; + disabled?: boolean; +} + +export function AddFolderButton({ + label = '폴더 추가', + icon = true, + disabled = false, + onPress, + ...rest +}: AddFolderButtonProps) { + return ( + + {icon && ( + + + )} + {label} + + ); +} + +const styles = StyleSheet.create({ + base: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + paddingVertical: 7, + paddingHorizontal: 12, + borderRadius: 999, + borderWidth: 1, + borderColor: Colors.brand.line, + backgroundColor: '#fff', + gap: 2, + }, + disabled: { + borderColor: Colors.brand.line, + backgroundColor: Colors.brand.softMint, + }, + icon: { + ...Typography.section, + color: Colors.brand.primary, + lineHeight: 20, + }, + iconDisabled: { + color: Colors.brand.textHint, + }, + label: { + ...Typography.caption, + color: Colors.brand.text, + }, + labelDisabled: { + color: Colors.brand.textHint, + }, +}); diff --git a/components/ui/app-icon.tsx b/components/ui/app-icon.tsx new file mode 100644 index 0000000..093a64a --- /dev/null +++ b/components/ui/app-icon.tsx @@ -0,0 +1,98 @@ +import { + StyleSheet, + Text, + TouchableOpacity, + type StyleProp, + type ViewStyle, +} from 'react-native'; + +import { Colors, Typography } from '@/constants/theme'; +import { IconSymbol } from './icon-symbol'; + +export type AppIconName = + | 'bookmark' + | 'chevron-down' + | 'chevron-right' + | 'chevron-left' + | 'pencil' + | 'plus' + | 'user' + | 'back' + | 'home' + | 'more' + | 'settings' + | 'search'; + +const SYMBOL_MAP: Record = { + bookmark: 'bookmark.fill', + 'chevron-down': 'chevron.down', + 'chevron-right': 'chevron.right', + 'chevron-left': 'chevron.left', + pencil: 'pencil', + plus: 'plus', + user: 'person.fill', + back: 'chevron.left', + home: 'house.fill', + more: 'ellipsis.vertical', + settings: 'gearshape.fill', + search: 'magnifyingglass', +}; + +export interface AppIconProps { + name: AppIconName; + label?: string; + icon?: boolean; + disabled?: boolean; + size?: number; + onPress?: () => void; + style?: StyleProp; +} + +export function AppIcon({ + name, + label, + icon = true, + disabled = false, + size = 24, + onPress, + style, +}: AppIconProps) { + const iconColor = disabled ? Colors.brand.textHint : Colors.brand.text; + + return ( + + {icon && ( + [0]['name']} + size={size} + color={iconColor} + /> + )} + {label != null && ( + + {label} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + gap: 4, + }, + label: { + ...Typography.caption, + color: Colors.brand.text, + }, + labelDisabled: { + color: Colors.brand.textHint, + }, +}); diff --git a/components/ui/bookmark-chip.tsx b/components/ui/bookmark-chip.tsx new file mode 100644 index 0000000..9fe44e1 --- /dev/null +++ b/components/ui/bookmark-chip.tsx @@ -0,0 +1,95 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { IconSymbol } from '@/components/ui/icon-symbol'; +import { Colors, Typography } from '@/constants/theme'; + +export type BookmarkChipVariant = 'active' | 'inactive'; + +interface BookmarkChipProps { + variant?: BookmarkChipVariant; + label?: string; + icon?: boolean; + disabled?: boolean; + onPress?: () => void; +} + +export function BookmarkChip({ + variant = 'inactive', + label = '북마크', + icon = true, + disabled = false, + onPress, +}: BookmarkChipProps) { + const isActive = variant === 'active'; + + const containerStyle = disabled + ? styles.chipDisabled + : isActive + ? styles.chipActive + : styles.chipInactive; + + const textColor = disabled + ? Colors.brand.textHint + : isActive + ? Colors.brand.text + : Colors.brand.textSecondary; + + const iconColor = disabled + ? Colors.brand.textHint + : isActive + ? Colors.brand.text + : Colors.brand.textSecondary; + + return ( + [ + styles.chip, + containerStyle, + pressed && !disabled && styles.chipPressed, + ]} + accessibilityRole="button" + accessibilityState={{ disabled, selected: isActive }} + accessibilityLabel={`${label} 필터 ${isActive ? '활성' : '비활성'}`} + > + {icon && ( + + )} + {label} + + ); +} + +const styles = StyleSheet.create({ + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 100, + borderWidth: 1, + alignSelf: 'flex-start', + }, + chipInactive: { + backgroundColor: Colors.light.background, + borderColor: Colors.brand.line, + }, + chipActive: { + backgroundColor: Colors.brand.softMint, + borderColor: Colors.brand.softMint, + }, + chipDisabled: { + backgroundColor: Colors.brand.background, + borderColor: Colors.brand.line, + }, + chipPressed: { + opacity: 0.7, + }, + label: { + ...Typography.caption, + }, +}); diff --git a/components/ui/bottom-tab-bar.tsx b/components/ui/bottom-tab-bar.tsx new file mode 100644 index 0000000..a8dc310 --- /dev/null +++ b/components/ui/bottom-tab-bar.tsx @@ -0,0 +1,208 @@ +import { TouchableOpacity, View, StyleSheet, Text } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Colors, Typography } from '@/constants/theme'; +import { IconSymbol } from './icon-symbol'; + +// ───────────────────────────────────────── +// Types +// ───────────────────────────────────────── + +export type TabVariant = 'home' | 'addLink' | 'folder'; + +export interface BottomTabItemProps { + /** 탭 종류 */ + variant: TabVariant; + /** 탭 라벨 텍스트 */ + label: string; + /** 아이콘 표시 여부 */ + showIcon?: boolean; + /** 비활성화 상태 */ + disabled?: boolean; + /** 선택(활성) 상태 */ + active?: boolean; + onPress?: () => void; +} + +export interface BottomTabBarProps { + activeTab: TabVariant; + onTabPress?: (variant: TabVariant) => void; + /** 각 탭의 label / showIcon / disabled 오버라이드 */ + home?: Partial>; + addLink?: Partial>; + folder?: Partial>; +} + +// ───────────────────────────────────────── +// Icon name per variant +// ───────────────────────────────────────── + +const ICON_NAME: Record = { + home: 'house.fill', + addLink: 'link.badge.plus', + folder: 'folder', +}; + +// ───────────────────────────────────────── +// BottomTabItem +// ───────────────────────────────────────── + +export function BottomTabItem({ + variant, + label, + showIcon = true, + disabled = false, + active = false, + onPress, +}: BottomTabItemProps) { + const isAddLink = variant === 'addLink'; + + const iconColor = disabled + ? Colors.brand.textHint + : active + ? Colors.brand.text + : Colors.brand.textHint; + + if (isAddLink) { + return ( + + {showIcon && ( + + + + )} + + {label} + + + ); + } + + return ( + + {showIcon && ( + + )} + + {label} + + + ); +} + +// ───────────────────────────────────────── +// BottomTabBar +// ───────────────────────────────────────── + +export function BottomTabBar({ + activeTab, + onTabPress, + home, + addLink, + folder, +}: BottomTabBarProps) { + const insets = useSafeAreaInsets(); + + return ( + + + + onTabPress?.('home')} + /> + onTabPress?.('addLink')} + /> + onTabPress?.('folder')} + /> + + + ); +} + +// ───────────────────────────────────────── +// Styles +// ───────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#FFFFFF', + }, + border: { + height: StyleSheet.hairlineWidth, + backgroundColor: Colors.brand.line, + }, + row: { + flexDirection: 'row', + height: 64, + }, + tabItem: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 4, + }, + addLinkCircle: { + width: 52, + height: 52, + borderRadius: 26, + backgroundColor: Colors.brand.primary, + alignItems: 'center', + justifyContent: 'center', + marginTop: -18, + }, + addLinkCircleDisabled: { + backgroundColor: Colors.brand.textHint, + }, + label: { + ...Typography.bold12, + textAlign: 'center', + }, + labelActive: { + color: Colors.brand.text, + }, + labelDefault: { + color: Colors.brand.textHint, + fontWeight: '400', + }, + labelDisabled: { + color: Colors.brand.textHint, + }, +}); diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..aae3c36 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,174 @@ +import { + ActivityIndicator, + StyleSheet, + Text, + TouchableOpacity, + View, + type TouchableOpacityProps, +} from 'react-native'; + +import { Colors, Typography } from '@/constants/theme'; + +export type ButtonVariant = 'primary' | 'secondary' | 'ghost'; +export type ButtonSize = 'large' | 'medium' | 'small'; + +export interface ButtonProps extends Omit { + label: string; + variant?: ButtonVariant; + size?: ButtonSize; + icon?: React.ReactNode; + iconPosition?: 'left' | 'right'; + disabled?: boolean; + loading?: boolean; +} + +export function Button({ + label, + variant = 'primary', + size = 'medium', + icon, + iconPosition = 'left', + disabled = false, + loading = false, + onPress, + ...rest +}: ButtonProps) { + const isDisabled = disabled || loading; + + return ( + + {loading ? ( + + ) : ( + <> + {icon && iconPosition === 'left' && ( + {icon} + )} + + {label} + + {icon && iconPosition === 'right' && ( + {icon} + )} + + )} + + ); +} + +const styles = StyleSheet.create({ + base: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 12, + borderWidth: 1.5, + borderColor: 'transparent', + }, + + // --- Size variants --- + size_large: { + paddingVertical: 16, + paddingHorizontal: 24, + borderRadius: 18, + }, + size_medium: { + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 12, + }, + size_small: { + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 8, + }, + + // --- Color variants --- + variant_primary: { + backgroundColor: Colors.brand.primary, + borderColor: Colors.brand.primary, + }, + variant_primary_disabled: { + backgroundColor: Colors.brand.softMint, + borderColor: Colors.brand.softMint, + }, + variant_secondary: { + backgroundColor: 'transparent', + borderColor: Colors.brand.primary, + }, + variant_secondary_disabled: { + borderColor: Colors.brand.line, + }, + variant_ghost: { + backgroundColor: 'transparent', + borderColor: 'transparent', + }, + variant_ghost_disabled: {}, + + // --- Label base --- + label: { + textAlign: 'center', + }, + + // --- Label sizes --- + label_large: { + ...Typography.body, + fontWeight: '700', + }, + label_medium: { + ...Typography.summary, + fontWeight: '700', + }, + label_small: { + ...Typography.caption, + }, + + // --- Label colors per variant --- + label_primary: { + color: '#fff', + }, + label_primary_disabled: { + color: Colors.brand.textHint, + }, + label_secondary: { + color: Colors.brand.primary, + }, + label_secondary_disabled: { + color: Colors.brand.textHint, + }, + label_ghost: { + color: Colors.brand.primary, + }, + label_ghost_disabled: { + color: Colors.brand.textHint, + }, + + // --- Icon spacing --- + iconLeft: { + marginRight: 8, + }, + iconRight: { + marginLeft: 8, + }, +}); diff --git a/components/ui/card-link.tsx b/components/ui/card-link.tsx new file mode 100644 index 0000000..36aed5d --- /dev/null +++ b/components/ui/card-link.tsx @@ -0,0 +1,155 @@ +import { useRef } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { Colors, Typography } from '@/constants/theme'; +import type { AnchorPosition } from './folder-card'; +import { IconSymbol } from './icon-symbol'; + +export type CardLinkVariant = 'default' | 'no-icon' | 'disabled'; + +export interface CardLinkProps { + label: string; + title: string; + summary: string; + url: string; + bookmarked?: boolean; + icon?: boolean; + disabled?: boolean; + onBookmark?: () => void; + onMore?: (anchor: AnchorPosition) => void; + onPress?: () => void; +} + +export function CardLink({ + label, + title, + summary, + url, + bookmarked = false, + icon = true, + disabled = false, + onBookmark, + onMore, + onPress, +}: CardLinkProps) { + const moreRef = useRef(null); + + const handleMorePress = () => { + moreRef.current?.measure((_fx, _fy, width, height, px, py) => { + onMore?.({ x: px, y: py, width, height }); + }); + }; + + return ( + [ + styles.card, + disabled && styles.cardDisabled, + pressed && !disabled && styles.cardPressed, + ]} + accessibilityRole="button" + accessibilityState={{ disabled }} + > + + {label} + {icon && ( + + pressed && !disabled && styles.pressed} + > + + + pressed && !disabled && styles.pressed} + > + + + + )} + + + + {title} + + + + {summary} + + + + {url} + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#fff', + borderRadius: 12, + borderWidth: 1, + borderColor: Colors.brand.line, + paddingHorizontal: 16, + paddingVertical: 12, + gap: 4, + }, + cardDisabled: { + opacity: 0.45, + }, + cardPressed: { + opacity: 0.75, + }, + + topRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 4, + }, + iconRow: { + flexDirection: 'row', + gap: 8, + }, + + label: { + ...Typography.caption, + color: Colors.brand.textSecondary, + }, + title: { + ...Typography.section, + color: Colors.brand.text, + }, + summary: { + ...Typography.summary, + color: Colors.brand.textSecondary, + }, + url: { + ...Typography.url, + color: Colors.brand.textHint, + }, + + textDisabled: { + color: Colors.brand.textHint, + }, + pressed: { + opacity: 0.6, + }, +}); diff --git a/components/ui/check-icon.tsx b/components/ui/check-icon.tsx new file mode 100644 index 0000000..29ced75 --- /dev/null +++ b/components/ui/check-icon.tsx @@ -0,0 +1,94 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { Colors, Typography } from '@/constants/theme'; + +type Variant = 'checked' | 'unchecked'; + +interface CheckIconProps { + variant?: Variant; + checked?: boolean; + label?: string; + icon?: boolean; + disabled?: boolean; + onPress?: () => void; +} + +function CheckMark({ color }: { color: string }) { + return ( + + ); +} + +function CheckIconGraphic({ checked, disabled }: { checked: boolean; disabled: boolean }) { + const bgColor = disabled + ? Colors.brand.line + : checked + ? Colors.brand.primary + : Colors.brand.text; + + return ( + + {checked && } + + ); +} + +export function CheckIcon({ + variant, + checked, + label, + icon = true, + disabled = false, + onPress, +}: CheckIconProps) { + const isChecked = checked ?? variant === 'checked'; + const labelColor = disabled ? Colors.brand.textHint : Colors.brand.textSecondary; + + return ( + [ + styles.container, + pressed && !disabled && styles.pressed, + ]} + accessibilityRole="checkbox" + accessibilityState={{ checked: isChecked, disabled }} + > + {icon && } + {Boolean(label) && ( + {label} + )} + + ); +} + +const ICON_SIZE = 24; +const CHECKMARK_THICKNESS = 2; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + pressed: { + opacity: 0.6, + }, + iconCircle: { + width: ICON_SIZE, + height: ICON_SIZE, + borderRadius: ICON_SIZE / 2, + alignItems: 'center', + justifyContent: 'center', + }, + checkmark: { + width: 5, + height: 9, + borderBottomWidth: CHECKMARK_THICKNESS, + borderRightWidth: CHECKMARK_THICKNESS, + borderColor: 'white', + transform: [{ rotate: '45deg' }, { translateY: -2 }], + }, + label: { + ...Typography.caption, + }, +}); diff --git a/components/ui/filter-chip.tsx b/components/ui/filter-chip.tsx new file mode 100644 index 0000000..e389303 --- /dev/null +++ b/components/ui/filter-chip.tsx @@ -0,0 +1,207 @@ +import { useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { IconSymbol } from '@/components/ui/icon-symbol'; +import { Colors, Typography } from '@/constants/theme'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type FilterChipVariant = 'active' | 'inactive'; + +export interface FilterChipItem { + label: string; + value: string; +} + +interface FilterChipProps { + variant?: FilterChipVariant; + label?: string; + icon?: boolean; + disabled?: boolean; + items?: FilterChipItem[]; + selectedValue?: string; + onSelect?: (value: string) => void; + onPress?: () => void; +} + +// ─── Dropdown ───────────────────────────────────────────────────────────────── + +interface DropdownProps { + items: FilterChipItem[]; + selectedValue: string; + onSelect: (value: string) => void; +} + +function Dropdown({ items, selectedValue, onSelect }: DropdownProps) { + return ( + + {items.map((item, index) => { + const isSelected = item.value === selectedValue; + const isLast = index === items.length - 1; + + return ( + + onSelect(item.value)} + style={({ pressed }) => [ + dropdownStyles.item, + isSelected && dropdownStyles.itemSelected, + pressed && dropdownStyles.itemPressed, + ]} + accessibilityRole="menuitem" + accessibilityState={{ selected: isSelected }} + > + + {item.label} + + + {!isLast && } + + ); + })} + + ); +} + +const dropdownStyles = StyleSheet.create({ + container: { + position: 'absolute', + top: '100%', + left: 0, + marginTop: 4, + backgroundColor: Colors.light.background, + borderRadius: 10, + minWidth: 120, + zIndex: 100, + shadowColor: Colors.brand.text, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 4, + overflow: 'hidden', + }, + item: { + paddingVertical: 12, + paddingHorizontal: 16, + }, + itemSelected: { + backgroundColor: Colors.brand.softMint, + }, + itemPressed: { + opacity: 0.7, + }, + itemLabel: { + ...Typography.caption, + }, + divider: { + height: 1, + backgroundColor: Colors.brand.line, + marginHorizontal: 8, + }, +}); + +// ─── FilterChip ─────────────────────────────────────────────────────────────── + +export function FilterChip({ + variant = 'inactive', + label, + icon = true, + disabled = false, + items = [], + selectedValue, + onSelect, + onPress, +}: FilterChipProps) { + const [isOpen, setIsOpen] = useState(false); + + const currentItem = items.find((i) => i.value === selectedValue); + const displayLabel = label ?? currentItem?.label ?? ''; + + const isActive = variant === 'active'; + const labelColor = disabled + ? Colors.brand.textHint + : Colors.brand.text; + + function handlePress() { + if (disabled) return; + if (isActive && items.length > 0) { + setIsOpen((prev) => !prev); + } + onPress?.(); + } + + function handleSelect(value: string) { + setIsOpen(false); + onSelect?.(value); + } + + return ( + + [ + chipStyles.chip, + disabled && chipStyles.chipDisabled, + pressed && !disabled && chipStyles.chipPressed, + ]} + accessibilityRole="button" + accessibilityState={{ disabled, expanded: isOpen }} + > + {displayLabel} + {icon && ( + + + + )} + + + {isActive && isOpen && items.length > 0 && ( + + )} + + ); +} + +const chipStyles = StyleSheet.create({ + wrapper: { + position: 'relative', + alignSelf: 'flex-start', + zIndex: 10, + }, + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 100, + borderWidth: 1, + borderColor: Colors.brand.line, + backgroundColor: Colors.light.background, + }, + chipDisabled: { + backgroundColor: Colors.brand.background, + borderColor: Colors.brand.line, + }, + chipPressed: { + opacity: 0.7, + }, + label: { + ...Typography.caption, + }, + iconRotated: { + transform: [{ rotate: '180deg' }], + }, +}); diff --git a/components/ui/folder-card.tsx b/components/ui/folder-card.tsx new file mode 100644 index 0000000..83a2d80 --- /dev/null +++ b/components/ui/folder-card.tsx @@ -0,0 +1,146 @@ +import { useRef } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Colors, Typography } from '@/constants/theme'; +import { AppIcon } from './app-icon'; + +export interface AnchorPosition { + x: number; + y: number; + width: number; + height: number; +} + +export interface FolderCardProps { + folderName: string; + urlCount: number; + onPress?: () => void; + onMorePress?: (anchor: AnchorPosition) => void; + disabled?: boolean; +} + +export function FolderCard({ + folderName, + urlCount, + onPress, + onMorePress, + disabled = false, +}: FolderCardProps) { + const moreRef = useRef(null); + + const handleMorePress = () => { + moreRef.current?.measure((_fx, _fy, width, height, px, py) => { + onMorePress?.({ x: px, y: py, width, height }); + }); + }; + + return ( + [pressed && !disabled && styles.pressed]} + onPress={disabled ? undefined : onPress} + accessibilityRole="button" + accessibilityLabel={`${folderName} 폴더, ${urlCount}개`} + accessibilityState={{ disabled }} + > + {disabled ? ( + + + + {urlCount}개 + + + + + + {folderName} + + + + ) : ( + + + + {urlCount}개 + + + + + + {folderName} + + + + )} + + ); +} + +const styles = StyleSheet.create({ + shadow: { + borderRadius: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.08, + shadowRadius: 8, + elevation: 4, + }, + shadowActive: { + backgroundColor: Colors.brand.folderGradientEnd, + }, + shadowDisabled: { + backgroundColor: Colors.brand.softMint, + }, + card: { + borderRadius: 16, + paddingHorizontal: 12, + paddingVertical: 12, + width: 144, + minHeight: 96, + justifyContent: 'space-between', + }, + pressed: { + opacity: 0.8, + }, + cardDisabled: { + borderRadius: 16, + paddingHorizontal: 12, + paddingVertical: 12, + width: 144, + minHeight: 96, + justifyContent: 'space-between', + overflow: 'hidden', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + count: { + ...Typography.regular12, + color: Colors.brand.textSecondary, + }, + countDisabled: { + color: Colors.brand.textHint, + }, + folderName: { + ...Typography.section, + color: Colors.brand.text, + }, + folderNameDisabled: { + color: Colors.brand.textHint, + }, +}); diff --git a/components/ui/folder-context-menu.tsx b/components/ui/folder-context-menu.tsx new file mode 100644 index 0000000..2acc06a --- /dev/null +++ b/components/ui/folder-context-menu.tsx @@ -0,0 +1,121 @@ +import { Dimensions, Modal, Pressable, StyleSheet, Text, View } from 'react-native'; +import { Colors, Typography } from '@/constants/theme'; +import type { AnchorPosition } from './folder-card'; + +const MENU_WIDTH = 140; +const MENU_ITEM_HEIGHT = 48; +const GAP = 4; + +export interface ContextMenuItem { + label: string; + onPress: () => void; + destructive?: boolean; +} + +export interface FolderContextMenuProps { + visible: boolean; + anchor?: AnchorPosition; + /** 범용 메뉴 아이템. 제공 시 onEditName/onDelete 대신 사용됨 */ + items?: ContextMenuItem[]; + onEditName?: () => void; + onDelete?: () => void; + onDismiss?: () => void; +} + +export function FolderContextMenu({ + visible, + anchor, + items, + onEditName, + onDelete, + onDismiss, +}: FolderContextMenuProps) { + const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); + + const resolvedItems: ContextMenuItem[] = items ?? [ + { label: '폴더명 수정', onPress: () => onEditName?.() }, + { label: '폴더 삭제', onPress: () => onDelete?.(), destructive: true }, + ]; + + const menuHeight = MENU_ITEM_HEIGHT * resolvedItems.length + (resolvedItems.length - 1); + + const menuTop = anchor + ? anchor.y + anchor.height + GAP + menuHeight > screenHeight + ? anchor.y - menuHeight - GAP + : anchor.y + anchor.height + GAP + : screenHeight / 2; + + const menuLeft = anchor + ? Math.min(anchor.x, screenWidth - MENU_WIDTH - 8) + : (screenWidth - MENU_WIDTH) / 2; + + return ( + + + + {resolvedItems.map((item, index) => ( + + {index > 0 && } + [styles.menuItem, pressed && styles.menuItemPressed]} + onPress={() => { + onDismiss?.(); + item.onPress(); + }} + accessibilityRole="button" + accessibilityLabel={item.label} + > + + {item.label} + + + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + }, + menu: { + position: 'absolute', + width: MENU_WIDTH, + backgroundColor: Colors.light.background, + borderRadius: 12, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.12, + shadowRadius: 12, + elevation: 8, + }, + menuItem: { + height: MENU_ITEM_HEIGHT, + justifyContent: 'center', + paddingHorizontal: 16, + }, + menuItemPressed: { + backgroundColor: Colors.brand.background, + }, + menuItemText: { + ...Typography.caption, + color: Colors.brand.text, + }, + deleteText: { + color: Colors.brand.textWarning, + }, + divider: { + height: 1, + backgroundColor: Colors.brand.line, + marginHorizontal: 12, + }, +}); diff --git a/components/ui/folder-icon.tsx b/components/ui/folder-icon.tsx new file mode 100644 index 0000000..7c4c391 --- /dev/null +++ b/components/ui/folder-icon.tsx @@ -0,0 +1,67 @@ +import { StyleSheet, Text, TouchableOpacity, type StyleProp, type ViewStyle } from 'react-native'; + +import { Colors, Typography } from '@/constants/theme'; +import { IconSymbol } from './icon-symbol'; + +export interface FolderIconProps { + active?: boolean; + icon?: boolean; + label?: string; + disabled?: boolean; + size?: number; + onPress?: () => void; + style?: StyleProp; +} + +export function FolderIcon({ + active = false, + icon = true, + label, + disabled = false, + size = 24, + onPress, + style, +}: FolderIconProps) { + const iconColor = disabled + ? Colors.brand.textHint + : active + ? Colors.brand.text + : Colors.brand.textSecondary; + + const labelColor = disabled + ? Colors.brand.textHint + : active + ? Colors.brand.text + : Colors.brand.textSecondary; + + return ( + + {icon && ( + + )} + {label != null && ( + {label} + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + gap: 4, + }, + label: { + ...Typography.regular12, + }, +}); diff --git a/components/ui/kakao-icon.tsx b/components/ui/kakao-icon.tsx new file mode 100644 index 0000000..a380092 --- /dev/null +++ b/components/ui/kakao-icon.tsx @@ -0,0 +1,44 @@ +import { View } from 'react-native'; +import { Colors } from '@/constants/theme'; + +interface KakaoIconProps { + size?: number; + color?: string; +} + +export function KakaoIcon({ size = 24, color = Colors.kakao.icon }: KakaoIconProps) { + const bubbleH = size * 0.78; + const tailW = size * 0.22; + const tailH = size * 0.26; + + return ( + + + + + ); +} diff --git a/components/ui/result-status-icon.tsx b/components/ui/result-status-icon.tsx new file mode 100644 index 0000000..5920054 --- /dev/null +++ b/components/ui/result-status-icon.tsx @@ -0,0 +1,127 @@ +import { StyleSheet, View, Text } from 'react-native'; +import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; +import { Colors, Typography } from '@/constants/theme'; + +type Variant = 'safe' | 'caution' | 'danger' | 'block'; + +interface ResultStatusIconProps { + variant: Variant; + label?: string; + icon?: boolean; + disabled?: boolean; +} + +const VARIANT_CONFIG = { + safe: { + iconName: 'shield-check' as const, + iconColor: Colors.brand.primary, + glowColor: Colors.brand.softMint, + chipBackground: Colors.brand.primary, + chipTextColor: Colors.brand.text, + }, + caution: { + iconName: 'shield-alert' as const, + iconColor: Colors.brand.textCaution, + glowColor: '#F5ECC8', + chipBackground: Colors.brand.textCaution, + chipTextColor: Colors.brand.text, + }, + danger: { + iconName: 'shield-remove' as const, + iconColor: Colors.brand.textWarning, + glowColor: '#F5C8C8', + chipBackground: Colors.brand.textWarning, + chipTextColor: '#FFFFFF', + }, + block: { + iconName: 'shield-remove' as const, + iconColor: Colors.brand.textWarning, + glowColor: '#F5C8C8', + chipBackground: Colors.brand.textWarning, + chipTextColor: '#FFFFFF', + }, +}; + +export function ResultStatusIcon({ + variant, + label, + icon = true, + disabled = false, +}: ResultStatusIconProps) { + const config = VARIANT_CONFIG[variant]; + + return ( + + {/* 외부 글로우 레이어 */} + + {/* 내부 글로우 레이어 */} + + + {/* 방패 아이콘 뱃지 */} + + {icon && ( + + )} + + + {/* 하단 상태 칩 */} + {label && ( + + {label} + + )} + + ); +} + +const CONTAINER_SIZE = 108; +const BADGE_SIZE = 56; +const GLOW_OUTER_SIZE = CONTAINER_SIZE; +const GLOW_INNER_SIZE = 80; + +const styles = StyleSheet.create({ + container: { + width: CONTAINER_SIZE, + height: CONTAINER_SIZE, + alignItems: 'center', + justifyContent: 'center', + }, + disabled: { + opacity: 0.5, + }, + glowOuter: { + position: 'absolute', + width: GLOW_OUTER_SIZE, + height: GLOW_OUTER_SIZE, + borderRadius: GLOW_OUTER_SIZE / 2, + }, + glowInner: { + position: 'absolute', + width: GLOW_INNER_SIZE, + height: GLOW_INNER_SIZE, + borderRadius: GLOW_INNER_SIZE / 2, + }, + badge: { + width: BADGE_SIZE, + height: BADGE_SIZE, + borderRadius: BADGE_SIZE / 2, + borderWidth: 1.5, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.08)', + }, + chip: { + position: 'absolute', + bottom: 0, + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 99, + }, + chipLabel: { + ...Typography.caption, + }, +}); diff --git a/components/ui/scan-button.tsx b/components/ui/scan-button.tsx new file mode 100644 index 0000000..41696ad --- /dev/null +++ b/components/ui/scan-button.tsx @@ -0,0 +1,46 @@ +import { StyleSheet, Text, TouchableOpacity, type StyleProp, type ViewStyle } from 'react-native'; +import { Colors, Typography } from '@/constants/theme'; +import { IconSymbol } from './icon-symbol'; + +interface ScanButtonProps { + onPress?: () => void; + disabled?: boolean; + style?: StyleProp; +} + +export function ScanButton({ onPress, disabled = false, style }: ScanButtonProps) { + const color = disabled ? Colors.brand.textHint : Colors.brand.primary; + + return ( + + + 검사 시작 + + ); +} + +const styles = StyleSheet.create({ + container: { + width: 60, + height: 60, + borderRadius: 16, + backgroundColor: Colors.light.background, + borderWidth: 1.5, + borderColor: Colors.brand.primary, + alignItems: 'center', + justifyContent: 'center', + gap: 4, + }, + containerDisabled: { + borderColor: Colors.brand.line, + backgroundColor: Colors.brand.background, + }, + label: { + ...Typography.bold12, + }, +}); diff --git a/components/ui/section-header.tsx b/components/ui/section-header.tsx new file mode 100644 index 0000000..a0a23f5 --- /dev/null +++ b/components/ui/section-header.tsx @@ -0,0 +1,37 @@ +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Colors, Typography } from '@/constants/theme'; + +interface SectionHeaderProps { + label: string; + onViewAll?: () => void; + viewAllLabel?: string; +} + +export function SectionHeader({ label, onViewAll, viewAllLabel = '전체보기' }: SectionHeaderProps) { + return ( + + {label} + {onViewAll && ( + + {viewAllLabel} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + label: { + ...Typography.section, + color: Colors.brand.text, + }, + viewAll: { + ...Typography.caption, + color: Colors.brand.primaryDeep, + }, +}); diff --git a/components/ui/swipeable-card-link.tsx b/components/ui/swipeable-card-link.tsx new file mode 100644 index 0000000..8487e3b --- /dev/null +++ b/components/ui/swipeable-card-link.tsx @@ -0,0 +1,72 @@ +import { useRef } from 'react'; +import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Swipeable } from 'react-native-gesture-handler'; +import { Colors, Typography } from '@/constants/theme'; +import { CardLink, type CardLinkProps } from './card-link'; +import { IconSymbol } from './icon-symbol'; + +interface SwipeableCardLinkProps extends CardLinkProps { + onDelete?: () => void; +} + +export function SwipeableCardLink({ onDelete, ...cardLinkProps }: SwipeableCardLinkProps) { + const swipeableRef = useRef(null); + + const handleDelete = () => { + swipeableRef.current?.close(); + onDelete?.(); + }; + + const renderRightActions = (_progress: Animated.AnimatedInterpolation, dragX: Animated.AnimatedInterpolation) => { + const translateX = dragX.interpolate({ + inputRange: [-80, 0], + outputRange: [0, 80], + extrapolate: 'clamp', + }); + + return ( + + + + 삭제 + + + ); + }; + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + swipeableContainer: { + borderRadius: 12, + }, + deleteContainer: { + justifyContent: 'center', + alignItems: 'flex-end', + marginLeft: 8, + }, + deleteButton: { + backgroundColor: Colors.brand.textWarning, + borderRadius: 12, + width: 72, + height: '100%', + justifyContent: 'center', + alignItems: 'center', + gap: 4, + }, + deleteText: { + ...Typography.bold12, + color: '#fff', + }, +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx new file mode 100644 index 0000000..6c4f781 --- /dev/null +++ b/components/ui/toast.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef } from 'react'; +import { Animated, StyleSheet, Text, View } from 'react-native'; +import { Colors, Typography } from '@/constants/theme'; +import { IconSymbol } from './icon-symbol'; + +interface ToastProps { + visible: boolean; + message: string; + duration?: number; + onHide?: () => void; +} + +export function Toast({ visible, message, duration = 2500, onHide }: ToastProps) { + const opacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + Animated.sequence([ + Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }), + Animated.delay(duration - 400), + Animated.timing(opacity, { toValue: 0, duration: 200, useNativeDriver: true }), + ]).start(() => onHide?.()); + } + }, [visible]); + + if (!visible) return null; + + return ( + + + + {message} + + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + zIndex: 100, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 16, + }, + toast: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + backgroundColor: Colors.brand.text, + borderRadius: 100, + paddingVertical: 14, + paddingHorizontal: 20, + }, + message: { + ...Typography.summary, + fontWeight: '700', + color: '#fff', + }, +});