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() {
-
+
);
}