diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index c223463..0e51a79 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,136 +1,5 @@ -import { Feather } from '@expo/vector-icons'; -import { Tabs, router } from 'expo-router'; -import { Alert, Platform, Pressable, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { colors } from '@/constants/colors'; -import { getTabBarHeight } from '@/constants/layout'; -import { useTravelSessionStore } from '@/store/travelSessionStore'; - -type TabIconName = keyof typeof Feather.glyphMap; - -const webGlassTabBarStyle = { - backdropFilter: 'blur(22px) saturate(150%)', - WebkitBackdropFilter: 'blur(22px) saturate(150%)', -}; - -function TabIcon({ name, color }: { name: TabIconName; color: string }) { - return ; -} - -function CameraTabButton() { - const { session, startSession } = useTravelSessionStore(); - const openCamera = () => router.push('/camera'); - const handlePress = () => { - if (session.status === 'active') { - openCamera(); - return; - } - - Alert.alert('여행을 시작할까요?', '순간 저장은 현재 여행에 연결돼요.', [ - { style: 'cancel', text: '취소' }, - { - onPress: () => { - startSession(); - openCamera(); - }, - text: '시작하고 촬영', - }, - ]); - }; - - return ( - - - - - - ); -} +import { BottomNavigation } from '@/components/navigation/BottomNavigation'; export default function TabsLayout() { - const insets = useSafeAreaInsets(); - - return ( - - , - title: '홈', - }} - /> - , - title: 'Recap', - }} - /> - , - title: '기록', - }} - /> - , - title: '보관함', - }} - /> - - - , - title: '마이', - }} - /> - - ); + return ; } diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 188644b..270924c 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,6 +1,6 @@ import { router } from 'expo-router'; -import { useCallback, useEffect, useState } from 'react'; -import { ScrollView } from 'react-native'; +import { useCallback, useEffect } from 'react'; +import { ScrollView, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { @@ -13,6 +13,7 @@ import { MiniPlayer } from '@/components/MiniPlayer'; import { FeaturedPlaylistSection } from '@/components/home/FeaturedPlaylistSection'; import { HomeHeader, + HomeNavigationBar, HomeTopFilterBar, isHomeTopFilter, } from '@/components/home/HomeHeader'; @@ -21,7 +22,6 @@ import { isMoodRecommendationFilter, } from '@/components/home/MoodRecommendationSection'; import { MusicLogSection } from '@/components/home/MusicLogSection'; -import { TravelModeSuggestionSheet } from '@/components/home/TravelModeSuggestionSheet'; import { TravelSessionCard } from '@/components/home/TravelSessionCard'; import { Screen } from '@/components/Screen'; import { getHomeContentBottomPadding } from '@/constants/layout'; @@ -40,8 +40,6 @@ import { createRecommendationEventContext } from '@/utils/recommendationEventCon function HomeContent() { const insets = useSafeAreaInsets(); - const [dismissedSuggestionPlaceId, setDismissedSuggestionPlaceId] = - useState(); const { selectedMoodFilter, selectedTopFilter, @@ -62,13 +60,11 @@ function HomeContent() { recommendationMode, selectedMode, session, - endSession, + resetSession, setLocation, setLocationStatus, - setMode, setPlace, setRecommendationMode, - startSession, } = useTravelSessionStore(); const nearbyPlacesQuery = useNearbyPlacesQuery({ @@ -122,6 +118,17 @@ function HomeContent() { }, [currentPlace?.id, nearbyPlacesQuery.data, setPlace]); const handleSelectRecommendation = (item: MoodRecommendation) => { + if (item.playlistId) { + router.push(`/playlist/${item.playlistId}`); + addRecommendationEvent({ + context: createRecommendationEventContext(), + playlistId: item.playlistId, + type: 'playlist_open', + value: item.playlistId, + }); + return; + } + setTrack(item.track); addRecommendationEvent({ context: createRecommendationEventContext(), @@ -219,50 +226,49 @@ function HomeContent() { profile.locationRecommendationEnabled, ]); - const shouldShowTravelModeSuggestion = - Boolean(currentPlace) && - profile.locationRecommendationEnabled && - recommendationMode === 'everyday' && - dismissedSuggestionPlaceId !== currentPlace?.id; - return ( + + - router.push('/recap')} - onSelectMode={setMode} - onStartSession={startSession} - selectedMode={selectedMode} - startedAt={session.startedAt} - status={session.status} - /> + {recommendationMode === 'travel' ? ( + + router.push('/recap')} + onOpenTravel={() => router.push('/travel')} + selectedMode={selectedMode} + startedAt={session.startedAt} + status={session.status} + /> + + ) : null} - + + + void featuredPlaylistsQuery.refetch()} /> - + + + - + + + {currentTrack ? : null} - {shouldShowTravelModeSuggestion && currentPlace ? ( - setDismissedSuggestionPlaceId(currentPlace.id)} - onStartTravelMode={() => { - handleSelectRecommendationMode('travel'); - setDismissedSuggestionPlaceId(currentPlace.id); - }} - place={currentPlace} - /> - ) : null} ); } diff --git a/app/(tabs)/travel.tsx b/app/(tabs)/travel.tsx new file mode 100644 index 0000000..96a5fbd --- /dev/null +++ b/app/(tabs)/travel.tsx @@ -0,0 +1,5 @@ +import { TravelScreen } from '@/components/travel/TravelScreen'; + +export default function TravelTabScreen() { + return ; +} diff --git a/assets/soundlog-logo.png b/assets/soundlog-logo.png index 41e4cab..5efdc53 100644 Binary files a/assets/soundlog-logo.png and b/assets/soundlog-logo.png differ diff --git a/docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md b/docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md new file mode 100644 index 0000000..de37588 --- /dev/null +++ b/docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md @@ -0,0 +1,45 @@ +# Soundlog Design System + +## Palette + +The Soundlog palette is built from the reference colors: + +| Token | Hex | Role | +| --- | --- | --- | +| `brand.pulseMagenta` | `#D718F1` | Personal taste, emotional highlights, everyday music energy | +| `brand.electricViolet` | `#4F2AEC` | Selected states, primary brand surfaces, active chips | +| `brand.signalPurple` | `#872BA8` | Supporting depth, recap and memory moments | +| `brand.limeWave` | `#B7E628` | High-priority action, travel mode, focus borders | +| `brand.deepIndigo` | `#3B11C4` | Deep anchors, player and navigation depth | + +## Color Roles + +Use role tokens from `src/constants/colors.ts` before adding screen-local colors. + +- Backgrounds use near-black violet tones so the bright palette feels musical instead of flat. +- Surfaces use glassy purple cards and chips with white borders at low opacity. +- Active selections use electric violet with lime focus where the action should feel immediate. +- Primary CTAs and travel-state highlights use lime with `text.inverse`. +- Magenta is reserved for taste, mood, and expressive music moments. + +## Tailwind Tokens + +NativeWind tokens live under `soundlog` in `tailwind.config.js`. + +- `bg-soundlog-bg`: app background +- `bg-soundlog-card`: standard cards +- `bg-soundlog-elevated`: emphasized panels +- `bg-soundlog-chip`: unselected chips and compact controls +- `bg-soundlog-selected`: selected chips and segmented controls +- `bg-soundlog-lime`: high-priority actions +- `border-soundlog-border`: standard purple border +- `border-soundlog-focus`: focus or active emphasis +- `text-soundlog-inverse`: text on lime or bright selected surfaces + +## Component Rules + +- Keep the camera capture action lime so it is recognizable across tabs. +- Keep tab active state lime and inactive state muted white. +- Chips should stay compact, rounded, and role-based: selected violet, unselected purple. +- Do not use the five source colors evenly on every screen. Each screen should have one dominant role and one accent. +- Avoid adding new hex values in components unless the value is data-driven, such as track artwork fallback colors. diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index ec49a27..fc7aaa1 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -15,8 +15,8 @@ import { createRecommendationEventContext } from '@/utils/recommendationEventCon import { getTrackKeyColor, hexToRgba } from '@/utils/trackVisuals'; const webGlassPlayerStyle = { - backdropFilter: 'blur(24px) saturate(155%)', - WebkitBackdropFilter: 'blur(24px) saturate(155%)', + backdropFilter: 'blur(30px) saturate(170%)', + WebkitBackdropFilter: 'blur(30px) saturate(170%)', }; export function MiniPlayer() { @@ -155,9 +155,9 @@ export function MiniPlayer() { return ( <> - + setIsActionMenuVisible(true)} + disabled={!canSkip} + onPress={handlePlayNext} + style={{ opacity: canSkip ? 1 : 0.35 }} > - + diff --git a/src/components/home/HomeHeader.tsx b/src/components/home/HomeHeader.tsx index e0b8eee..5ac5e22 100644 --- a/src/components/home/HomeHeader.tsx +++ b/src/components/home/HomeHeader.tsx @@ -1,11 +1,10 @@ -import { Feather } from '@expo/vector-icons'; import { Pressable, ScrollView, View } from 'react-native'; import { AppText } from '@/components/AppText'; import { BrandLogo } from '@/components/BrandLogo'; import { Chip } from '@/components/Chip'; import { colors } from '@/constants/colors'; -import type { MusicRecommendationMode, PlaceContext } from '@/types/domain'; +import type { MusicRecommendationMode } from '@/types/domain'; const topFilters = ['전체', '근처', '지역 트렌드', '내 취향', '저장 많은']; @@ -14,11 +13,8 @@ export function isHomeTopFilter(filter: string) { } type HomeHeaderProps = { - currentPlace?: PlaceContext; - isLocationLoading?: boolean; recommendationMode: MusicRecommendationMode; onSelectRecommendationMode: (mode: MusicRecommendationMode) => void; - onSetCurrentLocation: () => void; }; type HomeTopFilterBarProps = { @@ -33,55 +29,34 @@ const musicModeOptions: Array<{ }> = [ { accent: colors.accent.blue, - label: 'Everyday', + label: '일상 모드', value: 'everyday', }, { accent: colors.accent.lime, - label: 'Travel', + label: '여행 모드', value: 'travel', }, ]; +export function HomeNavigationBar() { + return ( + + + + Soundlog + + + ); +} + export function HomeHeader({ - currentPlace, - isLocationLoading = false, onSelectRecommendationMode, - onSetCurrentLocation, recommendationMode, }: HomeHeaderProps) { - const activeMode = - musicModeOptions.find((mode) => mode.value === recommendationMode) ?? - musicModeOptions[0]; - const locationLabel = currentPlace?.title ?? '위치를 확인해 볼까요?'; - return ( - - - - - - - Music Mode - - - {recommendationMode === 'travel' ? '여행지에 맞는 음악' : '평소 취향 중심 추천'} - - - - - - - - - + + {musicModeOptions.map((mode) => { const selected = recommendationMode === mode.value; @@ -90,7 +65,7 @@ export function HomeHeader({ onSelectRecommendationMode(mode.value)} style={{ @@ -99,8 +74,10 @@ export function HomeHeader({ }} > {mode.label} @@ -109,27 +86,6 @@ export function HomeHeader({ })} - - - - - - {locationLabel} - - - - - - {isLocationLoading ? '확인 중' : '위치 설정'} - - - ); } diff --git a/src/components/home/LocationContextCard.tsx b/src/components/home/LocationContextCard.tsx index 1690eb6..967ff33 100644 --- a/src/components/home/LocationContextCard.tsx +++ b/src/components/home/LocationContextCard.tsx @@ -34,9 +34,9 @@ const statusCopy: Record diff --git a/src/components/home/MusicLogCard.tsx b/src/components/home/MusicLogCard.tsx index d9d57be..5dd77c8 100644 --- a/src/components/home/MusicLogCard.tsx +++ b/src/components/home/MusicLogCard.tsx @@ -1,65 +1,86 @@ import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { + Animated, + Pressable, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native'; import { AppText } from '@/components/AppText'; import { MusicLogItem } from '@/types/domain'; type MusicLogCardProps = { + animatedStyle?: StyleProp; + cardHeight?: number; + cardWidth?: number; index: number; item: MusicLogItem; onPress?: () => void; + style?: StyleProp; }; -export function MusicLogCard({ index, item, onPress }: MusicLogCardProps) { +export function MusicLogCard({ + animatedStyle, + cardHeight = 170, + cardWidth = 116, + item, + onPress, + style, +}: MusicLogCardProps) { const hasImage = Boolean(item.imageUrl); return ( - - {item.imageUrl ? ( - <> - - - - ) : null} + + + {item.imageUrl ? ( + <> + + + + ) : null} - - LOG - + + LOG + - - {item.placeName} - - - {item.trackTitle} - - + + {item.placeName} + + + {item.trackTitle} + + + ); } diff --git a/src/components/home/MusicLogSection.tsx b/src/components/home/MusicLogSection.tsx index 0ea0d0b..36a806e 100644 --- a/src/components/home/MusicLogSection.tsx +++ b/src/components/home/MusicLogSection.tsx @@ -1,4 +1,11 @@ -import { ScrollView, View } from 'react-native'; +import { useEffect, useRef, useState } from 'react'; +import { + Animated, + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + View, +} from 'react-native'; import { AppText } from '@/components/AppText'; import { MusicLogCard } from '@/components/home/MusicLogCard'; @@ -11,11 +18,17 @@ type MusicLogSectionProps = { onSelectLog?: (item: MusicLogItem) => void; }; +const MUSIC_LOG_CARD_WIDTH = 164; +const MUSIC_LOG_CARD_HEIGHT = 216; +const MUSIC_LOG_CARD_GAP = 14; +const MUSIC_LOG_SNAP_INTERVAL = MUSIC_LOG_CARD_WIDTH + MUSIC_LOG_CARD_GAP; +const MUSIC_LOG_INITIAL_INDEX = 1; + function MusicLogSkeleton() { return ( - + {[0, 1, 2].map((item) => ( - + ))} ); @@ -27,9 +40,67 @@ export function MusicLogSection({ isLoading = false, onSelectLog, }: MusicLogSectionProps) { + const scrollRef = useRef(null); + const scrollX = useRef(new Animated.Value(MUSIC_LOG_INITIAL_INDEX * MUSIC_LOG_SNAP_INTERVAL)).current; + const [sectionWidth, setSectionWidth] = useState(0); + const carouselSidePadding = + sectionWidth > 0 ? Math.max(0, (sectionWidth - MUSIC_LOG_CARD_WIDTH) / 2) : 0; + const initialIndex = data.length > MUSIC_LOG_INITIAL_INDEX ? MUSIC_LOG_INITIAL_INDEX : 0; + const canLoop = data.length > 1; + const carouselData = canLoop ? [...data, ...data, ...data] : data; + const initialCarouselIndex = canLoop ? data.length + initialIndex : initialIndex; + const initialOffset = initialCarouselIndex * MUSIC_LOG_SNAP_INTERVAL; + + const handleLayout = (event: LayoutChangeEvent) => { + setSectionWidth(event.nativeEvent.layout.width); + }; + + const resetLoopEdge = (event: NativeSyntheticEvent) => { + if (!canLoop) { + return; + } + + const currentIndex = Math.round(event.nativeEvent.contentOffset.x / MUSIC_LOG_SNAP_INTERVAL); + + if (currentIndex >= data.length && currentIndex < data.length * 2) { + return; + } + + const realIndex = ((currentIndex % data.length) + data.length) % data.length; + const nextIndex = data.length + realIndex; + + const nextOffset = nextIndex * MUSIC_LOG_SNAP_INTERVAL; + + scrollX.setValue(nextOffset); + scrollRef.current?.scrollTo({ + animated: false, + x: nextOffset, + y: 0, + }); + }; + + useEffect(() => { + if (data.length === 0) { + return; + } + + const nextIndex = data.length > MUSIC_LOG_INITIAL_INDEX ? MUSIC_LOG_INITIAL_INDEX : 0; + const nextCarouselIndex = canLoop ? data.length + nextIndex : nextIndex; + const nextOffset = nextCarouselIndex * MUSIC_LOG_SNAP_INTERVAL; + + scrollX.setValue(nextOffset); + requestAnimationFrame(() => { + scrollRef.current?.scrollTo({ + animated: false, + x: nextOffset, + y: 0, + }); + }); + }, [canLoop, data.length, scrollX]); + return ( - - Music Log + + Music Log {isLoading ? ( @@ -46,18 +117,61 @@ export function MusicLogSection({ ) : ( - - - {data.map((item, index) => ( + + {carouselData.map((item, index) => { + const inputRange = [ + (index - 1) * MUSIC_LOG_SNAP_INTERVAL, + index * MUSIC_LOG_SNAP_INTERVAL, + (index + 1) * MUSIC_LOG_SNAP_INTERVAL, + ]; + const rotate = scrollX.interpolate({ + extrapolate: 'clamp', + inputRange, + outputRange: ['8deg', '0deg', '-8deg'], + }); + const scale = scrollX.interpolate({ + extrapolate: 'clamp', + inputRange, + outputRange: [0.9, 1, 0.9], + }); + + return ( onSelectLog(item) : undefined} + style={{ marginRight: index === carouselData.length - 1 ? 0 : MUSIC_LOG_CARD_GAP }} /> - ))} - - + ); + })} + )} ); diff --git a/src/components/home/TravelSessionCard.tsx b/src/components/home/TravelSessionCard.tsx index 23fc86a..72cefb1 100644 --- a/src/components/home/TravelSessionCard.tsx +++ b/src/components/home/TravelSessionCard.tsx @@ -1,19 +1,18 @@ import { Feather } from '@expo/vector-icons'; -import { Pressable, ScrollView, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; -import { Chip } from '@/components/Chip'; -import type { TravelMode } from '@/types/domain'; +import type { PlaceContext, TravelMode } from '@/types/domain'; import { formatRecapRecordedAt } from '@/utils/dateFormat'; type TravelSessionStatus = 'active' | 'ended' | 'idle'; type TravelSessionCardProps = { endedAt?: string; - onEndSession: () => void; + currentPlace?: PlaceContext; + onDismissEnded?: () => void; onOpenRecap: () => void; - onSelectMode: (mode: TravelMode) => void; - onStartSession: () => void; + onOpenTravel: () => void; selectedMode?: TravelMode; startedAt?: string; status: TravelSessionStatus; @@ -38,19 +37,19 @@ const statusCopy: Record< } > = { active: { - cta: '여행 종료', + cta: '여행 보기', description: '순간 저장과 Music Log가 지금 여행에 함께 묶이고 있어요.', icon: 'radio', title: '여행 기록 중', }, ended: { - cta: '새 여행 시작', + cta: '리캡 보기', description: '저장한 순간은 Recap에서 다시 확인할 수 있어요.', icon: 'check-circle', title: '여행이 종료됐어요', }, idle: { - cta: '여행 시작', + cta: '여행 보기', description: '현재 장소와 음악을 하나의 여정으로 묶어 기록해요.', icon: 'play-circle', title: '여행을 시작해볼까요?', @@ -70,95 +69,93 @@ function formatSessionTime(status: TravelSessionStatus, startedAt?: string, ende } export function TravelSessionCard({ + currentPlace, endedAt, - onEndSession, + onDismissEnded, onOpenRecap, - onSelectMode, - onStartSession, + onOpenTravel, selectedMode, startedAt, status, }: TravelSessionCardProps) { const copy = statusCopy[status]; - const onPrimaryPress = status === 'active' ? onEndSession : onStartSession; + const onPrimaryPress = status === 'ended' ? onOpenRecap : onOpenTravel; const sessionTime = formatSessionTime(status, startedAt, endedAt); const selectedModeLabel = travelModeOptions.find( (mode) => mode.value === selectedMode, )?.label; + const isActive = status === 'active'; + const locationLabel = currentPlace?.title ?? '현재 위치 확인 중'; return ( - - - - + + + + - - - {copy.title} + + {copy.title} + + {status === 'idle' ? ( + + {sessionTime} - - {selectedModeLabel ? ( - - + ) : null} + {status === 'active' && selectedModeLabel ? ( + + {selectedModeLabel} ) : null} - - {sessionTime} - + {isActive ? ( + + + + {sessionTime} + + · + + + {locationLabel} + + + ) : status !== 'idle' ? ( + + {sessionTime} + + ) : null} {copy.cta} - - {status !== 'active' ? ( - - {copy.description} - - ) : null} - - - 여행 모드 - - - {travelModeOptions.map((mode) => ( - onSelectMode(mode.value)} - selected={selectedMode === mode.value} - /> - ))} - - - - - {status === 'ended' ? ( - + {status === 'ended' && onDismissEnded ? ( - Recap 보기 + - - ) : null} + ) : null} + ); } diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx new file mode 100644 index 0000000..f9ee949 --- /dev/null +++ b/src/components/navigation/BottomNavigation.tsx @@ -0,0 +1,160 @@ +import { Feather } from '@expo/vector-icons'; +import { Tabs, router } from 'expo-router'; +import { + Alert, + Platform, + Pressable, + StyleProp, + View, + ViewStyle, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { colors } from '@/constants/colors'; +import { getTabBarHeight } from '@/constants/layout'; +import { useTravelSessionStore } from '@/store/travelSessionStore'; + +type TabIconName = keyof typeof Feather.glyphMap; + +const webGlassTabBarStyle = { + backdropFilter: 'blur(22px) saturate(150%)', + WebkitBackdropFilter: 'blur(22px) saturate(150%)', +}; + +function TabIcon({ name, color }: { name: TabIconName; color: string }) { + return ; +} + +function CameraTabButton({ style }: { style?: StyleProp }) { + const { session, startSession } = useTravelSessionStore(); + const openCamera = () => router.push('/camera'); + const handlePress = () => { + if (session.status === 'active') { + openCamera(); + return; + } + + Alert.alert('여행을 시작할까요?', '순간 저장은 현재 여행에 연결돼요.', [ + { style: 'cancel', text: '취소' }, + { + onPress: () => { + startSession(); + openCamera(); + }, + text: '시작하고 촬영', + }, + ]); + }; + + return ( + + + + + + + + ); +} + +export function BottomNavigation() { + const insets = useSafeAreaInsets(); + + return ( + + , + title: '홈', + }} + /> + , + title: '여행', + }} + /> + , + title: '카메라', + }} + /> + , + title: '보관함', + }} + /> + , + title: '마이', + }} + /> + + + + + ); +} diff --git a/src/components/playlist/PlaylistBackground.tsx b/src/components/playlist/PlaylistBackground.tsx index e9cc221..6ab3bda 100644 --- a/src/components/playlist/PlaylistBackground.tsx +++ b/src/components/playlist/PlaylistBackground.tsx @@ -1,21 +1,33 @@ import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet, useWindowDimensions, View } from 'react-native'; type PlaylistBackgroundProps = { + accentColor?: string; imageUrl?: string; }; -export function PlaylistBackground({ imageUrl }: PlaylistBackgroundProps) { +export function PlaylistBackground({ accentColor, imageUrl }: PlaylistBackgroundProps) { + const { height, width } = useWindowDimensions(); + const repeatedImageStyle = { height: height / 2, width }; + return ( {imageUrl ? ( - + <> + + + ) : ( )} - + + {accentColor ? ( + + ) : null} ); } diff --git a/src/components/playlist/PlaylistBottomSheet.tsx b/src/components/playlist/PlaylistBottomSheet.tsx index 25a1e5b..cfec4b1 100644 --- a/src/components/playlist/PlaylistBottomSheet.tsx +++ b/src/components/playlist/PlaylistBottomSheet.tsx @@ -1,8 +1,16 @@ import { PropsWithChildren, ReactNode } from 'react'; -import { ScrollView, useWindowDimensions, View } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Platform, ScrollView, useWindowDimensions, View } from 'react-native'; const COLLAPSED_TOP = 205; -const SHEET_BACKGROUND = 'rgba(5, 9, 22, 0.78)'; +const SHEET_BACKGROUND = 'rgba(10, 16, 31, 0.26)'; +const glassSurfaceStyle = Platform.select({ + web: { + WebkitBackdropFilter: 'blur(18px) saturate(135%)', + backdropFilter: 'blur(18px) saturate(135%)', + }, + default: {}, +}); type PlaylistBottomSheetProps = PropsWithChildren<{ stickyHeader?: ReactNode; @@ -10,6 +18,7 @@ type PlaylistBottomSheetProps = PropsWithChildren<{ export function PlaylistBottomSheet({ children, stickyHeader }: PlaylistBottomSheetProps) { const { height } = useWindowDimensions(); + const sheetStyle = { ...glassSurfaceStyle, backgroundColor: SHEET_BACKGROUND }; return ( {stickyHeader ? ( - [ - - - {stickyHeader} - , - - {children} - , - ] + + + + {stickyHeader} + {children} + ) : ( + {children} diff --git a/src/components/playlist/PlaylistCurationScreen.tsx b/src/components/playlist/PlaylistCurationScreen.tsx index 0adb3f9..c2d4236 100644 --- a/src/components/playlist/PlaylistCurationScreen.tsx +++ b/src/components/playlist/PlaylistCurationScreen.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { View } from 'react-native'; +import { ScrollView, useWindowDimensions, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { usePlaylistCurationQuery } from '@/api/playlistQueries'; @@ -27,6 +27,7 @@ type PlaylistCurationScreenProps = { export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenProps) { const insets = useSafeAreaInsets(); + const { height } = useWindowDimensions(); const { currentTrack, setTrack } = usePlayerStore(); const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent); const { @@ -75,6 +76,7 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro const hasMiniPlayer = Boolean(currentTrack); const listBottomPadding = getCurationListBottomPadding(insets.bottom, hasMiniPlayer); + const usesPlainMoodPage = playlistId === 'calm-walk' || Boolean(playlist?.accentColor); const playTrack = (track: Track) => { if (!playlist) { @@ -131,40 +133,59 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro }; const closeMenu = () => setSelectedTrackId(undefined); + const playlistContent = isLoading ? ( + + ) : isError ? ( + refetch()} /> + ) : !playlist ? ( + + ) : ( + setSelectedTrackId(track.id)} + onSelectTrack={playTrack} + savedTrackIds={savedTrackIds} + tracks={playlist.tracks} + /> + ); return ( - - - + + {usesPlainMoodPage ? ( + + {playlist ? ( - ) : undefined - } - > - {isLoading ? ( - - ) : isError ? ( - refetch()} /> - ) : !playlist ? ( - - ) : ( - setSelectedTrackId(track.id)} - onSelectTrack={playTrack} - savedTrackIds={savedTrackIds} - tracks={playlist.tracks} - /> - )} - + ) : null} + {playlistContent} + + ) : ( + + ) : undefined + } + > + {playlistContent} + + )} - - - {playlist.regionName} - - {playlist.placeName ? ( - - {playlist.placeName} + + + + + {playlist.regionName} - ) : null} - - {playlist.reason} - - - - {playlist.trackCount}곡 - - - {playlist.durationText} + {playlist.placeName ? ( + + {playlist.placeName} + + ) : null} + + {playlist.reason} + + + + {playlist.trackCount}곡 + + + {playlist.durationText} + + + + + - - - + + + ); } diff --git a/src/components/playlist/PlaylistMusicFilter.tsx b/src/components/playlist/PlaylistMusicFilter.tsx new file mode 100644 index 0000000..04ab68b --- /dev/null +++ b/src/components/playlist/PlaylistMusicFilter.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import { Pressable, ScrollView, View } from 'react-native'; + +import { AppText } from '@/components/AppText'; + +const musicFilters = ['전체', '드라이브', '산책', '시원한 바람', '신나는']; + +export function PlaylistMusicFilter() { + const [selectedFilter, setSelectedFilter] = useState(musicFilters[0]); + + return ( + + {musicFilters.map((filter) => { + const isSelected = selectedFilter === filter; + + return ( + setSelectedFilter(filter)} + style={{ + backgroundColor: isSelected ? 'rgba(255, 255, 255, 0.18)' : 'rgba(255, 255, 255, 0.09)', + borderColor: isSelected ? 'rgba(255, 255, 255, 0.42)' : 'rgba(255, 255, 255, 0.22)', + }} + > + + + {filter} + + + ); + })} + + ); +} diff --git a/src/components/travel/EndTravelConfirmModal.tsx b/src/components/travel/EndTravelConfirmModal.tsx new file mode 100644 index 0000000..54d2954 --- /dev/null +++ b/src/components/travel/EndTravelConfirmModal.tsx @@ -0,0 +1,52 @@ +import { Feather } from '@expo/vector-icons'; +import { Modal, Pressable, View } from 'react-native'; + +import { AppText } from '@/components/AppText'; + +type EndTravelConfirmModalProps = { + momentCount: number; + onCancel: () => void; + onConfirm: () => void; + visible: boolean; +}; + +export function EndTravelConfirmModal({ + momentCount, + onCancel, + onConfirm, + visible, +}: EndTravelConfirmModalProps) { + return ( + + + + + + + + 여행을 종료할까요? + + 지금까지 저장한 Moment {momentCount}개가 Recap 생성을 위한 여행 기록으로 묶여요. + + + + + 취소 + + + 종료 + + + + + + ); +} diff --git a/src/components/travel/MomentCard.tsx b/src/components/travel/MomentCard.tsx new file mode 100644 index 0000000..a0f5617 --- /dev/null +++ b/src/components/travel/MomentCard.tsx @@ -0,0 +1,69 @@ +import { Feather } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import { Pressable, View } from 'react-native'; + +import { AppText } from '@/components/AppText'; +import type { MomentLog } from '@/types/domain'; + +import { formatKoreanDateTime } from './travelFormat'; +import { moodLabelByValue } from './travelData'; + +type MomentCardProps = { + item: MomentLog; + onPress?: () => void; +}; + +export function MomentCard({ item, onPress }: MomentCardProps) { + const moodLabel = item.moodTags[0] ? moodLabelByValue[item.moodTags[0]] : '무드 기록'; + + return ( + + + {item.photoUri ? ( + + ) : ( + + + + )} + + + + + + + Moment + + + {moodLabel} + + + + + + + {item.placeName ?? '위치 없음'} + + + + + + + {item.track ? `${item.track.title} - ${item.track.artist}` : '음악 없음'} + + + + + + {formatKoreanDateTime(item.createdAt)} + + + + ); +} diff --git a/src/components/travel/RecapCard.tsx b/src/components/travel/RecapCard.tsx new file mode 100644 index 0000000..d73c8a6 --- /dev/null +++ b/src/components/travel/RecapCard.tsx @@ -0,0 +1,63 @@ +import { Feather } from '@expo/vector-icons'; +import { Pressable, View } from 'react-native'; + +import { AppText } from '@/components/AppText'; + +import { modeIconByValue, modeLabelByValue, type TravelRecap } from './travelData'; + +type RecapCardProps = { + item: TravelRecap; + onPress: () => void; +}; + +export function RecapCard({ item, onPress }: RecapCardProps) { + return ( + + + + {modeIconByValue[item.mode]} + + + + + + {modeLabelByValue[item.mode]} + + {item.date} + + + + {item.durationText} · {item.playTimeText.replace('총 음악 재생 ', '')} + + + + + + {item.playCount}회 + + + + + {item.trackCount}곡 + + + + + Moment {item.momentCount} + + + + + + + + + + + ); +} diff --git a/src/components/travel/TravelModeBottomSheet.tsx b/src/components/travel/TravelModeBottomSheet.tsx new file mode 100644 index 0000000..9721dd9 --- /dev/null +++ b/src/components/travel/TravelModeBottomSheet.tsx @@ -0,0 +1,102 @@ +import { Feather } from '@expo/vector-icons'; +import { Modal, Pressable, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { AppText } from '@/components/AppText'; +import type { TravelMode } from '@/types/domain'; + +import { travelModeOptions } from './travelData'; + +type TravelModeBottomSheetProps = { + onClose: () => void; + onSelectMode: (mode: TravelMode) => void; + onStart: () => void; + selectedMode?: TravelMode; + visible: boolean; +}; + +export function TravelModeBottomSheet({ + onClose, + onSelectMode, + onStart, + selectedMode, + visible, +}: TravelModeBottomSheetProps) { + const insets = useSafeAreaInsets(); + + return ( + + + + + + + + + 여행 모드 선택 + + 지금의 여행 맥락을 고르면 음악 추천과 Moment가 같은 세션으로 묶여요. + + + + + + + + + {travelModeOptions.map((mode) => { + const selected = selectedMode === mode.value; + + return ( + onSelectMode(mode.value)} + > + {mode.icon} + + {mode.label} + + + ); + })} + + + + + 여행 시작 + + + + + + ); +} diff --git a/src/components/travel/TravelReportModal.tsx b/src/components/travel/TravelReportModal.tsx new file mode 100644 index 0000000..512b73c --- /dev/null +++ b/src/components/travel/TravelReportModal.tsx @@ -0,0 +1,578 @@ +import { Feather } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useEffect, useState } from 'react'; +import { Image, Modal, Pressable, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { AppText } from '@/components/AppText'; + +import { modeIconByValue, modeLabelByValue, sampleMoments, type TravelRecap } from './travelData'; + +type TravelReportModalProps = { + item?: TravelRecap; + onClose: () => void; + visible: boolean; +}; + +type StoryPage = { + accent: string; + hideBottomBar?: boolean; + hideGrayCircle?: boolean; + hideInnerCircles?: boolean; + hideShapes?: boolean; + key: string; + node: React.ReactNode; + palette: [string, string, string]; +}; + +const STORY_DURATION_MS = 4200; +const STORY_PAGE_COUNT = 6; + +function CoverGeometry({ accent }: { accent: string }) { + return ( + <> + + + + + + + + + + + ); +} + +function DotPattern({ color = 'rgba(255,255,255,0.16)' }: { color?: string }) { + return ( + + {Array.from({ length: 42 }).map((_, index) => { + const row = Math.floor(index / 7); + const column = index % 7; + + return ( + + ); + })} + + ); +} + +function StoryBackdrop({ + accent, + hideBottomBar = false, + hideGrayCircle = false, + hideInnerCircles = false, + hideShapes = false, + minimal = false, + pattern, +}: { + accent: string; + hideBottomBar?: boolean; + hideGrayCircle?: boolean; + hideInnerCircles?: boolean; + hideShapes?: boolean; + minimal?: boolean; + pattern?: 'dots'; +}) { + return ( + + {pattern === 'dots' ? : null} + {minimal ? ( + + ) : hideShapes ? null : ( + <> + + {hideInnerCircles ? null : ( + + )} + + {hideGrayCircle ? null : ( + + )} + {hideInnerCircles ? null : ( + + )} + {hideBottomBar ? null : ( + + )} + + )} + + ); +} + +function ProgressBars({ currentIndex, total }: { currentIndex: number; total: number }) { + return ( + + {Array.from({ length: total }).map((_, index) => ( + + + + ))} + + ); +} + +function StoryShell({ + accent, + children, + hideBottomBar, + hideGrayCircle, + hideInnerCircles, + hideShapes, + minimalBackdrop, + palette, + pattern, +}: { + accent: string; + children: React.ReactNode; + hideBottomBar?: boolean; + hideGrayCircle?: boolean; + hideInnerCircles?: boolean; + hideShapes?: boolean; + minimalBackdrop?: boolean; + palette: [string, string, string]; + pattern?: 'dots'; +}) { + return ( + + + + {children} + + + ); +} + +function SmallCaps({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function PlayerBar({ title }: { title: string }) { + return ( + + + + NOW PLAYING + + {title} + + + + + + ); +} + +export function TravelReportModal({ item, onClose, visible }: TravelReportModalProps) { + const insets = useSafeAreaInsets(); + const [pageIndex, setPageIndex] = useState(0); + + useEffect(() => { + if (visible) { + setPageIndex(0); + } + }, [visible, item?.id]); + + useEffect(() => { + if (!visible) { + return; + } + + const timeoutId = setTimeout(() => { + setPageIndex((index) => { + if (index >= STORY_PAGE_COUNT - 1) { + return index; + } + + return index + 1; + }); + }, STORY_DURATION_MS); + + return () => clearTimeout(timeoutId); + }, [pageIndex, visible]); + + if (!item) { + return null; + } + + const modeLabel = modeLabelByValue[item.mode]; + const modeIcon = modeIconByValue[item.mode]; + const mostPlayed = item.topTracks[0]; + const [startedAtText = item.periodText, rawEndedAtText = item.periodText] = item.periodText + .split(' - ') + .map((value) => value.trim()); + const startedDateText = startedAtText.split(' ')[0] ?? ''; + const endedAtText = rawEndedAtText.includes('.') + ? rawEndedAtText + : `${startedDateText} ${rawEndedAtText}`.trim(); + const recapThumbnails = [sampleMoments[0], sampleMoments[1], sampleMoments[0]].filter(Boolean); + const pages: StoryPage[] = [ + { + accent: '#F2C94C', + hideBottomBar: true, + hideInnerCircles: true, + key: 'cover', + palette: ['#070B1F', '#070B1F', '#070B1F'], + node: ( + + + + Soundlog + + + Travel{'\n'}Recap + + {modeIcon} + + {modeLabel} 기록 + + + + {endedAtText} + + + {startedAtText} + + + + + + Travel location + + + {item.locations.join('\n')} + + + + ), + }, + { + accent: '#FF352B', + hideBottomBar: true, + hideInnerCircles: true, + key: 'summary', + palette: ['#F2C94C', '#F2C94C', '#F2C94C'], + node: ( + + + + Your listening time + + + {item.playTimeText.replace('총 음악 재생 ', '')} + + + 음악으로 채워진{'\n'}{item.durationText} + + + + + + Total plays + + + {item.playCount} + + 회 재생 + + + + Unique tracks + + + {item.trackCount} + + 곡 감상 + + + + ), + }, + { + accent: '#FF352B', + hideInnerCircles: true, + key: 'most', + palette: ['#07131A', '#07131A', '#111C22'], + node: ( + + + Most played + + 이 여행에서{'\n'}가장 많이 들은 노래 + + + + + + + + + + + + + + + + {mostPlayed.title} + + + {mostPlayed.artist} · {mostPlayed.playCount}회 + + + + ), + }, + { + accent: '#F2C94C', + hideInnerCircles: true, + hideBottomBar: true, + key: 'ranking', + palette: ['#FF352B', '#FF352B', '#FF352B'], + node: ( + + + Top songs + + 많이 들은 순위 + + + + {item.topTracks.map((track, index) => ( + + {index + 1} + + + + {track.title} + + + {track.artist} + + + + ))} + + + + ), + }, + { + accent: '#FF352B', + hideBottomBar: true, + hideGrayCircle: true, + hideInnerCircles: true, + key: 'recaps', + palette: ['#F2C94C', '#F2C94C', '#F2C94C'], + node: ( + + + Saved recaps + + 이 여행에서{'\n'}남긴 기록 + + + + + {item.momentCount} + + 개의 Recap + + + + {recapThumbnails.map((moment, index) => ( + + + + ))} + + + + ), + }, + { + accent: '#FF352B', + hideBottomBar: true, + hideInnerCircles: true, + key: 'share', + palette: ['#07131A', '#07131A', '#111C22'], + node: ( + + + Travel summary + + 숫자로 남은{'\n'}이번 여행 + + + + + + Travel time + + + {item.durationText.replace('의 여행', '')} + + + + + + Plays + + + {item.playCount} + + + + + Tracks + + + {item.trackCount} + + + + + + Moments + + + {item.momentCount} + + + + + + Mode + + + {modeIcon} {modeLabel} + + + + ), + }, + ]; + + const goPrevious = () => setPageIndex((index) => Math.max(0, index - 1)); + const goNext = () => setPageIndex((index) => Math.min(pages.length - 1, index + 1)); + const currentPage = pages[pageIndex]; + + return ( + + + + + + + + Soundlog · Travel Recap + + + + + + + + {currentPage.node} + + + + + + + ); +} diff --git a/src/components/travel/TravelScreen.tsx b/src/components/travel/TravelScreen.tsx new file mode 100644 index 0000000..e762381 --- /dev/null +++ b/src/components/travel/TravelScreen.tsx @@ -0,0 +1,191 @@ +import { router } from 'expo-router'; +import { useEffect, useMemo, useState } from 'react'; +import { Pressable, ScrollView, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { MiniPlayer } from '@/components/MiniPlayer'; +import { Screen } from '@/components/Screen'; +import { AppText } from '@/components/AppText'; +import { getHomeContentBottomPadding } from '@/constants/layout'; +import { useMomentLogStore } from '@/store/momentLogStore'; +import { usePlayerStore } from '@/store/playerStore'; +import { useTravelSessionStore } from '@/store/travelSessionStore'; +import type { TravelMode } from '@/types/domain'; + +import { EndTravelConfirmModal } from './EndTravelConfirmModal'; +import { MomentCard } from './MomentCard'; +import { RecapCard } from './RecapCard'; +import { TravelModeBottomSheet } from './TravelModeBottomSheet'; +import { TravelReportModal } from './TravelReportModal'; +import { TravelStatusCard } from './TravelStatusCard'; +import { sampleMoments, sampleRecaps, type TravelRecap } from './travelData'; + +const sampleMomentMusicLogIds: Record = { + 'sample-gwangalli': 'log-1', + 'sample-night': 'log-3', + 'sample-seongsu': 'log-2', +}; + +function getMomentMusicLogId(momentId: string) { + return sampleMomentMusicLogIds[momentId] ?? momentId; +} + +export function TravelScreen() { + const insets = useSafeAreaInsets(); + const [isModeSheetVisible, setIsModeSheetVisible] = useState(false); + const [isEndConfirmVisible, setIsEndConfirmVisible] = useState(false); + const [selectedReport, setSelectedReport] = useState(); + const [, setClockTick] = useState(0); + const { + currentPlace, + selectedMode, + session, + endSession, + resetSession, + setMode, + startSession, + } = useTravelSessionStore(); + const { currentTrack } = usePlayerStore(); + const momentLogs = useMomentLogStore((state) => state.logs); + const moments = useMemo( + () => [...momentLogs, ...sampleMoments].slice(0, 3), + [momentLogs], + ); + const momentCount = Math.max(momentLogs.length, session.status === 'active' ? 8 : 0); + + useEffect(() => { + if (session.status !== 'active') { + return; + } + + const intervalId = setInterval(() => { + setClockTick((tick) => tick + 1); + }, 1000); + + return () => clearInterval(intervalId); + }, [session.status]); + + const openModeSheet = () => { + if (session.status === 'ended') { + resetSession(); + } + + setIsModeSheetVisible(true); + }; + const handleSelectMode = (mode: TravelMode) => { + setMode(mode); + }; + const handleStartTravel = () => { + if (!selectedMode) { + setMode('cafe'); + } + + startSession(); + setIsModeSheetVisible(false); + }; + const handleConfirmEnd = () => { + endSession(); + setIsEndConfirmVisible(false); + }; + + return ( + + + + + 음악으로 기록하는 당신의 여정 + + + Travel + + + + setIsEndConfirmVisible(true)} + onOpenRecap={() => router.push('/recap-share/seoul-night')} + onSaveMoment={() => router.push('/camera')} + onStartTravel={openModeSheet} + selectedMode={selectedMode} + startedAt={session.startedAt} + status={session.status} + /> + + + + + 최근 Moment + 여행 중 직접 저장한 순간 + + router.push('/library')}> + 더보기 + + + + + {moments.map((moment) => ( + router.push(`/recap-share/${getMomentMusicLogId(moment.id)}`)} + /> + ))} + + + + + + + Travel Log + + 여행별 음악과 Moment 요약 + + + + + + {sampleRecaps.slice(0, session.status === 'idle' ? 3 : 2).map((recap) => ( + setSelectedReport(recap)} + /> + ))} + + + + + {currentTrack ? : null} + + setIsModeSheetVisible(false)} + onSelectMode={handleSelectMode} + onStart={handleStartTravel} + selectedMode={selectedMode} + visible={isModeSheetVisible} + /> + setIsEndConfirmVisible(false)} + onConfirm={handleConfirmEnd} + visible={isEndConfirmVisible} + /> + setSelectedReport(undefined)} + visible={Boolean(selectedReport)} + /> + + ); +} diff --git a/src/components/travel/TravelStatusCard.tsx b/src/components/travel/TravelStatusCard.tsx new file mode 100644 index 0000000..f237a80 --- /dev/null +++ b/src/components/travel/TravelStatusCard.tsx @@ -0,0 +1,184 @@ +import { Feather } from '@expo/vector-icons'; +import { Pressable, View } from 'react-native'; + +import { AppText } from '@/components/AppText'; +import type { PlaceContext, Track, TravelMode } from '@/types/domain'; + +import { formatDurationText, formatElapsedTime, formatKoreanDateTime, formatShortEndedAt } from './travelFormat'; +import { modeIconByValue, modeLabelByValue } from './travelData'; + +type TravelStatus = 'active' | 'ended' | 'idle'; + +type TravelStatusCardProps = { + currentPlace?: PlaceContext; + currentTrack?: Track; + endedAt?: string; + momentCount: number; + onEndTravel: () => void; + onOpenRecap: () => void; + onSaveMoment: () => void; + onStartTravel: () => void; + selectedMode?: TravelMode; + startedAt?: string; + status: TravelStatus; +}; + +function Metric({ label, value }: { label: string; value: string }) { + return ( + + {label} + + {value} + + + ); +} + +function CompactMetric({ label, value }: { label: string; value: string }) { + return ( + + {label} + + {value} + + + ); +} + +export function TravelStatusCard({ + currentPlace, + currentTrack, + endedAt, + momentCount, + onEndTravel, + onOpenRecap, + onSaveMoment, + onStartTravel, + selectedMode, + startedAt, + status, +}: TravelStatusCardProps) { + const modeLabel = selectedMode ? modeLabelByValue[selectedMode] : '카페 투어'; + const modeIcon = selectedMode ? modeIconByValue[selectedMode] : '☕'; + const placeLabel = currentPlace?.title ?? '성수 카페거리'; + const currentTrackLabel = currentTrack + ? `${currentTrack.artist} - ${currentTrack.title}` + : 'NewJeans - Ditto'; + + if (status === 'active') { + return ( + + + + + 여행 진행 중 + + + + {modeIcon} {modeLabel} + + + + + + {formatElapsedTime(startedAt)} + + + {formatKoreanDateTime(startedAt)} 시작 + + + + + + + + + + + 현재 재생 중 + + {currentTrackLabel} + + + + + + + 여행 종료 + + + 순간 저장 + + + + ); + } + + if (status === 'ended') { + return ( + + 여행이 종료됐어요 + + 저장한 순간은 Recap에서 다시 확인할 수 있어요. + + + {formatShortEndedAt(endedAt)} + + + + + + + + + + + + + Recap 보기 + + + 새 여행 시작 + + + + ); + } + + return ( + + + + + + 여행을 시작해보세요 + + + 현재 위치에 맞는 음악을 추천받고, 여행 중 남긴 순간을 Recap으로 기록할 수 있어요. + + + 새 여행 시작 + + + ); +} diff --git a/src/components/travel/travelData.ts b/src/components/travel/travelData.ts new file mode 100644 index 0000000..743d462 --- /dev/null +++ b/src/components/travel/travelData.ts @@ -0,0 +1,177 @@ +import type { MomentLog, MoodTag, TravelMode } from '@/types/domain'; + +export type TravelModeOption = { + icon: string; + label: string; + value: TravelMode; +}; + +export type TravelRecap = { + date: string; + durationText: string; + id: string; + locations: string[]; + mode: TravelMode; + momentCount: number; + periodText: string; + playCount: number; + playTimeText: string; + representativeTrack: string; + topTracks: Array<{ + artist: string; + playCount: number; + title: string; + }>; + trackCount: number; + uniqueTracks: string[]; +}; + +export const travelModeOptions: TravelModeOption[] = [ + { icon: '🚶', label: '산책', value: 'walk' }, + { icon: '🚗', label: '드라이브', value: 'drive' }, + { icon: '☕', label: '카페 투어', value: 'cafe' }, + { icon: '🌊', label: '바다 보기', value: 'ocean' }, + { icon: '🎪', label: '축제', value: 'festival' }, + { icon: '🌙', label: '야경 감상', value: 'night' }, +]; + +export const modeLabelByValue = travelModeOptions.reduce( + (acc, mode) => ({ + ...acc, + [mode.value]: mode.label, + }), + {} as Record, +); + +export const modeIconByValue = travelModeOptions.reduce( + (acc, mode) => ({ + ...acc, + [mode.value]: mode.icon, + }), + {} as Record, +); + +export const moodLabelByValue: Record = { + active: '활기찬', + calm: '잔잔한', + emotional: '감성적인', + fresh: '청량한', + local: '로컬', +}; + +export const sampleMoments: MomentLog[] = [ + { + createdAt: '2026-06-06T17:21:00.000+09:00', + id: 'sample-gwangalli', + moodTags: ['fresh'], + photoUri: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + placeName: '광안리 해변', + source: 'camera', + syncStatus: 'synced', + track: { + artist: 'NewJeans', + fallbackColor: '#7DD3FC', + id: 'ditto', + title: 'Ditto', + }, + travelMode: 'ocean', + }, + { + createdAt: '2026-06-06T15:42:00.000+09:00', + id: 'sample-seongsu', + moodTags: ['calm', 'local'], + photoUri: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + placeName: '성수 카페거리', + source: 'camera', + syncStatus: 'synced', + track: { + artist: 'IU', + fallbackColor: '#FBBF24', + id: 'love-wins-all', + title: 'Love wins all', + }, + travelMode: 'cafe', + }, + { + createdAt: '2026-06-06T13:18:00.000+09:00', + id: 'sample-night', + moodTags: ['emotional'], + photoUri: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', + placeName: '남산 산책로', + source: 'camera', + syncStatus: 'synced', + track: { + artist: '10CM', + fallbackColor: '#C084FC', + id: 'spring-snow', + title: '봄눈', + }, + travelMode: 'walk', + }, +]; + +export const sampleRecaps: TravelRecap[] = [ + { + date: '2026.06.06', + durationText: '2시간 14분의 여행', + id: 'seoul-night', + locations: ['성수 카페거리', '서울숲 입구', '뚝섬 전망대'], + mode: 'cafe', + momentCount: 12, + periodText: '2026.06.06 13:02 - 15:16', + playCount: 48, + playTimeText: '총 음악 재생 1시간 52분', + representativeTrack: 'IU - Love wins all', + topTracks: [ + { artist: 'IU', playCount: 9, title: 'Love wins all' }, + { artist: 'NewJeans', playCount: 7, title: 'Ditto' }, + { artist: 'NewJeans', playCount: 6, title: 'ETA' }, + { artist: 'IU', playCount: 5, title: 'Blueming' }, + { artist: 'NewJeans', playCount: 4, title: 'Attention' }, + ], + trackCount: 31, + uniqueTracks: ['Love wins all', 'Ditto', 'ETA', 'Blueming', 'Attention', '밤편지'], + }, + { + date: '2026.05.25', + durationText: '1시간 48분의 여행', + id: 'log-3', + locations: ['광안리 해변', '민락수변공원', '광안대교 전망'], + mode: 'ocean', + momentCount: 8, + periodText: '2026.05.25 16:41 - 18:29', + playCount: 36, + playTimeText: '총 음악 재생 1시간 20분', + representativeTrack: 'NewJeans - Ditto', + topTracks: [ + { artist: 'NewJeans', playCount: 8, title: 'Ditto' }, + { artist: 'NewJeans', playCount: 6, title: 'Attention' }, + { artist: 'NewJeans', playCount: 5, title: 'ETA' }, + { artist: 'AKMU', playCount: 4, title: 'DINOSAUR' }, + { artist: '10CM', playCount: 3, title: '봄눈' }, + ], + trackCount: 24, + uniqueTracks: ['Ditto', 'Attention', 'ETA', 'DINOSAUR', '봄눈', 'Seoul City'], + }, + { + date: '2026.05.11', + durationText: '3시간 02분의 여행', + id: 'log-2', + locations: ['남산 산책로', '해방촌', '서울 야경 전망대'], + mode: 'night', + momentCount: 15, + periodText: '2026.05.11 19:08 - 22:10', + playCount: 64, + playTimeText: '총 음악 재생 2시간 36분', + representativeTrack: 'JENNIE - Seoul City', + topTracks: [ + { artist: 'JENNIE', playCount: 11, title: 'Seoul City' }, + { artist: '10CM', playCount: 8, title: '봄눈' }, + { artist: 'IU', playCount: 7, title: '밤편지' }, + { artist: 'Crush', playCount: 6, title: 'Beautiful' }, + { artist: 'NewJeans', playCount: 5, title: 'Cool With You' }, + ], + trackCount: 42, + uniqueTracks: ['Seoul City', '봄눈', '밤편지', 'Beautiful', 'Cool With You', '그라데이션'], + }, +]; diff --git a/src/components/travel/travelFormat.ts b/src/components/travel/travelFormat.ts new file mode 100644 index 0000000..c133472 --- /dev/null +++ b/src/components/travel/travelFormat.ts @@ -0,0 +1,83 @@ +export function formatKoreanDateTime(value?: string) { + if (!value) { + return '아직 없음'; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return '확인 불가'; + } + + return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String( + date.getDate(), + ).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String( + date.getMinutes(), + ).padStart(2, '0')}`; +} + +export function formatShortEndedAt(value?: string) { + if (!value) { + return '종료 시간 확인 중'; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return '종료 시간 확인 중'; + } + + const hour = date.getHours(); + const minute = String(date.getMinutes()).padStart(2, '0'); + const period = hour >= 12 ? 'PM' : 'AM'; + const displayHour = hour % 12 || 12; + + return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String( + date.getDate(), + ).padStart(2, '0')} | ${displayHour}:${minute} ${period} 종료`; +} + +export function formatElapsedTime(startedAt?: string, endedAt?: string) { + if (!startedAt) { + return '00:00:00'; + } + + const start = new Date(startedAt).getTime(); + const end = endedAt ? new Date(endedAt).getTime() : Date.now(); + + if (Number.isNaN(start) || Number.isNaN(end) || end < start) { + return '00:00:00'; + } + + const seconds = Math.floor((end - start) / 1000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String( + remainingSeconds, + ).padStart(2, '0')}`; +} + +export function formatDurationText(startedAt?: string, endedAt?: string) { + if (!startedAt || !endedAt) { + return '2시간 00분'; + } + + const start = new Date(startedAt).getTime(); + const end = new Date(endedAt).getTime(); + + if (Number.isNaN(start) || Number.isNaN(end) || end < start) { + return '2시간 00분'; + } + + const minutes = Math.max(1, Math.round((end - start) / 60000)); + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + + if (hours <= 0) { + return `${remainingMinutes}분`; + } + + return `${hours}시간 ${String(remainingMinutes).padStart(2, '0')}분`; +} diff --git a/src/mock-server/playlistHandlers.ts b/src/mock-server/playlistHandlers.ts index 232d40f..e75e122 100644 --- a/src/mock-server/playlistHandlers.ts +++ b/src/mock-server/playlistHandlers.ts @@ -5,6 +5,6 @@ export const playlistMockHandlers = { getPlaylist: (id?: string) => mockServerDelay( 'playlist.detail', - id ? playlistCurationById[id] : playlistDetail, + id ? playlistCurationById[id] ?? playlistDetail : playlistDetail, ), }; diff --git a/src/mocks/homeMocks.ts b/src/mocks/homeMocks.ts index 59427ed..9d84693 100644 --- a/src/mocks/homeMocks.ts +++ b/src/mocks/homeMocks.ts @@ -31,6 +31,7 @@ export const moodRecommendations: MoodRecommendation[] = [ color: '#2B176C', genres: ['인디', '발라드'], moods: ['잔잔한', '감성적인'], + playlistId: 'calm-walk', track: { artist: 'JENNIE', fallbackColor: '#192554', id: 'seoul-city', title: 'Seoul City' }, travelStyles: ['산책', '야경 감상'], }, @@ -40,6 +41,7 @@ export const moodRecommendations: MoodRecommendation[] = [ color: '#B1913A', genres: ['팝', 'K-POP'], moods: ['신나는', '청량한', '활기찬'], + playlistId: 'drive', track: { artist: '김건모', fallbackColor: '#48A5B4', id: 'moon-seoul', title: '서울의 달' }, travelStyles: ['드라이브', '바다 보기'], }, @@ -49,6 +51,7 @@ export const moodRecommendations: MoodRecommendation[] = [ color: '#1F2937', genres: ['R&B', 'OST'], moods: ['감성적인', '잔잔한'], + playlistId: 'city-night', track: { artist: '폴킴', fallbackColor: '#D70D31', id: 'hangang', title: '한강에서' }, travelStyles: ['야경 감상', '카페 투어'], }, @@ -58,6 +61,7 @@ export const moodRecommendations: MoodRecommendation[] = [ color: '#3F2C6B', genres: ['인디', 'R&B'], moods: ['잔잔한', '청량한'], + playlistId: 'cafe-indie', track: { artist: '10cm', fallbackColor: '#814D2B', id: 'cafe-night', title: '카페의 밤' }, travelStyles: ['카페 투어', '산책'], }, @@ -67,6 +71,7 @@ export const moodRecommendations: MoodRecommendation[] = [ color: '#9A3E62', genres: ['K-POP', '힙합'], moods: ['신나는', '활기찬'], + playlistId: 'festival-kpop', track: { artist: 'NewJeans', fallbackColor: '#29376B', id: 'festival-air', title: 'Festival Air' }, travelStyles: ['축제'], }, @@ -100,4 +105,31 @@ export const recentMusicLogs: MusicLogItem[] = [ recapShareId: 'log-3', trackTitle: '서울의 밤', }, + { + artistName: 'wave to earth', + createdAt: '2026-05-25T00:30:00.000Z', + id: 'log-4', + imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/33/3010733_image2_1.jpg', + placeName: '제주 애월', + recapShareId: 'log-1', + trackTitle: 'seasons', + }, + { + artistName: 'AKMU', + createdAt: '2026-05-25T00:40:00.000Z', + id: 'log-5', + imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/08/2674208_image2_1.jpg', + placeName: '경주', + recapShareId: 'log-2', + trackTitle: 'Dinosaur', + }, + { + artistName: '잔나비', + createdAt: '2026-05-25T00:50:00.000Z', + id: 'log-6', + imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/85/2613985_image2_1.jpg', + placeName: '남산', + recapShareId: 'log-3', + trackTitle: '뜨거운 여름밤은 가고', + }, ]; diff --git a/src/mocks/playlistMocks.ts b/src/mocks/playlistMocks.ts index f60038a..929238f 100644 --- a/src/mocks/playlistMocks.ts +++ b/src/mocks/playlistMocks.ts @@ -128,6 +128,119 @@ const geojeTracks: Track[] = [ }, ]; +const calmWalkTracks: Track[] = [ + { + artist: 'JENNIE', + fallbackColor: '#2B176C', + id: 'calm-walk-seoul-city', + platformUrls: { + spotify: 'https://open.spotify.com/search/JENNIE%20Seoul%20City', + youtubeMusic: 'https://music.youtube.com/search?q=JENNIE%20Seoul%20City', + }, + title: 'Seoul City', + }, + { + artist: '아이유', + fallbackColor: '#6E4FD3', + id: 'calm-walk-night-letter', + platformUrls: { + spotify: + 'https://open.spotify.com/search/%EC%95%84%EC%9D%B4%EC%9C%A0%20%EB%B0%A4%ED%8E%B8%EC%A7%80', + youtubeMusic: + 'https://music.youtube.com/search?q=%EC%95%84%EC%9D%B4%EC%9C%A0%20%EB%B0%A4%ED%8E%B8%EC%A7%80', + }, + title: '밤편지', + }, + { + artist: 'wave to earth', + fallbackColor: '#2D6A72', + id: 'calm-walk-seasons', + platformUrls: { + spotify: 'https://open.spotify.com/search/wave%20to%20earth%20seasons', + youtubeMusic: 'https://music.youtube.com/search?q=wave%20to%20earth%20seasons', + }, + title: 'seasons', + }, + { + artist: '카더가든', + fallbackColor: '#334D3F', + id: 'calm-walk-tree', + platformUrls: { + spotify: + 'https://open.spotify.com/search/%EC%B9%B4%EB%8D%94%EA%B0%80%EB%93%A0%20%EB%82%98%EB%AC%B4', + youtubeMusic: + 'https://music.youtube.com/search?q=%EC%B9%B4%EB%8D%94%EA%B0%80%EB%93%A0%20%EB%82%98%EB%AC%B4', + }, + title: '나무', + }, + { + artist: '혁오', + fallbackColor: '#45536B', + id: 'calm-walk-wi-ing', + platformUrls: { + spotify: 'https://open.spotify.com/search/hyukoh%20wi%20ing%20wi%20ing', + youtubeMusic: 'https://music.youtube.com/search?q=hyukoh%20wi%20ing%20wi%20ing', + }, + title: '위잉위잉', + }, + { + artist: '오존', + fallbackColor: '#6C7F99', + id: 'calm-walk-down', + platformUrls: { + spotify: 'https://open.spotify.com/search/O3ohn%20Down', + youtubeMusic: 'https://music.youtube.com/search?q=O3ohn%20Down', + }, + title: 'Down', + }, +]; + +function createMoodPlaylistTracks(playlistId: string, sourceTracks: Track[], colors: string[]) { + return sourceTracks.map((track, index) => ({ + ...track, + fallbackColor: colors[index % colors.length] ?? track.fallbackColor, + id: `${playlistId}-${track.id}`, + isLiked: undefined, + isSaved: undefined, + })); +} + +const driveTracks = createMoodPlaylistTracks('drive', [ + geojeTracks[1], + tracks[1], + geojeTracks[0], + geojeTracks[2], + tracks[5], + geojeTracks[7], +], ['#B1913A', '#D29B42', '#1D7F8C', '#DA6C51']); + +const cityNightTracks = createMoodPlaylistTracks('city-night', [ + tracks[2], + tracks[0], + tracks[3], + geojeTracks[3], + geojeTracks[7], + tracks[5], +], ['#1F2937', '#45536B', '#526391', '#D70D31']); + +const cafeIndieTracks = createMoodPlaylistTracks('cafe-indie', [ + tracks[5], + geojeTracks[6], + geojeTracks[3], + geojeTracks[5], + tracks[3], + geojeTracks[4], +], ['#3F2C6B', '#814D2B', '#334D3F', '#6E4FD3']); + +const festivalKpopTracks = createMoodPlaylistTracks('festival-kpop', [ + tracks[0], + geojeTracks[1], + geojeTracks[0], + tracks[4], + tracks[1], + geojeTracks[2], +], ['#9A3E62', '#D70D31', '#E66A73', '#29376B']); + export const playlistDetail: PlaylistCuration = { backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', @@ -141,6 +254,61 @@ export const playlistDetail: PlaylistCuration = { }; export const playlistCurationById: Record = { + 'calm-walk': { + accentColor: '#2B176C', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/85/2613985_image2_1.jpg', + durationText: '28:00분', + id: 'calm-walk', + placeName: '느린 걸음의 산책길', + reason: '잔잔한 산책 무드에 맞춰 천천히 이어지는 음악을 추천했어요', + regionName: '잔잔한 산책', + trackCount: calmWalkTracks.length, + tracks: calmWalkTracks, + }, + drive: { + accentColor: '#B1913A', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + durationText: '34:00분', + id: 'drive', + placeName: '해안도로 드라이브', + reason: '드라이브 팝 채널의 경쾌한 색감에 맞춰 이동감 있는 음악을 추천했어요', + regionName: '드라이브 팝', + trackCount: driveTracks.length, + tracks: driveTracks, + }, + 'city-night': { + accentColor: '#1F2937', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', + durationText: '31:00분', + id: 'city-night', + placeName: '도시 야경 산책', + reason: '도시의 야경 채널에 맞춰 밤공기와 어울리는 감성적인 음악을 추천했어요', + regionName: '도시의 야경', + trackCount: cityNightTracks.length, + tracks: cityNightTracks, + }, + 'cafe-indie': { + accentColor: '#3F2C6B', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + durationText: '29:00분', + id: 'cafe-indie', + placeName: '창가 자리와 오후 산책', + reason: '카페 인디 채널의 차분한 색감에 맞춰 오래 머물기 좋은 음악을 추천했어요', + regionName: '카페 인디', + trackCount: cafeIndieTracks.length, + tracks: cafeIndieTracks, + }, + 'festival-kpop': { + accentColor: '#9A3E62', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/33/3010733_image2_1.jpg', + durationText: '33:00분', + id: 'festival-kpop', + placeName: '페스티벌 광장', + reason: '페스티벌 K-POP 채널의 에너지에 맞춰 밝고 리듬감 있는 음악을 추천했어요', + regionName: '페스티벌 K-POP', + trackCount: festivalKpopTracks.length, + tracks: festivalKpopTracks, + }, 'geoje-ocean': { backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', diff --git a/src/store/recommendationEventStore.ts b/src/store/recommendationEventStore.ts index 54bd2b3..a830080 100644 --- a/src/store/recommendationEventStore.ts +++ b/src/store/recommendationEventStore.ts @@ -12,6 +12,7 @@ export type RecommendationEventType = | 'track_unlike' | 'track_save' | 'track_unsave' + | 'playlist_open' | 'mood_filter_change' | 'recommendation_mode_change' | 'top_filter_change'; diff --git a/src/types/domain.ts b/src/types/domain.ts index 380fca6..722e846 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -54,6 +54,7 @@ export type MoodRecommendation = { color: string; genres?: string[]; moods?: string[]; + playlistId?: string; track: Track; travelStyles?: string[]; }; @@ -63,6 +64,7 @@ export type PlaylistCuration = { regionName: string; placeName?: string; reason: string; + accentColor?: string; coverImageUrl?: string; backgroundImageUrl?: string; trackCount: number;