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/
|
||||
build/
|
||||
meilisearch_data/
|
||||
redis_data/
|
||||
|
||||
# Scrape files
|
||||
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
|
||||
/ios
|
||||
/android
|
||||
app/build-*.apk
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@
|
|||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"extra": {
|
||||
"apiUrl": "https://fromis9.caadiq.co.kr/api"
|
||||
"apiUrl": "https://fromis9.caadiq.co.kr/api",
|
||||
"eas": {
|
||||
"projectId": "fdd66225-0e4b-4e84-a231-7f5006ca8ee3"
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
"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-stack": "^7.9.0",
|
||||
"axios": "^1.13.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"expo": "~54.0.31",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-image": "~3.0.11",
|
||||
|
|
@ -25,6 +28,7 @@
|
|||
"nativewind": "^4.2.1",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-color-matrix-image-filters": "^8.0.2",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-pager-view": "6.9.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
|
|
@ -3702,6 +3706,22 @@
|
|||
"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": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz",
|
||||
|
|
@ -4501,6 +4521,12 @@
|
|||
"integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz",
|
||||
|
|
@ -4705,6 +4731,15 @@
|
|||
"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": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
|
|
@ -4858,6 +4893,12 @@
|
|||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"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": {
|
||||
"version": "19.0.21",
|
||||
"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": {
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz",
|
||||
|
|
@ -5380,6 +5489,19 @@
|
|||
"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": {
|
||||
"version": "3.0.24",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
|
||||
|
|
@ -5513,6 +5635,15 @@
|
|||
"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": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
|
|
@ -5923,6 +6054,22 @@
|
|||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"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": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
|
|
@ -7135,6 +7282,12 @@
|
|||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||
"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": {
|
||||
"version": "2.2.3",
|
||||
"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": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-css-interop/-/react-native-css-interop-0.2.1.tgz",
|
||||
|
|
@ -9792,6 +9960,18 @@
|
|||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
|
|
@ -10583,6 +10763,12 @@
|
|||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"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": {
|
||||
"version": "4.0.8",
|
||||
"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-stack": "^7.9.0",
|
||||
"axios": "^1.13.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"expo": "~54.0.31",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-image": "~3.0.11",
|
||||
|
|
@ -26,6 +29,7 @@
|
|||
"nativewind": "^4.2.1",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-color-matrix-image-filters": "^8.0.2",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-pager-view": "6.9.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 {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -8,23 +8,82 @@ import {
|
|||
RefreshControl,
|
||||
Linking,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
Animated,
|
||||
Dimensions,
|
||||
Pressable,
|
||||
PanResponder,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
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 { colors } from '../constants/colors';
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||
const CARD_WIDTH = (SCREEN_WIDTH - 32 - 24) / 3;
|
||||
|
||||
export default function MembersScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [formerMembers, setFormerMembers] = useState<Member[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 () => {
|
||||
try {
|
||||
const data = await getMembers();
|
||||
// 전 멤버 제외
|
||||
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) {
|
||||
console.error('멤버 로드 오류:', error);
|
||||
} finally {
|
||||
|
|
@ -39,29 +98,129 @@ export default function MembersScreen() {
|
|||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fadeAnim.setValue(0);
|
||||
fetchMembers();
|
||||
};
|
||||
|
||||
const openInstagram = (instagram: string) => {
|
||||
Linking.openURL(`https://instagram.com/${instagram}`);
|
||||
const openModal = (member: Member) => {
|
||||
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 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--;
|
||||
if (!birthDate) return null;
|
||||
const birth = dayjs(birthDate);
|
||||
const today = dayjs();
|
||||
return today.diff(birth, 'year');
|
||||
};
|
||||
|
||||
// 날짜 포맷팅 (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) {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>멤버</Text>
|
||||
</View>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
|
@ -69,105 +228,377 @@ export default function MembersScreen() {
|
|||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
{/* 헤더 */}
|
||||
<View style={{
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
}}>
|
||||
<Text style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
멤버
|
||||
</Text>
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* 상단 헤더 */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>멤버</Text>
|
||||
</View>
|
||||
|
||||
{/* 멤버 목록 */}
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{members.map((member) => (
|
||||
<View
|
||||
key={member.id}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{/* 프로필 이미지 */}
|
||||
<Image
|
||||
source={{ uri: member.image_url }}
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: colors.border,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 정보 */}
|
||||
<View style={{ flex: 1, marginLeft: 16, justifyContent: 'center' }}>
|
||||
<Text style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
}}>
|
||||
{member.name}
|
||||
</Text>
|
||||
|
||||
{member.position && (
|
||||
<Text style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 4,
|
||||
}}>
|
||||
{member.position}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text style={{
|
||||
fontSize: 12,
|
||||
color: colors.textTertiary,
|
||||
marginTop: 4,
|
||||
}}>
|
||||
{member.birth_date?.slice(0, 10)} ({calculateAge(member.birth_date)}세)
|
||||
</Text>
|
||||
|
||||
{/* 인스타그램 */}
|
||||
{member.instagram && (
|
||||
<TouchableOpacity
|
||||
onPress={() => openInstagram(member.instagram!)}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="logo-instagram" size={16} color={colors.primary} />
|
||||
<Text style={{
|
||||
fontSize: 12,
|
||||
color: colors.primary,
|
||||
marginLeft: 4,
|
||||
}}>
|
||||
@{member.instagram}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{/* 현재 멤버 그리드 */}
|
||||
<View style={styles.content}>
|
||||
<View style={styles.membersGrid}>
|
||||
{members.map((member, index) => renderMemberCard(member, index))}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* 전 멤버 */}
|
||||
{formerMembers.length > 0 && (
|
||||
<>
|
||||
<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
|
||||
style={[
|
||||
styles.modalOverlay,
|
||||
{
|
||||
opacity: slideAnim.interpolate({
|
||||
inputRange: [0, 300],
|
||||
outputRange: [1, 0],
|
||||
})
|
||||
}
|
||||
]}
|
||||
>
|
||||
<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={styles.modalHeader}>
|
||||
<Text style={styles.modalHeaderTitle}>멤버 정보</Text>
|
||||
<TouchableOpacity
|
||||
onPress={closeModal}
|
||||
style={styles.closeButton}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<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>
|
||||
|
||||
{selectedMember.birth_date && (
|
||||
<View style={styles.modalBirthRow}>
|
||||
<Calendar size={14} color="#9CA3AF" />
|
||||
<Text style={styles.modalBirthText}>
|
||||
{formatDate(selectedMember.birth_date)}
|
||||
{calculateAge(selectedMember.birth_date) && (
|
||||
<Text style={styles.modalAge}> ({calculateAge(selectedMember.birth_date)}세)</Text>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{selectedMember.instagram && (
|
||||
<TouchableOpacity
|
||||
style={styles.instagramButton}
|
||||
onPress={() => openInstagram(selectedMember.instagram!)}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#F58529', '#DD2A7B', '#8134AF', '#515BD4']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.instagramGradient}
|
||||
>
|
||||
<Instagram size={14} color="#FFFFFF" />
|
||||
<Text style={styles.instagramText}>Instagram</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</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 (
|
||||
<>
|
||||
<div className="pb-6">
|
||||
<div>
|
||||
{/* 앨범 히어로 섹션 - 커버 이미지 배경 */}
|
||||
<div className="relative">
|
||||
{/* 배경 블러 이미지 */}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Instagram } from 'lucide-react';
|
||||
import { Instagram, Calendar, X } from 'lucide-react';
|
||||
import { getMembers } from '../../../api/public/members';
|
||||
|
||||
// 모바일 멤버 페이지
|
||||
|
|
@ -18,104 +18,185 @@ function MobileMembers() {
|
|||
.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) => (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
onClick={() => setSelectedMember(member)}
|
||||
className="text-center cursor-pointer"
|
||||
className="cursor-pointer group"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05, duration: 0.3 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<div className={`aspect-square rounded-2xl overflow-hidden bg-gray-200 mb-2 shadow-sm ${isFormer ? 'grayscale' : ''}`}>
|
||||
{member.image_url && (
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{/* 카드 컨테이너 */}
|
||||
<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 && (
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 정보 영역 - 하단 그라데이션 오버레이 */}
|
||||
<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>
|
||||
<p className={`font-medium text-sm ${isFormer ? 'text-gray-400' : ''}`}>{member.name}</p>
|
||||
<p className="text-xs text-gray-400">{member.position || ''}</p>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4">
|
||||
{/* 현재 멤버 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{members.map((member, index) => renderMemberCard(member, index))}
|
||||
<div className="pb-4">
|
||||
{/* 현재 멤버 그리드 */}
|
||||
<div className="px-4 pt-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{members.map((member, index) => renderMemberCard(member, index))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전 멤버 */}
|
||||
{formerMembers.length > 0 && (
|
||||
<>
|
||||
<div className="mt-8 mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-400">전 멤버</h2>
|
||||
<div className="px-4 mt-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<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 className="grid grid-cols-3 gap-3">
|
||||
{formerMembers.map((member, index) => renderMemberCard(member, index, true))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 멤버 상세 모달 */}
|
||||
{/* 멤버 상세 모달 - 드래그로 닫기 가능 */}
|
||||
<AnimatePresence>
|
||||
{selectedMember && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-end"
|
||||
onClick={() => setSelectedMember(null)}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-end"
|
||||
onClick={closeModal}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
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()}
|
||||
>
|
||||
<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' : ''}`}>
|
||||
{selectedMember.image_url && (
|
||||
<img
|
||||
src={selectedMember.image_url}
|
||||
alt={selectedMember.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
{/* 드래그 핸들 */}
|
||||
<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>
|
||||
<h2 className="text-xl font-bold">{selectedMember.name}</h2>
|
||||
<p className="text-gray-500 text-sm">{selectedMember.position}</p>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')}
|
||||
</p>
|
||||
{/* 전 멤버가 아닌 경우에만 인스타그램 표시 */}
|
||||
{!selectedMember.is_former && selectedMember.instagram && (
|
||||
<a
|
||||
href={selectedMember.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 mt-2 text-pink-500"
|
||||
>
|
||||
<Instagram size={16} />
|
||||
<span className="text-sm">Instagram</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* 헤더 */}
|
||||
<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={`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 && (
|
||||
<img
|
||||
src={selectedMember.image_url}
|
||||
alt={selectedMember.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="flex-1 flex flex-col justify-between py-1">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{selectedMember.name}</h2>
|
||||
|
||||
{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('-', '.')}
|
||||
{calculateAge(selectedMember.birth_date) && (
|
||||
<span className="ml-1 text-gray-300">
|
||||
({calculateAge(selectedMember.birth_date)}세)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 인스타그램 버튼 - 정보 영역 아래쪽 */}
|
||||
{!selectedMember.is_former && selectedMember.instagram && (
|
||||
<a
|
||||
href={selectedMember.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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={14} />
|
||||
<span>Instagram</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedMember(null)}
|
||||
className="w-full mt-6 py-3 bg-gray-100 rounded-xl font-medium"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue