diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..94f480d
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
\ No newline at end of file
diff --git "a/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\355\205\234\355\224\214\353\246\277.md" "b/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\355\205\234\355\224\214\353\246\277.md"
new file mode 100644
index 0000000..17efc3b
--- /dev/null
+++ "b/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\355\205\234\355\224\214\353\246\277.md"
@@ -0,0 +1,21 @@
+---
+name: 이슈 템플릿
+about: issue
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+## 📝 설명
+
+
+## ✅ 작업 내용
+
+- [ ]
+
+## 🖼️ 관련 스크린샷/디자인
+
+
+## 📎 참고사항
+
diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/pull_request_template.md
similarity index 100%
rename from .github/PULL_REQUEST_TEMPLATE/pull_request_template.md
rename to .github/pull_request_template.md
diff --git a/.github/workflows/sync-fork.yml b/.github/workflows/sync-fork.yml
new file mode 100644
index 0000000..c85191d
--- /dev/null
+++ b/.github/workflows/sync-fork.yml
@@ -0,0 +1,53 @@
+name: CI/CD Pipeline
+
+on:
+ push:
+ branches:
+ - main
+ - develop
+ pull_request:
+ branches:
+ - main
+ - develop
+ workflow_dispatch:
+
+jobs:
+ build-test:
+ runs-on: ubuntu-22.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: '20'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build
+ run: npm run build
+
+ sync-fork:
+ needs: build-test
+ runs-on: ubuntu-22.04
+ if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop')
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+ with:
+ fetch-depth: 0
+
+ - name: Setup SSH Agent
+ uses: webfactory/ssh-agent@v0.8.0
+ with:
+ ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
+
+ - name: Add known hosts
+ run: ssh-keyscan github.com >> ~/.ssh/known_hosts
+
+ - name: Push to fork
+ run: |
+ git remote add fork git@github.com:LeeCh0129/Rolling.git
+ git push fork ${GITHUB_REF#refs/heads/}
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..06feeca
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,10 @@
+# 빌드 결과물
+dist
+build
+
+# 의존성
+node_modules
+
+# 기타 제외 파일
+coverage
+.github
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..f416c32
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,13 @@
+{
+ "semi": true,
+ "singleQuote": true,
+ "jsxSingleQuote": false,
+ "trailingComma": "all",
+ "useTabs": false,
+ "tabWidth": 2,
+ "printWidth": 80,
+ "arrowParens": "always",
+ "bracketSpacing": true,
+ "bracketSameLine": false,
+ "endOfLine": "auto"
+}
diff --git a/README.md b/README.md
index 27caf96..4457feb 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,204 @@
-# 롤링 프로젝트
+# Rolling
+마음을 종이비행기에 담아 전하세요. 부담 없이, 따뜻하게.
+
+## 🚀 배포 주소
+[롤링 바로가기](https://rolling-gamma.vercel.app/)
+
+## 📝 프로젝트 소개
+Rolling은 소중한 사람들에게 마음을 전할 수 있는 롤링 페이퍼 서비스입니다. 이름, 배경, 색삭을 설정하여 롤링 페이퍼를 만들고, 메시지와 이모지 반응을 남겨보세요.
+
+## 📚 기술 스택
+### Language
+
+
+### FrontEnd
+
+
+### Style
+
+
+### 도구 및 유틸리티
+
+
+### API
+
+
+### 코드 포매터 및 검사 도구
+
+
+### 협업툴
+

+
+### 배포 및 CI/CD
+
+
+
+## 👥 팀원 소개 및 역할 분담
+
+
+ | 박광민 |
+ 이찬호 |
+ 이승민 |
+ 전지윤 |
+ 윤진우 |
+
+
+  |
+  |
+  |
+  |
+  |
+
+
+ |
+ |
+ |
+ |
+  |
+
+
+
+## 🔨 역할 분담
+### 박광민
+- 공용 컴포넌트(Button, Input) 개발 및 버튼 중복 클릭 방지 처리
+- Axios 통신 모듈화 및 API 관리, 에러 발생 시 사용자 알림(Alert) 처리
+- 메시지 작성 페이지 반응형 구현 및 메타태그를 활용한 미리보기 설정
+- 전체 폰트 스타일 적용 (에디터 및 렌더링 결과물에 클래스 기반 반영)
+- 홈 페이지 및 메시지 작성 페이지 애니메이션 효과 구현
+- 메시지 작성 페이지(`/post/:id/message`) 구현
+ - 드롭다운 컴포넌트 개발
+ - Rich Text Editor(Quill) 커스텀 툴바 구성 (링크, 색상, 리스트 등 포함)
+ - 프로필 이미지 업로드 기능 및 기본 이미지 클릭 시 초기화 처리
+ - 폼/에디터 유효성 검사 및 버튼 비활성화 처리
+ - 프로필 이미지 GET 요청 시 Skeleton UI 적용
+ - 기존 API 연동(GET) 및 게시글 작성(POST) 기능 구현
+ - 작성 완료 후 해당 카드 페이지(`/post/:id`)로 이동 처리
+ - 로컬스토리지 임시 저장 및 관리 기능 개발
+ - 작성 중 임시 저장
+ - 제출 시 임시 저장 삭제
+ - 저장 후 24시간 경과 시 자동 삭제
+ - 초기 상태 세팅 처리 (기본 이미지, 기본 폰트 등)
+
+### 이찬호
+- 프로젝트 초기 세팅
+ - ESLint, Prettier 등 도구 설정
+- 깃허브 프로젝트 관리
+ - PR 템플릿 설정
+ - 브랜치 전략 수립 및 관리
+ - Fork Repository 자동 동기화 워크플로우 구축
+- CI/CD 파이프라인 구축
+ - GitHub Actions 워크플로우 설정
+ - Vercel 자동배포 환경 구성
+ - Production/Preview 환경 분리
+- 헤더 서비스 구현
+ - 이모지 리액션 기능 (추가/조회/카운팅)
+ - Optimstic UI 패턴 적용
+ - 중복 클릭 방지
+ - 카카오톡 공유 기능
+ - URL 클립보드 복사 기능
+ - 토스트 알림
+ - 반응형 UI/UX 구현
+
+### 이승민
+- 메인 화면 구현
+- 반응형 UI/UX 구현
+
+### 전지윤
+- 공통 헤더 컴포넌트 구현
+- /list 페이지 캐러셀 구현
+ - 슬라이드 기능: 화면 크기에 따라 버튼, 터치, 드래그 입력 방식으로 동작
+ - 캐러셀 끝 도달 시 Bounce 애니메이션 적용
+ - Skeleton UI와 이미지 Preload 처리로 초기 로딩 시 사용자 경험 개선
+
+
+### 윤진우
+- 롤링 페이퍼 생성 페이지 (`/post`) 구현
+ - 배경 색상 및 이미지 선택 기능 제공
+ - 배경 이미지 업로드 기능 추가 구현
+ - 입력된 데이터를 기반으로 API에 POST 요청 후, 생성된 페이지로 이동
+ - 이미지 로딩 중 스켈레톤 UI를 적용해 사용자 경험 향상
+
+- 롤링 페이퍼 페이지 (`/post/{id}`) 구현
+ - `IntersectionObserver`를 활용한 무한 스크롤 기능 구현 (메시지를 일정 개수씩 반복 호출)
+ - 사용자 입력값에 대해 XSS 방지를 위해 `DOMPurify` 적용
+ - 메시지 클릭 시 상세 내용을 모달 창으로 출력
+ - 메시지 내용이 길 경우 `...`으로 처리하여 레이아웃 균형 유지
+ - 뒤로 가기 버튼 추가로 페이지 탐색 편의성 제공
+
+- 롤링 페이퍼 편집 페이지 (`/post/{id}/edit`) 구현
+ - 메시지 삭제 기능 구현
+ - 페이지 삭제 기능 구현 (**실수 방지를 위해 2단계 확인창을 적용하여 안전성 강화**)
+ - 편집 모드에서는 "추가하기" 버튼이 숨겨지므로, 메시지 개수를 조정해 레이아웃의 시각적 균형을 유지
+
+- 사용자 경험(UX) 최적화
+ - 뒤로 가기 버튼에 지속적인 색상 변화 애니메이션을 적용해, 특정 배경 이미지 위에서도 눈에 띄도록 개선
+ - 모달 창 등장 및 종료에 부드러운 애니메이션을 적용해 사용자 몰입도 향상
+ - 메시지 삭제 버튼에 hover 애니메이션 추가로 조작 피드백 제공
+
+## 📂 폴더 구조
+``` bash
+project-root/
+├── src/
+│ ├── api/
+│ ├── assets/
+│ │ ├── images/
+│ │ └── styles/
+│ ├── components/
+│ │ ├── common/
+│ │ └── layout/
+│ ├── context/
+│ ├── hooks/
+│ ├── pages/
+│ ├── utils/
+│ ├── App.jsx
+│ └── main.jsx
+└── ...
+```
+
+## 📝 컨벤션
+
+### 🧐 Commit Type & Emoji Guide
+
+| **commit type** | **description** |
+|---------------|----------------|
+| feat | ✨ 기능 추가 |
+| feat | 🖼️ 아이콘 추가 |
+| fix | 🐛 버그 수정 |
+| docs | 📝 문서 수정 |
+| style | 🎨 UI, 스타일 관련 추가 및 수정 |
+| refactor | ♻️ 리팩토링 |
+| chore | 🔧 설정, 빌드 변경 |
+| chore | 📁 폴더 구조 변경 또는 디렉토리 작업 |
+| remove | 🔥 불필요한 코드/파일 제거 |
+| deploy | 🚀 프로젝트 배포 |
+
+
+
+### 📂 폴더/파일명 네이밍 컨벤션
+
+| **대상** | **규칙** | **예시** |
+|------|------|--------|
+| 폴더명 | 케밥케이스 (kebab-case) | components, user-profile |
+| 컴포넌트 파일명 | 파스칼케이스 (PascalCase) | UserProfile.jsx |
+| 스타일 파일명 | 케밥케이스 + .styles.js | user-profile.styles.js |
+| 이미지/아이콘 파일명 | 케밥케이스 | logo-icon.png, profile-default.png |
+| 함수명/변수명 | 카멜케이스 (camelCase) | fetchUserData, userList |
+| 환경변수 | 대문자+스네이크케이스 | VITE_API_URL |
+| 클래스명 | BEM 방식 | .block__element--modifier |
+
+### 🖊️ Git Flow
+
+| **브랜치명** | **설명** |
+|------------|---------|
+| main | 배포 브랜치 |
+| develop | 통합 개발 브랜치 |
+| feature/* | 기능 개발 브랜치 |
+
+### 🌿 브랜치 네이밍 컨벤션
+
+| **브랜치 종류** | **네이밍 규칙** | **예시** |
+|------|------|--------|
+| 기능 개발 | feature/{이름} | feature/park |
+| 버그 수정 | fix/{버그-설명} | fix/login-button-bug |
+| 문서 수정 | docs/{문서-설명} | docs/readme-update |
+
diff --git a/eslint.config.js b/eslint.config.js
index b7837cf..b93ec7b 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -3,13 +3,22 @@ import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
-export default [
- { ignores: ['dist'] },
+import prettier from 'eslint-plugin-prettier';
+import importPlugin from 'eslint-plugin-import';
+
+const config = [
+ { ignores: ['dist', 'eslint.config.js'] },
+
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
- globals: globals.browser,
+
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ },
+
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
@@ -19,15 +28,52 @@ export default [
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
+
+ prettier: prettier,
+ import: importPlugin,
+
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
- 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
+
+
+ 'no-unused-vars': ['warn', { varsIgnorePattern: '^[A-Z_]' }],
+ 'no-console': 'warn',
+ 'prefer-const': 'warn',
+
+
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
+
+ 'react-hooks/rules-of-hooks': 'warn',
+
+ 'prettier/prettier': ['warn', {}, { usePrettierrc: true }],
+
+ 'import/no-anonymous-default-export': [
+ 'error',
+ {
+ allowArrowFunction: true,
+ allowAnonymousFunction: false,
+ allowAnonymousClass: false,
+ },
+ ],
+
+ 'func-style': ['warn', 'declaration', { allowArrowFunctions: true }],
+ },
+ settings: {
+ react: {
+ version: '18.2.0',
+ },
+ 'import/resolver': {
+ node: {
+ extensions: ['.js', '.jsx'],
+ },
+ },
},
},
];
+
+export default config;
diff --git a/index.html b/index.html
index 0c589ec..3a384e1 100644
--- a/index.html
+++ b/index.html
@@ -1,13 +1,55 @@
-
+
+
+
+
+
+
- Vite + React
+ Rolling – 마음을 실은 종이비행기
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index a0e599c..453f812 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,8 +9,10 @@
"version": "0.0.0",
"dependencies": {
"axios": "^1.5.0",
+ "dompurify": "^3.2.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-quill": "^2.0.0",
"react-router-dom": "^6.16.0",
"sass": "^1.67.0"
},
@@ -1712,6 +1714,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/quill": {
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz",
+ "integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==",
+ "license": "MIT",
+ "dependencies": {
+ "parchment": "^1.1.2"
+ }
+ },
"node_modules/@types/react": {
"version": "18.3.20",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz",
@@ -1733,6 +1744,15 @@
"@types/react": "^18.0.0"
}
},
+
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
+
"node_modules/@vitejs/plugin-react": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.0.tgz",
@@ -2047,7 +2067,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.0",
@@ -2079,7 +2098,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -2155,6 +2173,16 @@
"url": "https://paulmillr.com/funding/"
}
},
+
+ "node_modules/clone": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2295,6 +2323,27 @@
}
}
},
+ "node_modules/deep-equal": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
+ "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arguments": "^1.1.1",
+ "is-date-object": "^1.0.5",
+ "is-regex": "^1.1.4",
+ "object-is": "^1.1.5",
+ "object-keys": "^1.1.1",
+ "regexp.prototype.flags": "^1.5.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2306,7 +2355,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
@@ -2324,7 +2372,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
@@ -2373,6 +2420,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dompurify": {
+ "version": "3.2.5",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz",
+ "integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2916,6 +2972,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/eventemitter3": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
+ "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
+ "license": "MIT"
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3108,7 +3176,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -3261,7 +3328,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
@@ -3383,6 +3449,22 @@
"node": ">= 0.4"
}
},
+ "node_modules/is-arguments": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
+ "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -3505,7 +3587,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -3620,7 +3701,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -3881,6 +3961,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4037,11 +4123,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/object-is": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
+ "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4189,6 +4290,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parchment": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
+ "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4343,6 +4450,40 @@
"node": ">=6"
}
},
+ "node_modules/quill": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
+ "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "clone": "^2.1.1",
+ "deep-equal": "^1.0.1",
+ "eventemitter3": "^2.0.3",
+ "extend": "^3.0.2",
+ "parchment": "^1.1.4",
+ "quill-delta": "^3.6.2"
+ }
+ },
+ "node_modules/quill-delta": {
+ "version": "3.6.3",
+ "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
+ "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
+ "license": "MIT",
+ "dependencies": {
+ "deep-equal": "^1.0.1",
+ "extend": "^3.0.2",
+ "fast-diff": "1.1.2"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/quill-delta/node_modules/fast-diff": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
+ "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
+ "license": "Apache-2.0"
+ },
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -4368,6 +4509,21 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-quill": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz",
+ "integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/quill": "^1.3.10",
+ "lodash": "^4.17.4",
+ "quill": "^1.3.7"
+ },
+ "peerDependencies": {
+ "react": "^16 || ^17 || ^18",
+ "react-dom": "^16 || ^17 || ^18"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4450,7 +4606,6 @@
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
@@ -4636,7 +4791,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
@@ -4654,7 +4808,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
diff --git a/package.json b/package.json
index 0504d59..4269108 100644
--- a/package.json
+++ b/package.json
@@ -7,12 +7,16 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
+ "lint:fix": "eslint --fix .",
+ "format": "prettier --write .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.5.0",
+ "dompurify": "^3.2.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-quill": "^2.0.0",
"react-router-dom": "^6.16.0",
"sass": "^1.67.0"
},
diff --git a/public/fonts/NanumMyeongjo.woff2 b/public/fonts/NanumMyeongjo.woff2
new file mode 100644
index 0000000..483adb9
Binary files /dev/null and b/public/fonts/NanumMyeongjo.woff2 differ
diff --git a/public/fonts/NanumSonPyeonJiCe.woff2 b/public/fonts/NanumSonPyeonJiCe.woff2
new file mode 100644
index 0000000..fe242b3
Binary files /dev/null and b/public/fonts/NanumSonPyeonJiCe.woff2 differ
diff --git a/public/rolling-meta.png b/public/rolling-meta.png
new file mode 100644
index 0000000..9fe9405
Binary files /dev/null and b/public/rolling-meta.png differ
diff --git a/src/App.jsx b/src/App.jsx
index 2e1c20f..b9d9a88 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,9 +1,34 @@
-function App() {
+import './assets/styles/base.scss';
+import { Route, Routes } from 'react-router-dom';
+import { Suspense, lazy } from 'react';
+import LoadingSpinner from './components/common/LoadingSpinner';
+import Header from './components/layout/Header/Header';
+
+const RecipientList = lazy(() => import('./pages/RecipientList/RecipientList'));
+const Home = lazy(() => import('./pages/Home/Home'));
+const CreateRecipient = lazy(
+ () => import('./pages/CreateRecipient/CreateRecipient'),
+);
+const Recipient = lazy(() => import('./pages/Recipient/Recipient'));
+const MessageForm = lazy(() => import('./pages/MessageForm/MessageForm'));
+
+export default function App() {
return (
-
-
롤링 페이퍼 프로젝트
-
+ <>
+
+ }>
+
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+
+
+ >
);
-}
-
-export default App;
+}
\ No newline at end of file
diff --git a/src/api/api.js b/src/api/api.js
new file mode 100644
index 0000000..460732b
--- /dev/null
+++ b/src/api/api.js
@@ -0,0 +1,22 @@
+import axios from 'axios';
+
+export const api = axios.create({
+ baseURL: import.meta.env.VITE_API_BASE_URL,
+ headers: { 'Content-Type': 'application/json' },
+ timeout: 5000,
+});
+
+api.interceptors.response.use(
+ (res) => res,
+ (error) => {
+ const status = error.response?.status;
+ if (status === 404) {
+ alert('요청한 자원을 찾을 수 없습니다.');
+ } else if (status === 500) {
+ alert('서버에 문제가 발생했습니다.');
+ } else {
+ alert('문제가 발생했습니다.');
+ }
+ return Promise.reject(error);
+ },
+);
diff --git a/src/api/deleteMessage.js b/src/api/deleteMessage.js
new file mode 100644
index 0000000..3e4d9f1
--- /dev/null
+++ b/src/api/deleteMessage.js
@@ -0,0 +1,7 @@
+import { api } from './api';
+
+export default async function deleteMessage(messageId) {
+ const teamId = import.meta.env.VITE_TEAM_ID;
+ const res = await api.delete(`/${teamId}/messages/${messageId}/`);
+ return res.data;
+}
diff --git a/src/api/deleteRecipient.js b/src/api/deleteRecipient.js
new file mode 100644
index 0000000..ddb2fd1
--- /dev/null
+++ b/src/api/deleteRecipient.js
@@ -0,0 +1,7 @@
+import { api } from './api';
+
+export default async function deleteMessage(recipientId) {
+ const teamId = import.meta.env.VITE_TEAM_ID;
+ const res = await api.delete(`/${teamId}/recipients/${recipientId}/`);
+ return res.data;
+}
diff --git a/src/api/emojiReactions.js b/src/api/emojiReactions.js
new file mode 100644
index 0000000..567bd57
--- /dev/null
+++ b/src/api/emojiReactions.js
@@ -0,0 +1,48 @@
+import { api } from './api';
+
+export const fetchReactions = async (recipientId, limit = 8) => {
+ try {
+ const teamId = import.meta.env.VITE_TEAM_ID;
+ const res = await api.get(
+ `/${teamId}/recipients/${recipientId}/reactions/?limit=${limit}`,
+ );
+ return res.data.results;
+ } catch (error) {
+ console.error('리액션 목록 가져오기 실패:', error);
+ return [];
+ }
+};
+
+export const addReaction = async (recipientId, emoji) => {
+ try {
+ const teamId = import.meta.env.VITE_TEAM_ID;
+ const res = await api.post(
+ `/${teamId}/recipients/${recipientId}/reactions/`,
+ {
+ emoji: emoji,
+ type: 'increase',
+ },
+ );
+ return res.data;
+ } catch (error) {
+ console.error('이모지 리액션 추가 실패:', error);
+ throw error;
+ }
+};
+
+export const decreaseReaction = async (recipientId, emoji) => {
+ try {
+ const teamId = import.meta.env.VITE_TEAM_ID;
+ const res = await api.post(
+ `/${teamId}/recipients/${recipientId}/reactions/`,
+ {
+ emoji: emoji,
+ type: 'decrease',
+ },
+ );
+ return res.data;
+ } catch (error) {
+ console.error('이모지 리액션 감소 실패:', error);
+ throw error;
+ }
+};
diff --git a/src/api/getBackgroundImage.js b/src/api/getBackgroundImage.js
new file mode 100644
index 0000000..c2518ca
--- /dev/null
+++ b/src/api/getBackgroundImage.js
@@ -0,0 +1,6 @@
+import { api } from './api';
+
+export default async function getBackgroundImage() {
+ const res = await api.get('/background-images/');
+ return res.data.imageUrls;
+}
diff --git a/src/api/getMessages.js b/src/api/getMessages.js
new file mode 100644
index 0000000..00dac64
--- /dev/null
+++ b/src/api/getMessages.js
@@ -0,0 +1,13 @@
+import { api } from './api';
+
+export default async function getMessages(id, offset, limit) {
+ try {
+ const teamId = import.meta.env.VITE_TEAM_ID;
+ const res = await api.get(
+ `/${teamId}/recipients/${id}/messages/?limit=${limit}&offset=${offset}`,
+ );
+ return res.data;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/api/getProfileImages.js b/src/api/getProfileImages.js
new file mode 100644
index 0000000..7170ffd
--- /dev/null
+++ b/src/api/getProfileImages.js
@@ -0,0 +1,10 @@
+import { api } from './api';
+
+export default async function getProfileImages() {
+ try {
+ const res = await api.get('/profile-images/');
+ return res.data.imageUrls;
+ } catch {
+ return [];
+ }
+}
diff --git a/src/api/getRecipient.js b/src/api/getRecipient.js
new file mode 100644
index 0000000..b4de94a
--- /dev/null
+++ b/src/api/getRecipient.js
@@ -0,0 +1,11 @@
+import { api } from './api';
+
+export default async function getRecipient(id) {
+ try {
+ const teamId = import.meta.env.VITE_TEAM_ID;
+ const res = await api.get(`/${teamId}/recipients/${id}/`);
+ return res.data;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/api/getRecipients.js b/src/api/getRecipients.js
new file mode 100644
index 0000000..7f60d87
--- /dev/null
+++ b/src/api/getRecipients.js
@@ -0,0 +1,9 @@
+import { api } from './api';
+
+export default async function getRecipients(sort = '') {
+ const teamId = import.meta.env.VITE_TEAM_ID;
+ const res = await api.get(
+ `/${teamId}/recipients/?limit=8&offset=0&sort=${sort}`,
+ );
+ return res.data;
+}
diff --git a/src/api/postMessage.js b/src/api/postMessage.js
new file mode 100644
index 0000000..4e7a3fa
--- /dev/null
+++ b/src/api/postMessage.js
@@ -0,0 +1,24 @@
+import { api } from './api';
+
+export default async function createMessage({
+ team,
+ recipientId,
+ sender,
+ profileImageURL,
+ relationship,
+ content,
+ font,
+}) {
+ const teamId = import.meta.env.VITE_TEAM_ID;
+ const res = await api.post(`/${teamId}/recipients/${recipientId}/messages/`, {
+ team,
+ recipientId,
+ sender,
+ profileImageURL,
+ relationship,
+ content,
+ font,
+ });
+
+ return res.data;
+}
diff --git a/src/api/postRecipient.js b/src/api/postRecipient.js
new file mode 100644
index 0000000..c88ae19
--- /dev/null
+++ b/src/api/postRecipient.js
@@ -0,0 +1,16 @@
+import { api } from './api';
+
+export default async function createPost({
+ team,
+ name,
+ backgroundColor,
+ backgroundImageURL,
+}) {
+ const res = await api.post('/15-7/recipients/', {
+ team,
+ name,
+ backgroundColor,
+ backgroundImageURL,
+ });
+ return res.data.id;
+}
diff --git a/src/api/postUpload.js b/src/api/postUpload.js
new file mode 100644
index 0000000..428d4b4
--- /dev/null
+++ b/src/api/postUpload.js
@@ -0,0 +1,30 @@
+import { api } from './api';
+
+export default async function uploadImage(file, onProgress) {
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('upload_preset', 'profile_upload');
+ const cloudName = import.meta.env.VITE_CLOUD_NAME;
+
+ try {
+ const res = await api.post(
+ `https://api.cloudinary.com/v1_1/${cloudName}/image/upload`,
+ formData,
+ {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ onUploadProgress: (e) => {
+ if (onProgress) {
+ const percent = Math.round((e.loaded * 100) / e.total);
+ onProgress(percent);
+ }
+ },
+ },
+ );
+ return res.data.secure_url;
+ } catch (error) {
+ console.error('이미지 업로드 실패', error);
+ throw error;
+ }
+}
diff --git a/src/assets/images/arrow.svg b/src/assets/images/arrow.svg
new file mode 100644
index 0000000..796691a
--- /dev/null
+++ b/src/assets/images/arrow.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/images/checked.svg b/src/assets/images/checked.svg
new file mode 100644
index 0000000..9327ade
--- /dev/null
+++ b/src/assets/images/checked.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/images/chevron-down.svg b/src/assets/images/chevron-down.svg
new file mode 100644
index 0000000..87f0d84
--- /dev/null
+++ b/src/assets/images/chevron-down.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/images/close.svg b/src/assets/images/close.svg
new file mode 100644
index 0000000..9f97899
--- /dev/null
+++ b/src/assets/images/close.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/images/delete.svg b/src/assets/images/delete.svg
new file mode 100644
index 0000000..96faf38
--- /dev/null
+++ b/src/assets/images/delete.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/assets/images/emoji-add.svg b/src/assets/images/emoji-add.svg
new file mode 100644
index 0000000..daeac4d
--- /dev/null
+++ b/src/assets/images/emoji-add.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/images/img-01.svg b/src/assets/images/img-01.svg
new file mode 100644
index 0000000..08e4c37
--- /dev/null
+++ b/src/assets/images/img-01.svg
@@ -0,0 +1,89 @@
+
diff --git a/src/assets/images/img-02.png b/src/assets/images/img-02.png
new file mode 100644
index 0000000..41dce1f
Binary files /dev/null and b/src/assets/images/img-02.png differ
diff --git a/src/assets/images/logo.svg b/src/assets/images/logo.svg
new file mode 100644
index 0000000..b3dea19
--- /dev/null
+++ b/src/assets/images/logo.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/images/next.svg b/src/assets/images/next.svg
new file mode 100644
index 0000000..4fff6f6
--- /dev/null
+++ b/src/assets/images/next.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/images/plus.svg b/src/assets/images/plus.svg
new file mode 100644
index 0000000..a23af24
--- /dev/null
+++ b/src/assets/images/plus.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/assets/images/rolling-logo.svg b/src/assets/images/rolling-logo.svg
new file mode 100644
index 0000000..2153a6a
--- /dev/null
+++ b/src/assets/images/rolling-logo.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/images/share.svg b/src/assets/images/share.svg
new file mode 100644
index 0000000..cb39fc6
--- /dev/null
+++ b/src/assets/images/share.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/images/success.svg b/src/assets/images/success.svg
new file mode 100644
index 0000000..18be8ec
--- /dev/null
+++ b/src/assets/images/success.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/images/upload.svg b/src/assets/images/upload.svg
new file mode 100644
index 0000000..2f6847d
--- /dev/null
+++ b/src/assets/images/upload.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/styles/base.scss b/src/assets/styles/base.scss
new file mode 100644
index 0000000..8417fe7
--- /dev/null
+++ b/src/assets/styles/base.scss
@@ -0,0 +1,54 @@
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+div,
+span,
+p,
+a,
+ol,
+ul,
+li,
+header,
+footer,
+section {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline;
+}
+
+html {
+ font-family: 'Pretendard', sans-serif;
+ font-size: 62.5%;
+}
+
+button {
+ cursor: pointer;
+}
+
+h1,
+h2,
+h3 {
+ all: unset;
+}
+
+@font-face {
+ font-display: swap;
+ font-family: '나눔명조';
+ font-style: normal;
+ font-weight: 400;
+ src: url('/fonts/NanumMyeongjo.woff2') format('woff2');
+}
+
+@font-face {
+ font-display: swap;
+ font-family: '나눔손글씨 손편지체';
+ font-style: normal;
+ font-weight: 400;
+ src: url('/fonts/NanumSonPyeonJiCe.woff2') format('woff2');
+}
diff --git a/src/assets/styles/variables.scss b/src/assets/styles/variables.scss
new file mode 100644
index 0000000..dac6a47
--- /dev/null
+++ b/src/assets/styles/variables.scss
@@ -0,0 +1,158 @@
+// 색상
+$purple-100: #f8f0ff;
+$purple-200: #ecd9ff;
+$purple-300: #dcb9ff;
+$purple-400: #c894fd;
+$purple-500: #ab57ff;
+$purple-600: #9935ff;
+$purple-700: #861dee;
+$purple-800: #6e0ad1;
+$purple-900: #5603a7;
+
+$beige-100: #fff0d6;
+$beige-200: #ffe2ad;
+$beige-300: #ffc583;
+$beige-400: #ffae65;
+$beige-500: #ff8832;
+
+$blue-100: #e2f5ff;
+$blue-200: #b1e4ff;
+$blue-300: #7cd2ff;
+$blue-400: #34b9ff;
+$blue-500: #00a2fe;
+
+$green-100: #e4fbdc;
+$green-200: #d0f5c3;
+$green-300: #9be282;
+$green-400: #60cf37;
+$green-500: #2ba600;
+
+$gray-100: #f6f6f6;
+$gray-200: #eeeeee;
+$gray-300: #cccccc;
+$gray-400: #999999;
+$gray-500: #555555;
+$gray-600: #4a4a4a;
+$gray-700: #3a3a3a;
+$gray-800: #2b2b2b;
+$gray-900: #181818;
+
+// 기본 색상
+$white: #ffffff;
+$black: #000000;
+$error: #dc3a3a;
+$surface: #f6f8ff;
+
+// 폰트 사이즈
+$font-size-10: 1rem;
+$font-size-12: 1.2rem;
+$font-size-14: 1.4rem;
+$font-size-15: 1.5rem;
+$font-size-16: 1.6rem;
+$font-size-18: 1.8rem;
+$font-size-20: 2rem;
+$font-size-22: 2.2rem;
+$font-size-24: 2.4rem;
+$font-size-26: 2.6rem;
+$font-size-28: 2.8rem;
+
+// 폰트 두께
+$font-weight-regular: 400;
+$font-weight-bold: 700;
+
+@mixin font-28-bold {
+ font-size: $font-size-28;
+ font-weight: $font-weight-bold;
+ line-height: 4.2rem;
+ letter-spacing: -0.01em;
+}
+
+@mixin font-24-bold {
+ font-size: $font-size-24;
+ font-weight: $font-weight-bold;
+ line-height: 3.6rem;
+ letter-spacing: -0.01em;
+}
+
+@mixin font-24-regular {
+ font-size: $font-size-24;
+ font-weight: $font-weight-regular;
+ line-height: 3.6rem;
+ letter-spacing: -0.01em;
+}
+
+@mixin font-20-bold {
+ font-size: $font-size-20;
+ font-weight: $font-weight-bold;
+ line-height: 3rem;
+ letter-spacing: -0.01em;
+}
+@mixin font-20-regular {
+ font-size: $font-size-20;
+ font-weight: $font-weight-regular;
+ line-height: 3rem;
+ letter-spacing: -0.01em;
+}
+
+@mixin font-18-bold {
+ font-size: $font-size-18;
+ font-weight: $font-weight-bold;
+ line-height: 2.8rem;
+ letter-spacing: -0.01em;
+}
+
+@mixin font-18-regular {
+ font-size: $font-size-18;
+ font-weight: $font-weight-regular;
+ line-height: 2.8rem;
+ letter-spacing: -0.01em;
+}
+
+@mixin font-16-bold {
+ font-size: $font-size-16;
+ font-weight: $font-weight-bold;
+ line-height: 2.6rem;
+ letter-spacing: -0.01em;
+}
+
+@mixin font-16-regular {
+ font-size: $font-size-16;
+ font-weight: $font-weight-regular;
+ line-height: 2.6rem;
+ letter-spacing: -0.01em;
+}
+
+@mixin font-15-regular {
+ font-size: $font-size-15;
+ font-weight: $font-weight-regular;
+ line-height: 2.2rem;
+ letter-spacing: -0.01em;
+}
+
+@mixin font-15-bold {
+ font-size: $font-size-15;
+ font-weight: $font-weight-bold;
+ line-height: 2.2rem;
+ letter-spacing: -0.01em;
+}
+
+@mixin font-14-regular {
+ font-size: $font-size-14;
+ font-weight: $font-weight-regular;
+ line-height: 2rem;
+ letter-spacing: -0.005em;
+}
+
+@mixin font-14-bold {
+ font-size: $font-size-14;
+ font-weight: $font-weight-bold;
+ line-height: 2rem;
+ letter-spacing: -0.005em;
+}
+
+@mixin font-12-regular {
+ font-size: $font-size-12;
+ font-weight: $font-weight-regular;
+ line-height: 1.8rem;
+ letter-spacing: -0.005em;
+}
diff --git a/src/components/BackgroundCard/BackgroundCard.jsx b/src/components/BackgroundCard/BackgroundCard.jsx
new file mode 100644
index 0000000..c83dc6d
--- /dev/null
+++ b/src/components/BackgroundCard/BackgroundCard.jsx
@@ -0,0 +1,134 @@
+import { useState } from 'react';
+import styles from './BackgroundCard.module.scss';
+import uploadImage from '../../api/postUpload';
+import checked from '../../assets/images/checked.svg';
+import upload from '../../assets/images/upload.svg';
+import UploadProgressBar from '../common/UploadProgressBar';
+
+export default function BackgroundCard({
+ type, // 'color' 또는 'image' 또는 'upload'
+ color,
+ url,
+ isSelected,
+ onClick,
+ isLoading,
+ onLoad,
+ onSelect,
+ isUploading,
+ setIsUploading,
+}) {
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const [imageUrl, setImageUrl] = useState(url);
+
+ const getClassName = () => {
+ if (type === 'color') {
+ return `${styles[`background-card__color--${color}`]} ${isSelected ? styles['background-card__color--selected'] : ''}`;
+ }
+ if (type === 'image') {
+ return `${styles[`background-card__image--${color}`]} ${isSelected ? styles['background-card__image--selected'] : ''}`;
+ }
+ if (type === 'upload') {
+ return `${styles['background-card__upload']} ${imageUrl ? '' : styles['background-card__upload--no-image']} ${isSelected ? styles['background-card__image--selected'] : ''}`;
+ }
+ return '';
+ };
+
+ const handleImageUpload = async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ e.target.value = null;
+
+ setIsUploading(true);
+
+ try {
+ const uploadedUrl = await uploadImage(file, (percent) => {
+ setUploadProgress(percent);
+ });
+ setImageUrl(uploadedUrl);
+ onSelect?.(uploadedUrl);
+ } catch {
+ alert('이미지 업로드 실패했습니다.');
+ } finally {
+ setIsUploading(false);
+ setUploadProgress(0);
+ }
+ };
+
+ return (
+
+ {type === 'image' && (
+ <>
+ {isLoading && }
+
+ >
+ )}
+ {type === 'upload' && (
+ <>
+ {imageUrl ? (
+ <>
+ {isLoading && (
+
+ )}
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+ >
+ )}
+ {isSelected && (
+
+ )}
+ {isUploading && }
+
+ );
+}
diff --git a/src/components/BackgroundCard/BackgroundCard.module.scss b/src/components/BackgroundCard/BackgroundCard.module.scss
new file mode 100644
index 0000000..2e80b27
--- /dev/null
+++ b/src/components/BackgroundCard/BackgroundCard.module.scss
@@ -0,0 +1,182 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.background-card__color--beige,
+.background-card__color--purple,
+.background-card__color--blue,
+.background-card__color--green {
+ position: relative;
+ width: 168px;
+ aspect-ratio: 1 / 1;
+ border: 1px solid rgba(0, 0, 0, 0.08);
+ border-radius: 16px;
+ list-style: none;
+ flex-shrink: 0;
+ cursor: pointer;
+ transition:
+ transform 0.3s ease-in-out,
+ box-shadow 0.3s ease-in-out;
+
+ &:hover {
+ transform: scale(1.05);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ }
+}
+.background-card__color--selected {
+ cursor: default;
+}
+
+.background-card__color--beige {
+ background-color: $beige-200;
+}
+.background-card__color--purple {
+ background-color: $purple-200;
+}
+.background-card__color--blue {
+ background-color: $blue-200;
+}
+.background-card__color--green {
+ background-color: $green-200;
+}
+
+.background-card__check-icon {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 44px;
+ height: 44px;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+}
+
+.background-card__image--1,
+.background-card__image--2,
+.background-card__image--3,
+.background-card__upload {
+ position: relative;
+ width: 168px;
+ aspect-ratio: 1 / 1;
+ border-radius: 16px;
+ list-style: none;
+ overflow: hidden;
+ flex-shrink: 0;
+ cursor: pointer;
+ transition:
+ transform 0.3s ease,
+ box-shadow 0.3s ease;
+
+ &:hover {
+ transform: scale(1.05) translateZ(0);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ }
+
+ .background-card__background-img {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+}
+
+.background-card__upload {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: relative;
+}
+.background-card__upload-btn {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ background-color: $gray-200;
+ cursor: pointer;
+ gap: 4px;
+ @include font-15-regular;
+ color: $black;
+}
+
+.background-card__upload-img {
+ width: 40px;
+ height: 40px;
+}
+
+.background-card__remove-btn {
+ position: absolute;
+ top: 5px;
+ right: 10px;
+ padding: 0;
+ background: inherit;
+ border: none;
+ color: black;
+ @include font-24-bold;
+ cursor: pointer;
+
+ transition:
+ transform 0.2s ease,
+ color 0.2s ease;
+
+ &:hover {
+ transform: scale(1.2) translateZ(0);
+ color: red;
+ }
+}
+
+.background-card__image--selected .background-card__background-img {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.background-card__check-icon {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 44px;
+ height: 44px;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+}
+
+.background-card__upload--no-image {
+ .background-card__check-icon {
+ display: none;
+ }
+}
+.background-card__skeleton {
+ width: 100%;
+ height: 100%;
+ background-color: #e0e0e0;
+ animation: skeleton-loading 1.2s infinite ease-in-out;
+ border-radius: 8px;
+}
+
+@keyframes skeleton-loading {
+ 0% {
+ background-color: #e0e0e0;
+ }
+ 50% {
+ background-color: #f0f0f0;
+ }
+ 100% {
+ background-color: #e0e0e0;
+ }
+}
+
+.background-card__background-img--hidden {
+ display: none;
+}
+
+@media (max-width: 767px) {
+ .background-card__color--beige,
+ .background-card__color--purple,
+ .background-card__color--blue,
+ .background-card__color--green,
+ .background-card__image--1,
+ .background-card__image--2,
+ .background-card__image--3,
+ .background-card__upload {
+ width: calc((100% - 12px) / 2);
+ }
+}
diff --git a/src/components/Badge/Badge.jsx b/src/components/Badge/Badge.jsx
new file mode 100644
index 0000000..3eb319b
--- /dev/null
+++ b/src/components/Badge/Badge.jsx
@@ -0,0 +1,16 @@
+import styles from './Badge.module.scss';
+
+const relationship = {
+ 지인: 'acquaintance',
+ 동료: 'colleague',
+ 가족: 'family',
+ 친구: 'friend',
+};
+
+export default function Badge({ relation }) {
+ return (
+
+ {relation}
+
+ );
+}
diff --git a/src/components/Badge/Badge.module.scss b/src/components/Badge/Badge.module.scss
new file mode 100644
index 0000000..87a0634
--- /dev/null
+++ b/src/components/Badge/Badge.module.scss
@@ -0,0 +1,28 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.badge {
+ display: inline-block;
+ padding: 0 8px;
+ border-radius: 4px;
+ @include font-14-regular;
+
+ &.acquaintance {
+ background-color: $beige-100;
+ color: $beige-500;
+ }
+
+ &.colleague {
+ background-color: $purple-100;
+ color: $purple-600;
+ }
+
+ &.family {
+ background-color: $green-100;
+ color: $green-500;
+ }
+
+ &.friend {
+ background-color: $blue-100;
+ color: $blue-500;
+ }
+}
diff --git a/src/components/Card/Card.jsx b/src/components/Card/Card.jsx
new file mode 100644
index 0000000..4745ea1
--- /dev/null
+++ b/src/components/Card/Card.jsx
@@ -0,0 +1,82 @@
+import DOMPurify from 'dompurify';
+import { useNavigate } from 'react-router-dom';
+import deleteIcon from '../../assets/images/delete.svg';
+import plus from '../../assets/images/plus.svg';
+import Badge from '../Badge/Badge';
+import styles from './Card.module.scss';
+
+export default function Card({
+ id,
+ image,
+ recipientId,
+ sender,
+ relationship,
+ children,
+ createdAt,
+ font,
+ empty = false,
+ onDelete,
+ onClick,
+ showDelete,
+}) {
+ const navigate = useNavigate();
+ const sanitizedHTML = DOMPurify.sanitize(children);
+
+ function clickPost() {
+ navigate(`/post/${recipientId}/message/`);
+ }
+
+ function clickDelete(e) {
+ e.stopPropagation();
+ if (confirm('정말 삭제하시겠어요?')) {
+ onDelete?.(id, recipientId);
+ }
+ }
+
+ return (
+ (empty ? clickPost() : onClick?.(id))}
+ >
+ {empty ? (
+
+

+
+ ) : (
+ <>
+
+
+

+
+
+ {showDelete && (
+
+
+
+ )}
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/Card/Card.module.scss b/src/components/Card/Card.module.scss
new file mode 100644
index 0000000..fffad74
--- /dev/null
+++ b/src/components/Card/Card.module.scss
@@ -0,0 +1,204 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.card {
+ display: flex;
+ flex-direction: column;
+ width: 384px;
+ height: 280px;
+ padding: 28px 24px;
+ border-radius: 16px;
+ box-shadow: 0px 2px 12px 0px rgba(0, 0, 0, 0.08);
+ background: $white;
+ gap: 16px;
+ transition: transform 0.2s ease-in-out;
+ cursor: pointer;
+
+ &--unshow:hover,
+ &--empty:hover {
+ transform: scale(0.95) translateZ(0);
+ }
+
+ &.card--empty {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ &:hover img {
+ animation: bounce 0.5s 2 ease;
+ }
+ img {
+ width: 56px;
+ height: 56px;
+ cursor: pointer;
+ }
+ }
+
+ &.card--show {
+ cursor: default;
+ }
+
+ &__header {
+ display: flex;
+ gap: 14px;
+ justify-content: space-between;
+ padding-bottom: 16px;
+ border-bottom: 1px solid $gray-200;
+
+ .card__profile-img {
+ position: relative;
+ width: 56px;
+ height: 56px;
+ border: 1px solid $gray-200;
+ border-radius: 100px;
+ overflow: hidden;
+
+ img {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+ }
+
+ .card__user-info {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 6px;
+
+ .card__profile-name {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ -webkit-line-clamp: 1;
+ line-clamp: 1;
+ @include font-20-regular;
+
+ span {
+ @include font-20-bold;
+ }
+ }
+ }
+
+ .card__delete-button {
+ button {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 8px;
+ border: 1px solid $gray-300;
+ border-radius: 6px;
+ background-color: $white;
+ transition:
+ background-color 0.2s ease,
+ border-color 0.2s ease;
+
+ @media (hover: hover) and (pointer: fine) {
+ &:hover {
+ background-color: $error;
+ border-color: $gray-400;
+ img {
+ animation: shake 0.5s ease-in-out;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @keyframes shake {
+ 0% {
+ transform: rotate(0deg) translateX(0) translateY(0);
+ }
+ 25% {
+ transform: rotate(-5deg) translateX(-3px) translateY(3px);
+ }
+ 50% {
+ transform: rotate(5deg) translateX(3px) translateY(-3px);
+ }
+ 75% {
+ transform: rotate(-3deg) translateX(-2px) translateY(2px);
+ }
+ 100% {
+ transform: rotate(0deg) translateX(0) translateY(0);
+ }
+ }
+
+ @keyframes bounce {
+ 0%,
+ 100% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.1);
+ }
+ }
+
+ &__body {
+ height: 280px;
+
+ .card__content {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ word-break: break-word;
+ @include font-18-regular;
+ color: $gray-600;
+ ul,
+ li,
+ a {
+ all: revert;
+ }
+ }
+ }
+
+ &__footer {
+ @include font-12-regular;
+ color: $gray-400;
+ }
+}
+
+@media (max-width: 1248px) {
+ .card {
+ width: inherit;
+ }
+}
+
+@media (max-width: 1023px) {
+ .card {
+ height: 284px;
+ }
+}
+
+@media (max-width: 767px) {
+ .card {
+ height: 230px;
+
+ &__body {
+ .card__content {
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ word-break: break-word;
+ @include font-15-regular;
+ }
+ }
+ }
+}
+
+:global(.font-나눔명조) {
+ font-family: '나눔명조', serif;
+}
+
+:global(.font-나눔손글씨손편지체) {
+ font-family: '나눔손글씨 손편지체', cursive;
+}
+
+:global(.font-Pretendard) {
+ font-family: 'Pretendard', sans-serif;
+}
+
+:global(.font-NotoSans) {
+ font-family: 'Noto Sans', sans-serif;
+}
diff --git a/src/components/Carousel/Carousel.jsx b/src/components/Carousel/Carousel.jsx
new file mode 100644
index 0000000..065201c
--- /dev/null
+++ b/src/components/Carousel/Carousel.jsx
@@ -0,0 +1,142 @@
+import { useState, useEffect } from 'react';
+import styles from './Carousel.module.scss';
+import RecipientCard from '../RecipientCard/RecipientCard';
+
+export default function Carousel({ recipients }) {
+ const [index, setIndex] = useState(0);
+ const [offsetX, setOffsetX] = useState({}); // 캐러셀 x좌표
+ const [startX, setstartX] = useState(0); // 터치 스크롤 시작 x좌표
+ const [isBouncing, setBouncing] = useState(false); // 캐러셀 끝이면 bouncing 모션
+ const [deviceType, setDeviceType] = useState(getDeviceType());
+ const windowSize = getDeviceType();
+ const isDesktop = windowSize === 'desktop';
+ const isMobile = windowSize === 'mobile';
+
+ // 캐러셀 버튼 작동과정: button onclick --> settingIndex(), setIndex --> useEffect( setOffsetX(),[index] ): x좌표 상태 업데이트: 캐러셀 이동
+ useEffect(() => {
+ if (isMobile) {
+ setOffsetX({
+ transform: `translateX(-${index * 228}px)`,
+ });
+ } else {
+ setOffsetX({
+ transform: `translateX(-${index * 295}px)`,
+ });
+ }
+ }, [index]);
+
+ function settingIndex(direction) {
+ setIndex((prev) => (direction === 'next' ? prev + 1 : prev - 1)); // next? next index : back index
+ }
+
+ // 화면 리사이즈 감지
+ function getDeviceType() {
+ const width = window.innerWidth;
+ if (width < 768) return 'mobile';
+ if (width <= 1200) return 'bigTablet';
+ if (width <= 1023) return 'tablet';
+ return 'desktop';
+ }
+
+ useEffect(() => {
+ function handleResize() {
+ const newType = getDeviceType();
+ const isMobile = deviceType === 'mobile';
+ const willBeMobile = newType === 'mobile';
+
+ // mobile → non-mobile 또는 non-mobile → mobile 로 변경될 때만
+ const crossedMobileBoundary = isMobile !== willBeMobile;
+ if (crossedMobileBoundary) {
+ setIndex(0);
+ }
+ if (newType !== deviceType) {
+ setDeviceType(newType);
+ }
+ }
+
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, [deviceType]);
+
+ // 터치, 마우스 드래그 감지 --> 캐러셀 한 칸 이동
+ function handleStart(e) {
+ if (isDesktop) return;
+ const touchStart =
+ e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
+ setstartX(touchStart);
+ }
+
+ function handleEnd(e) {
+ const touchEnd =
+ e.type === 'touchend' ? e.changedTouches[0].clientX : e.clientX;
+ const distance = Math.abs(touchEnd - startX); //드래그 거리
+ const isNext = startX > touchEnd; // direction(next,back) 결정
+ if (isDesktop || distance < 10) return;
+
+ if (!isNext) {
+ if (index === 0) {
+ setBouncing(true);
+ return;
+ } else if (index > 0) {
+ settingIndex('back');
+ return;
+ }
+ } else if (isNext) {
+ if (isMobile) {
+ if (index === 6) {
+ setBouncing(true);
+ return;
+ } else if (index < 6) {
+ settingIndex('next');
+ return;
+ }
+ } else {
+ if (index === 5) {
+ setBouncing(true);
+ return;
+ } else if (index < 5) {
+ settingIndex('next');
+ return;
+ }
+ }
+ }
+ }
+ useEffect(() => {
+ if (isBouncing) {
+ const timer = setTimeout(() => {
+ setBouncing(false); // Bouncing 모션 끝나고 바로 리셋
+ }, 500);
+ }
+ }, [isBouncing]);
+
+ return (
+
+
+
+ {recipients.map((it) => (
+
+ ))}
+
+
+ {index > 0 && ( //시작점 이후부터
+
+ )}
+ {recipients.length > 4 && // 캐러셀 끝에 도달하기 전까지
+ index < 4 && (
+
+ )}
+
+ );
+}
diff --git a/src/components/Carousel/Carousel.module.scss b/src/components/Carousel/Carousel.module.scss
new file mode 100644
index 0000000..182df77
--- /dev/null
+++ b/src/components/Carousel/Carousel.module.scss
@@ -0,0 +1,144 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.carousel {
+ position: relative;
+}
+.carousel__cardset-wrapper {
+ overflow: hidden;
+ width: 1200px;
+ padding: 0 20px;
+}
+.carousel__cardset {
+ display: flex;
+ gap: 20px;
+ transition: transform 0.45s ease-in-out;
+}
+
+.carousel::before,
+.carousel::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ width: 18px;
+ height: 100%;
+ z-index: 1;
+ pointer-events: none;
+}
+.carousel::before {
+ left: 0;
+ background: linear-gradient(
+ to right,
+ rgba(255, 255, 255, 1) 0px,
+ rgba(255, 255, 255, 0.8) 5px,
+ transparent
+ );
+}
+.carousel::after {
+ right: 0;
+ background: linear-gradient(
+ to left,
+ rgba(255, 255, 255, 1) 0px,
+ rgba(255, 255, 255, 0.8) 5px,
+ transparent
+ );
+}
+
+//캐러셀 버튼
+.carousel__direction-button {
+ display: flex;
+ justify-content: space-between;
+ gap: 1120px;
+ position: absolute;
+ top: 50%;
+ left: 100%;
+ width: 40px;
+ height: 40px;
+ margin-left: -40px;
+ z-index: 9999;
+ border: solid 1px $gray-300;
+ border-radius: 50%;
+ background-color: rgba(255, 255, 255, 0.9);
+ background-image: url(../../assets/images/next.svg);
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 40% 40%;
+ transform: translateY(-50%);
+
+ -webkit-filter: drop-shadow(0px 4px 8px #00000014);
+ filter: drop-shadow(0px 4px 8px #00000014);
+ -webkit-backdrop-filter: blur(4px);
+ backdrop-filter: blur(4px);
+
+ &.back {
+ transform: scaleX(-1) translateY(-50%);
+ left: 40px;
+ }
+
+ &:active {
+ border: solid 2px $purple-200;
+ }
+}
+
+// 반응형
+@media (max-width: 1200px) {
+ //캐러셀 안밀리도록 추가함
+ .carousel__cardset-wrapper {
+ width: 100vw;
+ padding: 0 24px;
+ }
+ .carousel::before,
+ .carousel::after {
+ display: none;
+ }
+ .carousel__direction-button {
+ display: none;
+ }
+}
+
+//모바일
+@media (max-width: 767px) {
+ .carousel__cardset-wrapper {
+ padding: 0 20px;
+ }
+ h2 {
+ margin-bottom: 12px;
+ }
+ section {
+ margin-left: 40px 20px 0px 20px;
+ }
+}
+
+// 캐러셀 끝 - 바운스 애니메이션
+@-webkit-keyframes bounce-horizontal {
+ 0% {
+ transform: translateX(0);
+ }
+ 30% {
+ transform: translateX(-20px);
+ }
+ 60% {
+ transform: translateX(20px);
+ }
+ 100% {
+ transform: translateX(0);
+ }
+}
+@keyframes bounce-horizontal {
+ 0% {
+ transform: translateX(0);
+ }
+ 30% {
+ transform: translateX(-20px);
+ }
+ 60% {
+ transform: translateX(20px);
+ }
+ 100% {
+ transform: translateX(0);
+ }
+}
+
+.end-of-carousel {
+ -webkit-animation: bounce-horizontal 0.5s ease;
+ animation: bounce-horizontal 0.5s ease;
+}
diff --git a/src/components/CarouselSkeleton/CarouselSkeleton.jsx b/src/components/CarouselSkeleton/CarouselSkeleton.jsx
new file mode 100644
index 0000000..8bb29c1
--- /dev/null
+++ b/src/components/CarouselSkeleton/CarouselSkeleton.jsx
@@ -0,0 +1,14 @@
+import styles from './CarouselSkeleton.module.scss';
+
+export default function CarouselSkeleton() {
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/src/components/CarouselSkeleton/CarouselSkeleton.module.scss b/src/components/CarouselSkeleton/CarouselSkeleton.module.scss
new file mode 100644
index 0000000..b496939
--- /dev/null
+++ b/src/components/CarouselSkeleton/CarouselSkeleton.module.scss
@@ -0,0 +1,59 @@
+@-webkit-keyframes shimmer {
+ from {
+ transform: translateX(-300px);
+ }
+ to {
+ transform: translateX(600px);
+ }
+}
+
+@keyframes shimmer {
+ from {
+ transform: translateX(-300px);
+ }
+ to {
+ transform: translateX(600px);
+ }
+}
+
+.section--sk {
+ display: flex;
+ gap: 20px;
+}
+.card--sk {
+ overflow: hidden;
+ position: relative;
+ min-width: 275px;
+ height: 260px;
+ border-radius: 16px;
+ background-color: #e0e0e0;
+}
+.card--sk::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ width: 300px;
+ height: 100%;
+ background-size: 300px;
+ background: linear-gradient(0.25turn, #e0e0e0 0%, #f5f5f5 50%, #e0e0e0 100%);
+ -webkit-animation: shimmer 1.2s infinite;
+ animation: shimmer 1.2s infinite;
+}
+@media (max-width: 1200px) {
+ //안밀리도록 추가함(캐러셀보다 화면 사이즈가 작아질때부터 적용해야함..)
+ .section--sk {
+ width: 100vw;
+ padding: 0 24px;
+ overflow: hidden;
+ }
+}
+//모바일
+@media (max-width: 767px) {
+ .section--sk {
+ padding: 0 20px;
+ }
+ .card--sk {
+ min-width: 208px;
+ height: 232px;
+ }
+}
diff --git a/src/components/Editor/Editor.jsx b/src/components/Editor/Editor.jsx
new file mode 100644
index 0000000..c611de7
--- /dev/null
+++ b/src/components/Editor/Editor.jsx
@@ -0,0 +1,70 @@
+import { useMemo, useEffect, useRef } from 'react';
+import ReactQuill from 'react-quill';
+import 'react-quill/dist/quill.snow.css';
+import styles from './Editor.module.scss';
+
+const formats = [
+ 'bold',
+ 'italic',
+ 'underline',
+ 'list',
+ 'link',
+ 'color',
+ 'background',
+];
+
+export default function Editor({ value, onChange, font, onBlur, isError }) {
+ const quillRef = useRef();
+
+ const modules = useMemo(() => {
+ return {
+ toolbar: [
+ ['bold', 'italic', 'underline'],
+ [{ color: [] }, { background: [] }],
+ [{ list: 'ordered' }, { list: 'bullet' }],
+ ['link'],
+ ],
+ };
+ }, []);
+
+ useEffect(() => {
+ const editorRoot = quillRef.current?.editor?.root;
+ if (!editorRoot) return;
+
+ editorRoot.classList.remove(
+ 'font-나눔명조',
+ 'font-나눔손글씨손편지체',
+ 'font-Pretendard',
+ 'font-NotoSans',
+ );
+
+ if (font) {
+ const fontClassName = `font-${font.replace(/\s/g, '')}`;
+ editorRoot.classList.add(fontClassName);
+ }
+ }, [font]);
+
+ return (
+
+
내용을 입력해 주세요
+
+
+
+ {isError && (
+
값을 입력해 주세요.
+ )}
+
+
+ );
+}
diff --git a/src/components/Editor/Editor.module.scss b/src/components/Editor/Editor.module.scss
new file mode 100644
index 0000000..385837e
--- /dev/null
+++ b/src/components/Editor/Editor.module.scss
@@ -0,0 +1,88 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.editor {
+ display: flex;
+ flex-direction: column;
+ max-width: 720px;
+ min-width: 320px;
+ width: 100%;
+ gap: 12px;
+
+ &__box {
+ height: 260px;
+ margin-bottom: 4px;
+ border: 1px solid $gray-300;
+ border-radius: 8px;
+
+ &--error {
+ border: 1px solid $error;
+ color: $gray-900;
+ }
+ }
+
+ :global(.ql-toolbar) {
+ display: flex;
+ align-items: center;
+ height: 49px;
+ border: none;
+ border-bottom: 1px;
+ border-top-right-radius: 8px;
+ border-top-left-radius: 8px;
+ background-color: $gray-200;
+ }
+
+ :global(.ql-container) {
+ max-width: 688px;
+ min-width: 288px;
+ height: 178px;
+ margin: 16px;
+ border: none;
+ }
+
+ &__title {
+ margin: 0;
+ @include font-24-bold;
+ color: $gray-900;
+ }
+
+ &__error-message {
+ @include font-12-regular;
+ color: $error;
+ }
+
+ @media (max-width: 767px) {
+ & {
+ max-width: 320px;
+ }
+ :global(.ql-toolbar button),
+ :global(.ql-toolbar .ql-picker) {
+ width: 24px;
+ }
+
+ :global(.ql-toolbar .ql-color-picker),
+ :global(.ql-toolbar .ql-icon-picker) {
+ width: 24px;
+ }
+ }
+}
+
+:global(.ql-editor) {
+ font-family: 'Noto Sans', sans-serif;
+ @include font-18-regular;
+}
+
+:global(.ql-editor.font-나눔명조) {
+ font-family: '나눔명조', serif;
+}
+
+:global(.ql-editor.font-나눔손글씨손편지체) {
+ font-family: '나눔손글씨 손편지체', cursive;
+}
+
+:global(.ql-editor.font-Pretendard) {
+ font-family: 'Pretendard', sans-serif;
+}
+
+:global(.ql-editor.font-NotoSans) {
+ font-family: 'Noto Sans', sans-serif;
+}
diff --git a/src/components/FontSelect/FontSelect.jsx b/src/components/FontSelect/FontSelect.jsx
new file mode 100644
index 0000000..342d599
--- /dev/null
+++ b/src/components/FontSelect/FontSelect.jsx
@@ -0,0 +1,57 @@
+import { useRef, useId } from 'react';
+import useDetectClose from '../../hooks/useDetectClose';
+import styles from './FontSelect.module.scss';
+
+const FONTS = ['Noto Sans', 'Pretendard', '나눔명조', '나눔손글씨 손편지체'];
+
+const fontClassMap = {
+ 'Noto Sans': styles['font-noto'],
+ Pretendard: styles['font-pretendard'],
+ 나눔명조: styles['font-nanum-myeongjo'],
+ '나눔손글씨 손편지체': styles['font-naunm-hand'],
+};
+
+export default function FontSelect({ value = 'Noto Sans', onChange }) {
+ const dropdownRef = useRef(null);
+ const id = useId();
+ const [isOpen, setIsOpen] = useDetectClose(dropdownRef);
+
+ const handleSelect = (font) => {
+ onChange?.(font);
+ setIsOpen(false);
+ };
+
+ return (
+
+
+
+
+ {isOpen && (
+
+ {FONTS.map((font) => (
+ - handleSelect(font)}
+ >
+ {font}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/FontSelect/FontSelect.module.scss b/src/components/FontSelect/FontSelect.module.scss
new file mode 100644
index 0000000..db52be3
--- /dev/null
+++ b/src/components/FontSelect/FontSelect.module.scss
@@ -0,0 +1,103 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.dropdown {
+ display: flex;
+ flex-direction: column;
+ width: 320px;
+ gap: 12px;
+
+ &__label {
+ @include font-24-bold;
+ color: $gray-900;
+ }
+
+ &__button {
+ all: unset;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ position: relative;
+ width: 286px;
+ height: 24px;
+ padding: 12px 16px;
+ border: 1px solid $gray-300;
+ border-radius: 8px;
+ @include font-16-regular;
+ color: $gray-500;
+
+ &:active {
+ border: 2px solid $gray-500;
+ color: $gray-900;
+ }
+
+ &:hover {
+ border: 1px solid $gray-500;
+ }
+
+ &:focus {
+ border: 2px solid $gray-500;
+ color: $gray-900;
+ }
+ }
+
+ &__arrow {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ background-image: url('/src/assets/images/arrow.svg');
+ background-size: contain;
+ background-repeat: no-repeat;
+ }
+
+ &__arrow.open {
+ transform: rotate(180deg);
+ }
+
+ &__list {
+ display: flex;
+ justify-content: space-between;
+ flex-direction: column;
+ position: static;
+ width: 318px;
+ height: 220px;
+ margin-top: 8px;
+ padding: 10px 1px;
+ border: 1px solid $gray-300;
+ border-radius: 8px;
+ background-color: $white;
+ list-style: none;
+ z-index: 1;
+ }
+
+ &__item {
+ width: 318px;
+ padding: 12px 16px;
+ @include font-16-regular;
+ color: $gray-900;
+
+ &:hover {
+ background-color: $gray-100;
+ }
+ }
+ @media (max-width: 767px) {
+ &__list {
+ position: absolute;
+ }
+ }
+}
+
+.font-noto {
+ font-family: 'Noto Sans', sans-serif;
+}
+
+.font-pretendard {
+ font-family: 'Pretendard', sans-serif;
+}
+
+.font-nanum-myeongjo {
+ font-family: 'Nanum Myeongjo', serif;
+}
+
+.font-naunm-hand {
+ font-family: 'NanumSonPyeonjiCe', cursive;
+}
diff --git a/src/components/Modal/Modal.jsx b/src/components/Modal/Modal.jsx
new file mode 100644
index 0000000..694e5d6
--- /dev/null
+++ b/src/components/Modal/Modal.jsx
@@ -0,0 +1,76 @@
+import { useRef, useState, useEffect, useCallback } from 'react';
+import DOMPurify from 'dompurify';
+import ReactDOM from 'react-dom';
+import Badge from '../Badge/Badge';
+import Button from '../common/Button';
+import useModalClose from '../../hooks/useModalClose'; // 경로에 맞게 수정
+import styles from './Modal.module.scss';
+
+export default function Modal({
+ image,
+ sender,
+ relationship,
+ children,
+ font,
+ createdAt,
+ onClose,
+}) {
+ const modalRef = useRef(null);
+ const [isClosing, setIsClosing] = useState(false);
+ const sanitizedHTML = DOMPurify.sanitize(children);
+
+ // 닫기 로직 (애니메이션 포함)
+ const handleCloseModal = useCallback(() => {
+ setIsClosing(true);
+ setTimeout(() => {
+ onClose();
+ }, 300);
+ }, [onClose]);
+
+ // 외부 클릭 감지하여 닫기
+ useModalClose(modalRef, handleCloseModal);
+
+ return ReactDOM.createPortal(
+
+
+
+
+

+
+
+ {createdAt}
+
+
+
+
+
+
+
,
+ document.getElementById('modal-root'),
+ );
+}
diff --git a/src/components/Modal/Modal.module.scss b/src/components/Modal/Modal.module.scss
new file mode 100644
index 0000000..8bf53c7
--- /dev/null
+++ b/src/components/Modal/Modal.module.scss
@@ -0,0 +1,160 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.backdrop {
+ display: flex;
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 999;
+ width: 100vw;
+ height: 100vh;
+ background-color: rgba(0, 0, 0, 0.6);
+ justify-content: center;
+ align-items: center;
+}
+
+.modal {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ z-index: 1000;
+ width: 600px;
+ height: 476px;
+ padding: 40px;
+ border-radius: 20px;
+ background-color: $white;
+ transform: translate(-50%, -50%);
+ box-shadow: 0px 2px 12px 0px rgba(0, 0, 0, 0.08);
+ gap: 16px;
+ animation: scaleIn 0.5s ease-out;
+
+ &.closing {
+ animation: scaleOut 0.5s forwards;
+ }
+
+ &__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid $gray-200;
+ }
+
+ &__profile-img {
+ position: relative;
+ width: 56px;
+ height: 56px;
+ border: 1px solid $gray-200;
+ border-radius: 100px;
+ overflow: hidden;
+
+ img {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+ }
+
+ &__user-info {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ gap: 6px;
+ }
+
+ &__profile-name {
+ display: -webkit-box;
+ overflow: hidden;
+ @include font-20-regular;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 1;
+ line-clamp: 1;
+ word-break: break-all;
+ span {
+ @include font-20-bold;
+ }
+ }
+
+ &__date {
+ @include font-14-regular;
+ color: $gray-400;
+ }
+
+ &__body {
+ height: 240px;
+ overflow-y: auto;
+ }
+
+ &__body::-webkit-scrollbar {
+ width: 4px;
+ }
+
+ &__body::-webkit-scrollbar-thumb {
+ background-color: $gray-300;
+ border-radius: 4px;
+ }
+
+ &__content {
+ @include font-18-regular;
+ color: $gray-500;
+ word-break: break-word;
+ }
+
+ &__footer {
+ padding-top: 8px;
+ margin: 0 auto;
+ }
+
+ &__button {
+ align-self: center;
+ }
+}
+
+@keyframes scaleIn {
+ 0% {
+ transform: translate(0%, -50%) scale(0.5);
+ opacity: 0;
+ }
+ 100% {
+ transform: translate(-50%, -50%) scale(1);
+ opacity: 1;
+ }
+}
+
+@keyframes scaleOut {
+ 0% {
+ transform: translate(-50%, -50%) scale(1);
+ opacity: 1;
+ }
+ 100% {
+ transform: translate(-100%, -50%) scale(0.5);
+ opacity: 0;
+ }
+}
+
+@media (max-width: 767px) {
+ .modal {
+ width: calc(100% - 48px);
+ }
+}
+
+:global(.font-나눔명조) {
+ font-family: '나눔명조', serif;
+}
+
+:global(.font-나눔손글씨손편지체) {
+ font-family: '나눔손글씨 손편지체', cursive;
+}
+
+:global(.font-Pretendard) {
+ font-family: 'Pretendard', sans-serif;
+}
+
+:global(.font-NotoSans) {
+ font-family: 'Noto Sans', sans-serif;
+}
diff --git a/src/components/RecentMessages/RecentMessages.jsx b/src/components/RecentMessages/RecentMessages.jsx
new file mode 100644
index 0000000..452a8f2
--- /dev/null
+++ b/src/components/RecentMessages/RecentMessages.jsx
@@ -0,0 +1,19 @@
+import styles from './RecentMessages.module.scss';
+
+//카드 내부 요소 컴포넌트(동그라미 배열-최신메세지)
+export default function RecentMessages({ messages, count }) {
+ return (
+
+ {messages.map((message) => (
+

+ ))}
+ {count > 3 ? (
+
+{count > 102 ? 99 : count - 3}
+ ) : null}
+
+ );
+}
diff --git a/src/components/RecentMessages/RecentMessages.module.scss b/src/components/RecentMessages/RecentMessages.module.scss
new file mode 100644
index 0000000..034aca6
--- /dev/null
+++ b/src/components/RecentMessages/RecentMessages.module.scss
@@ -0,0 +1,25 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.card__recent-messages {
+ display: flex;
+ margin: 12px 0 12px 13px;
+}
+.img {
+ width: 28px;
+ height: 28px;
+ margin-left: -13px;
+ border-radius: 30px;
+ border: solid 1.5px white;
+}
+.count {
+ @include font-12-regular;
+ display: grid;
+ place-items: center;
+ width: 33px;
+ height: 28px;
+ margin-left: -13px;
+ border-radius: 30px;
+ background-color: white;
+ color: $gray-500;
+ line-height: 18px;
+}
diff --git a/src/components/RecipientCard/RecipientCard.jsx b/src/components/RecipientCard/RecipientCard.jsx
new file mode 100644
index 0000000..222b1a8
--- /dev/null
+++ b/src/components/RecipientCard/RecipientCard.jsx
@@ -0,0 +1,76 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import styles from './RecipientCard.module.scss';
+import RecentMessages from '../RecentMessages/RecentMessages';
+import TopReactions from '../TopReactions/TopReactions';
+
+export default function RecipientCard({ Recipient }) {
+ const {
+ id,
+ name,
+ recentMessages,
+ messageCount,
+ topReactions,
+ backgroundColor,
+ backgroundImageURL,
+ } = Recipient;
+ const navigate = useNavigate();
+ const [isDragging, setIsDragging] = useState(false);
+ const [startX, setStartX] = useState(null);
+
+ function handleCardClick() {
+ if (!isDragging) {
+ navigate(`/post/${id}`);
+ }
+ }
+ function handleStart(e) {
+ const x = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
+ setStartX(x);
+ setIsDragging(false);
+ }
+
+ function handleMove(e) {
+ if (startX === null) return;
+ const x = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
+ const distance = Math.abs(x - startX);
+ if (distance >= 10) {
+ setIsDragging(true);
+ }
+ }
+
+ return (
+
+ {!backgroundImageURL && backgroundColor === 'blue' && (
+
+ )}
+
+ {`To. ${name}`}
+
+
+
+ {messageCount}
+ 명이 작성했어요!
+
+
+
+
+ );
+}
diff --git a/src/components/RecipientCard/RecipientCard.module.scss b/src/components/RecipientCard/RecipientCard.module.scss
new file mode 100644
index 0000000..4c056e8
--- /dev/null
+++ b/src/components/RecipientCard/RecipientCard.module.scss
@@ -0,0 +1,149 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.card__h3 {
+ @include font-24-bold;
+ color: $gray-900;
+}
+.card__writer-count {
+ @include font-16-regular;
+ color: $gray-700;
+}
+.card__count {
+ @include font-16-bold;
+}
+.card__centerline {
+ height: 1px;
+ margin: 41px 0px 16px 0px;
+ background-color: rgba(0, 0, 0, 0.12);
+}
+.white {
+ color: white;
+}
+
+.card {
+ position: relative;
+ overflow: hidden; // ::after 도형 잘리게
+ min-width: 275px;
+ height: 260px;
+ padding: 30px 24px 20px 24px;
+ border: solid 1px rgba(0, 0, 0, 0.1);
+ border-radius: 16px;
+ background-size: cover;
+ background-repeat: no-repeat;
+ -webkit-filter: drop-shadow(0px 4px 12px rgba(0, 0, 0, 1));
+ filter: drop-shadow(0px 4px 10px rgba(0, 0, 0, 0.08));
+ transition: transform 0.25s ease;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-use-select: none;
+ -ms-user-select: none;
+ user-select: none;
+
+ &::after {
+ content: '';
+ position: absolute;
+ z-index: -1;
+ }
+ &.purple {
+ background: $purple-200;
+ }
+ &.purple::after {
+ top: 124px;
+ left: 133px;
+ width: 336px;
+ height: 169px;
+ border-radius: 90.5px;
+ background: #dcb9ff66;
+ }
+ &.beige {
+ background-color: $beige-200;
+ }
+ &.beige::after {
+ top: 124px;
+ left: 154px;
+ width: 332px;
+ height: 318px;
+ border-radius: 51px;
+ background: #ffd382b2;
+ }
+ &.blue {
+ background-color: $blue-200;
+ }
+ .triangle {
+ position: absolute;
+ top: 82px;
+ left: 110px;
+ width: 250px;
+ background-color: #9dddff;
+ z-index: -1;
+ --r: 35px; //border radius
+ aspect-ratio: 1 / cos(30deg);
+ --_g: calc(tan(60deg) * var(--r)) bottom var(--r), #000 98%, #0000 101%;
+ -webkit-mask: //conic gradient: 중앙 영역 채움, radial gradients: 세 꼭짓점
+ conic-gradient(
+ from -30deg at 50% calc(200% - 3 * var(--r) / 2),
+ #000 60deg,
+ #0000 0
+ )
+ 0 100%/100% calc(100% - 3 * var(--r) / 2) no-repeat,
+ radial-gradient(var(--r) at 50% calc(2 * var(--r)), #000 98%, #0000 101%),
+ radial-gradient(var(--r) at left var(--_g)),
+ radial-gradient(var(--r) at right var(--_g));
+ mask:
+ conic-gradient(
+ from -30deg at 50% calc(200% - 3 * var(--r) / 2),
+ #000 60deg,
+ #0000 0
+ )
+ 0 100%/100% calc(100% - 3 * var(--r) / 2) no-repeat,
+ radial-gradient(var(--r) at 50% calc(2 * var(--r)), #000 98%, #0000 101%),
+ radial-gradient(var(--r) at left var(--_g)),
+ radial-gradient(var(--r) at right var(--_g));
+ // 3포인트 폴리곤
+ -webkit-clip-path: polygon(50% 0, 100% 100%, 0 100%);
+ clip-path: polygon(50% 0, 100% 100%, 0 100%);
+ }
+ &.green {
+ background-color: $green-200;
+ }
+ &.green::after {
+ top: 124px;
+ left: 133px;
+ width: 336px;
+ height: 169px;
+ border-radius: 90.5px;
+ background: #9be2824d;
+ }
+
+ &:hover {
+ transform: scale(0.96) translateZ(0);
+ }
+}
+@media (max-width: 767px) {
+ .card {
+ min-width: 208px;
+ height: 232px;
+ }
+ .card__centerline {
+ margin-top: 30px;
+ }
+ .card__h3 {
+ @include font-18-bold;
+ }
+ .card__writer-count {
+ @include font-14-regular;
+ }
+ .card__count {
+ font-size: 1.4rem;
+ }
+ .card.purple::after,
+ .card.green::after,
+ .card.beige::after {
+ top: 124px;
+ left: 100px;
+ }
+ .card .triangle {
+ top: 90px;
+ left: 52px;
+ }
+}
diff --git a/src/components/RelationshipSelect/RelationshipSelect.jsx b/src/components/RelationshipSelect/RelationshipSelect.jsx
new file mode 100644
index 0000000..fd50c30
--- /dev/null
+++ b/src/components/RelationshipSelect/RelationshipSelect.jsx
@@ -0,0 +1,50 @@
+import { useState, useRef, useId } from 'react';
+import useDetectClose from '../../hooks/useDetectClose';
+import styles from './RelationshipSelect.module.scss';
+
+const OPTIONS = ['친구', '지인', '동료', '가족'];
+
+export default function RelationshipSelect({ value = '지인', onChange }) {
+ const dropdownRef = useRef(null);
+ const id = useId();
+ const [isOpen, setIsOpen] = useDetectClose(dropdownRef);
+
+ const handleSelect = (option) => {
+ onChange?.(option);
+ setIsOpen(false);
+ };
+
+ return (
+
+
+
+
+ {isOpen && (
+
+ {OPTIONS.map((option) => (
+ - handleSelect(option)}
+ >
+ {option}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/RelationshipSelect/RelationshipSelect.module.scss b/src/components/RelationshipSelect/RelationshipSelect.module.scss
new file mode 100644
index 0000000..a3e2d2f
--- /dev/null
+++ b/src/components/RelationshipSelect/RelationshipSelect.module.scss
@@ -0,0 +1,82 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.dropdown {
+ display: flex;
+ flex-direction: column;
+ width: 320px;
+ gap: 12px;
+
+ &__label {
+ @include font-24-bold;
+ color: $gray-900;
+ }
+
+ &__button {
+ all: unset;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ position: relative;
+ width: 286px;
+ height: 24px;
+ padding: 12px 16px;
+ border: 1px solid $gray-300;
+ border-radius: 8px;
+ @include font-16-regular;
+ color: $gray-500;
+
+ &:active {
+ border: 2px solid $gray-500;
+ color: $gray-900;
+ }
+
+ &:hover {
+ border: 1px solid $gray-500;
+ }
+
+ &:focus {
+ border: 2px solid $gray-500;
+ color: $gray-900;
+ }
+ }
+
+ &__arrow {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ background-image: url('/src/assets/images/arrow.svg');
+ background-size: contain;
+ background-repeat: no-repeat;
+ }
+
+ &__arrow.open {
+ transform: rotate(180deg);
+ }
+
+ &__list {
+ display: flex;
+ justify-content: space-between;
+ flex-direction: column;
+ position: absolute;
+ width: 318px;
+ height: 220px;
+ margin-top: 8px;
+ padding: 10px 1px;
+ border: 1px solid $gray-300;
+ border-radius: 8px;
+ background-color: $white;
+ z-index: 1;
+ list-style: none;
+ }
+
+ &__item {
+ width: 318px;
+ padding: 12px 16px;
+ @include font-16-regular;
+ color: $gray-900;
+
+ &:hover {
+ background-color: $gray-100;
+ }
+ }
+}
diff --git a/src/components/TopReactions/TopReactions.jsx b/src/components/TopReactions/TopReactions.jsx
new file mode 100644
index 0000000..3fc355a
--- /dev/null
+++ b/src/components/TopReactions/TopReactions.jsx
@@ -0,0 +1,15 @@
+import styles from './TopReactions.module.scss';
+
+//카드 내부 요소 컴포넌트(반응 이모티콘 배열)
+export default function TopReactions({ reactions }) {
+ return (
+
+ {reactions.map((reaction) => (
+
+ {reaction.emoji}
+ {reaction.count}
+
+ ))}
+
+ );
+}
diff --git a/src/components/TopReactions/TopReactions.module.scss b/src/components/TopReactions/TopReactions.module.scss
new file mode 100644
index 0000000..fd82679
--- /dev/null
+++ b/src/components/TopReactions/TopReactions.module.scss
@@ -0,0 +1,25 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.card__reactions {
+ display: flex;
+ gap: 8px;
+}
+.card__reactions__reaction {
+ @include font-16-regular;
+ display: flex;
+ gap: 2px;
+ padding: 5px 12px;
+ border-radius: 30px;
+ background-color: rgba(0, 0, 0, 0.54);
+ color: white;
+ z-index: 1;
+}
+@media (max-width: 767px) {
+ .card__reactions {
+ gap: 4px;
+ }
+ .card__reactions__reaction {
+ @include font-14-regular;
+ padding: 5px 8px;
+ }
+}
diff --git a/src/components/UserProfileSelector/UserProfileSelector.jsx b/src/components/UserProfileSelector/UserProfileSelector.jsx
new file mode 100644
index 0000000..c30dc28
--- /dev/null
+++ b/src/components/UserProfileSelector/UserProfileSelector.jsx
@@ -0,0 +1,104 @@
+import { useEffect, useState, useRef } from 'react';
+import getProfileImages from '../../api/getProfileImages.js';
+import uploadImage from '../../api/postUpload.js';
+import DEFAULT_PROFILE_IMAGE from '../../constants/image.js';
+import UploadProgressBar from '../common/UploadProgressBar.jsx';
+import styles from './UserProfileSelector.module.scss';
+
+export default function UserProfileSelector({
+ value = DEFAULT_PROFILE_IMAGE,
+ onSelect,
+ isUploading,
+ setIsUploading,
+}) {
+ const [profileImages, setProfileImages] = useState([]);
+ const [loadedImages, setLoadedImages] = useState({});
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const fileInput = useRef(null);
+
+ useEffect(() => {
+ const loadImages = async () => {
+ const images = await getProfileImages();
+ setProfileImages(images);
+ };
+ loadImages();
+ }, []);
+
+ const handleImageLoad = (url) => {
+ setLoadedImages((prev) => ({ ...prev, [url]: true }));
+ };
+
+ const handleUpload = async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ e.target.value = null;
+
+ setIsUploading(true);
+
+ try {
+ const uploadedUrl = await uploadImage(file, (percent) => {
+ setUploadProgress(percent);
+ });
+
+ onSelect?.(uploadedUrl);
+ } catch (error) {
+ alert('이미지 업로드 실패했습니다.');
+ } finally {
+ setIsUploading(false);
+ setUploadProgress(0);
+ }
+ };
+
+ const triggerFileSeletor = () => {
+ fileInput.current?.click();
+ };
+
+ return (
+
+
프로필 이미지
+
+
+

+
+ {isUploading &&
}
+
+
+
+
+
+ 프로필을 클릭해 업로드해 주세요!
+
+
+ {profileImages.map((url, idx) => (
+

{
+ if (!isUploading) onSelect?.(url);
+ }}
+ onLoad={() => handleImageLoad(url)}
+ />
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/UserProfileSelector/UserProfileSelector.module.scss b/src/components/UserProfileSelector/UserProfileSelector.module.scss
new file mode 100644
index 0000000..da8bd1b
--- /dev/null
+++ b/src/components/UserProfileSelector/UserProfileSelector.module.scss
@@ -0,0 +1,121 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.profile-select {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ max-width: 717px;
+ min-width: 320px;
+ gap: 12px;
+
+ &__title {
+ max-width: 129px;
+ margin: 0;
+ @include font-24-bold;
+ color: $gray-900;
+ white-space: nowrap;
+ }
+
+ &__selected-image {
+ width: 80px;
+ height: 80px;
+ border-radius: 100px;
+ cursor: pointer;
+ }
+
+ &__selected-wrapper {
+ position: relative;
+ }
+
+ &__content {
+ display: flex;
+ align-items: center;
+ gap: 32px;
+ }
+
+ &__right {
+ display: flex;
+ flex-direction: column;
+ width: 605px;
+ gap: 12px;
+ }
+
+ &__description {
+ width: 190px;
+ @include font-16-regular;
+ color: $gray-500;
+
+ white-space: nowrap;
+ }
+ &__image-list {
+ display: flex;
+ width: 100%;
+ gap: 4px;
+ }
+
+ &__image--disabled {
+ pointer-events: none;
+ opacity: 0.4;
+ filter: grayscale(60%);
+ }
+
+ &__image {
+ width: 56px;
+ border: 1px solid $gray-200;
+ border-radius: 100px;
+ transition: transform 0.1s ease-in-out;
+ cursor: pointer;
+
+ &:hover {
+ transform: scale(1.15) translateY(-6px) translateZ(0);
+ }
+
+ &:active {
+ transform: scale(1) translateY(0) translateZ(0);
+ }
+ }
+
+ &__image--selected {
+ transform: scale(1.2) translateY(-6px) translateZ(0);
+ box-shadow: 0 0 0 2px $gray-400;
+ }
+
+ &__image--loading {
+ background: linear-gradient(270deg, $gray-200, $gray-300, $gray-400);
+ background-size: 400% 400%;
+ animation: loadingSpin 1.2s infinite linear;
+ }
+
+ @keyframes loadingSpin {
+ 0% {
+ background-position: 0% 50%;
+ }
+ 100% {
+ background-position: 100% 50%;
+ }
+ }
+
+ @media (max-width: 767px) {
+ & {
+ max-width: 320px;
+ }
+
+ &__right {
+ max-width: 208px;
+ }
+
+ &__image-list {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ column-gap: 2px;
+ row-gap: 4px;
+ }
+
+ &__image,
+ &__image--loading {
+ width: 40px;
+ height: 40px;
+ background-size: 400% 400%;
+ }
+ }
+}
diff --git a/src/components/common/Button.jsx b/src/components/common/Button.jsx
new file mode 100644
index 0000000..d52b0b9
--- /dev/null
+++ b/src/components/common/Button.jsx
@@ -0,0 +1,18 @@
+import styles from './Button.module.scss';
+
+export default function Button({
+ children,
+ type = 'primary',
+ onClick,
+ disabled,
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/common/Button.module.scss b/src/components/common/Button.module.scss
new file mode 100644
index 0000000..c1740ef
--- /dev/null
+++ b/src/components/common/Button.module.scss
@@ -0,0 +1,91 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.button {
+ width: 100%;
+ height: 56px;
+ padding: 14px 24px;
+ border: none;
+ border-radius: 12px;
+ background-color: $purple-600;
+ @include font-18-bold;
+ color: $white;
+ transition:
+ transform 0.2s ease-in-out,
+ background-color 0.2s ease-in-out;
+
+ &:disabled {
+ background-color: $gray-300;
+ cursor: not-allowed;
+ filter: opacity(50%);
+
+ &:hover,
+ &:active,
+ &:focus {
+ background-color: $gray-300;
+ border: $gray-300;
+ transform: none;
+ }
+ }
+
+ &:hover {
+ transform: scale(1.05) translateZ(0);
+ background-color: $purple-700;
+ }
+
+ &:active {
+ transform: scale(0.97) translateZ(0);
+ background-color: $purple-800;
+ }
+
+ &:focus-visible {
+ border: 2px solid $purple-900;
+ background-color: $purple-800;
+ }
+
+ &--primary {
+ display: block;
+ max-width: 280px;
+ min-width: 320px;
+ margin: 0 auto 24px;
+ }
+
+ &--confirm {
+ width: 120px;
+ height: 40px;
+ padding: 7px 16px;
+ border-radius: 6px;
+ @include font-16-regular;
+ }
+
+ &--delete {
+ width: inherit;
+ height: 39px;
+ padding: 7px 16px;
+ border-radius: 6px;
+ @include font-16-regular;
+ }
+
+ @media (min-width: 768px) and (max-width: 1023px) {
+ &--primary,
+ &--delete {
+ min-width: 720px;
+ }
+
+ &--primary {
+ margin-bottom: 24px;
+ }
+ }
+
+ @media (max-width: 767px) {
+ &--primary {
+ margin-bottom: 24px;
+ }
+ }
+
+ @media (max-width: 1023px) {
+ &--delete {
+ width: 100%;
+ height: 55px;
+ }
+ }
+}
diff --git a/src/components/common/FormInput.jsx b/src/components/common/FormInput.jsx
new file mode 100644
index 0000000..3caa8c1
--- /dev/null
+++ b/src/components/common/FormInput.jsx
@@ -0,0 +1,34 @@
+import styles from './FormInput.module.scss';
+import { useId } from 'react';
+
+export default function FormInput({
+ label,
+ placeholder,
+ value,
+ onChange,
+ onBlur,
+ isError,
+}) {
+ const id = useId();
+ return (
+
+
+
+ {isError && (
+
+ 값을 입력해 주세요.
+
+ )}
+
+ );
+}
diff --git a/src/components/common/FormInput.module.scss b/src/components/common/FormInput.module.scss
new file mode 100644
index 0000000..32468cd
--- /dev/null
+++ b/src/components/common/FormInput.module.scss
@@ -0,0 +1,75 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.form-input {
+ display: flex;
+ flex-direction: column;
+ max-width: 720px;
+ min-width: 320px;
+ width: 100%;
+ height: 98px;
+
+ &__label {
+ @include font-24-bold;
+ color: $gray-900;
+ white-space: nowrap;
+ }
+
+ &__input {
+ width: 100%;
+ margin: 12px 0 4px;
+ padding: 12px 16px;
+ border: 1px solid $gray-300;
+ border-radius: 8px;
+ background-color: $white;
+ @include font-16-regular;
+ color: $gray-500;
+
+ &:disabled {
+ border: 1px solid $gray-300;
+ background-color: $gray-100;
+ color: $gray-400;
+
+ &:hover,
+ &:active,
+ &:focus {
+ border: 1px solid $gray-300;
+ background-color: $gray-100;
+ color: $gray-400;
+ }
+ }
+
+ &:active {
+ border: 2px solid $gray-700;
+ color: $gray-900;
+ }
+
+ &:hover {
+ border: 1px solid $gray-500;
+ }
+
+ &:focus {
+ border: 2px solid $gray-500;
+ color: $gray-900;
+ }
+ &:focus-visible {
+ border: 2px solid $gray-500;
+ color: $gray-900;
+ outline: none;
+ }
+ &--error {
+ border: 1px solid $error;
+ color: $gray-900;
+ }
+ }
+
+ &__error-message {
+ @include font-12-regular;
+ color: $error;
+ }
+
+ @media (max-width: 767px) {
+ & {
+ min-width: 320px;
+ }
+ }
+}
diff --git a/src/components/common/LoadingSpinner.jsx b/src/components/common/LoadingSpinner.jsx
new file mode 100644
index 0000000..b3d1250
--- /dev/null
+++ b/src/components/common/LoadingSpinner.jsx
@@ -0,0 +1,10 @@
+import styles from './LoadingSpinner.module.scss';
+
+export default function LoadingSpinner() {
+ return (
+
+ );
+}
diff --git a/src/components/common/LoadingSpinner.module.scss b/src/components/common/LoadingSpinner.module.scss
new file mode 100644
index 0000000..2b3f36c
--- /dev/null
+++ b/src/components/common/LoadingSpinner.module.scss
@@ -0,0 +1,37 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.spinnerWrapper {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ z-index: 9999;
+ background: rgba(255, 255, 255, 0.5);
+}
+
+.spinner {
+ width: 40px;
+ height: 40px;
+ border: 4px solid #eee;
+ border-top: 4px solid $purple-600;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-bottom: 12px;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.text {
+ color: $purple-600;
+ font-size: 1.1rem;
+ font-weight: 500;
+}
diff --git a/src/components/common/Toast/Toast.jsx b/src/components/common/Toast/Toast.jsx
new file mode 100644
index 0000000..c454509
--- /dev/null
+++ b/src/components/common/Toast/Toast.jsx
@@ -0,0 +1,42 @@
+import { useEffect } from 'react';
+import styles from './Toast.module.scss';
+import successIcon from '../../../assets/images/success.svg';
+import closeIcon from '../../../assets/images/close.svg';
+
+export default function Toast({
+ isVisible,
+ message,
+ onClose,
+ duration = 5000,
+ type = 'success',
+}) {
+ useEffect(() => {
+ if (isVisible) {
+ const timer = setTimeout(() => {
+ onClose();
+ }, duration);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isVisible, onClose, duration]);
+
+ if (!isVisible) return null;
+
+ return (
+
+
+ {type === 'success' && (
+

+ )}
+
+
{message}
+
+
+ );
+}
diff --git a/src/components/common/Toast/Toast.module.scss b/src/components/common/Toast/Toast.module.scss
new file mode 100644
index 0000000..9574845
--- /dev/null
+++ b/src/components/common/Toast/Toast.module.scss
@@ -0,0 +1,63 @@
+@use '../../../assets/styles/variables.scss' as *;
+
+.toast {
+ position: fixed;
+ bottom: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ background-color: #383838;
+ color: white;
+ padding: 12px 16px;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ z-index: 9999;
+ max-width: 90%;
+ min-width: 320px;
+ text-align: left;
+ animation: fadeIn 0.3s ease-out;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 8px;
+}
+
+.iconContainer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.icon {
+ width: 24px;
+ height: 24px;
+}
+
+.content {
+ flex: 1;
+ word-break: break-word;
+ font-size: 16px;
+ font-weight: 500;
+ line-height: 1.5;
+ margin: 0 4px;
+}
+
+.closeButton {
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translate(-50%, 10px);
+ }
+ to {
+ opacity: 1;
+ transform: translate(-50%, 0);
+ }
+}
diff --git a/src/components/common/UploadProgressBar.jsx b/src/components/common/UploadProgressBar.jsx
new file mode 100644
index 0000000..c9b2c60
--- /dev/null
+++ b/src/components/common/UploadProgressBar.jsx
@@ -0,0 +1,11 @@
+import styles from './UploadProgressBar.module.scss';
+
+export default function UploadProgressBar({ progress = 0 }) {
+ return (
+
+ );
+}
diff --git a/src/components/common/UploadProgressBar.module.scss b/src/components/common/UploadProgressBar.module.scss
new file mode 100644
index 0000000..5e9d0bc
--- /dev/null
+++ b/src/components/common/UploadProgressBar.module.scss
@@ -0,0 +1,27 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.overlay {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ z-index: 10;
+ background-color: rgba(255, 255, 255, 0.6);
+}
+
+.bar {
+ overflow: hidden;
+ width: 60%;
+ height: 8px;
+ border-radius: 5px;
+ background-color: $gray-200;
+}
+
+.fill {
+ width: 0%;
+ height: 100%;
+ background-color: $purple-700;
+ transition: width 0.3s ease;
+}
diff --git a/src/components/layout/Header/Header.jsx b/src/components/layout/Header/Header.jsx
new file mode 100644
index 0000000..5c75bc2
--- /dev/null
+++ b/src/components/layout/Header/Header.jsx
@@ -0,0 +1,44 @@
+import { Link, useLocation } from 'react-router-dom';
+import { useState, useEffect } from 'react';
+import styles from './Header.module.scss';
+import logo from '../../../assets/images/rolling-logo.svg';
+
+const showButton = ['/', '/list'];
+
+export default function Header() {
+ const location = useLocation();
+ const isLocation = showButton.includes(location.pathname);
+
+ const [isMobile, setIsMobile] = useState(false);
+
+ useEffect(() => {
+ function handleResize() {
+ setIsMobile(window.innerWidth <= 767);
+ }
+
+ handleResize();
+ window.addEventListener('resize', handleResize);
+
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
+ if (isMobile && !isLocation) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+
+ {isLocation && (
+
+ 롤링 페이퍼 만들기
+
+ )}
+
+
+ >
+ );
+}
diff --git a/src/components/layout/Header/Header.module.scss b/src/components/layout/Header/Header.module.scss
new file mode 100644
index 0000000..d2a3e98
--- /dev/null
+++ b/src/components/layout/Header/Header.module.scss
@@ -0,0 +1,52 @@
+@use '../../../assets/styles/variables.scss' as *;
+
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ max-width: 1207px;
+ height: 64px;
+ margin: 0 auto;
+ padding: 0 24px;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-use-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+.underline {
+ height: 1px;
+ background-color: #ededed;
+}
+
+.logo {
+ width: 106px;
+ margin: 16px 0;
+}
+
+.button {
+ @include font-16-regular;
+ padding: 7px 16px;
+ border: 1px solid $gray-300;
+ border-radius: 6px;
+ white-space: nowrap;
+ color: inherit;
+ text-decoration: none;
+ &:hover {
+ background-color: $purple-100;
+ border: solid 1px $purple-300;
+ }
+ transition: 0.2s ease-in-out;
+}
+
+@media (min-width: 768px) and (max-width: 1023px) {
+ .header {
+ padding: 0 24px;
+ }
+}
+@media (max-width: 767px) {
+ .header {
+ padding: 0 16px;
+ }
+}
diff --git a/src/components/recipient/HeaderService/HeaderService.jsx b/src/components/recipient/HeaderService/HeaderService.jsx
new file mode 100644
index 0000000..10ad70a
--- /dev/null
+++ b/src/components/recipient/HeaderService/HeaderService.jsx
@@ -0,0 +1,293 @@
+import { useState, useEffect, useRef } from 'react';
+import styles from './HeaderService.module.scss';
+import emojiAdd from '../../../assets/images/emoji-add.svg';
+import chevronDown from '../../../assets/images/chevron-down.svg';
+import { fetchReactions, addReaction } from '../../../api/emojiReactions';
+import ShareButton from './Share/ShareButton';
+import { formatCount } from '../../../utils/numberFormat';
+
+function getProfileExtraCount(count) {
+ if (count > 99) return '99';
+ return count;
+}
+
+export default function HeaderService({ recipient }) {
+ const [reactions, setReactions] = useState([]);
+ const [showEmojiPicker, setShowEmojiPicker] = useState(false);
+ const [showAllEmojisDropdown, setShowAllEmojisDropdown] = useState(false);
+ const pendingEmojisRef = useRef([]);
+ const [pendingEmojis, setPendingEmojis] = useState([]);
+ const emojiPickerRef = useRef(null);
+ const emojiMoreRef = useRef(null);
+ const [isLargeScreen, setIsLargeScreen] = useState(true);
+
+ const commonEmojis = [
+ '😆',
+ '😉',
+ '😎',
+ '🥺',
+ '🤔',
+ '🤗',
+ '🤩',
+ '🤑',
+ '👍',
+ '❤️',
+ '🎉',
+ '👏',
+ '😊',
+ '🙌',
+ '💪',
+ '✨',
+ '👋',
+ '🤙',
+ '🙆',
+ '🙇',
+ '🙌',
+ '🙏',
+ '🤝',
+ '👀',
+ ];
+
+ useEffect(() => {
+ if (recipient?.id) {
+ fetchReactions(recipient.id)
+ .then((data) => {
+ setReactions(data);
+ })
+ .catch((error) => {
+ console.error('리액션 데이터 로딩 오류:', error);
+ });
+ }
+ }, [recipient?.id]);
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (
+ emojiPickerRef.current &&
+ !emojiPickerRef.current.contains(event.target)
+ ) {
+ setShowEmojiPicker(false);
+ }
+
+ if (
+ emojiMoreRef.current &&
+ !emojiMoreRef.current.contains(event.target)
+ ) {
+ setShowAllEmojisDropdown(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, []);
+
+ const toggleAllEmojisDropdown = () => {
+ setShowAllEmojisDropdown(!showAllEmojisDropdown);
+ };
+
+ const handleAddReaction = async (emoji) => {
+ if (!recipient?.id) return;
+ if (pendingEmojisRef.current.includes(emoji)) return;
+
+ pendingEmojisRef.current = [...pendingEmojisRef.current, emoji];
+ setPendingEmojis(pendingEmojisRef.current);
+
+ setReactions((prev) => {
+ const found = prev.find((r) => r.emoji === emoji);
+ if (found) {
+ return prev.map((r) =>
+ r.emoji === emoji ? { ...r, count: r.count + 1 } : r,
+ );
+ } else {
+ return [...prev, { id: `optimistic-${emoji}`, emoji, count: 1 }];
+ }
+ });
+
+ try {
+ await addReaction(recipient.id, emoji);
+ const updatedReactions = await fetchReactions(recipient.id);
+ setReactions(updatedReactions);
+ } catch (error) {
+ console.error(error);
+ setReactions((prev) => {
+ const found = prev.find((r) => r.emoji === emoji);
+ if (found && found.count === 1 && found.id?.startsWith('optimistic-')) {
+ return prev.filter((r) => r.emoji !== emoji);
+ } else {
+ return prev.map((r) =>
+ r.emoji === emoji ? { ...r, count: r.count - 1 } : r,
+ );
+ }
+ });
+ } finally {
+ pendingEmojisRef.current = pendingEmojisRef.current.filter(
+ (e) => e !== emoji,
+ );
+ setPendingEmojis(pendingEmojisRef.current);
+ setShowEmojiPicker(false);
+ setShowAllEmojisDropdown(false);
+ }
+ };
+
+ const topReactions = [...reactions]
+ .sort((a, b) => b.count - a.count)
+ .slice(0, 3);
+
+ useEffect(() => {
+ const checkScreenSize = () => {
+ setIsLargeScreen(window.innerWidth > 975);
+ };
+
+ checkScreenSize();
+
+ window.addEventListener('resize', checkScreenSize);
+
+ return () => window.removeEventListener('resize', checkScreenSize);
+ }, []);
+
+ return (
+
+
+
+
+ To. {recipient?.name || '받는사람 이름'}
+
+
+
+
+
+
+ {recipient?.recentMessages?.slice(0, 3).map((message, index) => (
+

+ ))}
+ {recipient?.messageCount > 3 && (
+
+ +{getProfileExtraCount(recipient.messageCount - 3)}
+
+ )}
+
+
+ {recipient?.messageCount || 0}명이 작성했어요!
+
+
+
+
+
+
+
+ {topReactions.length > 0 ? (
+ topReactions.map((reaction) => (
+
+ ))
+ ) : (
+ 아직 리액션이 없어요
+ )}
+
+
+
+ {showAllEmojisDropdown && (
+
+
+ {reactions.length > 0 ? (
+ [...reactions]
+ .sort((a, b) => b.count - a.count)
+ .slice(0, isLargeScreen ? 8 : 6)
+ .map((reaction) => (
+
+ ))
+ ) : (
+ 아직 리액션이 없어요
+ )}
+
+
+ )}
+
+
+
+
+
+
+ {showEmojiPicker && (
+
+ {commonEmojis.map((emoji, index) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/recipient/HeaderService/HeaderService.module.scss b/src/components/recipient/HeaderService/HeaderService.module.scss
new file mode 100644
index 0000000..6d4bc92
--- /dev/null
+++ b/src/components/recipient/HeaderService/HeaderService.module.scss
@@ -0,0 +1,459 @@
+@use '../../../assets/styles/variables.scss' as *;
+
+.header-service {
+ width: 100%;
+ background-color: $white;
+ border-bottom: 1px solid $gray-200;
+ padding: 13px 0;
+
+ @media (max-width: 767px) {
+ padding: 0;
+ border-bottom: none;
+ }
+
+ &__container {
+ width: 100%;
+ max-width: 1200px;
+ margin: 0 auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ @media (max-width: 1248px) {
+ padding: 0 24px;
+ }
+
+ @media (max-width: 767px) {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0;
+ padding: 0;
+ }
+ }
+
+ &__right-content {
+ display: flex;
+ align-items: center;
+ margin-left: 263px;
+
+ @media (max-width: 1248px) {
+ margin-left: 24px;
+ }
+
+ @media (max-width: 767px) {
+ width: 100%;
+ margin-left: 0;
+ padding: 16px;
+ }
+
+ @media (max-width: 420px) {
+ padding: 12px;
+ }
+ }
+
+ &__name-section {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+
+ @media (max-width: 767px) {
+ width: 100%;
+ padding: 16px;
+ border-bottom: 1px solid $gray-200;
+ }
+
+ @media (max-width: 420px) {
+ padding: 12px 16px;
+ }
+ }
+
+ &__recipient-name {
+ @include font-28-bold;
+ color: $gray-800;
+ margin: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ @media (max-width: 767px) {
+ font-size: 24px;
+ font-weight: bold;
+ }
+
+ @media (max-width: 420px) {
+ font-size: 22px;
+ }
+ }
+
+ &__profile-section {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ white-space: nowrap;
+
+ @media (max-width: 975px) {
+ display: none;
+ }
+ }
+
+ &__profile-images {
+ display: flex;
+ position: relative;
+ }
+
+ &__profile-image {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ border: 1px solid $white;
+ position: relative;
+
+ &:not(:first-child) {
+ margin-left: -10px;
+ }
+ }
+
+ &__additional-count {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ border: 1px solid #e3e3e3;
+ background-color: $white;
+ color: #484848;
+ margin-left: -12px;
+ position: relative;
+ z-index: 4;
+ @include font-12-regular;
+ }
+
+ &__message-count {
+ @include font-16-regular;
+ color: $gray-500;
+ }
+
+ &__divider {
+ &--left {
+ width: 1px;
+ height: 28px;
+ background-color: $gray-200;
+ margin: 0 28px;
+
+ @media (max-width: 975px) {
+ display: none;
+ }
+ }
+
+ &--right {
+ width: 1px;
+ height: 28px;
+ background-color: $gray-200;
+ margin: 0 13px;
+
+ @media (max-width: 360px) {
+ display: none;
+ }
+ }
+ }
+
+ &__actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ justify-content: space-between;
+ width: 100%;
+
+ @media (max-width: 420px) {
+ gap: 4px;
+ }
+
+ @media (max-width: 360px) {
+ gap: 3px;
+ }
+ }
+
+ &__emojis-left-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ &__emojis-right-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ @media (max-width: 420px) {
+ gap: 6px;
+ }
+
+ @media (max-width: 360px) {
+ gap: 4px;
+ }
+ }
+
+ &__emojis {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ border-radius: 32px;
+ padding: 8px 0;
+ @include font-16-regular;
+
+ @media (max-width: 767px) {
+ gap: 6px;
+ padding: 6px 0;
+ }
+
+ @media (max-width: 420px) {
+ gap: 4px;
+ padding: 4px 0;
+ }
+ }
+
+ &__emoji-item {
+ width: 63px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ white-space: nowrap;
+ background-color: #757575;
+ color: $white;
+ border: none;
+ padding: 8px 12px;
+ border-radius: 32px;
+ @include font-16-regular;
+ cursor: pointer;
+ transition:
+ transform 0.2s ease,
+ background-color 0.2s ease;
+ user-select: none;
+ -webkit-user-select: none;
+
+ &:hover {
+ background-color: #5d5d5d;
+ transform: scale(1.05);
+ }
+
+ @media (max-width: 767px) {
+ width: 52px;
+ height: 32px;
+ padding: 4px 8px;
+ font-size: 14px;
+ gap: 2px;
+ }
+
+ @media (max-width: 420px) {
+ width: 44px;
+ height: 28px;
+ padding: 2px 6px;
+ font-size: 13px;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ pointer-events: none;
+ }
+ }
+
+ &__emoji-more-container {
+ position: relative;
+ }
+
+ &__emoji-more {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: none;
+ cursor: pointer;
+
+ img {
+ transition: transform 0.3s ease;
+ }
+
+ &:hover {
+ opacity: 0.6;
+ }
+ }
+
+ &__emoji-dropdown {
+ position: absolute;
+ top: 45px;
+ right: 0;
+ background-color: $white;
+ border-radius: 8px;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+ padding: 12px;
+ z-index: 10;
+ min-width: 310px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ @media (max-width: 975px) and (min-width: 768px) {
+ width: 280px;
+ min-width: auto;
+ box-sizing: border-box;
+ }
+
+ @media (max-width: 767px) {
+ right: -24px;
+ width: 240px;
+ min-width: auto;
+ box-sizing: border-box;
+ }
+
+ @media (max-width: 420px) {
+ right: -12px;
+ width: 200px;
+ padding: 8px;
+ top: 35px;
+ }
+ }
+
+ &__emoji-dropdown-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+ width: 100%;
+ height: 100%;
+ justify-content: center;
+ align-content: center;
+ justify-items: center;
+ align-items: center;
+
+ @media (max-width: 975px) {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ @media (max-width: 420px) {
+ gap: 6px;
+ }
+ }
+
+ &__emoji-dropdown-item {
+ width: 63px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ white-space: nowrap;
+ background-color: #757575;
+ color: $white;
+ border: none;
+ padding: 8px 12px;
+ border-radius: 32px;
+ @include font-16-regular;
+ cursor: pointer;
+ transition:
+ transform 0.2s ease,
+ background-color 0.2s ease;
+ text-align: center;
+ user-select: none;
+ -webkit-user-select: none;
+
+ &:hover {
+ background-color: #5d5d5d;
+ transform: scale(1.05);
+ }
+
+ @media (max-width: 767px) {
+ width: 52px;
+ height: 32px;
+ padding: 4px 8px;
+ font-size: 14px;
+ }
+
+ @media (max-width: 420px) {
+ width: 44px;
+ height: 28px;
+ padding: 2px 6px;
+ font-size: 13px;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ pointer-events: none;
+ }
+ }
+
+ &__add-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 36px;
+ padding: 6px 16px;
+ border-radius: 6px;
+ border: 1px solid $gray-300;
+ background-color: $white;
+ @include font-16-regular;
+ cursor: pointer;
+ gap: 4px;
+ white-space: nowrap;
+
+ &:hover {
+ background-color: $gray-100;
+ }
+
+ @media (max-width: 480px) {
+ width: 36px;
+ height: 32px;
+ padding: 4px;
+
+ span {
+ display: none;
+ }
+ }
+
+ @media (max-width: 375px) {
+ width: 32px;
+ height: 28px;
+ }
+ }
+
+ &__emoji-picker-container {
+ position: relative;
+ }
+
+ &__emoji-picker {
+ position: absolute;
+ top: 45px;
+ right: 0;
+ background-color: $white;
+ border-radius: 8px;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+ padding: 12px;
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+ z-index: 10;
+ min-width: 200px;
+ }
+
+ &__emoji-picker-item {
+ font-size: 20px;
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+
+ &:hover {
+ background-color: $gray-100;
+ }
+ }
+}
diff --git a/src/components/recipient/HeaderService/Share/ShareButton.jsx b/src/components/recipient/HeaderService/Share/ShareButton.jsx
new file mode 100644
index 0000000..4ffd4c1
--- /dev/null
+++ b/src/components/recipient/HeaderService/Share/ShareButton.jsx
@@ -0,0 +1,94 @@
+import { useState, useRef, useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+import styles from './ShareButton.module.scss';
+import shareIcon from '../../../../assets/images/share.svg';
+import {
+ initializeKakaoSDK,
+ shareKakao,
+ copyToClipboard,
+} from '../../../../utils/share';
+import Toast from '../../../common/Toast/Toast';
+
+export default function ShareButton({ recipient }) {
+ const [showShareOptions, setShowShareOptions] = useState(false);
+ const [showToast, setShowToast] = useState(false);
+ const [toastMessage, setToastMessage] = useState('');
+ const shareButtonRef = useRef(null);
+ const location = useLocation();
+
+ useEffect(() => {
+ initializeKakaoSDK();
+ }, []);
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (
+ shareButtonRef.current &&
+ !shareButtonRef.current.contains(event.target)
+ ) {
+ setShowShareOptions(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, []);
+
+ const toggleShareOptions = () => {
+ setShowShareOptions(!showShareOptions);
+ };
+
+ const handleKakaoShare = () => {
+ const currentUrl = window.location.origin + location.pathname;
+ shareKakao(recipient, currentUrl);
+ setShowShareOptions(false);
+ };
+
+ const handleUrlCopy = async () => {
+ const currentUrl = window.location.origin + location.pathname;
+ const success = await copyToClipboard(currentUrl);
+
+ if (success) {
+ setToastMessage('URL이 복사되었습니다.');
+ setShowToast(true);
+ } else {
+ setToastMessage('URL 복사에 실패했습니다.');
+ setShowToast(true);
+ }
+
+ setShowShareOptions(false);
+ };
+
+ const handleCloseToast = () => {
+ setShowToast(false);
+ };
+
+ return (
+
+
+
+ {showShareOptions && (
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/recipient/HeaderService/Share/ShareButton.module.scss b/src/components/recipient/HeaderService/Share/ShareButton.module.scss
new file mode 100644
index 0000000..18bf970
--- /dev/null
+++ b/src/components/recipient/HeaderService/Share/ShareButton.module.scss
@@ -0,0 +1,67 @@
+@use '../../../../assets/styles/variables.scss' as *;
+
+.share-button-container {
+ position: relative;
+}
+
+.share-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 36px;
+ padding: 6px 16px;
+ border-radius: 6px;
+ border: 1px solid $gray-300;
+ background-color: $white;
+ @include font-16-regular;
+ cursor: pointer;
+
+ &:hover {
+ background-color: $gray-100;
+ }
+
+ @media (max-width: 480px) {
+ width: 36px;
+ height: 32px;
+ padding: 4px;
+ }
+
+ @media (max-width: 375px) {
+ width: 32px;
+ height: 28px;
+ }
+}
+
+.share-options {
+ position: absolute;
+ top: 45px;
+ right: 0;
+ background-color: $white;
+ border-radius: 8px;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+ padding: 8px 0;
+ z-index: 10;
+ min-width: 160px;
+ overflow: hidden;
+}
+
+.share-option {
+ display: block;
+ width: 100%;
+ padding: 12px 16px;
+ text-align: left;
+ border: none;
+ background: none;
+ @include font-16-regular;
+ color: $gray-800;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+
+ &:hover {
+ background-color: $gray-100;
+ }
+
+ &:not(:last-child) {
+ border-bottom: 1px solid $gray-200;
+ }
+}
diff --git a/src/constants/image.js b/src/constants/image.js
new file mode 100644
index 0000000..3eb5458
--- /dev/null
+++ b/src/constants/image.js
@@ -0,0 +1,4 @@
+const DEFAULT_PROFILE_IMAGE =
+ 'https://learn-codeit-kr-static.s3.ap-northeast-2.amazonaws.com/sprint-proj-image/default_avatar.png';
+
+export default DEFAULT_PROFILE_IMAGE;
diff --git a/src/hooks/useDetectClose.jsx b/src/hooks/useDetectClose.jsx
new file mode 100644
index 0000000..5eb844c
--- /dev/null
+++ b/src/hooks/useDetectClose.jsx
@@ -0,0 +1,20 @@
+import { useState, useEffect } from 'react';
+
+export default function useDetectClose(ref) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => {
+ const handleClick = (e) => {
+ if (ref.current && !ref.current.contains(e.target)) {
+ setIsOpen(false);
+ }
+ };
+ if (isOpen) {
+ document.addEventListener('click', handleClick);
+ }
+ return () => {
+ document.removeEventListener('click', handleClick);
+ };
+ }, [isOpen, ref]);
+ return [isOpen, setIsOpen];
+}
diff --git a/src/hooks/useImagePreloader.jsx b/src/hooks/useImagePreloader.jsx
new file mode 100644
index 0000000..8eb641a
--- /dev/null
+++ b/src/hooks/useImagePreloader.jsx
@@ -0,0 +1,44 @@
+import { useEffect, useState } from 'react';
+
+export const useImagePreloader = (data) => {
+ const [isLoading, setIsLoading] = useState(true);
+
+ //받아올 이미지URL에 대한 배열 생성
+ useEffect(() => {
+ const urls = data.flatMap((item) => {
+ const bg = item.backgroundImageURL ? [item.backgroundImageURL] : [];
+ const recents =
+ item.recentMessages?.map((msg) => msg.profileImageURL) || [];
+ return [...bg, ...recents];
+ });
+ const nonDuplicatedUrls = [...new Set(urls)];
+
+ //방어 코드 *_* //useEffect라서 첫 렌더링 시 빈 배열일때, 뒤의 setIsLoading(false)가 실행되지 않도록
+ if (nonDuplicatedUrls.length === 0) {
+ return;
+ }
+
+ //이미지 preloading
+ Promise.all(
+ nonDuplicatedUrls.map((imagePath) => {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.src = imagePath;
+ img.onload = () => {
+ // console.log('✅ 이미지 로딩 완료:', imagePath);
+ resolve();
+ };
+ img.onerror = (err) => {
+ console.warn('❌ 이미지 로딩 실패:', imagePath);
+ reject(err);
+ };
+ });
+ }),
+ ).then(() => {
+ console.log('🟢 모든 이미지 로딩 완료');
+ setIsLoading(false);
+ });
+ }, [data]);
+
+ return isLoading;
+};
diff --git a/src/hooks/useModalClose.jsx b/src/hooks/useModalClose.jsx
new file mode 100644
index 0000000..c086fac
--- /dev/null
+++ b/src/hooks/useModalClose.jsx
@@ -0,0 +1,15 @@
+import { useEffect } from 'react';
+
+export default function useDetectClose(ref, onClose) {
+ useEffect(() => {
+ const handleClick = (e) => {
+ if (ref.current && !ref.current.contains(e.target)) {
+ onClose(); // 바로 닫기 말고 외부에서 애니메이션 포함한 onClose 실행
+ }
+ };
+ document.addEventListener('mousedown', handleClick);
+ return () => {
+ document.removeEventListener('mousedown', handleClick);
+ };
+ }, [ref, onClose]);
+}
diff --git a/src/main.jsx b/src/main.jsx
index 0daebe8..8a470d6 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,9 +1,12 @@
-import { StrictMode } from "react";
-import { createRoot } from "react-dom/client";
-import App from "./App.jsx";
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+import App from './App.jsx';
-createRoot(document.getElementById("root")).render(
+createRoot(document.getElementById('root')).render(
-
-
+
+
+
+ ,
);
diff --git a/src/pages/CreateRecipient/CreateRecipient.jsx b/src/pages/CreateRecipient/CreateRecipient.jsx
new file mode 100644
index 0000000..2b930fb
--- /dev/null
+++ b/src/pages/CreateRecipient/CreateRecipient.jsx
@@ -0,0 +1,198 @@
+import { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import styles from './CreateRecipient.module.scss';
+import FormInput from '../../components/common/FormInput';
+import Button from '../../components/common/Button';
+import getBackgroundImage from '../../api/getBackgroundImage';
+import BackgroundCard from '../../components/BackgroundCard/BackgroundCard';
+import postRecipient from '../../api/postRecipient';
+
+const colors = ['beige', 'purple', 'blue', 'green'];
+
+export default function CreateRecipient() {
+ const [data, setData] = useState(null);
+ const [value, setValue] = useState('');
+ const [isError, setIsError] = useState(false);
+ const [selectedType, setSelectedType] = useState('color');
+ const [selectedColor, setSelectedColor] = useState('beige');
+ const [selectedImage, setSelectedImage] = useState(-1);
+ const [imageLoading, setImageLoading] = useState([]);
+ const [uploadedImageUrl, setUploadedImageUrl] = useState(null);
+ const [isCreating, setIsCreating] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
+
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const fetch = async () => {
+ try {
+ const result = await getBackgroundImage();
+ setData(result.slice(0, 3));
+ setImageLoading(new Array(result.length).fill(true));
+ } catch (error) {
+ console.error('데이터 로딩 실패:', error);
+ }
+ };
+
+ fetch();
+ }, []);
+
+ function handleInputChange(e) {
+ const inputValue = e.target.value.slice(0, 10);
+ setValue(inputValue);
+ }
+
+ function handleBlur() {
+ if (!value) {
+ setIsError(true);
+ } else {
+ setIsError(false);
+ }
+ }
+
+ function handleColorClick(color) {
+ setSelectedColor(color);
+ }
+
+ function handleImageClick(index) {
+ setSelectedImage(index);
+ }
+
+ function handleImageLoad(index) {
+ setImageLoading((prev) => {
+ const updated = [...prev];
+ updated[index] = false;
+ return updated;
+ });
+ }
+
+ async function handleButtonClick() {
+ if (isCreating) return;
+
+ if (value.trim() === '' || value.trim().length > 10) {
+ alert('이름은 공백이 아니어야 하며, 최대 10자까지 입력할 수 있습니다.');
+ return;
+ }
+
+ setIsCreating(true);
+
+ try {
+ const id = await postRecipient({
+ team: '15-7',
+ name: value,
+ backgroundColor: selectedColor,
+ backgroundImageURL: uploadedImageUrl || (data[selectedImage] ?? null),
+ });
+ navigate(`/post/${id}`);
+ } catch (error) {
+ console.error('페이지 생성 중 오류:', error.response.data);
+ } finally {
+ setIsCreating(false);
+ }
+ }
+
+ const handleUploadImage = (url) => {
+ setUploadedImageUrl(url);
+ setSelectedImage(data.length + 1);
+ };
+
+ return (
+
+
+
+
+
+
+
+ 배경화면을 선택해 주세요.
+
+
컬러를 선택하거나, 이미지를 선택할 수 있습니다.
+
+
+
+
+
+
+ {selectedType === 'color' && (
+
+ {colors.map((color) => (
+ handleColorClick(color)}
+ />
+ ))}
+
+ )}
+
+ {selectedType === 'image' && (
+
+ {data.map((url, index) => (
+ handleImageClick(index)}
+ isLoading={imageLoading[index]}
+ onLoad={() => handleImageLoad(index)}
+ />
+ ))}
+ handleImageClick(data.length + 1)}
+ isSelected={selectedImage === data.length + 1}
+ onSelect={handleUploadImage}
+ isUploading={isUploading}
+ setIsUploading={setIsUploading}
+ />
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/CreateRecipient/CreateRecipient.module.scss b/src/pages/CreateRecipient/CreateRecipient.module.scss
new file mode 100644
index 0000000..6c9f7d4
--- /dev/null
+++ b/src/pages/CreateRecipient/CreateRecipient.module.scss
@@ -0,0 +1,91 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.create-page {
+ display: flex;
+ flex-direction: column;
+ margin: 57px auto 0;
+ max-width: 720px;
+ gap: 50px;
+
+ &__background-select {
+ display: flex;
+ flex-direction: column;
+ gap: 25px;
+ }
+
+ &__text-section {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ p {
+ @include font-16-regular;
+
+ span {
+ @include font-24-bold;
+ }
+ }
+ }
+
+ &__toggle-button {
+ button {
+ width: 122px;
+ height: 40px;
+ padding: 8px 16px;
+ border: none;
+ border-radius: 6px;
+ @include font-16-regular;
+ color: $gray-900;
+ }
+
+ .create-page__select-color--active,
+ .create-page__select-image--active {
+ padding: 6px 14px;
+ border: 2px solid $purple-600;
+ background-color: $white;
+ @include font-16-bold;
+ color: $purple-700;
+ }
+ }
+
+ &__color-list {
+ display: flex;
+ margin: 20px 0;
+ gap: 16px;
+ }
+
+ &__image-list {
+ display: flex;
+ margin: 20px 0;
+ gap: 16px;
+ }
+}
+
+@media (max-width: 1023px) {
+ .create-page {
+ position: relative;
+ min-height: calc(100vh - 57px - 65px);
+ &__create-button {
+ width: 100%;
+ position: absolute;
+ bottom: 24px;
+ }
+ }
+}
+
+@media (max-width: 767px) {
+ .create-page {
+ margin: 50px 20px 0;
+
+ &__input-section {
+ width: 100%;
+ }
+ &__color-list,
+ &__image-list {
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 12px;
+ padding-bottom: calc(82px + 56px);
+ }
+ }
+}
diff --git a/src/pages/Home/Home.jsx b/src/pages/Home/Home.jsx
new file mode 100644
index 0000000..5364dec
--- /dev/null
+++ b/src/pages/Home/Home.jsx
@@ -0,0 +1,52 @@
+import styles from './Home.module.scss';
+import img01 from '../../assets/images/img-01.svg';
+import img02 from '../../assets/images/img-02.png';
+import Button from '../../components/common/Button';
+import { useNavigate } from 'react-router-dom';
+
+export default function Home() {
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
Point.01
+
+ 누구나 손쉽게, 온라인 롤링 페이퍼를 만들 수 있어요
+
+
로그인 없이 자유롭게 만들어요.
+
+
+
+
+
+
+
+
+
Point.02
+
+ 서로에게 이모지로 감정을 표현해보세요
+
+
+ 롤링 페이퍼에 이모지를 추가할 수 있어요.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/Home/Home.module.scss b/src/pages/Home/Home.module.scss
new file mode 100644
index 0000000..a125811
--- /dev/null
+++ b/src/pages/Home/Home.module.scss
@@ -0,0 +1,240 @@
+@use '../../assets/styles/variables.scss' as *;
+
+body {
+ overflow-x: hidden;
+}
+
+.homeWrapper {
+ width: 100%;
+ height: 100%;
+}
+
+.sectionBoxes {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin: 0px;
+}
+
+.sectionBoxes:first-of-type {
+ margin-top: 42px;
+}
+
+.sectionBox {
+ display: flex;
+
+ width: 100%;
+ max-width: 1200px;
+ height: 324px;
+
+ margin: 0 auto;
+ padding: 60px 0 60px 60px;
+ box-sizing: border-box;
+
+ border-radius: 16px;
+ background-color: $surface;
+
+ opacity: 0;
+ transition:
+ transform 1.5s ease,
+ opacity 1.5s ease;
+}
+
+.rightImage {
+ justify-content: space-between;
+ margin-bottom: 30px;
+
+ transform: translateY(-30px);
+ animation: slideDownFadeIn 1.2s ease forwards;
+ animation-delay: 0.3s;
+}
+
+.leftImage {
+ flex-direction: row-reverse;
+ justify-content: flex-end;
+ margin-bottom: 48px;
+
+ transform: translateY(30px);
+ animation: slideUpFadeIn 1.5s ease forwards;
+ animation-delay: 0.6s;
+}
+
+.sectionBox.leftImage {
+ padding-left: 30px;
+}
+
+.sectionImagePaper,
+.sectionImageEmoji {
+ max-width: 720px;
+}
+
+.pointBadge {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ width: 80px;
+ height: 32px;
+
+ margin: 0 0 16px 0;
+ padding: 6px 12px;
+ box-sizing: border-box;
+
+ @include font-14-bold;
+ color: $white;
+ background-color: $purple-600;
+ border-radius: 50px;
+}
+
+.title {
+ @include font-24-bold;
+ margin-bottom: 8px;
+ color: $gray-900;
+
+ > span {
+ display: block;
+ }
+}
+
+.subtext {
+ @include font-18-regular;
+ color: $gray-500;
+}
+
+@media (min-width: 768px) and (max-width: 1023px) {
+ .sectionBoxes {
+ width: 100%;
+ margin: 0 auto;
+ padding-inline: 24px;
+ max-width: 960px;
+ box-sizing: border-box;
+ }
+
+ .sectionBox {
+ width: 100%;
+ margin: 0;
+ height: auto;
+ align-items: center;
+ gap: 36px;
+ padding: 40px;
+ }
+
+ .rightImage,
+ .leftImage {
+ flex-direction: column;
+ }
+
+ .rightImage {
+ margin-bottom: 30px;
+ }
+
+ .leftImage {
+ margin-bottom: 48px;
+ }
+
+ .textBox {
+ width: 100%;
+ }
+
+ .pointBadge {
+ margin: 0 0 16px 0;
+ }
+
+ .title {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ > span {
+ display: inline;
+ }
+ }
+
+ .subtext {
+ margin-top: 8px;
+ }
+}
+
+@media (max-width: 767px) {
+ .sectionBoxes {
+ padding-inline: 20px;
+ box-sizing: border-box;
+ width: 100%;
+ margin: 0;
+ height: auto;
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .sectionBox {
+ width: 100%;
+ height: auto;
+ flex-direction: column;
+ align-items: center;
+ padding: 24px;
+ gap: 50px;
+ }
+
+ .title {
+ @include font-18-bold;
+ }
+
+ .subtext {
+ @include font-15-regular;
+ }
+
+ .rightImage {
+ gap: 45px;
+ transform: translateY(-20px);
+ }
+
+ .leftImage {
+ gap: 48px;
+ transform: translateY(20px);
+ }
+
+ .rightImage,
+ .leftImage {
+ flex-direction: column;
+ justify-content: center;
+ min-width: 320px;
+ overflow: hidden;
+ }
+
+ .sectionImageEmoji,
+ .sectionImagePaper {
+ width: 150%;
+ margin-bottom: 24px;
+ }
+
+ .textBox {
+ width: 100%;
+ }
+
+ .pointBadge {
+ margin-bottom: 16px;
+ }
+
+ .title {
+ overflow: visible;
+ text-overflow: unset;
+
+ > span {
+ display: block;
+ }
+ }
+}
+
+@keyframes slideDownFadeIn {
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slideUpFadeIn {
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
diff --git a/src/pages/MessageForm/MessageForm.jsx b/src/pages/MessageForm/MessageForm.jsx
new file mode 100644
index 0000000..8ef0e91
--- /dev/null
+++ b/src/pages/MessageForm/MessageForm.jsx
@@ -0,0 +1,177 @@
+import { useState, useEffect } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import FormInput from '../../components/common/FormInput';
+import UserProfileSelector from '../../components/UserProfileSelector/UserProfileSelector';
+import DEFAULT_PROFILE_IMAGE from '../../constants/image';
+import RelationshipSelect from '../../components/RelationshipSelect/RelationshipSelect';
+import Editor from '../../components/Editor/Editor';
+import FontSelect from '../../components/FontSelect/FontSelect';
+import Button from '../../components/common/Button';
+import postMessage from '../../api/postMessage';
+import styles from './MessageForm.module.scss';
+
+export default function MessageForm() {
+ const { id } = useParams();
+ const [sender, setSender] = useState('');
+ const [senderError, setSenderIsError] = useState(false);
+ const [messageError, setMessageIsError] = useState(false);
+ const [profileImage, setProfileImage] = useState(DEFAULT_PROFILE_IMAGE);
+ const [relationship, setRelationship] = useState('지인');
+ const [message, setMessage] = useState('');
+ const [font, setFont] = useState('Noto Sans');
+ const [isRestored, setIsRestored] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
+
+ const stripHtml = (html) => html.replace(/<[^>]+>/g, '').trim();
+ const isValid = sender.trim() !== '' && stripHtml(message) !== '';
+
+ const navigate = useNavigate();
+
+ function handleInputChange(e) {
+ const inputValue = e.target.value.slice(0, 10);
+ setSender(inputValue);
+ }
+
+ function handleSenderBlur() {
+ setSenderIsError(sender.trim() === '');
+ }
+
+ function handleMessageBlur() {
+ setMessageIsError(stripHtml(message) === '');
+ }
+
+ function resetForm() {
+ setSender('');
+ setProfileImage(DEFAULT_PROFILE_IMAGE);
+ setRelationship('지인');
+ setMessage('');
+ setFont('Noto Sans');
+ }
+
+ useEffect(() => {
+ try {
+ const saved = localStorage.getItem('message-form');
+ if (saved) {
+ const parsed = JSON.parse(saved);
+ setSender(parsed.sender || '');
+ setProfileImage(parsed.profileImage || DEFAULT_PROFILE_IMAGE);
+ setRelationship(parsed.relationship || '지인');
+ setMessage(parsed.message || '');
+ setFont(parsed.font || 'Noto Sans');
+ }
+ } catch (err) {
+ console.error('복원 중 에러 발생', err);
+ localStorage.removeItem('message-form');
+ } finally {
+ setIsRestored(true);
+ }
+ }, []);
+
+ useEffect(() => {
+ const timeout = setTimeout(
+ () => {
+ localStorage.removeItem('message-form');
+ console.log('하루 뒤 자동 삭제');
+ },
+ 1000 * 60 * 60 * 24,
+ );
+
+ return () => clearTimeout(timeout);
+ }, []);
+
+ useEffect(() => {
+ if (!isRestored) return;
+
+ const formData = {
+ sender,
+ profileImage,
+ relationship,
+ message,
+ font,
+ };
+ localStorage.setItem('message-form', JSON.stringify(formData));
+ }, [sender, profileImage, relationship, message, font]);
+
+ async function handleSubmit() {
+ if (isCreating) return;
+
+ if (
+ sender.trim() === '' ||
+ sender.trim().length > 10 ||
+ stripHtml(message) === ''
+ ) {
+ alert('이름은 최대 10자까지, 메시지를 모두 입력해주세요.');
+ return;
+ }
+
+ setIsCreating(true);
+
+ try {
+ await postMessage({
+ team: import.meta.env.VITE_TEAM_ID,
+ recipientId: Number(id),
+ sender,
+ profileImageURL: profileImage,
+ relationship,
+ content: message,
+ font,
+ });
+
+ localStorage.removeItem('message-form');
+ resetForm();
+ navigate(`/post/${id}`);
+ } catch (error) {
+ console.error('메세지 전송 실패', error);
+ } finally {
+ setIsCreating(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/MessageForm/MessageForm.module.scss b/src/pages/MessageForm/MessageForm.module.scss
new file mode 100644
index 0000000..0ccaa9f
--- /dev/null
+++ b/src/pages/MessageForm/MessageForm.module.scss
@@ -0,0 +1,24 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.message-form {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ max-width: 720px;
+ margin: 40px auto 36px;
+ gap: 62px;
+
+ &__content {
+ display: flex;
+ flex-direction: column;
+ gap: 50px;
+ }
+ @media (max-width: 767px) {
+ & {
+ display: flex;
+ align-items: center;
+ max-width: 320px;
+ gap: 206px;
+ }
+ }
+}
diff --git a/src/pages/Recipient/Recipient.jsx b/src/pages/Recipient/Recipient.jsx
new file mode 100644
index 0000000..118697f
--- /dev/null
+++ b/src/pages/Recipient/Recipient.jsx
@@ -0,0 +1,221 @@
+import { useParams, useNavigate } from 'react-router';
+import { useEffect, useState, useRef } from 'react';
+import getRecipient from '../../api/getRecipient';
+import getMessages from '../../api/getMessages';
+import deleteMessage from '../../api/deleteMessage.js';
+import deleteRecipient from '../../api/deleteRecipient.js';
+import HeaderService from '../../components/recipient/HeaderService/HeaderService';
+import Card from '../../components/Card/Card';
+import Button from '../../components/common/Button';
+import Modal from '../../components/Modal/Modal.jsx';
+import styles from './Recipient.module.scss';
+
+export default function Recipient({ showDelete }) {
+ const { id } = useParams();
+ const [postData, setPostData] = useState(null);
+ const [allMessages, setAllMessages] = useState([]);
+ const [messages, setMessages] = useState([]);
+ const [offset, setOffset] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [hasNextMessage, setHasNextMessage] = useState(false);
+ const [selectedCardId, setSelectedCardId] = useState(null);
+ const observerRef = useRef();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const fetchRecipient = async () => {
+ try {
+ const recipient = await getRecipient(id);
+ setPostData(recipient);
+ setHasNextMessage(recipient.messageCount > 0);
+ setLoading(false);
+ } catch (error) {
+ console.error('데이터 로딩 실패:', error.response.data);
+ }
+ };
+
+ fetchRecipient();
+ }, [id]);
+
+ useEffect(() => {
+ setLoading(true);
+ const fetchMessages = async () => {
+ try {
+ const limit = 6;
+ const newMessages = await getMessages(id, offset, limit);
+ setAllMessages((prev) => {
+ const combined = [...prev, ...newMessages.results];
+
+ const uniqueMessages = Array.from(
+ new Map(combined.map((message) => [message.id, message])).values(),
+ );
+
+ if (showDelete) {
+ setMessages(uniqueMessages);
+ } else {
+ if (
+ uniqueMessages.length % 6 === 0 &&
+ uniqueMessages.length !== newMessages.count
+ ) {
+ setMessages(uniqueMessages.slice(0, uniqueMessages.length - 1));
+ } else {
+ setMessages(uniqueMessages);
+ }
+ }
+
+ return uniqueMessages;
+ });
+ if (!postData) return;
+ setHasNextMessage(offset < postData.messageCount);
+ setLoading(false);
+ } catch (error) {
+ console.error(
+ '데이터 로딩 실패:',
+ error.response?.data || error.message,
+ );
+ }
+ };
+
+ fetchMessages();
+ }, [id, offset, showDelete]);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver((entries) => {
+ const firstEntry = entries[0];
+
+ if (firstEntry.isIntersecting && hasNextMessage && !loading) {
+ loadMoreMessages();
+ }
+ });
+ if (observerRef.current) observer.observe(observerRef.current);
+ return () => {
+ if (observerRef.current) observer.unobserve(observerRef.current);
+ };
+ }, [hasNextMessage, loading, offset]);
+
+ const loadMoreMessages = () => {
+ if (loading || !hasNextMessage) return;
+ setLoading(true);
+ const limit = 6;
+ setOffset((prev) => prev + limit);
+ };
+
+ async function handleDeleteMessage(messageId, recipientId) {
+ try {
+ await deleteMessage(messageId);
+ const updatedAllMessages = await getMessages(recipientId, 0, offset);
+ setAllMessages(updatedAllMessages.results);
+ setOffset((prev) => prev - 6);
+ } catch (error) {
+ console.error('삭제 실패:', error);
+ }
+ }
+
+ async function handleDeleteRecipient(id) {
+ try {
+ const firstConfirm = confirm('정말 이 페이지를 삭제하시겠어요?');
+ if (!firstConfirm) return;
+ const secondConfirm = confirm(
+ '정말 정말 삭제하시겠어요? 되돌릴 수 없어요!',
+ );
+ if (!secondConfirm) return;
+ await deleteRecipient(id);
+ navigate('/');
+ } catch (error) {
+ console.error('삭제 실패:', error);
+ }
+ }
+
+ function handleGoBack() {
+ showDelete ? navigate(`/post/${id}/`) : navigate('/list');
+ }
+
+ function handleEditClick(id) {
+ navigate(`/post/${id}/edit`);
+ }
+
+ function handleOpenModal(id) {
+ setSelectedCardId(id);
+ }
+
+ function handleCloseModal() {
+ setSelectedCardId(null);
+ }
+
+ const selectedCard = messages.find((card) => card.id === selectedCardId);
+
+ if (!postData || messages.length < 0) return;
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {showDelete ? (
+
+ ) : (
+
+ )}
+
+
+
+ {!showDelete && (
+
+ )}
+ {messages.map((msg) => (
+ handleOpenModal(msg.id)}
+ font={msg.font}
+ showDelete={showDelete}
+ >
+ {msg.content}
+
+ ))}
+ {selectedCardId && (
+
+ {selectedCard.content}
+
+ )}
+
+ {hasNextMessage &&
}
+
+ >
+ );
+}
diff --git a/src/pages/Recipient/Recipient.module.scss b/src/pages/Recipient/Recipient.module.scss
new file mode 100644
index 0000000..8ed05af
--- /dev/null
+++ b/src/pages/Recipient/Recipient.module.scss
@@ -0,0 +1,143 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.loading-message {
+ @include font-20-regular;
+}
+
+.post-container {
+ min-height: 100vh;
+ background-size: cover;
+ background-position: center;
+ background-attachment: fixed;
+ padding: 63px 0;
+}
+
+.with-image {
+ background-repeat: no-repeat;
+}
+
+.background--beige {
+ background-color: $beige-200;
+}
+
+.background--blue {
+ background-color: $blue-200;
+}
+
+.background--green {
+ background-color: $green-200;
+}
+
+.background--purple {
+ background-color: $purple-200;
+}
+
+.background--gray {
+ background-color: $gray-200;
+}
+
+.card-container {
+ max-width: 1200px;
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 28px 24px;
+}
+
+.button-container {
+ max-width: 1200px;
+ margin: 0 auto 11px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.back-button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: $black;
+ transition: transform 0.3s ease;
+ animation: color-change 2s infinite alternate;
+ @include font-18-bold;
+
+ &:hover {
+ animation:
+ shake 0.5s ease,
+ color-change 2s infinite alternate;
+ }
+}
+
+@keyframes color-change {
+ 0% {
+ color: $black;
+ }
+ 100% {
+ color: $white;
+ }
+}
+
+@keyframes shake {
+ 0% {
+ transform: translateX(0);
+ }
+ 25% {
+ transform: translateX(-5px);
+ }
+ 50% {
+ transform: translateX(5px);
+ }
+ 75% {
+ transform: translateX(-5px);
+ }
+ 100% {
+ transform: translateX(0);
+ }
+}
+
+@media (max-width: 1248px) {
+ .card-container {
+ margin: 0 24px;
+ grid-template-columns: repeat(3, calc((100% - 48px) / 3));
+ }
+
+ .button-container {
+ margin: 0 24px 11px;
+ }
+}
+
+@media (max-width: 1023px) {
+ .card-container {
+ grid-template-columns: repeat(2, calc((100% - 16px) / 2));
+ gap: 16px;
+ padding-bottom: 61px;
+ }
+
+ .button-container {
+ width: calc(100% - 48px);
+ display: block;
+ position: relative;
+ }
+
+ .back-button-wrapper {
+ position: static;
+ margin-bottom: 11px;
+ }
+
+ .action-button-wrapper {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 2;
+ width: calc(100% - 48px);
+ }
+}
+
+@media (max-width: 767px) {
+ .card-container {
+ grid-template-columns: repeat(1, 1fr);
+ gap: 16px;
+ padding-bottom: 61px;
+ }
+}
diff --git a/src/pages/RecipientList/RecipientList.jsx b/src/pages/RecipientList/RecipientList.jsx
new file mode 100644
index 0000000..401fb9b
--- /dev/null
+++ b/src/pages/RecipientList/RecipientList.jsx
@@ -0,0 +1,61 @@
+import { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import styles from './RecipientList.module.scss';
+import getRecipients from '../../api/getRecipients';
+import Button from '../../components/common/Button';
+import Carousel from '../../components/Carousel/Carousel';
+import { useImagePreloader } from '../../hooks/useImagePreloader';
+import CarouselSkeleton from '../../components/CarouselSkeleton/CarouselSkeleton';
+
+export default function RecipientList() {
+ const [popularity, setPopularity] = useState([]);
+ const [recently, setRecently] = useState([]);
+ const [isFetched, setIsFetched] = useState(false);
+ const navigate = useNavigate();
+
+ //데이터 받아옴, 상태 업데이트
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ const [dateRes, likedRes] = await Promise.all([
+ getRecipients(),
+ getRecipients('like'),
+ ]);
+ setRecently(dateRes.results);
+ setPopularity(likedRes.results);
+ setIsFetched(true);
+ } catch (error) {
+ console.error('데이터 불러오기 실패', error);
+ }
+ };
+ fetchData();
+ }, []);
+
+ const isLoadingImages = useImagePreloader(isFetched ? recently : []);
+
+ return (
+ <>
+
+ 인기 롤링 페이퍼 🔥
+ {isLoadingImages ? (
+
+ ) : (
+
+ )}
+
+
+ 최근에 만든 롤링 페이퍼 ⭐️
+ {isLoadingImages ? (
+
+ ) : (
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/src/pages/RecipientList/RecipientList.module.scss b/src/pages/RecipientList/RecipientList.module.scss
new file mode 100644
index 0000000..5f1003e
--- /dev/null
+++ b/src/pages/RecipientList/RecipientList.module.scss
@@ -0,0 +1,57 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.skeleton-liked {
+ margin-bottom: 102px;
+}
+
+.section-listpage {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ position: relative;
+ width: 100%;
+ margin: 50px auto;
+ padding: 0 24px;
+}
+.button-listpage {
+ margin-top: 64px;
+}
+.section__h2 {
+ @include font-24-bold;
+ width: 1160px;
+ margin: 0 auto;
+ margin-bottom: 16px;
+ white-space: nowrap;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-use-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+@media (max-width: 1200px) {
+ .section__h2 {
+ width: 100%;
+ }
+}
+@media (min-width: 768px) and (max-width: 1023px) {
+ .section-listpage {
+ padding: 0 24px;
+ }
+ .button-listpage {
+ margin-top: 156px;
+ margin-bottom: 24px;
+ }
+}
+@media (max-width: 767px) {
+ .section-listpage {
+ padding: 0 20px;
+ margin: 40px auto;
+ }
+ .button-listpage {
+ margin-top: 66px;
+ margin-bottom: 24px;
+ }
+ .section--mobile-gap {
+ margin-bottom: 74px;
+ }
+}
diff --git a/src/utils/numberFormat.js b/src/utils/numberFormat.js
new file mode 100644
index 0000000..71fd968
--- /dev/null
+++ b/src/utils/numberFormat.js
@@ -0,0 +1,5 @@
+export function formatCount(count) {
+ if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
+ if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
+ return count.toString();
+}
diff --git a/src/utils/share.js b/src/utils/share.js
new file mode 100644
index 0000000..ebd38c2
--- /dev/null
+++ b/src/utils/share.js
@@ -0,0 +1,60 @@
+export const initializeKakaoSDK = () => {
+ if (window.Kakao && window.Kakao.isInitialized()) {
+ return;
+ }
+
+ const script = document.createElement('script');
+ script.src = 'https://t1.kakaocdn.net/kakao_js_sdk/2.6.0/kakao.min.js';
+ script.async = true;
+ script.onload = () => {
+ if (window.Kakao) {
+ window.Kakao.init(import.meta.env.VITE_KAKAO_APP_KEY);
+ console.log('Kakao SDK initialized:', window.Kakao.isInitialized());
+ }
+ };
+ document.head.appendChild(script);
+};
+
+export const shareKakao = (recipient, url) => {
+ if (!window.Kakao || !window.Kakao.Share) {
+ console.error('Kakao SDK not loaded');
+ return;
+ }
+
+ const title = `Rolling - 마음을 실은 종이비행기`;
+ const description = `마음을 종이비행기에 담아 전하세요. 부담 없이, 따뜻하게.`;
+ const imageUrl = `${window.location.origin}/rolling-meta.png`;
+
+ window.Kakao.Share.sendDefault({
+ objectType: 'feed',
+ content: {
+ title: title,
+ description: description,
+ imageUrl: imageUrl,
+ link: {
+ mobileWebUrl: url,
+ webUrl: url,
+ },
+ },
+
+ buttons: [
+ {
+ title: '롤링페이퍼 작성하기',
+ link: {
+ mobileWebUrl: `${url}/message`,
+ webUrl: `${url}/message`,
+ },
+ },
+ ],
+ });
+};
+
+export const copyToClipboard = async (text) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch (err) {
+ console.error('클립보드 복사 실패:', err);
+ return false;
+ }
+};
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 0000000..0f32683
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,3 @@
+{
+ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
+}
diff --git a/vite.config.js b/vite.config.js
index 8b0f57b..4e5dabb 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,7 +1,7 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
-})
+});
\ No newline at end of file