앨범 상세: 바텀시트 웹 동일 애니메이션, 동영상 썸네일 지원
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-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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
<ScrollView
|
||||||
paddingHorizontal: 16,
|
style={styles.scrollView}
|
||||||
paddingVertical: 12,
|
showsVerticalScrollIndicator={false}
|
||||||
borderBottomWidth: 1,
|
>
|
||||||
borderBottomColor: colors.borderLight,
|
{/* 앨범 히어로 섹션 - 웹: relative */}
|
||||||
}}>
|
<View style={styles.heroSection}>
|
||||||
<TouchableOpacity onPress={() => navigation.goBack()}>
|
{/* 배경 블러 이미지 - 웹: absolute inset-0 overflow-hidden */}
|
||||||
<Ionicons name="arrow-back" size={24} color={colors.textPrimary} />
|
<View style={styles.heroBgContainer}>
|
||||||
</TouchableOpacity>
|
<Image
|
||||||
<Text style={{
|
source={{ uri: album.cover_medium_url }}
|
||||||
flex: 1,
|
style={styles.heroBgImage}
|
||||||
fontSize: 18,
|
blurRadius={24}
|
||||||
fontWeight: '600',
|
/>
|
||||||
color: colors.textPrimary,
|
{/* 그라데이션 오버레이 - 웹: bg-gradient-to-b from-white/60 via-white/80 to-white */}
|
||||||
textAlign: 'center',
|
<LinearGradient
|
||||||
marginRight: 24,
|
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>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 콘텐츠 - 웹: 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>
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
{/* 앨범 정보 - 웹: text-center */}
|
||||||
{/* 앨범 커버 */}
|
<View style={styles.albumInfoContainer}>
|
||||||
<View style={{ alignItems: 'center', marginBottom: 20 }}>
|
{/* 타입 뱃지 - 웹: inline-block px-3 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full mb-2 */}
|
||||||
<Image
|
<View style={styles.typeBadge}>
|
||||||
source={{ uri: album.cover_medium_url }}
|
<Text style={styles.typeBadgeText}>{album.album_type}</Text>
|
||||||
style={{
|
</View>
|
||||||
width: 200,
|
|
||||||
height: 200,
|
|
||||||
borderRadius: 12,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 수록곡 */}
|
{/* 타이틀 - 웹: text-2xl font-bold mb-2 */}
|
||||||
{album.tracks && album.tracks.length > 0 && (
|
<Text style={styles.albumTitle}>{album.title}</Text>
|
||||||
<View style={{ marginBottom: 24 }}>
|
|
||||||
<Text style={{
|
{/* 메타 정보 - 웹: flex items-center justify-center gap-4 text-sm text-gray-500 */}
|
||||||
fontSize: 16,
|
<View style={styles.metaRow}>
|
||||||
fontWeight: '600',
|
{/* 날짜 - 웹: flex items-center gap-1 */}
|
||||||
color: colors.textPrimary,
|
<View style={styles.metaItem}>
|
||||||
marginBottom: 12,
|
<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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{
|
|
||||||
width: 30,
|
|
||||||
fontSize: 14,
|
|
||||||
color: colors.textTertiary,
|
|
||||||
}}>
|
|
||||||
{String(track.track_number).padStart(2, '0')}
|
|
||||||
</Text>
|
|
||||||
<Text style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 15,
|
|
||||||
color: colors.textPrimary,
|
|
||||||
}}>
|
|
||||||
{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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
{/* 곡 수 */}
|
||||||
</View>
|
<View style={styles.metaItem}>
|
||||||
))}
|
<Music size={14} color="#6B7280" />
|
||||||
</View>
|
<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.photos && album.photos.length > 0 && (
|
{album.description ? (
|
||||||
<View>
|
<TouchableOpacity
|
||||||
<View style={{
|
style={styles.descButton}
|
||||||
flexDirection: 'row',
|
onPress={openDescriptionModal}
|
||||||
justifyContent: 'space-between',
|
>
|
||||||
alignItems: 'center',
|
<FileText size={12} color="#6B7280" />
|
||||||
marginBottom: 12,
|
<Text style={styles.descButtonText}>앨범 소개</Text>
|
||||||
}}>
|
</TouchableOpacity>
|
||||||
<Text style={{
|
) : null}
|
||||||
fontSize: 16,
|
</View>
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.textPrimary,
|
|
||||||
}}>
|
|
||||||
컨셉 포토
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => navigation.navigate('AlbumGallery', { name })}
|
|
||||||
>
|
|
||||||
<Text style={{ fontSize: 13, color: colors.primary }}>
|
|
||||||
전체보기 ({album.photos.length})
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
{/* 3열 그리드 (최대 6개) */}
|
</View>
|
||||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
||||||
{album.photos.slice(0, 6).map((photo) => (
|
{/* 티저 포토 - 웹: 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
|
<Image
|
||||||
key={photo.id}
|
source={{
|
||||||
source={{ uri: photo.thumb_url || photo.medium_url }}
|
uri: teaser.media_type === 'video'
|
||||||
style={{
|
? videoThumbnails[teaser.original_url] || undefined
|
||||||
width: '31%',
|
: (teaser.thumb_url || teaser.original_url)
|
||||||
aspectRatio: 1,
|
}}
|
||||||
borderRadius: 8,
|
style={styles.teaserImage}
|
||||||
backgroundColor: colors.border,
|
/>
|
||||||
}}
|
{/* 동영상인 경우 재생 버튼 오버레이 */}
|
||||||
/>
|
{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>
|
||||||
|
{/* 웹: 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>
|
||||||
|
{/* 웹: 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>
|
</View>
|
||||||
|
|
||||||
|
{/* 더보기/접기 버튼 - 웹: 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>
|
</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={styles.viewAllButtonText}>
|
||||||
|
전체 {allPhotos.length}장 보기
|
||||||
|
</Text>
|
||||||
|
<ChevronRight size={16} color={colors.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue