From e52951f43c3171b6f4f1474f34a4c60a5688c02f Mon Sep 17 00:00:00 2001 From: caadiq Date: Mon, 12 Jan 2026 15:07:43 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=A8=EB=B2=94=20=EA=B0=A4=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC:=20=ED=97=A4=EB=8D=94=20=EC=B6=94=EA=B0=80,=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=9B=90=EB=B3=B8=20=EB=B9=84?= =?UTF-8?q?=EC=9C=A8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/package-lock.json | 11 + app/package.json | 1 + app/src/api/albums.ts | 2 + app/src/components/common/Header.tsx | 41 +++- app/src/screens/AlbumGalleryScreen.tsx | 265 ++++++++++++++++--------- 5 files changed, 224 insertions(+), 96 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index f642651..05bf266 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -22,6 +22,7 @@ "expo-font": "~14.0.10", "expo-image": "~3.0.11", "expo-linear-gradient": "~15.0.8", + "expo-media-library": "^18.2.1", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-video-thumbnails": "^10.0.8", @@ -5503,6 +5504,16 @@ "expo": "*" } }, + "node_modules/expo-media-library": { + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-18.2.1.tgz", + "integrity": "sha512-dV1acx6Aseu+I5hmF61wY8UkD4vdt8d7YXHDfgNp6ZSs06qxayUxgrBsiG2eigLe54VLm3ycbFBbWi31lhfsCA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.24", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz", diff --git a/app/package.json b/app/package.json index 41754a6..5fc1994 100644 --- a/app/package.json +++ b/app/package.json @@ -23,6 +23,7 @@ "expo-font": "~14.0.10", "expo-image": "~3.0.11", "expo-linear-gradient": "~15.0.8", + "expo-media-library": "^18.2.1", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-video-thumbnails": "^10.0.8", diff --git a/app/src/api/albums.ts b/app/src/api/albums.ts index 6609a2e..bf46e9b 100644 --- a/app/src/api/albums.ts +++ b/app/src/api/albums.ts @@ -33,6 +33,8 @@ export interface AlbumPhoto { thumb_url: string; concept_name?: string; members?: { id: number; name: string }[]; + width?: number; + height?: number; } export interface AlbumTeaser { diff --git a/app/src/components/common/Header.tsx b/app/src/components/common/Header.tsx index ea89b26..12b51cb 100644 --- a/app/src/components/common/Header.tsx +++ b/app/src/components/common/Header.tsx @@ -1,23 +1,45 @@ import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useNavigation } from '@react-navigation/native'; +import { ChevronLeft } from 'lucide-react-native'; import { colors } from '../../constants/colors'; interface HeaderProps { // 페이지 제목 (없으면 "fromis_9" 표시) title?: string; + // 오른쪽 영역에 표시할 요소 + rightElement?: React.ReactNode; + // 뒤로가기 버튼 표시 여부 + showBack?: boolean; } // 모바일 웹 MobileHeader와 동일한 공통 헤더 컴포넌트 -export default function Header({ title }: HeaderProps) { +export default function Header({ title, rightElement, showBack }: HeaderProps) { const insets = useSafeAreaInsets(); + const navigation = useNavigation(); return ( + {/* 왼쪽 영역 */} + + {showBack && ( + navigation.goBack()} style={styles.backButton}> + + + )} + + + {/* 중앙 타이틀 */} {title || 'fromis_9'} + + {/* 오른쪽 영역 */} + + {rightElement} + ); @@ -34,13 +56,26 @@ const styles = StyleSheet.create({ }, headerContent: { height: 56, + flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', + justifyContent: 'space-between', paddingHorizontal: 16, }, + leftSection: { + width: 40, + alignItems: 'flex-start', + }, + backButton: { + padding: 4, + marginLeft: -4, + }, headerTitle: { fontSize: 20, fontWeight: '700', color: colors.primary, }, + rightSection: { + width: 40, + alignItems: 'flex-end', + }, }); diff --git a/app/src/screens/AlbumGalleryScreen.tsx b/app/src/screens/AlbumGalleryScreen.tsx index 2dac3a4..0bfa82f 100644 --- a/app/src/screens/AlbumGalleryScreen.tsx +++ b/app/src/screens/AlbumGalleryScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { View, Text, @@ -7,39 +7,45 @@ import { ActivityIndicator, Dimensions, Modal, + ScrollView, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { Ionicons } from '@expo/vector-icons'; +import { ChevronLeft, X, Download } from 'lucide-react-native'; import PagerView from 'react-native-pager-view'; +import * as FileSystem from 'expo-file-system'; +import * as MediaLibrary from 'expo-media-library'; +import { LinearGradient } from 'expo-linear-gradient'; import { getAlbumByName, Album, AlbumPhoto } from '../api/albums'; import { colors } from '../constants/colors'; +import Header from '../components/common/Header'; import type { AlbumStackParamList } from '../navigation/AppNavigator'; type NavigationProp = NativeStackNavigationProp; type RouteType = RouteProp; -const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); export default function AlbumGalleryScreen() { const navigation = useNavigation(); const route = useRoute(); const { name } = route.params; - const [photos, setPhotos] = useState([]); + const [album, setAlbum] = useState(null); const [loading, setLoading] = useState(true); // 라이트박스 상태 const [lightboxVisible, setLightboxVisible] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); + const pagerRef = useRef(null); useEffect(() => { const fetchAlbum = async () => { try { const data = await getAlbumByName(name); - setPhotos(data.photos || []); + setAlbum(data); } catch (error) { console.error('앨범 로드 오류:', error); } finally { @@ -49,10 +55,42 @@ export default function AlbumGalleryScreen() { fetchAlbum(); }, [name]); - const openLightbox = (index: number) => { + // 모든 컨셉 포토를 하나의 배열로 (웹과 동일) + const allPhotos = useMemo(() => { + if (!album) return []; + return album.conceptPhotos + ? Object.values(album.conceptPhotos).flat() as AlbumPhoto[] + : (album.photos || []); + }, [album]); + + const openLightbox = useCallback((index: number) => { setSelectedIndex(index); setLightboxVisible(true); - }; + }, []); + + const closeLightbox = useCallback(() => { + setLightboxVisible(false); + }, []); + + // 이미지 다운로드 + const downloadImage = useCallback(async () => { + const photo = allPhotos[selectedIndex]; + if (!photo) return; + + try { + const { status } = await MediaLibrary.requestPermissionsAsync(); + if (status !== 'granted') return; + + const filename = photo.original_url.split('/').pop() || 'photo.jpg'; + const fileUri = FileSystem.documentDirectory + filename; + + await FileSystem.downloadAsync(photo.original_url, fileUri); + await MediaLibrary.saveToLibraryAsync(fileUri); + await FileSystem.deleteAsync(fileUri); + } catch (error) { + console.error('다운로드 오류:', error); + } + }, [allPhotos, selectedIndex]); if (loading) { return ( @@ -64,123 +102,164 @@ export default function AlbumGalleryScreen() { ); } - // 2열로 분배 - const leftColumn = photos.filter((_, i) => i % 2 === 0); - const rightColumn = photos.filter((_, i) => i % 2 === 1); + // 2열 지그재그로 분배 (웹 동일) + const leftColumn = allPhotos.filter((_, i) => i % 2 === 0).map((photo, idx) => ({ ...photo, originalIndex: idx * 2 })); + const rightColumn = allPhotos.filter((_, i) => i % 2 === 1).map((photo, idx) => ({ ...photo, originalIndex: idx * 2 + 1 })); return ( - - {/* 헤더 */} - - navigation.goBack()}> - - - - 컨셉 포토 - - - {photos.length}장 - - - - {/* 2열 그리드 */} - - {/* 왼쪽 열 */} - - {leftColumn.map((photo, idx) => ( - openLightbox(idx * 2)} - style={{ marginBottom: 8 }} - > - +
+ + {/* 앨범 헤더 카드 - 웹: mx-4 mt-4 mb-4 p-4 bg-gradient-to-r from-primary/5 to-primary/10 rounded-2xl */} + navigation.goBack()} + activeOpacity={0.7} + style={{ + marginHorizontal: 16, + marginTop: 16, + marginBottom: 16, + padding: 16, + borderRadius: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 16, + backgroundColor: 'rgba(34, 197, 94, 0.08)', + }} + > + {/* 앨범 커버 */} + {album?.cover_thumb_url && ( + - - ))} - - - {/* 오른쪽 열 */} - - {rightColumn.map((photo, idx) => ( - openLightbox(idx * 2 + 1)} - style={{ marginBottom: 8 }} - > - - - ))} - - + )} + + {/* 앨범 정보 */} + + {/* 웹: text-xs text-primary font-medium mb-0.5 */} + + 컨셉 포토 + + {/* 웹: font-bold truncate */} + + {album?.title} + + {/* 웹: text-xs text-gray-500 */} + + {allPhotos.length}장의 사진 + + + + {/* 뒤로가기 화살표 - 웹: rotate-180 */} + + - {/* 라이트박스 모달 */} + {/* 2열 그리드 - 웹: px-3 flex gap-2 */} + + {/* 왼쪽 열 - 웹: flex-1 flex flex-col gap-2 */} + + {leftColumn.map((photo) => ( + openLightbox(photo.originalIndex)} + activeOpacity={0.8} + style={{ + borderRadius: 12, + overflow: 'hidden', + backgroundColor: '#F3F4F6', + }} + > + + + ))} + + + {/* 오른쪽 열 */} + + {rightColumn.map((photo) => ( + openLightbox(photo.originalIndex)} + activeOpacity={0.8} + style={{ + borderRadius: 12, + overflow: 'hidden', + backgroundColor: '#F3F4F6', + }} + > + + + ))} + + + + + {/* 풀스크린 라이트박스 - 웹: fixed inset-0 bg-black z-[60] */} setLightboxVisible(false)} + onRequestClose={closeLightbox} > - {/* 헤더 */} + {/* 상단 헤더 - 3등분 - 웹: absolute top-0 left-0 right-0 flex items-center px-4 py-3 z-20 */} - setLightboxVisible(false)}> - - - - {selectedIndex + 1} / {photos.length} + {/* 왼쪽: X 버튼 */} + + + + + + + {/* 중앙: 페이지 표시 - 웹: text-white/70 text-sm tabular-nums */} + + {selectedIndex + 1} / {allPhotos.length} - + + {/* 오른쪽: 다운로드 버튼 */} + + + + + - {/* PagerView */} + {/* PagerView - 웹: Swiper */} setSelectedIndex(e.nativeEvent.position)} > - {photos.map((photo, index) => ( - + {allPhotos.map((photo) => ( + @@ -189,6 +268,6 @@ export default function AlbumGalleryScreen() { - + ); }