feat(app): 멤버 화면 블러 효과 및 dayjs 날짜 처리 적용
- expo-blur로 모달 배경 블러 효과 구현 (experimentalBlurMethod) - dayjs로 생일 날짜 형식 처리 (YYYY.MM.DD) - dayjs로 나이 계산 로직 개선 - 헤더 타이틀 '멤버'로 변경 (웹과 동일) - .easignore 추가 (상위 폴더 데이터 제외) - redis_data 폴더 gitignore 추가
This commit is contained in:
parent
9661742a52
commit
58411f3dca
12 changed files with 906 additions and 164 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -19,6 +19,7 @@ Thumbs.db
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
meilisearch_data/
|
meilisearch_data/
|
||||||
|
redis_data/
|
||||||
|
|
||||||
# Scrape files
|
# Scrape files
|
||||||
backend/scrape_*.cjs
|
backend/scrape_*.cjs
|
||||||
|
|
|
||||||
14
app/.easignore
Normal file
14
app/.easignore
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# EAS Build에서 제외할 파일/폴더
|
||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
.git/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 상위 폴더의 데이터 폴더들
|
||||||
|
../redis_data/
|
||||||
|
../meilisearch_data/
|
||||||
|
../backend/
|
||||||
|
../frontend/
|
||||||
|
**/redis_data/
|
||||||
|
**/meilisearch_data/
|
||||||
1
app/.gitignore
vendored
1
app/.gitignore
vendored
|
|
@ -39,3 +39,4 @@ yarn-error.*
|
||||||
# generated native folders
|
# generated native folders
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
app/build-*.apk
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,10 @@
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
"apiUrl": "https://fromis9.caadiq.co.kr/api"
|
"apiUrl": "https://fromis9.caadiq.co.kr/api",
|
||||||
|
"eas": {
|
||||||
|
"projectId": "fdd66225-0e4b-4e84-a231-7f5006ca8ee3"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-font"
|
"expo-font"
|
||||||
|
|
|
||||||
BIN
app/build-1768186867181.apk
Normal file
BIN
app/build-1768186867181.apk
Normal file
Binary file not shown.
BIN
app/build-1768188981466.apk
Normal file
BIN
app/build-1768188981466.apk
Normal file
Binary file not shown.
21
app/eas.json
Normal file
21
app/eas.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.28.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
186
app/package-lock.json
generated
186
app/package-lock.json
generated
|
|
@ -14,7 +14,10 @@
|
||||||
"@react-navigation/native": "^7.1.26",
|
"@react-navigation/native": "^7.1.26",
|
||||||
"@react-navigation/native-stack": "^7.9.0",
|
"@react-navigation/native-stack": "^7.9.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"expo": "~54.0.31",
|
"expo": "~54.0.31",
|
||||||
|
"expo-blur": "~15.0.8",
|
||||||
|
"expo-dev-client": "~6.0.20",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
"expo-font": "~14.0.10",
|
"expo-font": "~14.0.10",
|
||||||
"expo-image": "~3.0.11",
|
"expo-image": "~3.0.11",
|
||||||
|
|
@ -25,6 +28,7 @@
|
||||||
"nativewind": "^4.2.1",
|
"nativewind": "^4.2.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
|
"react-native-color-matrix-image-filters": "^8.0.2",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-pager-view": "6.9.1",
|
"react-native-pager-view": "6.9.1",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
|
|
@ -3702,6 +3706,22 @@
|
||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ajv": {
|
||||||
|
"version": "8.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/anser": {
|
"node_modules/anser": {
|
||||||
"version": "1.4.10",
|
"version": "1.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz",
|
||||||
|
|
@ -4501,6 +4521,12 @@
|
||||||
"integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
|
"integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clamp": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cli-cursor": {
|
"node_modules/cli-cursor": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz",
|
||||||
|
|
@ -4705,6 +4731,15 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/concat-color-matrices": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-color-matrices/-/concat-color-matrices-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-K0IMtl4m9LcHg4vVFb40Txmie/GwCJBkquGSn6wtA9yIcszzFZgGc6co4sSnjFdqeJAv+q2LrCHMUSb2GHAChA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"invariant": "^2.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
|
|
@ -4858,6 +4893,12 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
|
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
@ -5328,6 +5369,68 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-blur": {
|
||||||
|
"version": "15.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-15.0.8.tgz",
|
||||||
|
"integrity": "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-dev-client": {
|
||||||
|
"version": "6.0.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-6.0.20.tgz",
|
||||||
|
"integrity": "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"expo-dev-launcher": "6.0.20",
|
||||||
|
"expo-dev-menu": "7.0.18",
|
||||||
|
"expo-dev-menu-interface": "2.0.0",
|
||||||
|
"expo-manifests": "~1.0.10",
|
||||||
|
"expo-updates-interface": "~2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-dev-launcher": {
|
||||||
|
"version": "6.0.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-6.0.20.tgz",
|
||||||
|
"integrity": "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "^8.11.0",
|
||||||
|
"expo-dev-menu": "7.0.18",
|
||||||
|
"expo-manifests": "~1.0.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-dev-menu": {
|
||||||
|
"version": "7.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-7.0.18.tgz",
|
||||||
|
"integrity": "sha512-4kTdlHrnZCAWCT6tZRQHSSjZ7vECFisL4T+nsG/GJDo/jcHNaOVGV5qPV9wzlTxyMk3YOPggRw4+g7Ownrg5eA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"expo-dev-menu-interface": "2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-dev-menu-interface": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-file-system": {
|
"node_modules/expo-file-system": {
|
||||||
"version": "19.0.21",
|
"version": "19.0.21",
|
||||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||||
|
|
@ -5369,6 +5472,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-json-utils": {
|
||||||
|
"version": "0.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz",
|
||||||
|
"integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expo-linear-gradient": {
|
"node_modules/expo-linear-gradient": {
|
||||||
"version": "15.0.8",
|
"version": "15.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz",
|
||||||
|
|
@ -5380,6 +5489,19 @@
|
||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-manifests": {
|
||||||
|
"version": "1.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz",
|
||||||
|
"integrity": "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/config": "~12.0.11",
|
||||||
|
"expo-json-utils": "~0.15.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-modules-autolinking": {
|
"node_modules/expo-modules-autolinking": {
|
||||||
"version": "3.0.24",
|
"version": "3.0.24",
|
||||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
|
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
|
||||||
|
|
@ -5513,6 +5635,15 @@
|
||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-updates-interface": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo/node_modules/@babel/code-frame": {
|
"node_modules/expo/node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
|
|
@ -5923,6 +6054,22 @@
|
||||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-uri": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
|
|
@ -7135,6 +7282,12 @@
|
||||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
|
|
@ -8987,6 +9140,21 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-color-matrix-image-filters": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-color-matrix-image-filters/-/react-native-color-matrix-image-filters-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-RJoNJt5Mhi1ztsPdKB3kNDHMSbarrWdNkcY2gPb3G7N9Kx6TKq00hl/X90PWRk9p2lqgiD0YznD8NlxDgqgLPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"concat-color-matrices": "^1.0.0",
|
||||||
|
"rn-color-matrices": "^4.1.0",
|
||||||
|
"ts-tiny-invariant": "^2.0.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-css-interop": {
|
"node_modules/react-native-css-interop": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-css-interop/-/react-native-css-interop-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-css-interop/-/react-native-css-interop-0.2.1.tgz",
|
||||||
|
|
@ -9792,6 +9960,18 @@
|
||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rn-color-matrices": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rn-color-matrices/-/rn-color-matrices-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-a8++z3Zi4GhA0oWwKS3etdrVuVQ2q/ByTMh6lMxo+vaSv3SRSSg5SzpZk65GS0NCAqMhFT8WSvgSgNhO/F+xag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"clamp": "^1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/run-parallel": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
|
|
@ -10583,6 +10763,12 @@
|
||||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-tiny-invariant": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-tiny-invariant/-/ts-tiny-invariant-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-NGQzWRLGLMjOUTpsxMSRj63fuFBC8HV8L4NUxzDVgU4MFeOt3qFI2534M5L+ch22x53oDEwPaRBPXgAfRjcXHA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/type-detect": {
|
"node_modules/type-detect": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@
|
||||||
"@react-navigation/native": "^7.1.26",
|
"@react-navigation/native": "^7.1.26",
|
||||||
"@react-navigation/native-stack": "^7.9.0",
|
"@react-navigation/native-stack": "^7.9.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"expo": "~54.0.31",
|
"expo": "~54.0.31",
|
||||||
|
"expo-blur": "~15.0.8",
|
||||||
|
"expo-dev-client": "~6.0.20",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
"expo-font": "~14.0.10",
|
"expo-font": "~14.0.10",
|
||||||
"expo-image": "~3.0.11",
|
"expo-image": "~3.0.11",
|
||||||
|
|
@ -26,6 +29,7 @@
|
||||||
"nativewind": "^4.2.1",
|
"nativewind": "^4.2.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
|
"react-native-color-matrix-image-filters": "^8.0.2",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-pager-view": "6.9.1",
|
"react-native-pager-view": "6.9.1",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
|
|
@ -8,23 +8,82 @@ import {
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
Linking,
|
Linking,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Modal,
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Pressable,
|
||||||
|
PanResponder,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Instagram, Calendar, X } from 'lucide-react-native';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { Grayscale } from 'react-native-color-matrix-image-filters';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
import { getMembers, Member } from '../api/members';
|
import { getMembers, Member } from '../api/members';
|
||||||
import { colors } from '../constants/colors';
|
import { colors } from '../constants/colors';
|
||||||
|
|
||||||
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||||
|
const CARD_WIDTH = (SCREEN_WIDTH - 32 - 24) / 3;
|
||||||
|
|
||||||
export default function MembersScreen() {
|
export default function MembersScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const [members, setMembers] = useState<Member[]>([]);
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [formerMembers, setFormerMembers] = useState<Member[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [selectedMember, setSelectedMember] = useState<Member | null>(null);
|
||||||
|
|
||||||
|
// 애니메이션
|
||||||
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const slideAnim = useRef(new Animated.Value(300)).current;
|
||||||
|
const dragY = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
// 드래그 핸들용 PanResponder
|
||||||
|
const panResponder = useRef(
|
||||||
|
PanResponder.create({
|
||||||
|
onStartShouldSetPanResponder: () => true,
|
||||||
|
onMoveShouldSetPanResponder: () => true,
|
||||||
|
onPanResponderMove: (_, gestureState) => {
|
||||||
|
if (gestureState.dy > 0) {
|
||||||
|
dragY.setValue(gestureState.dy);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPanResponderRelease: (_, gestureState) => {
|
||||||
|
if (gestureState.dy > 100 || gestureState.vy > 0.5) {
|
||||||
|
// 충분히 드래그했거나 빠르게 드래그하면 닫기
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: 300,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
setSelectedMember(null);
|
||||||
|
dragY.setValue(0);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 원위치로 복귀
|
||||||
|
Animated.spring(dragY, {
|
||||||
|
toValue: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).current;
|
||||||
|
|
||||||
const fetchMembers = async () => {
|
const fetchMembers = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getMembers();
|
const data = await getMembers();
|
||||||
// 전 멤버 제외
|
|
||||||
setMembers(data.filter(m => !m.is_former));
|
setMembers(data.filter(m => !m.is_former));
|
||||||
|
setFormerMembers(data.filter(m => m.is_former));
|
||||||
|
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('멤버 로드 오류:', error);
|
console.error('멤버 로드 오류:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -39,29 +98,129 @@ export default function MembersScreen() {
|
||||||
|
|
||||||
const onRefresh = () => {
|
const onRefresh = () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
|
fadeAnim.setValue(0);
|
||||||
fetchMembers();
|
fetchMembers();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openInstagram = (instagram: string) => {
|
const openModal = (member: Member) => {
|
||||||
Linking.openURL(`https://instagram.com/${instagram}`);
|
setSelectedMember(member);
|
||||||
|
dragY.setValue(0);
|
||||||
|
slideAnim.setValue(300);
|
||||||
|
Animated.spring(slideAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
damping: 25,
|
||||||
|
stiffness: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 생년월일에서 나이 계산
|
const closeModal = () => {
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: 300,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
setSelectedMember(null);
|
||||||
|
dragY.setValue(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openInstagram = (instagram: string) => {
|
||||||
|
Linking.openURL(instagram);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 나이 계산 (dayjs 사용)
|
||||||
const calculateAge = (birthDate: string) => {
|
const calculateAge = (birthDate: string) => {
|
||||||
const birth = new Date(birthDate);
|
if (!birthDate) return null;
|
||||||
const today = new Date();
|
const birth = dayjs(birthDate);
|
||||||
let age = today.getFullYear() - birth.getFullYear();
|
const today = dayjs();
|
||||||
const monthDiff = today.getMonth() - birth.getMonth();
|
return today.diff(birth, 'year');
|
||||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
};
|
||||||
age--;
|
|
||||||
|
// 날짜 포맷팅 (YYYY.MM.DD)
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
return dayjs(dateString).format('YYYY.MM.DD');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 멤버 카드 이미지 렌더링
|
||||||
|
const renderMemberImage = (imageUrl: string, isFormer: boolean) => {
|
||||||
|
const imageElement = (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={styles.cardImage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isFormer) {
|
||||||
|
return <Grayscale>{imageElement}</Grayscale>;
|
||||||
}
|
}
|
||||||
return age;
|
return imageElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 멤버 카드 렌더링
|
||||||
|
const renderMemberCard = (member: Member, index: number, isFormer: boolean = false) => (
|
||||||
|
<Animated.View
|
||||||
|
key={member.id}
|
||||||
|
style={[
|
||||||
|
styles.memberCardWrapper,
|
||||||
|
{
|
||||||
|
opacity: fadeAnim,
|
||||||
|
transform: [{
|
||||||
|
translateY: fadeAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [20, 0],
|
||||||
|
})
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.memberCard}
|
||||||
|
onPress={() => openModal(member)}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
{/* 카드 컨테이너 - 3:4 비율 */}
|
||||||
|
<View style={styles.cardContainer}>
|
||||||
|
{/* 이미지 */}
|
||||||
|
<View style={styles.cardImageContainer}>
|
||||||
|
{member.image_url && renderMemberImage(member.image_url, isFormer)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 하단 그라데이션 오버레이 */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['transparent', 'rgba(0,0,0,0.3)', 'rgba(0,0,0,0.7)']}
|
||||||
|
style={styles.cardGradient}
|
||||||
|
>
|
||||||
|
<Text style={styles.cardName}>{member.name}</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달 이미지 렌더링
|
||||||
|
const renderModalImage = (imageUrl: string, isFormer: boolean) => {
|
||||||
|
const imageElement = (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={styles.modalImage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isFormer) {
|
||||||
|
return <Grayscale>{imageElement}</Grayscale>;
|
||||||
|
}
|
||||||
|
return imageElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>멤버</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
<ActivityIndicator size="large" color={colors.primary} />
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|
@ -69,105 +228,377 @@ export default function MembersScreen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
{/* 헤더 */}
|
{/* 상단 헤더 */}
|
||||||
<View style={{
|
<View style={styles.header}>
|
||||||
paddingHorizontal: 20,
|
<Text style={styles.headerTitle}>멤버</Text>
|
||||||
paddingVertical: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: colors.borderLight,
|
|
||||||
}}>
|
|
||||||
<Text style={{
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: colors.textPrimary,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}>
|
|
||||||
멤버
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 멤버 목록 */}
|
{/* 멤버 목록 */}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
}
|
}
|
||||||
contentContainerStyle={{ padding: 16 }}
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
>
|
>
|
||||||
{members.map((member) => (
|
{/* 현재 멤버 그리드 */}
|
||||||
<View
|
<View style={styles.content}>
|
||||||
key={member.id}
|
<View style={styles.membersGrid}>
|
||||||
style={{
|
{members.map((member, index) => renderMemberCard(member, index))}
|
||||||
flexDirection: 'row',
|
</View>
|
||||||
backgroundColor: colors.backgroundSecondary,
|
|
||||||
borderRadius: 16,
|
{/* 전 멤버 */}
|
||||||
padding: 12,
|
{formerMembers.length > 0 && (
|
||||||
marginBottom: 12,
|
<>
|
||||||
}}
|
<View style={styles.formerHeader}>
|
||||||
|
<View style={styles.formerLine} />
|
||||||
|
<Text style={styles.formerTitle}>전 멤버</Text>
|
||||||
|
<View style={styles.formerLine} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.membersGrid}>
|
||||||
|
{formerMembers.map((member, index) => renderMemberCard(member, index, true))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 멤버 상세 모달 */}
|
||||||
|
<Modal
|
||||||
|
visible={!!selectedMember}
|
||||||
|
transparent
|
||||||
|
animationType="none"
|
||||||
|
onRequestClose={closeModal}
|
||||||
>
|
>
|
||||||
{/* 프로필 이미지 */}
|
<Animated.View
|
||||||
<Image
|
style={[
|
||||||
source={{ uri: member.image_url }}
|
styles.modalOverlay,
|
||||||
style={{
|
{
|
||||||
width: 80,
|
opacity: slideAnim.interpolate({
|
||||||
height: 80,
|
inputRange: [0, 300],
|
||||||
borderRadius: 40,
|
outputRange: [1, 0],
|
||||||
backgroundColor: colors.border,
|
})
|
||||||
}}
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Pressable style={StyleSheet.absoluteFill} onPress={closeModal}>
|
||||||
|
<BlurView
|
||||||
|
intensity={30}
|
||||||
|
tint="dark"
|
||||||
|
experimentalBlurMethod="dimezisBlurView"
|
||||||
|
style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(0,0,0,0.3)' }]}
|
||||||
/>
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.modalContent,
|
||||||
|
{
|
||||||
|
transform: [
|
||||||
|
{ translateY: Animated.add(slideAnim, dragY) }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* 드래그 핸들 */}
|
||||||
|
<View
|
||||||
|
style={styles.dragHandle}
|
||||||
|
{...panResponder.panHandlers}
|
||||||
|
>
|
||||||
|
<View style={styles.dragBar} />
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* 정보 */}
|
{/* 헤더 */}
|
||||||
<View style={{ flex: 1, marginLeft: 16, justifyContent: 'center' }}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={{
|
<Text style={styles.modalHeaderTitle}>멤버 정보</Text>
|
||||||
fontSize: 18,
|
<TouchableOpacity
|
||||||
fontWeight: 'bold',
|
onPress={closeModal}
|
||||||
color: colors.textPrimary,
|
style={styles.closeButton}
|
||||||
}}>
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
{member.name}
|
>
|
||||||
|
<X size={20} color="#9CA3AF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{selectedMember && (
|
||||||
|
<View style={[styles.modalBody, { paddingBottom: 24 + insets.bottom }]}>
|
||||||
|
<View style={styles.modalRow}>
|
||||||
|
{/* 프로필 이미지 - 3:4 비율 */}
|
||||||
|
<View style={styles.modalImageWrapper}>
|
||||||
|
<View style={styles.modalImageContainer}>
|
||||||
|
{selectedMember.image_url && renderModalImage(selectedMember.image_url, selectedMember.is_former)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 멤버 정보 */}
|
||||||
|
<View style={styles.modalInfo}>
|
||||||
|
<Text style={styles.modalName}>{selectedMember.name}</Text>
|
||||||
|
<Text style={styles.modalPosition}>
|
||||||
|
{selectedMember.position?.replaceAll(',', ', ')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{member.position && (
|
{selectedMember.birth_date && (
|
||||||
<Text style={{
|
<View style={styles.modalBirthRow}>
|
||||||
fontSize: 13,
|
<Calendar size={14} color="#9CA3AF" />
|
||||||
color: colors.textSecondary,
|
<Text style={styles.modalBirthText}>
|
||||||
marginTop: 4,
|
{formatDate(selectedMember.birth_date)}
|
||||||
}}>
|
{calculateAge(selectedMember.birth_date) && (
|
||||||
{member.position}
|
<Text style={styles.modalAge}> ({calculateAge(selectedMember.birth_date)}세)</Text>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text style={{
|
{selectedMember.instagram && (
|
||||||
fontSize: 12,
|
|
||||||
color: colors.textTertiary,
|
|
||||||
marginTop: 4,
|
|
||||||
}}>
|
|
||||||
{member.birth_date?.slice(0, 10)} ({calculateAge(member.birth_date)}세)
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* 인스타그램 */}
|
|
||||||
{member.instagram && (
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => openInstagram(member.instagram!)}
|
style={styles.instagramButton}
|
||||||
style={{
|
onPress={() => openInstagram(selectedMember.instagram!)}
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: 8,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Ionicons name="logo-instagram" size={16} color={colors.primary} />
|
<LinearGradient
|
||||||
<Text style={{
|
colors={['#F58529', '#DD2A7B', '#8134AF', '#515BD4']}
|
||||||
fontSize: 12,
|
start={{ x: 0, y: 0 }}
|
||||||
color: colors.primary,
|
end={{ x: 1, y: 1 }}
|
||||||
marginLeft: 4,
|
style={styles.instagramGradient}
|
||||||
}}>
|
>
|
||||||
@{member.instagram}
|
<Instagram size={14} color="#FFFFFF" />
|
||||||
</Text>
|
<Text style={styles.instagramText}>Instagram</Text>
|
||||||
|
</LinearGradient>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
</View>
|
||||||
</ScrollView>
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
</Modal>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 헤더
|
||||||
|
header: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 멤버 그리드
|
||||||
|
membersGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginHorizontal: -6,
|
||||||
|
},
|
||||||
|
memberCardWrapper: {
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
marginHorizontal: 6,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
memberCard: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
cardContainer: {
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
cardImageContainer: {
|
||||||
|
aspectRatio: 3/4,
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
cardImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
cardGradient: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
paddingTop: 40,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
cardName: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
textShadowColor: 'rgba(0,0,0,0.3)',
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 전 멤버 헤더
|
||||||
|
formerHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 32,
|
||||||
|
marginBottom: 16,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
formerLine: {
|
||||||
|
flex: 1,
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
formerTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#9CA3AF',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 모달
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
modalBackdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
},
|
||||||
|
dragHandle: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
dragBar: {
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: '#D1D5DB',
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
modalHeaderTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#111827',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 6,
|
||||||
|
},
|
||||||
|
modalBody: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingBottom: 24,
|
||||||
|
},
|
||||||
|
modalRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
modalImageWrapper: {
|
||||||
|
width: 112,
|
||||||
|
},
|
||||||
|
modalImageContainer: {
|
||||||
|
aspectRatio: 3/4,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
modalImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
modalInfo: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
modalName: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#111827',
|
||||||
|
},
|
||||||
|
modalPosition: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
modalBirthRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
modalBirth: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
},
|
||||||
|
modalAge: {
|
||||||
|
color: '#D1D5DB',
|
||||||
|
},
|
||||||
|
instagramButton: {
|
||||||
|
marginTop: 12,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
instagramGradient: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
instagramText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ function MobileAlbumDetail() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="pb-6">
|
<div>
|
||||||
{/* 앨범 히어로 섹션 - 커버 이미지 배경 */}
|
{/* 앨범 히어로 섹션 - 커버 이미지 배경 */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* 배경 블러 이미지 */}
|
{/* 배경 블러 이미지 */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Instagram } from 'lucide-react';
|
import { Instagram, Calendar, X } from 'lucide-react';
|
||||||
import { getMembers } from '../../../api/public/members';
|
import { getMembers } from '../../../api/public/members';
|
||||||
|
|
||||||
// 모바일 멤버 페이지
|
// 모바일 멤버 페이지
|
||||||
|
|
@ -18,70 +18,133 @@ function MobileMembers() {
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 나이 계산
|
||||||
|
const calculateAge = (birthDate) => {
|
||||||
|
if (!birthDate) return null;
|
||||||
|
const birth = new Date(birthDate);
|
||||||
|
const today = new Date();
|
||||||
|
let age = today.getFullYear() - birth.getFullYear();
|
||||||
|
const monthDiff = today.getMonth() - birth.getMonth();
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
return age;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 닫기
|
||||||
|
const closeModal = () => setSelectedMember(null);
|
||||||
|
|
||||||
// 멤버 카드 렌더링 함수
|
// 멤버 카드 렌더링 함수
|
||||||
const renderMemberCard = (member, index, isFormer = false) => (
|
const renderMemberCard = (member, index, isFormer = false) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={member.id}
|
key={member.id}
|
||||||
onClick={() => setSelectedMember(member)}
|
onClick={() => setSelectedMember(member)}
|
||||||
className="text-center cursor-pointer"
|
className="cursor-pointer group"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: index * 0.05, duration: 0.3 }}
|
transition={{ delay: index * 0.05, duration: 0.3 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<div className={`aspect-square rounded-2xl overflow-hidden bg-gray-200 mb-2 shadow-sm ${isFormer ? 'grayscale' : ''}`}>
|
{/* 카드 컨테이너 */}
|
||||||
|
<div className={`relative overflow-hidden rounded-2xl bg-white shadow-sm
|
||||||
|
transition-shadow duration-300 group-hover:shadow-lg
|
||||||
|
${isFormer ? 'grayscale' : ''}`}
|
||||||
|
>
|
||||||
|
{/* 이미지 영역 - 3:4 비율 */}
|
||||||
|
<div className="aspect-[3/4] bg-gradient-to-br from-gray-100 to-gray-200 overflow-hidden">
|
||||||
{member.image_url && (
|
{member.image_url && (
|
||||||
<img
|
<img
|
||||||
src={member.image_url}
|
src={member.image_url}
|
||||||
alt={member.name}
|
alt={member.name}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className={`font-medium text-sm ${isFormer ? 'text-gray-400' : ''}`}>{member.name}</p>
|
|
||||||
<p className="text-xs text-gray-400">{member.position || ''}</p>
|
{/* 정보 영역 - 하단 그라데이션 오버레이 */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent p-3 pt-10">
|
||||||
|
<p className="font-bold text-white text-sm drop-shadow-md">
|
||||||
|
{member.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 호버시 반짝이 효과 */}
|
||||||
|
{!isFormer && (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-tr from-primary/0 via-white/0 to-white/20
|
||||||
|
opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-4">
|
<div className="pb-4">
|
||||||
{/* 현재 멤버 */}
|
{/* 현재 멤버 그리드 */}
|
||||||
|
<div className="px-4 pt-4">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{members.map((member, index) => renderMemberCard(member, index))}
|
{members.map((member, index) => renderMemberCard(member, index))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 전 멤버 */}
|
{/* 전 멤버 */}
|
||||||
{formerMembers.length > 0 && (
|
{formerMembers.length > 0 && (
|
||||||
<>
|
<div className="px-4 mt-8">
|
||||||
<div className="mt-8 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<h2 className="text-lg font-bold text-gray-400">전 멤버</h2>
|
<div className="h-px flex-1 bg-gray-200" />
|
||||||
|
<h2 className="text-sm font-medium text-gray-400 px-2">전 멤버</h2>
|
||||||
|
<div className="h-px flex-1 bg-gray-200" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{formerMembers.map((member, index) => renderMemberCard(member, index, true))}
|
{formerMembers.map((member, index) => renderMemberCard(member, index, true))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 멤버 상세 모달 */}
|
{/* 멤버 상세 모달 - 드래그로 닫기 가능 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{selectedMember && (
|
{selectedMember && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 bg-black/50 z-50 flex items-end"
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-end"
|
||||||
onClick={() => setSelectedMember(null)}
|
onClick={closeModal}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ y: '100%' }}
|
initial={{ y: '100%' }}
|
||||||
animate={{ y: 0 }}
|
animate={{ y: 0 }}
|
||||||
exit={{ y: '100%' }}
|
exit={{ y: '100%' }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
className="bg-white w-full rounded-t-3xl p-6 pb-24"
|
drag="y"
|
||||||
|
dragConstraints={{ top: 0, bottom: 0 }}
|
||||||
|
dragElastic={{ top: 0, bottom: 0.5 }}
|
||||||
|
onDragEnd={(_, info) => {
|
||||||
|
if (info.offset.y > 100 || info.velocity.y > 300) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-white w-full rounded-t-3xl overflow-hidden"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
{/* 드래그 핸들 */}
|
||||||
|
<div className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing">
|
||||||
|
<div className="w-10 h-1 bg-gray-300 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-5 pb-3 border-b border-gray-100">
|
||||||
|
<h3 className="text-lg font-bold">멤버 정보</h3>
|
||||||
|
<button onClick={closeModal} className="p-1.5">
|
||||||
|
<X size={20} className="text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모달 콘텐츠 */}
|
||||||
|
<div className="px-5 py-4 pb-5">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className={`w-24 h-24 rounded-2xl overflow-hidden bg-gray-200 flex-shrink-0 ${selectedMember.is_former ? 'grayscale' : ''}`}>
|
{/* 프로필 이미지 - 원본 비율 */}
|
||||||
|
<div className={`w-28 flex-shrink-0 ${selectedMember.is_former ? 'grayscale' : ''}`}>
|
||||||
|
<div className="aspect-[3/4] rounded-2xl overflow-hidden bg-gradient-to-br from-gray-100 to-gray-200 shadow-lg">
|
||||||
{selectedMember.image_url && (
|
{selectedMember.image_url && (
|
||||||
<img
|
<img
|
||||||
src={selectedMember.image_url}
|
src={selectedMember.image_url}
|
||||||
|
|
@ -90,32 +153,50 @@ function MobileMembers() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="flex-1 flex flex-col justify-between py-1">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold">{selectedMember.name}</h2>
|
<h2 className="text-2xl font-bold text-gray-900">{selectedMember.name}</h2>
|
||||||
<p className="text-gray-500 text-sm">{selectedMember.position}</p>
|
|
||||||
<p className="text-gray-400 text-sm mt-1">
|
{selectedMember.position && (
|
||||||
|
<p className="text-gray-500 text-sm mt-1">{selectedMember.position}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedMember.birth_date && (
|
||||||
|
<div className="flex items-center gap-1.5 mt-2 text-gray-400 text-sm">
|
||||||
|
<Calendar size={14} />
|
||||||
|
<span>
|
||||||
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')}
|
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')}
|
||||||
</p>
|
{calculateAge(selectedMember.birth_date) && (
|
||||||
{/* 전 멤버가 아닌 경우에만 인스타그램 표시 */}
|
<span className="ml-1 text-gray-300">
|
||||||
|
({calculateAge(selectedMember.birth_date)}세)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인스타그램 버튼 - 정보 영역 아래쪽 */}
|
||||||
{!selectedMember.is_former && selectedMember.instagram && (
|
{!selectedMember.is_former && selectedMember.instagram && (
|
||||||
<a
|
<a
|
||||||
href={selectedMember.instagram}
|
href={selectedMember.instagram}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 mt-2 text-pink-500"
|
className="inline-flex items-center gap-1.5 mt-3 px-4 py-2
|
||||||
|
bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500
|
||||||
|
text-white text-sm rounded-full font-medium shadow-sm
|
||||||
|
hover:shadow-md transition-shadow w-fit"
|
||||||
>
|
>
|
||||||
<Instagram size={16} />
|
<Instagram size={14} />
|
||||||
<span className="text-sm">Instagram</span>
|
<span>Instagram</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</div>
|
||||||
onClick={() => setSelectedMember(null)}
|
|
||||||
className="w-full mt-6 py-3 bg-gray-100 rounded-xl font-medium"
|
|
||||||
>
|
|
||||||
닫기
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue