홈 화면 모바일 웹과 동일하게 수정 (히어로, 멤버, 앨범, 일정 섹션)
This commit is contained in:
parent
9d04c0de91
commit
9661742a52
25 changed files with 13282 additions and 0 deletions
41
app/.gitignore
vendored
Normal file
41
app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
17
app/App.tsx
Normal file
17
app/App.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
|
||||
import AppNavigator from './src/navigation/AppNavigator';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<StatusBar style="dark" />
|
||||
<AppNavigator />
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
38
app/app.json
Normal file
38
app/app.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "fromis_9",
|
||||
"slug": "fromis9",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#FF4D8D"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "kr.co.caadiq.fromis9"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#FF4D8D"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "kr.co.caadiq.fromis9"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"extra": {
|
||||
"apiUrl": "https://fromis9.caadiq.co.kr/api"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-font"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
app/assets/adaptive-icon.png
Normal file
BIN
app/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
app/assets/favicon.png
Normal file
BIN
app/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/assets/icon.png
Normal file
BIN
app/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
app/assets/splash-icon.png
Normal file
BIN
app/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
8
app/index.ts
Normal file
8
app/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
11097
app/package-lock.json
generated
Normal file
11097
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
app/package.json
Normal file
43
app/package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"name": "app",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo/ngrok": "^4.1.3",
|
||||
"@react-navigation/bottom-tabs": "^7.9.0",
|
||||
"@react-navigation/native": "^7.1.26",
|
||||
"@react-navigation/native-stack": "^7.9.0",
|
||||
"axios": "^1.13.2",
|
||||
"expo": "~54.0.31",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"lucide-react-native": "^0.562.0",
|
||||
"nativewind": "^4.2.1",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-pager-view": "6.9.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
61
app/src/api/albums.ts
Normal file
61
app/src/api/albums.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { apiClient } from './client';
|
||||
|
||||
// 앨범 타입 정의
|
||||
export interface Album {
|
||||
id: number;
|
||||
title: string;
|
||||
album_type: string;
|
||||
album_type_short: string;
|
||||
release_date: string;
|
||||
cover_original_url: string;
|
||||
cover_medium_url: string;
|
||||
cover_thumb_url: string;
|
||||
folder_name: string;
|
||||
description?: string;
|
||||
tracks?: Track[];
|
||||
photos?: AlbumPhoto[];
|
||||
teasers?: AlbumTeaser[];
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
id: number;
|
||||
title: string;
|
||||
track_number: number;
|
||||
duration?: string;
|
||||
is_title_track: boolean;
|
||||
}
|
||||
|
||||
export interface AlbumPhoto {
|
||||
id: number;
|
||||
original_url: string;
|
||||
medium_url: string;
|
||||
thumb_url: string;
|
||||
concept_name?: string;
|
||||
members?: { id: number; name: string }[];
|
||||
}
|
||||
|
||||
export interface AlbumTeaser {
|
||||
id: number;
|
||||
title?: string;
|
||||
media_url: string;
|
||||
media_type: 'image' | 'video';
|
||||
thumb_url?: string;
|
||||
}
|
||||
|
||||
// 앨범 목록 조회
|
||||
export const getAlbums = async (): Promise<Album[]> => {
|
||||
const { data } = await apiClient.get('/albums');
|
||||
return data;
|
||||
};
|
||||
|
||||
// 앨범 상세 조회 (이름으로)
|
||||
export const getAlbumByName = async (name: string): Promise<Album> => {
|
||||
const { data } = await apiClient.get(`/albums/by-name/${encodeURIComponent(name)}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
// 앨범 상세 조회 (ID로)
|
||||
export const getAlbumById = async (id: number): Promise<Album> => {
|
||||
const { data } = await apiClient.get(`/albums/${id}`);
|
||||
return data;
|
||||
};
|
||||
31
app/src/api/client.ts
Normal file
31
app/src/api/client.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import axios from 'axios';
|
||||
import { API_BASE_URL } from '../constants/api';
|
||||
|
||||
// axios 인스턴스 생성
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 요청 인터셉터 (디버깅용)
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 응답 인터셉터 (에러 처리)
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
console.error('[API Error]', error.message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
18
app/src/api/members.ts
Normal file
18
app/src/api/members.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { apiClient } from './client';
|
||||
|
||||
// 멤버 타입 정의
|
||||
export interface Member {
|
||||
id: number;
|
||||
name: string;
|
||||
birth_date: string;
|
||||
position?: string;
|
||||
image_url?: string;
|
||||
instagram?: string;
|
||||
is_former: boolean;
|
||||
}
|
||||
|
||||
// 멤버 목록 조회
|
||||
export const getMembers = async (): Promise<Member[]> => {
|
||||
const { data } = await apiClient.get('/members');
|
||||
return data;
|
||||
};
|
||||
77
app/src/api/schedules.ts
Normal file
77
app/src/api/schedules.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { apiClient } from './client';
|
||||
|
||||
// 일정 타입 정의
|
||||
export interface Schedule {
|
||||
id: number;
|
||||
title: string;
|
||||
date: string;
|
||||
time?: string;
|
||||
end_date?: string;
|
||||
end_time?: string;
|
||||
description?: string;
|
||||
location_name?: string;
|
||||
location_address?: string;
|
||||
source_url?: string;
|
||||
source_name?: string;
|
||||
category_id: number;
|
||||
category_name?: string;
|
||||
category_color?: string;
|
||||
member_names?: string;
|
||||
members?: { id: number; name: string }[];
|
||||
images?: { id: number; image_url: string }[];
|
||||
}
|
||||
|
||||
export interface ScheduleCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ScheduleSearchParams {
|
||||
q?: string;
|
||||
category?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
startDate?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// 일정 목록 조회
|
||||
export const getSchedules = async (params?: ScheduleSearchParams): Promise<Schedule[]> => {
|
||||
const { data } = await apiClient.get('/schedules', { params });
|
||||
return data;
|
||||
};
|
||||
|
||||
// 다가오는 일정 조회 (오늘 이후)
|
||||
export const getUpcomingSchedules = async (limit: number = 3): Promise<Schedule[]> => {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(today.getDate()).padStart(2, '0');
|
||||
const startDate = `${year}-${month}-${day}`;
|
||||
|
||||
const { data } = await apiClient.get('/schedules', {
|
||||
params: { startDate, limit }
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
// 일정 카테고리 조회
|
||||
export const getCategories = async (): Promise<ScheduleCategory[]> => {
|
||||
const { data } = await apiClient.get('/schedule-categories');
|
||||
return data;
|
||||
};
|
||||
|
||||
// 추천 검색어 조회
|
||||
export const getSuggestions = async (query: string): Promise<string[]> => {
|
||||
const { data } = await apiClient.get('/schedules/suggestions', {
|
||||
params: { q: query }
|
||||
});
|
||||
return data.suggestions || [];
|
||||
};
|
||||
|
||||
// 개별 일정 조회
|
||||
export const getScheduleById = async (id: number): Promise<Schedule> => {
|
||||
const { data } = await apiClient.get(`/schedules/${id}`);
|
||||
return data;
|
||||
};
|
||||
5
app/src/constants/api.ts
Normal file
5
app/src/constants/api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// API 기본 URL
|
||||
export const API_BASE_URL = 'https://fromis9.caadiq.co.kr/api';
|
||||
|
||||
// 이미지 URL
|
||||
export const IMAGE_BASE_URL = 'https://s3.caadiq.co.kr/fromis-9';
|
||||
30
app/src/constants/colors.ts
Normal file
30
app/src/constants/colors.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// fromis_9 테마 색상 (모바일 웹과 동일 - 팬덤 컬러)
|
||||
export const colors = {
|
||||
// 메인 색상 (초록 - 프로미스나인 팬덤 컬러)
|
||||
primary: '#548360',
|
||||
primaryDark: '#456E50',
|
||||
primaryLight: '#6A9A75',
|
||||
|
||||
// 배경
|
||||
background: '#FFFFFF',
|
||||
backgroundSecondary: '#F9FAFB',
|
||||
backgroundGray: '#F3F4F6',
|
||||
secondary: '#F5F5F5',
|
||||
|
||||
// 텍스트
|
||||
textPrimary: '#111827',
|
||||
textSecondary: '#6B7280',
|
||||
textTertiary: '#9CA3AF',
|
||||
|
||||
// 보더
|
||||
border: '#E5E7EB',
|
||||
borderLight: '#F3F4F6',
|
||||
|
||||
// 상태
|
||||
success: '#10B981',
|
||||
warning: '#F59E0B',
|
||||
error: '#EF4444',
|
||||
|
||||
// 보조
|
||||
accent: '#FFD700',
|
||||
};
|
||||
153
app/src/navigation/AppNavigator.tsx
Normal file
153
app/src/navigation/AppNavigator.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import React from 'react';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
// lucide 아이콘 (모바일 웹과 동일)
|
||||
import { Home, Users, Disc3, Calendar } from 'lucide-react-native';
|
||||
|
||||
// 스크린 import
|
||||
import HomeScreen from '../screens/HomeScreen';
|
||||
import MembersScreen from '../screens/MembersScreen';
|
||||
import AlbumScreen from '../screens/AlbumScreen';
|
||||
import AlbumDetailScreen from '../screens/AlbumDetailScreen';
|
||||
import AlbumGalleryScreen from '../screens/AlbumGalleryScreen';
|
||||
import ScheduleScreen from '../screens/ScheduleScreen';
|
||||
|
||||
import { colors } from '../constants/colors';
|
||||
|
||||
// 타입 정의
|
||||
export type RootTabParamList = {
|
||||
HomeTab: undefined;
|
||||
MembersTab: undefined;
|
||||
AlbumTab: undefined;
|
||||
ScheduleTab: undefined;
|
||||
};
|
||||
|
||||
export type AlbumStackParamList = {
|
||||
AlbumList: undefined;
|
||||
AlbumDetail: { name: string };
|
||||
AlbumGallery: { name: string };
|
||||
};
|
||||
|
||||
const Tab = createBottomTabNavigator<RootTabParamList>();
|
||||
const AlbumStack = createNativeStackNavigator<AlbumStackParamList>();
|
||||
|
||||
// 앨범 스택 네비게이터
|
||||
function AlbumStackNavigator() {
|
||||
return (
|
||||
<AlbumStack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<AlbumStack.Screen name="AlbumList" component={AlbumScreen} />
|
||||
<AlbumStack.Screen name="AlbumDetail" component={AlbumDetailScreen} />
|
||||
<AlbumStack.Screen name="AlbumGallery" component={AlbumGalleryScreen} />
|
||||
</AlbumStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
// 커스텀 탭바 (모바일 웹과 동일한 스타일)
|
||||
function CustomTabBar({ state, descriptors, navigation }: any) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const tabItems = [
|
||||
{ name: 'HomeTab', label: '홈', Icon: Home },
|
||||
{ name: 'MembersTab', label: '멤버', Icon: Users },
|
||||
{ name: 'AlbumTab', label: '앨범', Icon: Disc3 },
|
||||
{ name: 'ScheduleTab', label: '일정', Icon: Calendar },
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.tabBar,
|
||||
{ paddingBottom: Math.max(insets.bottom, 8) }
|
||||
]}>
|
||||
<View style={styles.tabBarContent}>
|
||||
{state.routes.map((route: any, index: number) => {
|
||||
const isFocused = state.index === index;
|
||||
const item = tabItems[index];
|
||||
const IconComponent = item.Icon;
|
||||
|
||||
const onPress = () => {
|
||||
const event = navigation.emit({
|
||||
type: 'tabPress',
|
||||
target: route.key,
|
||||
canPreventDefault: true,
|
||||
});
|
||||
if (!isFocused && !event.defaultPrevented) {
|
||||
navigation.navigate(route.name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={route.key}
|
||||
onPress={onPress}
|
||||
style={styles.tabItem}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<IconComponent
|
||||
size={22}
|
||||
strokeWidth={isFocused ? 2.5 : 2}
|
||||
color={isFocused ? colors.primary : '#9CA3AF'}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.tabLabel,
|
||||
{ color: isFocused ? colors.primary : '#9CA3AF' }
|
||||
]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 메인 탭 네비게이터
|
||||
export default function AppNavigator() {
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<Tab.Navigator
|
||||
tabBar={(props) => <CustomTabBar {...props} />}
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen name="HomeTab" component={HomeScreen} />
|
||||
<Tab.Screen name="MembersTab" component={MembersScreen} />
|
||||
<Tab.Screen name="AlbumTab" component={AlbumStackNavigator} />
|
||||
<Tab.Screen name="ScheduleTab" component={ScheduleScreen} />
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabBar: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E5E7EB',
|
||||
},
|
||||
tabBarContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
height: 56,
|
||||
},
|
||||
tabItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
217
app/src/screens/AlbumDetailScreen.tsx
Normal file
217
app/src/screens/AlbumDetailScreen.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
import { getAlbumByName, Album } from '../api/albums';
|
||||
import { colors } from '../constants/colors';
|
||||
import type { AlbumStackParamList } from '../navigation/AppNavigator';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<AlbumStackParamList>;
|
||||
type RouteType = RouteProp<AlbumStackParamList, 'AlbumDetail'>;
|
||||
|
||||
export default function AlbumDetailScreen() {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<RouteType>();
|
||||
const { name } = route.params;
|
||||
|
||||
const [album, setAlbum] = useState<Album | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAlbum = async () => {
|
||||
try {
|
||||
const data = await getAlbumByName(name);
|
||||
setAlbum(data);
|
||||
} catch (error) {
|
||||
console.error('앨범 로드 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchAlbum();
|
||||
}, [name]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (!album) {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Text>앨범을 찾을 수 없습니다.</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
{/* 헤더 */}
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
}}>
|
||||
<TouchableOpacity onPress={() => navigation.goBack()}>
|
||||
<Ionicons name="arrow-back" size={24} color={colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={{
|
||||
flex: 1,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center',
|
||||
marginRight: 24,
|
||||
}}>
|
||||
앨범
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||
{/* 앨범 커버 */}
|
||||
<View style={{ alignItems: 'center', marginBottom: 20 }}>
|
||||
<Image
|
||||
source={{ uri: album.cover_medium_url }}
|
||||
style={{
|
||||
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>
|
||||
|
||||
{/* 수록곡 */}
|
||||
{album.tracks && album.tracks.length > 0 && (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text style={{
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
수록곡
|
||||
</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>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 컨셉 포토 */}
|
||||
{album.photos && album.photos.length > 0 && (
|
||||
<View>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
<Text style={{
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
}}>
|
||||
컨셉 포토
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('AlbumGallery', { name })}
|
||||
>
|
||||
<Text style={{ fontSize: 13, color: colors.primary }}>
|
||||
전체보기 ({album.photos.length})
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 3열 그리드 (최대 6개) */}
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
||||
{album.photos.slice(0, 6).map((photo) => (
|
||||
<Image
|
||||
key={photo.id}
|
||||
source={{ uri: photo.thumb_url || photo.medium_url }}
|
||||
style={{
|
||||
width: '31%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.border,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
194
app/src/screens/AlbumGalleryScreen.tsx
Normal file
194
app/src/screens/AlbumGalleryScreen.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import PagerView from 'react-native-pager-view';
|
||||
|
||||
import { getAlbumByName, Album, AlbumPhoto } from '../api/albums';
|
||||
import { colors } from '../constants/colors';
|
||||
import type { AlbumStackParamList } from '../navigation/AppNavigator';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<AlbumStackParamList>;
|
||||
type RouteType = RouteProp<AlbumStackParamList, 'AlbumGallery'>;
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
export default function AlbumGalleryScreen() {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<RouteType>();
|
||||
const { name } = route.params;
|
||||
|
||||
const [photos, setPhotos] = useState<AlbumPhoto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 라이트박스 상태
|
||||
const [lightboxVisible, setLightboxVisible] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAlbum = async () => {
|
||||
try {
|
||||
const data = await getAlbumByName(name);
|
||||
setPhotos(data.photos || []);
|
||||
} catch (error) {
|
||||
console.error('앨범 로드 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchAlbum();
|
||||
}, [name]);
|
||||
|
||||
const openLightbox = (index: number) => {
|
||||
setSelectedIndex(index);
|
||||
setLightboxVisible(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// 2열로 분배
|
||||
const leftColumn = photos.filter((_, i) => i % 2 === 0);
|
||||
const rightColumn = photos.filter((_, i) => i % 2 === 1);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
{/* 헤더 */}
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
}}>
|
||||
<TouchableOpacity onPress={() => navigation.goBack()}>
|
||||
<Ionicons name="arrow-back" size={24} color={colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={{
|
||||
flex: 1,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
컨셉 포토
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: colors.textSecondary }}>
|
||||
{photos.length}장
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 2열 그리드 */}
|
||||
<View style={{ flex: 1, flexDirection: 'row', padding: 8 }}>
|
||||
{/* 왼쪽 열 */}
|
||||
<View style={{ flex: 1, paddingRight: 4 }}>
|
||||
{leftColumn.map((photo, idx) => (
|
||||
<TouchableOpacity
|
||||
key={photo.id}
|
||||
onPress={() => openLightbox(idx * 2)}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: photo.medium_url || photo.thumb_url }}
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: 0.75,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.border,
|
||||
}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 오른쪽 열 */}
|
||||
<View style={{ flex: 1, paddingLeft: 4 }}>
|
||||
{rightColumn.map((photo, idx) => (
|
||||
<TouchableOpacity
|
||||
key={photo.id}
|
||||
onPress={() => openLightbox(idx * 2 + 1)}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: photo.medium_url || photo.thumb_url }}
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: 0.75,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.border,
|
||||
}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 라이트박스 모달 */}
|
||||
<Modal
|
||||
visible={lightboxVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setLightboxVisible(false)}
|
||||
>
|
||||
<View style={{ flex: 1, backgroundColor: 'black' }}>
|
||||
{/* 헤더 */}
|
||||
<SafeAreaView>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
}}>
|
||||
<TouchableOpacity onPress={() => setLightboxVisible(false)}>
|
||||
<Ionicons name="close" size={28} color="white" />
|
||||
</TouchableOpacity>
|
||||
<Text style={{ color: 'white', fontSize: 14 }}>
|
||||
{selectedIndex + 1} / {photos.length}
|
||||
</Text>
|
||||
<View style={{ width: 28 }} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
{/* PagerView */}
|
||||
<PagerView
|
||||
style={{ flex: 1 }}
|
||||
initialPage={selectedIndex}
|
||||
onPageSelected={(e) => setSelectedIndex(e.nativeEvent.position)}
|
||||
>
|
||||
{photos.map((photo, index) => (
|
||||
<View key={photo.id} style={{ flex: 1, justifyContent: 'center' }}>
|
||||
<Image
|
||||
source={{ uri: photo.original_url || photo.medium_url }}
|
||||
style={{
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_WIDTH * 1.33,
|
||||
}}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</PagerView>
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
177
app/src/screens/AlbumScreen.tsx
Normal file
177
app/src/screens/AlbumScreen.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
import { getAlbums, Album } from '../api/albums';
|
||||
import { colors } from '../constants/colors';
|
||||
import type { AlbumStackParamList } from '../navigation/AppNavigator';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<AlbumStackParamList>;
|
||||
|
||||
export default function AlbumScreen() {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchAlbums = async () => {
|
||||
try {
|
||||
const data = await getAlbums();
|
||||
setAlbums(data);
|
||||
} catch (error) {
|
||||
console.error('앨범 로드 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlbums();
|
||||
}, []);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchAlbums();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* 헤더 */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>앨범</Text>
|
||||
</View>
|
||||
|
||||
{/* 앨범 목록 */}
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{/* 2열 그리드 */}
|
||||
<View style={styles.grid}>
|
||||
{albums.map((album) => (
|
||||
<TouchableOpacity
|
||||
key={album.id}
|
||||
onPress={() => navigation.navigate('AlbumDetail', { name: album.folder_name })}
|
||||
style={styles.albumCard}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.albumImageContainer}>
|
||||
<Image
|
||||
source={{ uri: album.cover_thumb_url || album.cover_medium_url }}
|
||||
style={styles.albumImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.albumInfo}>
|
||||
<Text style={styles.albumTitle} numberOfLines={1}>
|
||||
{album.title}
|
||||
</Text>
|
||||
<Text style={styles.albumType}>
|
||||
{album.album_type_short} · {album.release_date?.slice(0, 4)}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F9FAFB',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F3F4F6',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: 16,
|
||||
},
|
||||
grid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
albumCard: {
|
||||
width: '48%',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 3,
|
||||
elevation: 2,
|
||||
},
|
||||
albumImageContainer: {
|
||||
aspectRatio: 1,
|
||||
backgroundColor: '#E5E7EB',
|
||||
},
|
||||
albumImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
albumInfo: {
|
||||
padding: 12,
|
||||
},
|
||||
albumTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
albumType: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
});
|
||||
598
app/src/screens/HomeScreen.tsx
Normal file
598
app/src/screens/HomeScreen.tsx
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ChevronRight, Clock, Tag } from 'lucide-react-native';
|
||||
|
||||
import { getAlbums, Album } from '../api/albums';
|
||||
import { getMembers, Member } from '../api/members';
|
||||
import { getUpcomingSchedules, Schedule } from '../api/schedules';
|
||||
import { colors } from '../constants/colors';
|
||||
import type { AlbumStackParamList } from '../navigation/AppNavigator';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<AlbumStackParamList>;
|
||||
|
||||
// 전체 배경색 (tailwind bg-gray-50)
|
||||
const BG_GRAY = '#F9FAFB';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// 애니메이션
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(20)).current;
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [membersData, albumsData, schedulesData] = await Promise.all([
|
||||
getMembers(),
|
||||
getAlbums(),
|
||||
getUpcomingSchedules(3),
|
||||
]);
|
||||
setMembers(membersData.filter(m => !m.is_former));
|
||||
setAlbums(albumsData.slice(0, 2));
|
||||
setSchedules(schedulesData || []);
|
||||
|
||||
// 애니메이션 시작
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fadeAnim.setValue(0);
|
||||
slideAnim.setValue(20);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatScheduleDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const currentYear = today.getFullYear();
|
||||
const currentMonth = today.getMonth();
|
||||
|
||||
const scheduleYear = date.getFullYear();
|
||||
const scheduleMonth = date.getMonth();
|
||||
const isCurrentYear = scheduleYear === currentYear;
|
||||
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
|
||||
|
||||
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
return {
|
||||
day: date.getDate(),
|
||||
weekday: weekdays[date.getDay()],
|
||||
year: scheduleYear,
|
||||
month: scheduleMonth + 1,
|
||||
isCurrentYear,
|
||||
isCurrentMonth,
|
||||
};
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>fromis_9</Text>
|
||||
</View>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* 상단 헤더 */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>fromis_9</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{/* 히어로 섹션 - bg-gradient-to-br from-primary to-primary-dark py-12 px-4 */}
|
||||
<Animated.View style={[styles.heroSection, { opacity: fadeAnim }]}>
|
||||
<View style={styles.heroOverlay} />
|
||||
<View style={styles.heroContent}>
|
||||
<Text style={styles.heroTitle}>fromis_9</Text>
|
||||
<Text style={styles.heroSubtitle}>프로미스나인</Text>
|
||||
<Text style={styles.heroText}>
|
||||
인사드리겠습니다. 둘, 셋!{'\n'}
|
||||
이제는 약속해 소중히 간직해,{'\n'}
|
||||
당신의 아이돌로 성장하겠습니다!
|
||||
</Text>
|
||||
</View>
|
||||
{/* 장식 원 */}
|
||||
<View style={[styles.decorCircle, styles.decorCircle1]} />
|
||||
<View style={[styles.decorCircle, styles.decorCircle2]} />
|
||||
</Animated.View>
|
||||
|
||||
{/* 멤버 섹션 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.section,
|
||||
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }
|
||||
]}
|
||||
>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>멤버</Text>
|
||||
<TouchableOpacity style={styles.moreButton}>
|
||||
<Text style={styles.moreButtonText}>전체보기</Text>
|
||||
<ChevronRight size={16} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.membersGrid}>
|
||||
{members.slice(0, 5).map((member) => (
|
||||
<View key={member.id} style={styles.memberItem}>
|
||||
<View style={styles.memberImageContainer}>
|
||||
{member.image_url && (
|
||||
<Image
|
||||
source={{ uri: member.image_url }}
|
||||
style={styles.memberImage}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.memberName} numberOfLines={1}>
|
||||
{member.name}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* 앨범 섹션 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.section,
|
||||
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }
|
||||
]}
|
||||
>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>앨범</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.moreButton}
|
||||
onPress={() => navigation.navigate('AlbumList')}
|
||||
>
|
||||
<Text style={styles.moreButtonText}>전체보기</Text>
|
||||
<ChevronRight size={16} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.albumsGrid}>
|
||||
{albums.map((album) => (
|
||||
<TouchableOpacity
|
||||
key={album.id}
|
||||
style={styles.albumCard}
|
||||
onPress={() => navigation.navigate('AlbumDetail', { name: album.folder_name })}
|
||||
activeOpacity={0.98}
|
||||
>
|
||||
<View style={styles.albumImageContainer}>
|
||||
<Image
|
||||
source={{ uri: album.cover_thumb_url || album.cover_medium_url }}
|
||||
style={styles.albumImage}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.albumInfo}>
|
||||
<Text style={styles.albumTitle} numberOfLines={1}>
|
||||
{album.title}
|
||||
</Text>
|
||||
<Text style={styles.albumYear}>
|
||||
{album.release_date?.slice(0, 4)}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* 일정 섹션 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.scheduleSection,
|
||||
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }
|
||||
]}
|
||||
>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>다가오는 일정</Text>
|
||||
<TouchableOpacity style={styles.moreButton}>
|
||||
<Text style={styles.moreButtonText}>전체보기</Text>
|
||||
<ChevronRight size={16} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{schedules.length > 0 ? (
|
||||
<View style={styles.schedulesList}>
|
||||
{schedules.map((schedule) => {
|
||||
const dateInfo = formatScheduleDate(schedule.date);
|
||||
const memberList = schedule.member_names
|
||||
? schedule.member_names.split(',').map(n => n.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={schedule.id}
|
||||
style={styles.scheduleCard}
|
||||
activeOpacity={0.98}
|
||||
>
|
||||
{/* 날짜 영역 */}
|
||||
<View style={styles.scheduleDateContainer}>
|
||||
{!dateInfo.isCurrentYear && (
|
||||
<Text style={styles.scheduleDateExtra}>
|
||||
{dateInfo.year}.{dateInfo.month}
|
||||
</Text>
|
||||
)}
|
||||
{dateInfo.isCurrentYear && !dateInfo.isCurrentMonth && (
|
||||
<Text style={styles.scheduleDateExtra}>
|
||||
{dateInfo.month}월
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.scheduleDay}>{dateInfo.day}</Text>
|
||||
<Text style={styles.scheduleWeekday}>{dateInfo.weekday}</Text>
|
||||
</View>
|
||||
|
||||
{/* 세로 구분선 */}
|
||||
<View style={styles.scheduleDivider} />
|
||||
|
||||
{/* 내용 영역 */}
|
||||
<View style={styles.scheduleContent}>
|
||||
<Text style={styles.scheduleTitle} numberOfLines={2}>
|
||||
{schedule.title}
|
||||
</Text>
|
||||
<View style={styles.scheduleMeta}>
|
||||
{schedule.time && (
|
||||
<View style={styles.scheduleMetaItem}>
|
||||
<Clock size={12} color="#9CA3AF" />
|
||||
<Text style={styles.scheduleMetaText}>
|
||||
{schedule.time.slice(0, 5)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{schedule.category_name && (
|
||||
<View style={styles.scheduleMetaItem}>
|
||||
<Tag size={12} color="#9CA3AF" />
|
||||
<Text style={styles.scheduleMetaText}>
|
||||
{schedule.category_name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{memberList.length > 0 && (
|
||||
<View style={styles.memberTags}>
|
||||
{(memberList.length >= 5 ? ['프로미스나인'] : memberList).map((name, i) => (
|
||||
<View key={i} style={styles.memberTag}>
|
||||
<Text style={styles.memberTagText}>{name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptySchedule}>
|
||||
<Text style={styles.emptyText}>다가오는 일정이 없습니다</Text>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: BG_GRAY,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: BG_GRAY,
|
||||
},
|
||||
|
||||
// 헤더
|
||||
header: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: colors.primary,
|
||||
},
|
||||
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 0,
|
||||
},
|
||||
|
||||
// 히어로 섹션
|
||||
heroSection: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: 48,
|
||||
paddingHorizontal: 16,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
heroOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
heroContent: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 30,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 4,
|
||||
},
|
||||
heroSubtitle: {
|
||||
fontSize: 18,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '300',
|
||||
marginBottom: 12,
|
||||
},
|
||||
heroText: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
},
|
||||
decorCircle: {
|
||||
position: 'absolute',
|
||||
borderRadius: 999,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
decorCircle1: {
|
||||
width: 128,
|
||||
height: 128,
|
||||
right: -32,
|
||||
top: -32,
|
||||
},
|
||||
decorCircle2: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
left: -24,
|
||||
bottom: -24,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
|
||||
// 섹션 공통
|
||||
section: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 24,
|
||||
},
|
||||
scheduleSection: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#111827',
|
||||
},
|
||||
moreButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
moreButtonText: {
|
||||
fontSize: 14,
|
||||
color: colors.primary,
|
||||
},
|
||||
|
||||
// 멤버 섹션
|
||||
membersGrid: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
memberItem: {
|
||||
alignItems: 'center',
|
||||
width: '18%',
|
||||
},
|
||||
memberImageContainer: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#E5E7EB',
|
||||
marginBottom: 4,
|
||||
},
|
||||
memberImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
memberName: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#111827',
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
// 앨범 섹션
|
||||
albumsGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
albumCard: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 3,
|
||||
elevation: 2,
|
||||
},
|
||||
albumImageContainer: {
|
||||
aspectRatio: 1,
|
||||
backgroundColor: '#E5E7EB',
|
||||
},
|
||||
albumImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
albumInfo: {
|
||||
padding: 12,
|
||||
},
|
||||
albumTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: '#111827',
|
||||
},
|
||||
albumYear: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// 일정 섹션
|
||||
schedulesList: {
|
||||
gap: 12,
|
||||
},
|
||||
scheduleCard: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#F3F4F6',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
scheduleDateContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 50,
|
||||
},
|
||||
scheduleDateExtra: {
|
||||
fontSize: 10,
|
||||
color: '#9CA3AF',
|
||||
fontWeight: '500',
|
||||
},
|
||||
scheduleDay: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: colors.primary,
|
||||
},
|
||||
scheduleWeekday: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
fontWeight: '500',
|
||||
},
|
||||
scheduleDivider: {
|
||||
width: 1,
|
||||
backgroundColor: '#F3F4F6',
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
scheduleContent: {
|
||||
flex: 1,
|
||||
},
|
||||
scheduleTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1F2937',
|
||||
lineHeight: 20,
|
||||
},
|
||||
scheduleMeta: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
scheduleMetaItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
scheduleMetaText: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
memberTags: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
marginTop: 8,
|
||||
},
|
||||
memberTag: {
|
||||
backgroundColor: colors.primary + '1A',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
},
|
||||
memberTagText: {
|
||||
fontSize: 10,
|
||||
color: colors.primary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
emptySchedule: {
|
||||
paddingVertical: 32,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
});
|
||||
173
app/src/screens/MembersScreen.tsx
Normal file
173
app/src/screens/MembersScreen.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
Linking,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
import { getMembers, Member } from '../api/members';
|
||||
import { colors } from '../constants/colors';
|
||||
|
||||
export default function MembersScreen() {
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchMembers = async () => {
|
||||
try {
|
||||
const data = await getMembers();
|
||||
// 전 멤버 제외
|
||||
setMembers(data.filter(m => !m.is_former));
|
||||
} catch (error) {
|
||||
console.error('멤버 로드 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembers();
|
||||
}, []);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchMembers();
|
||||
};
|
||||
|
||||
const openInstagram = (instagram: string) => {
|
||||
Linking.openURL(`https://instagram.com/${instagram}`);
|
||||
};
|
||||
|
||||
// 생년월일에서 나이 계산
|
||||
const calculateAge = (birthDate: string) => {
|
||||
const birth = new Date(birthDate);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const monthDiff = today.getMonth() - birth.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
{/* 헤더 */}
|
||||
<View style={{
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
}}>
|
||||
<Text style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
멤버
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 멤버 목록 */}
|
||||
<ScrollView
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
>
|
||||
{members.map((member) => (
|
||||
<View
|
||||
key={member.id}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{/* 프로필 이미지 */}
|
||||
<Image
|
||||
source={{ uri: member.image_url }}
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: colors.border,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 정보 */}
|
||||
<View style={{ flex: 1, marginLeft: 16, justifyContent: 'center' }}>
|
||||
<Text style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
}}>
|
||||
{member.name}
|
||||
</Text>
|
||||
|
||||
{member.position && (
|
||||
<Text style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 4,
|
||||
}}>
|
||||
{member.position}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text style={{
|
||||
fontSize: 12,
|
||||
color: colors.textTertiary,
|
||||
marginTop: 4,
|
||||
}}>
|
||||
{member.birth_date?.slice(0, 10)} ({calculateAge(member.birth_date)}세)
|
||||
</Text>
|
||||
|
||||
{/* 인스타그램 */}
|
||||
{member.instagram && (
|
||||
<TouchableOpacity
|
||||
onPress={() => openInstagram(member.instagram!)}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="logo-instagram" size={16} color={colors.primary} />
|
||||
<Text style={{
|
||||
fontSize: 12,
|
||||
color: colors.primary,
|
||||
marginLeft: 4,
|
||||
}}>
|
||||
@{member.instagram}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
283
app/src/screens/ScheduleScreen.tsx
Normal file
283
app/src/screens/ScheduleScreen.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
import { getSchedules, getCategories, Schedule, ScheduleCategory } from '../api/schedules';
|
||||
import { colors } from '../constants/colors';
|
||||
|
||||
export default function ScheduleScreen() {
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||
const [categories, setCategories] = useState<ScheduleCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<number | null>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const data = await getCategories();
|
||||
setCategories(data);
|
||||
} catch (error) {
|
||||
console.error('카테고리 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSchedules = async (isRefresh = false) => {
|
||||
if (loadingMore && !isRefresh) return;
|
||||
|
||||
try {
|
||||
const currentOffset = isRefresh ? 0 : offset;
|
||||
const { schedules: data, total } = await getSchedules({
|
||||
q: searchQuery || undefined,
|
||||
category: selectedCategory || undefined,
|
||||
limit: LIMIT,
|
||||
offset: currentOffset,
|
||||
});
|
||||
|
||||
if (isRefresh) {
|
||||
setSchedules(data);
|
||||
setOffset(LIMIT);
|
||||
} else {
|
||||
setSchedules(prev => [...prev, ...data]);
|
||||
setOffset(prev => prev + LIMIT);
|
||||
}
|
||||
|
||||
setHasMore(currentOffset + data.length < total);
|
||||
} catch (error) {
|
||||
console.error('일정 로드 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setOffset(0);
|
||||
setHasMore(true);
|
||||
fetchSchedules(true);
|
||||
}, [searchQuery, selectedCategory]);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchSchedules(true);
|
||||
};
|
||||
|
||||
const onEndReached = () => {
|
||||
if (!loadingMore && hasMore) {
|
||||
setLoadingMore(true);
|
||||
fetchSchedules();
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const weekday = weekdays[date.getDay()];
|
||||
return `${month}/${day} (${weekday})`;
|
||||
};
|
||||
|
||||
const renderScheduleItem = ({ item }: { item: Schedule }) => (
|
||||
<View style={{
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: item.category_color || colors.primary,
|
||||
}}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||
<Text style={{
|
||||
fontSize: 12,
|
||||
color: item.category_color || colors.textSecondary,
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
{item.category_name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: colors.textTertiary }}>
|
||||
{formatDate(item.date)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginTop: 6,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
|
||||
{item.time && (
|
||||
<Text style={{
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 4,
|
||||
}}>
|
||||
{item.time.slice(0, 5)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{item.members && item.members.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginTop: 8, gap: 4 }}>
|
||||
{item.members.map(m => (
|
||||
<View
|
||||
key={m.id}
|
||||
style={{
|
||||
backgroundColor: colors.primary + '20',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 11, color: colors.primary }}>
|
||||
{m.name}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
{/* 헤더 */}
|
||||
<View style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
}}>
|
||||
<Text style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
일정
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 검색 */}
|
||||
<View style={{ paddingHorizontal: 16, paddingVertical: 12 }}>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
}}>
|
||||
<Ionicons name="search" size={20} color={colors.textTertiary} />
|
||||
<TextInput
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 10,
|
||||
fontSize: 15,
|
||||
color: colors.textPrimary,
|
||||
}}
|
||||
placeholder="일정 검색..."
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<TouchableOpacity onPress={() => setSearchQuery('')}>
|
||||
<Ionicons name="close-circle" size={20} color={colors.textTertiary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<View style={{ paddingHorizontal: 16, marginBottom: 12 }}>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={[{ id: null, name: '전체', color: colors.textSecondary }, ...categories]}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setSelectedCategory(item.id)}
|
||||
style={{
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
marginRight: 8,
|
||||
backgroundColor: selectedCategory === item.id
|
||||
? colors.primary
|
||||
: colors.backgroundSecondary,
|
||||
}}
|
||||
>
|
||||
<Text style={{
|
||||
fontSize: 13,
|
||||
color: selectedCategory === item.id ? 'white' : colors.textSecondary,
|
||||
fontWeight: selectedCategory === item.id ? '600' : '400',
|
||||
}}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 일정 목록 */}
|
||||
{loading ? (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={schedules}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
renderItem={renderScheduleItem}
|
||||
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 20 }}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListEmptyComponent={
|
||||
<View style={{ padding: 40, alignItems: 'center' }}>
|
||||
<Text style={{ color: colors.textTertiary }}>
|
||||
일정이 없습니다.
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
loadingMore ? (
|
||||
<ActivityIndicator style={{ padding: 20 }} color={colors.primary} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
15
app/tailwind.config.js
Normal file
15
app/tailwind.config.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./App.{js,jsx,ts,tsx}", "./src/**/*.{js,jsx,ts,tsx}"],
|
||||
presets: [require("nativewind/preset")],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// fromis_9 테마 색상
|
||||
primary: "#FF4D8D",
|
||||
"primary-dark": "#E0447D",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
6
app/tsconfig.json
Normal file
6
app/tsconfig.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue