앨범 상세: 바텀시트 웹 동일 애니메이션, 동영상 썸네일 지원
This commit is contained in:
parent
7f1c210be7
commit
64e12e47ae
8 changed files with 799 additions and 165 deletions
10
app/package-lock.json
generated
10
app/package-lock.json
generated
|
|
@ -24,6 +24,7 @@
|
|||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-video-thumbnails": "^10.0.8",
|
||||
"lucide-react-native": "^0.562.0",
|
||||
"nativewind": "^4.2.1",
|
||||
"react": "19.1.0",
|
||||
|
|
@ -5644,6 +5645,15 @@
|
|||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-video-thumbnails": {
|
||||
"version": "10.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-video-thumbnails/-/expo-video-thumbnails-10.0.8.tgz",
|
||||
"integrity": "sha512-nPUtP7ERLf5DY5V2A6gquRP5rP3Uvq6+FVkDwG9R3KKhFeTYkWZ5Ce1iQ7Yt5qDNQqcUcgEqmRpGCbJmn9ckKA==",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -25,6 +25,7 @@
|
|||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-video-thumbnails": "^10.0.8",
|
||||
"lucide-react-native": "^0.562.0",
|
||||
"nativewind": "^4.2.1",
|
||||
"react": "19.1.0",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface Album {
|
|||
tracks?: Track[];
|
||||
photos?: AlbumPhoto[];
|
||||
teasers?: AlbumTeaser[];
|
||||
conceptPhotos?: { [key: string]: AlbumPhoto[] };
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
|
|
@ -37,7 +38,7 @@ export interface AlbumPhoto {
|
|||
export interface AlbumTeaser {
|
||||
id: number;
|
||||
title?: string;
|
||||
media_url: string;
|
||||
original_url: string;
|
||||
media_type: 'image' | 'video';
|
||||
thumb_url?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -6,26 +6,105 @@ import {
|
|||
Image,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
Pressable,
|
||||
PanResponder,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useSafeAreaInsets } 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 { Calendar, Music, Clock, FileText, ChevronDown, ChevronUp, ChevronRight, X, Play } from 'lucide-react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import dayjs from 'dayjs';
|
||||
import * as VideoThumbnails from 'expo-video-thumbnails';
|
||||
|
||||
import { getAlbumByName, Album } from '../api/albums';
|
||||
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, 'AlbumDetail'>;
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
export default function AlbumDetailScreen() {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<RouteType>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { name } = route.params;
|
||||
|
||||
const [album, setAlbum] = useState<Album | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAllTracks, setShowAllTracks] = useState(false);
|
||||
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
|
||||
|
||||
// 바톰 시트 애니메이션 (웹과 동일)
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
||||
|
||||
// 모달 열기 - 웹과 동일: 배경 fade + 모달 slide up with spring
|
||||
const openDescriptionModal = () => {
|
||||
setShowDescriptionModal(true);
|
||||
// 배경 fade in
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
// 모달 slide up with spring bounce
|
||||
Animated.spring(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
damping: 25,
|
||||
stiffness: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
// 모달 닫기
|
||||
const closeDescriptionModal = () => {
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 300,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
setShowDescriptionModal(false);
|
||||
});
|
||||
};
|
||||
|
||||
// 드래그 핸들러
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: (_, gestureState) => gestureState.dy > 0,
|
||||
onPanResponderMove: (_, gestureState) => {
|
||||
if (gestureState.dy > 0) {
|
||||
sheetTranslateY.setValue(gestureState.dy);
|
||||
}
|
||||
},
|
||||
onPanResponderRelease: (_, gestureState) => {
|
||||
if (gestureState.dy > 100 || gestureState.vy > 0.5) {
|
||||
closeDescriptionModal();
|
||||
} else {
|
||||
Animated.spring(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
damping: 25,
|
||||
stiffness: 300,
|
||||
}).start();
|
||||
}
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAlbum = async () => {
|
||||
|
|
@ -41,177 +120,716 @@ export default function AlbumDetailScreen() {
|
|||
fetchAlbum();
|
||||
}, [name]);
|
||||
|
||||
// 총 재생 시간 계산 (웹과 동일)
|
||||
const getTotalDuration = () => {
|
||||
if (!album?.tracks) return '';
|
||||
let totalSeconds = 0;
|
||||
album.tracks.forEach(track => {
|
||||
if (track.duration) {
|
||||
const parts = track.duration.split(':');
|
||||
totalSeconds += parseInt(parts[0]) * 60 + parseInt(parts[1]);
|
||||
}
|
||||
});
|
||||
const mins = Math.floor(totalSeconds / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
return `${mins}:${String(secs).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 모든 컨셉 포토를 하나의 배열로 (웹과 동일)
|
||||
const allPhotos = useMemo(() => {
|
||||
if (!album) return [];
|
||||
return album.conceptPhotos
|
||||
? Object.values(album.conceptPhotos).flat() as AlbumPhoto[]
|
||||
: (album.photos || []);
|
||||
}, [album]);
|
||||
|
||||
// 표시할 트랙 (웹과 동일: 5개 또는 전체)
|
||||
const displayTracks = useMemo(() => {
|
||||
if (!album?.tracks) return [];
|
||||
return showAllTracks ? album.tracks : album.tracks.slice(0, 5);
|
||||
}, [album, showAllTracks]);
|
||||
|
||||
// 동영상 썸네일 캐시
|
||||
const [videoThumbnails, setVideoThumbnails] = useState<{[key: string]: string}>({});
|
||||
|
||||
// 동영상 썸네일 생성
|
||||
useEffect(() => {
|
||||
const generateThumbnails = async () => {
|
||||
if (!album?.teasers) return;
|
||||
|
||||
const videoTeasers = album.teasers.filter(t => t.media_type === 'video');
|
||||
const thumbnails: {[key: string]: string} = {};
|
||||
|
||||
for (const teaser of videoTeasers) {
|
||||
try {
|
||||
const { uri } = await VideoThumbnails.getThumbnailAsync(teaser.original_url, {
|
||||
time: 1000, // 1초 위치에서 썸네일 추출
|
||||
});
|
||||
thumbnails[teaser.original_url] = uri;
|
||||
} catch (e) {
|
||||
console.log('썸네일 생성 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
setVideoThumbnails(thumbnails);
|
||||
};
|
||||
|
||||
generateThumbnails();
|
||||
}, [album]);
|
||||
|
||||
if (loading) {
|
||||
// 웹: flex items-center justify-center h-64
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (!album) {
|
||||
// 웹: text-center py-12
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Text>앨범을 찾을 수 없습니다.</Text>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>앨범을 찾을 수 없습니다</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
marginRight: 24,
|
||||
}}>
|
||||
앨범
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.container}>
|
||||
{/* 헤더 - 웹 Layout에서 제공 */}
|
||||
<Header title="앨범" />
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||
{/* 앨범 커버 */}
|
||||
<View style={{ alignItems: 'center', marginBottom: 20 }}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 앨범 히어로 섹션 - 웹: relative */}
|
||||
<View style={styles.heroSection}>
|
||||
{/* 배경 블러 이미지 - 웹: absolute inset-0 overflow-hidden */}
|
||||
<View style={styles.heroBgContainer}>
|
||||
<Image
|
||||
source={{ uri: album.cover_medium_url }}
|
||||
style={{
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: 12,
|
||||
}}
|
||||
style={styles.heroBgImage}
|
||||
blurRadius={24}
|
||||
/>
|
||||
{/* 그라데이션 오버레이 - 웹: bg-gradient-to-b from-white/60 via-white/80 to-white */}
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.6)', 'rgba(255,255,255,0.8)', 'rgba(255,255,255,1)']}
|
||||
locations={[0, 0.5, 1]}
|
||||
style={styles.heroGradient}
|
||||
/>
|
||||
<Text style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{album.title}
|
||||
</Text>
|
||||
<Text style={{
|
||||
fontSize: 14,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 4,
|
||||
}}>
|
||||
{album.album_type} · {album.release_date?.slice(0, 10)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 수록곡 */}
|
||||
{album.tracks && album.tracks.length > 0 && (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text style={{
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
수록곡
|
||||
{/* 콘텐츠 - 웹: relative px-5 pt-4 pb-5 */}
|
||||
<View style={[styles.heroContent, { paddingTop: insets.top + 16 }]}>
|
||||
{/* 웹: flex flex-col items-center */}
|
||||
<View style={styles.heroInner}>
|
||||
{/* 앨범 커버 - 웹: w-44 h-44 rounded-2xl overflow-hidden shadow-xl mb-4 */}
|
||||
<View style={styles.coverContainer}>
|
||||
<Image
|
||||
source={{ uri: album.cover_medium_url }}
|
||||
style={styles.coverImage}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 앨범 정보 - 웹: text-center */}
|
||||
<View style={styles.albumInfoContainer}>
|
||||
{/* 타입 뱃지 - 웹: inline-block px-3 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full mb-2 */}
|
||||
<View style={styles.typeBadge}>
|
||||
<Text style={styles.typeBadgeText}>{album.album_type}</Text>
|
||||
</View>
|
||||
|
||||
{/* 타이틀 - 웹: text-2xl font-bold mb-2 */}
|
||||
<Text style={styles.albumTitle}>{album.title}</Text>
|
||||
|
||||
{/* 메타 정보 - 웹: flex items-center justify-center gap-4 text-sm text-gray-500 */}
|
||||
<View style={styles.metaRow}>
|
||||
{/* 날짜 - 웹: flex items-center gap-1 */}
|
||||
<View style={styles.metaItem}>
|
||||
<Calendar size={14} color="#6B7280" />
|
||||
<Text style={styles.metaText}>
|
||||
{dayjs(album.release_date).format('YYYY.MM.DD')}
|
||||
</Text>
|
||||
{album.tracks.map((track, index) => (
|
||||
<View
|
||||
key={track.id}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
}}
|
||||
</View>
|
||||
{/* 곡 수 */}
|
||||
<View style={styles.metaItem}>
|
||||
<Music size={14} color="#6B7280" />
|
||||
<Text style={styles.metaText}>{album.tracks?.length || 0}곡</Text>
|
||||
</View>
|
||||
{/* 총 시간 */}
|
||||
<View style={styles.metaItem}>
|
||||
<Clock size={14} color="#6B7280" />
|
||||
<Text style={styles.metaText}>{getTotalDuration()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 앨범 소개 버튼 - 웹: mt-3 flex items-center gap-1.5 mx-auto px-3 py-1.5 text-xs text-gray-500 bg-white/80 rounded-full shadow-sm */}
|
||||
{album.description ? (
|
||||
<TouchableOpacity
|
||||
style={styles.descButton}
|
||||
onPress={openDescriptionModal}
|
||||
>
|
||||
<Text style={{
|
||||
width: 30,
|
||||
fontSize: 14,
|
||||
color: colors.textTertiary,
|
||||
}}>
|
||||
<FileText size={12} color="#6B7280" />
|
||||
<Text style={styles.descButtonText}>앨범 소개</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 티저 포토 - 웹: px-4 py-4 border-b border-gray-100 */}
|
||||
{album.teasers && album.teasers.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
{/* 웹: text-sm font-semibold mb-3 */}
|
||||
<Text style={styles.sectionTitle}>티저 포토</Text>
|
||||
{/* 웹: flex gap-3 overflow-x-auto pb-1 -mx-4 px-4 scrollbar-hide */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.teaserScroll}
|
||||
>
|
||||
{album.teasers.map((teaser, index) => (
|
||||
// 웹: w-24 h-24 flex-shrink-0 bg-gray-100 rounded-2xl overflow-hidden relative shadow-sm
|
||||
<View key={index} style={styles.teaserItem}>
|
||||
<Image
|
||||
source={{
|
||||
uri: teaser.media_type === 'video'
|
||||
? videoThumbnails[teaser.original_url] || undefined
|
||||
: (teaser.thumb_url || teaser.original_url)
|
||||
}}
|
||||
style={styles.teaserImage}
|
||||
/>
|
||||
{/* 동영상인 경우 재생 버튼 오버레이 */}
|
||||
{teaser.media_type === 'video' && (
|
||||
<View style={styles.videoOverlay}>
|
||||
<View style={styles.playButton}>
|
||||
<Play size={14} fill="#374151" color="#374151" style={{ marginLeft: 2 }} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* 수록곡 - 웹: px-4 py-4 border-b border-gray-100 */}
|
||||
{album.tracks && album.tracks.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
{/* 웹: text-sm font-semibold mb-3 */}
|
||||
<Text style={styles.sectionTitle}>수록곡</Text>
|
||||
{/* 웹: space-y-1 */}
|
||||
<View style={styles.trackList}>
|
||||
{displayTracks.map((track) => (
|
||||
// 웹: flex items-center gap-3 py-2.5 px-3 rounded-xl hover:bg-gray-50 transition-colors
|
||||
<View key={track.id} style={styles.trackItem}>
|
||||
{/* 웹: w-6 text-center text-sm text-gray-400 tabular-nums */}
|
||||
<Text style={styles.trackNumber}>
|
||||
{String(track.track_number).padStart(2, '0')}
|
||||
</Text>
|
||||
<Text style={{
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
color: colors.textPrimary,
|
||||
}}>
|
||||
{/* 웹: flex-1 min-w-0 flex items-center gap-2 */}
|
||||
<View style={styles.trackTitleContainer}>
|
||||
{/* 웹: text-sm font-medium truncate ${track.is_title_track ? 'text-primary' : 'text-gray-800'} */}
|
||||
<Text
|
||||
style={[
|
||||
styles.trackTitle,
|
||||
Boolean(track.is_title_track) && styles.trackTitleHighlight
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{track.title}
|
||||
</Text>
|
||||
{track.is_title_track && (
|
||||
<View style={{
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
}}>
|
||||
<Text style={{ fontSize: 10, color: 'white', fontWeight: '600' }}>
|
||||
TITLE
|
||||
</Text>
|
||||
{/* 웹: px-1.5 py-0.5 bg-primary text-white text-[10px] font-bold rounded flex-shrink-0 */}
|
||||
{Boolean(track.is_title_track) ? (
|
||||
<View style={styles.titleBadge}>
|
||||
<Text style={styles.titleBadgeText}>TITLE</Text>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
{/* 웹: text-xs text-gray-400 tabular-nums */}
|
||||
<Text style={styles.trackDuration}>{track.duration || '-'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 컨셉 포토 */}
|
||||
{album.photos && album.photos.length > 0 && (
|
||||
<View>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
<Text style={{
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
}}>
|
||||
컨셉 포토
|
||||
</Text>
|
||||
{/* 더보기/접기 버튼 - 웹: w-full mt-2 py-2 text-sm text-gray-500 flex items-center justify-center gap-1 */}
|
||||
{album.tracks.length > 5 ? (
|
||||
<TouchableOpacity
|
||||
style={styles.showMoreButton}
|
||||
onPress={() => setShowAllTracks(!showAllTracks)}
|
||||
>
|
||||
<Text style={styles.showMoreText}>
|
||||
{showAllTracks ? '접기' : `${album.tracks.length - 5}곡 더보기`}
|
||||
</Text>
|
||||
{showAllTracks ? (
|
||||
<ChevronUp size={16} color="#6B7280" />
|
||||
) : (
|
||||
<ChevronDown size={16} color="#6B7280" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* 컨셉 포토 - 웹: px-4 py-4 */}
|
||||
{allPhotos.length > 0 ? (
|
||||
<View style={styles.sectionNoBorder}>
|
||||
{/* 웹: text-sm font-semibold mb-3 */}
|
||||
<Text style={styles.sectionTitle}>컨셉 포토</Text>
|
||||
{/* 웹: grid grid-cols-3 gap-2 */}
|
||||
<View style={styles.photoGrid}>
|
||||
{allPhotos.slice(0, 6).map((photo) => (
|
||||
// 웹: aspect-square bg-gray-100 rounded-xl overflow-hidden shadow-sm
|
||||
<View key={photo.id} style={styles.photoItem}>
|
||||
<Image
|
||||
source={{ uri: photo.thumb_url || photo.medium_url }}
|
||||
style={styles.photoImage}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{/* 전체보기 버튼 - 웹: w-full mt-3 py-3 text-sm text-primary font-medium bg-primary/5 rounded-xl flex items-center justify-center gap-1 */}
|
||||
<TouchableOpacity
|
||||
style={styles.viewAllButton}
|
||||
onPress={() => navigation.navigate('AlbumGallery', { name })}
|
||||
>
|
||||
<Text style={{ fontSize: 13, color: colors.primary }}>
|
||||
전체보기 ({album.photos.length})
|
||||
<Text style={styles.viewAllButtonText}>
|
||||
전체 {allPhotos.length}장 보기
|
||||
</Text>
|
||||
<ChevronRight size={16} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 3열 그리드 (최대 6개) */}
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
||||
{album.photos.slice(0, 6).map((photo) => (
|
||||
<Image
|
||||
key={photo.id}
|
||||
source={{ uri: photo.thumb_url || photo.medium_url }}
|
||||
style={{
|
||||
width: '31%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.border,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
|
||||
{/* 앨범 소개 바텀시트 - 웹과 동일: 배경 fade + 시트 slide up with spring */}
|
||||
{showDescriptionModal ? (
|
||||
<>
|
||||
{/* 배경 오버레이 - 웹: initial={{ opacity: 0 }} animate={{ opacity: 1 }} */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.modalOverlay,
|
||||
{ opacity: overlayOpacity }
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
style={StyleSheet.absoluteFill}
|
||||
onPress={closeDescriptionModal}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* 바텀 시트 - 웹: initial={{ y: '100%' }} animate={{ y: 0 }} spring */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.modalContent,
|
||||
{ transform: [{ translateY: sheetTranslateY }] }
|
||||
]}
|
||||
>
|
||||
{/* 드래그 핸들 - 웹과 동일: drag="y" */}
|
||||
<View {...panResponder.panHandlers} style={styles.modalHandle}>
|
||||
<View style={styles.modalHandleBar} />
|
||||
</View>
|
||||
{/* 헤더 */}
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>앨범 소개</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.modalCloseButton}
|
||||
onPress={closeDescriptionModal}
|
||||
>
|
||||
<X size={20} color="#6B7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{/* 내용 */}
|
||||
<ScrollView contentContainerStyle={styles.modalBody}>
|
||||
<Text style={styles.modalText} android_hyphenationFrequency="none" textBreakStrategy="simple">{album.description}</Text>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
// 로딩 - 웹: flex items-center justify-center h-64
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 256,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
// 에러 - 웹: text-center py-12
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 48,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
errorText: {
|
||||
color: '#6B7280',
|
||||
fontSize: 14,
|
||||
},
|
||||
// 히어로 섹션 - 웹: relative
|
||||
heroSection: {
|
||||
position: 'relative',
|
||||
},
|
||||
// 블러 배경 컨테이너 - 웹: absolute inset-0 overflow-hidden
|
||||
heroBgContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// 블러 이미지 - 웹: w-full h-full object-cover blur-2xl scale-110 opacity-30
|
||||
heroBgImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: [{ scale: 1.1 }],
|
||||
opacity: 0.3,
|
||||
},
|
||||
// 그라데이션 오버레이 - 웹: bg-gradient-to-b from-white/60 via-white/80 to-white
|
||||
heroGradient: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
// 히어로 콘텐츠 - 웹: relative px-5 pt-4 pb-5
|
||||
heroContent: {
|
||||
position: 'relative',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
// 웹: flex flex-col items-center
|
||||
heroInner: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
// 앨범 커버 - 웹: w-44 h-44 rounded-2xl overflow-hidden shadow-xl mb-4
|
||||
coverContainer: {
|
||||
width: 176,
|
||||
height: 176,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 16,
|
||||
// shadow-xl
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 25,
|
||||
elevation: 15,
|
||||
},
|
||||
coverImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
// 앨범 정보 컨테이너 - 웹: text-center
|
||||
albumInfoContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
// 타입 뱃지 - 웹: inline-block px-3 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full mb-2
|
||||
typeBadge: {
|
||||
backgroundColor: colors.primary + '1A', // /10 = 10% opacity
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 999,
|
||||
marginBottom: 8,
|
||||
},
|
||||
typeBadgeText: {
|
||||
color: colors.primary,
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
// 앨범 타이틀 - 웹: text-2xl font-bold mb-2
|
||||
albumTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#111827',
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
// 메타 정보 Row - 웹: flex items-center justify-center gap-4 text-sm text-gray-500
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
// 메타 아이템 - 웹: flex items-center gap-1
|
||||
metaItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: 14,
|
||||
color: '#6B7280',
|
||||
},
|
||||
// 앨범 소개 버튼 - 웹: mt-3 flex items-center gap-1.5 mx-auto px-3 py-1.5 text-xs text-gray-500 bg-white/80 rounded-full shadow-sm
|
||||
descButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginTop: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||
borderRadius: 999,
|
||||
// shadow-sm
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
descButtonText: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
},
|
||||
// 섹션 - 웹: px-4 py-4 border-b border-gray-100
|
||||
section: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F3F4F6',
|
||||
},
|
||||
// 섹션 (border 없음) - 웹: px-4 py-4
|
||||
sectionNoBorder: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
// 섹션 타이틀 - 웹: text-sm font-semibold mb-3
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
marginBottom: 12,
|
||||
},
|
||||
// 티저 스크롤 - 웹: flex gap-3
|
||||
teaserScroll: {
|
||||
gap: 12,
|
||||
paddingRight: 16,
|
||||
},
|
||||
// 티저 아이템 - 웹: w-24 h-24 flex-shrink-0 bg-gray-100 rounded-2xl overflow-hidden shadow-sm
|
||||
teaserItem: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
// shadow-sm
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
teaserImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
// 동영상 오버레이 - 웹: absolute inset-0 flex items-center justify-center bg-black/30
|
||||
videoOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
// 재생 버튼 - 웹: w-8 h-8 bg-white/90 rounded-full flex items-center justify-center
|
||||
playButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
// 트랙 리스트 - 웹: space-y-1
|
||||
trackList: {
|
||||
gap: 4,
|
||||
},
|
||||
// 트랙 아이템 - 웹: flex items-center gap-3 py-2.5 px-3 rounded-xl
|
||||
trackItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 12,
|
||||
},
|
||||
// 트랙 번호 - 웹: w-6 text-center text-sm text-gray-400 tabular-nums
|
||||
trackNumber: {
|
||||
width: 24,
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
// 트랙 타이틀 컨테이너 - 웹: flex-1 min-w-0 flex items-center gap-2
|
||||
trackTitleContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
// 트랙 타이틀 - 웹: text-sm font-medium truncate text-gray-800
|
||||
trackTitle: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: '#1F2937',
|
||||
},
|
||||
// 타이틀곡 하이라이트 - 웹: text-primary
|
||||
trackTitleHighlight: {
|
||||
color: colors.primary,
|
||||
},
|
||||
// TITLE 뱃지 - 웹: px-1.5 py-0.5 bg-primary text-white text-[10px] font-bold rounded
|
||||
titleBadge: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
titleBadgeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
// 트랙 재생시간 - 웹: text-xs text-gray-400 tabular-nums
|
||||
trackDuration: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
// 더보기 버튼 - 웹: w-full mt-2 py-2 text-sm text-gray-500 flex items-center justify-center gap-1
|
||||
showMoreButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
marginTop: 8,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
showMoreText: {
|
||||
fontSize: 14,
|
||||
color: '#6B7280',
|
||||
},
|
||||
// 포토 그리드 - 웹: grid grid-cols-3 gap-2
|
||||
photoGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
// 포토 아이템 - 웹: aspect-square bg-gray-100 rounded-xl overflow-hidden shadow-sm
|
||||
photoItem: {
|
||||
width: (SCREEN_WIDTH - 32 - 16) / 3,
|
||||
aspectRatio: 1,
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
// shadow-sm
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
photoImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
// 전체보기 버튼 - 웹: w-full mt-3 py-3 text-sm text-primary font-medium bg-primary/5 rounded-xl flex items-center justify-center gap-1
|
||||
viewAllButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
marginTop: 12,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: colors.primary + '0D', // /5 = 5% opacity
|
||||
borderRadius: 12,
|
||||
},
|
||||
viewAllButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: colors.primary,
|
||||
},
|
||||
// 모달 오버레이 - 웹: fixed inset-0 bg-black/60
|
||||
modalOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
zIndex: 100,
|
||||
},
|
||||
// 모달 콘텐츠 - 웹: bg-white rounded-t-3xl w-full max-h-[80vh] overflow-hidden
|
||||
modalContent: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: '80%',
|
||||
zIndex: 101,
|
||||
},
|
||||
// 드래그 핸들 - 웹: flex justify-center pt-3 pb-2
|
||||
modalHandle: {
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
// 핸들 바 - 웹: w-10 h-1 bg-gray-300 rounded-full
|
||||
modalHandleBar: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: '#D1D5DB',
|
||||
borderRadius: 999,
|
||||
},
|
||||
// 모달 헤더 - 웹: flex items-center justify-between px-5 pb-3 border-b border-gray-100
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F3F4F6',
|
||||
},
|
||||
// 모달 타이틀 - 웹: text-lg font-bold
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#111827',
|
||||
},
|
||||
modalCloseButton: {
|
||||
padding: 6,
|
||||
},
|
||||
// 모달 내용 - 웹: px-5 py-4 overflow-y-auto max-h-[60vh]
|
||||
modalBody: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
// 모달 텍스트 - 웹: text-sm text-gray-600 leading-relaxed whitespace-pre-line
|
||||
modalText: {
|
||||
fontSize: 14,
|
||||
color: '#4B5563',
|
||||
lineHeight: 22,
|
||||
textAlign: 'justify',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,22 +42,26 @@ export default function ScheduleScreen() {
|
|||
|
||||
try {
|
||||
const currentOffset = isRefresh ? 0 : offset;
|
||||
const { schedules: data, total } = await getSchedules({
|
||||
const data = await getSchedules({
|
||||
q: searchQuery || undefined,
|
||||
category: selectedCategory || undefined,
|
||||
limit: LIMIT,
|
||||
offset: currentOffset,
|
||||
});
|
||||
|
||||
// API가 배열을 반환하므로 직접 사용
|
||||
const scheduleData = Array.isArray(data) ? data : [];
|
||||
|
||||
if (isRefresh) {
|
||||
setSchedules(data);
|
||||
setSchedules(scheduleData);
|
||||
setOffset(LIMIT);
|
||||
} else {
|
||||
setSchedules(prev => [...prev, ...data]);
|
||||
setSchedules(prev => [...prev, ...scheduleData]);
|
||||
setOffset(prev => prev + LIMIT);
|
||||
}
|
||||
|
||||
setHasMore(currentOffset + data.length < total);
|
||||
// 반환된 데이터가 LIMIT보다 적으면 더 이상 없음
|
||||
setHasMore(scheduleData.length >= LIMIT);
|
||||
} catch (error) {
|
||||
console.error('일정 로드 오류:', error);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ function MobileLayout({ children, pageTitle, hideHeader = false, useCustomLayout
|
|||
// 자체 레이아웃 사용 시 (Schedule 페이지 등)
|
||||
if (useCustomLayout) {
|
||||
return (
|
||||
<div className="mobile-layout-container bg-gray-50">
|
||||
<div className="mobile-layout-container bg-white">
|
||||
{children}
|
||||
<MobileBottomNav />
|
||||
</div>
|
||||
|
|
@ -81,7 +81,7 @@ function MobileLayout({ children, pageTitle, hideHeader = false, useCustomLayout
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mobile-layout-container bg-gray-50">
|
||||
<div className="mobile-layout-container bg-white">
|
||||
{!hideHeader && <MobileHeader title={pageTitle} />}
|
||||
<main className="mobile-content">{children}</main>
|
||||
<MobileBottomNav />
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ function MobileAlbum() {
|
|||
<div className="p-3">
|
||||
<p className="font-semibold text-sm truncate">{album.title}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{album.album_type} · {album.release_date?.slice(0, 4)}
|
||||
{album.album_type_short} · {album.release_date?.slice(0, 4)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -385,7 +385,7 @@ function MobileAlbumDetail() {
|
|||
</div>
|
||||
{/* 내용 */}
|
||||
<div className="px-5 py-4 overflow-y-auto max-h-[60vh]">
|
||||
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-line">
|
||||
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-line text-justify">
|
||||
{album.description}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue