앨범 상세: 바텀시트 웹 동일 애니메이션, 동영상 썸네일 지원

This commit is contained in:
caadiq 2026-01-12 14:51:15 +09:00
parent 7f1c210be7
commit 64e12e47ae
8 changed files with 799 additions and 165 deletions

10
app/package-lock.json generated
View file

@ -24,6 +24,7 @@
"expo-linear-gradient": "~15.0.8", "expo-linear-gradient": "~15.0.8",
"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",
"lucide-react-native": "^0.562.0", "lucide-react-native": "^0.562.0",
"nativewind": "^4.2.1", "nativewind": "^4.2.1",
"react": "19.1.0", "react": "19.1.0",
@ -5644,6 +5645,15 @@
"expo": "*" "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": { "node_modules/expo/node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",

View file

@ -4,8 +4,8 @@
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo start --android", "android": "expo run:android",
"ios": "expo start --ios", "ios": "expo run:ios",
"web": "expo start --web" "web": "expo start --web"
}, },
"dependencies": { "dependencies": {
@ -25,6 +25,7 @@
"expo-linear-gradient": "~15.0.8", "expo-linear-gradient": "~15.0.8",
"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",
"lucide-react-native": "^0.562.0", "lucide-react-native": "^0.562.0",
"nativewind": "^4.2.1", "nativewind": "^4.2.1",
"react": "19.1.0", "react": "19.1.0",

View file

@ -15,6 +15,7 @@ export interface Album {
tracks?: Track[]; tracks?: Track[];
photos?: AlbumPhoto[]; photos?: AlbumPhoto[];
teasers?: AlbumTeaser[]; teasers?: AlbumTeaser[];
conceptPhotos?: { [key: string]: AlbumPhoto[] };
} }
export interface Track { export interface Track {
@ -37,7 +38,7 @@ export interface AlbumPhoto {
export interface AlbumTeaser { export interface AlbumTeaser {
id: number; id: number;
title?: string; title?: string;
media_url: string; original_url: string;
media_type: 'image' | 'video'; media_type: 'image' | 'video';
thumb_url?: string; thumb_url?: string;
} }

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useMemo, useRef } from 'react';
import { import {
View, View,
Text, Text,
@ -6,26 +6,105 @@ import {
Image, Image,
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
StyleSheet,
Dimensions,
Pressable,
PanResponder,
Animated,
} from 'react-native'; } 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 { 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 { 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 { 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, 'AlbumDetail'>; type RouteType = RouteProp<AlbumStackParamList, 'AlbumDetail'>;
const { width: SCREEN_WIDTH } = Dimensions.get('window');
export default function AlbumDetailScreen() { export default function AlbumDetailScreen() {
const navigation = useNavigation<NavigationProp>(); const navigation = useNavigation<NavigationProp>();
const route = useRoute<RouteType>(); const route = useRoute<RouteType>();
const insets = useSafeAreaInsets();
const { name } = route.params; const { name } = route.params;
const [album, setAlbum] = useState<Album | null>(null); const [album, setAlbum] = useState<Album | null>(null);
const [loading, setLoading] = useState(true); 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(() => { useEffect(() => {
const fetchAlbum = async () => { const fetchAlbum = async () => {
@ -41,177 +120,716 @@ export default function AlbumDetailScreen() {
fetchAlbum(); fetchAlbum();
}, [name]); }, [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) { if (loading) {
// 웹: flex items-center justify-center h-64
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}> <View style={styles.loadingContainer}>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
</View> </View>
</SafeAreaView>
); );
} }
if (!album) { if (!album) {
// 웹: text-center py-12
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}> <View style={styles.errorContainer}>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> <Text style={styles.errorText}> </Text>
<Text> .</Text>
</View> </View>
</SafeAreaView>
); );
} }
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}> <View style={styles.container}>
{/* 헤더 */} {/* 헤더 - 웹 Layout에서 제공 */}
<View style={{ <Header title="앨범" />
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>
<ScrollView contentContainerStyle={{ padding: 16 }}> <ScrollView
{/* 앨범 커버 */} style={styles.scrollView}
<View style={{ alignItems: 'center', marginBottom: 20 }}> showsVerticalScrollIndicator={false}
>
{/* 앨범 히어로 섹션 - 웹: relative */}
<View style={styles.heroSection}>
{/* 배경 블러 이미지 - 웹: absolute inset-0 overflow-hidden */}
<View style={styles.heroBgContainer}>
<Image <Image
source={{ uri: album.cover_medium_url }} source={{ uri: album.cover_medium_url }}
style={{ style={styles.heroBgImage}
width: 200, blurRadius={24}
height: 200, />
borderRadius: 12, {/* 그라데이션 오버레이 - 웹: 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> </View>
{/* 수록곡 */} {/* 콘텐츠 - 웹: relative px-5 pt-4 pb-5 */}
{album.tracks && album.tracks.length > 0 && ( <View style={[styles.heroContent, { paddingTop: insets.top + 16 }]}>
<View style={{ marginBottom: 24 }}> {/* 웹: flex flex-col items-center */}
<Text style={{ <View style={styles.heroInner}>
fontSize: 16, {/* 앨범 커버 - 웹: w-44 h-44 rounded-2xl overflow-hidden shadow-xl mb-4 */}
fontWeight: '600', <View style={styles.coverContainer}>
color: colors.textPrimary, <Image
marginBottom: 12, 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> </Text>
{album.tracks.map((track, index) => ( </View>
<View {/* 곡 수 */}
key={track.id} <View style={styles.metaItem}>
style={{ <Music size={14} color="#6B7280" />
flexDirection: 'row', <Text style={styles.metaText}>{album.tracks?.length || 0}</Text>
alignItems: 'center', </View>
paddingVertical: 12, {/* 총 시간 */}
borderBottomWidth: 1, <View style={styles.metaItem}>
borderBottomColor: colors.borderLight, <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={{ <FileText size={12} color="#6B7280" />
width: 30, <Text style={styles.descButtonText}> </Text>
fontSize: 14, </TouchableOpacity>
color: colors.textTertiary, ) : 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')} {String(track.track_number).padStart(2, '0')}
</Text> </Text>
<Text style={{ {/* 웹: flex-1 min-w-0 flex items-center gap-2 */}
flex: 1, <View style={styles.trackTitleContainer}>
fontSize: 15, {/* 웹: text-sm font-medium truncate ${track.is_title_track ? 'text-primary' : 'text-gray-800'} */}
color: colors.textPrimary, <Text
}}> style={[
styles.trackTitle,
Boolean(track.is_title_track) && styles.trackTitleHighlight
]}
numberOfLines={1}
>
{track.title} {track.title}
</Text> </Text>
{track.is_title_track && ( {/* 웹: px-1.5 py-0.5 bg-primary text-white text-[10px] font-bold rounded flex-shrink-0 */}
<View style={{ {Boolean(track.is_title_track) ? (
backgroundColor: colors.primary, <View style={styles.titleBadge}>
paddingHorizontal: 8, <Text style={styles.titleBadgeText}>TITLE</Text>
paddingVertical: 2,
borderRadius: 4,
}}>
<Text style={{ fontSize: 10, color: 'white', fontWeight: '600' }}>
TITLE
</Text>
</View> </View>
)} ) : null}
</View>
{/* 웹: text-xs text-gray-400 tabular-nums */}
<Text style={styles.trackDuration}>{track.duration || '-'}</Text>
</View> </View>
))} ))}
</View> </View>
)}
{/* 컨셉 포토 */} {/* 더보기/접기 버튼 - 웹: w-full mt-2 py-2 text-sm text-gray-500 flex items-center justify-center gap-1 */}
{album.photos && album.photos.length > 0 && ( {album.tracks.length > 5 ? (
<View>
<View style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
}}>
<Text style={{
fontSize: 16,
fontWeight: '600',
color: colors.textPrimary,
}}>
</Text>
<TouchableOpacity <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 })} onPress={() => navigation.navigate('AlbumGallery', { name })}
> >
<Text style={{ fontSize: 13, color: colors.primary }}> <Text style={styles.viewAllButtonText}>
({album.photos.length}) {allPhotos.length}
</Text> </Text>
<ChevronRight size={16} color={colors.primary} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : null}
{/* 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>
)}
</ScrollView> </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',
},
});

View file

@ -42,22 +42,26 @@ export default function ScheduleScreen() {
try { try {
const currentOffset = isRefresh ? 0 : offset; const currentOffset = isRefresh ? 0 : offset;
const { schedules: data, total } = await getSchedules({ const data = await getSchedules({
q: searchQuery || undefined, q: searchQuery || undefined,
category: selectedCategory || undefined, category: selectedCategory || undefined,
limit: LIMIT, limit: LIMIT,
offset: currentOffset, offset: currentOffset,
}); });
// API가 배열을 반환하므로 직접 사용
const scheduleData = Array.isArray(data) ? data : [];
if (isRefresh) { if (isRefresh) {
setSchedules(data); setSchedules(scheduleData);
setOffset(LIMIT); setOffset(LIMIT);
} else { } else {
setSchedules(prev => [...prev, ...data]); setSchedules(prev => [...prev, ...scheduleData]);
setOffset(prev => prev + LIMIT); setOffset(prev => prev + LIMIT);
} }
setHasMore(currentOffset + data.length < total); // 반환된 데이터가 LIMIT보다 적으면 더 이상 없음
setHasMore(scheduleData.length >= LIMIT);
} catch (error) { } catch (error) {
console.error('일정 로드 오류:', error); console.error('일정 로드 오류:', error);
} finally { } finally {

View file

@ -73,7 +73,7 @@ function MobileLayout({ children, pageTitle, hideHeader = false, useCustomLayout
// (Schedule ) // (Schedule )
if (useCustomLayout) { if (useCustomLayout) {
return ( return (
<div className="mobile-layout-container bg-gray-50"> <div className="mobile-layout-container bg-white">
{children} {children}
<MobileBottomNav /> <MobileBottomNav />
</div> </div>
@ -81,7 +81,7 @@ function MobileLayout({ children, pageTitle, hideHeader = false, useCustomLayout
} }
return ( return (
<div className="mobile-layout-container bg-gray-50"> <div className="mobile-layout-container bg-white">
{!hideHeader && <MobileHeader title={pageTitle} />} {!hideHeader && <MobileHeader title={pageTitle} />}
<main className="mobile-content">{children}</main> <main className="mobile-content">{children}</main>
<MobileBottomNav /> <MobileBottomNav />

View file

@ -50,7 +50,7 @@ function MobileAlbum() {
<div className="p-3"> <div className="p-3">
<p className="font-semibold text-sm truncate">{album.title}</p> <p className="font-semibold text-sm truncate">{album.title}</p>
<p className="text-xs text-gray-400 mt-0.5"> <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> </p>
</div> </div>
</motion.div> </motion.div>

View file

@ -385,7 +385,7 @@ function MobileAlbumDetail() {
</div> </div>
{/* 내용 */} {/* 내용 */}
<div className="px-5 py-4 overflow-y-auto max-h-[60vh]"> <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} {album.description}
</p> </p>
</div> </div>