diff --git a/.gitignore b/.gitignore index ac6620a..b5fd2b3 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ Thumbs.db dist/ build/ meilisearch_data/ +redis_data/ # Scrape files backend/scrape_*.cjs diff --git a/app/.easignore b/app/.easignore new file mode 100644 index 0000000..7e1d9f5 --- /dev/null +++ b/app/.easignore @@ -0,0 +1,14 @@ +# EAS Build에서 제외할 파일/폴더 +node_modules/ +.expo/ +dist/ +.git/ +*.log + +# 상위 폴더의 데이터 폴더들 +../redis_data/ +../meilisearch_data/ +../backend/ +../frontend/ +**/redis_data/ +**/meilisearch_data/ diff --git a/app/.gitignore b/app/.gitignore index d914c32..e91e1a9 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -39,3 +39,4 @@ yarn-error.* # generated native folders /ios /android +app/build-*.apk diff --git a/app/app.json b/app/app.json index d614824..6a9b389 100644 --- a/app/app.json +++ b/app/app.json @@ -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" diff --git a/app/build-1768186867181.apk b/app/build-1768186867181.apk new file mode 100644 index 0000000..258e52f Binary files /dev/null and b/app/build-1768186867181.apk differ diff --git a/app/build-1768188981466.apk b/app/build-1768188981466.apk new file mode 100644 index 0000000..67809f8 Binary files /dev/null and b/app/build-1768188981466.apk differ diff --git a/app/eas.json b/app/eas.json new file mode 100644 index 0000000..f4001c5 --- /dev/null +++ b/app/eas.json @@ -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": {} + } +} diff --git a/app/package-lock.json b/app/package-lock.json index b79a255..f02a23b 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -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", diff --git a/app/package.json b/app/package.json index 037cf16..b5a15ed 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/screens/MembersScreen.tsx b/app/src/screens/MembersScreen.tsx index 6eb844c..efd15ac 100644 --- a/app/src/screens/MembersScreen.tsx +++ b/app/src/screens/MembersScreen.tsx @@ -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([]); + const [formerMembers, setFormerMembers] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + const [selectedMember, setSelectedMember] = useState(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 = ( + + ); + + if (isFormer) { + return {imageElement}; } - return age; + return imageElement; + }; + + // 멤버 카드 렌더링 + const renderMemberCard = (member: Member, index: number, isFormer: boolean = false) => ( + + openModal(member)} + activeOpacity={0.9} + > + {/* 카드 컨테이너 - 3:4 비율 */} + + {/* 이미지 */} + + {member.image_url && renderMemberImage(member.image_url, isFormer)} + + + {/* 하단 그라데이션 오버레이 */} + + {member.name} + + + + + ); + + // 모달 이미지 렌더링 + const renderModalImage = (imageUrl: string, isFormer: boolean) => { + const imageElement = ( + + ); + + if (isFormer) { + return {imageElement}; + } + return imageElement; }; if (loading) { return ( - - + + + 멤버 + + @@ -69,105 +228,377 @@ export default function MembersScreen() { } return ( - - {/* 헤더 */} - - - 멤버 - + + {/* 상단 헤더 */} + + 멤버 {/* 멤버 목록 */} } - contentContainerStyle={{ padding: 16 }} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.scrollContent} > - {members.map((member) => ( - - {/* 프로필 이미지 */} - - - {/* 정보 */} - - - {member.name} - - - {member.position && ( - - {member.position} - - )} - - - {member.birth_date?.slice(0, 10)} ({calculateAge(member.birth_date)}세) - - - {/* 인스타그램 */} - {member.instagram && ( - openInstagram(member.instagram!)} - style={{ - flexDirection: 'row', - alignItems: 'center', - marginTop: 8, - }} - > - - - @{member.instagram} - - - )} - + {/* 현재 멤버 그리드 */} + + + {members.map((member, index) => renderMemberCard(member, index))} - ))} + + {/* 전 멤버 */} + {formerMembers.length > 0 && ( + <> + + + 전 멤버 + + + + {formerMembers.map((member, index) => renderMemberCard(member, index, true))} + + + )} + + + {/* 멤버 상세 모달 */} + + + + + + + {/* 드래그 핸들 */} + + + + + {/* 헤더 */} + + 멤버 정보 + + + + + + {selectedMember && ( + + + {/* 프로필 이미지 - 3:4 비율 */} + + + {selectedMember.image_url && renderModalImage(selectedMember.image_url, selectedMember.is_former)} + + + + {/* 멤버 정보 */} + + {selectedMember.name} + + {selectedMember.position?.replaceAll(',', ', ')} + + + {selectedMember.birth_date && ( + + + + {formatDate(selectedMember.birth_date)} + {calculateAge(selectedMember.birth_date) && ( + ({calculateAge(selectedMember.birth_date)}세) + )} + + + )} + + {selectedMember.instagram && ( + openInstagram(selectedMember.instagram!)} + > + + + Instagram + + + )} + + + + )} + + + ); } + +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', + }, +}); diff --git a/frontend/src/pages/mobile/public/AlbumDetail.jsx b/frontend/src/pages/mobile/public/AlbumDetail.jsx index 535412c..6390836 100644 --- a/frontend/src/pages/mobile/public/AlbumDetail.jsx +++ b/frontend/src/pages/mobile/public/AlbumDetail.jsx @@ -134,7 +134,7 @@ function MobileAlbumDetail() { return ( <> -
+
{/* 앨범 히어로 섹션 - 커버 이미지 배경 */}
{/* 배경 블러 이미지 */} diff --git a/frontend/src/pages/mobile/public/Members.jsx b/frontend/src/pages/mobile/public/Members.jsx index acad70e..e63ea31 100644 --- a/frontend/src/pages/mobile/public/Members.jsx +++ b/frontend/src/pages/mobile/public/Members.jsx @@ -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) => ( 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 }} > -
- {member.image_url && ( - {member.name} + {/* 카드 컨테이너 */} +
+ {/* 이미지 영역 - 3:4 비율 */} +
+ {member.image_url && ( + {member.name} + )} +
+ + {/* 정보 영역 - 하단 그라데이션 오버레이 */} +
+

+ {member.name} +

+
+ + {/* 호버시 반짝이 효과 */} + {!isFormer && ( +
)}
-

{member.name}

-

{member.position || ''}

); return ( -
- {/* 현재 멤버 */} -
- {members.map((member, index) => renderMemberCard(member, index))} +
+ {/* 현재 멤버 그리드 */} +
+
+ {members.map((member, index) => renderMemberCard(member, index))} +
{/* 전 멤버 */} {formerMembers.length > 0 && ( - <> -
-

전 멤버

+
+
+
+

전 멤버

+
{formerMembers.map((member, index) => renderMemberCard(member, index, true))}
- +
)} - {/* 멤버 상세 모달 */} + {/* 멤버 상세 모달 - 드래그로 닫기 가능 */} {selectedMember && ( setSelectedMember(null)} + className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-end" + onClick={closeModal} > { + if (info.offset.y > 100 || info.velocity.y > 300) { + closeModal(); + } + }} + className="bg-white w-full rounded-t-3xl overflow-hidden" onClick={e => e.stopPropagation()} > -
-
- {selectedMember.image_url && ( - {selectedMember.name} - )} + {/* 드래그 핸들 */} +
+
-
-

{selectedMember.name}

-

{selectedMember.position}

-

- {selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')} -

- {/* 전 멤버가 아닌 경우에만 인스타그램 표시 */} - {!selectedMember.is_former && selectedMember.instagram && ( - - - Instagram - - )} + + {/* 헤더 */} +
+

멤버 정보

+ +
+ + {/* 모달 콘텐츠 */} +
+
+ {/* 프로필 이미지 - 원본 비율 */} +
+
+ {selectedMember.image_url && ( + {selectedMember.name} + )} +
+
+ + {/* 정보 */} +
+
+

{selectedMember.name}

+ + {selectedMember.position && ( +

{selectedMember.position}

+ )} + + {selectedMember.birth_date && ( +
+ + + {selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')} + {calculateAge(selectedMember.birth_date) && ( + + ({calculateAge(selectedMember.birth_date)}세) + + )} + +
+ )} +
+ + {/* 인스타그램 버튼 - 정보 영역 아래쪽 */} + {!selectedMember.is_former && selectedMember.instagram && ( + + + Instagram + + )} +
+
-
- )}