feat(app): 멤버 화면 블러 효과 및 dayjs 날짜 처리 적용

- expo-blur로 모달 배경 블러 효과 구현 (experimentalBlurMethod)
- dayjs로 생일 날짜 형식 처리 (YYYY.MM.DD)
- dayjs로 나이 계산 로직 개선
- 헤더 타이틀 '멤버'로 변경 (웹과 동일)
- .easignore 추가 (상위 폴더 데이터 제외)
- redis_data 폴더 gitignore 추가
This commit is contained in:
caadiq 2026-01-12 12:47:59 +09:00
parent 9661742a52
commit 58411f3dca
12 changed files with 906 additions and 164 deletions

1
.gitignore vendored
View file

@ -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
View 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
View file

@ -39,3 +39,4 @@ yarn-error.*
# generated native folders # generated native folders
/ios /ios
/android /android
app/build-*.apk

View file

@ -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

Binary file not shown.

BIN
app/build-1768188981466.apk Normal file

Binary file not shown.

21
app/eas.json Normal file
View 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
View file

@ -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",

View file

@ -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",

View file

@ -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',
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> </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> </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> </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',
},
});

View file

@ -134,7 +134,7 @@ function MobileAlbumDetail() {
return ( return (
<> <>
<div className="pb-6"> <div>
{/* 앨범 히어로 섹션 - 커버 이미지 배경 */} {/* 앨범 히어로 섹션 - 커버 이미지 배경 */}
<div className="relative"> <div className="relative">
{/* 배경 블러 이미지 */} {/* 배경 블러 이미지 */}

View file

@ -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,104 +18,185 @@ 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' : ''}`}> {/* 카드 컨테이너 */}
{member.image_url && ( <div className={`relative overflow-hidden rounded-2xl bg-white shadow-sm
<img transition-shadow duration-300 group-hover:shadow-lg
src={member.image_url} ${isFormer ? 'grayscale' : ''}`}
alt={member.name} >
className="w-full h-full object-cover" {/* 이미지 영역 - 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> </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> </motion.div>
); );
return ( return (
<div className="px-4 py-4"> <div className="pb-4">
{/* 현재 멤버 */} {/* 현재 멤버 그리드 */}
<div className="grid grid-cols-3 gap-3"> <div className="px-4 pt-4">
{members.map((member, index) => renderMemberCard(member, index))} <div className="grid grid-cols-3 gap-3">
{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 gap-4"> {/* 드래그 핸들 */}
<div className={`w-24 h-24 rounded-2xl overflow-hidden bg-gray-200 flex-shrink-0 ${selectedMember.is_former ? 'grayscale' : ''}`}> <div className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing">
{selectedMember.image_url && ( <div className="w-10 h-1 bg-gray-300 rounded-full" />
<img
src={selectedMember.image_url}
alt={selectedMember.name}
className="w-full h-full object-cover"
/>
)}
</div> </div>
<div>
<h2 className="text-xl font-bold">{selectedMember.name}</h2> {/* 헤더 */}
<p className="text-gray-500 text-sm">{selectedMember.position}</p> <div className="flex items-center justify-between px-5 pb-3 border-b border-gray-100">
<p className="text-gray-400 text-sm mt-1"> <h3 className="text-lg font-bold">멤버 정보</h3>
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')} <button onClick={closeModal} className="p-1.5">
</p> <X size={20} className="text-gray-500" />
{/* 전 멤버가 아닌 경우에만 인스타그램 표시 */} </button>
{!selectedMember.is_former && selectedMember.instagram && ( </div>
<a
href={selectedMember.instagram} {/* 모달 콘텐츠 */}
target="_blank" <div className="px-5 py-4 pb-5">
rel="noopener noreferrer" <div className="flex gap-4">
className="inline-flex items-center gap-1 mt-2 text-pink-500" {/* 프로필 이미지 - 원본 비율 */}
> <div className={`w-28 flex-shrink-0 ${selectedMember.is_former ? 'grayscale' : ''}`}>
<Instagram size={16} /> <div className="aspect-[3/4] rounded-2xl overflow-hidden bg-gradient-to-br from-gray-100 to-gray-200 shadow-lg">
<span className="text-sm">Instagram</span> {selectedMember.image_url && (
</a> <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>
</div>
<button
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>
)} )}