diff --git a/app/(auth)/_layout.tsx b/app/(auth)/_layout.tsx
index f14114c..1c29790 100644
--- a/app/(auth)/_layout.tsx
+++ b/app/(auth)/_layout.tsx
@@ -2,11 +2,13 @@ import { useAuth } from '@clerk/expo';
import { Redirect } from 'expo-router';
import { Stack } from 'expo-router/stack';
+import { AppLoadingScreen } from '@/components/ui/app-loading-screen';
+
export default function AuthLayout() {
const { isLoaded, isSignedIn } = useAuth();
if (!isLoaded) {
- return null;
+ return ;
}
if (isSignedIn) {
diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx
index 0e3a133..50cea0d 100644
--- a/app/(auth)/login.tsx
+++ b/app/(auth)/login.tsx
@@ -5,11 +5,11 @@ import { router, useLocalSearchParams } from 'expo-router';
import { useShareIntentContext } from 'expo-share-intent';
import * as WebBrowser from 'expo-web-browser';
import { useEffect, useRef, useState } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
+import { Platform, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ApiError } from '@/api/api-client';
-import { SocialLoginButton } from '@/components/ui/social-login-button';
+import { SocialLoginButton, type SocialLoginProvider } from '@/components/ui/social-login-button';
import { Colors, Typography } from '@/constants/theme';
import { syncAuthenticatedMember } from '@/services/auth-api';
import { showAlert } from '@/utils/guarded-alert';
@@ -21,6 +21,10 @@ const CLERK_REDIRECT_URL = AuthSession.makeRedirectUri({
path: 'sso-callback',
});
const MEMBER_SYNC_RETRY_DELAYS_MS = [200, 500, 1000];
+const SSO_STRATEGY_BY_PROVIDER = {
+ google: 'oauth_google',
+ apple: 'oauth_apple',
+} as const;
WebBrowser.maybeCompleteAuthSession();
@@ -50,7 +54,9 @@ export default function LoginScreen() {
const { hasShareIntent, resetShareIntent, shareIntent } = useShareIntentContext();
const { notice } = useLocalSearchParams<{ notice?: LoginNotice }>();
const shownNoticeRef = useRef(null);
- const [isSigningIn, setIsSigningIn] = useState(false);
+ const [signingInProvider, setSigningInProvider] = useState(null);
+ const isSigningIn = signingInProvider !== null;
+ const shouldShowAppleLogin = Platform.OS === 'ios';
useEffect(() => {
if (!notice || shownNoticeRef.current === notice) {
@@ -66,17 +72,17 @@ export default function LoginScreen() {
showAlert(alert.title, alert.message);
}, [notice]);
- const handleGoogleLogin = async () => {
+ const handleSocialLogin = async (provider: SocialLoginProvider) => {
if (isSigningIn) {
return;
}
- setIsSigningIn(true);
+ setSigningInProvider(provider);
let sessionActivated = false;
try {
const { createdSessionId, setActive } = await startSSOFlow({
- strategy: 'oauth_google',
+ strategy: SSO_STRATEGY_BY_PROVIDER[provider],
redirectUrl: CLERK_REDIRECT_URL,
});
@@ -104,7 +110,7 @@ export default function LoginScreen() {
'계정 연결 중 서버와 통신하지 못했습니다. 잠시 후 다시 시도해주세요.'
);
} finally {
- setIsSigningIn(false);
+ setSigningInProvider(null);
}
};
@@ -157,10 +163,18 @@ export default function LoginScreen() {
handleSocialLogin('google')}
disabled={isSigningIn}
/>
+ {shouldShowAppleLogin && (
+ handleSocialLogin('apple')}
+ disabled={isSigningIn}
+ />
+ )}
계속 진행하면 서비스 이용약관 및 개인정보 처리방침에 동의하는 것으로 간주합니다.
diff --git a/app/(tabs)/(folder)/folder-add-url.tsx b/app/(tabs)/(folder)/folder-add-url.tsx
index a81062e..bfc5041 100644
--- a/app/(tabs)/(folder)/folder-add-url.tsx
+++ b/app/(tabs)/(folder)/folder-add-url.tsx
@@ -5,9 +5,11 @@ import {
Text,
View,
} from 'react-native';
-import { router, Stack, useLocalSearchParams } from 'expo-router';
+import { router, useLocalSearchParams } from 'expo-router';
+import { SafeAreaView } from 'react-native-safe-area-context';
import { Button } from '@/components/ui/button';
+import { ScreenHeader } from '@/components/ui/screen-header';
import { SelectableLinkCard } from '@/components/ui/selectable-link-card';
import { Colors, Typography } from '@/constants/theme';
import { getFolderErrorMessage, useFolders } from '@/context/folders-context';
@@ -68,25 +70,17 @@ export default function FolderAddUrlScreen() {
const selectedCount = selectedIds.size;
const name = folderName ?? '폴더';
+ const handleBack = () => {
+ if (router.canGoBack()) {
+ router.back();
+ } else {
+ router.replace({ pathname: '/(tabs)/(folder)/[id]', params: { id: folderId } });
+ }
+ };
+
return (
- <>
-
-
-
+
+
{/* ── 폴더명 표시 ── */}
추가할 폴더
@@ -138,8 +132,7 @@ export default function FolderAddUrlScreen() {
/>
-
- >
+
);
}
diff --git a/app/(tabs)/(folder)/folder-name.tsx b/app/(tabs)/(folder)/folder-name.tsx
index 5819a5c..fd9e692 100644
--- a/app/(tabs)/(folder)/folder-name.tsx
+++ b/app/(tabs)/(folder)/folder-name.tsx
@@ -9,9 +9,11 @@ import {
TouchableOpacity,
View,
} from 'react-native';
-import { router, Stack } from 'expo-router';
+import { router } from 'expo-router';
+import { SafeAreaView } from 'react-native-safe-area-context';
import { Button } from '@/components/ui/button';
+import { ScreenHeader } from '@/components/ui/screen-header';
import { Colors, Typography } from '@/constants/theme';
import { useFolders } from '@/context/folders-context';
import { useGuardedPress } from '@/utils/press-guard';
@@ -58,24 +60,17 @@ export default function FolderNameScreen() {
};
const guardedClearFolderName = useGuardedPress(() => setFolderName(''), { lockMs: 250 });
+ const handleBack = () => {
+ if (router.canGoBack()) {
+ router.back();
+ } else {
+ router.replace('/(tabs)/(folder)');
+ }
+ };
+
return (
- <>
-
-
+
+
-
- >
+
);
}
diff --git a/app/(tabs)/(folder)/folder-url-select.tsx b/app/(tabs)/(folder)/folder-url-select.tsx
index 9423262..942c27e 100644
--- a/app/(tabs)/(folder)/folder-url-select.tsx
+++ b/app/(tabs)/(folder)/folder-url-select.tsx
@@ -5,9 +5,11 @@ import {
Text,
View,
} from 'react-native';
-import { router, Stack, useLocalSearchParams } from 'expo-router';
+import { router, useLocalSearchParams } from 'expo-router';
+import { SafeAreaView } from 'react-native-safe-area-context';
import { Button } from '@/components/ui/button';
+import { ScreenHeader } from '@/components/ui/screen-header';
import { SelectableLinkCard } from '@/components/ui/selectable-link-card';
import { Colors, Typography } from '@/constants/theme';
import { getFolderErrorMessage, useFolders } from '@/context/folders-context';
@@ -71,25 +73,17 @@ export default function FolderUrlSelectScreen() {
const selectedCount = selectedIds.size;
+ const handleBack = () => {
+ if (router.canGoBack()) {
+ router.back();
+ } else {
+ router.replace('/(tabs)/(folder)');
+ }
+ };
+
return (
- <>
-
-
-
+
+
{/* ── 폴더명 표시 ── */}
현재 폴더명
@@ -141,8 +135,7 @@ export default function FolderUrlSelectScreen() {
/>
-
- >
+
);
}
diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx
index 717f807..073610a 100644
--- a/app/(tabs)/(folder)/index.tsx
+++ b/app/(tabs)/(folder)/index.tsx
@@ -312,7 +312,6 @@ export default function FolderScreen() {
urlCount={folder.linkCount}
width={folderCardWidth}
variant="plain"
- compactFolderName={isCompactWidth}
onPress={() => router.push({ pathname: '/(tabs)/(folder)/[id]' as any, params: { id: folder.id } })}
onMorePress={(anchor) => handleMorePress(folder.id, anchor)}
/>
@@ -436,11 +435,11 @@ const styles = StyleSheet.create({
content: {
paddingHorizontal: CONTENT_HORIZONTAL_PADDING,
paddingBottom: 32,
- gap: 20,
+ gap: 12,
},
header: {
- paddingTop: 16,
+ paddingTop: 6,
gap: 4,
},
title: {
diff --git a/app/(tabs)/(home)/add-link.tsx b/app/(tabs)/(home)/add-link.tsx
index 600c6ce..f96e1bd 100644
--- a/app/(tabs)/(home)/add-link.tsx
+++ b/app/(tabs)/(home)/add-link.tsx
@@ -12,11 +12,13 @@ import {
useWindowDimensions,
View,
} from 'react-native';
-import { Stack, router, useLocalSearchParams } from 'expo-router';
+import { router, useLocalSearchParams } from 'expo-router';
+import { SafeAreaView } from 'react-native-safe-area-context';
import { ApiError } from '@/api/api-client';
import { checkSavedLinkUrl } from '@/api/saved-links';
import { ScanButton } from '@/components/ui/scan-button';
+import { ScreenHeader } from '@/components/ui/screen-header';
import { Colors, Typography } from '@/constants/theme';
import { useGuardedPress } from '@/utils/press-guard';
import { normalizeHttpUrlInput } from '@/utils/shared-url';
@@ -160,24 +162,17 @@ export default function AddLinkScreen() {
setError('');
}, { lockMs: 250 });
+ const handleBack = () => {
+ if (router.canGoBack()) {
+ router.back();
+ } else {
+ router.replace('/(tabs)/(home)');
+ }
+ };
+
return (
- <>
-
-
+
+
-
- >
+
);
}
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index d270c39..de94cf0 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -4,6 +4,7 @@ import { Redirect, router, Tabs } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import { ShareIntentRouter } from '@/components/share-intent-router';
+import { AppLoadingScreen } from '@/components/ui/app-loading-screen';
import { BottomTabBar, type TabVariant } from '@/components/ui/bottom-tab-bar';
import { FoldersProvider } from '@/context/folders-context';
import { SavedLinksProvider } from '@/context/saved-links-context';
@@ -136,7 +137,7 @@ export default function TabLayout() {
}, [isLoaded, isSignedIn, sessionId]);
if (!isLoaded) {
- return null;
+ return ;
}
if (!isSignedIn) {
@@ -144,7 +145,7 @@ export default function TabLayout() {
}
if (!hasSyncedMember) {
- return null;
+ return ;
}
return (
diff --git a/app/_layout.tsx b/app/_layout.tsx
index e88eeaf..c9340de 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -9,6 +9,7 @@ import { StyleSheet } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { PressBlocker } from '@/components/ui/press-blocker';
+import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
@@ -31,14 +32,14 @@ export default function RootLayout() {
-
+
-
+
@@ -50,5 +51,6 @@ export default function RootLayout() {
const styles = StyleSheet.create({
root: {
flex: 1,
+ backgroundColor: Colors.brand.background,
},
});
diff --git a/app/index.tsx b/app/index.tsx
index 838c6f1..373441e 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -1,11 +1,13 @@
import { useAuth } from '@clerk/expo';
import { Redirect } from 'expo-router';
+import { AppLoadingScreen } from '@/components/ui/app-loading-screen';
+
export default function IndexScreen() {
const { isLoaded, isSignedIn } = useAuth();
if (!isLoaded) {
- return null;
+ return ;
}
return ;
diff --git a/components/ui/app-loading-screen.tsx b/components/ui/app-loading-screen.tsx
new file mode 100644
index 0000000..9f3b77e
--- /dev/null
+++ b/components/ui/app-loading-screen.tsx
@@ -0,0 +1,20 @@
+import { ActivityIndicator, StyleSheet, View } from 'react-native';
+
+import { Colors } from '@/constants/theme';
+
+export function AppLoadingScreen() {
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: Colors.brand.background,
+ },
+});
\ No newline at end of file
diff --git a/components/ui/card-link.tsx b/components/ui/card-link.tsx
index 846075c..f869198 100644
--- a/components/ui/card-link.tsx
+++ b/components/ui/card-link.tsx
@@ -70,8 +70,22 @@ export function CardLink({
const handleMorePress = (event: GestureResponderEvent) => {
event.stopPropagation();
- moreRef.current?.measure((_fx, _fy, width, height, px, py) => {
- guardedMore?.({ x: px, y: py, width, height });
+
+ const { pageX, pageY } = event.nativeEvent;
+ const fallbackAnchor = { x: pageX, y: pageY, width: 1, height: 1 };
+
+ if (!moreRef.current) {
+ guardedMore?.(fallbackAnchor);
+ return;
+ }
+
+ moreRef.current.measure((_fx, _fy, width, height, px, py) => {
+ const measuredAnchor = { x: px, y: py, width, height };
+ const anchor = Number.isFinite(px) && Number.isFinite(py) && width > 0 && height > 0
+ ? measuredAnchor
+ : fallbackAnchor;
+
+ guardedMore?.(anchor);
});
};
diff --git a/components/ui/filter-chip.tsx b/components/ui/filter-chip.tsx
index ac45e29..e948615 100644
--- a/components/ui/filter-chip.tsx
+++ b/components/ui/filter-chip.tsx
@@ -1,6 +1,5 @@
import { useRef, useState } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, useWindowDimensions, View } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors, Typography } from '@/constants/theme';
import { useGuardedPress } from '@/utils/press-guard';
@@ -158,7 +157,6 @@ export function FilterChip({
const [isOpen, setIsOpen] = useState(false);
const [chipBounds, setChipBounds] = useState({ x: 0, y: 0, width: 0, height: 0 });
const chipRef = useRef(null);
- const insets = useSafeAreaInsets();
const { width, height } = useWindowDimensions();
const currentItem = items.find((i) => i.value === selectedValue);
@@ -172,7 +170,7 @@ export function FilterChip({
DROPDOWN_SCREEN_PADDING,
Math.min(chipBounds.x, width - dropdownWidth - DROPDOWN_SCREEN_PADDING),
);
- const dropdownTop = chipBounds.y + chipBounds.height + insets.top + 4;
+ const dropdownTop = chipBounds.y + chipBounds.height + 4;
const dropdownMaxHeight = Math.max(
DROPDOWN_MIN_HEIGHT,
Math.min(height * DROPDOWN_MAX_HEIGHT_RATIO, height - dropdownTop - DROPDOWN_SCREEN_PADDING),
diff --git a/components/ui/folder-card.tsx b/components/ui/folder-card.tsx
index fc0520c..7961b0c 100644
--- a/components/ui/folder-card.tsx
+++ b/components/ui/folder-card.tsx
@@ -1,5 +1,5 @@
import { useRef } from 'react';
-import { Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { Pressable, StyleSheet, Text, TouchableOpacity, View, type GestureResponderEvent } from 'react-native';
import { Colors, ComponentTokens, Typography } from '@/constants/theme';
import { useGuardedPress } from '@/utils/press-guard';
@@ -54,9 +54,24 @@ export function FolderCard({
const verticalPadding = isCompact ? FOLDER_CARD.compact.verticalPadding : FOLDER_CARD.verticalPadding;
const menuSize = isCompact ? FOLDER_CARD.compact.menuSize : FOLDER_CARD.menuSize;
- const handleMorePress = () => {
- moreRef.current?.measure((_fx, _fy, measuredWidth, measuredHeight, px, py) => {
- guardedOnMorePress?.({ x: px, y: py, width: measuredWidth, height: measuredHeight });
+ const handleMorePress = (event: GestureResponderEvent) => {
+ event.stopPropagation();
+
+ const { pageX, pageY } = event.nativeEvent;
+ const fallbackAnchor = { x: pageX, y: pageY, width: 1, height: 1 };
+
+ if (!moreRef.current) {
+ guardedOnMorePress?.(fallbackAnchor);
+ return;
+ }
+
+ moreRef.current.measure((_fx, _fy, measuredWidth, measuredHeight, px, py) => {
+ const measuredAnchor = { x: px, y: py, width: measuredWidth, height: measuredHeight };
+ const anchor = Number.isFinite(px) && Number.isFinite(py) && measuredWidth > 0 && measuredHeight > 0
+ ? measuredAnchor
+ : fallbackAnchor;
+
+ guardedOnMorePress?.(anchor);
});
};
diff --git a/components/ui/folder-context-menu.tsx b/components/ui/folder-context-menu.tsx
index 017cfab..1ffa367 100644
--- a/components/ui/folder-context-menu.tsx
+++ b/components/ui/folder-context-menu.tsx
@@ -1,4 +1,5 @@
-import { Dimensions, Modal, Pressable, StyleSheet, Text, View } from 'react-native';
+import { useRef } from 'react';
+import { Modal, Pressable, StyleSheet, Text, useWindowDimensions, View } from 'react-native';
import { Colors, Typography } from '@/constants/theme';
import { useGuardedPress } from '@/utils/press-guard';
import type { AnchorPosition } from './folder-card';
@@ -6,6 +7,17 @@ import type { AnchorPosition } from './folder-card';
const MENU_WIDTH = 140;
const MENU_ITEM_HEIGHT = 48;
const GAP = 4;
+const SCREEN_PADDING = 8;
+
+function isValidAnchor(anchor?: AnchorPosition): anchor is AnchorPosition {
+ return Boolean(
+ anchor &&
+ Number.isFinite(anchor.x) &&
+ Number.isFinite(anchor.y) &&
+ Number.isFinite(anchor.width) &&
+ Number.isFinite(anchor.height),
+ );
+}
export interface ContextMenuItem {
label: string;
@@ -31,7 +43,7 @@ export function FolderContextMenu({
onDelete,
onDismiss,
}: FolderContextMenuProps) {
- const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const resolvedItems: ContextMenuItem[] = items ?? [
{ label: '폴더명 수정', onPress: () => onEditName?.() },
@@ -44,16 +56,24 @@ export function FolderContextMenu({
});
const menuHeight = MENU_ITEM_HEIGHT * resolvedItems.length + (resolvedItems.length - 1);
+ const lastAnchorRef = useRef(undefined);
+
+ if (isValidAnchor(anchor)) {
+ lastAnchorRef.current = anchor;
+ }
+
+ const resolvedAnchor = isValidAnchor(anchor) ? anchor : lastAnchorRef.current;
+ const fallbackLeft = Math.max(SCREEN_PADDING, screenWidth - MENU_WIDTH - SCREEN_PADDING);
- const menuTop = anchor
- ? anchor.y + anchor.height + GAP + menuHeight > screenHeight
- ? anchor.y - menuHeight - GAP
- : anchor.y + anchor.height + GAP
- : screenHeight / 2;
+ const menuTop = resolvedAnchor
+ ? resolvedAnchor.y + resolvedAnchor.height + GAP + menuHeight > screenHeight
+ ? resolvedAnchor.y - menuHeight - GAP
+ : resolvedAnchor.y + resolvedAnchor.height + GAP
+ : SCREEN_PADDING + GAP;
- const menuLeft = anchor
- ? Math.min(anchor.x, screenWidth - MENU_WIDTH - 8)
- : (screenWidth - MENU_WIDTH) / 2;
+ const menuLeft = resolvedAnchor
+ ? Math.min(resolvedAnchor.x, screenWidth - MENU_WIDTH - SCREEN_PADDING)
+ : fallbackLeft;
return (
void;
+}
+
+export function ScreenHeader({ title, onBack }: ScreenHeaderProps) {
+ return (
+
+
+ {title}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ paddingHorizontal: 20,
+ paddingTop: 12,
+ paddingBottom: 8,
+ },
+ title: {
+ ...Typography.pageTitle,
+ color: Colors.brand.text,
+ flexShrink: 1,
+ },
+});
\ No newline at end of file
diff --git a/constants/theme.ts b/constants/theme.ts
index e2e965d..91effbb 100644
--- a/constants/theme.ts
+++ b/constants/theme.ts
@@ -172,11 +172,11 @@ export const Typography = {
export const ComponentTokens = {
folderScreen: {
- contentHorizontalPadding: 24,
- canvasPaddingVertical: 16,
- canvasGap: 16,
- gridGap: 12,
- minTwoColumnCardWidth: 120,
+ contentHorizontalPadding: 20,
+ canvasPaddingVertical: 8,
+ canvasGap: 12,
+ gridGap: 10,
+ minTwoColumnCardWidth: 150,
headerRowGap: 10,
compactWidth: 380,
subtitleRowOffsetTop: -2,