앨범 갤러리: 헤더 추가, 이미지 원본 비율 적용
This commit is contained in:
parent
64e12e47ae
commit
e52951f43c
5 changed files with 224 additions and 96 deletions
11
app/package-lock.json
generated
11
app/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<View style={[styles.header, { paddingTop: insets.top }]}>
|
||||
<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}>
|
||||
{title || 'fromis_9'}
|
||||
</Text>
|
||||
|
||||
{/* 오른쪽 영역 */}
|
||||
<View style={styles.rightSection}>
|
||||
{rightElement}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<AlbumStackParamList>;
|
||||
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() {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<RouteType>();
|
||||
const { name } = route.params;
|
||||
|
||||
const [photos, setPhotos] = useState<AlbumPhoto[]>([]);
|
||||
const [album, setAlbum] = useState<Album | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 라이트박스 상태
|
||||
const [lightboxVisible, setLightboxVisible] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const pagerRef = useRef<PagerView>(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 (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
{/* 헤더 */}
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
}}>
|
||||
<TouchableOpacity onPress={() => navigation.goBack()}>
|
||||
<Ionicons name="arrow-back" size={24} color={colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={{
|
||||
flex: 1,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
컨셉 포토
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: colors.textSecondary }}>
|
||||
{photos.length}장
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 2열 그리드 */}
|
||||
<View style={{ flex: 1, flexDirection: 'row', padding: 8 }}>
|
||||
{/* 왼쪽 열 */}
|
||||
<View style={{ flex: 1, paddingRight: 4 }}>
|
||||
{leftColumn.map((photo, idx) => (
|
||||
<TouchableOpacity
|
||||
key={photo.id}
|
||||
onPress={() => openLightbox(idx * 2)}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<Header title="앨범" />
|
||||
<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',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.08)',
|
||||
}}
|
||||
>
|
||||
{/* 앨범 커버 */}
|
||||
{album?.cover_thumb_url && (
|
||||
<Image
|
||||
source={{ uri: photo.medium_url || photo.thumb_url }}
|
||||
source={{ uri: album.cover_thumb_url }}
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: 0.75,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.border,
|
||||
// shadow-sm
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 오른쪽 열 */}
|
||||
<View style={{ flex: 1, paddingLeft: 4 }}>
|
||||
{rightColumn.map((photo, idx) => (
|
||||
<TouchableOpacity
|
||||
key={photo.id}
|
||||
onPress={() => openLightbox(idx * 2 + 1)}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: photo.medium_url || photo.thumb_url }}
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: 0.75,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.border,
|
||||
}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{/* 앨범 정보 */}
|
||||
<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>
|
||||
{/* 웹: font-bold truncate */}
|
||||
<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>
|
||||
</View>
|
||||
|
||||
{/* 라이트박스 모달 */}
|
||||
{/* 뒤로가기 화살표 - 웹: rotate-180 */}
|
||||
<ChevronLeft size={20} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 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
|
||||
key={photo.id}
|
||||
onPress={() => openLightbox(photo.originalIndex)}
|
||||
activeOpacity={0.8}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#F3F4F6',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: photo.thumb_url || photo.medium_url }}
|
||||
style={{ width: '100%', aspectRatio: photo.width && photo.height ? photo.width / photo.height : 0.75 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 오른쪽 열 */}
|
||||
<View style={{ flex: 1, gap: 8 }}>
|
||||
{rightColumn.map((photo) => (
|
||||
<TouchableOpacity
|
||||
key={photo.id}
|
||||
onPress={() => openLightbox(photo.originalIndex)}
|
||||
activeOpacity={0.8}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#F3F4F6',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: photo.thumb_url || photo.medium_url }}
|
||||
style={{ width: '100%', aspectRatio: photo.width && photo.height ? photo.width / photo.height : 0.75 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* 풀스크린 라이트박스 - 웹: fixed inset-0 bg-black z-[60] */}
|
||||
<Modal
|
||||
visible={lightboxVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setLightboxVisible(false)}
|
||||
onRequestClose={closeLightbox}
|
||||
>
|
||||
<View style={{ flex: 1, backgroundColor: 'black' }}>
|
||||
{/* 헤더 */}
|
||||
{/* 상단 헤더 - 3등분 - 웹: absolute top-0 left-0 right-0 flex items-center px-4 py-3 z-20 */}
|
||||
<SafeAreaView>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
}}>
|
||||
<TouchableOpacity onPress={() => setLightboxVisible(false)}>
|
||||
<Ionicons name="close" size={28} color="white" />
|
||||
</TouchableOpacity>
|
||||
<Text style={{ color: 'white', fontSize: 14 }}>
|
||||
{selectedIndex + 1} / {photos.length}
|
||||
{/* 왼쪽: X 버튼 */}
|
||||
<View style={{ flex: 1, alignItems: 'flex-start' }}>
|
||||
<TouchableOpacity onPress={closeLightbox} style={{ padding: 4 }}>
|
||||
<X size={24} color="rgba(255,255,255,0.8)" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 중앙: 페이지 표시 - 웹: 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>
|
||||
<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>
|
||||
</SafeAreaView>
|
||||
|
||||
{/* PagerView */}
|
||||
{/* PagerView - 웹: Swiper */}
|
||||
<PagerView
|
||||
ref={pagerRef}
|
||||
style={{ flex: 1 }}
|
||||
initialPage={selectedIndex}
|
||||
onPageSelected={(e) => setSelectedIndex(e.nativeEvent.position)}
|
||||
>
|
||||
{photos.map((photo, index) => (
|
||||
<View key={photo.id} style={{ flex: 1, justifyContent: 'center' }}>
|
||||
{allPhotos.map((photo) => (
|
||||
<View key={photo.id} style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Image
|
||||
source={{ uri: photo.original_url || photo.medium_url }}
|
||||
source={{ uri: photo.medium_url || photo.original_url }}
|
||||
style={{
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_WIDTH * 1.33,
|
||||
height: SCREEN_HEIGHT * 0.8,
|
||||
}}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
|
|
@ -189,6 +268,6 @@ export default function AlbumGalleryScreen() {
|
|||
</PagerView>
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue