diff --git a/app/package-lock.json b/app/package-lock.json index f02a23b..f642651 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -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", diff --git a/app/package.json b/app/package.json index b5a15ed..41754a6 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/api/albums.ts b/app/src/api/albums.ts index 2cc015a..6609a2e 100644 --- a/app/src/api/albums.ts +++ b/app/src/api/albums.ts @@ -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; } diff --git a/app/src/screens/AlbumDetailScreen.tsx b/app/src/screens/AlbumDetailScreen.tsx index 893c131..80c47b1 100644 --- a/app/src/screens/AlbumDetailScreen.tsx +++ b/app/src/screens/AlbumDetailScreen.tsx @@ -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; type RouteType = RouteProp; +const { width: SCREEN_WIDTH } = Dimensions.get('window'); + export default function AlbumDetailScreen() { const navigation = useNavigation(); const route = useRoute(); + const insets = useSafeAreaInsets(); const { name } = route.params; const [album, setAlbum] = useState(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 ( - - - - - + + + ); } if (!album) { + // 웹: text-center py-12 return ( - - - 앨범을 찾을 수 없습니다. - - + + 앨범을 찾을 수 없습니다 + ); } return ( - - {/* 헤더 */} - - navigation.goBack()}> - - - - 앨범 - - + + {/* 헤더 - 웹 Layout에서 제공 */} +
+ + + {/* 앨범 히어로 섹션 - 웹: relative */} + + {/* 배경 블러 이미지 - 웹: absolute inset-0 overflow-hidden */} + + + {/* 그라데이션 오버레이 - 웹: bg-gradient-to-b from-white/60 via-white/80 to-white */} + + + + {/* 콘텐츠 - 웹: relative px-5 pt-4 pb-5 */} + + {/* 웹: flex flex-col items-center */} + + {/* 앨범 커버 - 웹: w-44 h-44 rounded-2xl overflow-hidden shadow-xl mb-4 */} + + + - - {/* 앨범 커버 */} - - - - {album.title} - - - {album.album_type} · {album.release_date?.slice(0, 10)} - - + {/* 앨범 정보 - 웹: text-center */} + + {/* 타입 뱃지 - 웹: inline-block px-3 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full mb-2 */} + + {album.album_type} + - {/* 수록곡 */} - {album.tracks && album.tracks.length > 0 && ( - - - 수록곡 - - {album.tracks.map((track, index) => ( - - - {String(track.track_number).padStart(2, '0')} - - - {track.title} - - {track.is_title_track && ( - - - TITLE + {/* 타이틀 - 웹: text-2xl font-bold mb-2 */} + {album.title} + + {/* 메타 정보 - 웹: flex items-center justify-center gap-4 text-sm text-gray-500 */} + + {/* 날짜 - 웹: flex items-center gap-1 */} + + + + {dayjs(album.release_date).format('YYYY.MM.DD')} - )} - - ))} - - )} + {/* 곡 수 */} + + + {album.tracks?.length || 0}곡 + + {/* 총 시간 */} + + + {getTotalDuration()} + + - {/* 컨셉 포토 */} - {album.photos && album.photos.length > 0 && ( - - - - 컨셉 포토 - - navigation.navigate('AlbumGallery', { name })} - > - - 전체보기 ({album.photos.length}) - - + {/* 앨범 소개 버튼 - 웹: 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 ? ( + + + 앨범 소개 + + ) : null} + - - {/* 3열 그리드 (최대 6개) */} - - {album.photos.slice(0, 6).map((photo) => ( + + + + {/* 티저 포토 - 웹: px-4 py-4 border-b border-gray-100 */} + {album.teasers && album.teasers.length > 0 ? ( + + {/* 웹: text-sm font-semibold mb-3 */} + 티저 포토 + {/* 웹: flex gap-3 overflow-x-auto pb-1 -mx-4 px-4 scrollbar-hide */} + + {album.teasers.map((teaser, index) => ( + // 웹: w-24 h-24 flex-shrink-0 bg-gray-100 rounded-2xl overflow-hidden relative shadow-sm + + source={{ + uri: teaser.media_type === 'video' + ? videoThumbnails[teaser.original_url] || undefined + : (teaser.thumb_url || teaser.original_url) + }} + style={styles.teaserImage} + /> + {/* 동영상인 경우 재생 버튼 오버레이 */} + {teaser.media_type === 'video' && ( + + + + + + )} + + ))} + + + ) : null} + + {/* 수록곡 - 웹: px-4 py-4 border-b border-gray-100 */} + {album.tracks && album.tracks.length > 0 ? ( + + {/* 웹: text-sm font-semibold mb-3 */} + 수록곡 + {/* 웹: space-y-1 */} + + {displayTracks.map((track) => ( + // 웹: flex items-center gap-3 py-2.5 px-3 rounded-xl hover:bg-gray-50 transition-colors + + {/* 웹: w-6 text-center text-sm text-gray-400 tabular-nums */} + + {String(track.track_number).padStart(2, '0')} + + {/* 웹: flex-1 min-w-0 flex items-center gap-2 */} + + {/* 웹: text-sm font-medium truncate ${track.is_title_track ? 'text-primary' : 'text-gray-800'} */} + + {track.title} + + {/* 웹: px-1.5 py-0.5 bg-primary text-white text-[10px] font-bold rounded flex-shrink-0 */} + {Boolean(track.is_title_track) ? ( + + TITLE + + ) : null} + + {/* 웹: text-xs text-gray-400 tabular-nums */} + {track.duration || '-'} + ))} + + {/* 더보기/접기 버튼 - 웹: w-full mt-2 py-2 text-sm text-gray-500 flex items-center justify-center gap-1 */} + {album.tracks.length > 5 ? ( + setShowAllTracks(!showAllTracks)} + > + + {showAllTracks ? '접기' : `${album.tracks.length - 5}곡 더보기`} + + {showAllTracks ? ( + + ) : ( + + )} + + ) : null} - )} + ) : null} + + {/* 컨셉 포토 - 웹: px-4 py-4 */} + {allPhotos.length > 0 ? ( + + {/* 웹: text-sm font-semibold mb-3 */} + 컨셉 포토 + {/* 웹: grid grid-cols-3 gap-2 */} + + {allPhotos.slice(0, 6).map((photo) => ( + // 웹: aspect-square bg-gray-100 rounded-xl overflow-hidden shadow-sm + + + + ))} + + {/* 전체보기 버튼 - 웹: w-full mt-3 py-3 text-sm text-primary font-medium bg-primary/5 rounded-xl flex items-center justify-center gap-1 */} + navigation.navigate('AlbumGallery', { name })} + > + + 전체 {allPhotos.length}장 보기 + + + + + ) : null} - + + {/* 앨범 소개 바텀시트 - 웹과 동일: 배경 fade + 시트 slide up with spring */} + {showDescriptionModal ? ( + <> + {/* 배경 오버레이 - 웹: initial={{ opacity: 0 }} animate={{ opacity: 1 }} */} + + + + + {/* 바텀 시트 - 웹: initial={{ y: '100%' }} animate={{ y: 0 }} spring */} + + {/* 드래그 핸들 - 웹과 동일: drag="y" */} + + + + {/* 헤더 */} + + 앨범 소개 + + + + + {/* 내용 */} + + {album.description} + + + + ) : null} + ); } + +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', + }, +}); diff --git a/app/src/screens/ScheduleScreen.tsx b/app/src/screens/ScheduleScreen.tsx index fd4c5ae..a6b6743 100644 --- a/app/src/screens/ScheduleScreen.tsx +++ b/app/src/screens/ScheduleScreen.tsx @@ -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 { diff --git a/frontend/src/components/mobile/Layout.jsx b/frontend/src/components/mobile/Layout.jsx index f0d387b..6c9e68d 100644 --- a/frontend/src/components/mobile/Layout.jsx +++ b/frontend/src/components/mobile/Layout.jsx @@ -73,7 +73,7 @@ function MobileLayout({ children, pageTitle, hideHeader = false, useCustomLayout // 자체 레이아웃 사용 시 (Schedule 페이지 등) if (useCustomLayout) { return ( -
+
{children}
@@ -81,7 +81,7 @@ function MobileLayout({ children, pageTitle, hideHeader = false, useCustomLayout } return ( -
+
{!hideHeader && }
{children}
diff --git a/frontend/src/pages/mobile/public/Album.jsx b/frontend/src/pages/mobile/public/Album.jsx index 972f2e5..97b3d6d 100644 --- a/frontend/src/pages/mobile/public/Album.jsx +++ b/frontend/src/pages/mobile/public/Album.jsx @@ -50,7 +50,7 @@ function MobileAlbum() {

{album.title}

- {album.album_type} · {album.release_date?.slice(0, 4)} + {album.album_type_short} · {album.release_date?.slice(0, 4)}

diff --git a/frontend/src/pages/mobile/public/AlbumDetail.jsx b/frontend/src/pages/mobile/public/AlbumDetail.jsx index 6390836..74d9d34 100644 --- a/frontend/src/pages/mobile/public/AlbumDetail.jsx +++ b/frontend/src/pages/mobile/public/AlbumDetail.jsx @@ -385,7 +385,7 @@ function MobileAlbumDetail() {
{/* 내용 */}
-

+

{album.description}