홈 화면 모바일 웹과 동일하게 수정 (히어로, 멤버, 앨범, 일정 섹션)
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