홈 화면 모바일 웹과 동일하게 수정 (히어로, 멤버, 앨범, 일정 섹션)

This commit is contained in:
caadiq 2026-01-12 10:20:34 +09:00
parent 9d04c0de91
commit 9661742a52
25 changed files with 13282 additions and 0 deletions

41
app/.gitignore vendored Normal file
View 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
View 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
View 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"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
app/assets/splash-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

8
app/index.ts Normal file
View 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

File diff suppressed because it is too large Load diff

43
app/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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';

View 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',
};

View 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',
},
});

View 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>
);
}

View 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>
);
}

View 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,
},
});

View 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',
},
});

View 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>
);
}

View 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
View 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
View file

@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}