앨범 갤러리: 헤더 추가, 이미지 원본 비율 적용

This commit is contained in:
caadiq 2026-01-12 15:07:43 +09:00
parent 64e12e47ae
commit e52951f43c
5 changed files with 224 additions and 96 deletions

11
app/package-lock.json generated
View file

@ -22,6 +22,7 @@
"expo-font": "~14.0.10", "expo-font": "~14.0.10",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
"expo-linear-gradient": "~15.0.8", "expo-linear-gradient": "~15.0.8",
"expo-media-library": "^18.2.1",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-video-thumbnails": "^10.0.8", "expo-video-thumbnails": "^10.0.8",
@ -5503,6 +5504,16 @@
"expo": "*" "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": { "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",

View file

@ -23,6 +23,7 @@
"expo-font": "~14.0.10", "expo-font": "~14.0.10",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
"expo-linear-gradient": "~15.0.8", "expo-linear-gradient": "~15.0.8",
"expo-media-library": "^18.2.1",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-video-thumbnails": "^10.0.8", "expo-video-thumbnails": "^10.0.8",

View file

@ -33,6 +33,8 @@ export interface AlbumPhoto {
thumb_url: string; thumb_url: string;
concept_name?: string; concept_name?: string;
members?: { id: number; name: string }[]; members?: { id: number; name: string }[];
width?: number;
height?: number;
} }
export interface AlbumTeaser { export interface AlbumTeaser {

View file

@ -1,23 +1,45 @@
import React from 'react'; 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 { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { ChevronLeft } from 'lucide-react-native';
import { colors } from '../../constants/colors'; import { colors } from '../../constants/colors';
interface HeaderProps { interface HeaderProps {
// 페이지 제목 (없으면 "fromis_9" 표시) // 페이지 제목 (없으면 "fromis_9" 표시)
title?: string; title?: string;
// 오른쪽 영역에 표시할 요소
rightElement?: React.ReactNode;
// 뒤로가기 버튼 표시 여부
showBack?: boolean;
} }
// 모바일 웹 MobileHeader와 동일한 공통 헤더 컴포넌트 // 모바일 웹 MobileHeader와 동일한 공통 헤더 컴포넌트
export default function Header({ title }: HeaderProps) { export default function Header({ title, rightElement, showBack }: HeaderProps) {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const navigation = useNavigation();
return ( return (
<View style={[styles.header, { paddingTop: insets.top }]}> <View style={[styles.header, { paddingTop: insets.top }]}>
<View style={styles.headerContent}> <View style={styles.headerContent}>
{/* 왼쪽 영역 */}
<View style={styles.leftSection}>
{showBack && (
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
<ChevronLeft size={24} color={colors.textPrimary} />
</TouchableOpacity>
)}
</View>
{/* 중앙 타이틀 */}
<Text style={styles.headerTitle}> <Text style={styles.headerTitle}>
{title || 'fromis_9'} {title || 'fromis_9'}
</Text> </Text>
{/* 오른쪽 영역 */}
<View style={styles.rightSection}>
{rightElement}
</View>
</View> </View>
</View> </View>
); );
@ -34,13 +56,26 @@ const styles = StyleSheet.create({
}, },
headerContent: { headerContent: {
height: 56, height: 56,
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'space-between',
paddingHorizontal: 16, paddingHorizontal: 16,
}, },
leftSection: {
width: 40,
alignItems: 'flex-start',
},
backButton: {
padding: 4,
marginLeft: -4,
},
headerTitle: { headerTitle: {
fontSize: 20, fontSize: 20,
fontWeight: '700', fontWeight: '700',
color: colors.primary, color: colors.primary,
}, },
rightSection: {
width: 40,
alignItems: 'flex-end',
},
}); });

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { import {
View, View,
Text, Text,
@ -7,39 +7,45 @@ import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
Modal, Modal,
ScrollView,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; 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 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 { getAlbumByName, Album, AlbumPhoto } from '../api/albums';
import { colors } from '../constants/colors'; import { colors } from '../constants/colors';
import Header from '../components/common/Header';
import type { AlbumStackParamList } from '../navigation/AppNavigator'; import type { AlbumStackParamList } from '../navigation/AppNavigator';
type NavigationProp = NativeStackNavigationProp<AlbumStackParamList>; type NavigationProp = NativeStackNavigationProp<AlbumStackParamList>;
type RouteType = RouteProp<AlbumStackParamList, 'AlbumGallery'>; type RouteType = RouteProp<AlbumStackParamList, 'AlbumGallery'>;
const { width: SCREEN_WIDTH } = Dimensions.get('window'); const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
export default function AlbumGalleryScreen() { export default function AlbumGalleryScreen() {
const navigation = useNavigation<NavigationProp>(); const navigation = useNavigation<NavigationProp>();
const route = useRoute<RouteType>(); const route = useRoute<RouteType>();
const { name } = route.params; const { name } = route.params;
const [photos, setPhotos] = useState<AlbumPhoto[]>([]); const [album, setAlbum] = useState<Album | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// 라이트박스 상태 // 라이트박스 상태
const [lightboxVisible, setLightboxVisible] = useState(false); const [lightboxVisible, setLightboxVisible] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const pagerRef = useRef<PagerView>(null);
useEffect(() => { useEffect(() => {
const fetchAlbum = async () => { const fetchAlbum = async () => {
try { try {
const data = await getAlbumByName(name); const data = await getAlbumByName(name);
setPhotos(data.photos || []); setAlbum(data);
} catch (error) { } catch (error) {
console.error('앨범 로드 오류:', error); console.error('앨범 로드 오류:', error);
} finally { } finally {
@ -49,10 +55,42 @@ export default function AlbumGalleryScreen() {
fetchAlbum(); fetchAlbum();
}, [name]); }, [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); setSelectedIndex(index);
setLightboxVisible(true); 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) { if (loading) {
return ( return (
@ -64,123 +102,164 @@ export default function AlbumGalleryScreen() {
); );
} }
// 2열로 분배 // 2열 지그재그로 분배 (웹 동일)
const leftColumn = photos.filter((_, i) => i % 2 === 0); const leftColumn = allPhotos.filter((_, i) => i % 2 === 0).map((photo, idx) => ({ ...photo, originalIndex: idx * 2 }));
const rightColumn = photos.filter((_, i) => i % 2 === 1); const rightColumn = allPhotos.filter((_, i) => i % 2 === 1).map((photo, idx) => ({ ...photo, originalIndex: idx * 2 + 1 }));
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}> <View style={{ flex: 1, backgroundColor: colors.background }}>
{/* 헤더 */} <Header title="앨범" />
<View style={{ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 16 }}>
{/* 앨범 헤더 카드 - 웹: mx-4 mt-4 mb-4 p-4 bg-gradient-to-r from-primary/5 to-primary/10 rounded-2xl */}
<TouchableOpacity
onPress={() => navigation.goBack()}
activeOpacity={0.7}
style={{
marginHorizontal: 16,
marginTop: 16,
marginBottom: 16,
padding: 16,
borderRadius: 16,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 16, gap: 16,
paddingVertical: 12, backgroundColor: 'rgba(34, 197, 94, 0.08)',
borderBottomWidth: 1, }}
borderBottomColor: colors.borderLight, >
}}> {/* 앨범 커버 */}
<TouchableOpacity onPress={() => navigation.goBack()}> {album?.cover_thumb_url && (
<Ionicons name="arrow-back" size={24} color={colors.textPrimary} /> <Image
</TouchableOpacity> source={{ uri: album.cover_thumb_url }}
<Text style={{ style={{
flex: 1, width: 56,
fontSize: 18, height: 56,
fontWeight: '600', borderRadius: 12,
color: colors.textPrimary, // shadow-sm
textAlign: 'center', shadowColor: '#000',
}}> shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
}}
/>
)}
{/* 앨범 정보 */}
<View style={{ flex: 1, minWidth: 0 }}>
{/* 웹: text-xs text-primary font-medium mb-0.5 */}
<Text style={{ fontSize: 12, color: colors.primary, fontWeight: '500', marginBottom: 2 }}>
</Text> </Text>
<Text style={{ fontSize: 13, color: colors.textSecondary }}> {/* 웹: font-bold truncate */}
{photos.length} <Text style={{ fontWeight: '700', color: colors.textPrimary }} numberOfLines={1}>
{album?.title}
</Text>
{/* 웹: text-xs text-gray-500 */}
<Text style={{ fontSize: 12, color: '#6B7280' }}>
{allPhotos.length}
</Text> </Text>
</View> </View>
{/* 2열 그리드 */} {/* 뒤로가기 화살표 - 웹: rotate-180 */}
<View style={{ flex: 1, flexDirection: 'row', padding: 8 }}> <ChevronLeft size={20} color="#9CA3AF" />
{/* 왼쪽 열 */} </TouchableOpacity>
<View style={{ flex: 1, paddingRight: 4 }}>
{leftColumn.map((photo, idx) => ( {/* 2열 그리드 - 웹: px-3 flex gap-2 */}
<View style={{ paddingHorizontal: 12, flexDirection: 'row', gap: 8 }}>
{/* 왼쪽 열 - 웹: flex-1 flex flex-col gap-2 */}
<View style={{ flex: 1, gap: 8 }}>
{leftColumn.map((photo) => (
<TouchableOpacity <TouchableOpacity
key={photo.id} key={photo.id}
onPress={() => openLightbox(idx * 2)} onPress={() => openLightbox(photo.originalIndex)}
style={{ marginBottom: 8 }} activeOpacity={0.8}
style={{
borderRadius: 12,
overflow: 'hidden',
backgroundColor: '#F3F4F6',
}}
> >
<Image <Image
source={{ uri: photo.medium_url || photo.thumb_url }} source={{ uri: photo.thumb_url || photo.medium_url }}
style={{ style={{ width: '100%', aspectRatio: photo.width && photo.height ? photo.width / photo.height : 0.75 }}
width: '100%',
aspectRatio: 0.75,
borderRadius: 12,
backgroundColor: colors.border,
}}
/> />
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
{/* 오른쪽 열 */} {/* 오른쪽 열 */}
<View style={{ flex: 1, paddingLeft: 4 }}> <View style={{ flex: 1, gap: 8 }}>
{rightColumn.map((photo, idx) => ( {rightColumn.map((photo) => (
<TouchableOpacity <TouchableOpacity
key={photo.id} key={photo.id}
onPress={() => openLightbox(idx * 2 + 1)} onPress={() => openLightbox(photo.originalIndex)}
style={{ marginBottom: 8 }} activeOpacity={0.8}
style={{
borderRadius: 12,
overflow: 'hidden',
backgroundColor: '#F3F4F6',
}}
> >
<Image <Image
source={{ uri: photo.medium_url || photo.thumb_url }} source={{ uri: photo.thumb_url || photo.medium_url }}
style={{ style={{ width: '100%', aspectRatio: photo.width && photo.height ? photo.width / photo.height : 0.75 }}
width: '100%',
aspectRatio: 0.75,
borderRadius: 12,
backgroundColor: colors.border,
}}
/> />
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
</View> </View>
</ScrollView>
{/* 라이트박스 모달 */} {/* 풀스크린 라이트박스 - 웹: fixed inset-0 bg-black z-[60] */}
<Modal <Modal
visible={lightboxVisible} visible={lightboxVisible}
transparent transparent
animationType="fade" animationType="fade"
onRequestClose={() => setLightboxVisible(false)} onRequestClose={closeLightbox}
> >
<View style={{ flex: 1, backgroundColor: 'black' }}> <View style={{ flex: 1, backgroundColor: 'black' }}>
{/* 헤더 */} {/* 상단 헤더 - 3등분 - 웹: absolute top-0 left-0 right-0 flex items-center px-4 py-3 z-20 */}
<SafeAreaView> <SafeAreaView>
<View style={{ <View style={{
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
}}> }}>
<TouchableOpacity onPress={() => setLightboxVisible(false)}> {/* 왼쪽: X 버튼 */}
<Ionicons name="close" size={28} color="white" /> <View style={{ flex: 1, alignItems: 'flex-start' }}>
<TouchableOpacity onPress={closeLightbox} style={{ padding: 4 }}>
<X size={24} color="rgba(255,255,255,0.8)" />
</TouchableOpacity> </TouchableOpacity>
<Text style={{ color: 'white', fontSize: 14 }}> </View>
{selectedIndex + 1} / {photos.length}
{/* 중앙: 페이지 표시 - 웹: text-white/70 text-sm tabular-nums */}
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 14, fontVariant: ['tabular-nums'] }}>
{selectedIndex + 1} / {allPhotos.length}
</Text> </Text>
<View style={{ width: 28 }} />
{/* 오른쪽: 다운로드 버튼 */}
<View style={{ flex: 1, alignItems: 'flex-end' }}>
<TouchableOpacity onPress={downloadImage} style={{ padding: 4 }}>
<Download size={22} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
</View>
</View> </View>
</SafeAreaView> </SafeAreaView>
{/* PagerView */} {/* PagerView - 웹: Swiper */}
<PagerView <PagerView
ref={pagerRef}
style={{ flex: 1 }} style={{ flex: 1 }}
initialPage={selectedIndex} initialPage={selectedIndex}
onPageSelected={(e) => setSelectedIndex(e.nativeEvent.position)} onPageSelected={(e) => setSelectedIndex(e.nativeEvent.position)}
> >
{photos.map((photo, index) => ( {allPhotos.map((photo) => (
<View key={photo.id} style={{ flex: 1, justifyContent: 'center' }}> <View key={photo.id} style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Image <Image
source={{ uri: photo.original_url || photo.medium_url }} source={{ uri: photo.medium_url || photo.original_url }}
style={{ style={{
width: SCREEN_WIDTH, width: SCREEN_WIDTH,
height: SCREEN_WIDTH * 1.33, height: SCREEN_HEIGHT * 0.8,
}} }}
resizeMode="contain" resizeMode="contain"
/> />
@ -189,6 +268,6 @@ export default function AlbumGalleryScreen() {
</PagerView> </PagerView>
</View> </View>
</Modal> </Modal>
</SafeAreaView> </View>
); );
} }