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 ? ( +
    + 추가하기 +
    + ) : ( + <> +
    +
    + 프로필 이미지 +
    +
    +
    +

    + From. {sender} +

    +
    +
    + +
    +
    + {showDelete && ( +
    + +
    + )} +
    +
    +
    +
    +
    +
    +
    {createdAt}
    + + )} +
    + ); +} 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( +
    +
    +
    +
    + 프로필 이미지 +
    +
    +
    +

    + From. {sender} +

    +
    +
    + +
    +
    +
    {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) => ( + {`profile-${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 ( + <> +
    + + rolling logo + + {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